@haverstack/server 0.8.0 → 0.9.1
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/dist/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +70 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changeStreams.d.ts +17 -0
- package/dist/lib/changeStreams.d.ts.map +1 -0
- package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
- package/dist/lib/changeStreams.js.map +1 -0
- package/dist/lib/frameGate.d.ts +26 -0
- package/dist/lib/frameGate.d.ts.map +1 -0
- package/dist/lib/frameGate.js +44 -0
- package/dist/lib/frameGate.js.map +1 -0
- package/dist/lib/json.d.ts +10 -0
- package/dist/lib/json.d.ts.map +1 -0
- package/dist/lib/json.js +27 -0
- package/dist/lib/json.js.map +1 -0
- package/dist/lib/nonceStore.d.ts +50 -0
- package/dist/lib/nonceStore.d.ts.map +1 -0
- package/dist/lib/nonceStore.js +96 -0
- package/dist/lib/nonceStore.js.map +1 -0
- package/dist/lib/queryLimit.d.ts +4 -0
- package/dist/lib/queryLimit.d.ts.map +1 -0
- package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
- package/dist/lib/queryLimit.js.map +1 -0
- package/dist/lib/queryWorker/pool.d.ts +60 -0
- package/dist/lib/queryWorker/pool.d.ts.map +1 -0
- package/dist/lib/queryWorker/pool.js +278 -0
- package/dist/lib/queryWorker/pool.js.map +1 -0
- package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
- package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
- package/dist/lib/queryWorker/protocol.js +2 -0
- package/dist/lib/queryWorker/protocol.js.map +1 -0
- package/dist/lib/queryWorker/worker.d.ts +2 -0
- package/dist/lib/queryWorker/worker.d.ts.map +1 -0
- package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
- package/dist/lib/queryWorker/worker.js.map +1 -0
- package/dist/lib/resumeBuffer.d.ts +143 -0
- package/dist/lib/resumeBuffer.d.ts.map +1 -0
- package/dist/lib/resumeBuffer.js +216 -0
- package/dist/lib/resumeBuffer.js.map +1 -0
- package/dist/lib/resumeCursor.d.ts +16 -0
- package/dist/lib/resumeCursor.d.ts.map +1 -0
- package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
- package/dist/lib/resumeCursor.js.map +1 -0
- package/dist/lib/tokenSweep.d.ts +17 -0
- package/dist/lib/tokenSweep.d.ts.map +1 -0
- package/dist/lib/tokenSweep.js +40 -0
- package/dist/lib/tokenSweep.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/middleware/auth.d.ts +25 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +74 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/errors.d.ts +17 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +64 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/routes/attachments.d.ts +5 -0
- package/dist/routes/attachments.d.ts.map +1 -0
- package/dist/routes/attachments.js +108 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +110 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/changes.d.ts +22 -0
- package/dist/routes/changes.d.ts.map +1 -0
- package/dist/routes/changes.js +233 -0
- package/dist/routes/changes.js.map +1 -0
- package/dist/routes/entity.d.ts +5 -0
- package/dist/routes/entity.d.ts.map +1 -0
- package/dist/routes/entity.js +30 -0
- package/dist/routes/entity.js.map +1 -0
- package/dist/routes/health.d.ts +4 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +7 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/records.d.ts +5 -0
- package/dist/routes/records.d.ts.map +1 -0
- package/dist/routes/records.js +231 -0
- package/dist/routes/records.js.map +1 -0
- package/dist/routes/tokens.d.ts +5 -0
- package/dist/routes/tokens.d.ts.map +1 -0
- package/dist/routes/tokens.js +65 -0
- package/dist/routes/tokens.js.map +1 -0
- package/dist/routes/types.d.ts +5 -0
- package/dist/routes/types.d.ts.map +1 -0
- package/dist/routes/types.js +46 -0
- package/dist/routes/types.js.map +1 -0
- package/dist/routes/wellknown.d.ts +6 -0
- package/dist/routes/wellknown.d.ts.map +1 -0
- package/dist/routes/wellknown.js +45 -0
- package/dist/routes/wellknown.js.map +1 -0
- package/dist/shutdown.d.ts +23 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +32 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/stack.d.ts +20 -0
- package/dist/stack.d.ts.map +1 -0
- package/dist/stack.js +50 -0
- package/dist/stack.js.map +1 -0
- package/dist/testing.d.ts +49 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +130 -0
- package/dist/testing.js.map +1 -0
- package/{src/types.ts → dist/types.d.ts} +5 -5
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
- package/dist/wireError.d.ts.map +1 -0
- package/dist/wireError.js +13 -0
- package/dist/wireError.js.map +1 -0
- package/package.json +42 -17
- package/.claude/settings.local.json +0 -22
- package/.dockerignore +0 -10
- package/.env.example +0 -90
- package/.github/pull_request_template.md +0 -23
- package/.github/workflows/ci.yml +0 -55
- package/.github/workflows/release.yml +0 -99
- package/.prettierignore +0 -3
- package/.prettierrc +0 -7
- package/AGENTS.md +0 -91
- package/CONTRIBUTING.md +0 -234
- package/Dockerfile +0 -30
- package/SECURITY.md +0 -54
- package/docs/api.md +0 -277
- package/docs/deployment.md +0 -212
- package/eslint.config.mjs +0 -18
- package/pnpm-workspace.yaml +0 -13
- package/src/app.ts +0 -86
- package/src/config.ts +0 -176
- package/src/index.ts +0 -54
- package/src/lib/frameGate.ts +0 -42
- package/src/lib/json.ts +0 -27
- package/src/lib/nonceStore.ts +0 -103
- package/src/lib/queryWorker/pool.ts +0 -324
- package/src/lib/resumeBuffer.ts +0 -268
- package/src/lib/tokenSweep.ts +0 -43
- package/src/middleware/auth.ts +0 -77
- package/src/middleware/errors.ts +0 -73
- package/src/routes/attachments.ts +0 -126
- package/src/routes/auth.ts +0 -137
- package/src/routes/changes.ts +0 -279
- package/src/routes/entity.ts +0 -33
- package/src/routes/health.ts +0 -8
- package/src/routes/records.ts +0 -259
- package/src/routes/tokens.ts +0 -85
- package/src/routes/types.ts +0 -57
- package/src/routes/wellknown.ts +0 -55
- package/src/shutdown.ts +0 -56
- package/src/stack.ts +0 -96
- package/tests/app.test.ts +0 -86
- package/tests/changeFeedClient.ts +0 -156
- package/tests/config.test.ts +0 -112
- package/tests/conformance.test.ts +0 -2322
- package/tests/fixtures/blockingWorker.ts +0 -27
- package/tests/fixtures/brokenWorker.ts +0 -7
- package/tests/lib/changeFeedClient.test.ts +0 -126
- package/tests/lib/frameGate.test.ts +0 -93
- package/tests/lib/queryLimit.test.ts +0 -33
- package/tests/lib/queryWorker.test.ts +0 -60
- package/tests/lib/queryWorkerPool.test.ts +0 -129
- package/tests/lib/resumeBuffer.test.ts +0 -341
- package/tests/lib/resumeCursor.test.ts +0 -46
- package/tests/middleware/errors.test.ts +0 -176
- package/tests/routes/associations.test.ts +0 -170
- package/tests/routes/attachments.test.ts +0 -621
- package/tests/routes/auth.test.ts +0 -448
- package/tests/routes/changes.test.ts +0 -567
- package/tests/routes/contentQuery.test.ts +0 -68
- package/tests/routes/entity.test.ts +0 -170
- package/tests/routes/groups.test.ts +0 -69
- package/tests/routes/records.test.ts +0 -1965
- package/tests/routes/relatedToQuery.test.ts +0 -182
- package/tests/routes/tokens.test.ts +0 -157
- package/tests/routes/tombstones.test.ts +0 -93
- package/tests/routes/types.test.ts +0 -129
- package/tests/routes/versions.test.ts +0 -177
- package/tests/routes/wellknown.test.ts +0 -71
- package/tests/setup.ts +0 -154
- package/tests/shutdown.test.ts +0 -121
- package/tests/stack.test.ts +0 -198
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -12
|
@@ -1,1965 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { SYSTEM_TYPES, generateId } from '@haverstack/core';
|
|
3
|
-
import {
|
|
4
|
-
buildTestApp,
|
|
5
|
-
req,
|
|
6
|
-
TEST_TOKEN,
|
|
7
|
-
TEST_ENTITY_ID,
|
|
8
|
-
OTHER_ENTITY_ID,
|
|
9
|
-
type TestApp,
|
|
10
|
-
} from '../setup.js';
|
|
11
|
-
|
|
12
|
-
const NOTE_TYPE_ID = 'com.example.test/note@1';
|
|
13
|
-
const GRANT_TYPE_ID = `${SYSTEM_TYPES.GRANT}@1`;
|
|
14
|
-
|
|
15
|
-
async function seedType(ctx: TestApp['ctx']) {
|
|
16
|
-
return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
|
|
17
|
-
title: { kind: 'string' as const },
|
|
18
|
-
body: { kind: 'text' as const, required: true as const },
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function seedRecord(ctx: TestApp['ctx'], overrides: Record<string, unknown> = {}) {
|
|
23
|
-
return ctx.stack.create(NOTE_TYPE_ID, { body: 'Hello world', ...overrides });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
describe('Records', () => {
|
|
27
|
-
let t: TestApp;
|
|
28
|
-
beforeEach(async () => {
|
|
29
|
-
t = await buildTestApp();
|
|
30
|
-
await seedType(t.ctx);
|
|
31
|
-
});
|
|
32
|
-
afterEach(async () => {
|
|
33
|
-
await t.cleanup();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('POST /records', () => {
|
|
37
|
-
it('creates a record and returns a server-generated id', async () => {
|
|
38
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
39
|
-
token: TEST_TOKEN,
|
|
40
|
-
body: {
|
|
41
|
-
typeId: NOTE_TYPE_ID,
|
|
42
|
-
content: { body: 'Test note' },
|
|
43
|
-
entityId: TEST_ENTITY_ID,
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
expect(status).toBe(200);
|
|
47
|
-
const d = data as Record<string, unknown>;
|
|
48
|
-
expect(typeof d.id).toBe('string');
|
|
49
|
-
expect(d.typeId).toBe(NOTE_TYPE_ID);
|
|
50
|
-
expect((d.content as Record<string, unknown>).body).toBe('Test note');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('returns 400 when typeId is missing', async () => {
|
|
54
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
55
|
-
token: TEST_TOKEN,
|
|
56
|
-
body: { content: { body: 'x' } },
|
|
57
|
-
});
|
|
58
|
-
expect(status).toBe(400);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('returns 401 without auth', async () => {
|
|
62
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
63
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'x' } },
|
|
64
|
-
});
|
|
65
|
-
expect(status).toBe(401);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('returns 400 for a typeId that has not been defined', async () => {
|
|
69
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
70
|
-
token: TEST_TOKEN,
|
|
71
|
-
body: { typeId: 'com.example.test/article@1', content: { body: 'x' } },
|
|
72
|
-
});
|
|
73
|
-
expect(status).toBe(400);
|
|
74
|
-
const d = data as { error: { code: string; message: string } };
|
|
75
|
-
expect(d.error.code).toBe('bad_request');
|
|
76
|
-
expect(d.error.message).toBe(
|
|
77
|
-
'Unknown type: "com.example.test/article@1". Call defineType() first.',
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('returns 422 when a required content field is missing', async () => {
|
|
82
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
83
|
-
token: TEST_TOKEN,
|
|
84
|
-
body: { typeId: NOTE_TYPE_ID, content: {} },
|
|
85
|
-
});
|
|
86
|
-
expect(status).toBe(422);
|
|
87
|
-
const d = data as { error: { code: string; message: string; details: unknown[] } };
|
|
88
|
-
expect(d.error.code).toBe('validation');
|
|
89
|
-
expect(typeof d.error.message).toBe('string');
|
|
90
|
-
expect(Array.isArray(d.error.details)).toBe(true);
|
|
91
|
-
expect(d.error.details.length).toBeGreaterThan(0);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe('GET /records/:id', () => {
|
|
96
|
-
it('returns a record by id', async () => {
|
|
97
|
-
const record = await seedRecord(t.ctx);
|
|
98
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, {
|
|
99
|
-
token: TEST_TOKEN,
|
|
100
|
-
});
|
|
101
|
-
expect(status).toBe(200);
|
|
102
|
-
expect((data as Record<string, unknown>).id).toBe(record.id);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('returns 404 for unknown id', async () => {
|
|
106
|
-
const { status } = await req(t.app, 'GET', '/records/nonexistent', { token: TEST_TOKEN });
|
|
107
|
-
expect(status).toBe(404);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('anonymous gets 404 + WWW-Authenticate for a private record, not 403', async () => {
|
|
111
|
-
const record = await seedRecord(t.ctx);
|
|
112
|
-
const res = await t.app.request(`/records/${record.id}`);
|
|
113
|
-
expect(res.status).toBe(404);
|
|
114
|
-
expect(res.headers.get('WWW-Authenticate')).toBe('Bearer');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('anonymous can read a public record', async () => {
|
|
118
|
-
const record = await t.ctx.stack.create(
|
|
119
|
-
NOTE_TYPE_ID,
|
|
120
|
-
{ body: 'public content' },
|
|
121
|
-
{
|
|
122
|
-
permissions: [{ access: 'public' }],
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}`);
|
|
126
|
-
expect(status).toBe(200);
|
|
127
|
-
expect((data as Record<string, unknown>).id).toBe(record.id);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('entity with a read grant can read the record', async () => {
|
|
131
|
-
const record = await t.ctx.stack.create(
|
|
132
|
-
NOTE_TYPE_ID,
|
|
133
|
-
{ body: 'shared content' },
|
|
134
|
-
{
|
|
135
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
|
|
136
|
-
},
|
|
137
|
-
);
|
|
138
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
139
|
-
const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token });
|
|
140
|
-
expect(status).toBe(200);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('entity without a grant gets 404, not 403, and no WWW-Authenticate (already authenticated)', async () => {
|
|
144
|
-
const record = await seedRecord(t.ctx);
|
|
145
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
146
|
-
const res = await t.app.request(`/records/${record.id}`, {
|
|
147
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
148
|
-
});
|
|
149
|
-
expect(res.status).toBe(404);
|
|
150
|
-
expect(res.headers.get('WWW-Authenticate')).toBeNull();
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('GET /records', () => {
|
|
155
|
-
it('returns records list', async () => {
|
|
156
|
-
await seedRecord(t.ctx, { body: 'Note 1' });
|
|
157
|
-
await seedRecord(t.ctx, { body: 'Note 2' });
|
|
158
|
-
const { status, data } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
159
|
-
expect(status).toBe(200);
|
|
160
|
-
const d = data as { records: unknown[] };
|
|
161
|
-
expect(d.records).toHaveLength(2);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('filters by typeId query param', async () => {
|
|
165
|
-
await seedRecord(t.ctx);
|
|
166
|
-
const { data } = await req(
|
|
167
|
-
t.app,
|
|
168
|
-
'GET',
|
|
169
|
-
`/records?typeId=${encodeURIComponent(NOTE_TYPE_ID)}`,
|
|
170
|
-
{ token: TEST_TOKEN },
|
|
171
|
-
);
|
|
172
|
-
expect((data as { records: unknown[] }).records).toHaveLength(1);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('filters by createdBefore/createdAfter query params', async () => {
|
|
176
|
-
await seedRecord(t.ctx);
|
|
177
|
-
const future = new Date(Date.now() + 60_000).toISOString();
|
|
178
|
-
const past = new Date(0).toISOString();
|
|
179
|
-
|
|
180
|
-
const { data: before } = await req(
|
|
181
|
-
t.app,
|
|
182
|
-
'GET',
|
|
183
|
-
`/records?createdBefore=${encodeURIComponent(future)}`,
|
|
184
|
-
{ token: TEST_TOKEN },
|
|
185
|
-
);
|
|
186
|
-
expect((before as { records: unknown[] }).records).toHaveLength(1);
|
|
187
|
-
|
|
188
|
-
const { data: after } = await req(
|
|
189
|
-
t.app,
|
|
190
|
-
'GET',
|
|
191
|
-
`/records?createdAfter=${encodeURIComponent(future)}`,
|
|
192
|
-
{ token: TEST_TOKEN },
|
|
193
|
-
);
|
|
194
|
-
expect((after as { records: unknown[] }).records).toHaveLength(0);
|
|
195
|
-
|
|
196
|
-
const { data: narrow } = await req(
|
|
197
|
-
t.app,
|
|
198
|
-
'GET',
|
|
199
|
-
`/records?createdAfter=${encodeURIComponent(past)}&createdBefore=${encodeURIComponent(future)}`,
|
|
200
|
-
{ token: TEST_TOKEN },
|
|
201
|
-
);
|
|
202
|
-
expect((narrow as { records: unknown[] }).records).toHaveLength(1);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('filters by relatedTo, narrowed further by relatedToLabel', async () => {
|
|
206
|
-
const target = await seedRecord(t.ctx, { body: 'target' });
|
|
207
|
-
const child = await t.ctx.stack.create(
|
|
208
|
-
NOTE_TYPE_ID,
|
|
209
|
-
{ body: 'child' },
|
|
210
|
-
{
|
|
211
|
-
associations: [
|
|
212
|
-
{
|
|
213
|
-
kind: 'relationship',
|
|
214
|
-
label: 'child',
|
|
215
|
-
target: { scope: 'record', recordId: target.id },
|
|
216
|
-
},
|
|
217
|
-
],
|
|
218
|
-
},
|
|
219
|
-
);
|
|
220
|
-
await t.ctx.stack.create(
|
|
221
|
-
NOTE_TYPE_ID,
|
|
222
|
-
{ body: 'sibling' },
|
|
223
|
-
{
|
|
224
|
-
associations: [
|
|
225
|
-
{
|
|
226
|
-
kind: 'relationship',
|
|
227
|
-
label: 'sibling',
|
|
228
|
-
target: { scope: 'record', recordId: target.id },
|
|
229
|
-
},
|
|
230
|
-
],
|
|
231
|
-
},
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
const { data: unlabeled } = await req(
|
|
235
|
-
t.app,
|
|
236
|
-
'GET',
|
|
237
|
-
`/records?relatedTo=${encodeURIComponent(target.id)}`,
|
|
238
|
-
{ token: TEST_TOKEN },
|
|
239
|
-
);
|
|
240
|
-
expect((unlabeled as { records: Array<{ id: string }> }).records).toHaveLength(2);
|
|
241
|
-
|
|
242
|
-
const { data: labeled } = await req(
|
|
243
|
-
t.app,
|
|
244
|
-
'GET',
|
|
245
|
-
`/records?relatedTo=${encodeURIComponent(target.id)}&relatedToLabel=child`,
|
|
246
|
-
{ token: TEST_TOKEN },
|
|
247
|
-
);
|
|
248
|
-
const labeledRecords = (labeled as { records: Array<{ id: string }> }).records;
|
|
249
|
-
expect(labeledRecords).toHaveLength(1);
|
|
250
|
-
expect(labeledRecords[0]!.id).toBe(child.id);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('filters by relatedToEntity, distinct from relatedTo', async () => {
|
|
254
|
-
const entityChild = await t.ctx.stack.create(
|
|
255
|
-
NOTE_TYPE_ID,
|
|
256
|
-
{ body: 'entity-child' },
|
|
257
|
-
{
|
|
258
|
-
associations: [
|
|
259
|
-
{
|
|
260
|
-
kind: 'relationship',
|
|
261
|
-
label: 'author',
|
|
262
|
-
target: { scope: 'entity', entityId: OTHER_ENTITY_ID },
|
|
263
|
-
},
|
|
264
|
-
],
|
|
265
|
-
},
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
const { status, data } = await req(
|
|
269
|
-
t.app,
|
|
270
|
-
'GET',
|
|
271
|
-
`/records?relatedToEntity=${encodeURIComponent(OTHER_ENTITY_ID)}`,
|
|
272
|
-
{ token: TEST_TOKEN },
|
|
273
|
-
);
|
|
274
|
-
expect(status).toBe(200);
|
|
275
|
-
const records = (data as { records: Array<{ id: string }> }).records;
|
|
276
|
-
expect(records).toHaveLength(1);
|
|
277
|
-
expect(records[0]!.id).toBe(entityChild.id);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('filters by relatedToNs, and relatedToId narrows to one target in the namespace', async () => {
|
|
281
|
-
const syndicated = await t.ctx.stack.create(
|
|
282
|
-
NOTE_TYPE_ID,
|
|
283
|
-
{ body: 'syndicated' },
|
|
284
|
-
{
|
|
285
|
-
associations: [
|
|
286
|
-
{
|
|
287
|
-
kind: 'relationship',
|
|
288
|
-
label: 'syndicated-to',
|
|
289
|
-
target: {
|
|
290
|
-
scope: 'external',
|
|
291
|
-
ns: 'atproto',
|
|
292
|
-
id: 'at://did:plc:abc/app.bsky.feed.post/1',
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
],
|
|
296
|
-
},
|
|
297
|
-
);
|
|
298
|
-
await t.ctx.stack.create(
|
|
299
|
-
NOTE_TYPE_ID,
|
|
300
|
-
{ body: 'other-namespace' },
|
|
301
|
-
{
|
|
302
|
-
associations: [
|
|
303
|
-
{
|
|
304
|
-
kind: 'relationship',
|
|
305
|
-
label: 'syndicated-to',
|
|
306
|
-
target: { scope: 'external', ns: 'activitypub', id: 'https://example.social/1' },
|
|
307
|
-
},
|
|
308
|
-
],
|
|
309
|
-
},
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
const { data: nsOnly } = await req(t.app, 'GET', '/records?relatedToNs=atproto', {
|
|
313
|
-
token: TEST_TOKEN,
|
|
314
|
-
});
|
|
315
|
-
const nsRecords = (nsOnly as { records: Array<{ id: string }> }).records;
|
|
316
|
-
expect(nsRecords).toHaveLength(1);
|
|
317
|
-
expect(nsRecords[0]!.id).toBe(syndicated.id);
|
|
318
|
-
|
|
319
|
-
const { data: nsAndId } = await req(
|
|
320
|
-
t.app,
|
|
321
|
-
'GET',
|
|
322
|
-
`/records?relatedToNs=atproto&relatedToId=${encodeURIComponent('at://did:plc:abc/app.bsky.feed.post/1')}`,
|
|
323
|
-
{ token: TEST_TOKEN },
|
|
324
|
-
);
|
|
325
|
-
const nsIdRecords = (nsAndId as { records: Array<{ id: string }> }).records;
|
|
326
|
-
expect(nsIdRecords).toHaveLength(1);
|
|
327
|
-
expect(nsIdRecords[0]!.id).toBe(syndicated.id);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('relatedToLabel alone (no target) matches every target under that label', async () => {
|
|
331
|
-
const recordChild = await t.ctx.stack.create(
|
|
332
|
-
NOTE_TYPE_ID,
|
|
333
|
-
{ body: 'record-child' },
|
|
334
|
-
{
|
|
335
|
-
associations: [
|
|
336
|
-
{
|
|
337
|
-
kind: 'relationship',
|
|
338
|
-
label: 'reply-to',
|
|
339
|
-
target: { scope: 'record', recordId: (await seedRecord(t.ctx)).id },
|
|
340
|
-
},
|
|
341
|
-
],
|
|
342
|
-
},
|
|
343
|
-
);
|
|
344
|
-
const entityChild = await t.ctx.stack.create(
|
|
345
|
-
NOTE_TYPE_ID,
|
|
346
|
-
{ body: 'entity-child' },
|
|
347
|
-
{
|
|
348
|
-
associations: [
|
|
349
|
-
{
|
|
350
|
-
kind: 'relationship',
|
|
351
|
-
label: 'reply-to',
|
|
352
|
-
target: { scope: 'entity', entityId: OTHER_ENTITY_ID },
|
|
353
|
-
},
|
|
354
|
-
],
|
|
355
|
-
},
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
const { status, data } = await req(t.app, 'GET', '/records?relatedToLabel=reply-to', {
|
|
359
|
-
token: TEST_TOKEN,
|
|
360
|
-
});
|
|
361
|
-
expect(status).toBe(200);
|
|
362
|
-
const ids = (data as { records: Array<{ id: string }> }).records.map((r) => r.id).sort();
|
|
363
|
-
expect(ids).toEqual([recordChild.id, entityChild.id].sort());
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it('an absent relatedToStack matches only local record targets', async () => {
|
|
367
|
-
const target = await seedRecord(t.ctx, { body: 'target' });
|
|
368
|
-
const local = await t.ctx.stack.create(
|
|
369
|
-
NOTE_TYPE_ID,
|
|
370
|
-
{ body: 'local' },
|
|
371
|
-
{
|
|
372
|
-
associations: [
|
|
373
|
-
{
|
|
374
|
-
kind: 'relationship',
|
|
375
|
-
label: 'ref',
|
|
376
|
-
target: { scope: 'record', recordId: target.id },
|
|
377
|
-
},
|
|
378
|
-
],
|
|
379
|
-
},
|
|
380
|
-
);
|
|
381
|
-
await t.ctx.stack.create(
|
|
382
|
-
NOTE_TYPE_ID,
|
|
383
|
-
{ body: 'remote' },
|
|
384
|
-
{
|
|
385
|
-
associations: [
|
|
386
|
-
{
|
|
387
|
-
kind: 'relationship',
|
|
388
|
-
label: 'ref',
|
|
389
|
-
target: {
|
|
390
|
-
scope: 'record',
|
|
391
|
-
recordId: target.id,
|
|
392
|
-
stackUrl: 'https://other.example/stack',
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
],
|
|
396
|
-
},
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
const { data: withoutStack } = await req(
|
|
400
|
-
t.app,
|
|
401
|
-
'GET',
|
|
402
|
-
`/records?relatedTo=${encodeURIComponent(target.id)}`,
|
|
403
|
-
{ token: TEST_TOKEN },
|
|
404
|
-
);
|
|
405
|
-
const withoutStackRecords = (withoutStack as { records: Array<{ id: string }> }).records;
|
|
406
|
-
expect(withoutStackRecords).toHaveLength(1);
|
|
407
|
-
expect(withoutStackRecords[0]!.id).toBe(local.id);
|
|
408
|
-
|
|
409
|
-
const { data: withStack } = await req(
|
|
410
|
-
t.app,
|
|
411
|
-
'GET',
|
|
412
|
-
`/records?relatedTo=${encodeURIComponent(target.id)}&relatedToStack=${encodeURIComponent('https://other.example/stack')}`,
|
|
413
|
-
{ token: TEST_TOKEN },
|
|
414
|
-
);
|
|
415
|
-
const withStackRecords = (withStack as { records: Array<{ id: string }> }).records;
|
|
416
|
-
expect(withStackRecords).toHaveLength(1);
|
|
417
|
-
expect(withStackRecords[0]!.id).not.toBe(local.id);
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
// Mixed relatedTo scopes, and a qualifier param without the param it
|
|
421
|
-
// qualifies, are parsing rejections pinned once at the unit level in
|
|
422
|
-
// @haverstack/core's wire-request.test.ts rather than here — see
|
|
423
|
-
// server#114. What stays below is validation deeper than parsing: an
|
|
424
|
-
// empty relatedToStack/relatedToId passes the parser raw and is only
|
|
425
|
-
// rejected by target validation, which the parser doesn't own.
|
|
426
|
-
|
|
427
|
-
it('rejects an empty relatedToStack with 400 rather than treating it as local or a wildcard', async () => {
|
|
428
|
-
const target = await seedRecord(t.ctx, { body: 'target' });
|
|
429
|
-
const { status, data } = await req(
|
|
430
|
-
t.app,
|
|
431
|
-
'GET',
|
|
432
|
-
`/records?relatedTo=${encodeURIComponent(target.id)}&relatedToStack=`,
|
|
433
|
-
{ token: TEST_TOKEN },
|
|
434
|
-
);
|
|
435
|
-
expect(status).toBe(400);
|
|
436
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('rejects an empty relatedToId with 400', async () => {
|
|
440
|
-
const { status, data } = await req(
|
|
441
|
-
t.app,
|
|
442
|
-
'GET',
|
|
443
|
-
'/records?relatedToNs=atproto&relatedToId=',
|
|
444
|
-
{
|
|
445
|
-
token: TEST_TOKEN,
|
|
446
|
-
},
|
|
447
|
-
);
|
|
448
|
-
expect(status).toBe(400);
|
|
449
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it('anonymous query returns only public records', async () => {
|
|
453
|
-
await seedRecord(t.ctx, { body: 'private' });
|
|
454
|
-
await t.ctx.stack.create(
|
|
455
|
-
NOTE_TYPE_ID,
|
|
456
|
-
{ body: 'public' },
|
|
457
|
-
{ permissions: [{ access: 'public' }] },
|
|
458
|
-
);
|
|
459
|
-
const { data } = await req(t.app, 'GET', '/records');
|
|
460
|
-
const d = data as { records: Array<{ content: { body: string } }> };
|
|
461
|
-
expect(d.records).toHaveLength(1);
|
|
462
|
-
expect(d.records[0].content.body).toBe('public');
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
describe('POST /records/query', () => {
|
|
467
|
-
it('accepts a full StackQuery body', async () => {
|
|
468
|
-
await seedRecord(t.ctx);
|
|
469
|
-
const { status, data } = await req(t.app, 'POST', '/records/query', {
|
|
470
|
-
token: TEST_TOKEN,
|
|
471
|
-
body: {
|
|
472
|
-
filter: { typeId: NOTE_TYPE_ID },
|
|
473
|
-
sort: { field: 'createdAt', direction: 'desc' },
|
|
474
|
-
limit: 10,
|
|
475
|
-
},
|
|
476
|
-
});
|
|
477
|
-
expect(status).toBe(200);
|
|
478
|
-
const d = data as { records: unknown[] };
|
|
479
|
-
expect(d.records).toHaveLength(1);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
it('anonymous query returns only public records', async () => {
|
|
483
|
-
await seedRecord(t.ctx, { body: 'private' });
|
|
484
|
-
await t.ctx.stack.create(
|
|
485
|
-
NOTE_TYPE_ID,
|
|
486
|
-
{ body: 'public' },
|
|
487
|
-
{ permissions: [{ access: 'public' }] },
|
|
488
|
-
);
|
|
489
|
-
const { status, data } = await req(t.app, 'POST', '/records/query', {
|
|
490
|
-
body: { filter: { typeId: NOTE_TYPE_ID } },
|
|
491
|
-
});
|
|
492
|
-
expect(status).toBe(200);
|
|
493
|
-
const d = data as { records: Array<{ content: { body: string } }> };
|
|
494
|
-
expect(d.records).toHaveLength(1);
|
|
495
|
-
expect(d.records[0].content.body).toBe('public');
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
describe('PATCH /records/:id', () => {
|
|
500
|
-
it('merges content (does not overwrite unmentioned fields)', async () => {
|
|
501
|
-
const record = await seedRecord(t.ctx, { body: 'original', title: 'My title' });
|
|
502
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
503
|
-
token: TEST_TOKEN,
|
|
504
|
-
body: { contentPatch: { body: 'Updated body' } },
|
|
505
|
-
});
|
|
506
|
-
expect(status).toBe(200);
|
|
507
|
-
const d = data as Record<string, unknown>;
|
|
508
|
-
const content = d.content as Record<string, unknown>;
|
|
509
|
-
expect(content.body).toBe('Updated body');
|
|
510
|
-
expect(content.title).toBe('My title');
|
|
511
|
-
expect(d.version).toBe(2);
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
it('snapshots the previous version on update', async () => {
|
|
515
|
-
const record = await seedRecord(t.ctx);
|
|
516
|
-
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
517
|
-
token: TEST_TOKEN,
|
|
518
|
-
body: { contentPatch: { body: 'v2' } },
|
|
519
|
-
});
|
|
520
|
-
const versions = await t.ctx.adapter.getVersions(record.id);
|
|
521
|
-
expect(versions).toHaveLength(1);
|
|
522
|
-
expect(versions[0].version).toBe(1);
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
it('returns 403 when requester lacks write access', async () => {
|
|
526
|
-
const record = await t.ctx.stack.create(
|
|
527
|
-
NOTE_TYPE_ID,
|
|
528
|
-
{ body: 'hi' },
|
|
529
|
-
{
|
|
530
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
|
|
531
|
-
},
|
|
532
|
-
);
|
|
533
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
534
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
535
|
-
token,
|
|
536
|
-
body: { contentPatch: { body: 'hacked' } },
|
|
537
|
-
});
|
|
538
|
-
expect(status).toBe(403);
|
|
539
|
-
});
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
describe('DELETE /records/:id', () => {
|
|
543
|
-
it('soft-deletes by default, answering with the deleted record', async () => {
|
|
544
|
-
const record = await seedRecord(t.ctx);
|
|
545
|
-
const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}`, {
|
|
546
|
-
token: TEST_TOKEN,
|
|
547
|
-
});
|
|
548
|
-
expect(status).toBe(200);
|
|
549
|
-
expect((data as Record<string, unknown>).deletedAt).toBeDefined();
|
|
550
|
-
const after = await t.ctx.adapter.getRecord(record.id);
|
|
551
|
-
expect(after?.deletedAt).toBeDefined();
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it('hard-deletes with ?hard=true (owner)', async () => {
|
|
555
|
-
const record = await seedRecord(t.ctx);
|
|
556
|
-
const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, {
|
|
557
|
-
token: TEST_TOKEN,
|
|
558
|
-
});
|
|
559
|
-
expect(status).toBe(204);
|
|
560
|
-
expect(await t.ctx.adapter.getRecord(record.id)).toBeNull();
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
it('non-owner with write access can soft-delete', async () => {
|
|
564
|
-
const record = await t.ctx.stack.create(
|
|
565
|
-
NOTE_TYPE_ID,
|
|
566
|
-
{ body: 'shared' },
|
|
567
|
-
{
|
|
568
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
569
|
-
},
|
|
570
|
-
);
|
|
571
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
572
|
-
const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token });
|
|
573
|
-
expect(status).toBe(200);
|
|
574
|
-
const after = await t.ctx.adapter.getRecord(record.id);
|
|
575
|
-
expect(after?.deletedAt).toBeDefined();
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it('non-owner gets 403 on hard delete even with write access', async () => {
|
|
579
|
-
const record = await t.ctx.stack.create(
|
|
580
|
-
NOTE_TYPE_ID,
|
|
581
|
-
{ body: 'shared' },
|
|
582
|
-
{
|
|
583
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
584
|
-
},
|
|
585
|
-
);
|
|
586
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
587
|
-
const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token });
|
|
588
|
-
expect(status).toBe(403);
|
|
589
|
-
expect(await t.ctx.adapter.getRecord(record.id)).not.toBeNull();
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('succeeds when If-Match names the current version', async () => {
|
|
593
|
-
const record = await seedRecord(t.ctx);
|
|
594
|
-
const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, {
|
|
595
|
-
token: TEST_TOKEN,
|
|
596
|
-
headers: { 'If-Match': `"${record.version}"` },
|
|
597
|
-
});
|
|
598
|
-
expect(status).toBe(200);
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
it('returns 412 version_conflict on an If-Match mismatch, leaving the record untouched', async () => {
|
|
602
|
-
const record = await seedRecord(t.ctx);
|
|
603
|
-
const { status, data } = await req(t.app, 'DELETE', `/records/${record.id}`, {
|
|
604
|
-
token: TEST_TOKEN,
|
|
605
|
-
headers: { 'If-Match': `"${record.version + 1}"` },
|
|
606
|
-
});
|
|
607
|
-
expect(status).toBe(412);
|
|
608
|
-
expect((data as { error: { code: string } }).error.code).toBe('version_conflict');
|
|
609
|
-
expect((await t.ctx.adapter.getRecord(record.id))?.deletedAt).toBeUndefined();
|
|
610
|
-
});
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
describe('_attachment@1 immutable field protection', () => {
|
|
614
|
-
// Enforced by ScopedStack now (docs/spec/attachments.md § The _attachment
|
|
615
|
-
// record type) — putAttachment() itself returns the created record, so
|
|
616
|
-
// no separate query is needed to find it.
|
|
617
|
-
async function seedAttachment(ctx: TestApp['ctx']) {
|
|
618
|
-
const record = await ctx.stack.putAttachment(
|
|
619
|
-
new TextEncoder().encode('hello'),
|
|
620
|
-
'text/plain',
|
|
621
|
-
'hello.txt',
|
|
622
|
-
);
|
|
623
|
-
return { fileId: (record.content as { fileId: string }).fileId, record };
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function detailPaths(data: unknown): string[] {
|
|
627
|
-
return (data as { error: { details: Array<{ path: string }> } }).error.details.map(
|
|
628
|
-
(d) => d.path,
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
it('returns 422 when PATCH attempts to change fileId', async () => {
|
|
633
|
-
const { record } = await seedAttachment(t.ctx);
|
|
634
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
635
|
-
token: TEST_TOKEN,
|
|
636
|
-
body: { contentPatch: { fileId: 'tampered-id' } },
|
|
637
|
-
});
|
|
638
|
-
expect(status).toBe(422);
|
|
639
|
-
expect(detailPaths(data)).toContain('fileId');
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it('returns 422 when PATCH attempts to change size', async () => {
|
|
643
|
-
const { record } = await seedAttachment(t.ctx);
|
|
644
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
645
|
-
token: TEST_TOKEN,
|
|
646
|
-
body: { contentPatch: { size: 9999 } },
|
|
647
|
-
});
|
|
648
|
-
expect(status).toBe(422);
|
|
649
|
-
expect(detailPaths(data)).toContain('size');
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
it('returns 422 when PATCH attempts to change mimeType', async () => {
|
|
653
|
-
const { record } = await seedAttachment(t.ctx);
|
|
654
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
655
|
-
token: TEST_TOKEN,
|
|
656
|
-
body: { contentPatch: { mimeType: 'text/html' } },
|
|
657
|
-
});
|
|
658
|
-
expect(status).toBe(422);
|
|
659
|
-
expect(detailPaths(data)).toContain('mimeType');
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
it('reports all attempted immutable fields', async () => {
|
|
663
|
-
const { record } = await seedAttachment(t.ctx);
|
|
664
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
665
|
-
token: TEST_TOKEN,
|
|
666
|
-
body: { contentPatch: { fileId: 'x', size: 0, mimeType: 'text/html' } },
|
|
667
|
-
});
|
|
668
|
-
expect(status).toBe(422);
|
|
669
|
-
const paths = detailPaths(data);
|
|
670
|
-
expect(paths).toContain('fileId');
|
|
671
|
-
expect(paths).toContain('size');
|
|
672
|
-
expect(paths).toContain('mimeType');
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
it('allows PATCH to change filename', async () => {
|
|
676
|
-
const { record } = await seedAttachment(t.ctx);
|
|
677
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
678
|
-
token: TEST_TOKEN,
|
|
679
|
-
body: { contentPatch: { filename: 'renamed.txt' } },
|
|
680
|
-
});
|
|
681
|
-
expect(status).toBe(200);
|
|
682
|
-
const content = (data as { content: Record<string, unknown> }).content;
|
|
683
|
-
expect(content.filename).toBe('renamed.txt');
|
|
684
|
-
expect(content.fileId).toBeDefined();
|
|
685
|
-
});
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
describe('_grant@1 write protection', () => {
|
|
689
|
-
it('refuses to grant privileges on _grant/_config/_app (privilege-escalation guard)', async () => {
|
|
690
|
-
// stack.grant() forecloses the escalation at the source: a grant
|
|
691
|
-
// targeting _grant cannot be handed out at all, so a non-owner has
|
|
692
|
-
// nothing to escalate with.
|
|
693
|
-
await expect(
|
|
694
|
-
t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['update-own'], typeId: GRANT_TYPE_ID }]),
|
|
695
|
-
).rejects.toThrow(/privilege escalation/);
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
it('returns 403 when non-owner tries to PATCH a grant record, even with direct write permission on it', async () => {
|
|
699
|
-
// ScopedStack refuses writes to any _grant record unconditionally,
|
|
700
|
-
// "whatever its own permissions say" — set an explicit write grant on
|
|
701
|
-
// this one record (bypassing stack.grant()'s own refusal by creating
|
|
702
|
-
// it directly) to prove the fence holds regardless.
|
|
703
|
-
const grantRecord = await t.ctx.stack.create(
|
|
704
|
-
GRANT_TYPE_ID,
|
|
705
|
-
{ typeId: NOTE_TYPE_ID, actions: ['read-own'] },
|
|
706
|
-
{
|
|
707
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
708
|
-
},
|
|
709
|
-
);
|
|
710
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
711
|
-
|
|
712
|
-
const { status } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, {
|
|
713
|
-
token,
|
|
714
|
-
body: { contentPatch: { actions: ['read-any', 'update-any', 'delete-any'] } },
|
|
715
|
-
});
|
|
716
|
-
expect(status).toBe(403);
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
it('returns 403 when non-owner tries to soft-DELETE a grant record, even with direct write permission on it', async () => {
|
|
720
|
-
// stack.grant() doesn't give the grantee read access to the grant
|
|
721
|
-
// record itself, only to what it grants — which would now 404 under
|
|
722
|
-
// the anti-oracle rule rather than exercise the write-protection fence
|
|
723
|
-
// this test targets. Set an explicit read+write permission directly on
|
|
724
|
-
// the grant record instead (same approach as the PATCH sibling above)
|
|
725
|
-
// so the requester can read it, and the refusal proven here is really
|
|
726
|
-
// "_grant records refuse deletion" rather than "can't read it".
|
|
727
|
-
const grantRecord = await t.ctx.stack.create(
|
|
728
|
-
GRANT_TYPE_ID,
|
|
729
|
-
{ typeId: NOTE_TYPE_ID, actions: ['read-own'] },
|
|
730
|
-
{
|
|
731
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
732
|
-
},
|
|
733
|
-
);
|
|
734
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
735
|
-
|
|
736
|
-
const { status } = await req(t.app, 'DELETE', `/records/${grantRecord.id}`, { token });
|
|
737
|
-
expect(status).toBe(403);
|
|
738
|
-
expect(await t.ctx.adapter.getRecord(grantRecord.id)).not.toBeNull();
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
it('allows the owner to PATCH a grant record', async () => {
|
|
742
|
-
const [grantRecord] = await t.ctx.stack.grant(OTHER_ENTITY_ID, [
|
|
743
|
-
{ actions: ['read-own'], typeId: NOTE_TYPE_ID },
|
|
744
|
-
]);
|
|
745
|
-
|
|
746
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, {
|
|
747
|
-
token: TEST_TOKEN,
|
|
748
|
-
body: { contentPatch: { actions: ['read-own', 'read-any'] } },
|
|
749
|
-
});
|
|
750
|
-
expect(status).toBe(200);
|
|
751
|
-
const content = (data as { content: Record<string, unknown> }).content;
|
|
752
|
-
expect(content.actions).toEqual(['read-own', 'read-any']);
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
it('allows the owner to soft-DELETE a grant record', async () => {
|
|
756
|
-
const [grantRecord] = await t.ctx.stack.grant(OTHER_ENTITY_ID, [
|
|
757
|
-
{ actions: ['read-own'], typeId: NOTE_TYPE_ID },
|
|
758
|
-
]);
|
|
759
|
-
|
|
760
|
-
const { status } = await req(t.app, 'DELETE', `/records/${grantRecord.id}`, {
|
|
761
|
-
token: TEST_TOKEN,
|
|
762
|
-
});
|
|
763
|
-
expect(status).toBe(200);
|
|
764
|
-
const after = await t.ctx.adapter.getRecord(grantRecord.id);
|
|
765
|
-
expect(after?.deletedAt).toBeDefined();
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
it('blocks escalation: a write-holder on a grant record cannot expand its actions', async () => {
|
|
769
|
-
// stack.grant() itself now refuses to grant update-own on _grant (see
|
|
770
|
-
// the first test in this block), so the escalation vector this test
|
|
771
|
-
// guards against is reached the only way still possible: a grant
|
|
772
|
-
// record with an explicit record-level write permission on itself.
|
|
773
|
-
const readGrant = await t.ctx.stack.create(
|
|
774
|
-
GRANT_TYPE_ID,
|
|
775
|
-
{ typeId: NOTE_TYPE_ID, actions: ['read-own'] },
|
|
776
|
-
{
|
|
777
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
778
|
-
},
|
|
779
|
-
);
|
|
780
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
781
|
-
|
|
782
|
-
const { status } = await req(t.app, 'PATCH', `/records/${readGrant.id}`, {
|
|
783
|
-
token,
|
|
784
|
-
body: {
|
|
785
|
-
contentPatch: { typeId: NOTE_TYPE_ID, actions: ['read-any', 'update-any', 'delete-any'] },
|
|
786
|
-
},
|
|
787
|
-
});
|
|
788
|
-
expect(status).toBe(403);
|
|
789
|
-
|
|
790
|
-
const unchanged = await t.ctx.adapter.getRecord(readGrant.id);
|
|
791
|
-
expect((unchanged?.content as { actions: string[] }).actions).toEqual(['read-own']);
|
|
792
|
-
});
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
// All Stack-level invariants (docs/spec.md § The _config record) —
|
|
796
|
-
// inherited for free, so the server's job is only to test them.
|
|
797
|
-
describe('_config protection', () => {
|
|
798
|
-
it('refuses to delete _config, soft or hard', async () => {
|
|
799
|
-
const { status: soft } = await req(t.app, 'DELETE', '/records/_config', {
|
|
800
|
-
token: TEST_TOKEN,
|
|
801
|
-
});
|
|
802
|
-
expect(soft).toBe(409);
|
|
803
|
-
|
|
804
|
-
const { status: hard } = await req(t.app, 'DELETE', '/records/_config?hard=true', {
|
|
805
|
-
token: TEST_TOKEN,
|
|
806
|
-
});
|
|
807
|
-
expect(hard).toBe(409);
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
it('refuses to change _config.entityId', async () => {
|
|
811
|
-
const { status } = await req(t.app, 'PATCH', '/records/_config', {
|
|
812
|
-
token: TEST_TOKEN,
|
|
813
|
-
body: { contentPatch: { entityId: 'someone-else' } },
|
|
814
|
-
});
|
|
815
|
-
expect(status).toBe(409);
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
it('is invisible to query() regardless of filter', async () => {
|
|
819
|
-
const { data } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
820
|
-
const records = (data as { records: Array<{ typeId: string }> }).records;
|
|
821
|
-
expect(records.some((r) => r.typeId.startsWith('_config'))).toBe(false);
|
|
822
|
-
|
|
823
|
-
const { data: filtered } = await req(t.app, 'GET', '/records?typeId=_config@1', {
|
|
824
|
-
token: TEST_TOKEN,
|
|
825
|
-
});
|
|
826
|
-
expect((filtered as { records: unknown[] }).records).toHaveLength(0);
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
it('is still addressable directly by id for the owner', async () => {
|
|
830
|
-
const { status, data } = await req(t.app, 'GET', '/records/_config', { token: TEST_TOKEN });
|
|
831
|
-
expect(status).toBe(200);
|
|
832
|
-
expect((data as { id: string }).id).toBe('_config');
|
|
833
|
-
});
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
describe('_app binding rules', () => {
|
|
837
|
-
const APP_TYPE_ID = `${SYSTEM_TYPES.APP}@1`;
|
|
838
|
-
|
|
839
|
-
it('refuses a non-owner create attempt (owner-only to set)', async () => {
|
|
840
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
841
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
842
|
-
token,
|
|
843
|
-
body: {
|
|
844
|
-
typeId: APP_TYPE_ID,
|
|
845
|
-
content: { did: 'did:key:zNonOwner', appId: 'app-x', name: 'X' },
|
|
846
|
-
},
|
|
847
|
-
});
|
|
848
|
-
expect(status).toBe(403);
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
it('refuses to grant create on _app (privilege-escalation guard, same as _grant/_config)', async () => {
|
|
852
|
-
await expect(
|
|
853
|
-
t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: APP_TYPE_ID }]),
|
|
854
|
-
).rejects.toThrow(/privilege escalation/);
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
it('refuses a second binding claiming an already-bound did', async () => {
|
|
858
|
-
await req(t.app, 'POST', '/records', {
|
|
859
|
-
token: TEST_TOKEN,
|
|
860
|
-
body: {
|
|
861
|
-
typeId: APP_TYPE_ID,
|
|
862
|
-
content: { did: 'did:key:zShared', appId: 'app-1', name: 'One' },
|
|
863
|
-
},
|
|
864
|
-
});
|
|
865
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
866
|
-
token: TEST_TOKEN,
|
|
867
|
-
body: {
|
|
868
|
-
typeId: APP_TYPE_ID,
|
|
869
|
-
content: { did: 'did:key:zShared', appId: 'app-2', name: 'Two' },
|
|
870
|
-
},
|
|
871
|
-
});
|
|
872
|
-
expect(status).toBe(409);
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
it('refuses to change did or appId once set, but allows name', async () => {
|
|
876
|
-
const { data } = await req(t.app, 'POST', '/records', {
|
|
877
|
-
token: TEST_TOKEN,
|
|
878
|
-
body: {
|
|
879
|
-
typeId: APP_TYPE_ID,
|
|
880
|
-
content: { did: 'did:key:zImmutable', appId: 'app-immutable', name: 'Original' },
|
|
881
|
-
},
|
|
882
|
-
});
|
|
883
|
-
const id = (data as { id: string }).id;
|
|
884
|
-
|
|
885
|
-
const { status: didChange } = await req(t.app, 'PATCH', `/records/${id}`, {
|
|
886
|
-
token: TEST_TOKEN,
|
|
887
|
-
body: { contentPatch: { did: 'did:key:zChanged' } },
|
|
888
|
-
});
|
|
889
|
-
expect(didChange).toBe(422);
|
|
890
|
-
|
|
891
|
-
const { status: appIdChange } = await req(t.app, 'PATCH', `/records/${id}`, {
|
|
892
|
-
token: TEST_TOKEN,
|
|
893
|
-
body: { contentPatch: { appId: 'changed' } },
|
|
894
|
-
});
|
|
895
|
-
expect(appIdChange).toBe(422);
|
|
896
|
-
|
|
897
|
-
const { status: nameChange, data: renamed } = await req(t.app, 'PATCH', `/records/${id}`, {
|
|
898
|
-
token: TEST_TOKEN,
|
|
899
|
-
body: { contentPatch: { name: 'Renamed' } },
|
|
900
|
-
});
|
|
901
|
-
expect(nameChange).toBe(200);
|
|
902
|
-
expect((renamed as { content: { name: string } }).content.name).toBe('Renamed');
|
|
903
|
-
});
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
describe('reserved content keys', () => {
|
|
907
|
-
// A wire body arrives as JSON text; JSON.parse (unlike a JS object
|
|
908
|
-
// literal) creates a genuine own property named "__proto__", which is
|
|
909
|
-
// exactly the shape a malicious request body would take.
|
|
910
|
-
for (const key of ['__proto__', 'constructor', 'prototype']) {
|
|
911
|
-
it(`rejects "${key}" as a content field with 422`, async () => {
|
|
912
|
-
const body = JSON.parse(
|
|
913
|
-
`{"typeId":"${NOTE_TYPE_ID}","content":{"body":"x","${key}":{"evil":true}}}`,
|
|
914
|
-
);
|
|
915
|
-
const { status, data } = await req(t.app, 'POST', '/records', { token: TEST_TOKEN, body });
|
|
916
|
-
expect(status).toBe(422);
|
|
917
|
-
const details = (data as { error: { details: Array<{ path: string }> } }).error.details;
|
|
918
|
-
expect(details.some((d) => d.path === key)).toBe(true);
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
describe('non-owner _attachment@1 direct create', () => {
|
|
924
|
-
const ATTACHMENT_TYPE_ID = `${SYSTEM_TYPES.ATTACHMENT}@1`;
|
|
925
|
-
|
|
926
|
-
it('refuses a create-grant holder with no readable reference to the fileId', async () => {
|
|
927
|
-
const bytes = new TextEncoder().encode('unreferenced');
|
|
928
|
-
const record = await t.ctx.stack.putAttachment(bytes, 'text/plain');
|
|
929
|
-
const fileId = (record.content as { fileId: string }).fileId;
|
|
930
|
-
await t.ctx.stack.grant(OTHER_ENTITY_ID, [
|
|
931
|
-
{ actions: ['create'], typeId: ATTACHMENT_TYPE_ID },
|
|
932
|
-
]);
|
|
933
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
934
|
-
|
|
935
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
936
|
-
token,
|
|
937
|
-
body: {
|
|
938
|
-
typeId: ATTACHMENT_TYPE_ID,
|
|
939
|
-
content: { fileId, mimeType: 'text/plain', size: bytes.byteLength },
|
|
940
|
-
},
|
|
941
|
-
});
|
|
942
|
-
expect(status).toBe(403);
|
|
943
|
-
});
|
|
944
|
-
|
|
945
|
-
it('allows a create-grant holder who can already read a record referencing the fileId', async () => {
|
|
946
|
-
const bytes = new TextEncoder().encode('referenced');
|
|
947
|
-
const record = await t.ctx.stack.putAttachment(bytes, 'text/plain');
|
|
948
|
-
const fileId = (record.content as { fileId: string }).fileId;
|
|
949
|
-
await t.ctx.stack.create(
|
|
950
|
-
NOTE_TYPE_ID,
|
|
951
|
-
{ body: 'ref' },
|
|
952
|
-
{
|
|
953
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
|
|
954
|
-
associations: [{ kind: 'attachment', label: 'file', fileId }],
|
|
955
|
-
},
|
|
956
|
-
);
|
|
957
|
-
await t.ctx.stack.grant(OTHER_ENTITY_ID, [
|
|
958
|
-
{ actions: ['create'], typeId: ATTACHMENT_TYPE_ID },
|
|
959
|
-
]);
|
|
960
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
961
|
-
|
|
962
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
963
|
-
token,
|
|
964
|
-
body: {
|
|
965
|
-
typeId: ATTACHMENT_TYPE_ID,
|
|
966
|
-
content: { fileId, mimeType: 'text/plain', size: bytes.byteLength },
|
|
967
|
-
},
|
|
968
|
-
});
|
|
969
|
-
expect(status).toBe(200);
|
|
970
|
-
});
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
describe('Delegation (principal/subject)', () => {
|
|
974
|
-
const APP_ID = 'app-entity-id-00000004';
|
|
975
|
-
const SUBJECT_ID = 'subject-entity-id-00000005';
|
|
976
|
-
|
|
977
|
-
it('a delegated session stamps principalId and attributes entityId to the subject', async () => {
|
|
978
|
-
// Delegated authority is the intersection of both parties' grants.
|
|
979
|
-
await t.ctx.stack.grant(SUBJECT_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
980
|
-
await t.ctx.stack.grant(APP_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
981
|
-
const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID });
|
|
982
|
-
|
|
983
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
984
|
-
token,
|
|
985
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'delegated note' } },
|
|
986
|
-
});
|
|
987
|
-
expect(status).toBe(200);
|
|
988
|
-
const d = data as Record<string, unknown>;
|
|
989
|
-
expect(d.entityId).toBe(SUBJECT_ID);
|
|
990
|
-
expect(d.principalId).toBe(APP_ID);
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
it('a delegated create is refused when only one party holds the create grant', async () => {
|
|
994
|
-
await t.ctx.stack.grant(SUBJECT_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
995
|
-
// APP_ID (the principal) has no grant of its own.
|
|
996
|
-
const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID });
|
|
997
|
-
|
|
998
|
-
const { status } = await req(t.app, 'POST', '/records', {
|
|
999
|
-
token,
|
|
1000
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'should be refused' } },
|
|
1001
|
-
});
|
|
1002
|
-
expect(status).toBe(403);
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
it('filters by ?principalId=', async () => {
|
|
1006
|
-
await t.ctx.stack.grant(SUBJECT_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1007
|
-
await t.ctx.stack.grant(APP_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1008
|
-
const { token } = await t.ctx.adapter.createToken(APP_ID, { onBehalfOf: SUBJECT_ID });
|
|
1009
|
-
await req(t.app, 'POST', '/records', {
|
|
1010
|
-
token,
|
|
1011
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'delegated note' } },
|
|
1012
|
-
});
|
|
1013
|
-
await seedRecord(t.ctx); // owner-authored, no principalId
|
|
1014
|
-
|
|
1015
|
-
const { data } = await req(
|
|
1016
|
-
t.app,
|
|
1017
|
-
'GET',
|
|
1018
|
-
`/records?principalId=${encodeURIComponent(APP_ID)}`,
|
|
1019
|
-
{ token: TEST_TOKEN },
|
|
1020
|
-
);
|
|
1021
|
-
const records = (data as { records: Array<{ principalId?: string }> }).records;
|
|
1022
|
-
expect(records).toHaveLength(1);
|
|
1023
|
-
expect(records[0].principalId).toBe(APP_ID);
|
|
1024
|
-
});
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
describe('POST /records — client-minted id', () => {
|
|
1028
|
-
it('accepts a client-supplied id', async () => {
|
|
1029
|
-
const id = generateId();
|
|
1030
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1031
|
-
token: TEST_TOKEN,
|
|
1032
|
-
body: { id, typeId: NOTE_TYPE_ID, content: { body: 'minted' } },
|
|
1033
|
-
});
|
|
1034
|
-
expect(status).toBe(200);
|
|
1035
|
-
expect((data as { id: string }).id).toBe(id);
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
it('generates an id when none is supplied', async () => {
|
|
1039
|
-
const { data } = await req(t.app, 'POST', '/records', {
|
|
1040
|
-
token: TEST_TOKEN,
|
|
1041
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'generated' } },
|
|
1042
|
-
});
|
|
1043
|
-
expect(typeof (data as { id: string }).id).toBe('string');
|
|
1044
|
-
expect((data as { id: string }).id).not.toBe('');
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
it('rejects a malformed-charset id with 400 bad_request', async () => {
|
|
1048
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1049
|
-
token: TEST_TOKEN,
|
|
1050
|
-
body: { id: '1hk153x0000!', typeId: NOTE_TYPE_ID, content: { body: 'x' } },
|
|
1051
|
-
});
|
|
1052
|
-
expect(status).toBe(400);
|
|
1053
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
it('rejects a wrong-length id with 400 bad_request', async () => {
|
|
1057
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1058
|
-
token: TEST_TOKEN,
|
|
1059
|
-
body: { id: 'short-id', typeId: NOTE_TYPE_ID, content: { body: 'x' } },
|
|
1060
|
-
});
|
|
1061
|
-
expect(status).toBe(400);
|
|
1062
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
it('rejects a reserved "_" prefix id with 400 bad_request', async () => {
|
|
1066
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1067
|
-
token: TEST_TOKEN,
|
|
1068
|
-
body: { id: '_hk153x00001', typeId: NOTE_TYPE_ID, content: { body: 'x' } },
|
|
1069
|
-
});
|
|
1070
|
-
expect(status).toBe(400);
|
|
1071
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
it('rejects a duplicate id with 409 conflict', async () => {
|
|
1075
|
-
const id = generateId();
|
|
1076
|
-
await req(t.app, 'POST', '/records', {
|
|
1077
|
-
token: TEST_TOKEN,
|
|
1078
|
-
body: { id, typeId: NOTE_TYPE_ID, content: { body: 'first' } },
|
|
1079
|
-
});
|
|
1080
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1081
|
-
token: TEST_TOKEN,
|
|
1082
|
-
body: { id, typeId: NOTE_TYPE_ID, content: { body: 'second' } },
|
|
1083
|
-
});
|
|
1084
|
-
expect(status).toBe(409);
|
|
1085
|
-
expect((data as { error: { code: string } }).error.code).toBe('conflict');
|
|
1086
|
-
});
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
describe('POST /records — createdAt/updatedAt (backdating on import)', () => {
|
|
1090
|
-
it('owner acting alone can backdate createdAt, and updatedAt defaults to it', async () => {
|
|
1091
|
-
// id omitted: derived from createdAt's timestamp rather than "now".
|
|
1092
|
-
const createdAt = new Date('2020-01-01T00:00:00.000Z');
|
|
1093
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1094
|
-
token: TEST_TOKEN,
|
|
1095
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'imported' }, createdAt },
|
|
1096
|
-
});
|
|
1097
|
-
expect(status).toBe(200);
|
|
1098
|
-
const d = data as { createdAt: string; updatedAt: string };
|
|
1099
|
-
expect(new Date(d.createdAt)).toEqual(createdAt);
|
|
1100
|
-
expect(new Date(d.updatedAt)).toEqual(createdAt);
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
it('owner acting alone can set updatedAt separately from createdAt', async () => {
|
|
1104
|
-
const createdAt = new Date('2020-01-01T00:00:00.000Z');
|
|
1105
|
-
const updatedAt = new Date('2020-06-01T00:00:00.000Z');
|
|
1106
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1107
|
-
token: TEST_TOKEN,
|
|
1108
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'imported' }, createdAt, updatedAt },
|
|
1109
|
-
});
|
|
1110
|
-
expect(status).toBe(200);
|
|
1111
|
-
const d = data as { createdAt: string; updatedAt: string };
|
|
1112
|
-
expect(new Date(d.createdAt)).toEqual(createdAt);
|
|
1113
|
-
expect(new Date(d.updatedAt)).toEqual(updatedAt);
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
it('rejects updatedAt earlier than createdAt with 422 validation', async () => {
|
|
1117
|
-
const createdAt = new Date('2020-06-01T00:00:00.000Z');
|
|
1118
|
-
const updatedAt = new Date('2020-01-01T00:00:00.000Z');
|
|
1119
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1120
|
-
token: TEST_TOKEN,
|
|
1121
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'x' }, createdAt, updatedAt },
|
|
1122
|
-
});
|
|
1123
|
-
expect(status).toBe(422);
|
|
1124
|
-
expect((data as { error: { code: string } }).error.code).toBe('validation');
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
it('rejects updatedAt earlier than a defaulted (now) createdAt with 422 validation', async () => {
|
|
1128
|
-
const updatedAt = new Date('2020-01-01T00:00:00.000Z');
|
|
1129
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1130
|
-
token: TEST_TOKEN,
|
|
1131
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'x' }, updatedAt },
|
|
1132
|
-
});
|
|
1133
|
-
expect(status).toBe(422);
|
|
1134
|
-
expect((data as { error: { code: string } }).error.code).toBe('validation');
|
|
1135
|
-
});
|
|
1136
|
-
|
|
1137
|
-
it('rejects a malformed createdAt with 422 validation instead of forwarding an Invalid Date', async () => {
|
|
1138
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1139
|
-
token: TEST_TOKEN,
|
|
1140
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'x' }, createdAt: 'not-a-date' },
|
|
1141
|
-
});
|
|
1142
|
-
expect(status).toBe(422);
|
|
1143
|
-
const d = data as { error: { code: string; details: unknown[] } };
|
|
1144
|
-
expect(d.error.code).toBe('validation');
|
|
1145
|
-
expect(d.error.details).toEqual([
|
|
1146
|
-
{ path: 'createdAt', message: 'Invalid createdAt: "not-a-date"' },
|
|
1147
|
-
]);
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
it("an owner's plain id-only create is unaffected — ordinary id-vs-now skew, not id-vs-createdAt", async () => {
|
|
1151
|
-
const id = generateId();
|
|
1152
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1153
|
-
token: TEST_TOKEN,
|
|
1154
|
-
body: { id, typeId: NOTE_TYPE_ID, content: { body: 'x' } },
|
|
1155
|
-
});
|
|
1156
|
-
expect(status).toBe(200);
|
|
1157
|
-
expect((data as { id: string }).id).toBe(id);
|
|
1158
|
-
});
|
|
1159
|
-
|
|
1160
|
-
it('drops createdAt/updatedAt from a grantee create instead of forwarding them into a 403', async () => {
|
|
1161
|
-
await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1162
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1163
|
-
const createdAt = new Date('2020-01-01T00:00:00.000Z');
|
|
1164
|
-
const before = Date.now();
|
|
1165
|
-
|
|
1166
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1167
|
-
token,
|
|
1168
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'not backdated' }, createdAt },
|
|
1169
|
-
});
|
|
1170
|
-
|
|
1171
|
-
expect(status).toBe(200);
|
|
1172
|
-
const d = data as { createdAt: string };
|
|
1173
|
-
expect(new Date(d.createdAt).getTime()).toBeGreaterThanOrEqual(before);
|
|
1174
|
-
});
|
|
1175
|
-
|
|
1176
|
-
it('drops createdAt/updatedAt from a delegated session with the owner as principal', async () => {
|
|
1177
|
-
const { token } = await t.ctx.adapter.createToken(TEST_ENTITY_ID, {
|
|
1178
|
-
onBehalfOf: OTHER_ENTITY_ID,
|
|
1179
|
-
});
|
|
1180
|
-
await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1181
|
-
await t.ctx.stack.grant(TEST_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1182
|
-
const createdAt = new Date('2020-01-01T00:00:00.000Z');
|
|
1183
|
-
const before = Date.now();
|
|
1184
|
-
|
|
1185
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1186
|
-
token,
|
|
1187
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'not backdated' }, createdAt },
|
|
1188
|
-
});
|
|
1189
|
-
|
|
1190
|
-
expect(status).toBe(200);
|
|
1191
|
-
const d = data as { createdAt: string };
|
|
1192
|
-
expect(new Date(d.createdAt).getTime()).toBeGreaterThanOrEqual(before);
|
|
1193
|
-
});
|
|
1194
|
-
});
|
|
1195
|
-
|
|
1196
|
-
describe('POST /records — unlistedAt', () => {
|
|
1197
|
-
it('unlistedAt in the body creates the record already unlisted', async () => {
|
|
1198
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1199
|
-
token: TEST_TOKEN,
|
|
1200
|
-
body: {
|
|
1201
|
-
typeId: NOTE_TYPE_ID,
|
|
1202
|
-
content: { body: 'link-shared draft' },
|
|
1203
|
-
unlistedAt: new Date().toISOString(),
|
|
1204
|
-
},
|
|
1205
|
-
});
|
|
1206
|
-
expect(status).toBe(200);
|
|
1207
|
-
expect((data as { unlistedAt?: string }).unlistedAt).toBeDefined();
|
|
1208
|
-
});
|
|
1209
|
-
|
|
1210
|
-
it('an unlisted create is absent from an unfiltered GET /records', async () => {
|
|
1211
|
-
const { data: created } = await req(t.app, 'POST', '/records', {
|
|
1212
|
-
token: TEST_TOKEN,
|
|
1213
|
-
body: {
|
|
1214
|
-
typeId: NOTE_TYPE_ID,
|
|
1215
|
-
content: { body: 'link-shared draft' },
|
|
1216
|
-
unlistedAt: new Date().toISOString(),
|
|
1217
|
-
},
|
|
1218
|
-
});
|
|
1219
|
-
const id = (created as { id: string }).id;
|
|
1220
|
-
|
|
1221
|
-
const { data: listed } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
1222
|
-
const ids = (listed as { records: { id: string }[] }).records.map((r) => r.id);
|
|
1223
|
-
expect(ids).not.toContain(id);
|
|
1224
|
-
|
|
1225
|
-
// Still reachable by id — unlisted withholds enumeration, not access.
|
|
1226
|
-
const { status: getStatus } = await req(t.app, 'GET', `/records/${id}`, {
|
|
1227
|
-
token: TEST_TOKEN,
|
|
1228
|
-
});
|
|
1229
|
-
expect(getStatus).toBe(200);
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
it('a non-owner grantee with a plain create grant may create unlisted — not owner-only', async () => {
|
|
1233
|
-
await t.ctx.stack.grant(OTHER_ENTITY_ID, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1234
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1235
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1236
|
-
token,
|
|
1237
|
-
body: {
|
|
1238
|
-
typeId: NOTE_TYPE_ID,
|
|
1239
|
-
content: { body: 'not the owner' },
|
|
1240
|
-
unlistedAt: new Date().toISOString(),
|
|
1241
|
-
},
|
|
1242
|
-
});
|
|
1243
|
-
expect(status).toBe(200);
|
|
1244
|
-
expect((data as { unlistedAt?: string }).unlistedAt).toBeDefined();
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
it('refuses unlistedAt from a delegated app acting for a non-owner subject', async () => {
|
|
1248
|
-
// Neither identity is the stack owner, so mayGrantAccess() — the same
|
|
1249
|
-
// gate the permissions field uses at create time — refuses: a
|
|
1250
|
-
// delegated app carries none of the owner's extra trust, and this
|
|
1251
|
-
// record is the subject's own to begin with.
|
|
1252
|
-
const appId = 'com.example.thirdparty';
|
|
1253
|
-
const subjectId = 'entity-third-party-subject';
|
|
1254
|
-
await t.ctx.stack.grant(subjectId, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1255
|
-
await t.ctx.stack.grant(appId, [{ actions: ['create'], typeId: NOTE_TYPE_ID }]);
|
|
1256
|
-
const { token } = await t.ctx.adapter.createToken(appId, { onBehalfOf: subjectId });
|
|
1257
|
-
|
|
1258
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1259
|
-
token,
|
|
1260
|
-
body: {
|
|
1261
|
-
typeId: NOTE_TYPE_ID,
|
|
1262
|
-
content: { body: 'delegated' },
|
|
1263
|
-
unlistedAt: new Date().toISOString(),
|
|
1264
|
-
},
|
|
1265
|
-
});
|
|
1266
|
-
expect(status).toBe(403);
|
|
1267
|
-
expect((data as { error: { code: string } }).error.code).toBe('permission');
|
|
1268
|
-
});
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
|
-
describe('PATCH /records/:id — If-Match / optimistic concurrency', () => {
|
|
1272
|
-
it('succeeds when If-Match names the current version', async () => {
|
|
1273
|
-
const record = await seedRecord(t.ctx);
|
|
1274
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1275
|
-
token: TEST_TOKEN,
|
|
1276
|
-
body: { contentPatch: { body: 'updated' } },
|
|
1277
|
-
headers: { 'If-Match': `"${record.version}"` },
|
|
1278
|
-
});
|
|
1279
|
-
expect(status).toBe(200);
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
it('returns 412 version_conflict with the versionConflict payload on a mismatch', async () => {
|
|
1283
|
-
const record = await seedRecord(t.ctx);
|
|
1284
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1285
|
-
token: TEST_TOKEN,
|
|
1286
|
-
body: { contentPatch: { body: 'updated' } },
|
|
1287
|
-
headers: { 'If-Match': `"${record.version + 1}"` },
|
|
1288
|
-
});
|
|
1289
|
-
expect(status).toBe(412);
|
|
1290
|
-
const body = data as {
|
|
1291
|
-
error: { code: string; versionConflict: Record<string, unknown> };
|
|
1292
|
-
};
|
|
1293
|
-
expect(body.error.code).toBe('version_conflict');
|
|
1294
|
-
expect(body.error.versionConflict).toMatchObject({
|
|
1295
|
-
recordId: record.id,
|
|
1296
|
-
expectedVersion: record.version + 1,
|
|
1297
|
-
actualVersion: record.version,
|
|
1298
|
-
});
|
|
1299
|
-
});
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
describe('POST /records/:id/undelete', () => {
|
|
1303
|
-
it('reverses a soft delete and returns the record with deletedAt absent', async () => {
|
|
1304
|
-
const record = await seedRecord(t.ctx);
|
|
1305
|
-
await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
1306
|
-
|
|
1307
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/undelete`, {
|
|
1308
|
-
token: TEST_TOKEN,
|
|
1309
|
-
});
|
|
1310
|
-
expect(status).toBe(200);
|
|
1311
|
-
const body = data as Record<string, unknown>;
|
|
1312
|
-
expect(body.id).toBe(record.id);
|
|
1313
|
-
expect(body.deletedAt).toBeUndefined();
|
|
1314
|
-
|
|
1315
|
-
const stored = await t.ctx.adapter.getRecord(record.id);
|
|
1316
|
-
expect(stored?.deletedAt).toBeUndefined();
|
|
1317
|
-
});
|
|
1318
|
-
|
|
1319
|
-
it('is idempotent — a second call returns the same result', async () => {
|
|
1320
|
-
const record = await seedRecord(t.ctx);
|
|
1321
|
-
await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
1322
|
-
|
|
1323
|
-
const first = await req(t.app, 'POST', `/records/${record.id}/undelete`, {
|
|
1324
|
-
token: TEST_TOKEN,
|
|
1325
|
-
});
|
|
1326
|
-
const second = await req(t.app, 'POST', `/records/${record.id}/undelete`, {
|
|
1327
|
-
token: TEST_TOKEN,
|
|
1328
|
-
});
|
|
1329
|
-
expect(first.status).toBe(200);
|
|
1330
|
-
expect(second.status).toBe(200);
|
|
1331
|
-
});
|
|
1332
|
-
|
|
1333
|
-
it('returns 403 for a non-owner without write access', async () => {
|
|
1334
|
-
const record = await t.ctx.stack.create(
|
|
1335
|
-
NOTE_TYPE_ID,
|
|
1336
|
-
{ body: 'shared' },
|
|
1337
|
-
{
|
|
1338
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
|
|
1339
|
-
},
|
|
1340
|
-
);
|
|
1341
|
-
await t.ctx.stack.delete(record.id);
|
|
1342
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1343
|
-
|
|
1344
|
-
const { status } = await req(t.app, 'POST', `/records/${record.id}/undelete`, { token });
|
|
1345
|
-
expect(status).toBe(403);
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
it('returns 404 for a record that does not exist', async () => {
|
|
1349
|
-
const { status } = await req(t.app, 'POST', '/records/nonexistent/undelete', {
|
|
1350
|
-
token: TEST_TOKEN,
|
|
1351
|
-
});
|
|
1352
|
-
expect(status).toBe(404);
|
|
1353
|
-
});
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
describe('the permissions change-set key, and GET /records/:id/permissions', () => {
|
|
1357
|
-
it('the permissions key replaces permissions and returns the updated record', async () => {
|
|
1358
|
-
const record = await seedRecord(t.ctx);
|
|
1359
|
-
const res = await t.app.request(`/records/${record.id}`, {
|
|
1360
|
-
method: 'PATCH',
|
|
1361
|
-
headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'application/json' },
|
|
1362
|
-
body: JSON.stringify({ permissions: [{ access: 'public' }] }),
|
|
1363
|
-
});
|
|
1364
|
-
expect(res.status).toBe(200);
|
|
1365
|
-
const body = (await res.json()) as { permissions: unknown[] };
|
|
1366
|
-
expect(body.permissions).toEqual([{ access: 'public' }]);
|
|
1367
|
-
|
|
1368
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1369
|
-
const { status: anonStatus } = await req(t.app, 'GET', `/records/${record.id}`);
|
|
1370
|
-
expect(anonStatus).toBe(200);
|
|
1371
|
-
const { status: otherStatus } = await req(t.app, 'GET', `/records/${record.id}`, { token });
|
|
1372
|
-
expect(otherStatus).toBe(200);
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
it('an empty permissions array makes the record private', async () => {
|
|
1376
|
-
const record = await t.ctx.stack.create(
|
|
1377
|
-
NOTE_TYPE_ID,
|
|
1378
|
-
{ body: 'was public' },
|
|
1379
|
-
{ permissions: [{ access: 'public' }] },
|
|
1380
|
-
);
|
|
1381
|
-
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1382
|
-
token: TEST_TOKEN,
|
|
1383
|
-
body: { permissions: [] },
|
|
1384
|
-
});
|
|
1385
|
-
// Anonymous can't tell "made private" from "never existed" — the
|
|
1386
|
-
// anti-oracle rule — so 404 + WWW-Authenticate, not 403 or a
|
|
1387
|
-
// bodyless 401.
|
|
1388
|
-
const res = await t.app.request(`/records/${record.id}`);
|
|
1389
|
-
expect(res.status).toBe(404);
|
|
1390
|
-
expect(res.headers.get('WWW-Authenticate')).toBe('Bearer');
|
|
1391
|
-
});
|
|
1392
|
-
|
|
1393
|
-
it('GET returns the current permissions', async () => {
|
|
1394
|
-
const record = await t.ctx.stack.create(
|
|
1395
|
-
NOTE_TYPE_ID,
|
|
1396
|
-
{ body: 'x' },
|
|
1397
|
-
{ permissions: [{ access: 'public' }] },
|
|
1398
|
-
);
|
|
1399
|
-
const { status, data } = await req(t.app, 'GET', `/records/${record.id}/permissions`, {
|
|
1400
|
-
token: TEST_TOKEN,
|
|
1401
|
-
});
|
|
1402
|
-
expect(status).toBe(200);
|
|
1403
|
-
expect((data as { permissions: unknown[] }).permissions).toEqual([{ access: 'public' }]);
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
it('returns 403 when a non-owner without write access names the key', async () => {
|
|
1407
|
-
const record = await t.ctx.stack.create(
|
|
1408
|
-
NOTE_TYPE_ID,
|
|
1409
|
-
{ body: 'x' },
|
|
1410
|
-
{
|
|
1411
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
|
|
1412
|
-
},
|
|
1413
|
-
);
|
|
1414
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1415
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1416
|
-
token,
|
|
1417
|
-
body: { permissions: [{ access: 'public' }] },
|
|
1418
|
-
});
|
|
1419
|
-
expect(status).toBe(403);
|
|
1420
|
-
});
|
|
1421
|
-
|
|
1422
|
-
it("returns the record even when the change revokes the caller's own read access", async () => {
|
|
1423
|
-
// The permissions key is gated on mayReshare(), owner-or-creator rather
|
|
1424
|
-
// than plain write access — OTHER_ENTITY_ID must be the record's
|
|
1425
|
-
// author, not merely a grantee, to reach it as a non-owner.
|
|
1426
|
-
const record = await t.ctx.stack.create(
|
|
1427
|
-
NOTE_TYPE_ID,
|
|
1428
|
-
{ body: 'x' },
|
|
1429
|
-
{ entityId: OTHER_ENTITY_ID },
|
|
1430
|
-
);
|
|
1431
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1432
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1433
|
-
token,
|
|
1434
|
-
body: { permissions: [] },
|
|
1435
|
-
});
|
|
1436
|
-
expect(status).toBe(200);
|
|
1437
|
-
expect((data as { id: string }).id).toBe(record.id);
|
|
1438
|
-
|
|
1439
|
-
// Confirms the write actually landed and the caller lost read access —
|
|
1440
|
-
// otherwise the 200 above wouldn't distinguish this from any other change.
|
|
1441
|
-
const { status: followUp } = await req(t.app, 'GET', `/records/${record.id}`, { token });
|
|
1442
|
-
expect(followUp).toBe(404);
|
|
1443
|
-
});
|
|
1444
|
-
|
|
1445
|
-
it('succeeds when If-Match names the current version', async () => {
|
|
1446
|
-
const record = await seedRecord(t.ctx);
|
|
1447
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1448
|
-
token: TEST_TOKEN,
|
|
1449
|
-
body: { permissions: [{ access: 'public' }] },
|
|
1450
|
-
headers: { 'If-Match': `"${record.version}"` },
|
|
1451
|
-
});
|
|
1452
|
-
expect(status).toBe(200);
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
it('returns 412 version_conflict on an If-Match mismatch', async () => {
|
|
1456
|
-
const record = await seedRecord(t.ctx);
|
|
1457
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1458
|
-
token: TEST_TOKEN,
|
|
1459
|
-
body: { permissions: [{ access: 'public' }] },
|
|
1460
|
-
headers: { 'If-Match': `"${record.version + 1}"` },
|
|
1461
|
-
});
|
|
1462
|
-
expect(status).toBe(412);
|
|
1463
|
-
expect((data as { error: { code: string } }).error.code).toBe('version_conflict');
|
|
1464
|
-
});
|
|
1465
|
-
});
|
|
1466
|
-
|
|
1467
|
-
describe('the unlisted change-set key', () => {
|
|
1468
|
-
it('withholds a record from enumeration without changing who may read it', async () => {
|
|
1469
|
-
const record = await t.ctx.stack.create(
|
|
1470
|
-
NOTE_TYPE_ID,
|
|
1471
|
-
{ body: 'x' },
|
|
1472
|
-
{ permissions: [{ access: 'public' }] },
|
|
1473
|
-
);
|
|
1474
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1475
|
-
token: TEST_TOKEN,
|
|
1476
|
-
body: { unlisted: true },
|
|
1477
|
-
});
|
|
1478
|
-
expect(status).toBe(200);
|
|
1479
|
-
expect(typeof (data as { unlistedAt?: string }).unlistedAt).toBe('string');
|
|
1480
|
-
// permissions untouched — orthogonal fields.
|
|
1481
|
-
expect((data as { version: number }).version).toBe(record.version + 1);
|
|
1482
|
-
|
|
1483
|
-
// Still directly reachable by anyone who could already read it.
|
|
1484
|
-
const anon = await req(t.app, 'GET', `/records/${record.id}`);
|
|
1485
|
-
expect(anon.status).toBe(200);
|
|
1486
|
-
|
|
1487
|
-
// But absent from an unfiltered query.
|
|
1488
|
-
const { data: queried } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
1489
|
-
expect(
|
|
1490
|
-
(queried as { records: Array<{ id: string }> }).records.some((r) => r.id === record.id),
|
|
1491
|
-
).toBe(false);
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
it('{ unlisted: false } relists a record', async () => {
|
|
1495
|
-
const record = await seedRecord(t.ctx);
|
|
1496
|
-
await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1497
|
-
token: TEST_TOKEN,
|
|
1498
|
-
body: { unlisted: true },
|
|
1499
|
-
});
|
|
1500
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1501
|
-
token: TEST_TOKEN,
|
|
1502
|
-
body: { unlisted: false },
|
|
1503
|
-
});
|
|
1504
|
-
expect(status).toBe(200);
|
|
1505
|
-
expect((data as { unlistedAt?: string }).unlistedAt).toBeUndefined();
|
|
1506
|
-
|
|
1507
|
-
const { data: queried } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
1508
|
-
expect(
|
|
1509
|
-
(queried as { records: Array<{ id: string }> }).records.some((r) => r.id === record.id),
|
|
1510
|
-
).toBe(true);
|
|
1511
|
-
});
|
|
1512
|
-
|
|
1513
|
-
// A key that addresses nothing is 400; a key whose value is the wrong
|
|
1514
|
-
// shape is 422. `unlisted` is a change-set key the envelope recognizes,
|
|
1515
|
-
// so a non-boolean is the second of those.
|
|
1516
|
-
it('rejects a non-boolean unlisted value with 422', async () => {
|
|
1517
|
-
const record = await seedRecord(t.ctx);
|
|
1518
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1519
|
-
token: TEST_TOKEN,
|
|
1520
|
-
body: { unlisted: 'true' },
|
|
1521
|
-
});
|
|
1522
|
-
expect(status).toBe(422);
|
|
1523
|
-
const details = (data as { error: { details: Array<{ path: string }> } }).error.details;
|
|
1524
|
-
expect(details.some((d) => d.path === 'unlisted')).toBe(true);
|
|
1525
|
-
});
|
|
1526
|
-
|
|
1527
|
-
// The same 422 is owed by every key carrying a structured value, not just
|
|
1528
|
-
// the boolean one. `permissions` and `associations` are each replaced
|
|
1529
|
-
// wholesale by the key, so a non-array is the shape error rather than a
|
|
1530
|
-
// content one — and without this, a core bump that quietly stopped
|
|
1531
|
-
// rejecting either would pass the whole suite on the `unlisted` case alone.
|
|
1532
|
-
it.each([
|
|
1533
|
-
['permissions', { permissions: 'nope' }],
|
|
1534
|
-
['associations', { associations: 'nope' }],
|
|
1535
|
-
])('rejects a non-array %s value with 422', async (key, body) => {
|
|
1536
|
-
const record = await seedRecord(t.ctx);
|
|
1537
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1538
|
-
token: TEST_TOKEN,
|
|
1539
|
-
body,
|
|
1540
|
-
});
|
|
1541
|
-
expect(status).toBe(422);
|
|
1542
|
-
const details = (data as { error: { details: Array<{ path: string }> } }).error.details;
|
|
1543
|
-
expect(details.some((d) => d.path === key)).toBe(true);
|
|
1544
|
-
});
|
|
1545
|
-
|
|
1546
|
-
it('rejects an unrecognized change-set key with 400', async () => {
|
|
1547
|
-
const record = await seedRecord(t.ctx);
|
|
1548
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1549
|
-
token: TEST_TOKEN,
|
|
1550
|
-
body: { unlistd: true },
|
|
1551
|
-
});
|
|
1552
|
-
expect(status).toBe(400);
|
|
1553
|
-
});
|
|
1554
|
-
|
|
1555
|
-
it('rejects an empty change set with 400', async () => {
|
|
1556
|
-
const record = await seedRecord(t.ctx);
|
|
1557
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1558
|
-
token: TEST_TOKEN,
|
|
1559
|
-
body: {},
|
|
1560
|
-
});
|
|
1561
|
-
expect(status).toBe(400);
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
it('returns 403 when a non-owner without write access names the key', async () => {
|
|
1565
|
-
const record = await t.ctx.stack.create(
|
|
1566
|
-
NOTE_TYPE_ID,
|
|
1567
|
-
{ body: 'x' },
|
|
1568
|
-
{
|
|
1569
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }],
|
|
1570
|
-
},
|
|
1571
|
-
);
|
|
1572
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1573
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1574
|
-
token,
|
|
1575
|
-
body: { unlisted: true },
|
|
1576
|
-
});
|
|
1577
|
-
expect(status).toBe(403);
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
it('returns 409 on a soft-deleted record', async () => {
|
|
1581
|
-
const record = await seedRecord(t.ctx);
|
|
1582
|
-
await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN });
|
|
1583
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1584
|
-
token: TEST_TOKEN,
|
|
1585
|
-
body: { unlisted: true },
|
|
1586
|
-
});
|
|
1587
|
-
expect(status).toBe(409);
|
|
1588
|
-
expect((data as { error: { code: string } }).error.code).toBe('conflict');
|
|
1589
|
-
});
|
|
1590
|
-
|
|
1591
|
-
it('succeeds when If-Match names the current version', async () => {
|
|
1592
|
-
const record = await seedRecord(t.ctx);
|
|
1593
|
-
const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1594
|
-
token: TEST_TOKEN,
|
|
1595
|
-
body: { unlisted: true },
|
|
1596
|
-
headers: { 'If-Match': `"${record.version}"` },
|
|
1597
|
-
});
|
|
1598
|
-
expect(status).toBe(200);
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
it('returns 412 version_conflict on an If-Match mismatch', async () => {
|
|
1602
|
-
const record = await seedRecord(t.ctx);
|
|
1603
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1604
|
-
token: TEST_TOKEN,
|
|
1605
|
-
body: { unlisted: true },
|
|
1606
|
-
headers: { 'If-Match': `"${record.version + 1}"` },
|
|
1607
|
-
});
|
|
1608
|
-
expect(status).toBe(412);
|
|
1609
|
-
expect((data as { error: { code: string } }).error.code).toBe('version_conflict');
|
|
1610
|
-
});
|
|
1611
|
-
});
|
|
1612
|
-
|
|
1613
|
-
describe('includeUnlisted', () => {
|
|
1614
|
-
it('is excluded by default from GET /records and POST /records/query', async () => {
|
|
1615
|
-
const record = await seedRecord(t.ctx);
|
|
1616
|
-
await t.ctx.stack.mutate(record.id, { unlisted: true });
|
|
1617
|
-
|
|
1618
|
-
const getRes = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
1619
|
-
expect(
|
|
1620
|
-
(getRes.data as { records: Array<{ id: string }> }).records.some((r) => r.id === record.id),
|
|
1621
|
-
).toBe(false);
|
|
1622
|
-
|
|
1623
|
-
const postRes = await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, body: {} });
|
|
1624
|
-
expect(
|
|
1625
|
-
(postRes.data as { records: Array<{ id: string }> }).records.some(
|
|
1626
|
-
(r) => r.id === record.id,
|
|
1627
|
-
),
|
|
1628
|
-
).toBe(false);
|
|
1629
|
-
});
|
|
1630
|
-
|
|
1631
|
-
it('surfaces unlisted records for the owner acting alone', async () => {
|
|
1632
|
-
const record = await seedRecord(t.ctx);
|
|
1633
|
-
await t.ctx.stack.mutate(record.id, { unlisted: true });
|
|
1634
|
-
|
|
1635
|
-
const { data } = await req(t.app, 'GET', '/records?includeUnlisted=true', {
|
|
1636
|
-
token: TEST_TOKEN,
|
|
1637
|
-
});
|
|
1638
|
-
expect(
|
|
1639
|
-
(data as { records: Array<{ id: string }> }).records.some((r) => r.id === record.id),
|
|
1640
|
-
).toBe(true);
|
|
1641
|
-
|
|
1642
|
-
const { data: postData } = await req(t.app, 'POST', '/records/query', {
|
|
1643
|
-
token: TEST_TOKEN,
|
|
1644
|
-
body: { filter: { includeUnlisted: true } },
|
|
1645
|
-
});
|
|
1646
|
-
expect(
|
|
1647
|
-
(postData as { records: Array<{ id: string }> }).records.some((r) => r.id === record.id),
|
|
1648
|
-
).toBe(true);
|
|
1649
|
-
});
|
|
1650
|
-
|
|
1651
|
-
it('is refused with 403 for a non-owner, on both query endpoints', async () => {
|
|
1652
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1653
|
-
|
|
1654
|
-
const getRes = await req(t.app, 'GET', '/records?includeUnlisted=true', { token });
|
|
1655
|
-
expect(getRes.status).toBe(403);
|
|
1656
|
-
|
|
1657
|
-
const postRes = await req(t.app, 'POST', '/records/query', {
|
|
1658
|
-
token,
|
|
1659
|
-
body: { filter: { includeUnlisted: true } },
|
|
1660
|
-
});
|
|
1661
|
-
expect(postRes.status).toBe(403);
|
|
1662
|
-
});
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1665
|
-
describe('reserved content keys — PATCH', () => {
|
|
1666
|
-
for (const key of ['__proto__', 'constructor', 'prototype']) {
|
|
1667
|
-
it(`rejects "${key}" as a patch field with 422`, async () => {
|
|
1668
|
-
const record = await seedRecord(t.ctx);
|
|
1669
|
-
const body = JSON.parse(`{"${key}":{"evil":true}}`);
|
|
1670
|
-
const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, {
|
|
1671
|
-
token: TEST_TOKEN,
|
|
1672
|
-
body: { contentPatch: body },
|
|
1673
|
-
});
|
|
1674
|
-
expect(status).toBe(422);
|
|
1675
|
-
const details = (data as { error: { details: Array<{ path: string }> } }).error.details;
|
|
1676
|
-
expect(details.some((d) => d.path === key)).toBe(true);
|
|
1677
|
-
});
|
|
1678
|
-
}
|
|
1679
|
-
});
|
|
1680
|
-
|
|
1681
|
-
describe('Pagination defaults', () => {
|
|
1682
|
-
it('omits limit -> returns one default-sized page, not everything', async () => {
|
|
1683
|
-
for (let i = 0; i < 55; i++) {
|
|
1684
|
-
await seedRecord(t.ctx, { body: `note ${i}` });
|
|
1685
|
-
}
|
|
1686
|
-
const { data } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN });
|
|
1687
|
-
const body = data as { records: unknown[]; cursor: string | null };
|
|
1688
|
-
expect(body.records.length).toBeLessThan(55);
|
|
1689
|
-
expect(body.records.length).toBeGreaterThan(0);
|
|
1690
|
-
expect(body.cursor).not.toBeNull();
|
|
1691
|
-
});
|
|
1692
|
-
});
|
|
1693
|
-
|
|
1694
|
-
describe('Query error shapes', () => {
|
|
1695
|
-
it('returns 400 bad_request for a malformed cursor', async () => {
|
|
1696
|
-
const { status, data } = await req(t.app, 'POST', '/records/query', {
|
|
1697
|
-
token: TEST_TOKEN,
|
|
1698
|
-
body: { cursor: 'not-a-valid-cursor' },
|
|
1699
|
-
});
|
|
1700
|
-
expect(status).toBe(400);
|
|
1701
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
1702
|
-
});
|
|
1703
|
-
|
|
1704
|
-
it('returns 400 bad_request for a cursor naming an unknown sort field', async () => {
|
|
1705
|
-
const badCursor = Buffer.from('badfield|123|1hk153x00001').toString('base64');
|
|
1706
|
-
const { status, data } = await req(t.app, 'POST', '/records/query', {
|
|
1707
|
-
token: TEST_TOKEN,
|
|
1708
|
-
body: { cursor: badCursor },
|
|
1709
|
-
});
|
|
1710
|
-
expect(status).toBe(400);
|
|
1711
|
-
expect((data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
1712
|
-
});
|
|
1713
|
-
|
|
1714
|
-
function expectBadRequest(result: { status: number; data: unknown }) {
|
|
1715
|
-
expect(result.status).toBe(400);
|
|
1716
|
-
expect((result.data as { error: { code: string } }).error.code).toBe('bad_request');
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
describe('limit', () => {
|
|
1720
|
-
it('GET /records?limit=abc does not silently become NaN', async () => {
|
|
1721
|
-
expectBadRequest(await req(t.app, 'GET', '/records?limit=abc', { token: TEST_TOKEN }));
|
|
1722
|
-
});
|
|
1723
|
-
|
|
1724
|
-
it('GET /records?limit=-5 is rejected, not passed through negative', async () => {
|
|
1725
|
-
expectBadRequest(await req(t.app, 'GET', '/records?limit=-5', { token: TEST_TOKEN }));
|
|
1726
|
-
});
|
|
1727
|
-
|
|
1728
|
-
it('GET /records?limit=2.7 is rejected, not truncated', async () => {
|
|
1729
|
-
expectBadRequest(await req(t.app, 'GET', '/records?limit=2.7', { token: TEST_TOKEN }));
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
it('POST /records/query with limit: -5 is rejected', async () => {
|
|
1733
|
-
expectBadRequest(
|
|
1734
|
-
await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, body: { limit: -5 } }),
|
|
1735
|
-
);
|
|
1736
|
-
});
|
|
1737
|
-
|
|
1738
|
-
it('POST /records/query with limit: 2.7 is rejected', async () => {
|
|
1739
|
-
expectBadRequest(
|
|
1740
|
-
await req(t.app, 'POST', '/records/query', { token: TEST_TOKEN, body: { limit: 2.7 } }),
|
|
1741
|
-
);
|
|
1742
|
-
});
|
|
1743
|
-
|
|
1744
|
-
it('POST /records/query with a positive integer limit still succeeds', async () => {
|
|
1745
|
-
const { status } = await req(t.app, 'POST', '/records/query', {
|
|
1746
|
-
token: TEST_TOKEN,
|
|
1747
|
-
body: { limit: 10 },
|
|
1748
|
-
});
|
|
1749
|
-
expect(status).toBe(200);
|
|
1750
|
-
});
|
|
1751
|
-
});
|
|
1752
|
-
|
|
1753
|
-
describe('date filters', () => {
|
|
1754
|
-
it('GET /records?createdBefore=garbage is rejected rather than producing Invalid Date', async () => {
|
|
1755
|
-
expectBadRequest(
|
|
1756
|
-
await req(t.app, 'GET', '/records?createdBefore=garbage', { token: TEST_TOKEN }),
|
|
1757
|
-
);
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
it('GET /records?updatedAfter=garbage is rejected', async () => {
|
|
1761
|
-
expectBadRequest(
|
|
1762
|
-
await req(t.app, 'GET', '/records?updatedAfter=garbage', { token: TEST_TOKEN }),
|
|
1763
|
-
);
|
|
1764
|
-
});
|
|
1765
|
-
|
|
1766
|
-
it('POST /records/query with an invalid filter.createdAt.before 400s instead of dropping the bound', async () => {
|
|
1767
|
-
expectBadRequest(
|
|
1768
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1769
|
-
token: TEST_TOKEN,
|
|
1770
|
-
body: { filter: { createdAt: { before: 'garbage' } } },
|
|
1771
|
-
}),
|
|
1772
|
-
);
|
|
1773
|
-
});
|
|
1774
|
-
|
|
1775
|
-
it('POST /records/query with an invalid filter.updatedAt.after 400s instead of dropping the bound', async () => {
|
|
1776
|
-
expectBadRequest(
|
|
1777
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1778
|
-
token: TEST_TOKEN,
|
|
1779
|
-
body: { filter: { updatedAt: { after: 'garbage' } } },
|
|
1780
|
-
}),
|
|
1781
|
-
);
|
|
1782
|
-
});
|
|
1783
|
-
});
|
|
1784
|
-
|
|
1785
|
-
describe('sort', () => {
|
|
1786
|
-
it('GET /records?sort=bogus rejects an unknown sort field', async () => {
|
|
1787
|
-
expectBadRequest(await req(t.app, 'GET', '/records?sort=bogus', { token: TEST_TOKEN }));
|
|
1788
|
-
});
|
|
1789
|
-
|
|
1790
|
-
it('GET /records?sort=createdAt&direction=bogus rejects an unknown direction', async () => {
|
|
1791
|
-
expectBadRequest(
|
|
1792
|
-
await req(t.app, 'GET', '/records?sort=createdAt&direction=bogus', {
|
|
1793
|
-
token: TEST_TOKEN,
|
|
1794
|
-
}),
|
|
1795
|
-
);
|
|
1796
|
-
});
|
|
1797
|
-
|
|
1798
|
-
it('POST /records/query with sort.field: "bogus" is rejected', async () => {
|
|
1799
|
-
expectBadRequest(
|
|
1800
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1801
|
-
token: TEST_TOKEN,
|
|
1802
|
-
body: { sort: { field: 'bogus' } },
|
|
1803
|
-
}),
|
|
1804
|
-
);
|
|
1805
|
-
});
|
|
1806
|
-
|
|
1807
|
-
it('POST /records/query with sort.direction: "bogus" is rejected', async () => {
|
|
1808
|
-
expectBadRequest(
|
|
1809
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1810
|
-
token: TEST_TOKEN,
|
|
1811
|
-
body: { sort: { field: 'createdAt', direction: 'bogus' } },
|
|
1812
|
-
}),
|
|
1813
|
-
);
|
|
1814
|
-
});
|
|
1815
|
-
});
|
|
1816
|
-
|
|
1817
|
-
describe('filter shape', () => {
|
|
1818
|
-
it('POST /records/query with filter.tags as a non-array is rejected', async () => {
|
|
1819
|
-
expectBadRequest(
|
|
1820
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1821
|
-
token: TEST_TOKEN,
|
|
1822
|
-
body: { filter: { tags: 'not-an-array' } },
|
|
1823
|
-
}),
|
|
1824
|
-
);
|
|
1825
|
-
});
|
|
1826
|
-
|
|
1827
|
-
it('POST /records/query with a mixed-type filter.tags array is rejected', async () => {
|
|
1828
|
-
expectBadRequest(
|
|
1829
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1830
|
-
token: TEST_TOKEN,
|
|
1831
|
-
body: { filter: { tags: ['ok', 5] } },
|
|
1832
|
-
}),
|
|
1833
|
-
);
|
|
1834
|
-
});
|
|
1835
|
-
|
|
1836
|
-
it('POST /records/query with filter.content as a non-object is rejected', async () => {
|
|
1837
|
-
expectBadRequest(
|
|
1838
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1839
|
-
token: TEST_TOKEN,
|
|
1840
|
-
body: { filter: { content: 'not-an-object' } },
|
|
1841
|
-
}),
|
|
1842
|
-
);
|
|
1843
|
-
});
|
|
1844
|
-
|
|
1845
|
-
it('POST /records/query with filter.typeId as a non-string, non-array is rejected', async () => {
|
|
1846
|
-
expectBadRequest(
|
|
1847
|
-
await req(t.app, 'POST', '/records/query', {
|
|
1848
|
-
token: TEST_TOKEN,
|
|
1849
|
-
body: { filter: { typeId: 5 } },
|
|
1850
|
-
}),
|
|
1851
|
-
);
|
|
1852
|
-
});
|
|
1853
|
-
});
|
|
1854
|
-
});
|
|
1855
|
-
|
|
1856
|
-
describe('Payload too large', () => {
|
|
1857
|
-
it('returns 413 payload_too_large for an oversized record body', async () => {
|
|
1858
|
-
const { status, data } = await req(t.app, 'POST', '/records', {
|
|
1859
|
-
token: TEST_TOKEN,
|
|
1860
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'x'.repeat(2 * 1024 * 1024) } },
|
|
1861
|
-
});
|
|
1862
|
-
expect(status).toBe(413);
|
|
1863
|
-
expect((data as { error: { code: string } }).error.code).toBe('payload_too_large');
|
|
1864
|
-
});
|
|
1865
|
-
});
|
|
1866
|
-
|
|
1867
|
-
describe('POST /records/:id/migrate', () => {
|
|
1868
|
-
const NOTE_V2_TYPE_ID = 'com.example.test/note@2';
|
|
1869
|
-
|
|
1870
|
-
async function seedV2Type(ctx: TestApp['ctx']) {
|
|
1871
|
-
return ctx.stack.defineType(
|
|
1872
|
-
NOTE_V2_TYPE_ID,
|
|
1873
|
-
'Note',
|
|
1874
|
-
{
|
|
1875
|
-
title: { kind: 'string' as const },
|
|
1876
|
-
body: { kind: 'text' as const, required: true as const },
|
|
1877
|
-
},
|
|
1878
|
-
{ migratesFrom: NOTE_TYPE_ID },
|
|
1879
|
-
);
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
it('changes typeId, bumps version, and writes the given content (owner)', async () => {
|
|
1883
|
-
await seedV2Type(t.ctx);
|
|
1884
|
-
const record = await seedRecord(t.ctx, { body: 'original' });
|
|
1885
|
-
|
|
1886
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
1887
|
-
token: TEST_TOKEN,
|
|
1888
|
-
body: { toTypeId: NOTE_V2_TYPE_ID, content: { body: 'migrated', title: 'New' } },
|
|
1889
|
-
});
|
|
1890
|
-
expect(status).toBe(200);
|
|
1891
|
-
const body = data as Record<string, unknown>;
|
|
1892
|
-
expect(body.typeId).toBe(NOTE_V2_TYPE_ID);
|
|
1893
|
-
expect(body.version).toBe(2);
|
|
1894
|
-
expect((body.content as Record<string, unknown>).body).toBe('migrated');
|
|
1895
|
-
});
|
|
1896
|
-
|
|
1897
|
-
it('returns 403 for a non-owner, even one with write access to the record', async () => {
|
|
1898
|
-
await seedV2Type(t.ctx);
|
|
1899
|
-
const record = await t.ctx.stack.create(
|
|
1900
|
-
NOTE_TYPE_ID,
|
|
1901
|
-
{ body: 'shared' },
|
|
1902
|
-
{
|
|
1903
|
-
permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }],
|
|
1904
|
-
},
|
|
1905
|
-
);
|
|
1906
|
-
const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID);
|
|
1907
|
-
|
|
1908
|
-
const { status } = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
1909
|
-
token,
|
|
1910
|
-
body: { toTypeId: NOTE_V2_TYPE_ID, content: { body: 'x' } },
|
|
1911
|
-
});
|
|
1912
|
-
expect(status).toBe(403);
|
|
1913
|
-
});
|
|
1914
|
-
|
|
1915
|
-
it('returns 401 for an unauthenticated request', async () => {
|
|
1916
|
-
await seedV2Type(t.ctx);
|
|
1917
|
-
const record = await seedRecord(t.ctx);
|
|
1918
|
-
const { status } = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
1919
|
-
body: { toTypeId: NOTE_V2_TYPE_ID, content: { body: 'x' } },
|
|
1920
|
-
});
|
|
1921
|
-
expect(status).toBe(401);
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
it('returns 422 for an unregistered toTypeId', async () => {
|
|
1925
|
-
const record = await seedRecord(t.ctx);
|
|
1926
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
1927
|
-
token: TEST_TOKEN,
|
|
1928
|
-
body: { toTypeId: 'com.example.test/nonexistent@1', content: { body: 'x' } },
|
|
1929
|
-
});
|
|
1930
|
-
expect(status).toBe(422);
|
|
1931
|
-
expect((data as { error: { code: string } }).error.code).toBe('validation');
|
|
1932
|
-
});
|
|
1933
|
-
|
|
1934
|
-
it('returns 422 when content fails the target schema', async () => {
|
|
1935
|
-
await seedV2Type(t.ctx);
|
|
1936
|
-
const record = await seedRecord(t.ctx);
|
|
1937
|
-
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
1938
|
-
token: TEST_TOKEN,
|
|
1939
|
-
body: { toTypeId: NOTE_V2_TYPE_ID, content: {} },
|
|
1940
|
-
});
|
|
1941
|
-
expect(status).toBe(422);
|
|
1942
|
-
expect((data as { error: { code: string } }).error.code).toBe('validation');
|
|
1943
|
-
});
|
|
1944
|
-
|
|
1945
|
-
it('returns 404 for a record that does not exist', async () => {
|
|
1946
|
-
await seedV2Type(t.ctx);
|
|
1947
|
-
const { status } = await req(t.app, 'POST', '/records/nonexistent/migrate', {
|
|
1948
|
-
token: TEST_TOKEN,
|
|
1949
|
-
body: { toTypeId: NOTE_V2_TYPE_ID, content: { body: 'x' } },
|
|
1950
|
-
});
|
|
1951
|
-
expect(status).toBe(404);
|
|
1952
|
-
});
|
|
1953
|
-
|
|
1954
|
-
it('leaves a pre-migration snapshot in version history', async () => {
|
|
1955
|
-
await seedV2Type(t.ctx);
|
|
1956
|
-
const record = await seedRecord(t.ctx, { body: 'before' });
|
|
1957
|
-
await req(t.app, 'POST', `/records/${record.id}/migrate`, {
|
|
1958
|
-
token: TEST_TOKEN,
|
|
1959
|
-
body: { toTypeId: NOTE_V2_TYPE_ID, content: { body: 'after' } },
|
|
1960
|
-
});
|
|
1961
|
-
const versions = await t.ctx.adapter.getVersions(record.id);
|
|
1962
|
-
expect(versions.some((v) => v.typeId === NOTE_TYPE_ID)).toBe(true);
|
|
1963
|
-
});
|
|
1964
|
-
});
|
|
1965
|
-
});
|