@interop/was-conformance-suite 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/suites/client-spaces.d.ts.map +1 -1
- package/dist/suites/client-spaces.js +4 -2
- package/dist/suites/client-spaces.js.map +1 -1
- package/dist/suites/conditional-requests-api.d.ts.map +1 -1
- package/dist/suites/conditional-requests-api.js +198 -18
- package/dist/suites/conditional-requests-api.js.map +1 -1
- package/dist/suites/governed-log-api.d.ts +35 -0
- package/dist/suites/governed-log-api.d.ts.map +1 -0
- package/dist/suites/governed-log-api.js +692 -0
- package/dist/suites/governed-log-api.js.map +1 -0
- package/dist/suites/index.d.ts +2 -1
- package/dist/suites/index.d.ts.map +1 -1
- package/dist/suites/index.js +3 -1
- package/dist/suites/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Interop Alliance. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* WAS conformance tests -- a Collection's governing history log (the
|
|
6
|
+
* `governed-history-logs` feature).
|
|
7
|
+
*
|
|
8
|
+
* The log is the sub-resource `/space/{space}/{collection}/meta/log`: a JSON
|
|
9
|
+
* Lines body whose last line is the head entry, and whose head `state` the
|
|
10
|
+
* server serves as the Collection's `encryption` descriptor with a
|
|
11
|
+
* `history: { method, resource }` member stamped on. A Collection becomes
|
|
12
|
+
* governed by the guarded create of its log (`PUT` with `If-None-Match: *`);
|
|
13
|
+
* later writes are compare-and-swap appends (`If-Match` carrying the prior
|
|
14
|
+
* bytes plus one new line), 412 on a lost race. The server verifies neither
|
|
15
|
+
* entry proofs nor the hash chain; it checks the line contract and runs the
|
|
16
|
+
* encryption descriptor's transition checks between the prior head and the
|
|
17
|
+
* new one.
|
|
18
|
+
*
|
|
19
|
+
* The feature is OPTIONAL, gated on a backend advertising the
|
|
20
|
+
* `governed-history-logs` token in its Backend description. Rather than mark
|
|
21
|
+
* the whole suite optional, setup() probes the Space's backend list for the
|
|
22
|
+
* token and each test skips when it is absent; once advertised, the behaviors
|
|
23
|
+
* below are MUST-level and run at the required tier.
|
|
24
|
+
*
|
|
25
|
+
* The log is written and read through raw `fetch` over the low-level signing
|
|
26
|
+
* primitive: the body is `text/jsonl` (not JSON, which the high-level clients
|
|
27
|
+
* would parse), and a 304 or a problem response is read like any other.
|
|
28
|
+
*/
|
|
29
|
+
import { signCapabilityInvocation } from '@interop/http-signature-zcap-invoke';
|
|
30
|
+
import assert from '../harness/assert.js';
|
|
31
|
+
/**
|
|
32
|
+
* A minimal conforming EDV Encrypted Document envelope, the stored
|
|
33
|
+
* representation an `edv` (encrypted) Collection requires for a Resource.
|
|
34
|
+
*/
|
|
35
|
+
const edvDocument = {
|
|
36
|
+
id: 'z1',
|
|
37
|
+
sequence: 0,
|
|
38
|
+
indexed: [],
|
|
39
|
+
jwe: { protected: 'eyJhbGciOiJkaXI', ciphertext: 'c1phertext' }
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A descriptor recipient entry (the JWE recipients-entry shape).
|
|
43
|
+
*
|
|
44
|
+
* @param kid {string}
|
|
45
|
+
* @returns {object}
|
|
46
|
+
*/
|
|
47
|
+
function recipient(kid) {
|
|
48
|
+
return {
|
|
49
|
+
header: { kid, alg: 'ECDH-ES+A256KW' },
|
|
50
|
+
encrypted_key: `wrapped-${kid}`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** A one-epoch `edv` key-epoch descriptor, the genesis head state. */
|
|
54
|
+
const oneEpoch = {
|
|
55
|
+
type: 'WasEpochConfiguration',
|
|
56
|
+
scheme: 'edv',
|
|
57
|
+
currentEpoch: 'urn:epoch:1',
|
|
58
|
+
epochs: [{ id: 'urn:epoch:1', recipients: [recipient('did:key:zApp1#ka')] }]
|
|
59
|
+
};
|
|
60
|
+
/** The same descriptor after one rotation: a second, newer epoch. */
|
|
61
|
+
const twoEpochs = {
|
|
62
|
+
...oneEpoch,
|
|
63
|
+
currentEpoch: 'urn:epoch:2',
|
|
64
|
+
epochs: [
|
|
65
|
+
{ id: 'urn:epoch:2', recipients: [recipient('did:key:zApp2#ka')] },
|
|
66
|
+
...oneEpoch.epochs
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* One log entry line: the resource-log entry members with `state` as given.
|
|
71
|
+
* The server reads only `state` (and the genesis `parameters.method`); the
|
|
72
|
+
* rest is the profile's business and is not verified here.
|
|
73
|
+
*
|
|
74
|
+
* @param options {object}
|
|
75
|
+
* @param options.ordinal {number}
|
|
76
|
+
* @param options.state {object}
|
|
77
|
+
* @param [options.parameters] {object}
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function entryLine({ ordinal, state, parameters = {} }) {
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
versionId: `${ordinal}-hash${ordinal}`,
|
|
83
|
+
versionTime: '2026-09-07T00:00:00Z',
|
|
84
|
+
parameters,
|
|
85
|
+
state,
|
|
86
|
+
proof: []
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The genesis line, carrying the format identifier and the SCID.
|
|
91
|
+
*
|
|
92
|
+
* @param state {object}
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function genesisLine(state) {
|
|
96
|
+
return entryLine({
|
|
97
|
+
ordinal: 1,
|
|
98
|
+
state,
|
|
99
|
+
parameters: { method: 'resource-log:0.1', scid: 'zScid' }
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Asserts a response is a problem document of the given status whose `type`
|
|
104
|
+
* ends in the given registry anchor.
|
|
105
|
+
*
|
|
106
|
+
* @param options {object}
|
|
107
|
+
* @param options.response {Response}
|
|
108
|
+
* @param options.status {number}
|
|
109
|
+
* @param options.type {string} the problem-type anchor, e.g. `not-found`
|
|
110
|
+
* @returns {Promise<any>} the parsed problem document
|
|
111
|
+
*/
|
|
112
|
+
async function assertProblem({ response, status, type }) {
|
|
113
|
+
assert.equal(response.status, status);
|
|
114
|
+
assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/);
|
|
115
|
+
const problem = await response.json();
|
|
116
|
+
assert.equal(problem.type, `https://wallet.storage/spec#${type}`);
|
|
117
|
+
return problem;
|
|
118
|
+
}
|
|
119
|
+
export const governedLogApi = {
|
|
120
|
+
id: 'governed-log-api',
|
|
121
|
+
name: 'Governing history log API',
|
|
122
|
+
specRefs: ['https://wallet.storage/spec#collection-data-model'],
|
|
123
|
+
setup: async (ctx) => {
|
|
124
|
+
const alice = { ...ctx.actors.alice };
|
|
125
|
+
const bob = { ...ctx.actors.bob };
|
|
126
|
+
alice.space1 = { id: ctx.generateId() };
|
|
127
|
+
await ctx.createSpace({
|
|
128
|
+
spaceDescription: {
|
|
129
|
+
id: alice.space1.id,
|
|
130
|
+
name: "Alice's Governed Log Space",
|
|
131
|
+
controller: alice.did
|
|
132
|
+
},
|
|
133
|
+
rootClient: alice.rootClient
|
|
134
|
+
});
|
|
135
|
+
function collectionUrl(collectionId) {
|
|
136
|
+
return new URL(`/space/${alice.space1.id}/${collectionId}`, ctx.serverUrl).toString();
|
|
137
|
+
}
|
|
138
|
+
function logUrl(collectionId) {
|
|
139
|
+
return `${collectionUrl(collectionId)}/meta/log`;
|
|
140
|
+
}
|
|
141
|
+
async function freshCollection(body = {}) {
|
|
142
|
+
const collectionId = `col-${ctx.generateId()}`;
|
|
143
|
+
await alice.rootClient.request({
|
|
144
|
+
url: new URL(`/space/${alice.space1.id}/`, ctx.serverUrl).toString(),
|
|
145
|
+
method: 'POST',
|
|
146
|
+
action: 'POST',
|
|
147
|
+
json: { id: collectionId, name: collectionId, ...body }
|
|
148
|
+
});
|
|
149
|
+
return collectionId;
|
|
150
|
+
}
|
|
151
|
+
async function putLog({ collectionId, body, headers = {} }) {
|
|
152
|
+
const url = logUrl(collectionId);
|
|
153
|
+
const bytes = new TextEncoder().encode(body);
|
|
154
|
+
const signatureHeaders = await signCapabilityInvocation({
|
|
155
|
+
url,
|
|
156
|
+
method: 'PUT',
|
|
157
|
+
headers: {
|
|
158
|
+
date: new Date().toUTCString(),
|
|
159
|
+
'content-type': 'text/jsonl'
|
|
160
|
+
},
|
|
161
|
+
invocationSigner: alice.signer,
|
|
162
|
+
capabilityAction: 'PUT',
|
|
163
|
+
body: bytes
|
|
164
|
+
});
|
|
165
|
+
// The precondition headers describe the request, not the capability
|
|
166
|
+
// target, so they ride outside the signature. The cast is fetch's
|
|
167
|
+
// `BodyInit` typing rejecting a bare `Uint8Array` (a lib variance quirk).
|
|
168
|
+
return fetch(url, {
|
|
169
|
+
method: 'PUT',
|
|
170
|
+
headers: {
|
|
171
|
+
...signatureHeaders,
|
|
172
|
+
...headers
|
|
173
|
+
},
|
|
174
|
+
body: bytes
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function getLog({ collectionId, headers = {}, invocationSigner = alice.signer, capability }) {
|
|
178
|
+
const url = logUrl(collectionId);
|
|
179
|
+
const signatureHeaders = await signCapabilityInvocation({
|
|
180
|
+
url,
|
|
181
|
+
method: 'GET',
|
|
182
|
+
headers: { date: new Date().toUTCString() },
|
|
183
|
+
invocationSigner,
|
|
184
|
+
capabilityAction: 'GET',
|
|
185
|
+
...(capability !== undefined && { capability })
|
|
186
|
+
});
|
|
187
|
+
return fetch(url, {
|
|
188
|
+
method: 'GET',
|
|
189
|
+
headers: { ...signatureHeaders, ...headers }
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async function governedCollection() {
|
|
193
|
+
const collectionId = await freshCollection();
|
|
194
|
+
const body = genesisLine(oneEpoch) + '\n';
|
|
195
|
+
const created = await putLog({
|
|
196
|
+
collectionId,
|
|
197
|
+
body,
|
|
198
|
+
headers: { 'if-none-match': '*' }
|
|
199
|
+
});
|
|
200
|
+
assert.equal(created.status, 204, await created.text());
|
|
201
|
+
const etag = created.headers.get('etag');
|
|
202
|
+
assert.ok(etag, 'expected the guarded create to return an ETag');
|
|
203
|
+
return { collectionId, body, etag };
|
|
204
|
+
}
|
|
205
|
+
// Discover whether any of the Space's backends advertises
|
|
206
|
+
// `governed-history-logs` (spec "Backends"). Absent the token the
|
|
207
|
+
// sub-resource is OPTIONAL and each test skips.
|
|
208
|
+
const backendsResponse = await alice.rootClient.request({
|
|
209
|
+
url: new URL(`/space/${alice.space1.id}/backends`, ctx.serverUrl).toString(),
|
|
210
|
+
method: 'GET'
|
|
211
|
+
});
|
|
212
|
+
const backends = Array.isArray(backendsResponse.data)
|
|
213
|
+
? backendsResponse.data
|
|
214
|
+
: (backendsResponse.data?.backends ?? []);
|
|
215
|
+
const governedSupported = backends.some(backend => backend.features?.includes('governed-history-logs'));
|
|
216
|
+
return {
|
|
217
|
+
alice,
|
|
218
|
+
bob,
|
|
219
|
+
governedSupported,
|
|
220
|
+
collectionUrl,
|
|
221
|
+
logUrl,
|
|
222
|
+
freshCollection,
|
|
223
|
+
governedCollection,
|
|
224
|
+
putLog,
|
|
225
|
+
getLog
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
teardown: async (ctx, state) => {
|
|
229
|
+
const { alice } = state;
|
|
230
|
+
try {
|
|
231
|
+
await alice.rootClient.request({
|
|
232
|
+
url: new URL(`/space/${alice.space1.id}`, ctx.serverUrl).toString(),
|
|
233
|
+
method: 'DELETE'
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
/* best-effort cleanup */
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
tests: [
|
|
241
|
+
{
|
|
242
|
+
id: 'governed-log.guarded-create-derives-encryption',
|
|
243
|
+
name: '[root] a guarded create governs the Collection: encryption is the head state plus history',
|
|
244
|
+
run: async (ctx, state) => {
|
|
245
|
+
const { alice, collectionUrl, logUrl, governedCollection } = state;
|
|
246
|
+
if (!state.governedSupported) {
|
|
247
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
248
|
+
}
|
|
249
|
+
const { collectionId } = await governedCollection();
|
|
250
|
+
const expected = {
|
|
251
|
+
...oneEpoch,
|
|
252
|
+
history: {
|
|
253
|
+
method: 'resource-log:0.1',
|
|
254
|
+
resource: logUrl(collectionId)
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const described = await alice.rootClient.request({
|
|
258
|
+
url: collectionUrl(collectionId),
|
|
259
|
+
method: 'GET'
|
|
260
|
+
});
|
|
261
|
+
assert.deepStrictEqual(described.data.encryption, expected);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: 'governed-log.read-verbatim-with-etag',
|
|
266
|
+
name: '[root] the log reads back verbatim as text/jsonl with the ETag the create returned',
|
|
267
|
+
run: async (ctx, state) => {
|
|
268
|
+
const { getLog, governedCollection } = state;
|
|
269
|
+
if (!state.governedSupported) {
|
|
270
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
271
|
+
}
|
|
272
|
+
const { collectionId, body, etag } = await governedCollection();
|
|
273
|
+
const read = await getLog({ collectionId });
|
|
274
|
+
assert.equal(read.status, 200);
|
|
275
|
+
assert.match(read.headers.get('content-type') ?? '', /^text\/jsonl/);
|
|
276
|
+
assert.equal(read.headers.get('etag'), etag);
|
|
277
|
+
assert.equal(await read.text(), body);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: 'governed-log.conditional-read-304',
|
|
282
|
+
name: '[root] a log GET with a matching If-None-Match is 304 with the ETag and no body',
|
|
283
|
+
optional: true,
|
|
284
|
+
specRefs: ['https://wallet.storage/spec#caching'],
|
|
285
|
+
run: async (ctx, state) => {
|
|
286
|
+
const { getLog, governedCollection } = state;
|
|
287
|
+
if (!state.governedSupported) {
|
|
288
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
289
|
+
}
|
|
290
|
+
const { collectionId, etag } = await governedCollection();
|
|
291
|
+
const conditional = await getLog({
|
|
292
|
+
collectionId,
|
|
293
|
+
headers: { 'if-none-match': etag }
|
|
294
|
+
});
|
|
295
|
+
assert.equal(conditional.status, 304);
|
|
296
|
+
assert.equal(conditional.headers.get('etag'), etag);
|
|
297
|
+
assert.equal(await conditional.text(), '');
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: 'governed-log.missing-log-404',
|
|
302
|
+
name: '[root] a Collection with no log is 404 on the log read, and a nonexistent Collection is 404 on the log write',
|
|
303
|
+
run: async (ctx, state) => {
|
|
304
|
+
const { freshCollection, getLog, putLog } = state;
|
|
305
|
+
if (!state.governedSupported) {
|
|
306
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
307
|
+
}
|
|
308
|
+
const collectionId = await freshCollection();
|
|
309
|
+
await assertProblem({
|
|
310
|
+
response: await getLog({ collectionId }),
|
|
311
|
+
status: 404,
|
|
312
|
+
type: 'not-found'
|
|
313
|
+
});
|
|
314
|
+
// A log write never creates the Collection it would govern.
|
|
315
|
+
await assertProblem({
|
|
316
|
+
response: await putLog({
|
|
317
|
+
collectionId: `absent-${ctx.generateId()}`,
|
|
318
|
+
body: genesisLine(oneEpoch) + '\n',
|
|
319
|
+
headers: { 'if-none-match': '*' }
|
|
320
|
+
}),
|
|
321
|
+
status: 404,
|
|
322
|
+
type: 'not-found'
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: 'governed-log.append-compare-and-swap',
|
|
328
|
+
name: '[root] an If-Match append lands, bumps the log ETag, and moves the derived member to the new head',
|
|
329
|
+
run: async (ctx, state) => {
|
|
330
|
+
const { alice, collectionUrl, putLog, governedCollection } = state;
|
|
331
|
+
if (!state.governedSupported) {
|
|
332
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
333
|
+
}
|
|
334
|
+
const { collectionId, body, etag } = await governedCollection();
|
|
335
|
+
const before = await alice.rootClient.request({
|
|
336
|
+
url: collectionUrl(collectionId),
|
|
337
|
+
method: 'GET'
|
|
338
|
+
});
|
|
339
|
+
const extended = body + entryLine({ ordinal: 2, state: twoEpochs }) + '\n';
|
|
340
|
+
const appended = await putLog({
|
|
341
|
+
collectionId,
|
|
342
|
+
body: extended,
|
|
343
|
+
headers: { 'if-match': etag }
|
|
344
|
+
});
|
|
345
|
+
assert.equal(appended.status, 204, await appended.text());
|
|
346
|
+
const newEtag = appended.headers.get('etag');
|
|
347
|
+
assert.ok(newEtag, 'expected the append to return an ETag');
|
|
348
|
+
assert.notEqual(newEtag, etag);
|
|
349
|
+
const after = await alice.rootClient.request({
|
|
350
|
+
url: collectionUrl(collectionId),
|
|
351
|
+
method: 'GET'
|
|
352
|
+
});
|
|
353
|
+
assert.equal(after.data.encryption.currentEpoch, 'urn:epoch:2');
|
|
354
|
+
assert.equal(after.data.encryption.epochs.length, 2);
|
|
355
|
+
// The Description's own ETag moves too: its served content changed.
|
|
356
|
+
assert.notEqual(after.headers.get('etag'), before.headers.get('etag'), 'expected a log append to bump the Collection Description ETag');
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: 'governed-log.stale-if-match-412',
|
|
361
|
+
name: '[root] a stale If-Match append is 412 precondition-failed and the log is unchanged',
|
|
362
|
+
specRefs: ['https://wallet.storage/spec#precondition-failed'],
|
|
363
|
+
run: async (ctx, state) => {
|
|
364
|
+
const { getLog, putLog, governedCollection } = state;
|
|
365
|
+
if (!state.governedSupported) {
|
|
366
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
367
|
+
}
|
|
368
|
+
const { collectionId, body, etag } = await governedCollection();
|
|
369
|
+
const extended = body + entryLine({ ordinal: 2, state: twoEpochs }) + '\n';
|
|
370
|
+
const won = await putLog({
|
|
371
|
+
collectionId,
|
|
372
|
+
body: extended,
|
|
373
|
+
headers: { 'if-match': etag }
|
|
374
|
+
});
|
|
375
|
+
assert.equal(won.status, 204);
|
|
376
|
+
const winnerEtag = won.headers.get('etag');
|
|
377
|
+
const lost = await putLog({
|
|
378
|
+
collectionId,
|
|
379
|
+
body: extended,
|
|
380
|
+
headers: { 'if-match': etag }
|
|
381
|
+
});
|
|
382
|
+
await assertProblem({
|
|
383
|
+
response: lost,
|
|
384
|
+
status: 412,
|
|
385
|
+
type: 'precondition-failed'
|
|
386
|
+
});
|
|
387
|
+
const read = await getLog({ collectionId });
|
|
388
|
+
assert.equal(read.headers.get('etag'), winnerEtag);
|
|
389
|
+
assert.equal(await read.text(), extended);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: 'governed-log.guarded-create-existing-412',
|
|
394
|
+
name: '[root] a guarded create on an existing log is 412 precondition-failed',
|
|
395
|
+
specRefs: ['https://wallet.storage/spec#precondition-failed'],
|
|
396
|
+
run: async (ctx, state) => {
|
|
397
|
+
const { putLog, governedCollection } = state;
|
|
398
|
+
if (!state.governedSupported) {
|
|
399
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
400
|
+
}
|
|
401
|
+
const { collectionId, body } = await governedCollection();
|
|
402
|
+
await assertProblem({
|
|
403
|
+
response: await putLog({
|
|
404
|
+
collectionId,
|
|
405
|
+
body,
|
|
406
|
+
headers: { 'if-none-match': '*' }
|
|
407
|
+
}),
|
|
408
|
+
status: 412,
|
|
409
|
+
type: 'precondition-failed'
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: 'governed-log.direct-encryption-write-refused',
|
|
415
|
+
name: '[root] a direct encryption write on a governed Collection is 409 encryption-history-log-governed',
|
|
416
|
+
specRefs: ['https://wallet.storage/spec#encryption-history-log-governed'],
|
|
417
|
+
run: async (ctx, state) => {
|
|
418
|
+
const { alice, collectionUrl, governedCollection } = state;
|
|
419
|
+
if (!state.governedSupported) {
|
|
420
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
421
|
+
}
|
|
422
|
+
const { collectionId } = await governedCollection();
|
|
423
|
+
let expectedError;
|
|
424
|
+
try {
|
|
425
|
+
await alice.rootClient.request({
|
|
426
|
+
url: collectionUrl(collectionId),
|
|
427
|
+
method: 'PUT',
|
|
428
|
+
action: 'PUT',
|
|
429
|
+
json: { id: collectionId, encryption: twoEpochs }
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
expectedError = err;
|
|
434
|
+
}
|
|
435
|
+
assert.ok(expectedError, 'expected the direct write to be refused');
|
|
436
|
+
assert.equal(expectedError.response.status, 409);
|
|
437
|
+
assert.equal(expectedError.data.type, 'https://wallet.storage/spec#encryption-history-log-governed');
|
|
438
|
+
// The descriptor is untouched, and a Description update that leaves
|
|
439
|
+
// `encryption` alone still lands.
|
|
440
|
+
const described = await alice.rootClient.request({
|
|
441
|
+
url: collectionUrl(collectionId),
|
|
442
|
+
method: 'GET'
|
|
443
|
+
});
|
|
444
|
+
assert.equal(described.data.encryption.currentEpoch, 'urn:epoch:1');
|
|
445
|
+
const renamed = await alice.rootClient.request({
|
|
446
|
+
url: collectionUrl(collectionId),
|
|
447
|
+
method: 'PUT',
|
|
448
|
+
action: 'PUT',
|
|
449
|
+
json: { id: collectionId, name: 'Renamed' }
|
|
450
|
+
});
|
|
451
|
+
assert.equal(renamed.status, 204);
|
|
452
|
+
const reread = await alice.rootClient.request({
|
|
453
|
+
url: collectionUrl(collectionId),
|
|
454
|
+
method: 'GET'
|
|
455
|
+
});
|
|
456
|
+
assert.equal(reread.data.name, 'Renamed');
|
|
457
|
+
assert.equal(reread.data.encryption.currentEpoch, 'urn:epoch:1');
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
id: 'governed-log.epoch-violation-refused',
|
|
462
|
+
name: '[root] an append that drops an epoch or moves currentEpoch back is refused and the log is unchanged',
|
|
463
|
+
specRefs: ['https://wallet.storage/spec#collection-data-model'],
|
|
464
|
+
run: async (ctx, state) => {
|
|
465
|
+
const { freshCollection, getLog, putLog } = state;
|
|
466
|
+
if (!state.governedSupported) {
|
|
467
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
468
|
+
}
|
|
469
|
+
const collectionId = await freshCollection();
|
|
470
|
+
const body = genesisLine(twoEpochs) + '\n';
|
|
471
|
+
const created = await putLog({
|
|
472
|
+
collectionId,
|
|
473
|
+
body,
|
|
474
|
+
headers: { 'if-none-match': '*' }
|
|
475
|
+
});
|
|
476
|
+
assert.equal(created.status, 204, await created.text());
|
|
477
|
+
const etag = created.headers.get('etag');
|
|
478
|
+
// Rolling back to the one-epoch state both drops an epoch and moves
|
|
479
|
+
// `currentEpoch` to an older one; either alone is enough to refuse.
|
|
480
|
+
const rolledBack = body + entryLine({ ordinal: 2, state: oneEpoch }) + '\n';
|
|
481
|
+
const response = await putLog({
|
|
482
|
+
collectionId,
|
|
483
|
+
body: rolledBack,
|
|
484
|
+
headers: { 'if-match': etag }
|
|
485
|
+
});
|
|
486
|
+
assert.equal(response.status, 400);
|
|
487
|
+
assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/);
|
|
488
|
+
const read = await getLog({ collectionId });
|
|
489
|
+
assert.equal(read.headers.get('etag'), etag);
|
|
490
|
+
assert.equal(await read.text(), body);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
id: 'governed-log.line-contract-break-400',
|
|
495
|
+
name: '[root] a body that breaks the line contract is 400 invalid-request-body and governs nothing',
|
|
496
|
+
specRefs: ['https://wallet.storage/spec#invalid-request-body'],
|
|
497
|
+
run: async (ctx, state) => {
|
|
498
|
+
const { freshCollection, getLog, putLog } = state;
|
|
499
|
+
if (!state.governedSupported) {
|
|
500
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
501
|
+
}
|
|
502
|
+
const collectionId = await freshCollection();
|
|
503
|
+
const bodies = [
|
|
504
|
+
// Empty: no head entry.
|
|
505
|
+
'',
|
|
506
|
+
// Not JSON.
|
|
507
|
+
'not json\n',
|
|
508
|
+
// An object with no `state` member.
|
|
509
|
+
JSON.stringify({ versionId: '1-x', parameters: {} }) + '\n',
|
|
510
|
+
// A blank line between entries.
|
|
511
|
+
genesisLine(oneEpoch) +
|
|
512
|
+
'\n\n' +
|
|
513
|
+
entryLine({ ordinal: 2, state: oneEpoch })
|
|
514
|
+
];
|
|
515
|
+
for (const body of bodies) {
|
|
516
|
+
const response = await putLog({
|
|
517
|
+
collectionId,
|
|
518
|
+
body,
|
|
519
|
+
headers: { 'if-none-match': '*' }
|
|
520
|
+
});
|
|
521
|
+
assert.equal(response.status, 400, `body ${JSON.stringify(body)}`);
|
|
522
|
+
const problem = await response.json();
|
|
523
|
+
assert.equal(problem.type, 'https://wallet.storage/spec#invalid-request-body');
|
|
524
|
+
}
|
|
525
|
+
// None of them declared the Collection governed.
|
|
526
|
+
assert.equal((await getLog({ collectionId })).status, 404);
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
id: 'governed-log.already-described-refused',
|
|
531
|
+
name: '[root] governing a Collection that already carries a client-written descriptor is 409 encryption-immutable',
|
|
532
|
+
specRefs: ['https://wallet.storage/spec#encryption-immutable'],
|
|
533
|
+
run: async (ctx, state) => {
|
|
534
|
+
const { freshCollection, getLog, putLog } = state;
|
|
535
|
+
if (!state.governedSupported) {
|
|
536
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
537
|
+
}
|
|
538
|
+
const collectionId = await freshCollection({
|
|
539
|
+
encryption: { scheme: 'edv' }
|
|
540
|
+
});
|
|
541
|
+
await assertProblem({
|
|
542
|
+
response: await putLog({
|
|
543
|
+
collectionId,
|
|
544
|
+
body: genesisLine(oneEpoch) + '\n',
|
|
545
|
+
headers: { 'if-none-match': '*' }
|
|
546
|
+
}),
|
|
547
|
+
status: 409,
|
|
548
|
+
type: 'encryption-immutable'
|
|
549
|
+
});
|
|
550
|
+
assert.equal((await getLog({ collectionId })).status, 404);
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: 'governed-log.not-a-resource',
|
|
555
|
+
name: '[root] the log is absent from the listing, exempt from the envelope rule, and untouched by a PUT /meta',
|
|
556
|
+
specRefs: [
|
|
557
|
+
'https://wallet.storage/spec#list-collection-operation',
|
|
558
|
+
'https://wallet.storage/spec#update-collection-metadata-operation'
|
|
559
|
+
],
|
|
560
|
+
run: async (ctx, state) => {
|
|
561
|
+
const { alice, collectionUrl, getLog, governedCollection } = state;
|
|
562
|
+
if (!state.governedSupported) {
|
|
563
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
564
|
+
}
|
|
565
|
+
const { collectionId, etag } = await governedCollection();
|
|
566
|
+
const itemsUrl = `${collectionUrl(collectionId)}/`;
|
|
567
|
+
// The governed Collection is encrypted: a plaintext Resource is
|
|
568
|
+
// refused by the envelope rule while a conforming envelope lands. The
|
|
569
|
+
// log itself (JSON Lines, no envelope) was accepted above regardless.
|
|
570
|
+
let expectedError;
|
|
571
|
+
try {
|
|
572
|
+
await alice.rootClient.request({
|
|
573
|
+
url: itemsUrl,
|
|
574
|
+
method: 'POST',
|
|
575
|
+
action: 'POST',
|
|
576
|
+
json: { hello: 'world' }
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
expectedError = err;
|
|
581
|
+
}
|
|
582
|
+
assert.ok(expectedError, 'expected the plaintext write to be refused');
|
|
583
|
+
assert.equal(expectedError.response.status, 422);
|
|
584
|
+
const created = await alice.rootClient.request({
|
|
585
|
+
url: itemsUrl,
|
|
586
|
+
method: 'POST',
|
|
587
|
+
action: 'POST',
|
|
588
|
+
json: edvDocument
|
|
589
|
+
});
|
|
590
|
+
assert.equal(created.status, 201);
|
|
591
|
+
// One Resource in the listing, and it is not the log.
|
|
592
|
+
const listing = await alice.rootClient.request({
|
|
593
|
+
url: itemsUrl,
|
|
594
|
+
method: 'GET'
|
|
595
|
+
});
|
|
596
|
+
const ids = listing.data.items.map((item) => item.id);
|
|
597
|
+
assert.equal(ids.length, 1);
|
|
598
|
+
assert.ok(!ids.some(id => /log|meta/.test(id)));
|
|
599
|
+
// A `/meta` write leaves the log (and its ETag) alone.
|
|
600
|
+
const meta = await alice.rootClient.request({
|
|
601
|
+
url: `${collectionUrl(collectionId)}/meta`,
|
|
602
|
+
method: 'PUT',
|
|
603
|
+
action: 'PUT',
|
|
604
|
+
json: { custom: edvDocument }
|
|
605
|
+
});
|
|
606
|
+
assert.equal(meta.status, 204);
|
|
607
|
+
assert.equal((await getLog({ collectionId })).headers.get('etag'), etag);
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: 'governed-log.delegated-read',
|
|
612
|
+
name: '[delegated] a GET capability on the Collection URL covers the log read',
|
|
613
|
+
run: async (ctx, state) => {
|
|
614
|
+
const { alice, collectionUrl, getLog, governedCollection } = state;
|
|
615
|
+
const { aliceDelegatedApp } = ctx.actors;
|
|
616
|
+
if (!state.governedSupported) {
|
|
617
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
618
|
+
}
|
|
619
|
+
const { collectionId, body } = await governedCollection();
|
|
620
|
+
// A Space-rooted grant: the chain descends from the Space's root
|
|
621
|
+
// capability to the Collection's items subtree, which the log URL
|
|
622
|
+
// sits under.
|
|
623
|
+
const capability = await alice.was.grant({
|
|
624
|
+
to: aliceDelegatedApp.did,
|
|
625
|
+
actions: ['GET'],
|
|
626
|
+
target: `${collectionUrl(collectionId)}/`
|
|
627
|
+
});
|
|
628
|
+
const read = await getLog({
|
|
629
|
+
collectionId,
|
|
630
|
+
invocationSigner: aliceDelegatedApp.signer,
|
|
631
|
+
capability
|
|
632
|
+
});
|
|
633
|
+
assert.equal(read.status, 200);
|
|
634
|
+
assert.equal(await read.text(), body);
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
id: 'governed-log.other-controller-404-mask',
|
|
639
|
+
name: "[root] another controller's log read is the 404 mask, not a 403",
|
|
640
|
+
specRefs: ['https://wallet.storage/spec#not-found'],
|
|
641
|
+
run: async (ctx, state) => {
|
|
642
|
+
const { bob, getLog, governedCollection } = state;
|
|
643
|
+
if (!state.governedSupported) {
|
|
644
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
645
|
+
}
|
|
646
|
+
const { collectionId } = await governedCollection();
|
|
647
|
+
await assertProblem({
|
|
648
|
+
response: await getLog({
|
|
649
|
+
collectionId,
|
|
650
|
+
invocationSigner: bob.signer
|
|
651
|
+
}),
|
|
652
|
+
status: 404,
|
|
653
|
+
type: 'not-found'
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
id: 'governed-log.delete-collection-removes-log',
|
|
659
|
+
name: '[root] deleting the Collection takes the log with it: a re-created Collection is ungoverned',
|
|
660
|
+
specRefs: ['https://wallet.storage/spec#delete-collection-operation'],
|
|
661
|
+
run: async (ctx, state) => {
|
|
662
|
+
const { alice, collectionUrl, putLog, governedCollection } = state;
|
|
663
|
+
if (!state.governedSupported) {
|
|
664
|
+
ctx.skip('backend does not advertise governed-history-logs');
|
|
665
|
+
}
|
|
666
|
+
const { collectionId, body } = await governedCollection();
|
|
667
|
+
await alice.rootClient.request({
|
|
668
|
+
url: collectionUrl(collectionId),
|
|
669
|
+
method: 'DELETE'
|
|
670
|
+
});
|
|
671
|
+
await alice.rootClient.request({
|
|
672
|
+
url: new URL(`/space/${alice.space1.id}/`, ctx.serverUrl).toString(),
|
|
673
|
+
method: 'POST',
|
|
674
|
+
action: 'POST',
|
|
675
|
+
json: { id: collectionId, name: collectionId }
|
|
676
|
+
});
|
|
677
|
+
const described = await alice.rootClient.request({
|
|
678
|
+
url: collectionUrl(collectionId),
|
|
679
|
+
method: 'GET'
|
|
680
|
+
});
|
|
681
|
+
assert.equal(described.data.encryption, undefined);
|
|
682
|
+
const recreated = await putLog({
|
|
683
|
+
collectionId,
|
|
684
|
+
body,
|
|
685
|
+
headers: { 'if-none-match': '*' }
|
|
686
|
+
});
|
|
687
|
+
assert.equal(recreated.status, 204, await recreated.text());
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
]
|
|
691
|
+
};
|
|
692
|
+
//# sourceMappingURL=governed-log-api.js.map
|