Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
queryRecordsFixtures,
patchContentFixtures,
deleteRecordFixtures,
deleteRecordSequenceFixtures,
undeleteRecordFixtures,
associateFixtures,
dissociateFixtures,
Expand All @@ -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';
Expand Down Expand Up @@ -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<string>();

function assertCoverage(names: string[], handled: Set<string>, skipped: Set<string>) {
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);
}

// -------------------------------------------------------
Expand Down Expand Up @@ -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(),
);
Expand Down Expand Up @@ -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([]);
});
});
148 changes: 148 additions & 0 deletions tests/routes/changes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
});
Loading
Loading