@learncard/learn-cloud-service 2.5.34 → 2.5.35
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/.env.example +13 -0
- package/CHANGELOG.md +19 -0
- package/dist/accesslayer/share-content/delete.d.ts +5 -0
- package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
- package/dist/accesslayer/share-content/index.d.ts +8 -0
- package/dist/accesslayer/share-content/index.d.ts.map +1 -0
- package/dist/accesslayer/share-content/initialize.d.ts +5 -0
- package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
- package/dist/accesslayer/share-content/internal.d.ts +12 -0
- package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
- package/dist/accesslayer/share-content/put.d.ts +5 -0
- package/dist/accesslayer/share-content/put.d.ts.map +1 -0
- package/dist/accesslayer/share-content/read.d.ts +7 -0
- package/dist/accesslayer/share-content/read.d.ts.map +1 -0
- package/dist/accesslayer/share-content/types.d.ts +85 -0
- package/dist/accesslayer/share-content/types.d.ts.map +1 -0
- package/dist/config/environment.d.ts +12 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
- package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
- package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/claims.d.ts +19 -0
- package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/config.d.ts +12 -0
- package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/index.d.ts +8 -0
- package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/token.d.ts +28 -0
- package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/types.d.ts +70 -0
- package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
- package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/ShareContent.d.ts +160 -0
- package/dist/models/ShareContent.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/share-content/config.d.ts +28 -0
- package/dist/share-content/config.d.ts.map +1 -0
- package/dist/share-content/index.d.ts +6 -0
- package/dist/share-content/index.d.ts.map +1 -0
- package/dist/share-content/plugin.d.ts +5 -0
- package/dist/share-content/plugin.d.ts.map +1 -0
- package/dist/share-content/production.d.ts +15 -0
- package/dist/share-content/production.d.ts.map +1 -0
- package/dist/share-content/runtime.d.ts +23 -0
- package/dist/share-content/runtime.d.ts.map +1 -0
- package/dist/share-content/types.d.ts +23 -0
- package/dist/share-content/types.d.ts.map +1 -0
- package/lambda.ts +61 -0
- package/package.json +14 -14
- package/serverless.yml +25 -0
- package/src/accesslayer/share-content/delete.ts +124 -0
- package/src/accesslayer/share-content/index.ts +70 -0
- package/src/accesslayer/share-content/initialize.ts +24 -0
- package/src/accesslayer/share-content/internal.ts +89 -0
- package/src/accesslayer/share-content/put.ts +93 -0
- package/src/accesslayer/share-content/read.ts +124 -0
- package/src/accesslayer/share-content/types.ts +155 -0
- package/src/config/environment.ts +33 -0
- package/src/docker-entry.ts +19 -0
- package/src/helpers/share-content-auth/adapters.ts +48 -0
- package/src/helpers/share-content-auth/canonical.ts +91 -0
- package/src/helpers/share-content-auth/claims.ts +164 -0
- package/src/helpers/share-content-auth/config.ts +160 -0
- package/src/helpers/share-content-auth/index.ts +59 -0
- package/src/helpers/share-content-auth/token.ts +148 -0
- package/src/helpers/share-content-auth/types.ts +195 -0
- package/src/helpers/share-content-auth/verifier.ts +220 -0
- package/src/models/ShareContent.ts +198 -0
- package/src/models/index.ts +1 -0
- package/src/share-content/config.ts +199 -0
- package/src/share-content/index.ts +5 -0
- package/src/share-content/plugin.ts +317 -0
- package/src/share-content/production.ts +107 -0
- package/src/share-content/runtime.ts +90 -0
- package/src/share-content/types.ts +59 -0
- package/test/share-content-auth.spec.ts +1161 -0
- package/test/share-content-config.spec.ts +252 -0
- package/test/share-content-didkit.spec.ts +411 -0
- package/test/share-content-endpoint.spec.ts +825 -0
- package/test/share-content-redis-setup.ts +18 -0
- package/test/share-content.spec.ts +681 -0
- package/vitest.config.ts +1 -0
- package/vitest.share-content-config-unit.config.ts +8 -0
- package/vitest.share-content-endpoint.config.ts +21 -0
- package/vitest.share-content.config.ts +19 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { GenericContainer } from 'testcontainers';
|
|
2
|
+
import type { TestProject } from 'vitest/node';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Real Redis for the LC-2187 endpoint tests. A random host port and a fresh
|
|
6
|
+
* container keep the suite away from development data. The replay path is
|
|
7
|
+
* security-critical, so it is exercised against a real Redis, never a mock.
|
|
8
|
+
*/
|
|
9
|
+
export default async function setup({ provide }: TestProject): Promise<() => Promise<void>> {
|
|
10
|
+
const redis = await new GenericContainer('redis:7-alpine').withExposedPorts(6379).start();
|
|
11
|
+
|
|
12
|
+
provide('share-content-redis-host', redis.getHost());
|
|
13
|
+
provide('share-content-redis-port', redis.getMappedPort(6379));
|
|
14
|
+
|
|
15
|
+
return async () => {
|
|
16
|
+
await redis.stop();
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { computeShareLinkPayloadHash } from '../../brain-service/src/helpers/share-link-lifecycle/request-hash.helpers';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { MongoClient, type Collection, type Db } from 'mongodb';
|
|
5
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
6
|
+
import { MAX_SHARE_CIPHERTEXT_BYTES, encodeBase64Url, type ShareEnvelope } from '@learncard/types';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
MongoShareContentDocument,
|
|
10
|
+
ShareContentObjectBinding,
|
|
11
|
+
ShareContentPutInput,
|
|
12
|
+
} from '@models';
|
|
13
|
+
import {
|
|
14
|
+
SHARE_CONTENT_IDENTITY_INDEX,
|
|
15
|
+
ShareContentRepositoryNotInitializedError,
|
|
16
|
+
createShareContentRepository,
|
|
17
|
+
} from '@accesslayer/share-content';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Real-Mongo integration tests for the LC-2187 immutable share-content
|
|
21
|
+
* repository. Each test uses its own ephemeral MongoMemoryServer collection, so
|
|
22
|
+
* there are no global/shared database mutations and the concurrency assertions
|
|
23
|
+
* run against actual Mongo atomic primitives rather than an in-memory fixture.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const SHARE_ID = encodeBase64Url(new Uint8Array(16).fill(9));
|
|
27
|
+
const OTHER_SHARE_ID = encodeBase64Url(new Uint8Array(16).fill(8));
|
|
28
|
+
const IV = encodeBase64Url(new Uint8Array(12).fill(7));
|
|
29
|
+
|
|
30
|
+
const makeEnvelope = (fill = 1, bytes = 64): ShareEnvelope => ({
|
|
31
|
+
v: 1,
|
|
32
|
+
alg: 'A256GCM',
|
|
33
|
+
iv: IV,
|
|
34
|
+
ct: encodeBase64Url(new Uint8Array(bytes).fill(fill)),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const makeRecovery = (fill = 1): ShareContentPutInput['ownerEncryptedRecovery'] => ({
|
|
38
|
+
protected: `protected-${fill}`,
|
|
39
|
+
iv: `iv-${fill}`,
|
|
40
|
+
ciphertext: `ciphertext-${fill}`,
|
|
41
|
+
tag: `tag-${fill}`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const makeBinding = (
|
|
45
|
+
overrides: Partial<ShareContentObjectBinding> = {}
|
|
46
|
+
): ShareContentObjectBinding => ({
|
|
47
|
+
namespace: 'tenant-a',
|
|
48
|
+
ownerProfileId: 'owner-a',
|
|
49
|
+
shareId: SHARE_ID,
|
|
50
|
+
contentVersion: 1,
|
|
51
|
+
objectId: 'object-1',
|
|
52
|
+
operationId: 'operation-1',
|
|
53
|
+
...overrides,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const makePutInput = (overrides: Partial<ShareContentPutInput> = {}): ShareContentPutInput => ({
|
|
57
|
+
...makeBinding(),
|
|
58
|
+
envelope: makeEnvelope(1),
|
|
59
|
+
ownerEncryptedRecovery: makeRecovery(1),
|
|
60
|
+
...overrides,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const pickBinding = (
|
|
64
|
+
input: ShareContentPutInput | ShareContentObjectBinding
|
|
65
|
+
): ShareContentObjectBinding => ({
|
|
66
|
+
namespace: input.namespace,
|
|
67
|
+
ownerProfileId: input.ownerProfileId,
|
|
68
|
+
shareId: input.shareId,
|
|
69
|
+
contentVersion: input.contentVersion,
|
|
70
|
+
objectId: input.objectId,
|
|
71
|
+
operationId: input.operationId,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let server: MongoMemoryServer;
|
|
75
|
+
let client: MongoClient;
|
|
76
|
+
let db: Db;
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
server = await MongoMemoryServer.create();
|
|
80
|
+
client = new MongoClient(server.getUri());
|
|
81
|
+
await client.connect();
|
|
82
|
+
db = client.db('share_content_spec');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await client?.close();
|
|
87
|
+
await server?.stop();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const createHarness = async (): Promise<{
|
|
91
|
+
collection: Collection<MongoShareContentDocument>;
|
|
92
|
+
repository: ReturnType<typeof createShareContentRepository>;
|
|
93
|
+
}> => {
|
|
94
|
+
const collection = db.collection<MongoShareContentDocument>(
|
|
95
|
+
`share_content_${randomUUID().replace(/-/g, '')}`
|
|
96
|
+
);
|
|
97
|
+
const repository = createShareContentRepository(collection);
|
|
98
|
+
await repository.initialize();
|
|
99
|
+
|
|
100
|
+
return { collection, repository };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `listIndexes` on a collection that has never been written throws
|
|
105
|
+
* `NamespaceNotFound` on MongoDB 7; treat that as "no indexes yet".
|
|
106
|
+
*/
|
|
107
|
+
const listIndexes = async (collection: Collection<MongoShareContentDocument>) => {
|
|
108
|
+
try {
|
|
109
|
+
return await collection.indexes();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (
|
|
112
|
+
typeof error === 'object' &&
|
|
113
|
+
error !== null &&
|
|
114
|
+
(error as { code?: unknown }).code === 26
|
|
115
|
+
) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const requireActiveContent = async (
|
|
124
|
+
repository: ReturnType<typeof createShareContentRepository>,
|
|
125
|
+
binding: ShareContentObjectBinding
|
|
126
|
+
) => {
|
|
127
|
+
const result = await repository.getContent(binding);
|
|
128
|
+
|
|
129
|
+
if (!result.ok) throw new Error(`expected active content, got ${result.error}`);
|
|
130
|
+
|
|
131
|
+
return result.value;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
describe('share-content repository initialization', () => {
|
|
135
|
+
it('refuses every operation until initialize() installs the unique identity index', async () => {
|
|
136
|
+
const collection = db.collection<MongoShareContentDocument>(
|
|
137
|
+
`share_content_${randomUUID().replace(/-/g, '')}`
|
|
138
|
+
);
|
|
139
|
+
const repository = createShareContentRepository(collection);
|
|
140
|
+
|
|
141
|
+
await expect(repository.put(makePutInput())).rejects.toBeInstanceOf(
|
|
142
|
+
ShareContentRepositoryNotInitializedError
|
|
143
|
+
);
|
|
144
|
+
await expect(repository.getContent(makeBinding())).rejects.toBeInstanceOf(
|
|
145
|
+
ShareContentRepositoryNotInitializedError
|
|
146
|
+
);
|
|
147
|
+
await expect(repository.getRecovery(makeBinding())).rejects.toBeInstanceOf(
|
|
148
|
+
ShareContentRepositoryNotInitializedError
|
|
149
|
+
);
|
|
150
|
+
await expect(repository.stat(makeBinding())).rejects.toBeInstanceOf(
|
|
151
|
+
ShareContentRepositoryNotInitializedError
|
|
152
|
+
);
|
|
153
|
+
await expect(repository.delete(makeBinding())).rejects.toBeInstanceOf(
|
|
154
|
+
ShareContentRepositoryNotInitializedError
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(
|
|
158
|
+
(await listIndexes(collection)).find(
|
|
159
|
+
index => index.name === SHARE_CONTENT_IDENTITY_INDEX
|
|
160
|
+
)
|
|
161
|
+
).toBeUndefined();
|
|
162
|
+
|
|
163
|
+
await repository.initialize();
|
|
164
|
+
await repository.initialize();
|
|
165
|
+
|
|
166
|
+
const identityIndex = (await listIndexes(collection)).find(
|
|
167
|
+
index => index.name === SHARE_CONTENT_IDENTITY_INDEX
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(identityIndex?.unique).toBe(true);
|
|
171
|
+
expect(identityIndex?.key).toEqual({ namespace: 1, objectId: 1 });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('enforces the identity uniqueness with a direct duplicate insert', async () => {
|
|
175
|
+
const { collection } = await createHarness();
|
|
176
|
+
const binding = makeBinding();
|
|
177
|
+
|
|
178
|
+
await collection.insertOne({
|
|
179
|
+
kind: 'active',
|
|
180
|
+
...binding,
|
|
181
|
+
contentHash: 'a'.repeat(64),
|
|
182
|
+
envelope: makeEnvelope(),
|
|
183
|
+
ownerEncryptedRecovery: makeRecovery(),
|
|
184
|
+
ciphertextBytes: 64,
|
|
185
|
+
recoveryBytes: 32,
|
|
186
|
+
createdAt: new Date(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
collection.insertOne({
|
|
191
|
+
kind: 'active',
|
|
192
|
+
...binding,
|
|
193
|
+
contentHash: 'b'.repeat(64),
|
|
194
|
+
envelope: makeEnvelope(2),
|
|
195
|
+
ownerEncryptedRecovery: makeRecovery(2),
|
|
196
|
+
ciphertextBytes: 64,
|
|
197
|
+
recoveryBytes: 32,
|
|
198
|
+
createdAt: new Date(),
|
|
199
|
+
})
|
|
200
|
+
).rejects.toMatchObject({ code: 11000 });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('share-content put (create-only, idempotent)', () => {
|
|
205
|
+
it('creates once and treats an exact retry as an idempotent success', async () => {
|
|
206
|
+
const { collection, repository } = await createHarness();
|
|
207
|
+
const input = makePutInput();
|
|
208
|
+
|
|
209
|
+
const first = await repository.put(input);
|
|
210
|
+
const second = await repository.put(input);
|
|
211
|
+
|
|
212
|
+
expect(first.ok && first.value.status).toBe('created');
|
|
213
|
+
expect(second.ok && second.value.status).toBe('idempotent');
|
|
214
|
+
expect(await collection.countDocuments({})).toBe(1);
|
|
215
|
+
|
|
216
|
+
if (!first.ok || !second.ok) throw new Error('expected successful puts');
|
|
217
|
+
|
|
218
|
+
expect(second.value.record.contentHash).toBe(first.value.record.contentHash);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('rejects a divergent overwrite and preserves the original bytes', async () => {
|
|
222
|
+
const { collection, repository } = await createHarness();
|
|
223
|
+
const input = makePutInput();
|
|
224
|
+
await repository.put(input);
|
|
225
|
+
|
|
226
|
+
const conflicting = await repository.put(
|
|
227
|
+
makePutInput({ envelope: makeEnvelope(2), ownerEncryptedRecovery: makeRecovery(2) })
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(conflicting).toEqual({ ok: false, error: 'CONFLICT' });
|
|
231
|
+
expect(await collection.countDocuments({})).toBe(1);
|
|
232
|
+
|
|
233
|
+
const content = await requireActiveContent(repository, pickBinding(input));
|
|
234
|
+
expect(content.envelope).toEqual(input.envelope);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('rejects a retry whose binding tuple differs even when the bytes match', async () => {
|
|
238
|
+
const { repository } = await createHarness();
|
|
239
|
+
const input = makePutInput();
|
|
240
|
+
await repository.put(input);
|
|
241
|
+
|
|
242
|
+
expect(await repository.put(makePutInput({ operationId: 'operation-2' }))).toEqual({
|
|
243
|
+
ok: false,
|
|
244
|
+
error: 'CONFLICT',
|
|
245
|
+
});
|
|
246
|
+
expect(await repository.put(makePutInput({ contentVersion: 2 }))).toEqual({
|
|
247
|
+
ok: false,
|
|
248
|
+
error: 'CONFLICT',
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('isolates object ids that share a namespace, share id and version', async () => {
|
|
253
|
+
const { collection, repository } = await createHarness();
|
|
254
|
+
const first = makePutInput({ objectId: 'object-a' });
|
|
255
|
+
const second = makePutInput({ objectId: 'object-b', envelope: makeEnvelope(2) });
|
|
256
|
+
|
|
257
|
+
expect((await repository.put(first)).ok).toBe(true);
|
|
258
|
+
expect((await repository.put(second)).ok).toBe(true);
|
|
259
|
+
expect(await collection.countDocuments({ shareId: SHARE_ID, contentVersion: 1 })).toBe(2);
|
|
260
|
+
|
|
261
|
+
expect((await requireActiveContent(repository, pickBinding(first))).envelope).toEqual(
|
|
262
|
+
first.envelope
|
|
263
|
+
);
|
|
264
|
+
expect((await requireActiveContent(repository, pickBinding(second))).envelope).toEqual(
|
|
265
|
+
second.envelope
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('derives the content hash and rejects a caller-supplied forged hash', async () => {
|
|
270
|
+
const { repository } = await createHarness();
|
|
271
|
+
const input = makePutInput();
|
|
272
|
+
|
|
273
|
+
const first = await repository.put(input);
|
|
274
|
+
if (!first.ok) throw new Error('expected created');
|
|
275
|
+
|
|
276
|
+
const retry = await repository.put(input);
|
|
277
|
+
if (!retry.ok || retry.value.status !== 'idempotent')
|
|
278
|
+
throw new Error('expected idempotent');
|
|
279
|
+
|
|
280
|
+
expect(first.value.record.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
281
|
+
expect(retry.value.record.contentHash).toBe(first.value.record.contentHash);
|
|
282
|
+
|
|
283
|
+
const forged = await repository.put({
|
|
284
|
+
...input,
|
|
285
|
+
contentHash: 'a'.repeat(64),
|
|
286
|
+
} as unknown as ShareContentPutInput);
|
|
287
|
+
|
|
288
|
+
expect(forged).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('rejects arbitrary metadata, URLs and non-opaque identifiers', async () => {
|
|
292
|
+
const { collection, repository } = await createHarness();
|
|
293
|
+
|
|
294
|
+
expect(
|
|
295
|
+
await repository.put(makePutInput({ namespace: 'https://evil.example/path' }))
|
|
296
|
+
).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
297
|
+
expect(await repository.put(makePutInput({ objectId: 'urn:example:object:1' }))).toEqual({
|
|
298
|
+
ok: false,
|
|
299
|
+
error: 'INVALID_INPUT',
|
|
300
|
+
});
|
|
301
|
+
expect(await collection.countDocuments({})).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('rejects ciphertext larger than 512 KiB before storing anything', async () => {
|
|
305
|
+
const { collection, repository } = await createHarness();
|
|
306
|
+
|
|
307
|
+
const result = await repository.put(
|
|
308
|
+
makePutInput({ envelope: makeEnvelope(5, MAX_SHARE_CIPHERTEXT_BYTES + 1) })
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(result).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
312
|
+
expect(await collection.countDocuments({})).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('rejects a serialized recovery JWE larger than 64 KiB before storing anything', async () => {
|
|
316
|
+
const { collection, repository } = await createHarness();
|
|
317
|
+
|
|
318
|
+
const result = await repository.put(
|
|
319
|
+
makePutInput({
|
|
320
|
+
ownerEncryptedRecovery: {
|
|
321
|
+
...makeRecovery(1),
|
|
322
|
+
ciphertext: 'a'.repeat(70_000),
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
expect(result).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
328
|
+
expect(await collection.countDocuments({})).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('share-content projections and binding enforcement', () => {
|
|
333
|
+
it('returns content without recovery and recovery without content', async () => {
|
|
334
|
+
const { repository } = await createHarness();
|
|
335
|
+
const input = makePutInput();
|
|
336
|
+
await repository.put(input);
|
|
337
|
+
const binding = pickBinding(input);
|
|
338
|
+
|
|
339
|
+
const content = await repository.getContent(binding);
|
|
340
|
+
if (!content.ok) throw new Error('expected content');
|
|
341
|
+
|
|
342
|
+
expect(content.value.envelope).toEqual(input.envelope);
|
|
343
|
+
expect('ownerEncryptedRecovery' in content.value).toBe(false);
|
|
344
|
+
|
|
345
|
+
const recovery = await repository.getRecovery(binding);
|
|
346
|
+
if (!recovery.ok) throw new Error('expected recovery');
|
|
347
|
+
|
|
348
|
+
expect(recovery.value.ownerEncryptedRecovery).toEqual(input.ownerEncryptedRecovery);
|
|
349
|
+
expect('envelope' in recovery.value).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const wrongBindingCases: Array<[string, Partial<ShareContentObjectBinding>]> = [
|
|
353
|
+
['namespace', { namespace: 'tenant-b' }],
|
|
354
|
+
['owner', { ownerProfileId: 'owner-b' }],
|
|
355
|
+
['share', { shareId: OTHER_SHARE_ID }],
|
|
356
|
+
['version', { contentVersion: 2 }],
|
|
357
|
+
['object', { objectId: 'object-2' }],
|
|
358
|
+
['operation', { operationId: 'operation-2' }],
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
it.each(wrongBindingCases)(
|
|
362
|
+
'fails closed on a wrong %s binding for content, recovery and stat',
|
|
363
|
+
async (_label, mutation) => {
|
|
364
|
+
const { repository } = await createHarness();
|
|
365
|
+
const input = makePutInput();
|
|
366
|
+
await repository.put(input);
|
|
367
|
+
const wrong = makeBinding(mutation);
|
|
368
|
+
|
|
369
|
+
expect(await repository.getContent(wrong)).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
370
|
+
expect(await repository.getRecovery(wrong)).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
371
|
+
expect(await repository.stat(wrong)).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
372
|
+
|
|
373
|
+
// The original exact object is untouched and still readable.
|
|
374
|
+
expect((await requireActiveContent(repository, pickBinding(input))).envelope).toEqual(
|
|
375
|
+
input.envelope
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
it('reports active existence, hash and sizes through stat', async () => {
|
|
381
|
+
const { repository } = await createHarness();
|
|
382
|
+
const input = makePutInput();
|
|
383
|
+
await repository.put(input);
|
|
384
|
+
|
|
385
|
+
const stat = await repository.stat(pickBinding(input));
|
|
386
|
+
if (!stat.ok || stat.value.kind !== 'active') throw new Error('expected active stat');
|
|
387
|
+
|
|
388
|
+
expect(stat.value.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
389
|
+
expect(stat.value.ciphertextBytes).toBe(64);
|
|
390
|
+
expect(stat.value.recoveryBytes).toBeGreaterThan(0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('returns NOT_FOUND for an object that was never written', async () => {
|
|
394
|
+
const { repository } = await createHarness();
|
|
395
|
+
|
|
396
|
+
expect(await repository.getContent(makeBinding())).toEqual({
|
|
397
|
+
ok: false,
|
|
398
|
+
error: 'NOT_FOUND',
|
|
399
|
+
});
|
|
400
|
+
expect(await repository.stat(makeBinding())).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('share-content delete and tombstones', () => {
|
|
405
|
+
it('tombstones a missing identity so a late put can never resurrect it', async () => {
|
|
406
|
+
const { collection, repository } = await createHarness();
|
|
407
|
+
const input = makePutInput();
|
|
408
|
+
const binding = pickBinding(input);
|
|
409
|
+
|
|
410
|
+
const deleted = await repository.delete(binding);
|
|
411
|
+
expect(deleted.ok).toBe(true);
|
|
412
|
+
|
|
413
|
+
const raw = await collection.findOne({
|
|
414
|
+
namespace: binding.namespace,
|
|
415
|
+
objectId: binding.objectId,
|
|
416
|
+
});
|
|
417
|
+
if (raw === null) throw new Error('expected a tombstone document');
|
|
418
|
+
|
|
419
|
+
expect(raw.kind).toBe('tombstone');
|
|
420
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'envelope')).toBe(false);
|
|
421
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'ownerEncryptedRecovery')).toBe(false);
|
|
422
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'contentHash')).toBe(false);
|
|
423
|
+
|
|
424
|
+
expect(await repository.put(input)).toEqual({ ok: false, error: 'TOMBSTONED' });
|
|
425
|
+
expect(await collection.countDocuments({})).toBe(1);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('atomically tombstones an active object and drops its payload', async () => {
|
|
429
|
+
const { collection, repository } = await createHarness();
|
|
430
|
+
const input = makePutInput();
|
|
431
|
+
const binding = pickBinding(input);
|
|
432
|
+
await repository.put(input);
|
|
433
|
+
|
|
434
|
+
const deleted = await repository.delete(binding);
|
|
435
|
+
expect(deleted.ok).toBe(true);
|
|
436
|
+
|
|
437
|
+
const raw = await collection.findOne({
|
|
438
|
+
namespace: binding.namespace,
|
|
439
|
+
objectId: binding.objectId,
|
|
440
|
+
});
|
|
441
|
+
if (raw === null) throw new Error('expected a tombstone document');
|
|
442
|
+
|
|
443
|
+
expect(raw.kind).toBe('tombstone');
|
|
444
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'envelope')).toBe(false);
|
|
445
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'ownerEncryptedRecovery')).toBe(false);
|
|
446
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'ciphertextBytes')).toBe(false);
|
|
447
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'recoveryBytes')).toBe(false);
|
|
448
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'createdAt')).toBe(false);
|
|
449
|
+
expect(Object.prototype.hasOwnProperty.call(raw, 'contentHash')).toBe(true);
|
|
450
|
+
|
|
451
|
+
expect(await repository.getContent(binding)).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
452
|
+
expect(await repository.getRecovery(binding)).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
453
|
+
|
|
454
|
+
const stat = await repository.stat(binding);
|
|
455
|
+
if (!stat.ok || stat.value.kind !== 'tombstone') throw new Error('expected tombstone stat');
|
|
456
|
+
|
|
457
|
+
expect(stat.value.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('is idempotent for repeated deletes of the same tuple', async () => {
|
|
461
|
+
const { repository } = await createHarness();
|
|
462
|
+
const input = makePutInput();
|
|
463
|
+
await repository.put(input);
|
|
464
|
+
|
|
465
|
+
expect((await repository.delete(pickBinding(input))).ok).toBe(true);
|
|
466
|
+
expect((await repository.delete(pickBinding(input))).ok).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('never resurrects a tombstoned identity, even for a stale replay or new operation id', async () => {
|
|
470
|
+
const { repository } = await createHarness();
|
|
471
|
+
const input = makePutInput();
|
|
472
|
+
|
|
473
|
+
await repository.put(input);
|
|
474
|
+
await repository.delete(pickBinding(input));
|
|
475
|
+
|
|
476
|
+
expect(await repository.put(input)).toEqual({ ok: false, error: 'TOMBSTONED' });
|
|
477
|
+
expect(await repository.put(makePutInput({ operationId: 'operation-2' }))).toEqual({
|
|
478
|
+
ok: false,
|
|
479
|
+
error: 'TOMBSTONED',
|
|
480
|
+
});
|
|
481
|
+
expect(
|
|
482
|
+
await repository.put(makePutInput({ objectId: 'object-1', contentVersion: 9 }))
|
|
483
|
+
).toEqual({ ok: false, error: 'TOMBSTONED' });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const mismatchedDeleteCases: Array<[string, Partial<ShareContentObjectBinding>]> = [
|
|
487
|
+
['owner', { ownerProfileId: 'owner-b' }],
|
|
488
|
+
['share', { shareId: OTHER_SHARE_ID }],
|
|
489
|
+
['version', { contentVersion: 2 }],
|
|
490
|
+
['operation', { operationId: 'operation-2' }],
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
it.each(mismatchedDeleteCases)(
|
|
494
|
+
'refuses to delete another %s binding and leaves the object intact',
|
|
495
|
+
async (_label, mutation) => {
|
|
496
|
+
const { collection, repository } = await createHarness();
|
|
497
|
+
const input = makePutInput();
|
|
498
|
+
await repository.put(input);
|
|
499
|
+
|
|
500
|
+
const result = await repository.delete(makeBinding(mutation));
|
|
501
|
+
expect(result).toEqual({ ok: false, error: 'BINDING_MISMATCH' });
|
|
502
|
+
|
|
503
|
+
const raw = await collection.findOne({
|
|
504
|
+
namespace: input.namespace,
|
|
505
|
+
objectId: input.objectId,
|
|
506
|
+
});
|
|
507
|
+
expect(raw?.kind).toBe('active');
|
|
508
|
+
|
|
509
|
+
expect((await requireActiveContent(repository, pickBinding(input))).envelope).toEqual(
|
|
510
|
+
input.envelope
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
it('scopes a delete/tombstone to its own namespace and identity', async () => {
|
|
516
|
+
const { repository } = await createHarness();
|
|
517
|
+
const input = makePutInput();
|
|
518
|
+
await repository.put(input);
|
|
519
|
+
|
|
520
|
+
// Different identity: allowed to tombstone that identity, never the original.
|
|
521
|
+
expect((await repository.delete(makeBinding({ objectId: 'object-2' }))).ok).toBe(true);
|
|
522
|
+
expect((await repository.delete(makeBinding({ namespace: 'tenant-b' }))).ok).toBe(true);
|
|
523
|
+
|
|
524
|
+
expect((await requireActiveContent(repository, pickBinding(input))).envelope).toEqual(
|
|
525
|
+
input.envelope
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe('share-content concurrency (real Mongo atomic primitives)', () => {
|
|
531
|
+
it('allows exactly one create across concurrent duplicate puts', async () => {
|
|
532
|
+
const { collection, repository } = await createHarness();
|
|
533
|
+
const input = makePutInput({ objectId: 'object-concurrent-duplicate' });
|
|
534
|
+
|
|
535
|
+
const results = await Promise.all(Array.from({ length: 8 }, () => repository.put(input)));
|
|
536
|
+
|
|
537
|
+
expect(
|
|
538
|
+
results.filter(result => result.ok && result.value.status === 'created')
|
|
539
|
+
).toHaveLength(1);
|
|
540
|
+
expect(
|
|
541
|
+
results.filter(result => result.ok && result.value.status === 'idempotent')
|
|
542
|
+
).toHaveLength(7);
|
|
543
|
+
expect(
|
|
544
|
+
await collection.countDocuments({
|
|
545
|
+
namespace: input.namespace,
|
|
546
|
+
objectId: input.objectId,
|
|
547
|
+
})
|
|
548
|
+
).toBe(1);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('allows exactly one winner across concurrent divergent puts', async () => {
|
|
552
|
+
const { collection, repository } = await createHarness();
|
|
553
|
+
const base = makePutInput({ objectId: 'object-concurrent-divergent' });
|
|
554
|
+
|
|
555
|
+
const results = await Promise.all(
|
|
556
|
+
Array.from({ length: 6 }, (_value, index) =>
|
|
557
|
+
repository.put(
|
|
558
|
+
makePutInput({
|
|
559
|
+
objectId: base.objectId,
|
|
560
|
+
envelope: makeEnvelope(index + 1),
|
|
561
|
+
})
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
expect(
|
|
567
|
+
results.filter(result => result.ok && result.value.status === 'created')
|
|
568
|
+
).toHaveLength(1);
|
|
569
|
+
expect(results.filter(result => !result.ok && result.error === 'CONFLICT')).toHaveLength(5);
|
|
570
|
+
expect(
|
|
571
|
+
await collection.countDocuments({
|
|
572
|
+
namespace: base.namespace,
|
|
573
|
+
objectId: base.objectId,
|
|
574
|
+
})
|
|
575
|
+
).toBe(1);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('never leaves ciphertext readable after a delete that races a first put', async () => {
|
|
579
|
+
for (let round = 0; round < 12; round += 1) {
|
|
580
|
+
const { collection, repository } = await createHarness();
|
|
581
|
+
const input = makePutInput({ objectId: `object-race-${round}` });
|
|
582
|
+
const binding = pickBinding(input);
|
|
583
|
+
|
|
584
|
+
const [putResult, deleteResult] = await Promise.all([
|
|
585
|
+
repository.put(input),
|
|
586
|
+
repository.delete(binding),
|
|
587
|
+
]);
|
|
588
|
+
|
|
589
|
+
expect(deleteResult.ok).toBe(true);
|
|
590
|
+
|
|
591
|
+
expect(await repository.getContent(binding)).toEqual({
|
|
592
|
+
ok: false,
|
|
593
|
+
error: 'NOT_FOUND',
|
|
594
|
+
});
|
|
595
|
+
expect(
|
|
596
|
+
await collection.countDocuments({
|
|
597
|
+
namespace: binding.namespace,
|
|
598
|
+
objectId: binding.objectId,
|
|
599
|
+
kind: 'active',
|
|
600
|
+
})
|
|
601
|
+
).toBe(0);
|
|
602
|
+
|
|
603
|
+
if (putResult.ok) {
|
|
604
|
+
expect(['created', 'idempotent']).toContain(putResult.value.status);
|
|
605
|
+
} else {
|
|
606
|
+
expect(putResult.error).toBe('TOMBSTONED');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('never leaves ciphertext readable after a delete that races a replay put', async () => {
|
|
612
|
+
for (let round = 0; round < 12; round += 1) {
|
|
613
|
+
const { collection, repository } = await createHarness();
|
|
614
|
+
const input = makePutInput({ objectId: `object-replay-race-${round}` });
|
|
615
|
+
const binding = pickBinding(input);
|
|
616
|
+
|
|
617
|
+
await repository.put(input);
|
|
618
|
+
|
|
619
|
+
const [putResult, deleteResult] = await Promise.all([
|
|
620
|
+
repository.put(input),
|
|
621
|
+
repository.delete(binding),
|
|
622
|
+
]);
|
|
623
|
+
|
|
624
|
+
expect(deleteResult.ok).toBe(true);
|
|
625
|
+
expect(await repository.getContent(binding)).toEqual({
|
|
626
|
+
ok: false,
|
|
627
|
+
error: 'NOT_FOUND',
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
if (putResult.ok) {
|
|
631
|
+
expect(putResult.value.status).toBe('idempotent');
|
|
632
|
+
} else {
|
|
633
|
+
expect(putResult.error).toBe('TOMBSTONED');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
expect(
|
|
637
|
+
await collection.countDocuments({
|
|
638
|
+
namespace: binding.namespace,
|
|
639
|
+
objectId: binding.objectId,
|
|
640
|
+
kind: 'active',
|
|
641
|
+
})
|
|
642
|
+
).toBe(0);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('serializes concurrent deletes without conflicted tombstones', async () => {
|
|
647
|
+
const { collection, repository } = await createHarness();
|
|
648
|
+
const input = makePutInput({ objectId: 'object-concurrent-delete' });
|
|
649
|
+
const binding = pickBinding(input);
|
|
650
|
+
await repository.put(input);
|
|
651
|
+
|
|
652
|
+
const results = await Promise.all(
|
|
653
|
+
Array.from({ length: 6 }, () => repository.delete(binding))
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
expect(results.every(result => result.ok)).toBe(true);
|
|
657
|
+
expect(
|
|
658
|
+
await collection.countDocuments({
|
|
659
|
+
namespace: binding.namespace,
|
|
660
|
+
objectId: binding.objectId,
|
|
661
|
+
kind: 'tombstone',
|
|
662
|
+
})
|
|
663
|
+
).toBe(1);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
describe('reservation payload digest', () => {
|
|
668
|
+
it('keeps payload hash stable before identity allocation while binding the full record hash', async () => {
|
|
669
|
+
const { repository } = await createHarness();
|
|
670
|
+
const payload = { envelope: makeEnvelope(), ownerEncryptedRecovery: makeRecovery() };
|
|
671
|
+
const first = await repository.put({ ...makeBinding(), ...payload });
|
|
672
|
+
const second = await repository.put({
|
|
673
|
+
...makeBinding({ objectId: 'another-object', operationId: 'another-operation' }),
|
|
674
|
+
...payload,
|
|
675
|
+
});
|
|
676
|
+
if (!first.ok || !second.ok) throw new Error('expected writes');
|
|
677
|
+
expect(first.value.record.payloadHash).toBe(second.value.record.payloadHash);
|
|
678
|
+
expect(first.value.record.payloadHash).toBe(computeShareLinkPayloadHash(payload));
|
|
679
|
+
expect(first.value.record.contentHash).not.toBe(second.value.record.contentHash);
|
|
680
|
+
});
|
|
681
|
+
});
|