From 7467f99f0140ec35e463a1da0d0cc3384492c640 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 22:09:30 +0000 Subject: [PATCH] test: dispatch the nine new grant fixtures, and sharpen the package gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @haverstack/conformance-fixtures to ^0.31.0 and handles what it adds. The caret pins the minor on a 0.x range, so 0.30.0 would never have picked these up on its own — the bump is what puts the coverage gate to work. Seven of the nine are plain POST /records bodies and share one dispatch. The other two need prior state, and each carries the control that makes it mean what it says: a grant naming _app@1 confers no create *while the same shape on a grantable family does*, and update-any with no read companion conveys nothing *while the same grant with read-any beside it conveys the update*. Without those, both tests would pass against a server holding no grant for the requester at all, since a missing grant answers 403 and 404 the same way. Two fixes to the whole-package gate, both visible the first time it fired in anger. It recorded a block's names only after that block's own assertions passed, so one failing block reported nothing and the gate blamed it for every fixture it does dispatch — 90 names for 9 real ones. And it read the allConformanceFixtures aggregate, which is a union of the arrays beside it, so every name arrived twice. Recording first and skipping the aggregate leaves the failure naming exactly what is unhandled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01St5i1czsAp26y8H56pmANC --- package.json | 2 +- pnpm-lock.yaml | 10 ++-- tests/conformance.test.ts | 105 +++++++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 9b4060c..395bf7c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@haverstack/conformance-fixtures": "^0.30.0", + "@haverstack/conformance-fixtures": "^0.31.0", "@types/node": "^26.2.0", "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a480f50..a1686e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1) '@haverstack/conformance-fixtures': - specifier: ^0.30.0 - version: 0.30.0 + specifier: ^0.31.0 + version: 0.31.0 '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -274,8 +274,8 @@ packages: '@haverstack/commons@0.31.0': resolution: {integrity: sha512-OhKy1IzXtZ9r6tFVwZ3fQb2Q+SbwBUu08FliU5BhwJXku/cFwVNBaOnZcrx1TQiOJ0i04ftz+WXq+BIDGblYxQ==} - '@haverstack/conformance-fixtures@0.30.0': - resolution: {integrity: sha512-20/m1byn9aUh96f2ny7fdAWwMgmrZdjCh4kSmv/VAVZHP9ihJOt0VLDN6DHrM6+QhxzhOA7GO/jJVp3ZAX3GWQ==} + '@haverstack/conformance-fixtures@0.31.0': + resolution: {integrity: sha512-2+0QR6L95iDl0BhGbs7q/BVz4CJ1zw0fbvUD0Fl6xAL90r5/M0ffRwY+rokTjgtxPK/ZsgVcr0nbDhlsD1Xokg==} '@haverstack/core@0.37.0': resolution: {integrity: sha512-tEFL/mVpVeOsQt0BDdtEJu8PQgy5DVoScCqEKID+DrAkvcspjcXrhUsGsCe87tWHbrYUWAMH8zW0UCSNw/ET0Q==} @@ -1275,7 +1275,7 @@ snapshots: dependencies: '@haverstack/core': 0.37.0 - '@haverstack/conformance-fixtures@0.30.0': + '@haverstack/conformance-fixtures@0.31.0': dependencies: '@haverstack/wire-types': 0.36.0 diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index d8131ed..8cefd80 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -109,6 +109,9 @@ const NOTE_TYPE_V2 = 'com.example/note@2'; const COMMENT_TYPE = 'com.example/comment@1'; const ATTACHMENT_TYPE = '_attachment@1'; const GROUP_TYPE = '_group@1'; +const GRANT_TYPE = '_grant@1'; +// Ungrantable, which is what the evaluation-time fixture below is about. +const APP_TYPE = '_app@1'; // Reusing the fixtures' own placeholder identities as real test principals // where possible — nothing requires DID-shaped entityIds for a record-level @@ -152,11 +155,14 @@ afterEach(async () => { const accountedFor = new Set(); function assertCoverage(names: string[], handled: Set, skipped: Set) { + // Recorded before the assertions below, not after: a block that fails its + // own coverage would otherwise report nothing, and the whole-package test + // at the end would blame it for every fixture it does dispatch. + for (const name of [...handled, ...skipped]) accountedFor.add(name); for (const name of names) { expect(handled.has(name) || skipped.has(name)).toBe(true); } expect(names.length).toBe(handled.size + skipped.size); - for (const name of [...handled, ...skipped]) accountedFor.add(name); } // ------------------------------------------------------- @@ -1887,6 +1893,100 @@ describe('error response fixtures', () => { expectError(status, data, fixture); }); + // The grantee arms, each refused at the write. All seven are plain + // POST /records bodies, so they share one shape: mint a fresh id and + // dispatch. What differs is which part of the grantee names nobody. + for (const name of [ + 'error-validation-grant-entity-grantee-without-entity-id', + 'error-validation-grant-entity-grantee-with-empty-entity-id', + 'error-validation-grant-group-grantee-without-group-id', + 'error-validation-grant-group-grantee-with-listing-only-role', + 'error-validation-grant-grantee-with-unknown-kind', + 'error-validation-grant-null-grantee', + ]) { + test(name, async () => { + const fixture = find(name); + const body = withFreshId(fixture.requestBody as WireRecord); + const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body); + expectError(status, data, fixture); + }); + } + + test('error-validation-anyone-element-labelled-write', async () => { + const fixture = find('error-validation-anyone-element-labelled-write'); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + const { status, data } = await dispatch( + fixture, + TEST_TOKEN, + `/records/${record.id}/permissions`, + ); + expectError(status, data, fixture); + }); + + test('error-permission-grant-on-an-ungrantable-family-confers-nothing', async () => { + const fixture = find('error-permission-grant-on-an-ungrantable-family-confers-nothing'); + // Written directly, because stack.grant() refuses the family outright — + // which is the point: this pins the second half of the rule, where a + // grant that reached storage some other way is read back as conferring + // nothing rather than trusted. + await t.ctx.stack.create(GRANT_TYPE, { + typeId: APP_TYPE, + actions: ['create', 'read-any'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const body = withFreshId(fixture.requestBody as WireRecord); + const { status, data } = await dispatch(fixture, token, undefined, body); + expectError(status, data, fixture); + + // The control, without which this test would pass just as well with no + // grant at all: the same grant shape on a grantable family does confer + // the create, so the refusal above is the family's and not the absence + // of any machinery to read. + await t.ctx.stack.create(GRANT_TYPE, { + typeId: COMMENT_TYPE, + actions: ['create', 'read-any'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const control = await req(t.app, 'POST', '/records', { + token, + body: { id: generateId(), typeId: COMMENT_TYPE, content: { body: 'allowed' } }, + }); + expect(control.status).toBe(200); + }); + + test('error-not-found-mutate-grant-with-no-read-companion', async () => { + const fixture = find('error-not-found-mutate-grant-with-no-read-companion'); + // update-any with no read-any beside it: the grant conveys neither the + // write nor a read, so the refusal is the disclosure rule's 404 rather + // than a 403. Written directly for the same reason as above — the + // companion rule is enforced where the grant is read, not only where a + // helper wrote it. + await t.ctx.stack.create(GRANT_TYPE, { + typeId: NOTE_TYPE, + actions: ['create', 'update-any'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: "someone else's" }); + const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID); + const { status, data } = await dispatch(fixture, token, `/records/${record.id}`); + expectError(status, data, fixture); + + // The control: add the read companion the grant was missing and the very + // same PATCH succeeds. Without it this test would pass against a server + // that had no grant for this requester at all, since both answer 404. + await t.ctx.stack.create(GRANT_TYPE, { + typeId: NOTE_TYPE, + actions: ['update-any', 'read-any'], + grantee: { kind: 'entity', entityId: CONTRIBUTOR_ID }, + }); + const control = await req(t.app, 'PATCH', `/records/${record.id}`, { + token, + body: fixture.requestBody, + }); + expect(control.status).toBe(200); + }); + test('error-query-permission-kind-in-associations-key', async () => { const fixture = find('error-query-permission-kind-in-associations-key'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); @@ -3099,6 +3199,9 @@ describe('fixture package coverage', () => { test('every exported fixture array is dispatched by a block above', () => { const missing: string[] = []; for (const [exportName, value] of Object.entries(conformanceFixtures)) { + // A union of the arrays beside it: every name in it is already + // reported under the export that owns it. + if (exportName === 'allConformanceFixtures') continue; if (!isFixtureArray(value)) continue; for (const fixture of value) { if (!accountedFor.has(fixture.name)) missing.push(`${exportName}: ${fixture.name}`);