diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index c69da17..d8131ed 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -35,6 +35,7 @@ import { queryRecordsFixtures, patchContentFixtures, deleteRecordFixtures, + deleteRecordSequenceFixtures, undeleteRecordFixtures, associateFixtures, dissociateFixtures, @@ -60,6 +61,7 @@ import { AUTH_FIXTURE_DID, AUTH_FIXTURE_NONCE, } from '@haverstack/conformance-fixtures'; +import * as conformanceFixtures from '@haverstack/conformance-fixtures'; import type { WireRecord, WireJournalResponse } from '@haverstack/wire-types'; import { generateId, hashSchema } from '@haverstack/core'; import type { DataAssociation } from '@haverstack/core'; @@ -141,11 +143,20 @@ afterEach(async () => { await t.cleanup(); }); +/** + * Every fixture name any block claimed, pooled across the file. A block's + * own coverage test answers "did this block handle its array"; the pooled + * set is what answers "is there an array no block reads at all", which no + * per-block check can see. See the whole-package test at the end. + */ +const accountedFor = new Set(); + function assertCoverage(names: string[], handled: Set, skipped: Set) { 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); } // ------------------------------------------------------- @@ -681,9 +692,53 @@ describe('deleteRecord fixtures', () => { expect(after.status).toBe(404); }); - test('coverage', () => { + // A write landing immediately before the purge, which the purge's own + // response must still name: what it pins is that the body is read at + // destruction time, not from a snapshot taken earlier in the request. + // Both fileIds are real uploads rather than the fixture's literal ones — + // an attachment association names a stored file, so the shape of the + // claim travels and the hashes cannot. + test('hard-delete-under-concurrent-write', async () => { + const sequence = deleteRecordSequenceFixtures.find( + (f) => f.name === 'hard-delete-under-concurrent-write', + )!; + handled.add(sequence.name); + const [cover, late] = await Promise.all([ + t.ctx.stack.putAttachment(new Uint8Array([1, 2, 3]), 'image/png', 'cover.png'), + t.ctx.stack.putAttachment(new Uint8Array([4, 5, 6]), 'image/png', 'late.png'), + ]); + const coverFileId = (cover.content as { fileId: string }).fileId; + const lateFileId = (late.content as { fileId: string }).fileId; + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'x' }, + { + associations: [ + { kind: 'attachment', label: 'cover', fileId: coverFileId, attachmentRecordId: cover.id }, + ], + }, + ); + + const [addStep, purgeStep] = sequence.steps; + const added = await req(t.app, addStep!.method, `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { kind: 'attachment', label: 'late-arrival', fileId: lateFileId }, + }); + expect(added.status).toBe(addStep!.responseStatus); + + const purged = await req(t.app, purgeStep!.method, `/records/${record.id}?hard=true`, { + token: TEST_TOKEN, + }); + expect(purged.status).toBe(purgeStep!.responseStatus); + const associations = (purged.data as { associations?: Array<{ label: string }> }).associations; + expect(associations?.map((a) => a.label).sort()).toEqual(['cover', 'late-arrival']); + }); + + // Last in the block: the sequence fixture above is dispatched after the + // single-request ones, so `handled` is only complete here. + test('coverage: deleteRecordFixtures + deleteRecordSequenceFixtures', () => { assertCoverage( - deleteRecordFixtures.map((f) => f.name), + [...deleteRecordFixtures, ...deleteRecordSequenceFixtures].map((f) => f.name), handled, new Set(), ); @@ -3010,3 +3065,45 @@ describe('attachmentUpload fixtures', () => { ); }); }); + +// ------------------------------------------------------- +// The whole package +// ------------------------------------------------------- + +/** + * Every fixture the package exports reaches a block above. + * + * Each block's own coverage test pins its own array, which leaves one gap + * open by construction: an array this file never imports is guarded by + * nothing, so core can add a whole block and every test here still passes. + * This closes it by discovering the arrays rather than listing them — + * anything shaped like a fixture set is found whether or not a block reads + * it, and an unhandled one names itself in the failure. + */ +describe('fixture package coverage', () => { + /** An exported array of fixtures, told from the package's other exports by shape. */ + function isFixtureArray(value: unknown): value is Array<{ name: string }> { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (entry) => + typeof entry === 'object' && + entry !== null && + typeof (entry as { name?: unknown }).name === 'string' && + ('method' in entry || 'steps' in entry), + ) + ); + } + + test('every exported fixture array is dispatched by a block above', () => { + const missing: string[] = []; + for (const [exportName, value] of Object.entries(conformanceFixtures)) { + if (!isFixtureArray(value)) continue; + for (const fixture of value) { + if (!accountedFor.has(fixture.name)) missing.push(`${exportName}: ${fixture.name}`); + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/tests/routes/changes.test.ts b/tests/routes/changes.test.ts index dd007ec..7a71e5d 100644 --- a/tests/routes/changes.test.ts +++ b/tests/routes/changes.test.ts @@ -580,4 +580,152 @@ describe('GET /changes', () => { } }); }); + + /** + * A subscription is the one place authority is cached across operations + * — ScopedSubscription holds the grant set and resolved roster roles for + * the life of the connection — so it is the one place a withdrawal has to + * arrive as an event to be seen at all. The resume test above covers a + * change that happened while nobody was listening; these cover the + * opposite case, where the connection is open the whole time and the + * cache is what could go stale. See docs/spec/events.md § Permission + * scoping. + */ + describe('permission scoping on a live connection', () => { + it('stops delivering once a type-level grant is withdrawn mid-stream', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'covered by a grant' }); + const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: CONTRIBUTOR_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const { token } = await t.ctx.tokens.createToken(CONTRIBUTOR_ID); + + const conn = await openChangeFeed(t.app, '/changes', { token }); + try { + await conn.waitForFrames(1); // ready + await t.ctx.stack.patchContent(record.id, { title: 'still readable' }); + const [, first] = await conn.waitForFrames(2); + expect((first.data as { recordId: string }).recordId).toBe(record.id); + + // The withdrawal itself is a write to a _grant record, which is what + // expires the connection's cached grant set. + await t.ctx.stack.delete(grantRecord!.id); + await t.ctx.stack.patchContent(record.id, { title: 'no longer readable' }); + + // Nothing further for this record. A frame the subscriber *can* see + // is what proves the feed is still live rather than merely quiet: + // without it, a broken connection would pass this test. + const proof = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'visible' }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, + ); + const frames = await conn.waitForFrames(3); + expect((frames[2]!.data as { recordId: string }).recordId).toBe(proof.id); + } finally { + await conn.close(); + } + }); + + it('starts delivering once a record is shared mid-stream', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'private' }); + const { token } = await t.ctx.tokens.createToken(CONTRIBUTOR_ID); + + const conn = await openChangeFeed(t.app, '/changes', { token }); + try { + await conn.waitForFrames(1); // ready + await t.ctx.stack.patchContent(record.id, { title: 'still private' }); + await expect(conn.waitForFrames(2, 300)).rejects.toThrow(); + + await t.ctx.stack.grantAccess(record.id, { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }); + await t.ctx.stack.patchContent(record.id, { title: 'now shared' }); + + const frames = await conn.waitForFrames(2); + expect((frames[1]!.data as { recordId: string }).recordId).toBe(record.id); + } finally { + await conn.close(); + } + }); + + it('follows a roster change mid-stream, which is a separate cache from the grant set', async () => { + const group = await t.ctx.stack.create( + '_group@1', + { name: 'Editors' }, + { + associations: [ + { + kind: 'relationship', + label: 'admin', + target: { scope: 'entity', entityId: t.ctx.stack.ownerEntityId }, + }, + { + kind: 'relationship', + label: 'member', + target: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, + ); + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'group-readable' }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'group', groupId: group.id, role: 'member' }, + }, + ], + }, + ); + const { token } = await t.ctx.tokens.createToken(CONTRIBUTOR_ID); + + const conn = await openChangeFeed(t.app, '/changes', { token }); + try { + await conn.waitForFrames(1); // ready + await t.ctx.stack.patchContent(record.id, { title: 'read through the roster' }); + const [, first] = await conn.waitForFrames(2); + expect((first.data as { recordId: string }).recordId).toBe(record.id); + + // Removal is a write to the _group record, which expires the + // connection's resolved roles rather than its grant set. + await t.ctx.stack.dissociate(group.id, { + kind: 'relationship', + label: 'member', + target: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }); + await t.ctx.stack.patchContent(record.id, { title: 'off the roster' }); + + const proof = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'visible' }, + { + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { scope: 'entity', entityId: CONTRIBUTOR_ID }, + }, + ], + }, + ); + const frames = await conn.waitForFrames(3); + expect((frames[2]!.data as { recordId: string }).recordId).toBe(proof.id); + } finally { + await conn.close(); + } + }); + }); }); diff --git a/tests/routes/queryScoping.test.ts b/tests/routes/queryScoping.test.ts new file mode 100644 index 0000000..dbcaa12 --- /dev/null +++ b/tests/routes/queryScoping.test.ts @@ -0,0 +1,263 @@ +/** + * What a query returns, and to whom. + * + * Both query endpoints run on `QueryWorkerPool` — a worker thread with its + * own connection to the same database — so a grant's reach is re-derived + * there rather than inherited from the request thread that accepted the + * call. Nothing else in this suite crosses that boundary: the route tests + * beside this one exercise by-id reads and writes, which stay on the main + * thread, and core's own tests exercise `ScopedStack` in one process. So + * the assertions here are about two things at once — the access rules, and + * that the worker resolves them from storage on every call. + * + * Revocation is the direction that matters most: a cached grant set on the + * worker would answer a query the owner has already withdrawn, and no + * by-id test would notice. + * + * See src/lib/queryWorker/pool.ts, docs/spec/access-control.md + * § Type-level grants and docs/spec/wire-format.md § Bounding query cost. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + buildTestApp, + req, + TEST_TOKEN, + TEST_ENTITY_ID, + OTHER_ENTITY_ID, + type TestApp, +} from '../setup.js'; +import type { StackRecord } from '@haverstack/core'; + +const NOTE_TYPE = 'com.example/note@1'; +const GRANT_TYPE = '_grant@1'; +const GROUP_TYPE = '_group@1'; +/** A third party, so a `-own` grant has someone else's record to not reach. */ +const THIRD_ENTITY_ID = 'did:key:third-entity-id-00000003'; + +let t: TestApp; +beforeEach(async () => { + t = await buildTestApp(); + await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { + title: { kind: 'string', required: true }, + }); +}); +afterEach(async () => { + await t.cleanup(); +}); + +/** A token the auth middleware will resolve to an undelegated session for `did`. */ +async function tokenFor(did: string): Promise { + const { token } = await t.ctx.tokens.createToken(did); + return token; +} + +/** The ids a requester's query answers with, via URL params. */ +async function queryIds( + token: string | undefined, + path = `/records?typeId=${encodeURIComponent(NOTE_TYPE)}`, +) { + const { status, data } = await req(t.app, 'GET', path, { ...(token && { token }) }); + expect(status).toBe(200); + return (data as { records: StackRecord[] }).records.map((r) => r.id).sort(); +} + +/** The same question through the content-filter endpoint, which is a different parser. */ +async function postQueryIds( + token: string | undefined, + filter: Record = { typeId: NOTE_TYPE }, +) { + const { status, data } = await req(t.app, 'POST', '/records/query', { + ...(token && { token }), + body: { filter }, + }); + expect(status).toBe(200); + return (data as { records: StackRecord[] }).records.map((r) => r.id).sort(); +} + +/** One note per author, so every scoping question has a record that must not match. */ +async function seedNotes() { + const owner = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'owner' }, + { entityId: TEST_ENTITY_ID }, + ); + const other = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'other' }, + { entityId: OTHER_ENTITY_ID }, + ); + const third = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'third' }, + { entityId: THIRD_ENTITY_ID }, + ); + return { owner, other, third }; +} + +describe('query scoping across the worker boundary', () => { + it('answers a read-own grantee with their own records and nobody else’s', async () => { + const { owner, other, third } = await seedNotes(); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-own'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + + expect(await queryIds(token)).toEqual([other.id]); + expect(await queryIds(token)).not.toContain(owner.id); + expect(await queryIds(token)).not.toContain(third.id); + }); + + it('widens to every record of the type under read-any', async () => { + const { owner, other, third } = await seedNotes(); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + + expect(await queryIds(token)).toEqual([owner.id, other.id, third.id].sort()); + }); + + it('stops answering on the next query once the grant is withdrawn', async () => { + const { owner } = await seedNotes(); + const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + expect(await queryIds(token)).toContain(owner.id); + + // Withdrawn on the request thread; the worker holds its own connection + // and must see it on the very next call. + await req(t.app, 'DELETE', `/records/${grantRecord!.id}`, { token: TEST_TOKEN }); + expect(await queryIds(token)).not.toContain(owner.id); + }); + + it('confers again once the withdrawn grant is undeleted', async () => { + const { owner } = await seedNotes(); + const [grantRecord] = await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + await req(t.app, 'DELETE', `/records/${grantRecord!.id}`, { token: TEST_TOKEN }); + expect(await queryIds(token)).not.toContain(owner.id); + + await req(t.app, 'POST', `/records/${grantRecord!.id}/undelete`, { token: TEST_TOKEN }); + expect(await queryIds(token)).toContain(owner.id); + }); + + it('withholds a deleted group’s roster from a group-targeted grant', async () => { + const { owner } = await seedNotes(); + const group = await t.ctx.stack.create( + GROUP_TYPE, + { name: 'Editors' }, + { + associations: [ + { + kind: 'relationship', + label: 'admin', + target: { scope: 'entity', entityId: TEST_ENTITY_ID }, + }, + { + kind: 'relationship', + label: 'member', + target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], + }, + ); + await t.ctx.stack.grant({ kind: 'group', groupId: group.id, role: 'member' }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + expect(await queryIds(token)).toContain(owner.id); + + // Deleting the Group is the withdrawal an admin believes it is, and the + // roster resolver on the worker has to read the tombstone as naming + // nobody. See docs/spec/identity.md § Group. + await req(t.app, 'DELETE', `/records/${group.id}`, { token: TEST_TOKEN }); + expect(await queryIds(token)).not.toContain(owner.id); + }); + + it('narrows immediately when a member leaves the roster', async () => { + const { owner } = await seedNotes(); + const group = await t.ctx.stack.create( + GROUP_TYPE, + { name: 'Editors' }, + { + associations: [ + { + kind: 'relationship', + label: 'admin', + target: { scope: 'entity', entityId: TEST_ENTITY_ID }, + }, + { + kind: 'relationship', + label: 'member', + target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + ], + }, + ); + await t.ctx.stack.grant({ kind: 'group', groupId: group.id, role: 'member' }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + expect(await queryIds(token)).toContain(owner.id); + + await req(t.app, 'POST', `/records/${group.id}/associations/delete`, { + token: TEST_TOKEN, + body: { + kind: 'relationship', + label: 'member', + target: { scope: 'entity', entityId: OTHER_ENTITY_ID }, + }, + }); + expect(await queryIds(token)).not.toContain(owner.id); + }); + + it('never enumerates a _grant record for a grantee', async () => { + await seedNotes(); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + + // A _grant carries no entityId and no permissions, so no grant on + // another type — and no `-own` reading — reaches one. + expect(await queryIds(token, `/records?typeId=${encodeURIComponent(GRANT_TYPE)}`)).toEqual([]); + expect( + (await queryIds(TEST_TOKEN, `/records?typeId=${encodeURIComponent(GRANT_TYPE)}`)).length, + ).toBeGreaterThan(0); + }); + + it('answers both query encodings with the same set', async () => { + await seedNotes(); + await t.ctx.stack.grant({ kind: 'entity', entityId: OTHER_ENTITY_ID }, [ + { typeId: NOTE_TYPE, actions: ['read-own'] }, + ]); + const token = await tokenFor(OTHER_ENTITY_ID); + + // One scoping rule, two parsers: a content filter must not widen what + // the URL-param encoding of the same question returns. + expect(await postQueryIds(token)).toEqual(await queryIds(token)); + }); + + it('answers an anonymous requester with world-readable records only', async () => { + const { owner, other } = await seedNotes(); + await t.ctx.stack.grantAccess(owner.id, { kind: 'anyone', label: 'read' }); + + expect(await queryIds(undefined)).toEqual([owner.id]); + expect(await queryIds(undefined)).not.toContain(other.id); + }); + + it('does not let a default grant reach an anonymous requester', async () => { + await seedNotes(); + // `{ kind: 'authenticated' }` is every entity that turned up with a DID, + // which is never the anonymous view. + await t.ctx.stack.grant({ kind: 'authenticated' }, [ + { typeId: NOTE_TYPE, actions: ['read-any'] }, + ]); + + expect(await queryIds(undefined)).toEqual([]); + expect((await queryIds(await tokenFor(THIRD_ENTITY_ID))).length).toBe(3); + }); +});