@haverstack/server 0.8.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/.claude/settings.local.json +22 -0
- package/.dockerignore +10 -0
- package/.env.example +90 -0
- package/.github/pull_request_template.md +23 -0
- package/.github/workflows/ci.yml +55 -0
- package/.github/workflows/release.yml +99 -0
- package/.prettierignore +3 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +91 -0
- package/CONTRIBUTING.md +234 -0
- package/Dockerfile +30 -0
- package/LICENSE +121 -0
- package/README.md +107 -0
- package/SECURITY.md +54 -0
- package/docs/api.md +277 -0
- package/docs/deployment.md +212 -0
- package/eslint.config.mjs +18 -0
- package/package.json +42 -0
- package/pnpm-workspace.yaml +13 -0
- package/src/app.ts +86 -0
- package/src/config.ts +176 -0
- package/src/index.ts +54 -0
- package/src/lib/changeStreams.ts +23 -0
- package/src/lib/frameGate.ts +42 -0
- package/src/lib/json.ts +27 -0
- package/src/lib/nonceStore.ts +103 -0
- package/src/lib/queryLimit.ts +11 -0
- package/src/lib/queryWorker/pool.ts +324 -0
- package/src/lib/queryWorker/protocol.ts +39 -0
- package/src/lib/queryWorker/worker.ts +51 -0
- package/src/lib/resumeBuffer.ts +268 -0
- package/src/lib/resumeCursor.ts +48 -0
- package/src/lib/tokenSweep.ts +43 -0
- package/src/middleware/auth.ts +77 -0
- package/src/middleware/errors.ts +73 -0
- package/src/routes/attachments.ts +126 -0
- package/src/routes/auth.ts +137 -0
- package/src/routes/changes.ts +279 -0
- package/src/routes/entity.ts +33 -0
- package/src/routes/health.ts +8 -0
- package/src/routes/records.ts +259 -0
- package/src/routes/tokens.ts +85 -0
- package/src/routes/types.ts +57 -0
- package/src/routes/wellknown.ts +55 -0
- package/src/shutdown.ts +56 -0
- package/src/stack.ts +96 -0
- package/src/types.ts +9 -0
- package/src/wireError.ts +21 -0
- package/tests/app.test.ts +86 -0
- package/tests/changeFeedClient.ts +156 -0
- package/tests/config.test.ts +112 -0
- package/tests/conformance.test.ts +2322 -0
- package/tests/fixtures/blockingWorker.ts +27 -0
- package/tests/fixtures/brokenWorker.ts +7 -0
- package/tests/lib/changeFeedClient.test.ts +126 -0
- package/tests/lib/frameGate.test.ts +93 -0
- package/tests/lib/queryLimit.test.ts +33 -0
- package/tests/lib/queryWorker.test.ts +60 -0
- package/tests/lib/queryWorkerPool.test.ts +129 -0
- package/tests/lib/resumeBuffer.test.ts +341 -0
- package/tests/lib/resumeCursor.test.ts +46 -0
- package/tests/middleware/errors.test.ts +176 -0
- package/tests/routes/associations.test.ts +170 -0
- package/tests/routes/attachments.test.ts +621 -0
- package/tests/routes/auth.test.ts +448 -0
- package/tests/routes/changes.test.ts +567 -0
- package/tests/routes/contentQuery.test.ts +68 -0
- package/tests/routes/entity.test.ts +170 -0
- package/tests/routes/groups.test.ts +69 -0
- package/tests/routes/records.test.ts +1965 -0
- package/tests/routes/relatedToQuery.test.ts +182 -0
- package/tests/routes/tokens.test.ts +157 -0
- package/tests/routes/tombstones.test.ts +93 -0
- package/tests/routes/types.test.ts +129 -0
- package/tests/routes/versions.test.ts +177 -0
- package/tests/routes/wellknown.test.ts +71 -0
- package/tests/setup.ts +154 -0
- package/tests/shutdown.test.ts +121 -0
- package/tests/stack.test.ts +198 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,2322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the shared @haverstack/conformance-fixtures suite against this
|
|
3
|
+
* server's real HTTP surface. A companion to the hand-written route tests
|
|
4
|
+
* under tests/routes/, not a replacement: each fixture pins one documented
|
|
5
|
+
* wire-contract point (a status code, an error code, a merge/versioning
|
|
6
|
+
* rule), so a fixture that starts failing here means this server and the
|
|
7
|
+
* documented contract have diverged.
|
|
8
|
+
*
|
|
9
|
+
* Server-generated values (ids left unspecified, timestamps, opaque
|
|
10
|
+
* pagination cursors) are never compared for literal equality against a
|
|
11
|
+
* fixture's illustrative placeholders — @haverstack/adapter-api's own
|
|
12
|
+
* consumption of these same fixtures (packages/adapter-api/tests/
|
|
13
|
+
* conformance.test.ts in haverstack/core) takes the same targeted-field
|
|
14
|
+
* approach rather than deep-equating whole response bodies.
|
|
15
|
+
*
|
|
16
|
+
* Every fixture in each imported array is either dispatched below or
|
|
17
|
+
* named in that describe block's SKIPPED set with a reason. Each block's
|
|
18
|
+
* final "coverage" test fails loudly if core adds, removes, or renames a
|
|
19
|
+
* fixture this file hasn't been told about — that's what makes this an
|
|
20
|
+
* acceptance gate rather than a snapshot of one day's fixture list.
|
|
21
|
+
*
|
|
22
|
+
* Only two fixtures are skipped, and neither is a gap: each states in its
|
|
23
|
+
* own description that it pins a *client* obligation rather than a server
|
|
24
|
+
* one, and neither can be dispatched against a conformant server without
|
|
25
|
+
* forging the response it checks. Everything else — including the cases
|
|
26
|
+
* that need a second principal, a readable attachment reference, an
|
|
27
|
+
* adapter-level write, or a server configured with a deadline no query can
|
|
28
|
+
* meet — is dispatched.
|
|
29
|
+
*/
|
|
30
|
+
import { Hono } from 'hono';
|
|
31
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
32
|
+
import {
|
|
33
|
+
discoveryFixtures,
|
|
34
|
+
createRecordFixtures,
|
|
35
|
+
queryRecordsFixtures,
|
|
36
|
+
patchContentFixtures,
|
|
37
|
+
deleteRecordFixtures,
|
|
38
|
+
undeleteRecordFixtures,
|
|
39
|
+
associateFixtures,
|
|
40
|
+
dissociateFixtures,
|
|
41
|
+
permissionsChangeFixtures,
|
|
42
|
+
getVersionsFixtures,
|
|
43
|
+
getVersionFixtures,
|
|
44
|
+
getVersionsAfterMutateFixtures,
|
|
45
|
+
restoreVersionFixtures,
|
|
46
|
+
commitMigrationFixtures,
|
|
47
|
+
errorResponseFixtures,
|
|
48
|
+
authChallengeFixtures,
|
|
49
|
+
authTokenFixtures,
|
|
50
|
+
authSequenceFixtures,
|
|
51
|
+
changeFeedFixtures,
|
|
52
|
+
changeFeedSequenceFixtures,
|
|
53
|
+
unlistedChangeFixtures,
|
|
54
|
+
parentChangeFixtures,
|
|
55
|
+
attachmentDownloadFixtures,
|
|
56
|
+
attachmentUploadFixtures,
|
|
57
|
+
AUTH_FIXTURE_DID,
|
|
58
|
+
AUTH_FIXTURE_NONCE,
|
|
59
|
+
} from '@haverstack/conformance-fixtures';
|
|
60
|
+
import type { WireRecord } from '@haverstack/wire-types';
|
|
61
|
+
import { generateId, hashSchema } from '@haverstack/core';
|
|
62
|
+
import type { Association } from '@haverstack/core';
|
|
63
|
+
import {
|
|
64
|
+
buildTestApp,
|
|
65
|
+
req,
|
|
66
|
+
testConfig,
|
|
67
|
+
TEST_TOKEN,
|
|
68
|
+
TEST_ENTITY_ID,
|
|
69
|
+
logger,
|
|
70
|
+
type TestApp,
|
|
71
|
+
} from './setup.js';
|
|
72
|
+
import { openChangeFeed, type DecodedFrame } from './changeFeedClient.js';
|
|
73
|
+
import { changeRoutes } from '../src/routes/changes.js';
|
|
74
|
+
import { authMiddleware } from '../src/middleware/auth.js';
|
|
75
|
+
import { createApp } from '../src/app.js';
|
|
76
|
+
import type { AppEnv } from '../src/types.js';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fixture ids embed a fixed, long-past timestamp (they were authored once
|
|
80
|
+
* and never touched again); this server's ScopedStack.create() rejects any
|
|
81
|
+
* client-supplied id whose embedded timestamp is outside a clock-skew
|
|
82
|
+
* tolerance of "now" (default 24h — see validateIdTimestampSkew() in
|
|
83
|
+
* @haverstack/core). A literal fixture id would fail that freshness check
|
|
84
|
+
* before ever reaching the behavior the fixture is actually pinning, so
|
|
85
|
+
* every dispatched create swaps in a freshly generated id instead.
|
|
86
|
+
*
|
|
87
|
+
* Fixture bodies carry the same long-past `createdAt`/`updatedAt` (a
|
|
88
|
+
* WireRecord is a whole record). Those are stripped too: an owner-token
|
|
89
|
+
* dispatch backdates for real, so a stale `createdAt` beside the fresh id
|
|
90
|
+
* above would fail the skew check these fixtures aren't testing. A fixture
|
|
91
|
+
* that *is* testing backdating passes its own matching pair instead.
|
|
92
|
+
*/
|
|
93
|
+
function withFreshId<T extends { id?: string; createdAt?: unknown; updatedAt?: unknown }>(
|
|
94
|
+
body: T,
|
|
95
|
+
): Omit<T, 'createdAt' | 'updatedAt'> & { id: string } {
|
|
96
|
+
const rest = { ...body };
|
|
97
|
+
delete rest.createdAt;
|
|
98
|
+
delete rest.updatedAt;
|
|
99
|
+
return { ...rest, id: generateId() };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const NOTE_TYPE = 'com.example/note@1';
|
|
103
|
+
const NOTE_TYPE_V2 = 'com.example/note@2';
|
|
104
|
+
const COMMENT_TYPE = 'com.example/comment@1';
|
|
105
|
+
const ATTACHMENT_TYPE = '_attachment@1';
|
|
106
|
+
|
|
107
|
+
// Reusing the fixtures' own placeholder identities as real test principals
|
|
108
|
+
// where possible — nothing requires DID-shaped entityIds for a record-level
|
|
109
|
+
// permission entry or a minted token's principal, so fixture and harness
|
|
110
|
+
// agree on more than just shape.
|
|
111
|
+
const CONTRIBUTOR_ID = 'entity-contributor-789';
|
|
112
|
+
const WRITER_ID = 'entity-writer-101112';
|
|
113
|
+
const BLOG_APP_ID = 'com.example.blog';
|
|
114
|
+
const BLOG_SUBJECT_ID = 'entity-blog-subject-131415';
|
|
115
|
+
|
|
116
|
+
async function seedTypes(ctx: TestApp['ctx']) {
|
|
117
|
+
await ctx.stack.defineType(NOTE_TYPE, 'Note', {
|
|
118
|
+
title: { kind: 'string' },
|
|
119
|
+
body: { kind: 'text' },
|
|
120
|
+
pinned: { kind: 'boolean' },
|
|
121
|
+
});
|
|
122
|
+
await ctx.stack.defineType(NOTE_TYPE_V2, 'Note', {
|
|
123
|
+
title: { kind: 'string' },
|
|
124
|
+
pinned: { kind: 'boolean' },
|
|
125
|
+
});
|
|
126
|
+
await ctx.stack.defineType(COMMENT_TYPE, 'Comment', {
|
|
127
|
+
body: { kind: 'text', required: true },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let t: TestApp;
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
t = await buildTestApp();
|
|
134
|
+
await seedTypes(t.ctx);
|
|
135
|
+
});
|
|
136
|
+
afterEach(async () => {
|
|
137
|
+
await t.cleanup();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function assertCoverage(names: string[], handled: Set<string>, skipped: Set<string>) {
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
expect(handled.has(name) || skipped.has(name)).toBe(true);
|
|
143
|
+
}
|
|
144
|
+
expect(names.length).toBe(handled.size + skipped.size);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -------------------------------------------------------
|
|
148
|
+
// Discovery
|
|
149
|
+
// -------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('discovery fixtures', () => {
|
|
152
|
+
const SKIPPED = new Set([
|
|
153
|
+
// Not a requirement this server can fail: the fixture describes a
|
|
154
|
+
// *different* conformant server, one that neither resumes nor includes
|
|
155
|
+
// records. This one does both — src/routes/wellknown.ts writes both
|
|
156
|
+
// flags as literals precisely so discovery can't contradict the route
|
|
157
|
+
// beside it, on a field clients act on without asking again — so the
|
|
158
|
+
// only way to produce this response would be an override that lets it.
|
|
159
|
+
// The obligation the fixture exists to pin is the client's.
|
|
160
|
+
'discovery-advertises-a-feed-that-neither-resumes-nor-includes-records',
|
|
161
|
+
]);
|
|
162
|
+
const handled = new Set<string>();
|
|
163
|
+
|
|
164
|
+
test('discovery-declares-protocol-version-and-capabilities', async () => {
|
|
165
|
+
const fixture = discoveryFixtures.find(
|
|
166
|
+
(f) => f.name === 'discovery-declares-protocol-version-and-capabilities',
|
|
167
|
+
)!;
|
|
168
|
+
handled.add(fixture.name);
|
|
169
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path);
|
|
170
|
+
expect(status).toBe(fixture.responseStatus);
|
|
171
|
+
const d = data as Record<string, unknown>;
|
|
172
|
+
expect(typeof d.version).toBe('string');
|
|
173
|
+
expect((d.version as string).split('.')[0]).toBe(
|
|
174
|
+
(fixture.responseBody!.version as string).split('.')[0],
|
|
175
|
+
);
|
|
176
|
+
expect(typeof d.entityId).toBe('string');
|
|
177
|
+
const capabilities = d.capabilities as { sort: { fields: unknown } };
|
|
178
|
+
expect(Array.isArray(capabilities.sort.fields)).toBe(true);
|
|
179
|
+
for (const field of fixture.responseBody!.capabilities!.sort!.fields!) {
|
|
180
|
+
expect(capabilities.sort.fields).toContain(field);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('discovery-omits-absent-timezone', async () => {
|
|
185
|
+
const fixture = discoveryFixtures.find((f) => f.name === 'discovery-omits-absent-timezone')!;
|
|
186
|
+
handled.add(fixture.name);
|
|
187
|
+
const noTzApp = await buildTestApp({ timezone: undefined });
|
|
188
|
+
try {
|
|
189
|
+
const { status, data } = await req(noTzApp.app, fixture.method, fixture.path);
|
|
190
|
+
expect(status).toBe(fixture.responseStatus);
|
|
191
|
+
expect('timezone' in (data as Record<string, unknown>)).toBe(false);
|
|
192
|
+
} finally {
|
|
193
|
+
await noTzApp.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('discovery-advertises-did-challenge-auth', async () => {
|
|
198
|
+
const fixture = discoveryFixtures.find(
|
|
199
|
+
(f) => f.name === 'discovery-advertises-did-challenge-auth',
|
|
200
|
+
)!;
|
|
201
|
+
handled.add(fixture.name);
|
|
202
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path);
|
|
203
|
+
expect(status).toBe(fixture.responseStatus);
|
|
204
|
+
expect((data as { auth?: { methods: string[] } }).auth).toEqual(fixture.responseBody!.auth);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('discovery-advertises-a-change-feed', async () => {
|
|
208
|
+
const fixture = discoveryFixtures.find((f) => f.name === 'discovery-advertises-a-change-feed')!;
|
|
209
|
+
handled.add(fixture.name);
|
|
210
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path);
|
|
211
|
+
expect(status).toBe(fixture.responseStatus);
|
|
212
|
+
const changes = (data as { changes?: Record<string, unknown> }).changes;
|
|
213
|
+
expect(changes).toBeDefined();
|
|
214
|
+
expect(changes!.transports).toEqual(['sse']);
|
|
215
|
+
// Both mirror what GET /changes actually does: it mints cursors and
|
|
216
|
+
// honors Last-Event-ID/?since=, and it honors `?include=record`.
|
|
217
|
+
expect(changes!.resume).toBe(true);
|
|
218
|
+
expect(changes!.records).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('coverage', () => {
|
|
222
|
+
assertCoverage(
|
|
223
|
+
discoveryFixtures.map((f) => f.name),
|
|
224
|
+
handled,
|
|
225
|
+
SKIPPED,
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// -------------------------------------------------------
|
|
231
|
+
// Records: create
|
|
232
|
+
// -------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
describe('createRecord fixtures', () => {
|
|
235
|
+
// Every fixture in this block is dispatched — nothing here is skipped.
|
|
236
|
+
const SKIPPED = new Set<string>();
|
|
237
|
+
const handled = new Set<string>();
|
|
238
|
+
|
|
239
|
+
test('create-record — owner-authored, echoed back verbatim', async () => {
|
|
240
|
+
const fixture = createRecordFixtures.find((f) => f.name === 'create-record')!;
|
|
241
|
+
handled.add(fixture.name);
|
|
242
|
+
const body = withFreshId(fixture.requestBody as WireRecord);
|
|
243
|
+
const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
|
|
244
|
+
expect(status).toBe(fixture.responseStatus);
|
|
245
|
+
const d = data as Record<string, unknown>;
|
|
246
|
+
expect(d.id).toBe(body.id);
|
|
247
|
+
expect(d.typeId).toBe(body.typeId);
|
|
248
|
+
expect(d.content).toEqual(body.content);
|
|
249
|
+
expect(d.version).toBe(1);
|
|
250
|
+
// The owner token acts as the owner entity itself, so entityId is
|
|
251
|
+
// stamped to it — undelegated, so principalId stays absent.
|
|
252
|
+
expect(d.entityId).toBe(TEST_ENTITY_ID);
|
|
253
|
+
expect(d.principalId).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('create-record-ignores-client-supplied-entity-and-principal', async () => {
|
|
257
|
+
const fixture = createRecordFixtures.find(
|
|
258
|
+
(f) => f.name === 'create-record-ignores-client-supplied-entity-and-principal',
|
|
259
|
+
)!;
|
|
260
|
+
handled.add(fixture.name);
|
|
261
|
+
const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string });
|
|
262
|
+
await t.ctx.stack.grant(CONTRIBUTOR_ID, [{ actions: ['create'], typeId: body.typeId }]);
|
|
263
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
264
|
+
const { status, data } = await req(t.app, 'POST', fixture.path, { token, body });
|
|
265
|
+
expect(status).toBe(fixture.responseStatus);
|
|
266
|
+
const d = data as Record<string, unknown>;
|
|
267
|
+
expect(d.content).toEqual(body.content);
|
|
268
|
+
// entityId/principalId are stamped from the session, never the body.
|
|
269
|
+
expect(d.entityId).toBe(CONTRIBUTOR_ID);
|
|
270
|
+
expect(d.principalId).toBeUndefined();
|
|
271
|
+
// appId is the deliberate exception: self-reported, honored verbatim.
|
|
272
|
+
expect(d.appId).toBe(body.appId);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('create-record-response-carries-principal-under-delegation', async () => {
|
|
276
|
+
const fixture = createRecordFixtures.find(
|
|
277
|
+
(f) => f.name === 'create-record-response-carries-principal-under-delegation',
|
|
278
|
+
)!;
|
|
279
|
+
handled.add(fixture.name);
|
|
280
|
+
const body = withFreshId(fixture.requestBody as WireRecord & { appId?: string });
|
|
281
|
+
await t.ctx.stack.grant(BLOG_SUBJECT_ID, [{ actions: ['create'], typeId: body.typeId }]);
|
|
282
|
+
await t.ctx.stack.grant(BLOG_APP_ID, [{ actions: ['create'], typeId: body.typeId }]);
|
|
283
|
+
const { token } = await t.ctx.adapter.createToken(BLOG_APP_ID, { onBehalfOf: BLOG_SUBJECT_ID });
|
|
284
|
+
const { status, data } = await req(t.app, 'POST', fixture.path, { token, body });
|
|
285
|
+
expect(status).toBe(fixture.responseStatus);
|
|
286
|
+
const d = data as Record<string, unknown>;
|
|
287
|
+
expect(d.content).toEqual(body.content);
|
|
288
|
+
expect(d.entityId).toBe(BLOG_SUBJECT_ID);
|
|
289
|
+
expect(d.principalId).toBe(BLOG_APP_ID);
|
|
290
|
+
expect(d.appId).toBe(body.appId);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('create-attachment-record-matching-mimetype-succeeds', async () => {
|
|
294
|
+
const fixture = createRecordFixtures.find(
|
|
295
|
+
(f) => f.name === 'create-attachment-record-matching-mimetype-succeeds',
|
|
296
|
+
)!;
|
|
297
|
+
handled.add(fixture.name);
|
|
298
|
+
const body = withFreshId(
|
|
299
|
+
fixture.requestBody as WireRecord & {
|
|
300
|
+
content: { fileId: string; mimeType: string };
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
await t.ctx.stack.create(ATTACHMENT_TYPE, {
|
|
304
|
+
fileId: body.content.fileId,
|
|
305
|
+
mimeType: body.content.mimeType,
|
|
306
|
+
size: 1,
|
|
307
|
+
});
|
|
308
|
+
const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
|
|
309
|
+
expect(status).toBe(fixture.responseStatus);
|
|
310
|
+
expect((data as Record<string, unknown>).content).toEqual(body.content);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('create-attachment-record-non-owner-carve-out-succeeds', async () => {
|
|
314
|
+
const fixture = createRecordFixtures.find(
|
|
315
|
+
(f) => f.name === 'create-attachment-record-non-owner-carve-out-succeeds',
|
|
316
|
+
)!;
|
|
317
|
+
handled.add(fixture.name);
|
|
318
|
+
const body = withFreshId(
|
|
319
|
+
fixture.requestBody as WireRecord & { content: { fileId: string; mimeType: string } },
|
|
320
|
+
);
|
|
321
|
+
// The readable reference the carve-out turns on: an owner-authored Note
|
|
322
|
+
// the contributor may read, carrying an attachment association for the
|
|
323
|
+
// same file. The bytes stay the owner's — the contributor never uploads
|
|
324
|
+
// and never proves possession, which is the whole point of the carve-out.
|
|
325
|
+
const uploaded = await t.ctx.stack.putAttachment(
|
|
326
|
+
new Uint8Array([1, 2, 3]),
|
|
327
|
+
body.content.mimeType,
|
|
328
|
+
'owner.png',
|
|
329
|
+
);
|
|
330
|
+
const fileId = (uploaded.content as { fileId: string }).fileId;
|
|
331
|
+
const note = await t.ctx.stack.create(NOTE_TYPE, { title: 'has a cover' });
|
|
332
|
+
await t.ctx.stack.associate(note.id, { kind: 'attachment', label: 'cover', fileId });
|
|
333
|
+
await t.ctx.stack.grant(CONTRIBUTOR_ID, [
|
|
334
|
+
{ actions: ['read-any'], typeId: NOTE_TYPE },
|
|
335
|
+
{ actions: ['create'], typeId: ATTACHMENT_TYPE },
|
|
336
|
+
]);
|
|
337
|
+
const content = { ...body.content, fileId };
|
|
338
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
339
|
+
const { status, data } = await req(t.app, 'POST', fixture.path, {
|
|
340
|
+
token,
|
|
341
|
+
body: { ...body, content },
|
|
342
|
+
});
|
|
343
|
+
expect(status).toBe(fixture.responseStatus);
|
|
344
|
+
const d = data as Record<string, unknown>;
|
|
345
|
+
// Their own record — own id, own filename — not a dedup of the owner's.
|
|
346
|
+
expect(d.id).toBe(body.id);
|
|
347
|
+
expect(d.content).toEqual(content);
|
|
348
|
+
expect(d.entityId).toBe(CONTRIBUTOR_ID);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('create-record-unlisted — unlistedAt in the create body suppresses enumeration from create time', async () => {
|
|
352
|
+
const fixture = createRecordFixtures.find((f) => f.name === 'create-record-unlisted')!;
|
|
353
|
+
handled.add(fixture.name);
|
|
354
|
+
const body = withFreshId(fixture.requestBody as WireRecord);
|
|
355
|
+
const { status, data } = await req(t.app, 'POST', fixture.path, { token: TEST_TOKEN, body });
|
|
356
|
+
expect(status).toBe(fixture.responseStatus);
|
|
357
|
+
const d = data as Record<string, unknown>;
|
|
358
|
+
expect(d.id).toBe(body.id);
|
|
359
|
+
expect(d.content).toEqual(body.content);
|
|
360
|
+
expect(d.version).toBe(1);
|
|
361
|
+
// Core stamps unlistedAt to the real create time rather than honoring a
|
|
362
|
+
// backdated value from the body it's echoed alongside — presence is
|
|
363
|
+
// what this fixture pins, not the literal value.
|
|
364
|
+
expect(typeof d.unlistedAt).toBe('string');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('coverage', () => {
|
|
368
|
+
assertCoverage(
|
|
369
|
+
createRecordFixtures.map((f) => f.name),
|
|
370
|
+
handled,
|
|
371
|
+
SKIPPED,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// -------------------------------------------------------
|
|
377
|
+
// Records: query — envelope invariants
|
|
378
|
+
// -------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe('queryRecords fixtures', () => {
|
|
381
|
+
// These pin the query *envelope* (records and cursor, and nothing else;
|
|
382
|
+
// cursor, not records.length, signals exhaustion), not the fixtures'
|
|
383
|
+
// literal opaque cursor strings or record ids, which are this server's own
|
|
384
|
+
// pagination implementation detail. Exercised with real seeded data and
|
|
385
|
+
// real returned cursors rather than replayed fixture cursors.
|
|
386
|
+
|
|
387
|
+
test('query-envelope-is-records-and-cursor — POST /records/query and GET /records agree', async () => {
|
|
388
|
+
await t.ctx.stack.create(NOTE_TYPE, { title: 'Readable' });
|
|
389
|
+
const post = await req(t.app, 'POST', '/records/query', {
|
|
390
|
+
token: TEST_TOKEN,
|
|
391
|
+
body: { filter: { typeId: NOTE_TYPE } },
|
|
392
|
+
});
|
|
393
|
+
expect(post.status).toBe(200);
|
|
394
|
+
expect(Object.keys(post.data as object).sort()).toEqual(['cursor', 'records']);
|
|
395
|
+
|
|
396
|
+
const get = await req(t.app, 'GET', `/records?typeId=${encodeURIComponent(NOTE_TYPE)}`, {
|
|
397
|
+
token: TEST_TOKEN,
|
|
398
|
+
});
|
|
399
|
+
expect(get.status).toBe(200);
|
|
400
|
+
expect(Object.keys(get.data as object).sort()).toEqual(['cursor', 'records']);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('query-final-page-closes-the-cursor — paging with limit exhausts to cursor: null', async () => {
|
|
404
|
+
for (let i = 0; i < 3; i++) await t.ctx.stack.create(NOTE_TYPE, { title: `note-${i}` });
|
|
405
|
+
|
|
406
|
+
let cursor: string | null | undefined;
|
|
407
|
+
let seen = 0;
|
|
408
|
+
let pages = 0;
|
|
409
|
+
do {
|
|
410
|
+
const { status, data } = await req(t.app, 'POST', '/records/query', {
|
|
411
|
+
token: TEST_TOKEN,
|
|
412
|
+
body: { filter: { typeId: NOTE_TYPE }, limit: 1, ...(cursor && { cursor }) },
|
|
413
|
+
});
|
|
414
|
+
expect(status).toBe(200);
|
|
415
|
+
const page = data as { records: unknown[]; cursor: string | null };
|
|
416
|
+
seen += page.records.length;
|
|
417
|
+
cursor = page.cursor;
|
|
418
|
+
pages++;
|
|
419
|
+
} while (cursor && pages < 10);
|
|
420
|
+
|
|
421
|
+
expect(cursor).toBeNull();
|
|
422
|
+
expect(seen).toBe(3);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('query-related-to-record-target — relatedTo (+ relatedToLabel) accepts a record-target filter', async () => {
|
|
426
|
+
const fixture = queryRecordsFixtures.find((f) => f.name === 'query-related-to-record-target')!;
|
|
427
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
|
|
428
|
+
expect(status).toBe(fixture.responseStatus);
|
|
429
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
430
|
+
expect(page.records).toEqual([]);
|
|
431
|
+
expect(page.cursor).toBeNull();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('query-related-to-entity-target — relatedToEntity accepts an entity-target filter', async () => {
|
|
435
|
+
const fixture = queryRecordsFixtures.find((f) => f.name === 'query-related-to-entity-target')!;
|
|
436
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
|
|
437
|
+
expect(status).toBe(fixture.responseStatus);
|
|
438
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
439
|
+
expect(page.records).toEqual([]);
|
|
440
|
+
expect(page.cursor).toBeNull();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('query-related-to-external-namespace — relatedToNs alone matches the whole namespace', async () => {
|
|
444
|
+
const fixture = queryRecordsFixtures.find(
|
|
445
|
+
(f) => f.name === 'query-related-to-external-namespace',
|
|
446
|
+
)!;
|
|
447
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
|
|
448
|
+
expect(status).toBe(fixture.responseStatus);
|
|
449
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
450
|
+
expect(page.records).toEqual([]);
|
|
451
|
+
expect(page.cursor).toBeNull();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('query-filters-by-content-presence — filter.contentPresent is accepted', async () => {
|
|
455
|
+
const fixture = queryRecordsFixtures.find(
|
|
456
|
+
(f) => f.name === 'query-filters-by-content-presence',
|
|
457
|
+
)!;
|
|
458
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
459
|
+
token: TEST_TOKEN,
|
|
460
|
+
body: fixture.requestBody,
|
|
461
|
+
});
|
|
462
|
+
expect(status).toBe(fixture.responseStatus);
|
|
463
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
464
|
+
expect(page.records).toEqual([]);
|
|
465
|
+
expect(page.cursor).toBeNull();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('query-sorts-by-a-content-field — sort.contentField is accepted on POST', async () => {
|
|
469
|
+
const fixture = queryRecordsFixtures.find((f) => f.name === 'query-sorts-by-a-content-field')!;
|
|
470
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
471
|
+
token: TEST_TOKEN,
|
|
472
|
+
body: fixture.requestBody,
|
|
473
|
+
});
|
|
474
|
+
expect(status).toBe(fixture.responseStatus);
|
|
475
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
476
|
+
expect(page.records).toEqual([]);
|
|
477
|
+
expect(page.cursor).toBeNull();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('query-content-sort-folds-case-and-accents — a content-field sort request is accepted', async () => {
|
|
481
|
+
const fixture = queryRecordsFixtures.find(
|
|
482
|
+
(f) => f.name === 'query-content-sort-folds-case-and-accents',
|
|
483
|
+
)!;
|
|
484
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
485
|
+
token: TEST_TOKEN,
|
|
486
|
+
body: fixture.requestBody,
|
|
487
|
+
});
|
|
488
|
+
expect(status).toBe(fixture.responseStatus);
|
|
489
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
490
|
+
expect(page.records).toEqual([]);
|
|
491
|
+
expect(page.cursor).toBeNull();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('query-get-sorts-by-a-content-field — ?sortContent= is accepted on GET', async () => {
|
|
495
|
+
const fixture = queryRecordsFixtures.find(
|
|
496
|
+
(f) => f.name === 'query-get-sorts-by-a-content-field',
|
|
497
|
+
)!;
|
|
498
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, { token: TEST_TOKEN });
|
|
499
|
+
expect(status).toBe(fixture.responseStatus);
|
|
500
|
+
const page = data as { records: unknown[]; cursor: unknown };
|
|
501
|
+
expect(page.records).toEqual([]);
|
|
502
|
+
expect(page.cursor).toBeNull();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('coverage', () => {
|
|
506
|
+
// query-empty-page-with-live-cursor and query-get-records-uses-the-same-
|
|
507
|
+
// envelope pin narrower edge cases of the same two invariants (a
|
|
508
|
+
// partial-visibility empty page that isn't exhaustion; GET/POST sharing
|
|
509
|
+
// an envelope) already covered above by construction.
|
|
510
|
+
assertCoverage(
|
|
511
|
+
queryRecordsFixtures.map((f) => f.name),
|
|
512
|
+
new Set([
|
|
513
|
+
'query-envelope-is-records-and-cursor',
|
|
514
|
+
'query-empty-page-with-live-cursor',
|
|
515
|
+
'query-final-page-closes-the-cursor',
|
|
516
|
+
'query-get-records-uses-the-same-envelope',
|
|
517
|
+
'query-related-to-record-target',
|
|
518
|
+
'query-related-to-entity-target',
|
|
519
|
+
'query-related-to-external-namespace',
|
|
520
|
+
'query-filters-by-content-presence',
|
|
521
|
+
'query-sorts-by-a-content-field',
|
|
522
|
+
'query-content-sort-folds-case-and-accents',
|
|
523
|
+
'query-get-sorts-by-a-content-field',
|
|
524
|
+
]),
|
|
525
|
+
new Set(),
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// -------------------------------------------------------
|
|
531
|
+
// Records: patchContent
|
|
532
|
+
// -------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
describe('patchContent fixtures', () => {
|
|
535
|
+
const handled = new Set<string>();
|
|
536
|
+
|
|
537
|
+
test('patch-content-merges-and-adds-fields', async () => {
|
|
538
|
+
const fixture = patchContentFixtures.find(
|
|
539
|
+
(f) => f.name === 'patch-content-merges-and-adds-fields',
|
|
540
|
+
)!;
|
|
541
|
+
handled.add(fixture.name);
|
|
542
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, {
|
|
543
|
+
title: 'original title',
|
|
544
|
+
body: 'original body',
|
|
545
|
+
});
|
|
546
|
+
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
547
|
+
token: TEST_TOKEN,
|
|
548
|
+
body: fixture.requestBody,
|
|
549
|
+
});
|
|
550
|
+
expect(status).toBe(fixture.responseStatus);
|
|
551
|
+
const d = data as Record<string, unknown>;
|
|
552
|
+
expect(d.content).toEqual(fixture.responseBody!.content);
|
|
553
|
+
expect(d.version).toBe(fixture.responseBody!.version);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('patch-content-null-deletes-a-field', async () => {
|
|
557
|
+
const fixture = patchContentFixtures.find(
|
|
558
|
+
(f) => f.name === 'patch-content-null-deletes-a-field',
|
|
559
|
+
)!;
|
|
560
|
+
handled.add(fixture.name);
|
|
561
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, {
|
|
562
|
+
title: 'to be removed',
|
|
563
|
+
body: 'kept',
|
|
564
|
+
});
|
|
565
|
+
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
566
|
+
token: TEST_TOKEN,
|
|
567
|
+
body: fixture.requestBody,
|
|
568
|
+
});
|
|
569
|
+
expect(status).toBe(fixture.responseStatus);
|
|
570
|
+
const d = data as Record<string, unknown>;
|
|
571
|
+
expect(d.content).toEqual(fixture.responseBody!.content);
|
|
572
|
+
expect(d.version).toBe(fixture.responseBody!.version);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('patch-record-restamps-the-actor', async () => {
|
|
576
|
+
const fixture = patchContentFixtures.find((f) => f.name === 'patch-record-restamps-the-actor')!;
|
|
577
|
+
handled.add(fixture.name);
|
|
578
|
+
const record = await t.ctx.stack.create(
|
|
579
|
+
NOTE_TYPE,
|
|
580
|
+
{ title: 'original' },
|
|
581
|
+
{
|
|
582
|
+
entityId: TEST_ENTITY_ID,
|
|
583
|
+
permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }],
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
587
|
+
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
588
|
+
token,
|
|
589
|
+
body: fixture.requestBody,
|
|
590
|
+
});
|
|
591
|
+
expect(status).toBe(fixture.responseStatus);
|
|
592
|
+
const d = data as Record<string, unknown>;
|
|
593
|
+
expect(d.content).toEqual(fixture.responseBody!.content);
|
|
594
|
+
// Authorship (entityId) is untouched by a non-author write; updatedBy
|
|
595
|
+
// moves to the requester who made this edit.
|
|
596
|
+
expect(d.entityId).toBe(TEST_ENTITY_ID);
|
|
597
|
+
expect(d.updatedBy).toBe(CONTRIBUTOR_ID);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test('coverage', () => {
|
|
601
|
+
assertCoverage(
|
|
602
|
+
patchContentFixtures.map((f) => f.name),
|
|
603
|
+
handled,
|
|
604
|
+
new Set(),
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// -------------------------------------------------------
|
|
610
|
+
// Records: delete / undelete
|
|
611
|
+
// -------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
describe('deleteRecord fixtures', () => {
|
|
614
|
+
const handled = new Set<string>();
|
|
615
|
+
|
|
616
|
+
test('delete-record-soft', async () => {
|
|
617
|
+
const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-soft')!;
|
|
618
|
+
handled.add(fixture.name);
|
|
619
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
620
|
+
const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
621
|
+
expect(status).toBe(fixture.responseStatus);
|
|
622
|
+
// Soft-deleted: still fetchable directly, history retained.
|
|
623
|
+
const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
624
|
+
expect(after.status).toBe(200);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('delete-record-hard', async () => {
|
|
628
|
+
const fixture = deleteRecordFixtures.find((f) => f.name === 'delete-record-hard')!;
|
|
629
|
+
handled.add(fixture.name);
|
|
630
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
631
|
+
const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, {
|
|
632
|
+
token: TEST_TOKEN,
|
|
633
|
+
});
|
|
634
|
+
expect(status).toBe(fixture.responseStatus);
|
|
635
|
+
const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
636
|
+
expect(after.status).toBe(404);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test('coverage', () => {
|
|
640
|
+
assertCoverage(
|
|
641
|
+
deleteRecordFixtures.map((f) => f.name),
|
|
642
|
+
handled,
|
|
643
|
+
new Set(),
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
describe('undeleteRecord fixtures', () => {
|
|
649
|
+
const handled = new Set<string>();
|
|
650
|
+
|
|
651
|
+
test('undelete-record — reverses a soft delete and is idempotent', async () => {
|
|
652
|
+
const fixture = undeleteRecordFixtures.find((f) => f.name === 'undelete-record')!;
|
|
653
|
+
handled.add(fixture.name);
|
|
654
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
|
|
655
|
+
await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
656
|
+
|
|
657
|
+
const first = await req(t.app, 'POST', fixture.path.replace('1hk153x00001', record.id), {
|
|
658
|
+
token: TEST_TOKEN,
|
|
659
|
+
});
|
|
660
|
+
expect(first.status).toBe(fixture.responseStatus);
|
|
661
|
+
const firstBody = first.data as Record<string, unknown>;
|
|
662
|
+
expect(firstBody.deletedAt).toBeUndefined();
|
|
663
|
+
|
|
664
|
+
const second = await req(t.app, 'POST', fixture.path.replace('1hk153x00001', record.id), {
|
|
665
|
+
token: TEST_TOKEN,
|
|
666
|
+
});
|
|
667
|
+
expect(second.status).toBe(200);
|
|
668
|
+
expect((second.data as Record<string, unknown>).deletedAt).toBeUndefined();
|
|
669
|
+
expect((second.data as Record<string, unknown>).content).toEqual(firstBody.content);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('coverage', () => {
|
|
673
|
+
assertCoverage(
|
|
674
|
+
undeleteRecordFixtures.map((f) => f.name),
|
|
675
|
+
handled,
|
|
676
|
+
new Set(),
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// -------------------------------------------------------
|
|
682
|
+
// Associations
|
|
683
|
+
// -------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
describe('associate fixtures', () => {
|
|
686
|
+
const handled = new Set<string>();
|
|
687
|
+
|
|
688
|
+
test('associate-tag', async () => {
|
|
689
|
+
const fixture = associateFixtures.find((f) => f.name === 'associate-tag')!;
|
|
690
|
+
handled.add(fixture.name);
|
|
691
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
692
|
+
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
693
|
+
token: TEST_TOKEN,
|
|
694
|
+
body: fixture.requestBody,
|
|
695
|
+
});
|
|
696
|
+
expect(status).toBe(fixture.responseStatus);
|
|
697
|
+
const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
|
|
698
|
+
token: TEST_TOKEN,
|
|
699
|
+
});
|
|
700
|
+
expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test('associate-relationship-external-target', async () => {
|
|
704
|
+
const fixture = associateFixtures.find(
|
|
705
|
+
(f) => f.name === 'associate-relationship-external-target',
|
|
706
|
+
)!;
|
|
707
|
+
handled.add(fixture.name);
|
|
708
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
709
|
+
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
710
|
+
token: TEST_TOKEN,
|
|
711
|
+
body: fixture.requestBody,
|
|
712
|
+
});
|
|
713
|
+
expect(status).toBe(fixture.responseStatus);
|
|
714
|
+
const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
|
|
715
|
+
token: TEST_TOKEN,
|
|
716
|
+
});
|
|
717
|
+
// The discriminated target travels verbatim — core validates the union,
|
|
718
|
+
// this server neither flattens nor reshapes it.
|
|
719
|
+
expect((data as { associations: unknown[] }).associations).toEqual([fixture.requestBody]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('coverage', () => {
|
|
723
|
+
assertCoverage(
|
|
724
|
+
associateFixtures.map((f) => f.name),
|
|
725
|
+
handled,
|
|
726
|
+
new Set(),
|
|
727
|
+
);
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
describe('dissociate fixtures', () => {
|
|
732
|
+
const handled = new Set<string>();
|
|
733
|
+
|
|
734
|
+
test('dissociate-tag', async () => {
|
|
735
|
+
const fixture = dissociateFixtures.find((f) => f.name === 'dissociate-tag')!;
|
|
736
|
+
handled.add(fixture.name);
|
|
737
|
+
const record = await t.ctx.stack.create(
|
|
738
|
+
NOTE_TYPE,
|
|
739
|
+
{ title: 'x' },
|
|
740
|
+
{ associations: [fixture.requestBody as Association] },
|
|
741
|
+
);
|
|
742
|
+
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, {
|
|
743
|
+
token: TEST_TOKEN,
|
|
744
|
+
body: fixture.requestBody,
|
|
745
|
+
});
|
|
746
|
+
expect(status).toBe(fixture.responseStatus);
|
|
747
|
+
const { data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
|
|
748
|
+
token: TEST_TOKEN,
|
|
749
|
+
});
|
|
750
|
+
expect((data as { associations: unknown[] }).associations).toEqual([]);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('coverage', () => {
|
|
754
|
+
assertCoverage(
|
|
755
|
+
dissociateFixtures.map((f) => f.name),
|
|
756
|
+
handled,
|
|
757
|
+
new Set(),
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// -------------------------------------------------------
|
|
763
|
+
// Permissions
|
|
764
|
+
// -------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
describe('permissions change fixtures', () => {
|
|
767
|
+
const handled = new Set<string>();
|
|
768
|
+
|
|
769
|
+
test('set-permissions-public', async () => {
|
|
770
|
+
const fixture = permissionsChangeFixtures.find((f) => f.name === 'set-permissions-public')!;
|
|
771
|
+
handled.add(fixture.name);
|
|
772
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
773
|
+
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
774
|
+
token: TEST_TOKEN,
|
|
775
|
+
body: fixture.requestBody,
|
|
776
|
+
});
|
|
777
|
+
expect(status).toBe(fixture.responseStatus);
|
|
778
|
+
const anon = await req(t.app, 'GET', `/records/${record.id}`);
|
|
779
|
+
expect(anon.status).toBe(200);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('set-permissions-empty-is-private', async () => {
|
|
783
|
+
const fixture = permissionsChangeFixtures.find(
|
|
784
|
+
(f) => f.name === 'set-permissions-empty-is-private',
|
|
785
|
+
)!;
|
|
786
|
+
handled.add(fixture.name);
|
|
787
|
+
const record = await t.ctx.stack.create(
|
|
788
|
+
NOTE_TYPE,
|
|
789
|
+
{ title: 'x' },
|
|
790
|
+
{ permissions: [{ access: 'public' }] },
|
|
791
|
+
);
|
|
792
|
+
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
793
|
+
token: TEST_TOKEN,
|
|
794
|
+
body: fixture.requestBody,
|
|
795
|
+
});
|
|
796
|
+
expect(status).toBe(fixture.responseStatus);
|
|
797
|
+
// Anonymous can't tell "made private" from "never existed" — the
|
|
798
|
+
// anti-oracle rule, so 404 + WWW-Authenticate, not 403.
|
|
799
|
+
const anon = await t.app.request(`/records/${record.id}`);
|
|
800
|
+
expect(anon.status).toBe(404);
|
|
801
|
+
expect(anon.headers.get('WWW-Authenticate')).toBe('Bearer');
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test('coverage', () => {
|
|
805
|
+
assertCoverage(
|
|
806
|
+
permissionsChangeFixtures.map((f) => f.name),
|
|
807
|
+
handled,
|
|
808
|
+
new Set(),
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// -------------------------------------------------------
|
|
814
|
+
// Versions: a coordinated lifecycle matching the fixtures' own designed
|
|
815
|
+
// story — get-versions-after-restore/migrate explicitly assume the paired
|
|
816
|
+
// mutating fixture (restore-version / commit-migration) already ran, so
|
|
817
|
+
// this block runs them together against one seeded record rather than in
|
|
818
|
+
// independent isolation.
|
|
819
|
+
// -------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
describe('version lifecycle fixtures', () => {
|
|
822
|
+
const handled = new Set<string>();
|
|
823
|
+
|
|
824
|
+
test('owner sees permissions; non-owner write-holder has them stripped', async () => {
|
|
825
|
+
const ownerFixture = getVersionsFixtures.find(
|
|
826
|
+
(f) => f.name === 'get-versions-owner-includes-permissions',
|
|
827
|
+
)!;
|
|
828
|
+
const writerFixture = getVersionsFixtures.find(
|
|
829
|
+
(f) => f.name === 'get-versions-non-owner-write-holder-strips-permissions',
|
|
830
|
+
)!;
|
|
831
|
+
const singleFixture = getVersionFixtures.find(
|
|
832
|
+
(f) => f.name === 'get-version-single-strips-permissions-for-non-owner',
|
|
833
|
+
)!;
|
|
834
|
+
handled.add(ownerFixture.name);
|
|
835
|
+
handled.add(writerFixture.name);
|
|
836
|
+
handled.add(singleFixture.name);
|
|
837
|
+
|
|
838
|
+
const memberPermission = (ownerFixture.responseBody![0] as { permissions: unknown[] })
|
|
839
|
+
.permissions[0];
|
|
840
|
+
const record = await t.ctx.stack.create(
|
|
841
|
+
NOTE_TYPE,
|
|
842
|
+
{ title: 'original title' },
|
|
843
|
+
{
|
|
844
|
+
permissions: [
|
|
845
|
+
memberPermission as never,
|
|
846
|
+
{ access: 'entity', entityId: WRITER_ID, read: true, write: true },
|
|
847
|
+
],
|
|
848
|
+
},
|
|
849
|
+
);
|
|
850
|
+
// /versions returns historical snapshots, not the current live state —
|
|
851
|
+
// a record still at its original version has no history yet, so bump
|
|
852
|
+
// it once to give version 1 a snapshot to appear in.
|
|
853
|
+
await t.ctx.stack.patchContent(record.id, { title: 'updated title' });
|
|
854
|
+
|
|
855
|
+
const owner = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN });
|
|
856
|
+
expect(owner.status).toBe(ownerFixture.responseStatus);
|
|
857
|
+
const ownerVersions = owner.data as Array<{ permissions?: unknown[] }>;
|
|
858
|
+
expect(ownerVersions[0]!.permissions).toEqual([
|
|
859
|
+
memberPermission,
|
|
860
|
+
{ access: 'entity', entityId: WRITER_ID, read: true, write: true },
|
|
861
|
+
]);
|
|
862
|
+
|
|
863
|
+
const { token: writerToken } = await t.ctx.adapter.createToken(WRITER_ID);
|
|
864
|
+
const writer = await req(t.app, 'GET', `/records/${record.id}/versions`, {
|
|
865
|
+
token: writerToken,
|
|
866
|
+
});
|
|
867
|
+
expect(writer.status).toBe(writerFixture.responseStatus);
|
|
868
|
+
const writerVersions = writer.data as Array<{ permissions?: unknown[] }>;
|
|
869
|
+
expect(writerVersions[0]!.permissions).toBeUndefined();
|
|
870
|
+
|
|
871
|
+
const single = await req(t.app, 'GET', `/records/${record.id}/versions/1`, {
|
|
872
|
+
token: writerToken,
|
|
873
|
+
});
|
|
874
|
+
expect(single.status).toBe(singleFixture.responseStatus);
|
|
875
|
+
expect((single.data as { permissions?: unknown[] }).permissions).toBeUndefined();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test('restore auto-snapshots the pre-restore state; migrate auto-snapshots the pre-migration state', async () => {
|
|
879
|
+
const restoreFixture = restoreVersionFixtures.find((f) => f.name === 'restore-version')!;
|
|
880
|
+
const migrateFixture = commitMigrationFixtures.find((f) => f.name === 'commit-migration')!;
|
|
881
|
+
const afterRestoreFixture = getVersionsAfterMutateFixtures.find(
|
|
882
|
+
(f) => f.name === 'get-versions-after-restore-includes-pre-restore-snapshot',
|
|
883
|
+
)!;
|
|
884
|
+
const afterMigrateFixture = getVersionsAfterMutateFixtures.find(
|
|
885
|
+
(f) => f.name === 'get-versions-after-migrate-includes-pre-migration-snapshot',
|
|
886
|
+
)!;
|
|
887
|
+
handled.add(restoreFixture.name);
|
|
888
|
+
handled.add(migrateFixture.name);
|
|
889
|
+
handled.add(afterRestoreFixture.name);
|
|
890
|
+
handled.add(afterMigrateFixture.name);
|
|
891
|
+
|
|
892
|
+
// v1
|
|
893
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original title' });
|
|
894
|
+
// v2 — an intermediate edit, so v3 (below) isn't restore-version's own
|
|
895
|
+
// pre-restore auto-snapshot colliding with the create itself.
|
|
896
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
897
|
+
token: TEST_TOKEN,
|
|
898
|
+
body: { contentPatch: { title: 'intermediate title' } },
|
|
899
|
+
});
|
|
900
|
+
// v3 — the state restore-version will move away from
|
|
901
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
902
|
+
token: TEST_TOKEN,
|
|
903
|
+
body: { contentPatch: { title: 'title before restore' } },
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// v3 (auto pre-restore snapshot of v2) + v4 (restored content, from v1)
|
|
907
|
+
const restore = await req(t.app, 'POST', `/records/${record.id}/restore/1`, {
|
|
908
|
+
token: TEST_TOKEN,
|
|
909
|
+
});
|
|
910
|
+
expect(restore.status).toBe(restoreFixture.responseStatus);
|
|
911
|
+
const restored = restore.data as Record<string, unknown>;
|
|
912
|
+
expect(restored.content).toEqual(restoreFixture.responseBody!.content);
|
|
913
|
+
expect(restored.version).toBe(4);
|
|
914
|
+
|
|
915
|
+
// v5 (auto pre-migration snapshot of v4 is v4 itself) + new typeId/content
|
|
916
|
+
const migrateBody = migrateFixture.requestBody!;
|
|
917
|
+
const migrate = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
918
|
+
token: TEST_TOKEN,
|
|
919
|
+
body: migrateBody,
|
|
920
|
+
});
|
|
921
|
+
expect(migrate.status).toBe(migrateFixture.responseStatus);
|
|
922
|
+
const migrated = migrate.data as Record<string, unknown>;
|
|
923
|
+
expect(migrated.typeId).toBe(migrateBody.toTypeId);
|
|
924
|
+
expect(migrated.content).toEqual(migrateBody.content);
|
|
925
|
+
expect(migrated.version).toBe(5);
|
|
926
|
+
|
|
927
|
+
const versions = await req(t.app, 'GET', `/records/${record.id}/versions`, {
|
|
928
|
+
token: TEST_TOKEN,
|
|
929
|
+
});
|
|
930
|
+
expect(versions.status).toBe(afterMigrateFixture.responseStatus);
|
|
931
|
+
const versionList = versions.data as Array<{ version: number; typeId: string }>;
|
|
932
|
+
const numbers = versionList.map((v) => v.version);
|
|
933
|
+
// v4 (pre-migration snapshot), v3 (pre-restore snapshot), v1 (original) —
|
|
934
|
+
// matches afterMigrateFixture/afterRestoreFixture's own version lists.
|
|
935
|
+
expect(numbers).toEqual(
|
|
936
|
+
expect.arrayContaining(afterMigrateFixture.responseBody!.map((v) => v.version)),
|
|
937
|
+
);
|
|
938
|
+
expect(numbers).toEqual(
|
|
939
|
+
expect.arrayContaining(afterRestoreFixture.responseBody!.map((v) => v.version)),
|
|
940
|
+
);
|
|
941
|
+
const v4Snapshot = versionList.find((v) => v.version === 4)!;
|
|
942
|
+
expect(v4Snapshot.typeId).toBe(NOTE_TYPE);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('a snapshot carries its container, and a restore puts the record back in it', async () => {
|
|
946
|
+
const snapshotFixture = getVersionFixtures.find(
|
|
947
|
+
(f) => f.name === 'get-version-carries-the-container-the-snapshot-was-taken-in',
|
|
948
|
+
)!;
|
|
949
|
+
const restoreFixture = restoreVersionFixtures.find(
|
|
950
|
+
(f) => f.name === 'restore-version-puts-the-record-back-in-its-old-container',
|
|
951
|
+
)!;
|
|
952
|
+
handled.add(snapshotFixture.name);
|
|
953
|
+
handled.add(restoreFixture.name);
|
|
954
|
+
|
|
955
|
+
const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' });
|
|
956
|
+
// v1, in the container
|
|
957
|
+
const record = await t.ctx.stack.create(
|
|
958
|
+
NOTE_TYPE,
|
|
959
|
+
{ title: 'original title' },
|
|
960
|
+
{ parentId: container.id },
|
|
961
|
+
);
|
|
962
|
+
// v2 — the state the restore below will return to, still in the container
|
|
963
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
964
|
+
token: TEST_TOKEN,
|
|
965
|
+
body: { contentPatch: { title: 'title before restore' } },
|
|
966
|
+
});
|
|
967
|
+
// v3 — moved to the root, which is what gives v2 a container to have been in
|
|
968
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
969
|
+
token: TEST_TOKEN,
|
|
970
|
+
body: { parentId: null },
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const snapshot = await req(t.app, 'GET', `/records/${record.id}/versions/2`, {
|
|
974
|
+
token: TEST_TOKEN,
|
|
975
|
+
});
|
|
976
|
+
expect(snapshot.status).toBe(snapshotFixture.responseStatus);
|
|
977
|
+
const v2 = snapshot.data as { parentId?: string; content: unknown };
|
|
978
|
+
expect(v2.parentId).toBe(container.id);
|
|
979
|
+
expect(v2.content).toEqual(snapshotFixture.responseBody!.content);
|
|
980
|
+
|
|
981
|
+
const restore = await req(t.app, 'POST', `/records/${record.id}/restore/2`, {
|
|
982
|
+
token: TEST_TOKEN,
|
|
983
|
+
});
|
|
984
|
+
expect(restore.status).toBe(restoreFixture.responseStatus);
|
|
985
|
+
const restored = restore.data as { parentId?: string; content: unknown; version: number };
|
|
986
|
+
// A restore settles containment rather than leaving the record where it
|
|
987
|
+
// sits: the snapshot names the container, so the record goes back to it.
|
|
988
|
+
expect(restored.parentId).toBe(container.id);
|
|
989
|
+
expect(restored.content).toEqual(restoreFixture.responseBody!.content);
|
|
990
|
+
expect(restored.version).toBe(4);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('coverage', () => {
|
|
994
|
+
const names = [
|
|
995
|
+
...getVersionsFixtures.map((f) => f.name),
|
|
996
|
+
...getVersionFixtures.map((f) => f.name),
|
|
997
|
+
...getVersionsAfterMutateFixtures.map((f) => f.name),
|
|
998
|
+
...restoreVersionFixtures.map((f) => f.name),
|
|
999
|
+
...commitMigrationFixtures.map((f) => f.name),
|
|
1000
|
+
];
|
|
1001
|
+
assertCoverage(names, handled, new Set());
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// -------------------------------------------------------
|
|
1006
|
+
// Error responses
|
|
1007
|
+
// -------------------------------------------------------
|
|
1008
|
+
|
|
1009
|
+
describe('error response fixtures', () => {
|
|
1010
|
+
// Every fixture in this block is dispatched — nothing here is skipped.
|
|
1011
|
+
const SKIPPED = new Set<string>();
|
|
1012
|
+
const handled = new Set<string>();
|
|
1013
|
+
|
|
1014
|
+
function find(name: string) {
|
|
1015
|
+
const fixture = errorResponseFixtures.find((f) => f.name === name)!;
|
|
1016
|
+
handled.add(name);
|
|
1017
|
+
return fixture;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function dispatch(
|
|
1021
|
+
fixture: (typeof errorResponseFixtures)[number],
|
|
1022
|
+
token: string | undefined,
|
|
1023
|
+
pathOverride?: string,
|
|
1024
|
+
bodyOverride?: unknown,
|
|
1025
|
+
) {
|
|
1026
|
+
const body = bodyOverride !== undefined ? bodyOverride : fixture.requestBody;
|
|
1027
|
+
return req(t.app, fixture.method, pathOverride ?? fixture.path, {
|
|
1028
|
+
token,
|
|
1029
|
+
...(body !== undefined && { body }),
|
|
1030
|
+
...(fixture.requestHeaders && { headers: fixture.requestHeaders }),
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function expectError(
|
|
1035
|
+
status: number,
|
|
1036
|
+
data: unknown,
|
|
1037
|
+
fixture: (typeof errorResponseFixtures)[number],
|
|
1038
|
+
) {
|
|
1039
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1040
|
+
if (fixture.responseBody) {
|
|
1041
|
+
expect((data as { error: { code: string } }).error.code).toBe(
|
|
1042
|
+
(fixture.responseBody as { error: { code: string } }).error.code,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
test('error-permission-denied — can read, no write grant', async () => {
|
|
1048
|
+
const fixture = find('error-permission-denied');
|
|
1049
|
+
// Readability is what earns the 403 (see error-not-found-record-the-
|
|
1050
|
+
// requester-cannot-read below) — a write-only-denied requester still
|
|
1051
|
+
// needs an explicit read grant, or this would hit the anti-oracle 404
|
|
1052
|
+
// instead of the permission check this fixture pins.
|
|
1053
|
+
const record = await t.ctx.stack.create(
|
|
1054
|
+
NOTE_TYPE,
|
|
1055
|
+
{ title: 'x' },
|
|
1056
|
+
{ permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
|
|
1057
|
+
);
|
|
1058
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1059
|
+
const { status, data } = await dispatch(fixture, token, `/records/${record.id}`);
|
|
1060
|
+
expectError(status, data, fixture);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test('error-not-found-record-the-requester-cannot-read — the anti-oracle rule', async () => {
|
|
1064
|
+
const fixture = find('error-not-found-record-the-requester-cannot-read');
|
|
1065
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
1066
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1067
|
+
const { status, data } = await dispatch(fixture, token, `/records/${record.id}`);
|
|
1068
|
+
expectError(status, data, fixture);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('error-validation-permission-write-without-read', async () => {
|
|
1072
|
+
const fixture = find('error-validation-permission-write-without-read');
|
|
1073
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
1074
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
|
|
1075
|
+
expectError(status, data, fixture);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test('error-bad-request-malformed-parent-id', async () => {
|
|
1079
|
+
const fixture = find('error-bad-request-malformed-parent-id');
|
|
1080
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
1081
|
+
// Format is checked before existence, so the empty string answers for
|
|
1082
|
+
// being malformed rather than for naming nothing. It is not a spelling
|
|
1083
|
+
// of the root here — `null` is.
|
|
1084
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
|
|
1085
|
+
expectError(status, data, fixture);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test('error-conflict-parent-does-not-exist', async () => {
|
|
1089
|
+
const fixture = find('error-conflict-parent-does-not-exist');
|
|
1090
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
1091
|
+
// Well-formed and simply names nothing, which is the conflict. Only the
|
|
1092
|
+
// owner sees it: the reference gate exempts them, so the existence check
|
|
1093
|
+
// behind it is theirs to reach. Anyone else gets one indistinguishable
|
|
1094
|
+
// 403 for a destination they cannot read and one that is not there.
|
|
1095
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
|
|
1096
|
+
expectError(status, data, fixture);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test('error-permission-denied-versions-read-only — can read, cannot write', async () => {
|
|
1100
|
+
const fixture = find('error-permission-denied-versions-read-only');
|
|
1101
|
+
const record = await t.ctx.stack.create(
|
|
1102
|
+
NOTE_TYPE,
|
|
1103
|
+
{ title: 'x' },
|
|
1104
|
+
{ permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
|
|
1105
|
+
);
|
|
1106
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1107
|
+
const { status, data } = await dispatch(fixture, token, `/records/${record.id}/versions`);
|
|
1108
|
+
expectError(status, data, fixture);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test('error-permission-denied-attachment-non-owner-create', async () => {
|
|
1112
|
+
const fixture = find('error-permission-denied-attachment-non-owner-create');
|
|
1113
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1114
|
+
const { status, data } = await dispatch(fixture, token);
|
|
1115
|
+
expectError(status, data, fixture);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
test('create-attachment-record-non-owner-without-carve-out-refused', async () => {
|
|
1119
|
+
const fixture = find('create-attachment-record-non-owner-without-carve-out-refused');
|
|
1120
|
+
await t.ctx.stack.grant(CONTRIBUTOR_ID, [{ actions: ['create'], typeId: ATTACHMENT_TYPE }]);
|
|
1121
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1122
|
+
// The contributor uploads the bytes themselves, so they end up holding
|
|
1123
|
+
// an _attachment@1 record for this fileId — the "uploaded it themselves"
|
|
1124
|
+
// clause of the getAttachment() access rule. That clause is exactly what
|
|
1125
|
+
// the create() carve-out excludes: nothing else references the file, so
|
|
1126
|
+
// a second metadata record for it is still refused.
|
|
1127
|
+
const upload = await t.app.request('/attachments', {
|
|
1128
|
+
method: 'POST',
|
|
1129
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'image/png' },
|
|
1130
|
+
body: new Uint8Array([9, 9, 9]),
|
|
1131
|
+
});
|
|
1132
|
+
expect(upload.status).toBe(200);
|
|
1133
|
+
const own = (await upload.json()) as { content: { fileId: string } };
|
|
1134
|
+
const body = withFreshId(fixture.requestBody as WireRecord & { content: { fileId: string } });
|
|
1135
|
+
const { status, data } = await dispatch(fixture, token, undefined, {
|
|
1136
|
+
...body,
|
|
1137
|
+
content: { ...body.content, fileId: own.content.fileId },
|
|
1138
|
+
});
|
|
1139
|
+
expectError(status, data, fixture);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
test('error-permission-denied-restore-reference-reconveyance', async () => {
|
|
1143
|
+
const fixture = find('error-permission-denied-restore-reference-reconveyance');
|
|
1144
|
+
// v1 carries an attachment association; v2 drops it. Nothing else
|
|
1145
|
+
// references the file afterward — an _attachment@1 record holds its
|
|
1146
|
+
// fileId in a plain string field, not a file-ref one, so the owner's
|
|
1147
|
+
// own upload record is not a reference the attachmentFileId filter
|
|
1148
|
+
// sees. The writer therefore cannot attach the file fresh today, and
|
|
1149
|
+
// restoring v1 would hand it back to them.
|
|
1150
|
+
const uploaded = await t.ctx.stack.putAttachment(
|
|
1151
|
+
new Uint8Array([4, 5, 6]),
|
|
1152
|
+
'image/png',
|
|
1153
|
+
'cover.png',
|
|
1154
|
+
);
|
|
1155
|
+
const fileId = (uploaded.content as { fileId: string }).fileId;
|
|
1156
|
+
const record = await t.ctx.stack.create(
|
|
1157
|
+
NOTE_TYPE,
|
|
1158
|
+
{ title: 'has a cover' },
|
|
1159
|
+
{
|
|
1160
|
+
permissions: [{ access: 'entity', entityId: WRITER_ID, read: true, write: true }],
|
|
1161
|
+
associations: [{ kind: 'attachment', label: 'cover', fileId }],
|
|
1162
|
+
},
|
|
1163
|
+
);
|
|
1164
|
+
await t.ctx.stack.dissociate(record.id, { kind: 'attachment', label: 'cover', fileId });
|
|
1165
|
+
|
|
1166
|
+
const { token } = await t.ctx.adapter.createToken(WRITER_ID);
|
|
1167
|
+
const { status, data } = await dispatch(fixture, token, `/records/${record.id}/restore/1`);
|
|
1168
|
+
expectError(status, data, fixture);
|
|
1169
|
+
// The refusal is about the reference, not about write access: give the
|
|
1170
|
+
// writer a readable record that carries the same file today and the
|
|
1171
|
+
// identical restore goes through.
|
|
1172
|
+
const cover = await t.ctx.stack.create(
|
|
1173
|
+
NOTE_TYPE,
|
|
1174
|
+
{ title: 'still has the cover' },
|
|
1175
|
+
{
|
|
1176
|
+
permissions: [{ access: 'entity', entityId: WRITER_ID, read: true, write: true }],
|
|
1177
|
+
associations: [{ kind: 'attachment', label: 'cover', fileId }],
|
|
1178
|
+
},
|
|
1179
|
+
);
|
|
1180
|
+
expect(cover.id).toBeDefined();
|
|
1181
|
+
const retry = await req(t.app, 'POST', `/records/${record.id}/restore/1`, { token });
|
|
1182
|
+
expect(retry.status).toBe(200);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('error-validation-failed-restore', async () => {
|
|
1186
|
+
const fixture = find('error-validation-failed-restore');
|
|
1187
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
|
|
1188
|
+
// Drift can't be reached through the write API — defineType() refuses
|
|
1189
|
+
// any in-place schema change that would invalidate stored content, and
|
|
1190
|
+
// create()/update() validate everything they write. So the corrupted
|
|
1191
|
+
// snapshot is written straight to the adapter, the way core's own
|
|
1192
|
+
// restoreVersion test produces one, and the assertion is that restore
|
|
1193
|
+
// validates the snapshot against its own stored typeId rather than
|
|
1194
|
+
// trusting it. A create leaves no v1 snapshot of its own, so this adds
|
|
1195
|
+
// one rather than overwriting — the SQLite adapter refuses to replace
|
|
1196
|
+
// an existing version row.
|
|
1197
|
+
await t.ctx.adapter.saveVersion(record.id, {
|
|
1198
|
+
version: 1,
|
|
1199
|
+
typeId: NOTE_TYPE,
|
|
1200
|
+
content: { title: 42 },
|
|
1201
|
+
updatedAt: new Date(),
|
|
1202
|
+
});
|
|
1203
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}/restore/1`);
|
|
1204
|
+
expectError(status, data, fixture);
|
|
1205
|
+
// Nothing was applied: the record still holds its pre-restore content.
|
|
1206
|
+
const after = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
1207
|
+
expect((after.data as { content: unknown }).content).toEqual({ title: 'original' });
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test('error-timeout-search-exceeds-server-bound', async () => {
|
|
1211
|
+
const fixture = find('error-timeout-search-exceeds-server-bound');
|
|
1212
|
+
// The fixture's own search isn't costly — per its description it stands
|
|
1213
|
+
// in for one that outruns whatever bound a server sets, pinning the
|
|
1214
|
+
// error shape rather than a particular cost. This server's bound is
|
|
1215
|
+
// config.queryTimeoutMs, enforced at the query worker (see
|
|
1216
|
+
// src/lib/queryWorker/pool.ts), so this dispatches against a deadline
|
|
1217
|
+
// no query can meet — the same substitution
|
|
1218
|
+
// attachment-upload-payload-too-large makes with maxAttachmentBytes.
|
|
1219
|
+
const impatientApp = createApp(t.ctx, { ...testConfig(t.dbPath), queryTimeoutMs: 1 }, logger);
|
|
1220
|
+
const { status, data } = await req(impatientApp, fixture.method, fixture.path, {
|
|
1221
|
+
token: TEST_TOKEN,
|
|
1222
|
+
body: fixture.requestBody,
|
|
1223
|
+
});
|
|
1224
|
+
expectError(status, data, fixture);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test('error-permission-denied-includeUnlisted-non-owner', async () => {
|
|
1228
|
+
const fixture = find('error-permission-denied-includeUnlisted-non-owner');
|
|
1229
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1230
|
+
const { status, data } = await dispatch(fixture, token);
|
|
1231
|
+
expectError(status, data, fixture);
|
|
1232
|
+
// Same refusal on GET /records — includeUnlisted is owner-only on every
|
|
1233
|
+
// route that accepts it, not just the fixture's own POST /records/query.
|
|
1234
|
+
const get = await req(t.app, 'GET', '/records?includeUnlisted=true', { token });
|
|
1235
|
+
expect(get.status).toBe(403);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test('error-not-found — write against a nonexistent id', async () => {
|
|
1239
|
+
const fixture = find('error-not-found');
|
|
1240
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1241
|
+
expectError(status, data, fixture);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test('error-conflict-duplicate-id', async () => {
|
|
1245
|
+
const fixture = find('error-conflict-duplicate-id');
|
|
1246
|
+
const body = withFreshId(fixture.requestBody as WireRecord);
|
|
1247
|
+
await t.ctx.stack.create(NOTE_TYPE, { title: 'first' }, { id: body.id });
|
|
1248
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body);
|
|
1249
|
+
expectError(status, data, fixture);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test('error-validation-failed — PATCH content fails the type schema', async () => {
|
|
1253
|
+
const fixture = find('error-validation-failed');
|
|
1254
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
|
|
1255
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`);
|
|
1256
|
+
expectError(status, data, fixture);
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test('error-validation-attachment-mimetype-conflict-on-create', async () => {
|
|
1260
|
+
const fixture = find('error-validation-attachment-mimetype-conflict-on-create');
|
|
1261
|
+
const body = withFreshId(fixture.requestBody as WireRecord & { content: { fileId: string } });
|
|
1262
|
+
await t.ctx.stack.create(ATTACHMENT_TYPE, {
|
|
1263
|
+
fileId: body.content.fileId,
|
|
1264
|
+
mimeType: 'image/png',
|
|
1265
|
+
size: 1,
|
|
1266
|
+
});
|
|
1267
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, body);
|
|
1268
|
+
expectError(status, data, fixture);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test('error-validation-attachment-mimetype-immutable-on-update', async () => {
|
|
1272
|
+
const fixture = find('error-validation-attachment-mimetype-immutable-on-update');
|
|
1273
|
+
const id = fixture.path.split('/')[2]!;
|
|
1274
|
+
await t.ctx.stack.create(
|
|
1275
|
+
ATTACHMENT_TYPE,
|
|
1276
|
+
{ fileId: 'a'.repeat(64), mimeType: 'image/png', size: 1 },
|
|
1277
|
+
{ id },
|
|
1278
|
+
);
|
|
1279
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1280
|
+
expectError(status, data, fixture);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
test('error-bad-request-malformed-cursor', async () => {
|
|
1284
|
+
const fixture = find('error-bad-request-malformed-cursor');
|
|
1285
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1286
|
+
expectError(status, data, fixture);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test('error-bad-request-unknown-sort-field-cursor', async () => {
|
|
1290
|
+
const fixture = find('error-bad-request-unknown-sort-field-cursor');
|
|
1291
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1292
|
+
expectError(status, data, fixture);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test('error-bad-request-both-sort-parameters', async () => {
|
|
1296
|
+
const fixture = find('error-bad-request-both-sort-parameters');
|
|
1297
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1298
|
+
expectError(status, data, fixture);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test('error-payload-too-large-record-body', async () => {
|
|
1302
|
+
const fixture = find('error-payload-too-large-record-body');
|
|
1303
|
+
const oversized = {
|
|
1304
|
+
...(fixture.requestBody as WireRecord),
|
|
1305
|
+
content: { body: 'x'.repeat(2 * 1024 * 1024) },
|
|
1306
|
+
};
|
|
1307
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1308
|
+
token: TEST_TOKEN,
|
|
1309
|
+
body: oversized,
|
|
1310
|
+
});
|
|
1311
|
+
expectError(status, data, fixture);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test('error-reserved-content-key', async () => {
|
|
1315
|
+
const fixture = find('error-reserved-content-key');
|
|
1316
|
+
const id = fixture.path.split('/')[2]!;
|
|
1317
|
+
await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { id });
|
|
1318
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1319
|
+
expectError(status, data, fixture);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
test('error-version-conflict-if-match-mismatch', async () => {
|
|
1323
|
+
const fixture = find('error-version-conflict-if-match-mismatch');
|
|
1324
|
+
const id = fixture.path.split('/')[2]!;
|
|
1325
|
+
await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { id });
|
|
1326
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1327
|
+
expectError(status, data, fixture);
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test('error-schema-drift-non-additive-redefinition', async () => {
|
|
1331
|
+
const fixture = find('error-schema-drift-non-additive-redefinition');
|
|
1332
|
+
// schemaHash is verified server-side against the submitted schema — the
|
|
1333
|
+
// fixture's own value is illustrative, not a real hash of its schema.
|
|
1334
|
+
const body = fixture.requestBody as { schema: Record<string, unknown> };
|
|
1335
|
+
const schemaHash = await hashSchema(body.schema as never);
|
|
1336
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN, undefined, {
|
|
1337
|
+
...body,
|
|
1338
|
+
schemaHash,
|
|
1339
|
+
});
|
|
1340
|
+
expectError(status, data, fixture);
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
test.each([
|
|
1344
|
+
'error-bad-request-id-invalid-charset',
|
|
1345
|
+
'error-bad-request-id-invalid-length',
|
|
1346
|
+
'error-bad-request-id-reserved-prefix',
|
|
1347
|
+
])('%s', async (name) => {
|
|
1348
|
+
const fixture = find(name);
|
|
1349
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1350
|
+
expectError(status, data, fixture);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
test.each(['error-conflict-delete-config', 'error-conflict-config-entityid-change'])(
|
|
1354
|
+
'%s',
|
|
1355
|
+
async (name) => {
|
|
1356
|
+
const fixture = find(name);
|
|
1357
|
+
const { status, data } = await dispatch(fixture, TEST_TOKEN);
|
|
1358
|
+
expectError(status, data, fixture);
|
|
1359
|
+
},
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
test('error-unauthorized-anonymous', async () => {
|
|
1363
|
+
const fixture = find('error-unauthorized-anonymous');
|
|
1364
|
+
const { status } = await dispatch(fixture, undefined);
|
|
1365
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
test('coverage', () => {
|
|
1369
|
+
assertCoverage(
|
|
1370
|
+
errorResponseFixtures.map((f) => f.name),
|
|
1371
|
+
handled,
|
|
1372
|
+
SKIPPED,
|
|
1373
|
+
);
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
// -------------------------------------------------------
|
|
1378
|
+
// Change feed: a change-feed fixture isn't a request/response pair like
|
|
1379
|
+
// every other block here — it pins an ordered stream of frames a
|
|
1380
|
+
// connection sees, optionally across mutations made while it's open.
|
|
1381
|
+
// tests/changeFeedClient.ts dispatches those against the real GET /changes.
|
|
1382
|
+
//
|
|
1383
|
+
// Same targeted-field discipline as every other block: a fixture's `seq`
|
|
1384
|
+
// and record ids/timestamps are illustrative, not literal. This server
|
|
1385
|
+
// mints real cursors (`resume: true`), so `ready.data.seq` and
|
|
1386
|
+
// every `record` frame's SSE `id:` are asserted structurally (base64url
|
|
1387
|
+
// shaped) below rather than deep-equated against a fixture's placeholder.
|
|
1388
|
+
// -------------------------------------------------------
|
|
1389
|
+
|
|
1390
|
+
function frameData(frame: DecodedFrame): Record<string, unknown> {
|
|
1391
|
+
return frame.data as Record<string, unknown>;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** base64url charset — the shape every minted cursor is held to (isValidSeq). */
|
|
1395
|
+
const SEQ_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
1396
|
+
|
|
1397
|
+
describe('changeFeed fixtures', () => {
|
|
1398
|
+
const handled = new Set<string>();
|
|
1399
|
+
const SKIPPED = new Set([
|
|
1400
|
+
// Pins a client obligation, in the fixture's own words — ignore an
|
|
1401
|
+
// unrecognized SSE event name — by injecting a synthetic `type` frame
|
|
1402
|
+
// between `ready` and the `record` frame it expects. A server
|
|
1403
|
+
// implementing only this version emits no such frame, so there is
|
|
1404
|
+
// nothing server-side to exercise until a later minor adds one; the
|
|
1405
|
+
// frame would have to be forged to dispatch this at all.
|
|
1406
|
+
'change-feed-unknown-frame-names-are-ignored',
|
|
1407
|
+
]);
|
|
1408
|
+
|
|
1409
|
+
test('change-feed-ready-leads-every-connection', async () => {
|
|
1410
|
+
const fixture = changeFeedFixtures.find(
|
|
1411
|
+
(f) => f.name === 'change-feed-ready-leads-every-connection',
|
|
1412
|
+
)!;
|
|
1413
|
+
handled.add(fixture.name);
|
|
1414
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1415
|
+
try {
|
|
1416
|
+
expect(conn.status).toBe(fixture.responseStatus);
|
|
1417
|
+
const [ready] = await conn.waitForFrames(1);
|
|
1418
|
+
expect(ready.event).toBe('ready');
|
|
1419
|
+
// ready never carries an SSE `id:` line — the cursor it reports rides
|
|
1420
|
+
// in the JSON body, as `data.seq`.
|
|
1421
|
+
expect(ready.id).toBeUndefined();
|
|
1422
|
+
expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
|
|
1423
|
+
} finally {
|
|
1424
|
+
await conn.close();
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
test('change-feed-created-frame', async () => {
|
|
1429
|
+
const fixture = changeFeedFixtures.find((f) => f.name === 'change-feed-created-frame')!;
|
|
1430
|
+
handled.add(fixture.name);
|
|
1431
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1432
|
+
try {
|
|
1433
|
+
await conn.waitForFrames(1); // ready
|
|
1434
|
+
const { data: created } = await req(t.app, 'POST', '/records', {
|
|
1435
|
+
token: TEST_TOKEN,
|
|
1436
|
+
body: { typeId: NOTE_TYPE, content: { title: 'Hello' } },
|
|
1437
|
+
});
|
|
1438
|
+
const recordId = (created as { id: string }).id;
|
|
1439
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1440
|
+
expect(frame.event).toBe('record');
|
|
1441
|
+
// A record frame's SSE `id:` is its resume cursor.
|
|
1442
|
+
expect(frame.id).toMatch(SEQ_PATTERN);
|
|
1443
|
+
expect(frame.id).toBe((frameData(frame) as { seq?: string }).seq);
|
|
1444
|
+
const data = frameData(frame);
|
|
1445
|
+
expect(data.kind).toBe('created');
|
|
1446
|
+
expect(data.ops).toEqual(['create']);
|
|
1447
|
+
expect(data.recordId).toBe(recordId);
|
|
1448
|
+
expect(data.typeId).toBe(NOTE_TYPE);
|
|
1449
|
+
expect(data.version).toBe(1);
|
|
1450
|
+
expect((data.actor as { entityId: string }).entityId).toBe(TEST_ENTITY_ID);
|
|
1451
|
+
} finally {
|
|
1452
|
+
await conn.close();
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test('change-feed-changed-frame-names-the-verb', async () => {
|
|
1457
|
+
const fixture = changeFeedFixtures.find(
|
|
1458
|
+
(f) => f.name === 'change-feed-changed-frame-names-the-verb',
|
|
1459
|
+
)!;
|
|
1460
|
+
handled.add(fixture.name);
|
|
1461
|
+
const record = await t.ctx.stack.create(
|
|
1462
|
+
NOTE_TYPE,
|
|
1463
|
+
{ title: 'original' },
|
|
1464
|
+
{ permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: true }] },
|
|
1465
|
+
);
|
|
1466
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1467
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1468
|
+
try {
|
|
1469
|
+
await conn.waitForFrames(1); // ready
|
|
1470
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1471
|
+
token,
|
|
1472
|
+
body: { contentPatch: { title: 'edited by a contributor' } },
|
|
1473
|
+
});
|
|
1474
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1475
|
+
const data = frameData(frame);
|
|
1476
|
+
expect(data.kind).toBe('changed');
|
|
1477
|
+
expect(data.ops).toEqual(['patch']);
|
|
1478
|
+
expect(data.recordId).toBe(record.id);
|
|
1479
|
+
expect((data.actor as { entityId: string }).entityId).toBe(CONTRIBUTOR_ID);
|
|
1480
|
+
} finally {
|
|
1481
|
+
await conn.close();
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
test('change-feed-deleted-frame-is-not-terminal', async () => {
|
|
1486
|
+
const fixture = changeFeedFixtures.find(
|
|
1487
|
+
(f) => f.name === 'change-feed-deleted-frame-is-not-terminal',
|
|
1488
|
+
)!;
|
|
1489
|
+
handled.add(fixture.name);
|
|
1490
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
|
|
1491
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1492
|
+
try {
|
|
1493
|
+
await conn.waitForFrames(1); // ready
|
|
1494
|
+
await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
1495
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1496
|
+
const data = frameData(frame);
|
|
1497
|
+
expect(data.kind).toBe('deleted');
|
|
1498
|
+
expect(data.ops).toEqual(['delete']);
|
|
1499
|
+
expect(data.recordId).toBe(record.id);
|
|
1500
|
+
} finally {
|
|
1501
|
+
await conn.close();
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
test('change-feed-purged-frame-carries-nothing-about-the-record', async () => {
|
|
1506
|
+
const fixture = changeFeedFixtures.find(
|
|
1507
|
+
(f) => f.name === 'change-feed-purged-frame-carries-nothing-about-the-record',
|
|
1508
|
+
)!;
|
|
1509
|
+
handled.add(fixture.name);
|
|
1510
|
+
const parent = await t.ctx.stack.create(NOTE_TYPE, { title: 'parent' });
|
|
1511
|
+
const record = await t.ctx.stack.create(
|
|
1512
|
+
NOTE_TYPE,
|
|
1513
|
+
{ title: 'to be purged' },
|
|
1514
|
+
{ parentId: parent.id, entityId: CONTRIBUTOR_ID },
|
|
1515
|
+
);
|
|
1516
|
+
const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN });
|
|
1517
|
+
try {
|
|
1518
|
+
await conn.waitForFrames(1); // ready
|
|
1519
|
+
await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN });
|
|
1520
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1521
|
+
const data = frameData(frame);
|
|
1522
|
+
expect(data.kind).toBe('purged');
|
|
1523
|
+
expect(data.ops).toEqual(['hard-delete']);
|
|
1524
|
+
expect(data.recordId).toBe(record.id);
|
|
1525
|
+
expect(data.typeId).toBe(NOTE_TYPE);
|
|
1526
|
+
expect('record' in data).toBe(false);
|
|
1527
|
+
expect('parentId' in data).toBe(false);
|
|
1528
|
+
// Owner-acting-alone is the only way to reach hard delete, and a
|
|
1529
|
+
// purge stamps nothing on a record that no longer exists — the actor
|
|
1530
|
+
// is the requester, never the record's own author (CONTRIBUTOR_ID).
|
|
1531
|
+
expect((data.actor as { entityId: string }).entityId).toBe(TEST_ENTITY_ID);
|
|
1532
|
+
} finally {
|
|
1533
|
+
await conn.close();
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
test('change-feed-include-record-carries-the-body', async () => {
|
|
1538
|
+
const fixture = changeFeedFixtures.find(
|
|
1539
|
+
(f) => f.name === 'change-feed-include-record-carries-the-body',
|
|
1540
|
+
)!;
|
|
1541
|
+
handled.add(fixture.name);
|
|
1542
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
|
|
1543
|
+
const conn = await openChangeFeed(t.app, '/changes?include=record', { token: TEST_TOKEN });
|
|
1544
|
+
try {
|
|
1545
|
+
await conn.waitForFrames(1); // ready
|
|
1546
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1547
|
+
token: TEST_TOKEN,
|
|
1548
|
+
body: { contentPatch: { title: 'Updated title' } },
|
|
1549
|
+
});
|
|
1550
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1551
|
+
const data = frameData(frame);
|
|
1552
|
+
const wireRecord = data.record as { id: string; content: { title: string } };
|
|
1553
|
+
expect(wireRecord.id).toBe(record.id);
|
|
1554
|
+
expect(wireRecord.content).toEqual({ title: 'Updated title' });
|
|
1555
|
+
} finally {
|
|
1556
|
+
await conn.close();
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test('change-feed-unreadable-record-produces-no-frame', async () => {
|
|
1561
|
+
const fixture = changeFeedFixtures.find(
|
|
1562
|
+
(f) => f.name === 'change-feed-unreadable-record-produces-no-frame',
|
|
1563
|
+
)!;
|
|
1564
|
+
handled.add(fixture.name);
|
|
1565
|
+
const privateRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'private' }); // owner-only
|
|
1566
|
+
const sharedRecord = await t.ctx.stack.create(
|
|
1567
|
+
NOTE_TYPE,
|
|
1568
|
+
{ title: 'shared' },
|
|
1569
|
+
{ permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
|
|
1570
|
+
);
|
|
1571
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
1572
|
+
const conn = await openChangeFeed(t.app, '/changes', { token });
|
|
1573
|
+
try {
|
|
1574
|
+
await conn.waitForFrames(1); // ready
|
|
1575
|
+
await req(t.app, 'PATCH', `/records/${privateRecord.id}`, {
|
|
1576
|
+
token: TEST_TOKEN,
|
|
1577
|
+
body: { contentPatch: { title: 'still private' } },
|
|
1578
|
+
});
|
|
1579
|
+
await req(t.app, 'PATCH', `/records/${sharedRecord.id}`, {
|
|
1580
|
+
token: TEST_TOKEN,
|
|
1581
|
+
body: { contentPatch: { title: 'shared, updated' } },
|
|
1582
|
+
});
|
|
1583
|
+
// Exactly one more frame — the readable record's — proves the
|
|
1584
|
+
// private edit produced none rather than merely arriving later.
|
|
1585
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1586
|
+
const data = frameData(frame);
|
|
1587
|
+
expect(data.recordId).toBe(sharedRecord.id);
|
|
1588
|
+
} finally {
|
|
1589
|
+
await conn.close();
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
test('change-feed-typeid-filter-matches-by-baseid', async () => {
|
|
1594
|
+
const fixture = changeFeedFixtures.find(
|
|
1595
|
+
(f) => f.name === 'change-feed-typeid-filter-matches-by-baseid',
|
|
1596
|
+
)!;
|
|
1597
|
+
handled.add(fixture.name);
|
|
1598
|
+
const note = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
|
|
1599
|
+
const comment = await t.ctx.stack.create(COMMENT_TYPE, { body: 'unrelated' });
|
|
1600
|
+
const conn = await openChangeFeed(t.app, `/changes?typeId=${encodeURIComponent(NOTE_TYPE)}`, {
|
|
1601
|
+
token: TEST_TOKEN,
|
|
1602
|
+
});
|
|
1603
|
+
try {
|
|
1604
|
+
await conn.waitForFrames(1); // ready
|
|
1605
|
+
await req(t.app, 'PATCH', `/records/${comment.id}`, {
|
|
1606
|
+
token: TEST_TOKEN,
|
|
1607
|
+
body: { contentPatch: { body: 'still unrelated' } },
|
|
1608
|
+
});
|
|
1609
|
+
await req(t.app, 'POST', `/records/${note.id}/migrate`, {
|
|
1610
|
+
token: TEST_TOKEN,
|
|
1611
|
+
body: { toTypeId: NOTE_TYPE_V2, content: { title: 'Hello', pinned: false } },
|
|
1612
|
+
});
|
|
1613
|
+
// Exactly one more frame — the migration — proves the unrelated
|
|
1614
|
+
// type's edit was filtered out rather than merely arriving later.
|
|
1615
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1616
|
+
const data = frameData(frame);
|
|
1617
|
+
expect(data.recordId).toBe(note.id);
|
|
1618
|
+
expect(data.typeId).toBe(NOTE_TYPE_V2);
|
|
1619
|
+
expect(data.ops).toEqual(['migrate']);
|
|
1620
|
+
} finally {
|
|
1621
|
+
await conn.close();
|
|
1622
|
+
}
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
test('change-feed-reset-when-no-cursor-is-honored', async () => {
|
|
1626
|
+
const fixture = changeFeedFixtures.find(
|
|
1627
|
+
(f) => f.name === 'change-feed-reset-when-no-cursor-is-honored',
|
|
1628
|
+
)!;
|
|
1629
|
+
handled.add(fixture.name);
|
|
1630
|
+
// This fixture pins a server advertising `resume: false`, and this
|
|
1631
|
+
// server defaults to true, so it runs against a standalone app built
|
|
1632
|
+
// with the matching test-only override.
|
|
1633
|
+
const resumeDisabledApp = new Hono<AppEnv>();
|
|
1634
|
+
resumeDisabledApp.use(authMiddleware(testConfig(t.dbPath).ownerToken, t.ctx));
|
|
1635
|
+
resumeDisabledApp.route(
|
|
1636
|
+
'/',
|
|
1637
|
+
changeRoutes(t.ctx, testConfig(t.dbPath), logger, { resume: false }),
|
|
1638
|
+
);
|
|
1639
|
+
const conn = await openChangeFeed(resumeDisabledApp, '/', {
|
|
1640
|
+
token: TEST_TOKEN,
|
|
1641
|
+
headers: { 'Last-Event-ID': 'AA3f1R' },
|
|
1642
|
+
});
|
|
1643
|
+
try {
|
|
1644
|
+
expect(conn.status).toBe(fixture.responseStatus);
|
|
1645
|
+
const [ready, reset] = await conn.waitForFrames(2);
|
|
1646
|
+
expect(ready.event).toBe('ready');
|
|
1647
|
+
expect(reset.event).toBe('reset');
|
|
1648
|
+
expect(frameData(reset).reason).toBe('not_supported');
|
|
1649
|
+
} finally {
|
|
1650
|
+
await conn.close();
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
test('change-feed-unlist-frame-is-a-deleted-kind', async () => {
|
|
1655
|
+
const fixture = changeFeedFixtures.find(
|
|
1656
|
+
(f) => f.name === 'change-feed-unlist-frame-is-a-deleted-kind',
|
|
1657
|
+
)!;
|
|
1658
|
+
handled.add(fixture.name);
|
|
1659
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
|
|
1660
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1661
|
+
try {
|
|
1662
|
+
await conn.waitForFrames(1); // ready
|
|
1663
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1664
|
+
token: TEST_TOKEN,
|
|
1665
|
+
body: { unlisted: true },
|
|
1666
|
+
});
|
|
1667
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1668
|
+
const data = frameData(frame);
|
|
1669
|
+
expect(data.kind).toBe('deleted');
|
|
1670
|
+
expect(data.ops).toEqual(['unlist']);
|
|
1671
|
+
expect(data.recordId).toBe(record.id);
|
|
1672
|
+
} finally {
|
|
1673
|
+
await conn.close();
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
test('change-feed-list-frame-is-a-changed-kind', async () => {
|
|
1678
|
+
const fixture = changeFeedFixtures.find(
|
|
1679
|
+
(f) => f.name === 'change-feed-list-frame-is-a-changed-kind',
|
|
1680
|
+
)!;
|
|
1681
|
+
handled.add(fixture.name);
|
|
1682
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' });
|
|
1683
|
+
await t.ctx.stack.mutate(record.id, { unlisted: true });
|
|
1684
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1685
|
+
try {
|
|
1686
|
+
await conn.waitForFrames(1); // ready
|
|
1687
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1688
|
+
token: TEST_TOKEN,
|
|
1689
|
+
body: { unlisted: false },
|
|
1690
|
+
});
|
|
1691
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1692
|
+
const data = frameData(frame);
|
|
1693
|
+
expect(data.kind).toBe('changed');
|
|
1694
|
+
expect(data.ops).toEqual(['list']);
|
|
1695
|
+
expect(data.recordId).toBe(record.id);
|
|
1696
|
+
} finally {
|
|
1697
|
+
await conn.close();
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
test('change-feed-unlisted-record-produces-no-frame-by-default', async () => {
|
|
1702
|
+
const fixture = changeFeedFixtures.find(
|
|
1703
|
+
(f) => f.name === 'change-feed-unlisted-record-produces-no-frame-by-default',
|
|
1704
|
+
)!;
|
|
1705
|
+
handled.add(fixture.name);
|
|
1706
|
+
const unlistedRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Unlisted' });
|
|
1707
|
+
await t.ctx.stack.mutate(unlistedRecord.id, { unlisted: true });
|
|
1708
|
+
const visibleRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Visible' });
|
|
1709
|
+
const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
|
|
1710
|
+
try {
|
|
1711
|
+
await conn.waitForFrames(1); // ready
|
|
1712
|
+
await req(t.app, 'PATCH', `/records/${unlistedRecord.id}`, {
|
|
1713
|
+
token: TEST_TOKEN,
|
|
1714
|
+
body: { contentPatch: { title: 'Edited while unlisted' } },
|
|
1715
|
+
});
|
|
1716
|
+
await req(t.app, 'PATCH', `/records/${visibleRecord.id}`, {
|
|
1717
|
+
token: TEST_TOKEN,
|
|
1718
|
+
body: { contentPatch: { title: 'Edited, visible' } },
|
|
1719
|
+
});
|
|
1720
|
+
// Exactly one more frame — the visible record's — proves the
|
|
1721
|
+
// unlisted edit produced none rather than merely arriving later.
|
|
1722
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1723
|
+
const data = frameData(frame);
|
|
1724
|
+
expect(data.recordId).toBe(visibleRecord.id);
|
|
1725
|
+
} finally {
|
|
1726
|
+
await conn.close();
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
test('change-feed-reparent-reaches-the-container-a-record-left', async () => {
|
|
1731
|
+
const fixture = changeFeedFixtures.find(
|
|
1732
|
+
(f) => f.name === 'change-feed-reparent-reaches-the-container-a-record-left',
|
|
1733
|
+
)!;
|
|
1734
|
+
handled.add(fixture.name);
|
|
1735
|
+
|
|
1736
|
+
const origin = await t.ctx.stack.create(NOTE_TYPE, { title: 'Origin' });
|
|
1737
|
+
const destination = await t.ctx.stack.create(NOTE_TYPE, { title: 'Destination' });
|
|
1738
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }, { parentId: origin.id });
|
|
1739
|
+
|
|
1740
|
+
// Filtered on the container the record is about to leave: the record's
|
|
1741
|
+
// post-change state names only the destination, so a subscriber watching
|
|
1742
|
+
// the origin hears about the departure only because a reparent is
|
|
1743
|
+
// matched against both sides of the move.
|
|
1744
|
+
const conn = await openChangeFeed(t.app, `/changes?parentId=${origin.id}`, {
|
|
1745
|
+
token: TEST_TOKEN,
|
|
1746
|
+
});
|
|
1747
|
+
try {
|
|
1748
|
+
await conn.waitForFrames(1); // ready
|
|
1749
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1750
|
+
token: TEST_TOKEN,
|
|
1751
|
+
body: { parentId: destination.id },
|
|
1752
|
+
});
|
|
1753
|
+
const [, frame] = await conn.waitForFrames(2);
|
|
1754
|
+
const data = frameData(frame);
|
|
1755
|
+
// Still there and still readable — only its container moved.
|
|
1756
|
+
expect(data.kind).toBe('changed');
|
|
1757
|
+
expect(data.ops).toEqual(['reparent']);
|
|
1758
|
+
expect(data.recordId).toBe(record.id);
|
|
1759
|
+
// The frame carries the destination, as every frame carries the
|
|
1760
|
+
// record's state at the moment of the change.
|
|
1761
|
+
expect(data.parentId).toBe(destination.id);
|
|
1762
|
+
} finally {
|
|
1763
|
+
await conn.close();
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
test('coverage', () => {
|
|
1768
|
+
assertCoverage(
|
|
1769
|
+
changeFeedFixtures.map((f) => f.name),
|
|
1770
|
+
handled,
|
|
1771
|
+
SKIPPED,
|
|
1772
|
+
);
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
describe('changeFeed sequence fixtures', () => {
|
|
1777
|
+
const handled = new Set<string>();
|
|
1778
|
+
|
|
1779
|
+
test('change-feed-resume-delivers-what-was-missed', async () => {
|
|
1780
|
+
const fixture = changeFeedSequenceFixtures.find(
|
|
1781
|
+
(f) => f.name === 'change-feed-resume-delivers-what-was-missed',
|
|
1782
|
+
)!;
|
|
1783
|
+
handled.add(fixture.name);
|
|
1784
|
+
|
|
1785
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
|
|
1786
|
+
|
|
1787
|
+
// Step 1: connect fresh, see one change, retain the id it carried.
|
|
1788
|
+
const first = await openChangeFeed(t.app, fixture.steps[0]!.path, { token: TEST_TOKEN });
|
|
1789
|
+
let lastEventId: string;
|
|
1790
|
+
try {
|
|
1791
|
+
expect(first.status).toBe(fixture.steps[0]!.responseStatus);
|
|
1792
|
+
const [ready] = await first.waitForFrames(1);
|
|
1793
|
+
expect(ready.event).toBe('ready');
|
|
1794
|
+
expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
|
|
1795
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1796
|
+
token: TEST_TOKEN,
|
|
1797
|
+
body: { contentPatch: { title: 'first' } },
|
|
1798
|
+
});
|
|
1799
|
+
const [, changeFrame] = await first.waitForFrames(2);
|
|
1800
|
+
expect(changeFrame.event).toBe('record');
|
|
1801
|
+
expect(changeFrame.id).toMatch(SEQ_PATTERN);
|
|
1802
|
+
lastEventId = changeFrame.id!;
|
|
1803
|
+
} finally {
|
|
1804
|
+
await first.close();
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// A second edit lands with nobody listening — the buffer (retained
|
|
1808
|
+
// past disconnect) keeps collecting on its own.
|
|
1809
|
+
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1810
|
+
token: TEST_TOKEN,
|
|
1811
|
+
body: { contentPatch: { title: 'second' } },
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
// Step 2: reconnect presenting the last id seen. ready still leads,
|
|
1815
|
+
// and exactly the missed change is replayed — never the frame the
|
|
1816
|
+
// first connection already had.
|
|
1817
|
+
const second = await openChangeFeed(t.app, fixture.steps[1]!.path, {
|
|
1818
|
+
token: TEST_TOKEN,
|
|
1819
|
+
headers: { 'Last-Event-ID': lastEventId },
|
|
1820
|
+
});
|
|
1821
|
+
try {
|
|
1822
|
+
expect(second.status).toBe(fixture.steps[1]!.responseStatus);
|
|
1823
|
+
const [ready, replayed] = await second.waitForFrames(2);
|
|
1824
|
+
expect(ready.event).toBe('ready');
|
|
1825
|
+
expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
|
|
1826
|
+
expect(replayed.event).toBe('record');
|
|
1827
|
+
expect(replayed.id).toMatch(SEQ_PATTERN);
|
|
1828
|
+
expect(replayed.id).not.toBe(lastEventId);
|
|
1829
|
+
const data = frameData(replayed);
|
|
1830
|
+
expect(data.recordId).toBe(record.id);
|
|
1831
|
+
expect(data.version).toBe(3);
|
|
1832
|
+
// Nothing else arrives — confirms the already-delivered frame from
|
|
1833
|
+
// step 1 wasn't replayed a second time.
|
|
1834
|
+
await expect(second.waitForFrames(3, 300)).rejects.toThrow();
|
|
1835
|
+
} finally {
|
|
1836
|
+
await second.close();
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
test('change-feed-reset-rather-than-resume-from-wherever-it-can', async () => {
|
|
1841
|
+
const fixture = changeFeedSequenceFixtures.find(
|
|
1842
|
+
(f) => f.name === 'change-feed-reset-rather-than-resume-from-wherever-it-can',
|
|
1843
|
+
)!;
|
|
1844
|
+
handled.add(fixture.name);
|
|
1845
|
+
|
|
1846
|
+
// A buffer past its retention window is unrecognized on reconnect —
|
|
1847
|
+
// forced deterministically with a near-zero window rather than
|
|
1848
|
+
// waiting out the real (5-minute) default.
|
|
1849
|
+
const config = testConfig(t.dbPath);
|
|
1850
|
+
const resumeApp = new Hono<AppEnv>();
|
|
1851
|
+
resumeApp.use(authMiddleware(config.ownerToken, t.ctx));
|
|
1852
|
+
resumeApp.route(
|
|
1853
|
+
'/changes',
|
|
1854
|
+
changeRoutes(t.ctx, config, logger, { resume: true, resumeRetentionMs: 10 }),
|
|
1855
|
+
);
|
|
1856
|
+
|
|
1857
|
+
const first = await openChangeFeed(resumeApp, fixture.steps[0]!.path, { token: TEST_TOKEN });
|
|
1858
|
+
let headCursor: string;
|
|
1859
|
+
try {
|
|
1860
|
+
expect(first.status).toBe(fixture.steps[0]!.responseStatus);
|
|
1861
|
+
const [ready] = await first.waitForFrames(1);
|
|
1862
|
+
expect(ready.event).toBe('ready');
|
|
1863
|
+
headCursor = frameData(ready).seq as string;
|
|
1864
|
+
expect(headCursor).toMatch(SEQ_PATTERN);
|
|
1865
|
+
} finally {
|
|
1866
|
+
await first.close();
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Long enough past the deliberately tiny retention window for the
|
|
1870
|
+
// buffer to have been dropped.
|
|
1871
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1872
|
+
|
|
1873
|
+
const second = await openChangeFeed(resumeApp, fixture.steps[1]!.path, {
|
|
1874
|
+
token: TEST_TOKEN,
|
|
1875
|
+
headers: { 'Last-Event-ID': headCursor },
|
|
1876
|
+
});
|
|
1877
|
+
try {
|
|
1878
|
+
expect(second.status).toBe(fixture.steps[1]!.responseStatus);
|
|
1879
|
+
const [ready, reset] = await second.waitForFrames(2);
|
|
1880
|
+
expect(ready.event).toBe('ready');
|
|
1881
|
+
// A fresh buffer, so a fresh (different) head cursor.
|
|
1882
|
+
expect(frameData(ready).seq).toMatch(SEQ_PATTERN);
|
|
1883
|
+
expect(frameData(ready).seq).not.toBe(headCursor);
|
|
1884
|
+
expect(reset.event).toBe('reset');
|
|
1885
|
+
expect(frameData(reset).reason).toBe('cursor_expired');
|
|
1886
|
+
} finally {
|
|
1887
|
+
await second.close();
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test('coverage', () => {
|
|
1892
|
+
assertCoverage(
|
|
1893
|
+
changeFeedSequenceFixtures.map((f) => f.name),
|
|
1894
|
+
handled,
|
|
1895
|
+
new Set(),
|
|
1896
|
+
);
|
|
1897
|
+
});
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
// -------------------------------------------------------
|
|
1901
|
+
// Auth: DID challenge-response handshake
|
|
1902
|
+
// -------------------------------------------------------
|
|
1903
|
+
|
|
1904
|
+
describe('auth handshake fixtures', () => {
|
|
1905
|
+
// Fixture-carried signatures are real Ed25519 signatures over one exact,
|
|
1906
|
+
// fixed nonce value — this server generates its own random nonces via
|
|
1907
|
+
// POST /auth/challenge, so authTokenFixtures/authSequenceFixtures seed
|
|
1908
|
+
// that exact value directly into the nonce store rather than going
|
|
1909
|
+
// through the challenge endpoint.
|
|
1910
|
+
function seedFixtureNonce(ttlMs = 5 * 60 * 1000) {
|
|
1911
|
+
t.ctx.nonces.seedForTesting(AUTH_FIXTURE_NONCE, AUTH_FIXTURE_DID, new Date(Date.now() + ttlMs));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const handled = new Set<string>();
|
|
1915
|
+
|
|
1916
|
+
test('auth-challenge-issues-nonce', async () => {
|
|
1917
|
+
const fixture = authChallengeFixtures.find((f) => f.name === 'auth-challenge-issues-nonce')!;
|
|
1918
|
+
handled.add(fixture.name);
|
|
1919
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1920
|
+
body: fixture.requestBody,
|
|
1921
|
+
});
|
|
1922
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1923
|
+
const d = data as { nonce: string; expiresAt: string };
|
|
1924
|
+
// The nonce itself is server-generated and random — not compared
|
|
1925
|
+
// literally — but must be base64url, per the same charset constraint
|
|
1926
|
+
// buildAuthChallengePayload enforces on the signed payload.
|
|
1927
|
+
expect(d.nonce).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
1928
|
+
expect(typeof d.expiresAt).toBe('string');
|
|
1929
|
+
expect(new Date(d.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
test('auth-challenge-rejects-malformed-did', async () => {
|
|
1933
|
+
const fixture = authChallengeFixtures.find(
|
|
1934
|
+
(f) => f.name === 'auth-challenge-rejects-malformed-did',
|
|
1935
|
+
)!;
|
|
1936
|
+
handled.add(fixture.name);
|
|
1937
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1938
|
+
body: fixture.requestBody,
|
|
1939
|
+
});
|
|
1940
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1941
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_did');
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
test('auth-token-issues-bearer-token', async () => {
|
|
1945
|
+
const fixture = authTokenFixtures.find((f) => f.name === 'auth-token-issues-bearer-token')!;
|
|
1946
|
+
handled.add(fixture.name);
|
|
1947
|
+
seedFixtureNonce();
|
|
1948
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1949
|
+
body: fixture.requestBody,
|
|
1950
|
+
});
|
|
1951
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1952
|
+
const d = data as { token: string; principalId: string; subjectId: string };
|
|
1953
|
+
expect(typeof d.token).toBe('string');
|
|
1954
|
+
expect(d.principalId).toBe(AUTH_FIXTURE_DID);
|
|
1955
|
+
expect(d.subjectId).toBe(AUTH_FIXTURE_DID);
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
test('auth-token-rejects-foreign-signature', async () => {
|
|
1959
|
+
const fixture = authTokenFixtures.find(
|
|
1960
|
+
(f) => f.name === 'auth-token-rejects-foreign-signature',
|
|
1961
|
+
)!;
|
|
1962
|
+
handled.add(fixture.name);
|
|
1963
|
+
seedFixtureNonce();
|
|
1964
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1965
|
+
body: fixture.requestBody,
|
|
1966
|
+
});
|
|
1967
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1968
|
+
expect((data as { error: { code: string } }).error.code).toBe('invalid_signature');
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
test('auth-token-rejects-expired-nonce', async () => {
|
|
1972
|
+
const fixture = authTokenFixtures.find((f) => f.name === 'auth-token-rejects-expired-nonce')!;
|
|
1973
|
+
handled.add(fixture.name);
|
|
1974
|
+
seedFixtureNonce(-1000); // already expired
|
|
1975
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1976
|
+
body: fixture.requestBody,
|
|
1977
|
+
});
|
|
1978
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1979
|
+
expect((data as { error: { code: string } }).error.code).toBe('expired_nonce');
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
test('auth-token-rejects-nonce-issued-to-another-did', async () => {
|
|
1983
|
+
const fixture = authTokenFixtures.find(
|
|
1984
|
+
(f) => f.name === 'auth-token-rejects-nonce-issued-to-another-did',
|
|
1985
|
+
)!;
|
|
1986
|
+
handled.add(fixture.name);
|
|
1987
|
+
// Nonce is bound to AUTH_FIXTURE_DID; the request signs as a different,
|
|
1988
|
+
// valid DID with its own valid signature — still refused, since the
|
|
1989
|
+
// nonce belongs to the DID it was issued for.
|
|
1990
|
+
seedFixtureNonce();
|
|
1991
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
1992
|
+
body: fixture.requestBody,
|
|
1993
|
+
});
|
|
1994
|
+
expect(status).toBe(fixture.responseStatus);
|
|
1995
|
+
expect((data as { error: { code: string } }).error.code).toBe('unknown_nonce');
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
test('auth-token-rejects-unknown-nonce', async () => {
|
|
1999
|
+
const fixture = authTokenFixtures.find((f) => f.name === 'auth-token-rejects-unknown-nonce')!;
|
|
2000
|
+
handled.add(fixture.name);
|
|
2001
|
+
// No seeding — this fixture's nonce was never issued.
|
|
2002
|
+
const { status, data } = await req(t.app, fixture.method, fixture.path, {
|
|
2003
|
+
body: fixture.requestBody,
|
|
2004
|
+
});
|
|
2005
|
+
expect(status).toBe(fixture.responseStatus);
|
|
2006
|
+
expect((data as { error: { code: string } }).error.code).toBe('unknown_nonce');
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
test('auth-nonce-is-single-use', async () => {
|
|
2010
|
+
const sequence = authSequenceFixtures.find((f) => f.name === 'auth-nonce-is-single-use')!;
|
|
2011
|
+
handled.add(sequence.name);
|
|
2012
|
+
seedFixtureNonce();
|
|
2013
|
+
for (const step of sequence.steps) {
|
|
2014
|
+
const { status, data } = await req(t.app, step.method, step.path, {
|
|
2015
|
+
body: step.requestBody,
|
|
2016
|
+
});
|
|
2017
|
+
expect(status).toBe(step.responseStatus);
|
|
2018
|
+
const expected = step.responseBody as { error?: { code: string } } | undefined;
|
|
2019
|
+
if (expected?.error) {
|
|
2020
|
+
expect((data as { error: { code: string } }).error.code).toBe(expected.error.code);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
// Last in the block: `handled` is only complete once every test above has
|
|
2026
|
+
// run, and the sequence fixtures are dispatched at the end.
|
|
2027
|
+
test('coverage: authChallengeFixtures + authTokenFixtures + authSequenceFixtures', () => {
|
|
2028
|
+
assertCoverage(
|
|
2029
|
+
[...authChallengeFixtures, ...authTokenFixtures, ...authSequenceFixtures].map((f) => f.name),
|
|
2030
|
+
handled,
|
|
2031
|
+
new Set(),
|
|
2032
|
+
);
|
|
2033
|
+
});
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// -------------------------------------------------------
|
|
2037
|
+
// Records: the unlisted change-set key
|
|
2038
|
+
// -------------------------------------------------------
|
|
2039
|
+
|
|
2040
|
+
describe('unlisted change fixtures', () => {
|
|
2041
|
+
const handled = new Set<string>();
|
|
2042
|
+
|
|
2043
|
+
// Run together against one seeded record, same as the versions block
|
|
2044
|
+
// below — set-unlisted-false-relists assumes set-unlisted-true already
|
|
2045
|
+
// ran (its own description says so).
|
|
2046
|
+
test('set-unlisted-true then set-unlisted-false-relists', async () => {
|
|
2047
|
+
const trueFixture = unlistedChangeFixtures.find((f) => f.name === 'set-unlisted-true')!;
|
|
2048
|
+
const falseFixture = unlistedChangeFixtures.find(
|
|
2049
|
+
(f) => f.name === 'set-unlisted-false-relists',
|
|
2050
|
+
)!;
|
|
2051
|
+
handled.add(trueFixture.name);
|
|
2052
|
+
handled.add(falseFixture.name);
|
|
2053
|
+
|
|
2054
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' });
|
|
2055
|
+
|
|
2056
|
+
const unlisted = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
2057
|
+
token: TEST_TOKEN,
|
|
2058
|
+
body: trueFixture.requestBody,
|
|
2059
|
+
});
|
|
2060
|
+
expect(unlisted.status).toBe(trueFixture.responseStatus);
|
|
2061
|
+
expect((unlisted.data as WireRecord).unlistedAt).toBeDefined();
|
|
2062
|
+
|
|
2063
|
+
// Excluded from an unfiltered query, same posture as a soft-deleted record.
|
|
2064
|
+
const queried = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
2065
|
+
expect(
|
|
2066
|
+
(queried.data as { records: WireRecord[] }).records.some((r) => r.id === record.id),
|
|
2067
|
+
).toBe(false);
|
|
2068
|
+
|
|
2069
|
+
const relisted = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
2070
|
+
token: TEST_TOKEN,
|
|
2071
|
+
body: falseFixture.requestBody,
|
|
2072
|
+
});
|
|
2073
|
+
expect(relisted.status).toBe(falseFixture.responseStatus);
|
|
2074
|
+
expect((relisted.data as WireRecord).unlistedAt).toBeUndefined();
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
test('coverage', () => {
|
|
2078
|
+
assertCoverage(
|
|
2079
|
+
unlistedChangeFixtures.map((f) => f.name),
|
|
2080
|
+
handled,
|
|
2081
|
+
new Set(),
|
|
2082
|
+
);
|
|
2083
|
+
});
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
// -------------------------------------------------------
|
|
2087
|
+
// Records: the parentId change-set key
|
|
2088
|
+
// -------------------------------------------------------
|
|
2089
|
+
|
|
2090
|
+
describe('parent change fixtures', () => {
|
|
2091
|
+
const handled = new Set<string>();
|
|
2092
|
+
|
|
2093
|
+
// Run together against one seeded record: the null fixture moves a
|
|
2094
|
+
// record back to the root, which only means anything once something
|
|
2095
|
+
// has moved it off the root.
|
|
2096
|
+
test('set-parent-moves-a-record-into-a-container then back to the root', async () => {
|
|
2097
|
+
const intoFixture = parentChangeFixtures.find(
|
|
2098
|
+
(f) => f.name === 'set-parent-moves-a-record-into-a-container',
|
|
2099
|
+
)!;
|
|
2100
|
+
const rootFixture = parentChangeFixtures.find(
|
|
2101
|
+
(f) => f.name === 'set-parent-null-moves-a-record-to-the-root',
|
|
2102
|
+
)!;
|
|
2103
|
+
handled.add(intoFixture.name);
|
|
2104
|
+
handled.add(rootFixture.name);
|
|
2105
|
+
|
|
2106
|
+
// The destination has to exist: a caller-named parentId is checked for
|
|
2107
|
+
// format and then for existence, so the fixture's own literal id would
|
|
2108
|
+
// answer 409 here rather than moving anything.
|
|
2109
|
+
const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' });
|
|
2110
|
+
const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' });
|
|
2111
|
+
|
|
2112
|
+
const moved = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
2113
|
+
token: TEST_TOKEN,
|
|
2114
|
+
body: { ...intoFixture.requestBody, parentId: container.id },
|
|
2115
|
+
});
|
|
2116
|
+
expect(moved.status).toBe(intoFixture.responseStatus);
|
|
2117
|
+
expect((moved.data as WireRecord).parentId).toBe(container.id);
|
|
2118
|
+
// A move touches containment and nothing else.
|
|
2119
|
+
expect((moved.data as WireRecord).content).toEqual({ title: 'Hello', body: 'World' });
|
|
2120
|
+
|
|
2121
|
+
const toRoot = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
2122
|
+
token: TEST_TOKEN,
|
|
2123
|
+
body: rootFixture.requestBody,
|
|
2124
|
+
});
|
|
2125
|
+
expect(toRoot.status).toBe(rootFixture.responseStatus);
|
|
2126
|
+
// State spells the root by omission, where the input spelled it null.
|
|
2127
|
+
expect((toRoot.data as WireRecord).parentId).toBeUndefined();
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
test('coverage', () => {
|
|
2131
|
+
assertCoverage(
|
|
2132
|
+
parentChangeFixtures.map((f) => f.name),
|
|
2133
|
+
handled,
|
|
2134
|
+
new Set(),
|
|
2135
|
+
);
|
|
2136
|
+
});
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
// -------------------------------------------------------
|
|
2140
|
+
// Attachments: download
|
|
2141
|
+
// -------------------------------------------------------
|
|
2142
|
+
|
|
2143
|
+
describe('attachmentDownload fixtures', () => {
|
|
2144
|
+
const handled = new Set<string>();
|
|
2145
|
+
|
|
2146
|
+
function find(name: string) {
|
|
2147
|
+
const fixture = attachmentDownloadFixtures.find((f) => f.name === name)!;
|
|
2148
|
+
handled.add(name);
|
|
2149
|
+
return fixture;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Fixture paths embed the fixture authors' own fileIds — sha256 hashes of
|
|
2153
|
+
// bytes this harness never had. Like every other server-generated value
|
|
2154
|
+
// in this file (record ids, cursors), they're illustrative rather than
|
|
2155
|
+
// literal: each dispatch below uploads its own bytes for a real fileId
|
|
2156
|
+
// and checks the fixture's pinned response headers against that, rather
|
|
2157
|
+
// than replaying the fixture's path verbatim.
|
|
2158
|
+
async function uploadFile(mimeType: string): Promise<string> {
|
|
2159
|
+
const record = await t.ctx.stack.putAttachment(
|
|
2160
|
+
new TextEncoder().encode(`conformance-fixture-bytes:${mimeType}`),
|
|
2161
|
+
mimeType,
|
|
2162
|
+
);
|
|
2163
|
+
return (record.content as { fileId: string }).fileId;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function queryFrom(path: string): string {
|
|
2167
|
+
const i = path.indexOf('?');
|
|
2168
|
+
return i === -1 ? '' : path.slice(i);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
async function dispatch(
|
|
2172
|
+
fixture: (typeof attachmentDownloadFixtures)[number],
|
|
2173
|
+
fileId: string,
|
|
2174
|
+
): Promise<void> {
|
|
2175
|
+
const res = await t.app.request(`/attachments/${fileId}${queryFrom(fixture.path)}`, {
|
|
2176
|
+
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
|
2177
|
+
});
|
|
2178
|
+
expect(res.status).toBe(200);
|
|
2179
|
+
for (const [name, value] of Object.entries(fixture.responseHeaders)) {
|
|
2180
|
+
expect(res.headers.get(name)).toBe(value);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
test('attachment-download-contenttype-param-safe-passes-through', async () => {
|
|
2185
|
+
const fixture = find('attachment-download-contenttype-param-safe-passes-through');
|
|
2186
|
+
await dispatch(fixture, await uploadFile('application/octet-stream'));
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
test('attachment-download-contenttype-param-dangerous-forced', async () => {
|
|
2190
|
+
const fixture = find('attachment-download-contenttype-param-dangerous-forced');
|
|
2191
|
+
await dispatch(fixture, await uploadFile('application/octet-stream'));
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
test('attachment-download-filename-extension-safe-passes-through', async () => {
|
|
2195
|
+
const fixture = find('attachment-download-filename-extension-safe-passes-through');
|
|
2196
|
+
await dispatch(fixture, await uploadFile('application/octet-stream'));
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
test('attachment-download-filename-extension-dangerous-forced', async () => {
|
|
2200
|
+
const fixture = find('attachment-download-filename-extension-dangerous-forced');
|
|
2201
|
+
await dispatch(fixture, await uploadFile('application/octet-stream'));
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
test('attachment-download-stored-mimetype-safe-passes-through', async () => {
|
|
2205
|
+
const fixture = find('attachment-download-stored-mimetype-safe-passes-through');
|
|
2206
|
+
await dispatch(fixture, await uploadFile('image/png'));
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
test('attachment-download-stored-mimetype-dangerous-forced', async () => {
|
|
2210
|
+
const fixture = find('attachment-download-stored-mimetype-dangerous-forced');
|
|
2211
|
+
await dispatch(fixture, await uploadFile('text/html'));
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
test('attachment-download-no-metadata-defaults-to-octet-stream', async () => {
|
|
2215
|
+
const fixture = find('attachment-download-no-metadata-defaults-to-octet-stream');
|
|
2216
|
+
// Raw bytes with no _attachment@1 record at all — bypasses
|
|
2217
|
+
// Stack.putAttachment (which creates the record atomically) by writing
|
|
2218
|
+
// straight through the adapter.
|
|
2219
|
+
const fileId = await t.ctx.adapter.putAttachment(new TextEncoder().encode('orphan-bytes'));
|
|
2220
|
+
await dispatch(fixture, fileId);
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
test('coverage', () => {
|
|
2224
|
+
assertCoverage(
|
|
2225
|
+
attachmentDownloadFixtures.map((f) => f.name),
|
|
2226
|
+
handled,
|
|
2227
|
+
new Set(),
|
|
2228
|
+
);
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
// -------------------------------------------------------
|
|
2233
|
+
// Attachments: upload
|
|
2234
|
+
// -------------------------------------------------------
|
|
2235
|
+
|
|
2236
|
+
describe('attachmentUpload fixtures', () => {
|
|
2237
|
+
const handled = new Set<string>();
|
|
2238
|
+
|
|
2239
|
+
function find(name: string) {
|
|
2240
|
+
const fixture = attachmentUploadFixtures.find((f) => f.name === name)!;
|
|
2241
|
+
handled.add(name);
|
|
2242
|
+
return fixture;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
async function dispatch(
|
|
2246
|
+
fixture: (typeof attachmentUploadFixtures)[number],
|
|
2247
|
+
opts: { token?: string; app?: TestApp['app'] } = {},
|
|
2248
|
+
): Promise<{ status: number; data: Record<string, unknown> }> {
|
|
2249
|
+
const path = fixture.appId
|
|
2250
|
+
? `/attachments?appId=${encodeURIComponent(fixture.appId)}`
|
|
2251
|
+
: '/attachments';
|
|
2252
|
+
const headers: Record<string, string> = { ...fixture.requestHeaders };
|
|
2253
|
+
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
|
|
2254
|
+
const res = await (opts.app ?? t.app).request(path, {
|
|
2255
|
+
method: 'POST',
|
|
2256
|
+
headers,
|
|
2257
|
+
body: new Uint8Array(fixture.requestBodyBytes),
|
|
2258
|
+
});
|
|
2259
|
+
const data = JSON.parse(await res.text()) as Record<string, unknown>;
|
|
2260
|
+
return { status: res.status, data };
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
test('attachment-upload-creates-metadata-record', async () => {
|
|
2264
|
+
const fixture = find('attachment-upload-creates-metadata-record');
|
|
2265
|
+
const { status, data } = await dispatch(fixture, { token: TEST_TOKEN });
|
|
2266
|
+
expect(status).toBe(fixture.responseStatus);
|
|
2267
|
+
const expected = fixture.responseBody as { content: Record<string, unknown>; version: number };
|
|
2268
|
+
expect(data.content).toEqual(expected.content);
|
|
2269
|
+
expect(data.typeId).toBe('_attachment@1');
|
|
2270
|
+
expect(data.version).toBe(expected.version);
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
test('attachment-upload-carries-appid-query-param', async () => {
|
|
2274
|
+
const fixture = find('attachment-upload-carries-appid-query-param');
|
|
2275
|
+
const { status, data } = await dispatch(fixture, { token: TEST_TOKEN });
|
|
2276
|
+
expect(status).toBe(fixture.responseStatus);
|
|
2277
|
+
const expected = fixture.responseBody as { content: Record<string, unknown>; version: number };
|
|
2278
|
+
expect(data.content).toEqual(expected.content);
|
|
2279
|
+
expect(data.appId).toBe(fixture.appId);
|
|
2280
|
+
expect(data.version).toBe(expected.version);
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
test('attachment-upload-no-content-type-defaults-to-octet-stream', async () => {
|
|
2284
|
+
const fixture = find('attachment-upload-no-content-type-defaults-to-octet-stream');
|
|
2285
|
+
const { status, data } = await dispatch(fixture, { token: TEST_TOKEN });
|
|
2286
|
+
expect(status).toBe(fixture.responseStatus);
|
|
2287
|
+
const expected = fixture.responseBody as { content: Record<string, unknown>; version: number };
|
|
2288
|
+
expect(data.content).toEqual(expected.content);
|
|
2289
|
+
expect(data.version).toBe(expected.version);
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
test('attachment-upload-non-owner-without-create-grant-forbidden', async () => {
|
|
2293
|
+
const fixture = find('attachment-upload-non-owner-without-create-grant-forbidden');
|
|
2294
|
+
const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
|
|
2295
|
+
const { status, data } = await dispatch(fixture, { token });
|
|
2296
|
+
expect(status).toBe(fixture.responseStatus);
|
|
2297
|
+
const expected = fixture.responseBody as { error: { code: string } };
|
|
2298
|
+
expect((data as { error: { code: string } }).error.code).toBe(expected.error.code);
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
test('attachment-upload-payload-too-large', async () => {
|
|
2302
|
+
const fixture = find('attachment-upload-payload-too-large');
|
|
2303
|
+
// The fixture's own body doesn't exceed any real limit — per its own
|
|
2304
|
+
// description it stands in for one that does, pinning the error shape
|
|
2305
|
+
// rather than a specific size — so this dispatches against a server
|
|
2306
|
+
// configured with a ceiling below the fixture's body length instead.
|
|
2307
|
+
const smallConfig = { ...testConfig(t.dbPath), maxAttachmentBytes: 2 };
|
|
2308
|
+
const smallApp = createApp(t.ctx, smallConfig, logger);
|
|
2309
|
+
const { status, data } = await dispatch(fixture, { token: TEST_TOKEN, app: smallApp });
|
|
2310
|
+
expect(status).toBe(fixture.responseStatus);
|
|
2311
|
+
const expected = fixture.responseBody as { error: { code: string } };
|
|
2312
|
+
expect((data as { error: { code: string } }).error.code).toBe(expected.error.code);
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
test('coverage', () => {
|
|
2316
|
+
assertCoverage(
|
|
2317
|
+
attachmentUploadFixtures.map((f) => f.name),
|
|
2318
|
+
handled,
|
|
2319
|
+
new Set(),
|
|
2320
|
+
);
|
|
2321
|
+
});
|
|
2322
|
+
});
|