@learncard/learn-cloud-service 2.5.33 → 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 +38 -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,825 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
4
|
+
import { MongoClient, type Collection, type Db } from 'mongodb';
|
|
5
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
6
|
+
import { inject } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { encodeBase64Url, type ShareEnvelope } from '@learncard/types';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createShareContentRepository,
|
|
12
|
+
type ShareContentObjectBinding,
|
|
13
|
+
type ShareContentRepository,
|
|
14
|
+
} from '@accesslayer/share-content';
|
|
15
|
+
import {
|
|
16
|
+
SHARE_CONTENT_AUTH_PURPOSE,
|
|
17
|
+
computeShareContentRequestHash,
|
|
18
|
+
createShareContentAuthorizationVerifier,
|
|
19
|
+
type PresentationVerificationResult,
|
|
20
|
+
type PresentationVerifier,
|
|
21
|
+
} from '@helpers/share-content-auth';
|
|
22
|
+
import type { MongoShareContentDocument } from '@models';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
SHARE_CONTENT_PLUGIN_PREFIX,
|
|
26
|
+
buildShareContentRuntime,
|
|
27
|
+
resolveShareContentConfig,
|
|
28
|
+
shareContentFastifyPlugin,
|
|
29
|
+
type ShareContentPluginOptions,
|
|
30
|
+
type ShareContentReplayStoreFactory,
|
|
31
|
+
} from '../src/share-content';
|
|
32
|
+
import { createShareContentRedisReplayStore } from '../src/share-content/production';
|
|
33
|
+
|
|
34
|
+
declare module 'vitest' {
|
|
35
|
+
export interface ProvidedContext {
|
|
36
|
+
'share-content-redis-host': string;
|
|
37
|
+
'share-content-redis-port': number;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REDIS_HOST = inject('share-content-redis-host');
|
|
42
|
+
const REDIS_PORT = inject('share-content-redis-port');
|
|
43
|
+
|
|
44
|
+
// Fail loudly rather than silently defaulting to an ambient localhost Redis: the
|
|
45
|
+
// replay path must be exercised against the disposable container from
|
|
46
|
+
// `test/share-content-redis-setup.ts`.
|
|
47
|
+
if (typeof REDIS_HOST !== 'string' || typeof REDIS_PORT !== 'number') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'share-content endpoint tests require the real Redis global setup (vitest.share-content-endpoint.config.ts)'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NOW = 1_700_000_000;
|
|
54
|
+
const AUDIENCE = 'did:web:cloud.learncard.com';
|
|
55
|
+
const SIGNER = 'did:web:brain.learncard.com';
|
|
56
|
+
const KID = `${SIGNER}#key-1`;
|
|
57
|
+
const USER_DID = 'did:key:z6Mkenduserenduserenduserenduserenduserenduser';
|
|
58
|
+
const NAMESPACE = 'learncard';
|
|
59
|
+
const SHARE_ID = Buffer.from(new Uint8Array(16).fill(7)).toString('base64url');
|
|
60
|
+
const OTHER_SHARE_ID = Buffer.from(new Uint8Array(16).fill(8)).toString('base64url');
|
|
61
|
+
const DUMMY_SIGNATURE = Buffer.from(new Uint8Array(64).fill(1)).toString('base64url');
|
|
62
|
+
const RECOVERY_CIPHERTEXT = 'recovery-secret-ciphertext-value';
|
|
63
|
+
|
|
64
|
+
const encodePart = (value: unknown): string =>
|
|
65
|
+
Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
|
66
|
+
|
|
67
|
+
const makeEnvelope = (fill = 1, bytes = 64): ShareEnvelope => ({
|
|
68
|
+
v: 1,
|
|
69
|
+
alg: 'A256GCM',
|
|
70
|
+
iv: encodeBase64Url(new Uint8Array(12).fill(7)),
|
|
71
|
+
ct: encodeBase64Url(new Uint8Array(bytes).fill(fill)),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const makeRecovery = (overrides: Record<string, unknown> = {}) => ({
|
|
75
|
+
protected: 'protected-a',
|
|
76
|
+
iv: 'iv-a',
|
|
77
|
+
ciphertext: RECOVERY_CIPHERTEXT,
|
|
78
|
+
tag: 'tag-a',
|
|
79
|
+
...overrides,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const makeBinding = (
|
|
83
|
+
overrides: Partial<ShareContentObjectBinding> = {}
|
|
84
|
+
): ShareContentObjectBinding => ({
|
|
85
|
+
namespace: NAMESPACE,
|
|
86
|
+
ownerProfileId: 'owner-profile-1',
|
|
87
|
+
shareId: SHARE_ID,
|
|
88
|
+
contentVersion: 1,
|
|
89
|
+
objectId: 'object-0001',
|
|
90
|
+
operationId: 'operation-0001',
|
|
91
|
+
...overrides,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const makePutBody = (overrides: Record<string, unknown> = {}) => ({
|
|
95
|
+
...makeBinding(),
|
|
96
|
+
envelope: makeEnvelope(1),
|
|
97
|
+
ownerEncryptedRecovery: makeRecovery(),
|
|
98
|
+
...overrides,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const makeClaims = (op: string, body: Record<string, unknown>, overrides = {}) => ({
|
|
102
|
+
iss: SIGNER,
|
|
103
|
+
aud: AUDIENCE,
|
|
104
|
+
purpose: SHARE_CONTENT_AUTH_PURPOSE,
|
|
105
|
+
namespace: body.namespace,
|
|
106
|
+
op,
|
|
107
|
+
shareId: body.shareId,
|
|
108
|
+
contentVersion: body.contentVersion,
|
|
109
|
+
ownerProfileId: body.ownerProfileId,
|
|
110
|
+
objectId: body.objectId,
|
|
111
|
+
operationId: body.operationId,
|
|
112
|
+
requestHash: computeShareContentRequestHash(body),
|
|
113
|
+
iat: NOW,
|
|
114
|
+
exp: NOW + 30,
|
|
115
|
+
jti: `jti-${randomUUID().replace(/-/g, '')}`,
|
|
116
|
+
...overrides,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const buildToken = (
|
|
120
|
+
claims: unknown,
|
|
121
|
+
options: { signerDid?: string; kid?: string; header?: Record<string, unknown> } = {}
|
|
122
|
+
): string => {
|
|
123
|
+
const signerDid = options.signerDid ?? SIGNER;
|
|
124
|
+
const kid = options.kid ?? `${signerDid}#key-1`;
|
|
125
|
+
|
|
126
|
+
const payload = {
|
|
127
|
+
iss: signerDid,
|
|
128
|
+
vp: {
|
|
129
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
130
|
+
type: ['VerifiablePresentation'],
|
|
131
|
+
holder: signerDid,
|
|
132
|
+
verifiableCredential: [],
|
|
133
|
+
},
|
|
134
|
+
nonce: JSON.stringify(claims),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return `${encodePart({ alg: 'EdDSA', kid, ...(options.header ?? {}) })}.${encodePart(
|
|
138
|
+
payload
|
|
139
|
+
)}.${DUMMY_SIGNATURE}`;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const okVerification = (): PresentationVerificationResult => ({
|
|
143
|
+
checks: ['JWS'],
|
|
144
|
+
warnings: [],
|
|
145
|
+
errors: [],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let mongoServer: MongoMemoryServer;
|
|
149
|
+
let mongoClient: MongoClient;
|
|
150
|
+
let db: Db;
|
|
151
|
+
|
|
152
|
+
beforeAll(async () => {
|
|
153
|
+
mongoServer = await MongoMemoryServer.create();
|
|
154
|
+
mongoClient = new MongoClient(mongoServer.getUri());
|
|
155
|
+
await mongoClient.connect();
|
|
156
|
+
db = mongoClient.db('share_content_endpoint');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
afterAll(async () => {
|
|
160
|
+
await mongoClient?.close();
|
|
161
|
+
await mongoServer?.stop();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
type InjectInput = {
|
|
165
|
+
payload?: unknown;
|
|
166
|
+
token?: string;
|
|
167
|
+
authorization?: string;
|
|
168
|
+
contentType?: string;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
type Harness = {
|
|
172
|
+
app: FastifyInstance;
|
|
173
|
+
repository: ShareContentRepository;
|
|
174
|
+
collection: Collection<MongoShareContentDocument>;
|
|
175
|
+
verifyCalls: string[];
|
|
176
|
+
replay: ReturnType<ShareContentReplayStoreFactory>;
|
|
177
|
+
close: () => Promise<void>;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const makeHarness = async (
|
|
181
|
+
options: {
|
|
182
|
+
configOverrides?: Record<string, unknown>;
|
|
183
|
+
verifyImpl?: PresentationVerifier;
|
|
184
|
+
createReplayStore?: ShareContentReplayStoreFactory;
|
|
185
|
+
} = {}
|
|
186
|
+
): Promise<Harness> => {
|
|
187
|
+
const collection = db.collection<MongoShareContentDocument>(
|
|
188
|
+
`share_content_${randomUUID().replace(/-/g, '')}`
|
|
189
|
+
);
|
|
190
|
+
const repository = createShareContentRepository(collection);
|
|
191
|
+
await repository.initialize();
|
|
192
|
+
|
|
193
|
+
const resolved = resolveShareContentConfig({
|
|
194
|
+
enabled: true,
|
|
195
|
+
audience: AUDIENCE,
|
|
196
|
+
serviceDids: [SIGNER],
|
|
197
|
+
verificationMethods: [KID],
|
|
198
|
+
namespaceBindings: { [SIGNER]: [NAMESPACE] },
|
|
199
|
+
...options.configOverrides,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (resolved.status !== 'enabled') {
|
|
203
|
+
throw new Error(`test config not enabled: ${JSON.stringify(resolved)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const verifyCalls: string[] = [];
|
|
207
|
+
const verifyPresentation: PresentationVerifier =
|
|
208
|
+
options.verifyImpl ??
|
|
209
|
+
(async token => {
|
|
210
|
+
verifyCalls.push(token);
|
|
211
|
+
|
|
212
|
+
return okVerification();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const replay =
|
|
216
|
+
options.createReplayStore?.() ??
|
|
217
|
+
createShareContentRedisReplayStore({ host: REDIS_HOST, port: REDIS_PORT });
|
|
218
|
+
|
|
219
|
+
const verifier = createShareContentAuthorizationVerifier({
|
|
220
|
+
config: resolved.trustConfig,
|
|
221
|
+
verifyPresentation,
|
|
222
|
+
replayStore: replay.store,
|
|
223
|
+
now: () => NOW,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const pluginOptions: ShareContentPluginOptions = {
|
|
227
|
+
verifier,
|
|
228
|
+
repository,
|
|
229
|
+
namespacePolicy: resolved.namespacePolicy,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const app = Fastify({ logger: false });
|
|
233
|
+
await app.register(shareContentFastifyPlugin, {
|
|
234
|
+
prefix: SHARE_CONTENT_PLUGIN_PREFIX,
|
|
235
|
+
...pluginOptions,
|
|
236
|
+
});
|
|
237
|
+
await app.ready();
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
app,
|
|
241
|
+
repository,
|
|
242
|
+
collection,
|
|
243
|
+
verifyCalls,
|
|
244
|
+
replay,
|
|
245
|
+
close: async () => {
|
|
246
|
+
await app.close();
|
|
247
|
+
await replay.close();
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const injectRoute = (app: FastifyInstance, op: string, input: InjectInput = {}) => {
|
|
253
|
+
const headers: Record<string, string> = {};
|
|
254
|
+
|
|
255
|
+
if (input.token) headers.authorization = `Bearer ${input.token}`;
|
|
256
|
+
if (input.authorization) headers.authorization = input.authorization;
|
|
257
|
+
if (input.contentType) headers['content-type'] = input.contentType;
|
|
258
|
+
|
|
259
|
+
return app.inject({
|
|
260
|
+
method: 'POST',
|
|
261
|
+
url: `${SHARE_CONTENT_PLUGIN_PREFIX}/${op}`,
|
|
262
|
+
headers,
|
|
263
|
+
...(input.payload === undefined ? {} : { payload: input.payload as object }),
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const parseBody = (body: string) => JSON.parse(body);
|
|
268
|
+
|
|
269
|
+
describe('share-content endpoint contract', () => {
|
|
270
|
+
it('creates content on a valid put and echoes the C2 success shape with ISO dates', async () => {
|
|
271
|
+
const harness = await makeHarness();
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const body = makePutBody();
|
|
275
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
276
|
+
payload: body,
|
|
277
|
+
token: buildToken(makeClaims('put', body)),
|
|
278
|
+
});
|
|
279
|
+
const parsed = parseBody(response.body);
|
|
280
|
+
|
|
281
|
+
expect(response.statusCode).toBe(200);
|
|
282
|
+
expect(response.headers['cache-control']).toBe('no-store');
|
|
283
|
+
expect(parsed.ok).toBe(true);
|
|
284
|
+
expect(parsed.value.status).toBe('created');
|
|
285
|
+
expect(parsed.value.record.objectId).toBe('object-0001');
|
|
286
|
+
expect(typeof parsed.value.record.createdAt).toBe('string');
|
|
287
|
+
expect(harness.verifyCalls).toHaveLength(1);
|
|
288
|
+
} finally {
|
|
289
|
+
await harness.close();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('treats an exact replay with a fresh token as an idempotent success', async () => {
|
|
294
|
+
const harness = await makeHarness();
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const body = makePutBody();
|
|
298
|
+
const first = await injectRoute(harness.app, 'put', {
|
|
299
|
+
payload: body,
|
|
300
|
+
token: buildToken(makeClaims('put', body)),
|
|
301
|
+
});
|
|
302
|
+
const second = await injectRoute(harness.app, 'put', {
|
|
303
|
+
payload: body,
|
|
304
|
+
token: buildToken(makeClaims('put', body)),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(first.statusCode).toBe(200);
|
|
308
|
+
expect(parseBody(first.body).value.status).toBe('created');
|
|
309
|
+
expect(second.statusCode).toBe(200);
|
|
310
|
+
expect(parseBody(second.body).value.status).toBe('idempotent');
|
|
311
|
+
} finally {
|
|
312
|
+
await harness.close();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('uses the route operation, not the body, and rejects an unknown op field', async () => {
|
|
317
|
+
const harness = await makeHarness();
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const body: Record<string, unknown> = { ...makePutBody(), op: 'put' };
|
|
321
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
322
|
+
payload: body,
|
|
323
|
+
token: buildToken(makeClaims('put', makePutBody())),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(response.statusCode).toBe(400);
|
|
327
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
328
|
+
} finally {
|
|
329
|
+
await harness.close();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('returns the exact object content on get without ever exposing recovery', async () => {
|
|
334
|
+
const harness = await makeHarness();
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const body = makePutBody();
|
|
338
|
+
await injectRoute(harness.app, 'put', {
|
|
339
|
+
payload: body,
|
|
340
|
+
token: buildToken(makeClaims('put', body)),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const binding = makeBinding();
|
|
344
|
+
const response = await injectRoute(harness.app, 'get', {
|
|
345
|
+
payload: binding,
|
|
346
|
+
token: buildToken(makeClaims('get', binding)),
|
|
347
|
+
});
|
|
348
|
+
const parsed = parseBody(response.body);
|
|
349
|
+
|
|
350
|
+
expect(response.statusCode).toBe(200);
|
|
351
|
+
expect(parsed.value.kind).toBe('active');
|
|
352
|
+
expect(parsed.value.ownerEncryptedRecovery).toBeUndefined();
|
|
353
|
+
expect(response.body).not.toContain('ownerEncryptedRecovery');
|
|
354
|
+
expect(response.body).not.toContain(RECOVERY_CIPHERTEXT);
|
|
355
|
+
} finally {
|
|
356
|
+
await harness.close();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('only returns recovery for a specifically signed readRecovery intent', async () => {
|
|
361
|
+
const harness = await makeHarness();
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const putBody = makePutBody();
|
|
365
|
+
await injectRoute(harness.app, 'put', {
|
|
366
|
+
payload: putBody,
|
|
367
|
+
token: buildToken(makeClaims('put', putBody)),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const binding = makeBinding();
|
|
371
|
+
const recovery = await injectRoute(harness.app, 'readRecovery', {
|
|
372
|
+
payload: binding,
|
|
373
|
+
token: buildToken(makeClaims('readRecovery', binding)),
|
|
374
|
+
});
|
|
375
|
+
expect(recovery.statusCode).toBe(200);
|
|
376
|
+
expect(parseBody(recovery.body).value.ownerEncryptedRecovery.ciphertext).toBe(
|
|
377
|
+
RECOVERY_CIPHERTEXT
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// A `get`-signed token is not accepted for the recovery route.
|
|
381
|
+
const mismatch = await injectRoute(harness.app, 'readRecovery', {
|
|
382
|
+
payload: binding,
|
|
383
|
+
token: buildToken(makeClaims('get', binding)),
|
|
384
|
+
});
|
|
385
|
+
expect(mismatch.statusCode).toBe(401);
|
|
386
|
+
} finally {
|
|
387
|
+
await harness.close();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('stats active then tombstone and rejects a late put after delete', async () => {
|
|
392
|
+
const harness = await makeHarness();
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const body = makePutBody();
|
|
396
|
+
await injectRoute(harness.app, 'put', {
|
|
397
|
+
payload: body,
|
|
398
|
+
token: buildToken(makeClaims('put', body)),
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const binding = makeBinding();
|
|
402
|
+
const activeStat = await injectRoute(harness.app, 'stat', {
|
|
403
|
+
payload: binding,
|
|
404
|
+
token: buildToken(makeClaims('stat', binding)),
|
|
405
|
+
});
|
|
406
|
+
expect(parseBody(activeStat.body).value.kind).toBe('active');
|
|
407
|
+
|
|
408
|
+
const deleted = await injectRoute(harness.app, 'delete', {
|
|
409
|
+
payload: binding,
|
|
410
|
+
token: buildToken(makeClaims('delete', binding)),
|
|
411
|
+
});
|
|
412
|
+
expect(deleted.statusCode).toBe(200);
|
|
413
|
+
expect(parseBody(deleted.body).value.kind).toBe('tombstone');
|
|
414
|
+
|
|
415
|
+
const tombstoneStat = await injectRoute(harness.app, 'stat', {
|
|
416
|
+
payload: binding,
|
|
417
|
+
token: buildToken(makeClaims('stat', binding)),
|
|
418
|
+
});
|
|
419
|
+
expect(parseBody(tombstoneStat.body).value.kind).toBe('tombstone');
|
|
420
|
+
|
|
421
|
+
const latePut = await injectRoute(harness.app, 'put', {
|
|
422
|
+
payload: body,
|
|
423
|
+
token: buildToken(makeClaims('put', body)),
|
|
424
|
+
});
|
|
425
|
+
expect(latePut.statusCode).toBe(409);
|
|
426
|
+
expect(parseBody(latePut.body)).toEqual({ ok: false, error: 'CONFLICT' });
|
|
427
|
+
} finally {
|
|
428
|
+
await harness.close();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('returns NOT_FOUND for an absent exact object', async () => {
|
|
433
|
+
const harness = await makeHarness();
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const binding = makeBinding({ objectId: 'object-absent' });
|
|
437
|
+
const response = await injectRoute(harness.app, 'get', {
|
|
438
|
+
payload: binding,
|
|
439
|
+
token: buildToken(makeClaims('get', binding)),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(response.statusCode).toBe(404);
|
|
443
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'NOT_FOUND' });
|
|
444
|
+
} finally {
|
|
445
|
+
await harness.close();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('share-content endpoint authorization', () => {
|
|
451
|
+
it('rejects missing, malformed and oversized authorization headers', async () => {
|
|
452
|
+
const harness = await makeHarness();
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const body = makePutBody();
|
|
456
|
+
|
|
457
|
+
expect((await injectRoute(harness.app, 'put', { payload: body })).statusCode).toBe(401);
|
|
458
|
+
expect(
|
|
459
|
+
(
|
|
460
|
+
await injectRoute(harness.app, 'put', {
|
|
461
|
+
payload: body,
|
|
462
|
+
authorization: 'Basic abc',
|
|
463
|
+
})
|
|
464
|
+
).statusCode
|
|
465
|
+
).toBe(401);
|
|
466
|
+
expect(
|
|
467
|
+
(
|
|
468
|
+
await injectRoute(harness.app, 'put', {
|
|
469
|
+
payload: body,
|
|
470
|
+
authorization: `Bearer ${'a'.repeat(9000)}`,
|
|
471
|
+
})
|
|
472
|
+
).statusCode
|
|
473
|
+
).toBe(401);
|
|
474
|
+
expect(
|
|
475
|
+
(
|
|
476
|
+
await injectRoute(harness.app, 'put', {
|
|
477
|
+
payload: body,
|
|
478
|
+
authorization: 'Bearer ..',
|
|
479
|
+
})
|
|
480
|
+
).statusCode
|
|
481
|
+
).toBe(401);
|
|
482
|
+
} finally {
|
|
483
|
+
await harness.close();
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('rejects a malformed token before invoking the signature verifier', async () => {
|
|
488
|
+
const harness = await makeHarness();
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
492
|
+
payload: makePutBody(),
|
|
493
|
+
authorization: 'Bearer not.a.jwt',
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(response.statusCode).toBe(401);
|
|
497
|
+
expect(harness.verifyCalls).toHaveLength(0);
|
|
498
|
+
} finally {
|
|
499
|
+
await harness.close();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('never invokes the verifier for an unknown signer or unknown kid', async () => {
|
|
504
|
+
const harness = await makeHarness();
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const body = makePutBody();
|
|
508
|
+
|
|
509
|
+
const unknownSigner = buildToken(makeClaims('put', body), { signerDid: USER_DID });
|
|
510
|
+
const unknownKid = buildToken(makeClaims('put', body), {
|
|
511
|
+
signerDid: SIGNER,
|
|
512
|
+
kid: `${SIGNER}#key-unknown`,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(
|
|
516
|
+
(await injectRoute(harness.app, 'put', { payload: body, token: unknownSigner }))
|
|
517
|
+
.statusCode
|
|
518
|
+
).toBe(401);
|
|
519
|
+
expect(
|
|
520
|
+
(await injectRoute(harness.app, 'put', { payload: body, token: unknownKid }))
|
|
521
|
+
.statusCode
|
|
522
|
+
).toBe(401);
|
|
523
|
+
expect(harness.verifyCalls).toHaveLength(0);
|
|
524
|
+
} finally {
|
|
525
|
+
await harness.close();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('rejects wrong audience, purpose, operation and signer mismatch', async () => {
|
|
530
|
+
const harness = await makeHarness();
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const body = makePutBody();
|
|
534
|
+
|
|
535
|
+
const cases = [
|
|
536
|
+
makeClaims('put', body, { aud: 'did:web:other.example' }),
|
|
537
|
+
makeClaims('put', body, { purpose: 'other/v1' }),
|
|
538
|
+
makeClaims('put', body, { op: 'get' }),
|
|
539
|
+
makeClaims('put', body, { iss: USER_DID }),
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
for (const claims of cases) {
|
|
543
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
544
|
+
payload: body,
|
|
545
|
+
token: buildToken(claims),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
expect(response.statusCode).toBe(401);
|
|
549
|
+
}
|
|
550
|
+
} finally {
|
|
551
|
+
await harness.close();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('rejects a wrong tuple binding and a wrong body hash', async () => {
|
|
556
|
+
const harness = await makeHarness();
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const body = makePutBody();
|
|
560
|
+
|
|
561
|
+
for (const override of [
|
|
562
|
+
{ objectId: 'object-other' },
|
|
563
|
+
{ ownerProfileId: 'owner-other' },
|
|
564
|
+
{ shareId: OTHER_SHARE_ID },
|
|
565
|
+
{ operationId: 'operation-other' },
|
|
566
|
+
{ contentVersion: 2 },
|
|
567
|
+
]) {
|
|
568
|
+
const wrongBinding = makeClaims('put', body, override);
|
|
569
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
570
|
+
payload: body,
|
|
571
|
+
token: buildToken(wrongBinding),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
expect(response.statusCode).toBe(401);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const wrongHashBody = makePutBody({ envelope: makeEnvelope(2) });
|
|
578
|
+
const wrongHashClaims = makeClaims('put', body);
|
|
579
|
+
const hashResponse = await injectRoute(harness.app, 'put', {
|
|
580
|
+
payload: wrongHashBody,
|
|
581
|
+
token: buildToken(wrongHashClaims),
|
|
582
|
+
});
|
|
583
|
+
expect(hashResponse.statusCode).toBe(401);
|
|
584
|
+
} finally {
|
|
585
|
+
await harness.close();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('rejects a token whose namespace is not bound to the signing service', async () => {
|
|
590
|
+
const harness = await makeHarness();
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
const body = makePutBody({ namespace: 'tenant-other' });
|
|
594
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
595
|
+
payload: body,
|
|
596
|
+
token: buildToken(makeClaims('put', body)),
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
expect(response.statusCode).toBe(401);
|
|
600
|
+
} finally {
|
|
601
|
+
await harness.close();
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('rejects a regular user DID even when the signature verifier would accept it', async () => {
|
|
606
|
+
const harness = await makeHarness();
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const body = makePutBody();
|
|
610
|
+
const token = buildToken(makeClaims('put', body), { signerDid: USER_DID });
|
|
611
|
+
const response = await injectRoute(harness.app, 'put', { payload: body, token });
|
|
612
|
+
|
|
613
|
+
expect(response.statusCode).toBe(401);
|
|
614
|
+
expect(harness.verifyCalls).toHaveLength(0);
|
|
615
|
+
} finally {
|
|
616
|
+
await harness.close();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('rejects a token whose signature verifier reports an error', async () => {
|
|
621
|
+
const harness = await makeHarness({
|
|
622
|
+
verifyImpl: async () => ({ checks: [], warnings: [], errors: ['bad signature'] }),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
const body = makePutBody();
|
|
627
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
628
|
+
payload: body,
|
|
629
|
+
token: buildToken(makeClaims('put', body)),
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
expect(response.statusCode).toBe(401);
|
|
633
|
+
} finally {
|
|
634
|
+
await harness.close();
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
describe('share-content endpoint replay protection', () => {
|
|
640
|
+
it('rejects a sequential replay of the same token', async () => {
|
|
641
|
+
const harness = await makeHarness();
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
const body = makePutBody();
|
|
645
|
+
const token = buildToken(makeClaims('put', body));
|
|
646
|
+
|
|
647
|
+
const first = await injectRoute(harness.app, 'put', { payload: body, token });
|
|
648
|
+
const second = await injectRoute(harness.app, 'put', { payload: body, token });
|
|
649
|
+
|
|
650
|
+
expect(first.statusCode).toBe(200);
|
|
651
|
+
expect(second.statusCode).toBe(401);
|
|
652
|
+
expect(parseBody(second.body)).toEqual({ ok: false, error: 'UNAUTHORIZED' });
|
|
653
|
+
} finally {
|
|
654
|
+
await harness.close();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('accepts exactly one of two concurrent duplicate tokens (atomic SET NX)', async () => {
|
|
659
|
+
const harness = await makeHarness();
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const body = makePutBody();
|
|
663
|
+
const token = buildToken(makeClaims('put', body));
|
|
664
|
+
|
|
665
|
+
const [first, second] = await Promise.all([
|
|
666
|
+
injectRoute(harness.app, 'put', { payload: body, token }),
|
|
667
|
+
injectRoute(harness.app, 'put', { payload: body, token }),
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
const statuses = [first.statusCode, second.statusCode].sort();
|
|
671
|
+
expect(statuses).toEqual([200, 401]);
|
|
672
|
+
expect(await harness.collection.countDocuments()).toBe(1);
|
|
673
|
+
} finally {
|
|
674
|
+
await harness.close();
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('fails closed with UNAVAILABLE and no database effect when Redis is down', async () => {
|
|
679
|
+
const harness = await makeHarness();
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const warmBody = makePutBody({ objectId: 'object-warm' });
|
|
683
|
+
const warm = await injectRoute(harness.app, 'put', {
|
|
684
|
+
payload: warmBody,
|
|
685
|
+
token: buildToken(makeClaims('put', warmBody)),
|
|
686
|
+
});
|
|
687
|
+
expect(warm.statusCode).toBe(200);
|
|
688
|
+
|
|
689
|
+
await harness.replay.close();
|
|
690
|
+
|
|
691
|
+
const body = makePutBody({ objectId: 'object-after-redis-down' });
|
|
692
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
693
|
+
payload: body,
|
|
694
|
+
token: buildToken(makeClaims('put', body)),
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
expect(response.statusCode).toBe(503);
|
|
698
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'UNAVAILABLE' });
|
|
699
|
+
expect(await harness.collection.countDocuments()).toBe(1);
|
|
700
|
+
} finally {
|
|
701
|
+
await harness.close();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe('share-content endpoint body bounds', () => {
|
|
707
|
+
it('rejects a total request larger than 1 MiB', async () => {
|
|
708
|
+
const harness = await makeHarness();
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
const body = makePutBody({ filler: 'a'.repeat(1024 * 1024 + 1024) });
|
|
712
|
+
const response = await injectRoute(harness.app, 'put', { payload: body });
|
|
713
|
+
|
|
714
|
+
expect(response.statusCode).toBe(413);
|
|
715
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'PAYLOAD_TOO_LARGE' });
|
|
716
|
+
} finally {
|
|
717
|
+
await harness.close();
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('rejects decoded ciphertext above 512 KiB', async () => {
|
|
722
|
+
const harness = await makeHarness();
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
const body = makePutBody({
|
|
726
|
+
envelope: {
|
|
727
|
+
v: 1,
|
|
728
|
+
alg: 'A256GCM',
|
|
729
|
+
iv: encodeBase64Url(new Uint8Array(12).fill(7)),
|
|
730
|
+
ct: encodeBase64Url(new Uint8Array(512 * 1024 + 16).fill(3)),
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
734
|
+
payload: body,
|
|
735
|
+
token: buildToken(makeClaims('put', body)),
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
expect(response.statusCode).toBe(413);
|
|
739
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'PAYLOAD_TOO_LARGE' });
|
|
740
|
+
} finally {
|
|
741
|
+
await harness.close();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('rejects serialized recovery above 64 KiB', async () => {
|
|
746
|
+
const harness = await makeHarness();
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const body = makePutBody({
|
|
750
|
+
ownerEncryptedRecovery: makeRecovery({ ciphertext: 'b'.repeat(70 * 1024) }),
|
|
751
|
+
});
|
|
752
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
753
|
+
payload: body,
|
|
754
|
+
token: buildToken(makeClaims('put', body)),
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
expect(response.statusCode).toBe(413);
|
|
758
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'PAYLOAD_TOO_LARGE' });
|
|
759
|
+
} finally {
|
|
760
|
+
await harness.close();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('rejects a non-JSON body with the generic invalid-input error', async () => {
|
|
765
|
+
const harness = await makeHarness();
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
769
|
+
payload: 'not-json',
|
|
770
|
+
contentType: 'application/json',
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
expect(response.statusCode).toBe(400);
|
|
774
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
775
|
+
} finally {
|
|
776
|
+
await harness.close();
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('rejects an unsupported content type with the generic invalid-input error', async () => {
|
|
781
|
+
const harness = await makeHarness();
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
const response = await injectRoute(harness.app, 'put', {
|
|
785
|
+
payload: JSON.stringify(makePutBody()),
|
|
786
|
+
contentType: 'application/xml',
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
expect(response.statusCode).toBe(400);
|
|
790
|
+
expect(parseBody(response.body)).toEqual({ ok: false, error: 'INVALID_INPUT' });
|
|
791
|
+
} finally {
|
|
792
|
+
await harness.close();
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
describe('share-content disabled configuration', () => {
|
|
798
|
+
it('registers no routes when the runtime is disabled', async () => {
|
|
799
|
+
const runtime = await buildShareContentRuntime(
|
|
800
|
+
{ enabled: false },
|
|
801
|
+
{
|
|
802
|
+
getLearnCard: async () => {
|
|
803
|
+
throw new Error('must not be called');
|
|
804
|
+
},
|
|
805
|
+
createReplayStore: () => {
|
|
806
|
+
throw new Error('must not be called');
|
|
807
|
+
},
|
|
808
|
+
getRepository: async () => {
|
|
809
|
+
throw new Error('must not be called');
|
|
810
|
+
},
|
|
811
|
+
}
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
expect(runtime.enabled).toBe(false);
|
|
815
|
+
|
|
816
|
+
const app = Fastify({ logger: false });
|
|
817
|
+
await app.ready();
|
|
818
|
+
|
|
819
|
+
const response = await injectRoute(app, 'put', { payload: makePutBody() });
|
|
820
|
+
|
|
821
|
+
expect(response.statusCode).toBe(404);
|
|
822
|
+
|
|
823
|
+
await app.close();
|
|
824
|
+
});
|
|
825
|
+
});
|