@interop/was-conformance-suite 0.14.0 → 0.16.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.
Files changed (60) hide show
  1. package/README.md +1 -0
  2. package/dist/helpers.d.ts.map +1 -1
  3. package/dist/helpers.js +3 -6
  4. package/dist/helpers.js.map +1 -1
  5. package/dist/suites/authz-ordering-api.d.ts.map +1 -1
  6. package/dist/suites/authz-ordering-api.js +1 -26
  7. package/dist/suites/authz-ordering-api.js.map +1 -1
  8. package/dist/suites/blinded-index-api.d.ts.map +1 -1
  9. package/dist/suites/blinded-index-api.js +2 -4
  10. package/dist/suites/blinded-index-api.js.map +1 -1
  11. package/dist/suites/changes-query-api.js +1 -1
  12. package/dist/suites/changes-query-api.js.map +1 -1
  13. package/dist/suites/chunks-api.js +1 -1
  14. package/dist/suites/chunks-api.js.map +1 -1
  15. package/dist/suites/client-spaces.d.ts.map +1 -1
  16. package/dist/suites/client-spaces.js +19 -6
  17. package/dist/suites/client-spaces.js.map +1 -1
  18. package/dist/suites/collection-api.d.ts.map +1 -1
  19. package/dist/suites/collection-api.js +317 -126
  20. package/dist/suites/collection-api.js.map +1 -1
  21. package/dist/suites/conditional-requests-api.d.ts.map +1 -1
  22. package/dist/suites/conditional-requests-api.js +122 -30
  23. package/dist/suites/conditional-requests-api.js.map +1 -1
  24. package/dist/suites/delegation-cryptosuites-api.js +1 -1
  25. package/dist/suites/delegation-cryptosuites-api.js.map +1 -1
  26. package/dist/suites/denial-reasons-api.d.ts.map +1 -1
  27. package/dist/suites/denial-reasons-api.js +5 -2
  28. package/dist/suites/denial-reasons-api.js.map +1 -1
  29. package/dist/suites/digest-api.js +1 -1
  30. package/dist/suites/digest-api.js.map +1 -1
  31. package/dist/suites/encryption-descriptor-api.d.ts.map +1 -1
  32. package/dist/suites/encryption-descriptor-api.js +48 -51
  33. package/dist/suites/encryption-descriptor-api.js.map +1 -1
  34. package/dist/suites/governed-log-api.d.ts.map +1 -1
  35. package/dist/suites/governed-log-api.js +18 -17
  36. package/dist/suites/governed-log-api.js.map +1 -1
  37. package/dist/suites/index.d.ts +2 -1
  38. package/dist/suites/index.d.ts.map +1 -1
  39. package/dist/suites/index.js +3 -1
  40. package/dist/suites/index.js.map +1 -1
  41. package/dist/suites/invocation-target-api.js +1 -1
  42. package/dist/suites/invocation-target-api.js.map +1 -1
  43. package/dist/suites/plaintext-declaration-api.d.ts.map +1 -1
  44. package/dist/suites/plaintext-declaration-api.js +24 -15
  45. package/dist/suites/plaintext-declaration-api.js.map +1 -1
  46. package/dist/suites/policy-api.js +1 -1
  47. package/dist/suites/policy-api.js.map +1 -1
  48. package/dist/suites/reserved-methods-api.d.ts +9 -0
  49. package/dist/suites/reserved-methods-api.d.ts.map +1 -0
  50. package/dist/suites/reserved-methods-api.js +549 -0
  51. package/dist/suites/reserved-methods-api.js.map +1 -0
  52. package/dist/suites/resource-api.js +1 -1
  53. package/dist/suites/resource-api.js.map +1 -1
  54. package/dist/suites/spaces-api.d.ts.map +1 -1
  55. package/dist/suites/spaces-api.js +193 -63
  56. package/dist/suites/spaces-api.js.map +1 -1
  57. package/dist/suites/write-validation-api.d.ts.map +1 -1
  58. package/dist/suites/write-validation-api.js +16 -36
  59. package/dist/suites/write-validation-api.js.map +1 -1
  60. package/package.json +6 -6
@@ -33,27 +33,6 @@ async function getWithCapability({ url, capability, invocationSigner }) {
33
33
  headers: signatureHeaders
34
34
  });
35
35
  }
