@learncard/network-plugin 2.13.17 → 2.14.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/lcn-plugin.cjs.development.cjs +342 -24
- package/dist/lcn-plugin.cjs.development.cjs.map +3 -3
- package/dist/lcn-plugin.cjs.production.min.cjs +9 -9
- package/dist/lcn-plugin.cjs.production.min.cjs.map +3 -3
- package/dist/lcn-plugin.esm.js +342 -24
- package/dist/lcn-plugin.esm.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/test/verify-boost-regression.test.d.ts +2 -0
- package/dist/test/verify-boost-regression.test.d.ts.map +1 -0
- package/dist/types.d.ts +57 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/plugin.ts +177 -11
- package/src/test/index.test.ts +438 -4
- package/src/test/verify-boost-regression.test.ts +167 -0
- package/src/types.ts +66 -0
package/src/test/index.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- plugin integration mocks intentionally expose dynamic method bags */
|
|
1
2
|
import { generateLearnCard } from '@learncard/core';
|
|
2
3
|
import { getDidKitPlugin } from '@learncard/didkit-plugin';
|
|
3
4
|
import { getDidKeyPlugin } from '@learncard/didkey-plugin';
|
|
@@ -16,8 +17,30 @@ vi.mock('@learncard/core', () => ({ generateLearnCard: vi.fn() }));
|
|
|
16
17
|
vi.mock('@learncard/didkit-plugin', () => ({ getDidKitPlugin: vi.fn() }));
|
|
17
18
|
vi.mock('@learncard/didkey-plugin', () => ({ getDidKeyPlugin: vi.fn() }));
|
|
18
19
|
vi.mock('@learncard/vc-plugin', () => ({ getVCPlugin: vi.fn() }));
|
|
19
|
-
vi.mock('@learncard/helpers', () => ({
|
|
20
|
-
|
|
20
|
+
vi.mock('@learncard/helpers', () => ({
|
|
21
|
+
isVC2Format: (credential: any) => {
|
|
22
|
+
const contexts = credential?.['@context'];
|
|
23
|
+
const list = Array.isArray(contexts) ? contexts : [contexts];
|
|
24
|
+
|
|
25
|
+
return list.includes('https://www.w3.org/ns/credentials/v2');
|
|
26
|
+
},
|
|
27
|
+
getCredentialStatusArray: () => [],
|
|
28
|
+
resolveStorageReadResult: (value: any) => value,
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('@learncard/types', () => ({
|
|
31
|
+
VCValidator: {
|
|
32
|
+
parse: (value: any) => {
|
|
33
|
+
if (!value?.type) throw new Error('Invalid credential');
|
|
34
|
+
return value;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
UnsignedVCValidator: {
|
|
38
|
+
spa: async (value: any) => ({
|
|
39
|
+
success: true,
|
|
40
|
+
data: typeof value === 'string' ? JSON.parse(value) : value,
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
21
44
|
|
|
22
45
|
const PROFILE = {
|
|
23
46
|
profileId: 'usera',
|
|
@@ -34,12 +57,33 @@ const PROFILE = {
|
|
|
34
57
|
|
|
35
58
|
const PROMPT_ID = '00000000-0000-4000-8000-000000000001';
|
|
36
59
|
|
|
60
|
+
const REFRESH_ID = 'cnVsbGFiZWxseXVuZ3Vlc3NhYmxlcmVmcmVzaGlkMTIzNDU2Nzg';
|
|
61
|
+
const TARGET_DID = 'did:web:network.example:users:userb';
|
|
62
|
+
|
|
63
|
+
const ALLOCATION = {
|
|
64
|
+
refreshId: REFRESH_ID,
|
|
65
|
+
refreshService: {
|
|
66
|
+
id: `https://network.example/refresh/${REFRESH_ID}`,
|
|
67
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
68
|
+
authorization: { type: 'LearnCardDIDAuth' },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const getUnsignedBoostTemplate = (overrides: Record<string, unknown> = {}) => ({
|
|
73
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
74
|
+
type: ['VerifiableCredential', 'BoostCredential'],
|
|
75
|
+
issuer: 'did:example:template-issuer',
|
|
76
|
+
validFrom: '2020-01-01T00:00:00.000Z',
|
|
77
|
+
credentialSubject: { id: 'did:example:template-subject' },
|
|
78
|
+
...overrides,
|
|
79
|
+
});
|
|
80
|
+
|
|
37
81
|
const getMockLearnCard = () =>
|
|
38
82
|
({
|
|
39
83
|
id: { did: () => PROFILE.did },
|
|
40
84
|
invoke: { getDidAuthVp: vi.fn() },
|
|
41
85
|
debug: vi.fn(),
|
|
42
|
-
} as any
|
|
86
|
+
}) as any;
|
|
43
87
|
|
|
44
88
|
const getMockClient = (profile: typeof PROFILE | null = PROFILE) => ({
|
|
45
89
|
profile: {
|
|
@@ -67,9 +111,59 @@ const getMockClient = (profile: typeof PROFILE | null = PROFILE) => ({
|
|
|
67
111
|
mutate: vi.fn().mockResolvedValue({ promptId: PROMPT_ID, status: 'CONNECTED' }),
|
|
68
112
|
},
|
|
69
113
|
},
|
|
114
|
+
boost: {
|
|
115
|
+
getBoost: { query: vi.fn().mockResolvedValue({ boost: getUnsignedBoostTemplate() }) },
|
|
116
|
+
sendBoost: { mutate: vi.fn().mockResolvedValue('boost-credential-uri') },
|
|
117
|
+
allocateCredentialStatus: { mutate: vi.fn().mockResolvedValue([]) },
|
|
118
|
+
},
|
|
119
|
+
utilities: {
|
|
120
|
+
getDid: { query: vi.fn().mockResolvedValue('did:web:network.example') },
|
|
121
|
+
},
|
|
122
|
+
credentialRefresh: {
|
|
123
|
+
allocateCredentialRefresh: { mutate: vi.fn().mockResolvedValue(ALLOCATION) },
|
|
124
|
+
sendRefreshableCredential: { mutate: vi.fn().mockResolvedValue('managed-credential-uri') },
|
|
125
|
+
publishCredentialRefresh: {
|
|
126
|
+
mutate: vi.fn().mockResolvedValue({
|
|
127
|
+
refreshId: REFRESH_ID,
|
|
128
|
+
version: 2,
|
|
129
|
+
publishedAt: '2026-09-02T00:00:00.000Z',
|
|
130
|
+
notification: 'queued',
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
getCredentialRefreshHistory: {
|
|
134
|
+
query: vi.fn().mockResolvedValue({
|
|
135
|
+
records: [
|
|
136
|
+
{ version: 2, publishedAt: '2026-09-02T00:00:00.000Z' },
|
|
137
|
+
{ version: 1, publishedAt: '2026-09-01T00:00:00.000Z' },
|
|
138
|
+
],
|
|
139
|
+
hasMore: false,
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
},
|
|
70
143
|
});
|
|
71
144
|
|
|
72
|
-
|
|
145
|
+
const getMockIssuingLearnCard = () => {
|
|
146
|
+
const issuedCredentials: any[] = [];
|
|
147
|
+
|
|
148
|
+
const learnCard = {
|
|
149
|
+
id: { did: () => PROFILE.did },
|
|
150
|
+
invoke: {
|
|
151
|
+
getDidAuthVp: vi.fn(),
|
|
152
|
+
getProfile: vi.fn().mockResolvedValue({ profileId: 'userb', did: TARGET_DID }),
|
|
153
|
+
issueCredential: vi.fn(async (credential: any) => {
|
|
154
|
+
issuedCredentials.push(credential);
|
|
155
|
+
|
|
156
|
+
return { ...credential, proof: { type: 'DataIntegrityProof' } };
|
|
157
|
+
}),
|
|
158
|
+
createDagJwe: vi.fn().mockResolvedValue({ ciphertext: 'encrypted' }),
|
|
159
|
+
},
|
|
160
|
+
debug: vi.fn(),
|
|
161
|
+
} as any;
|
|
162
|
+
|
|
163
|
+
return { learnCard, issuedCredentials };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const learnCards: Record<string, { learnCard: any }> = {};
|
|
73
167
|
|
|
74
168
|
const getLearnCard = async (seed = 'a'.repeat(64)) => {
|
|
75
169
|
if (!learnCards[seed]) {
|
|
@@ -152,6 +246,313 @@ describe('connection prompt methods', () => {
|
|
|
152
246
|
});
|
|
153
247
|
});
|
|
154
248
|
|
|
249
|
+
describe('credential refresh methods', () => {
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
vi.clearAllMocks();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('forwards allocateCredentialRefresh input to the credentialRefresh procedure', async () => {
|
|
255
|
+
const client = getMockClient();
|
|
256
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
257
|
+
const learnCard = getMockLearnCard();
|
|
258
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
259
|
+
|
|
260
|
+
const input = {
|
|
261
|
+
holder: { profileId: 'userb', did: TARGET_DID },
|
|
262
|
+
credentialId: 'urn:uuid:credential-1',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
await expect(plugin.methods?.allocateCredentialRefresh(learnCard, input)).resolves.toEqual(
|
|
266
|
+
ALLOCATION
|
|
267
|
+
);
|
|
268
|
+
expect(client.credentialRefresh.allocateCredentialRefresh.mutate).toHaveBeenCalledWith(
|
|
269
|
+
input
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('forwards sendRefreshableCredential to the managed send procedure', async () => {
|
|
274
|
+
const client = getMockClient();
|
|
275
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
276
|
+
const learnCard = getMockLearnCard();
|
|
277
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
278
|
+
|
|
279
|
+
const signedCredential = {
|
|
280
|
+
...getUnsignedBoostTemplate({ id: 'urn:uuid:credential-1' }),
|
|
281
|
+
proof: { type: 'DataIntegrityProof' },
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
await expect(
|
|
285
|
+
plugin.methods?.sendRefreshableCredential(
|
|
286
|
+
learnCard,
|
|
287
|
+
REFRESH_ID,
|
|
288
|
+
signedCredential as any
|
|
289
|
+
)
|
|
290
|
+
).resolves.toEqual('managed-credential-uri');
|
|
291
|
+
expect(client.credentialRefresh.sendRefreshableCredential.mutate).toHaveBeenCalledWith({
|
|
292
|
+
refreshId: REFRESH_ID,
|
|
293
|
+
credential: signedCredential,
|
|
294
|
+
boostUri: undefined,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('forwards optional boostUri and notification suppression to the managed send procedure', async () => {
|
|
299
|
+
const client = getMockClient();
|
|
300
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
301
|
+
const learnCard = getMockLearnCard();
|
|
302
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
303
|
+
|
|
304
|
+
const signedCredential = {
|
|
305
|
+
...getUnsignedBoostTemplate({ id: 'urn:uuid:credential-1' }),
|
|
306
|
+
proof: { type: 'DataIntegrityProof' },
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
await plugin.methods?.sendRefreshableCredential(
|
|
310
|
+
learnCard,
|
|
311
|
+
REFRESH_ID,
|
|
312
|
+
signedCredential as any,
|
|
313
|
+
'did:example:boost:9',
|
|
314
|
+
true
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(client.credentialRefresh.sendRefreshableCredential.mutate).toHaveBeenCalledWith({
|
|
318
|
+
refreshId: REFRESH_ID,
|
|
319
|
+
credential: signedCredential,
|
|
320
|
+
boostUri: 'did:example:boost:9',
|
|
321
|
+
skipNotification: true,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it.each([
|
|
326
|
+
[
|
|
327
|
+
'issuer-signed',
|
|
328
|
+
{
|
|
329
|
+
mode: 'issuer-signed',
|
|
330
|
+
refreshId: REFRESH_ID,
|
|
331
|
+
signedCredential: {
|
|
332
|
+
...getUnsignedBoostTemplate({ id: 'urn:uuid:credential-1' }),
|
|
333
|
+
proof: { type: 'DataIntegrityProof' },
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
[
|
|
338
|
+
'signing-authority',
|
|
339
|
+
{
|
|
340
|
+
mode: 'signing-authority',
|
|
341
|
+
refreshId: REFRESH_ID,
|
|
342
|
+
credential: getUnsignedBoostTemplate({ id: 'urn:uuid:credential-1' }),
|
|
343
|
+
signingAuthority: { type: 'http', endpoint: 'https://sa.example', name: 'sa' },
|
|
344
|
+
updateSummary: 'Updated employer name',
|
|
345
|
+
idempotencyKey: 'publish-attempt-1',
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
] as const)(
|
|
349
|
+
'forwards publishCredentialRefresh %s input and returns the typed result',
|
|
350
|
+
async (_mode, input) => {
|
|
351
|
+
const client = getMockClient();
|
|
352
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
353
|
+
const learnCard = getMockLearnCard();
|
|
354
|
+
const plugin = await getLearnCardNetworkPlugin(
|
|
355
|
+
learnCard,
|
|
356
|
+
'https://network.example/trpc'
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await expect(
|
|
360
|
+
plugin.methods?.publishCredentialRefresh(learnCard, input as any)
|
|
361
|
+
).resolves.toEqual({
|
|
362
|
+
refreshId: REFRESH_ID,
|
|
363
|
+
version: 2,
|
|
364
|
+
publishedAt: '2026-09-02T00:00:00.000Z',
|
|
365
|
+
notification: 'queued',
|
|
366
|
+
});
|
|
367
|
+
expect(client.credentialRefresh.publishCredentialRefresh.mutate).toHaveBeenCalledWith(
|
|
368
|
+
input
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
it('forwards getCredentialRefreshHistory input to the history query', async () => {
|
|
374
|
+
const client = getMockClient();
|
|
375
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
376
|
+
const learnCard = getMockLearnCard();
|
|
377
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
378
|
+
|
|
379
|
+
const input = { refreshId: REFRESH_ID, cursor: 'cursor-1', limit: 10 };
|
|
380
|
+
|
|
381
|
+
await expect(
|
|
382
|
+
plugin.methods?.getCredentialRefreshHistory(learnCard, input)
|
|
383
|
+
).resolves.toEqual({
|
|
384
|
+
records: [
|
|
385
|
+
{ version: 2, publishedAt: '2026-09-02T00:00:00.000Z' },
|
|
386
|
+
{ version: 1, publishedAt: '2026-09-01T00:00:00.000Z' },
|
|
387
|
+
],
|
|
388
|
+
hasMore: false,
|
|
389
|
+
});
|
|
390
|
+
expect(client.credentialRefresh.getCredentialRefreshHistory.query).toHaveBeenCalledWith(
|
|
391
|
+
input
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it.each([
|
|
396
|
+
[
|
|
397
|
+
'allocateCredentialRefresh',
|
|
398
|
+
[{ holder: { did: TARGET_DID }, credentialId: 'urn:uuid:credential-1' }],
|
|
399
|
+
],
|
|
400
|
+
['sendRefreshableCredential', [REFRESH_ID, getUnsignedBoostTemplate()]],
|
|
401
|
+
[
|
|
402
|
+
'publishCredentialRefresh',
|
|
403
|
+
[
|
|
404
|
+
{
|
|
405
|
+
mode: 'issuer-signed',
|
|
406
|
+
refreshId: REFRESH_ID,
|
|
407
|
+
signedCredential: getUnsignedBoostTemplate(),
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
],
|
|
411
|
+
['getCredentialRefreshHistory', [{ refreshId: REFRESH_ID }]],
|
|
412
|
+
] as const)('runs ensureUser before %s', async (method, args) => {
|
|
413
|
+
const client = getMockClient(null);
|
|
414
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
415
|
+
const learnCard = getMockLearnCard();
|
|
416
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
417
|
+
|
|
418
|
+
await expect((plugin.methods?.[method] as any)(learnCard, ...args)).rejects.toThrow(
|
|
419
|
+
'Please make an account first!'
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const operation = {
|
|
423
|
+
allocateCredentialRefresh: client.credentialRefresh.allocateCredentialRefresh.mutate,
|
|
424
|
+
sendRefreshableCredential: client.credentialRefresh.sendRefreshableCredential.mutate,
|
|
425
|
+
publishCredentialRefresh: client.credentialRefresh.publishCredentialRefresh.mutate,
|
|
426
|
+
getCredentialRefreshHistory: client.credentialRefresh.getCredentialRefreshHistory.query,
|
|
427
|
+
}[method];
|
|
428
|
+
expect(operation).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('sendBoost with enableRefresh allocates before signing, injects the service, and uses managed storage', async () => {
|
|
432
|
+
const client = getMockClient();
|
|
433
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
434
|
+
const { learnCard, issuedCredentials } = getMockIssuingLearnCard();
|
|
435
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
436
|
+
|
|
437
|
+
const uri = await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
|
|
438
|
+
enableRefresh: true,
|
|
439
|
+
skipNotification: true,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(uri).toEqual('managed-credential-uri');
|
|
443
|
+
|
|
444
|
+
// A stable UUID credential ID is generated when the template has none
|
|
445
|
+
expect(client.credentialRefresh.allocateCredentialRefresh.mutate).toHaveBeenCalledTimes(1);
|
|
446
|
+
const allocateInput = client.credentialRefresh.allocateCredentialRefresh.mutate.mock
|
|
447
|
+
.calls[0]?.[0] as any;
|
|
448
|
+
expect(allocateInput.holder).toEqual({ profileId: 'userb', did: TARGET_DID });
|
|
449
|
+
expect(allocateInput.credentialId).toMatch(
|
|
450
|
+
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Allocation happens before signing so the service lands in the signed payload
|
|
454
|
+
const allocateOrder =
|
|
455
|
+
client.credentialRefresh.allocateCredentialRefresh.mutate.mock.invocationCallOrder[0]!;
|
|
456
|
+
const signOrder = learnCard.invoke.issueCredential.mock.invocationCallOrder[0]!;
|
|
457
|
+
expect(allocateOrder).toBeLessThan(signOrder);
|
|
458
|
+
|
|
459
|
+
// The allocated service and its inline context are injected into the signed credential
|
|
460
|
+
const signedInput = issuedCredentials[0];
|
|
461
|
+
expect(signedInput.id).toEqual(allocateInput.credentialId);
|
|
462
|
+
expect(signedInput.refreshService).toEqual(ALLOCATION.refreshService);
|
|
463
|
+
expect(signedInput['@context']).toContainEqual(
|
|
464
|
+
expect.objectContaining({ 'LearnCardCredentialRefresh2026': expect.any(String) })
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
// Dedicated managed-send procedure is used instead of legacy credential storage,
|
|
468
|
+
// with holder-only server-side encryption (no client-side JWE, no LCN recipient).
|
|
469
|
+
// The boost URI is forwarded so the issued credential stays linked INSTANCE_OF
|
|
470
|
+
// the boost for canonical recipient management (including revocation).
|
|
471
|
+
expect(client.credentialRefresh.sendRefreshableCredential.mutate).toHaveBeenCalledWith({
|
|
472
|
+
refreshId: REFRESH_ID,
|
|
473
|
+
credential: expect.objectContaining({
|
|
474
|
+
id: allocateInput.credentialId,
|
|
475
|
+
refreshService: ALLOCATION.refreshService,
|
|
476
|
+
proof: { type: 'DataIntegrityProof' },
|
|
477
|
+
}),
|
|
478
|
+
boostUri: 'did:example:boost:1',
|
|
479
|
+
skipNotification: true,
|
|
480
|
+
});
|
|
481
|
+
expect(client.boost.sendBoost.mutate).not.toHaveBeenCalled();
|
|
482
|
+
expect(learnCard.invoke.createDagJwe).not.toHaveBeenCalled();
|
|
483
|
+
expect(client.utilities.getDid.query).not.toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('sendBoost with enableRefresh reuses an existing credential ID', async () => {
|
|
487
|
+
const client = getMockClient();
|
|
488
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
489
|
+
client.boost.getBoost.query.mockResolvedValue({
|
|
490
|
+
boost: getUnsignedBoostTemplate({ id: 'urn:uuid:existing-credential-id' }),
|
|
491
|
+
});
|
|
492
|
+
const { learnCard } = getMockIssuingLearnCard();
|
|
493
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
494
|
+
|
|
495
|
+
await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
|
|
496
|
+
enableRefresh: true,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(client.credentialRefresh.allocateCredentialRefresh.mutate).toHaveBeenCalledWith({
|
|
500
|
+
holder: { profileId: 'userb', did: TARGET_DID },
|
|
501
|
+
credentialId: 'urn:uuid:existing-credential-id',
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it.each([[{ encrypt: true }], [{ encrypt: false }], [true], [false], [undefined]] as const)(
|
|
506
|
+
'sendBoost without enableRefresh keeps the legacy storage path (options=%j)',
|
|
507
|
+
async options => {
|
|
508
|
+
const client = getMockClient();
|
|
509
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
510
|
+
const { learnCard } = getMockIssuingLearnCard();
|
|
511
|
+
const plugin = await getLearnCardNetworkPlugin(
|
|
512
|
+
learnCard,
|
|
513
|
+
'https://network.example/trpc'
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const uri = await plugin.methods?.sendBoost(
|
|
517
|
+
learnCard,
|
|
518
|
+
'userb',
|
|
519
|
+
'did:example:boost:1',
|
|
520
|
+
options as any
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect(uri).toEqual('boost-credential-uri');
|
|
524
|
+
expect(client.boost.sendBoost.mutate).toHaveBeenCalledTimes(1);
|
|
525
|
+
expect(
|
|
526
|
+
client.credentialRefresh.allocateCredentialRefresh.mutate
|
|
527
|
+
).not.toHaveBeenCalled();
|
|
528
|
+
expect(
|
|
529
|
+
client.credentialRefresh.sendRefreshableCredential.mutate
|
|
530
|
+
).not.toHaveBeenCalled();
|
|
531
|
+
|
|
532
|
+
const sentCredential = (client.boost.sendBoost.mutate.mock.calls[0]?.[0] as any)
|
|
533
|
+
?.credential;
|
|
534
|
+
const encrypts =
|
|
535
|
+
options === undefined ||
|
|
536
|
+
options === true ||
|
|
537
|
+
(typeof options === 'object' && options.encrypt);
|
|
538
|
+
|
|
539
|
+
if (encrypts) {
|
|
540
|
+
expect(learnCard.invoke.createDagJwe).toHaveBeenCalledWith(expect.anything(), [
|
|
541
|
+
PROFILE.did,
|
|
542
|
+
TARGET_DID,
|
|
543
|
+
'did:web:network.example',
|
|
544
|
+
]);
|
|
545
|
+
expect(sentCredential).toEqual({ ciphertext: 'encrypted' });
|
|
546
|
+
} else {
|
|
547
|
+
expect(learnCard.invoke.createDagJwe).not.toHaveBeenCalled();
|
|
548
|
+
expect(sentCredential).toEqual(
|
|
549
|
+
expect.objectContaining({ proof: { type: 'DataIntegrityProof' } })
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
155
556
|
// Skipping until LCN has the updated function and vc-templates can be updated with resolveable VCs.
|
|
156
557
|
describe.skip('LearnCard Network Plugin', () => {
|
|
157
558
|
describe('NetworkLearnCard', () => {
|
|
@@ -205,3 +606,36 @@ describe.skip('LearnCard Network Plugin', () => {
|
|
|
205
606
|
});
|
|
206
607
|
});
|
|
207
608
|
});
|
|
609
|
+
|
|
610
|
+
it('recovers valid deliveries despite decryption and validation failures on the page', async () => {
|
|
611
|
+
const client = {
|
|
612
|
+
...getMockClient(),
|
|
613
|
+
inbox: {
|
|
614
|
+
getMyInboxDeliveries: {
|
|
615
|
+
query: vi.fn().mockResolvedValue({
|
|
616
|
+
records: [
|
|
617
|
+
{ id: 'bad-decrypt', credential: 'broken' },
|
|
618
|
+
{ id: 'bad-vc', credential: {} },
|
|
619
|
+
{ id: 'good', credential: { type: ['VerifiableCredential'] } },
|
|
620
|
+
],
|
|
621
|
+
hasMore: true,
|
|
622
|
+
cursor: 'good',
|
|
623
|
+
}),
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as any);
|
|
628
|
+
const wallet = getMockLearnCard();
|
|
629
|
+
wallet.id.keypair = () => ({ publicKey: 'key' });
|
|
630
|
+
wallet.invoke.decryptDagJwe = vi.fn(async (credential: any) => {
|
|
631
|
+
if (credential === 'broken') throw new Error('Cannot decrypt');
|
|
632
|
+
return credential;
|
|
633
|
+
});
|
|
634
|
+
const plugin = await getLearnCardNetworkPlugin(wallet, 'https://network.example');
|
|
635
|
+
expect(await plugin.methods?.recoverInboxCredentials(wallet, {})).toEqual({
|
|
636
|
+
records: [{ id: 'good', credential: { type: ['VerifiableCredential'] } }],
|
|
637
|
+
failed: 2,
|
|
638
|
+
hasMore: true,
|
|
639
|
+
cursor: 'good',
|
|
640
|
+
});
|
|
641
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { VC } from '@learncard/types';
|
|
3
|
+
|
|
4
|
+
import { getVerifyBoostPlugin } from '../plugin';
|
|
5
|
+
|
|
6
|
+
const trustedIssuer = 'did:web:network.learncard.com';
|
|
7
|
+
const boostId = 'lc:network:network.learncard.com/trpc:boost:test';
|
|
8
|
+
const result = (errors: string[] = []) => ({ errors, warnings: [], checks: ['proof', 'status'] });
|
|
9
|
+
|
|
10
|
+
describe('VerifyBoost wrapper transition', () => {
|
|
11
|
+
it.each([
|
|
12
|
+
['lc:network:localhost%3A4000/trpc:boost:test', 'did:web:localhost%3A4000'],
|
|
13
|
+
['lc:network:preview.example/brain/trpc:boost:test', 'did:web:preview.example:brain'],
|
|
14
|
+
['lc:network:preview.example/trpc/trpc:boost:test', 'did:web:preview.example:trpc'],
|
|
15
|
+
['lc:network:network.learncard.com/boost:test', trustedIssuer],
|
|
16
|
+
])('resolves the registry network from %s', async (boostId, did) => {
|
|
17
|
+
const card = {
|
|
18
|
+
invoke: { verifyCredential: vi.fn().mockResolvedValue(result()) },
|
|
19
|
+
} as unknown as Parameters<typeof getVerifyBoostPlugin>[0];
|
|
20
|
+
const registryUrl = `data:application/json,${encodeURIComponent(
|
|
21
|
+
JSON.stringify([{ id: 'Test Network', did, url: 'https://registry.example' }])
|
|
22
|
+
)}`;
|
|
23
|
+
const plugin = await getVerifyBoostPlugin(card, registryUrl);
|
|
24
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
25
|
+
issuer: 'did:key:z6MkSigningAuthority',
|
|
26
|
+
boostId,
|
|
27
|
+
type: ['BoostCredential'],
|
|
28
|
+
} as unknown as VC);
|
|
29
|
+
expect(verified.checks).toContain('Boost is Authentic. Verified by Test Network.');
|
|
30
|
+
expect(verified.errors).toEqual([]);
|
|
31
|
+
expect(verified.warnings).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not infer authenticity for a pre-signed Boost without a signed association', async () => {
|
|
35
|
+
const card = {
|
|
36
|
+
invoke: { verifyCredential: vi.fn().mockResolvedValue(result()) },
|
|
37
|
+
} as unknown as Parameters<typeof getVerifyBoostPlugin>[0];
|
|
38
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
39
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
40
|
+
issuer: trustedIssuer,
|
|
41
|
+
type: ['BoostCredential'],
|
|
42
|
+
} as unknown as VC);
|
|
43
|
+
expect(verified.errors).toEqual([]);
|
|
44
|
+
expect(verified.checks).toContain('proof');
|
|
45
|
+
expect(verified.checks.some(check => check.includes('Boost is Authentic'))).toBe(false);
|
|
46
|
+
expect(verified.warnings).toEqual([
|
|
47
|
+
'Boost Authenticity could not be verified: Boost ID metadata is missing.',
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it.each([
|
|
52
|
+
'did:key:z6MkSigningAuthority',
|
|
53
|
+
'did:web:network.learncard.com:users:issuer',
|
|
54
|
+
{ id: 'did:web:network.learncard.com:users:issuer' },
|
|
55
|
+
])('verifies a direct SA credential using its signed Boost network (%j)', async issuer => {
|
|
56
|
+
const verifyCredential = vi.fn().mockResolvedValue(result());
|
|
57
|
+
const card = { invoke: { verifyCredential } } as unknown as Parameters<
|
|
58
|
+
typeof getVerifyBoostPlugin
|
|
59
|
+
>[0];
|
|
60
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
61
|
+
const vc = { issuer, boostId, type: ['BoostCredential'] } as unknown as VC;
|
|
62
|
+
const verified = await plugin.methods.verifyCredential(card, vc);
|
|
63
|
+
expect(verifyCredential).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(verified.errors).toEqual([]);
|
|
65
|
+
expect(verified.checks).toContain('Boost is Authentic. Verified by LearnCard Network.');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
'lc:network:untrusted.example/boost:test',
|
|
70
|
+
'lc:network:network.learncard.com.evil.example/boost:test',
|
|
71
|
+
'lc:network:network.learncard.com@evil.example/boost:test',
|
|
72
|
+
'lc:network:network.learncard.com/credential:test',
|
|
73
|
+
'lc:network:network.learncard.com/boost:',
|
|
74
|
+
'lc:network:network.learncard.com/boost:test?network=trusted',
|
|
75
|
+
'not-a-boost-uri',
|
|
76
|
+
])(
|
|
77
|
+
'does not mark a direct credential with an untrusted or invalid Boost URI authentic (%s)',
|
|
78
|
+
async boostId => {
|
|
79
|
+
const card = {
|
|
80
|
+
invoke: { verifyCredential: vi.fn().mockResolvedValue(result()) },
|
|
81
|
+
} as unknown as Parameters<typeof getVerifyBoostPlugin>[0];
|
|
82
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
83
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
84
|
+
issuer: trustedIssuer,
|
|
85
|
+
boostId,
|
|
86
|
+
type: ['BoostCredential'],
|
|
87
|
+
} as unknown as VC);
|
|
88
|
+
expect(verified.checks.some(check => check.includes('Boost is Authentic'))).toBe(false);
|
|
89
|
+
expect(verified.warnings).toEqual([
|
|
90
|
+
`Boost Authenticity could not be verified. Boost ID does not identify a trusted network: ${boostId}`,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it.each(['Invalid signature', 'Credential revoked'])(
|
|
96
|
+
'explains why a direct credential with %s cannot establish Boost authenticity',
|
|
97
|
+
async error => {
|
|
98
|
+
const card = {
|
|
99
|
+
invoke: { verifyCredential: vi.fn().mockResolvedValue(result([error])) },
|
|
100
|
+
} as unknown as Parameters<typeof getVerifyBoostPlugin>[0];
|
|
101
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
102
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
103
|
+
issuer: trustedIssuer,
|
|
104
|
+
boostId,
|
|
105
|
+
type: ['BoostCredential'],
|
|
106
|
+
} as unknown as VC);
|
|
107
|
+
expect(verified.errors).toEqual([error]);
|
|
108
|
+
expect(verified.warnings).toContain(
|
|
109
|
+
'Boost Authenticity could not be verified: Credential verification failed.'
|
|
110
|
+
);
|
|
111
|
+
expect(verified.checks.some(check => check.includes('Boost is Authentic'))).toBe(false);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it('preserves legacy inner verification failures even when both boost IDs are missing', async () => {
|
|
116
|
+
const verifyCredential = vi
|
|
117
|
+
.fn()
|
|
118
|
+
.mockResolvedValueOnce(result())
|
|
119
|
+
.mockResolvedValueOnce(result(['Credential revoked']));
|
|
120
|
+
const card = { invoke: { verifyCredential } } as unknown as Parameters<
|
|
121
|
+
typeof getVerifyBoostPlugin
|
|
122
|
+
>[0];
|
|
123
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
124
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
125
|
+
issuer: trustedIssuer,
|
|
126
|
+
type: ['CertifiedBoostCredential'],
|
|
127
|
+
boostCredential: { issuer: 'did:example:issuer', type: ['BoostCredential'] },
|
|
128
|
+
} as unknown as VC);
|
|
129
|
+
expect(verifyCredential).toHaveBeenCalledTimes(2);
|
|
130
|
+
expect(verified.errors).toContain('Credential revoked');
|
|
131
|
+
expect(verified.checks).not.toContain('status');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('continues to verify matching legacy wrappers', async () => {
|
|
135
|
+
const verifyCredential = vi.fn().mockImplementation(async () => result());
|
|
136
|
+
const card = { invoke: { verifyCredential } } as unknown as Parameters<
|
|
137
|
+
typeof getVerifyBoostPlugin
|
|
138
|
+
>[0];
|
|
139
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
140
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
141
|
+
issuer: trustedIssuer,
|
|
142
|
+
boostId,
|
|
143
|
+
type: ['CertifiedBoostCredential'],
|
|
144
|
+
boostCredential: { issuer: 'did:example:issuer', boostId, type: ['BoostCredential'] },
|
|
145
|
+
} as unknown as VC);
|
|
146
|
+
expect(verifyCredential).toHaveBeenCalledTimes(2);
|
|
147
|
+
expect(verified.errors).toEqual([]);
|
|
148
|
+
expect(verified.checks).toContain('Boost is Authentic. Verified by LearnCard Network.');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('identifies the issuer when a legacy wrapper is outside the trust network', async () => {
|
|
152
|
+
const card = {
|
|
153
|
+
invoke: { verifyCredential: vi.fn().mockImplementation(async () => result()) },
|
|
154
|
+
} as unknown as Parameters<typeof getVerifyBoostPlugin>[0];
|
|
155
|
+
const plugin = await getVerifyBoostPlugin(card);
|
|
156
|
+
const verified = await plugin.methods.verifyCredential(card, {
|
|
157
|
+
issuer: 'did:example:untrusted',
|
|
158
|
+
boostId,
|
|
159
|
+
type: ['CertifiedBoostCredential'],
|
|
160
|
+
boostCredential: { issuer: 'did:example:issuer', boostId, type: ['BoostCredential'] },
|
|
161
|
+
} as unknown as VC);
|
|
162
|
+
expect(verified.checks.some(check => check.includes('Boost is Authentic'))).toBe(false);
|
|
163
|
+
expect(verified.warnings).toEqual([
|
|
164
|
+
'Boost Authenticity could not be verified. Issuer is outside of trust network: did:example:untrusted',
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
});
|