36
- /**
37
- * Provisions a fresh Collection with a random id, returning its `/meta` URL and
38
- * the Collection URL it hangs off. Every Collection Metadata test works on its
39
- * own Collection, so the `metaVersion` sequence a test observes is its own.
40
- *
41
- * @param options {object}
42
- * @param options.ctx {any} the test context (for `serverUrl` / `generateId`)
43
- * @param options.alice {any} the suite's root actor
44
- * @returns {Promise<{collectionUrl: string, metaUrl: string}>}
45
- */
46
- async function freshCollection({ ctx, alice }) {
47
- const collectionId = ctx.generateId();
48
- const collectionUrl = new URL(`/space/${alice.space1.id}/${collectionId}`, ctx.serverUrl).toString();
49
- await alice.rootClient.request({
50
- url: collectionUrl,
51
- method: 'PUT',
52
- action: 'PUT',
53
- json: { id: collectionId, name: 'Metadata Collection' }
54
- });
55
- return { collectionUrl, metaUrl: `${collectionUrl}/meta` };
56
- }
57
36
  /**
58
37
  * Sends a signed Collection Metadata request, translating a 501
59
38
  * `unsupported-operation` into a skip: Collection Metadata is an OPTIONAL
@@ -86,6 +65,31 @@ async function metaRequestOrSkip({ ctx, alice, url, method, json, headers }) {
86
65
  throw err;
87
66
  }
88
67
  }
68
+ /**
69
+ * Provisions a fresh Collection with a random id via a guarded (create-only)
70
+ * `PUT` of its Metadata object -- the v0.5 create-by-id path, now that `PUT`
71
+ * at the bare Collection URL is retired (405). Returns its container URL and
72
+ * its `/meta` URL. Every Collection Metadata test works on its own
73
+ * Collection, so the `metaVersion` sequence a test observes is its own.
74
+ *
75
+ * @param options {object}
76
+ * @param options.ctx {any} the test context (for `serverUrl` / `generateId` / `skip`)
77
+ * @param options.alice {any} the suite's root actor
78
+ * @returns {Promise<{collectionUrl: string, metaUrl: string}>}
79
+ */
80
+ async function freshCollection({ ctx, alice }) {
81
+ const collectionId = ctx.generateId();
82
+ const collectionUrl = new URL(`/space/${alice.space1.id}/${collectionId}`, ctx.serverUrl).toString();
83
+ const metaUrl = `${collectionUrl}/meta`;
84
+ await metaRequestOrSkip({
85
+ ctx,
86
+ alice,
87
+ url: metaUrl,
88
+ method: 'PUT',
89
+ json: { id: collectionId, name: 'Metadata Collection' }
90
+ });
91
+ return { collectionUrl, metaUrl };
92
+ }
89
93
  export const collectionApi = {
90
94
  id: 'collection-api',
91
95
  name: 'Collections API',
@@ -106,7 +110,7 @@ export const collectionApi = {
106
110
  const { alice } = state;
107
111
  try {
108
112
  await alice.rootClient.request({
109
- url: new URL(`/space/${alice.space1.id}`, ctx.serverUrl).toString(),
113
+ url: new URL(`/space/${alice.space1.id}/`, ctx.serverUrl).toString(),
110
114
  method: 'DELETE'
111
115
  });
112
116
  }
@@ -140,12 +144,15 @@ export const collectionApi = {
140
144
  const { serverUrl } = ctx;
141
145
  const { alice } = state;
142
146
  const spaceUrl = new URL('/space/space-id-that-does-not-exist/', serverUrl).toString();
147
+ // A body is required so the request reaches the existence check
148
+ // rather than failing body validation first.
143
149
  let expectedError;
144
150
  try {
145
151
  await alice.rootClient.request({
146
152
  url: spaceUrl,
147
153
  method: 'POST',
148
- action: 'POST'
154
+ action: 'POST',
155
+ json: { name: 'Probe Collection' }
149
156
  });
150
157
  }
151
158
  catch (err) {
@@ -172,14 +179,17 @@ export const collectionApi = {
172
179
  json: body
173
180
  });
174
181
  assert.equal(response.status, 201);
182
+ // The Collection's `url` is stamped in its canonical trailing-slash
183
+ // (container) form, matching a subsequent Read Collection Metadata.
175
184
  assert.deepStrictEqual(withoutCreatedBy(response.data), {
176
185
  id: 'credentials',
177
186
  name: 'Verifiable Credentials',
178
187
  type: ['Collection'],
179
- backend: { id: 'default' }
188
+ backend: { id: 'default' },
189
+ url: `/space/${alice.space1.id}/credentials/`
180
190
  });
181
191
  assert.match(response.headers.get('content-type'), /application\/json/);
182
- assert.equal(response.headers.get('location'), `${serverUrl}/space/${alice.space1.id}/${body.id}`);
192
+ assert.equal(response.headers.get('location'), `${serverUrl}/space/${alice.space1.id}/${body.id}/`);
183
193
  }
184
194
  },
185
195
  {
@@ -221,7 +231,9 @@ export const collectionApi = {
221
231
  assert.equal(response.status, 200);
222
232
  const listResponse = response.data;
223
233
  assert.equal(listResponse.id, 'credentials');
224
- assert.equal(listResponse.url, `/space/${alice.space1.id}/credentials`);
234
+ // The listing envelope's own `url` carries the trailing slash (spec
235
+ // "Reading This Document": a trailing slash marks a container).
236
+ assert.equal(listResponse.url, `/space/${alice.space1.id}/credentials/`);
225
237
  assert.equal(listResponse.name, 'Verifiable Credentials');
226
238
  assert.deepStrictEqual(listResponse.type, ['Collection']);
227
239
  assert.equal(typeof listResponse.totalItems, 'number');
@@ -230,30 +242,98 @@ export const collectionApi = {
230
242
  }
231
243
  },
232
244
  {
233
- id: 'collection.read-description',
234
- name: '[root] get collection description via GET :collectionId',
245
+ id: 'collection.read-metadata',
246
+ name: '[root] get the Collection Metadata object via GET :collectionId/meta',
235
247
  specRefs: [
236
- 'https://wallet.storage/spec#get-collection-description-operation'
248
+ 'https://wallet.storage/spec#read-collection-metadata-operation',
249
+ 'https://wallet.storage/spec#collection-metadata-data-model'
237
250
  ],
238
251
  run: async (ctx, state) => {
239
252
  const { serverUrl, withoutCreatedBy } = ctx;
240
253
  const { alice } = state;
241
- const response = await alice.rootClient.request({
242
- url: new URL(`/space/${alice.space1.id}/credentials`, serverUrl).toString(),
243
- method: 'GET',
244
- action: 'GET'
254
+ // v0.5 retires the bare-URL Collection description; reading a
255
+ // Collection's description is now always a GET of `meta`.
256
+ const response = await metaRequestOrSkip({
257
+ ctx,
258
+ alice,
259
+ url: new URL(`/space/${alice.space1.id}/credentials/meta`, serverUrl).toString(),
260
+ method: 'GET'
245
261
  });
246
262
  assert.equal(response.status, 200);
247
- assert.deepStrictEqual(withoutCreatedBy(response.data), {
263
+ const { createdAt, updatedAt, ...rest } = withoutCreatedBy(response.data);
264
+ assert.ok(!Number.isNaN(Date.parse(createdAt)));
265
+ assert.ok(!Number.isNaN(Date.parse(updatedAt)));
266
+ assert.deepStrictEqual(rest, {
248
267
  id: 'credentials',
249
268
  name: 'Verifiable Credentials',
250
269
  type: ['Collection'],
251
270
  backend: { id: 'default' },
252
- url: `/space/${alice.space1.id}/credentials`,
271
+ url: `/space/${alice.space1.id}/credentials/`,
253
272
  linkset: `/space/${alice.space1.id}/credentials/linkset`
254
273
  });
255
274
  }
256
275
  },
276
+ {
277
+ id: 'collection.non-canonical-url-redirects',
278
+ name: '[root] the non-canonical (no-slash) Collection URL 308s to the ' +
279
+ 'canonical (trailing-slash) form; the canonical form is not redirected',
280
+ optional: true,
281
+ specRefs: ['https://wallet.storage/spec#reading-this-document'],
282
+ run: async (ctx, state) => {
283
+ const { serverUrl } = ctx;
284
+ const { alice } = state;
285
+ const bareUrl = new URL(`/space/${alice.space1.id}/credentials`, serverUrl);
286
+ const canonicalPath = `/space/${alice.space1.id}/credentials/`;
287
+ const redirected = await fetch(bareUrl, {
288
+ method: 'GET',
289
+ redirect: 'manual'
290
+ });
291
+ if (redirected.type === 'opaqueredirect') {
292
+ ctx.skip('redirect responses are opaque in this runtime (cross-origin fetch)');
293
+ }
294
+ assert.equal(redirected.status, 308);
295
+ const location = redirected.headers.get('location');
296
+ assert.ok(location, 'expected a Location header on the redirect');
297
+ assert.equal(new URL(location, serverUrl).pathname, canonicalPath);
298
+ // The canonical form itself is never redirected -- whatever it
299
+ // answers (200, 401, 404...) it is not a 308.
300
+ const canonical = await fetch(new URL(canonicalPath, serverUrl), {
301
+ method: 'GET',
302
+ redirect: 'manual'
303
+ });
304
+ assert.notEqual(canonical.status, 308);
305
+ }
306
+ },
307
+ {
308
+ id: 'collection.put-container-405',
309
+ name: '[root] PUT at the canonical Collection URL is 405 (PUT is not ' +
310
+ 'defined at the container)',
311
+ specRefs: ['https://wallet.storage/spec#collection-metadata-data-model'],
312
+ run: async (ctx, state) => {
313
+ const { serverUrl } = ctx;
314
+ const { alice } = state;
315
+ let expectedError;
316
+ try {
317
+ await alice.rootClient.request({
318
+ url: new URL(`/space/${alice.space1.id}/credentials/`, serverUrl).toString(),
319
+ method: 'PUT',
320
+ action: 'PUT',
321
+ json: { name: 'Should Not Replace' }
322
+ });
323
+ }
324
+ catch (err) {
325
+ expectedError = err;
326
+ }
327
+ assert.ok(expectedError, 'expected a PUT at the Collection container URL to be refused');
328
+ assert.equal(expectedError.response.status, 405);
329
+ const allow = (expectedError.response.headers.get('allow') ?? '')
330
+ .split(',')
331
+ .map((method) => method.trim())
332
+ .filter(Boolean);
333
+ assert.ok(!allow.includes('PUT'), 'Allow must not include PUT');
334
+ assert.match(expectedError.response.headers.get('content-type'), /application\/problem\+json/);
335
+ }
336
+ },
257
337
  {
258
338
  id: 'collection.paginate-limit-next',
259
339
  name: '[root] paginates List Collection via ?limit and follows next (spec Pagination)',
@@ -267,9 +347,12 @@ export const collectionApi = {
267
347
  // Fresh Collection seeded with > one page of Resources, inserted out of order
268
348
  // to prove the listing order is by id, not insertion.
269
349
  const collectionId = generateId();
270
- // WAS does not auto-create parent Collections, so provision it first.
271
- await alice.rootClient.request({
272
- url: new URL(`/space/${alice.space1.id}/${collectionId}`, serverUrl).toString(),
350
+ // WAS does not auto-create parent Collections, so provision it first
351
+ // (create-by-id is a guarded `PUT` of its Metadata object).
352
+ await metaRequestOrSkip({
353
+ ctx,
354
+ alice,
355
+ url: new URL(`/space/${alice.space1.id}/${collectionId}/meta`, serverUrl).toString(),
273
356
  method: 'PUT',
274
357
  json: { id: collectionId, name: 'Paginated Collection' }
275
358
  });
@@ -337,25 +420,30 @@ export const collectionApi = {
337
420
  const { alice } = state;
338
421
  const collectionId = 'new-collection';
339
422
  const collectionUrl = new URL(`/space/${alice.space1.id}/${collectionId}`, serverUrl).toString();
423
+ const metaUrl = `${collectionUrl}/meta`;
340
424
  const body = { id: collectionId, name: 'New Collection' };
341
- await alice.rootClient.request({
342
- url: collectionUrl,
425
+ const created = await metaRequestOrSkip({
426
+ ctx,
427
+ alice,
428
+ url: metaUrl,
343
429
  method: 'PUT',
344
430
  json: body
345
431
  });
432
+ assert.equal(created.status, 201);
346
433
  const existResponse = await alice.rootClient.request({
347
- url: collectionUrl,
434
+ url: metaUrl,
348
435
  method: 'GET'
349
436
  });
350
437
  assert.equal(existResponse.status, 200);
438
+ // Delete Collection is at the canonical (trailing-slash) container URL.
351
439
  const deleteResponse = await alice.rootClient.request({
352
- url: collectionUrl,
440
+ url: `${collectionUrl}/`,
353
441
  method: 'DELETE'
354
442
  });
355
443
  assert.equal(deleteResponse.status, 204);
356
444
  let checkResponse;
357
445
  try {
358
- await alice.rootClient.request({ url: collectionUrl, method: 'GET' });
446
+ await alice.rootClient.request({ url: metaUrl, method: 'GET' });
359
447
  }
360
448
  catch (err) {
361
449
  checkResponse = err.response;
@@ -398,6 +486,37 @@ export const collectionApi = {
398
486
  assert.equal(expectedError.data.type, 'https://wallet.storage/spec#unsupported-backend');
399
487
  }
400
488
  },
489
+ {
490
+ id: 'collection.create-post-meta-reserved-id-409',
491
+ name: '[root] creating a Collection with the reserved id `meta` via POST ' +
492
+ 'is rejected with 409 reserved-id',
493
+ specRefs: [
494
+ 'https://wallet.storage/spec#space-level-reserved-endpoints',
495
+ 'https://wallet.storage/spec#reserved-id'
496
+ ],
497
+ run: async (ctx, state) => {
498
+ const { serverUrl } = ctx;
499
+ const { alice } = state;
500
+ // `meta` occupies the Space Metadata object's own path
501
+ // (`/space/{s}/meta`), so it is a reserved Collection id -- v0.5
502
+ // widens the Space-level reserved-endpoint table to include it.
503
+ let expectedError;
504
+ try {
505
+ await alice.rootClient.request({
506
+ url: new URL(`/space/${alice.space1.id}/`, serverUrl).toString(),
507
+ method: 'POST',
508
+ action: 'POST',
509
+ json: { id: 'meta', name: 'Reserved Meta Collection Id Probe' }
510
+ });
511
+ }
512
+ catch (err) {
513
+ expectedError = err;
514
+ }
515
+ assert.ok(expectedError, 'expected the `meta` Collection id to be rejected');
516
+ assert.equal(expectedError.response.status, 409);
517
+ assert.equal(expectedError.data.type, 'https://wallet.storage/spec#reserved-id');
518
+ }
519
+ },
401
520
  {
402
521
  id: 'collection.paginate-delegated-no-redelegation',
403
522
  name: '[delegated] a single list capability reads every page; no per-page ' +
@@ -412,8 +531,10 @@ export const collectionApi = {
412
531
  const aliceDelegatedApp = ctx.actors.aliceDelegatedApp;
413
532
  // Seed a fresh Collection with more than one page of Resources.
414
533
  const collectionId = generateId();
415
- await alice.rootClient.request({
416
- url: new URL(`/space/${alice.space1.id}/${collectionId}`, serverUrl).toString(),
534
+ await metaRequestOrSkip({
535
+ ctx,
536
+ alice,
537
+ url: new URL(`/space/${alice.space1.id}/${collectionId}/meta`, serverUrl).toString(),
417
538
  method: 'PUT',
418
539
  json: { id: collectionId, name: 'Delegated Paginated Collection' }
419
540
  });
@@ -501,7 +622,7 @@ export const collectionApi = {
501
622
  name: '[root] PUT Collection Metadata sets `custom`, round-tripped by GET',
502
623
  group: 'Collection Metadata',
503
624
  specRefs: [
504
- 'https://wallet.storage/spec#update-collection-metadata-operation',
625
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation',
505
626
  'https://wallet.storage/spec#read-collection-metadata-operation'
506
627
  ],
507
628
  run: async (ctx, state) => {
@@ -538,7 +659,7 @@ export const collectionApi = {
538
659
  '`custom` clears it',
539
660
  group: 'Collection Metadata',
540
661
  specRefs: [
541
- 'https://wallet.storage/spec#update-collection-metadata-operation'
662
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation'
542
663
  ],
543
664
  run: async (ctx, state) => {
544
665
  const { alice } = state;
@@ -575,7 +696,7 @@ export const collectionApi = {
575
696
  'members (read-modify-write is safe)',
576
697
  group: 'Collection Metadata',
577
698
  specRefs: [
578
- 'https://wallet.storage/spec#update-collection-metadata-operation',
699
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation',
579
700
  'https://wallet.storage/spec#collection-metadata-data-model'
580
701
  ],
581
702
  run: async (ctx, state) => {
@@ -619,37 +740,39 @@ export const collectionApi = {
619
740
  }
620
741
  },
621
742
  {
622
- id: 'collection.meta-put-missing-collection-404',
623
- name: '[root] PUT Collection Metadata on a nonexistent Collection 404s ' +
624
- '(never creates)',
743
+ id: 'collection.meta-put-creates-missing-collection',
744
+ name: '[root] PUT Collection Metadata on a nonexistent Collection creates ' +
745
+ 'it (201), the `Location` naming the Collection',
625
746
  group: 'Collection Metadata',
626
747
  specRefs: [
627
- 'https://wallet.storage/spec#update-collection-metadata-operation',
628
- 'https://wallet.storage/spec#not-found'
748
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation'
629
749
  ],
630
750
  run: async (ctx, state) => {
631
751
  const { serverUrl } = ctx;
632
752
  const { alice } = state;
633
- // Probe first, so an unimplemented server skips rather than passing on
634
- // an unrelated 404.
635
- const { metaUrl } = await freshCollection({ ctx, alice });
636
- await metaRequestOrSkip({ ctx, alice, url: metaUrl, method: 'GET' });
637
- const missingMetaUrl = new URL(`/space/${alice.space1.id}/collection-that-does-not-exist/meta`, serverUrl).toString();
638
- let expectedError;
639
- try {
640
- await alice.rootClient.request({
641
- url: missingMetaUrl,
642
- method: 'PUT',
643
- action: 'PUT',
644
- json: { custom: { name: 'nope' } }
645
- });
646
- }
647
- catch (err) {
648
- expectedError = err;
649
- }
650
- assert.ok(expectedError, 'expected a metadata write on a missing Collection to be rejected');
651
- assert.equal(expectedError.response.status, 404);
652
- assert.match(expectedError.response.headers.get('content-type'), /application\/problem\+json/);
753
+ const collectionId = ctx.generateId();
754
+ const collectionUrl = new URL(`/space/${alice.space1.id}/${collectionId}`, serverUrl).toString();
755
+ const metaUrl = `${collectionUrl}/meta`;
756
+ const response = await metaRequestOrSkip({
757
+ ctx,
758
+ alice,
759
+ url: metaUrl,
760
+ method: 'PUT',
761
+ json: { custom: { name: 'Created via meta' } }
762
+ });
763
+ assert.equal(response.status, 201);
764
+ // `Location` names the Collection created, in its canonical
765
+ // trailing-slash form -- not the Metadata object that was written.
766
+ assert.equal(response.headers.get('location'), `${collectionUrl}/`);
767
+ const created = await alice.rootClient.request({
768
+ url: metaUrl,
769
+ method: 'GET'
770
+ });
771
+ assert.equal(created.status, 200);
772
+ assert.equal(created.data.id, collectionId);
773
+ assert.deepStrictEqual(created.data.custom, {
774
+ name: 'Created via meta'
775
+ });
653
776
  }
654
777
  },
655
778
  {
@@ -658,7 +781,7 @@ export const collectionApi = {
658
781
  'rejected with 400 invalid-request-body',
659
782
  group: 'Collection Metadata',
660
783
  specRefs: [
661
- 'https://wallet.storage/spec#update-collection-metadata-operation',
784
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation',
662
785
  'https://wallet.storage/spec#invalid-request-body'
663
786
  ],
664
787
  run: async (ctx, state) => {
@@ -688,7 +811,7 @@ export const collectionApi = {
688
811
  'one succeeds',
689
812
  group: 'Collection Metadata',
690
813
  specRefs: [
691
- 'https://wallet.storage/spec#update-collection-metadata-operation',
814
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation',
692
815
  'https://wallet.storage/spec#conditional-requests',
693
816
  'https://wallet.storage/spec#precondition-failed'
694
817
  ],
@@ -745,78 +868,74 @@ export const collectionApi = {
745
868
  }
746
869
  },
747
870
  {
748
- id: 'collection.meta-etag-independent-of-description',
749
- name: '[root] the Collection Metadata ETag is independent of the Collection ' +
750
- 'Description ETag',
871
+ id: 'collection.meta-configuration-and-annotation-share-etag',
872
+ name: '[root] a configuration write and an annotation write on Collection ' +
873
+ 'Metadata advance the same ETag',
751
874
  group: 'Collection Metadata',
752
875
  specRefs: [
753
- 'https://wallet.storage/spec#collection-metadata-data-model',
876
+ 'https://wallet.storage/spec#collection-metadata-versioning',
754
877
  'https://wallet.storage/spec#conditional-requests'
755
878
  ],
756
879
  run: async (ctx, state) => {
757
880
  const { alice } = state;
758
- const { collectionUrl, metaUrl } = await freshCollection({ ctx, alice });
759
- const described = await alice.rootClient.request({
760
- url: collectionUrl,
761
- method: 'GET'
762
- });
763
- const descriptionEtag = described.headers.get('etag');
764
- if (!descriptionEtag) {
765
- ctx.skip('server does not emit an ETag on the Collection Description');
766
- }
767
- // A metadata write does not disturb the description validator...
768
- await metaRequestOrSkip({
881
+ // v0.5 merges what used to be two independent validators (the
882
+ // Collection description's and the `/meta` annotation object's) into
883
+ // the one Collection Metadata object's `metaVersion`, so a
884
+ // configuration write and an annotation write now advance the SAME
885
+ // ETag rather than independent ones.
886
+ const { metaUrl } = await freshCollection({ ctx, alice });
887
+ const created = await metaRequestOrSkip({
769
888
  ctx,
770
889
  alice,
771
890
  url: metaUrl,
772
- method: 'PUT',
773
- json: { custom: { name: 'Independent' } }
774
- });
775
- const afterMeta = await alice.rootClient.request({
776
- url: collectionUrl,
777
891
  method: 'GET'
778
892
  });
779
- assert.equal(afterMeta.headers.get('etag'), descriptionEtag);
780
- const metaEtag = (await alice.rootClient.request({ url: metaUrl, method: 'GET' })).headers.get('etag');
781
- // ...and a description write does not disturb the metadata validator.
782
- await alice.rootClient.request({
783
- url: collectionUrl,
893
+ const createdEtag = created.headers.get('etag');
894
+ assert.ok(createdEtag, 'expected an ETag on Collection Metadata');
895
+ // An annotation write advances the one validator...
896
+ const annotated = await alice.rootClient.request({
897
+ url: metaUrl,
784
898
  method: 'PUT',
785
899
  action: 'PUT',
786
- json: {
787
- id: described.data.id,
788
- type: ['Collection'],
789
- name: 'Renamed'
790
- }
900
+ json: { name: 'Metadata Collection', custom: { name: 'Shared' } }
791
901
  });
792
- const afterDescription = await alice.rootClient.request({
793
- url: collectionUrl,
794
- method: 'GET'
902
+ const annotatedEtag = annotated.headers.get('etag');
903
+ assert.notEqual(annotatedEtag, createdEtag);
904
+ // ...and so does a configuration write (renaming the plaintext
905
+ // `name` is a configuration member, not an annotation).
906
+ const configured = await alice.rootClient.request({
907
+ url: metaUrl,
908
+ method: 'PUT',
909
+ action: 'PUT',
910
+ json: { name: 'Renamed Collection', custom: { name: 'Shared' } }
795
911
  });
796
- assert.notEqual(afterDescription.headers.get('etag'), descriptionEtag);
797
- const metaAfter = await alice.rootClient.request({
912
+ const configuredEtag = configured.headers.get('etag');
913
+ assert.notEqual(configuredEtag, annotatedEtag);
914
+ const meta = await alice.rootClient.request({
798
915
  url: metaUrl,
799
916
  method: 'GET'
800
917
  });
801
- assert.equal(metaAfter.headers.get('etag'), metaEtag);
802
- assert.deepStrictEqual(metaAfter.data.custom, { name: 'Independent' });
918
+ assert.equal(meta.headers.get('etag'), configuredEtag);
919
+ assert.equal(meta.data.name, 'Renamed Collection');
920
+ assert.deepStrictEqual(meta.data.custom, { name: 'Shared' });
803
921
  }
804
922
  },
805
923
  {
806
- id: 'collection.meta-reserved-resource-id-409',
807
- name: '[root] `meta` is a reserved Resource id where Collection Metadata is ' +
808
- 'implemented (409 reserved-id)',
924
+ id: 'collection.meta-delete-405-not-reserved-id',
925
+ name: '[root] DELETE at the Collection Metadata URL is 405 with an Allow ' +
926
+ 'header, not a reserved-id 409',
809
927
  group: 'Collection Metadata',
810
928
  specRefs: [
811
- 'https://wallet.storage/spec#reserved-path-segment-registry',
812
- 'https://wallet.storage/spec#reserved-id'
929
+ 'https://wallet.storage/spec#collection-metadata-data-model',
930
+ 'https://wallet.storage/spec#methods-at-reserved-endpoints'
813
931
  ],
814
932
  run: async (ctx, state) => {
815
933
  const { alice } = state;
816
- // The Collection Metadata route occupies the `{resourceId}` position, so
817
- // a Resource may not be named `meta`. The GET/PUT verbs at that URL are
818
- // the Metadata operations themselves; DELETE falls through to the
819
- // Resource route, where the reserved-id check rejects it.
934
+ // `meta` occupies the Collection's `:resourceId` position, so `meta`
935
+ // is a reserved Resource id -- but there is no DELETE defined at
936
+ // `meta` (spec "Lifecycle": deleting the Collection removes its
937
+ // Metadata object with it), so v0.5's Methods at Reserved Endpoints
938
+ // rule answers 405 here, never the reserved-id 409.
820
939
  const { metaUrl } = await freshCollection({ ctx, alice });
821
940
  await metaRequestOrSkip({ ctx, alice, url: metaUrl, method: 'GET' });
822
941
  let expectedError;
@@ -830,12 +949,84 @@ export const collectionApi = {
830
949
  catch (err) {
831
950
  expectedError = err;
832
951
  }
833
- assert.ok(expectedError, 'expected a Resource named `meta` to be rejected');
834
- if (expectedError.response.status === 405) {
835
- ctx.skip('server does not route DELETE at the Collection /meta URL');
952
+ assert.ok(expectedError, 'expected a DELETE at the Collection Metadata URL to be refused');
953
+ assert.equal(expectedError.response.status, 405);
954
+ const allow = (expectedError.response.headers.get('allow') ?? '')
955
+ .split(',')
956
+ .map((method) => method.trim())
957
+ .filter(Boolean);
958
+ assert.ok(!allow.includes('DELETE'), 'Allow must not include DELETE');
959
+ assert.equal(expectedError.data.type, 'about:blank');
960
+ assert.notEqual(expectedError.data.type, 'https://wallet.storage/spec#reserved-id');
961
+ }
962
+ },
963
+ {
964
+ id: 'collection.meta-update-omits-backend-keeps-selection',
965
+ name: '[root] a Collection Metadata update omitting `backend` keeps the ' +
966
+ 'stored selection',
967
+ group: 'Collection Metadata',
968
+ // Proving retention (rather than a no-op default-to-default) needs a
969
+ // genuinely non-default backend, which requires the reference server's
970
+ // backend-registration write endpoint -- a facility the spec does not
971
+ // define the wire contract of (only `GET .../backends` is spec'd).
972
+ // Skip gracefully wherever that facility, or Collection Metadata
973
+ // itself, is unavailable.
974
+ optional: true,
975
+ specRefs: [
976
+ 'https://wallet.storage/spec#update-or-create-by-id-collection-operation',
977
+ 'https://wallet.storage/spec#collection-metadata-data-model'
978
+ ],
979
+ run: async (ctx, state) => {
980
+ const { serverUrl, generateId } = ctx;
981
+ const { alice } = state;
982
+ const backendId = generateId();
983
+ try {
984
+ await alice.rootClient.request({
985
+ url: new URL(`/space/${alice.space1.id}/backends`, serverUrl).toString(),
986
+ method: 'POST',
987
+ action: 'POST',
988
+ json: {
989
+ id: backendId,
990
+ provider: 'google-drive',
991
+ connection: {
992
+ kind: 'oauth2-google',
993
+ authorizationCode: 'probe-code'
994
+ }
995
+ }
996
+ });
836
997
  }
837
- assert.equal(expectedError.response.status, 409);
838
- assert.equal(expectedError.data.type, 'https://wallet.storage/spec#reserved-id');
998
+ catch {
999
+ ctx.skip('server does not support registering a non-default backend ' +
1000
+ '(reference-server extension, not spec-defined)');
1001
+ }
1002
+ const collectionId = generateId();
1003
+ const collectionUrl = new URL(`/space/${alice.space1.id}/${collectionId}`, serverUrl).toString();
1004
+ await alice.rootClient.request({
1005
+ url: new URL(`/space/${alice.space1.id}/`, serverUrl).toString(),
1006
+ method: 'POST',
1007
+ action: 'POST',
1008
+ json: {
1009
+ id: collectionId,
1010
+ name: 'Backend Retention Probe',
1011
+ backend: { id: backendId }
1012
+ }
1013
+ });
1014
+ const metaUrl = `${collectionUrl}/meta`;
1015
+ // An update that omits `backend` must keep the stored selection, not
1016
+ // reset it to the default (spec "Update Collection": "Clearing it
1017
+ // would repoint the Collection at the default backend").
1018
+ await metaRequestOrSkip({
1019
+ ctx,
1020
+ alice,
1021
+ url: metaUrl,
1022
+ method: 'PUT',
1023
+ json: { name: 'Renamed, backend omitted' }
1024
+ });
1025
+ const after = await alice.rootClient.request({
1026
+ url: metaUrl,
1027
+ method: 'GET'
1028
+ });
1029
+ assert.deepStrictEqual(after.data.backend, { id: backendId });
839
1030
  }
840
1031
  }
841
1032
  ]