@learncard/network-plugin 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,735 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- plugin integration mocks intentionally expose dynamic method bags */
2
+ /**
3
+ * LC-2345 A3 — SDK holder-mode (student-only encrypted) managed sends.
4
+ *
5
+ * Covers the `encryption: 'holder'` branch of the network plugin's unified `send`
6
+ * method and the dedicated `publishHolderEncryptedRefreshable` method: client-side
7
+ * rejection of inbox/signedCredential/no-local-signing/no-Encryption-plugin inputs,
8
+ * refusal to fall back to network-mode plaintext against an old server, exact-recipient
9
+ * encryption with a real (unmocked) `assertExactJweRecipients`/`createDagJweExact`
10
+ * contract, and the `RECIPIENTS_CHANGED` retry-once semantics.
11
+ *
12
+ * Follows the mocking conventions of `refresh-send.test.ts`.
13
+ */
14
+ import { getClient as getBrainClient } from '@learncard/network-brain-client';
15
+ import { vi } from 'vitest';
16
+ import { UnsignedVCValidator } from '@learncard/types';
17
+
18
+ import { getLearnCardNetworkPlugin } from '../';
19
+
20
+ vi.mock('@learncard/network-brain-client', () => ({
21
+ getClient: vi.fn(),
22
+ getApiTokenClient: vi.fn(),
23
+ }));
24
+ vi.mock('@learncard/core', () => ({ generateLearnCard: vi.fn() }));
25
+ vi.mock('@learncard/didkit-plugin', () => ({ getDidKitPlugin: vi.fn() }));
26
+ vi.mock('@learncard/didkey-plugin', () => ({ getDidKeyPlugin: vi.fn() }));
27
+ vi.mock('@learncard/vc-plugin', () => ({ getVCPlugin: vi.fn() }));
28
+ vi.mock('@learncard/helpers', async importOriginal => ({
29
+ ...(await importOriginal<typeof import('@learncard/helpers')>()),
30
+ getCredentialStatusArray: () => [],
31
+ resolveStorageReadResult: (value: any) => value,
32
+ }));
33
+ vi.mock('@learncard/types', async importOriginal => ({
34
+ ...(await importOriginal<object>()),
35
+ VCValidator: {
36
+ parse: (value: any) => {
37
+ if (!value?.type) throw new Error('Invalid credential');
38
+ return value;
39
+ },
40
+ },
41
+ UnsignedVCValidator: {
42
+ parse: (await importOriginal<typeof import('@learncard/types')>()).UnsignedVCValidator
43
+ .parse,
44
+ spa: async (value: any) => ({
45
+ success: true,
46
+ data: typeof value === 'string' ? JSON.parse(value) : value,
47
+ }),
48
+ },
49
+ }));
50
+
51
+ const PROFILE = {
52
+ profileId: 'usera',
53
+ displayName: 'User A',
54
+ shortBio: '',
55
+ bio: '',
56
+ did: 'did:web:network.example:users:usera',
57
+ isPrivate: false,
58
+ profileVisibility: 'public',
59
+ showEmail: false,
60
+ allowConnectionRequests: 'anyone',
61
+ isServiceProfile: false,
62
+ };
63
+
64
+ const TARGET_PROFILE = { profileId: 'userb', did: 'did:key:zTargetHolder' };
65
+ const REFRESH_ID = 'cmVjaXBpZW50c2NoYW5nZWRyZWZyZXNoaWQxMjM0NTY3OA';
66
+
67
+ const STATUS_ENTRY = {
68
+ id: 'https://network.example/status/3#94567',
69
+ type: 'BitstringStatusListEntry' as const,
70
+ statusPurpose: 'revocation' as const,
71
+ statusListIndex: '94567',
72
+ statusListCredential: 'https://network.example/status/3',
73
+ };
74
+
75
+ const REFRESH_SERVICE = {
76
+ id: `https://network.example/refresh/${REFRESH_ID}`,
77
+ type: 'LearnCardCredentialRefresh2026' as const,
78
+ authorization: { type: 'LearnCardDIDAuth' as const },
79
+ };
80
+
81
+ /** Appends a deterministic key-agreement fragment to a DID, mirroring did:key verification-method ids. */
82
+ const keyIdForDid = (did: string): string => `${did}#${did.split(':').pop()}-key`;
83
+
84
+ const HOLDER_DID = 'did:key:zHolder';
85
+ const MANAGER_DID = 'did:key:zManager';
86
+ const RECIPIENT_KEY_IDS = [keyIdForDid(HOLDER_DID), keyIdForDid(MANAGER_DID)];
87
+
88
+ const defaultPreparedHolder = {
89
+ boostUri: 'lc:network:localhost%3A3000/trpc:boost:prepared',
90
+ credentialId: 'urn:uuid:prepared-credential',
91
+ refreshId: REFRESH_ID,
92
+ refreshService: REFRESH_SERVICE,
93
+ holderDid: HOLDER_DID,
94
+ statusEntries: [STATUS_ENTRY],
95
+ recipientKeyIds: RECIPIENT_KEY_IDS,
96
+ };
97
+
98
+ /** Builds a fake JWE whose recipient `kid`s are exactly derived from the given DIDs. */
99
+ const jweForDids = (dids: string[]) => ({
100
+ protected: 'protected',
101
+ iv: 'iv',
102
+ ciphertext: 'encrypted-ciphertext',
103
+ tag: 'tag',
104
+ recipients: dids.map(did => ({
105
+ header: { kid: keyIdForDid(did), alg: 'ECDH-ES+A256KW', iv: 'iv', tag: 'tag' },
106
+ encrypted_key: 'encrypted-key',
107
+ })),
108
+ });
109
+
110
+ const getUnsignedBoostTemplate = (overrides: Record<string, unknown> = {}) => ({
111
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
112
+ type: ['VerifiableCredential', 'BoostCredential'],
113
+ issuer: 'did:example:template-issuer',
114
+ validFrom: '2020-01-01T00:00:00.000Z',
115
+ credentialSubject: { id: 'did:example:template-subject' },
116
+ ...overrides,
117
+ });
118
+
119
+ const getMockIssuingLearnCard = (overrides: Record<string, any> = {}) =>
120
+ ({
121
+ id: { did: () => PROFILE.did },
122
+ invoke: {
123
+ getDidAuthVp: vi.fn(),
124
+ getProfile: vi.fn().mockResolvedValue(TARGET_PROFILE),
125
+ issueCredential: vi.fn(async (credential: any) => ({
126
+ ...credential,
127
+ proof: { type: 'DataIntegrityProof' },
128
+ })),
129
+ createDagJwe: vi.fn(),
130
+ createDagJweExact: vi.fn(async (_cleartext: unknown, recipients: string[]) =>
131
+ jweForDids(recipients)
132
+ ),
133
+ ...overrides,
134
+ },
135
+ debug: vi.fn(),
136
+ }) as any;
137
+
138
+ const getMockClient = (options: Record<string, any> = {}) => ({
139
+ profile: {
140
+ getProfile: { query: vi.fn().mockResolvedValue(PROFILE) },
141
+ },
142
+ boost: {
143
+ prepareRefreshableSend: {
144
+ mutate: vi.fn().mockResolvedValue(defaultPreparedHolder),
145
+ },
146
+ getBoost: { query: vi.fn().mockResolvedValue({ boost: getUnsignedBoostTemplate() }) },
147
+ send: { mutate: vi.fn() },
148
+ allocateCredentialStatus: { mutate: vi.fn().mockResolvedValue([]) },
149
+ },
150
+ utilities: { getDid: { query: vi.fn().mockResolvedValue('did:web:network.example') } },
151
+ credentialRefresh: {
152
+ allocateCredentialRefresh: { mutate: vi.fn() },
153
+ sendRefreshableCredential: { mutate: vi.fn() },
154
+ sendHolderEncryptedRefreshable: {
155
+ mutate: vi.fn().mockResolvedValue({
156
+ uri: 'did:web:network.example:credentials:holder-1',
157
+ activityId: 'activity-holder-1',
158
+ }),
159
+ },
160
+ getEncryptionRecipients: {
161
+ query: vi.fn().mockResolvedValue({ recipientKeyIds: RECIPIENT_KEY_IDS }),
162
+ },
163
+ publishHolderEncryptedRefreshable: {
164
+ mutate: vi.fn().mockResolvedValue({
165
+ refreshId: REFRESH_ID,
166
+ version: 2,
167
+ publishedAt: '2024-01-01T00:00:00.000Z',
168
+ notification: 'queued',
169
+ }),
170
+ },
171
+ },
172
+ ...options,
173
+ });
174
+
175
+ /** Every argument ever passed to a `client.*.*.mutate`/`.query` mock, flattened for a single substring check. */
176
+ const allClientCallArgsJson = (client: Record<string, any>): string => {
177
+ const calls: unknown[] = [];
178
+
179
+ for (const namespace of Object.values(client)) {
180
+ for (const procedure of Object.values(namespace as Record<string, any>)) {
181
+ for (const key of ['mutate', 'query']) {
182
+ const fn = (procedure as Record<string, any>)[key];
183
+ if (fn?.mock?.calls) calls.push(...fn.mock.calls);
184
+ }
185
+ }
186
+ }
187
+
188
+ return JSON.stringify(calls);
189
+ };
190
+
191
+ const getPlugin = async (learnCard: any, client: any) => {
192
+ vi.mocked(getBrainClient).mockResolvedValue(client as never);
193
+
194
+ return getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
195
+ };
196
+
197
+ const HOLDER_SEND_INPUT = {
198
+ type: 'boost' as const,
199
+ recipient: 'userb',
200
+ templateUri: 'did:web:network.example:boost:1',
201
+ refresh: true as const,
202
+ encryption: 'holder' as const,
203
+ };
204
+
205
+ beforeEach(() => {
206
+ vi.clearAllMocks();
207
+ });
208
+
209
+ describe('unified send with encryption: "holder"', () => {
210
+ it('preserves additional services and forwards the preparation idempotency key', async () => {
211
+ const other = {
212
+ id: 'https://issuer.example/refresh/other',
213
+ type: '1EdTechCredentialRefresh',
214
+ };
215
+ const client = getMockClient();
216
+ client.boost.getBoost.query.mockResolvedValue({
217
+ boost: getUnsignedBoostTemplate({ refreshService: other }),
218
+ });
219
+ const learnCard = getMockIssuingLearnCard();
220
+ const plugin = await getPlugin(learnCard, client);
221
+ const result = await plugin.methods?.send(learnCard, {
222
+ ...HOLDER_SEND_INPUT,
223
+ idempotencyKey: 'stable-key',
224
+ });
225
+ expect(result).toMatchObject({ refresh: { refreshService: [REFRESH_SERVICE, other] } });
226
+ expect(client.credentialRefresh.sendHolderEncryptedRefreshable.mutate).toHaveBeenCalledWith(
227
+ expect.objectContaining({
228
+ idempotencyKey: 'stable-key',
229
+ handoff: expect.objectContaining({ refreshService: [REFRESH_SERVICE, other] }),
230
+ })
231
+ );
232
+ });
233
+ it.each([false, true])('preserves the signed status shape (array=%s)', async arrayShape => {
234
+ const credentialStatus = arrayShape ? [STATUS_ENTRY] : STATUS_ENTRY;
235
+ const client = getMockClient();
236
+ client.boost.getBoost.query.mockResolvedValue({
237
+ boost: getUnsignedBoostTemplate({ credentialStatus }),
238
+ });
239
+ const learnCard = getMockIssuingLearnCard();
240
+ const plugin = await getPlugin(learnCard, client);
241
+
242
+ const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
243
+
244
+ expect(client.credentialRefresh.sendHolderEncryptedRefreshable.mutate).toHaveBeenCalledWith(
245
+ expect.objectContaining({ handoff: expect.objectContaining({ credentialStatus }) })
246
+ );
247
+ expect(result).toMatchObject({ refresh: { credentialStatus } });
248
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
249
+ expect.objectContaining({ credentialStatus })
250
+ );
251
+ });
252
+
253
+ it('encrypts exactly to recipientKeyIds and sends only the ciphertext handoff', async () => {
254
+ const client = getMockClient();
255
+ const learnCard = getMockIssuingLearnCard();
256
+ const plugin = await getPlugin(learnCard, client);
257
+
258
+ const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
259
+
260
+ expect(client.boost.prepareRefreshableSend.mutate).toHaveBeenCalledExactlyOnceWith(
261
+ expect.objectContaining({ encryption: 'holder', recipient: 'userb' })
262
+ );
263
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
264
+ expect(client.credentialRefresh.sendRefreshableCredential.mutate).not.toHaveBeenCalled();
265
+
266
+ const handoffCall = client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mock
267
+ .calls[0]?.[0] as any;
268
+
269
+ expect(Object.keys(handoffCall.handoff).sort()).toEqual(
270
+ [
271
+ 'credentialId',
272
+ 'credentialStatus',
273
+ 'holderDid',
274
+ 'issuerDid',
275
+ 'jwe',
276
+ 'refreshId',
277
+ 'refreshService',
278
+ ].sort()
279
+ );
280
+ expect(handoffCall.handoff.credentialId).toBe(defaultPreparedHolder.credentialId);
281
+ expect(handoffCall.handoff.holderDid).toBe(defaultPreparedHolder.holderDid);
282
+ expect(handoffCall.handoff.issuerDid).toBe(PROFILE.did);
283
+ expect(
284
+ handoffCall.handoff.jwe.recipients.map((recipient: any) => recipient.header.kid).sort()
285
+ ).toEqual([...RECIPIENT_KEY_IDS].sort());
286
+ expect(handoffCall.boostUri).toBe(defaultPreparedHolder.boostUri);
287
+
288
+ expect(result).toEqual({
289
+ type: 'boost',
290
+ uri: defaultPreparedHolder.boostUri,
291
+ credentialUri: 'did:web:network.example:credentials:holder-1',
292
+ activityId: 'activity-holder-1',
293
+ refresh: {
294
+ refreshId: defaultPreparedHolder.refreshId,
295
+ credentialId: defaultPreparedHolder.credentialId,
296
+ holderDid: defaultPreparedHolder.holderDid,
297
+ issuerDid: PROFILE.did,
298
+ credentialStatus: STATUS_ENTRY,
299
+ refreshService: defaultPreparedHolder.refreshService,
300
+ },
301
+ });
302
+ });
303
+
304
+ it('never includes templateData or any claim value derived from it in a client call payload', async () => {
305
+ const SENTINEL = 'SENTINEL_DO_NOT_LEAK_7f3a9c';
306
+ const client = getMockClient();
307
+ const learnCard = getMockIssuingLearnCard();
308
+ const plugin = await getPlugin(learnCard, client);
309
+
310
+ await plugin.methods?.send(learnCard, {
311
+ ...HOLDER_SEND_INPUT,
312
+ template: {
313
+ credential: getUnsignedBoostTemplate({
314
+ credentialSubject: { id: 'did:example:template-subject', secret: '{{secret}}' },
315
+ }),
316
+ },
317
+ templateUri: undefined,
318
+ templateData: { secret: SENTINEL },
319
+ });
320
+
321
+ // Sanity check: templating actually ran locally and embedded the sentinel into
322
+ // what gets signed, so the negative assertion below isn't vacuous.
323
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
324
+ expect.objectContaining({
325
+ credentialSubject: expect.objectContaining({ secret: SENTINEL }),
326
+ })
327
+ );
328
+
329
+ expect(allClientCallArgsJson(client)).not.toContain(SENTINEL);
330
+ });
331
+
332
+ it('rejects encryption: "holder" without refresh: true, before any server call', async () => {
333
+ const client = getMockClient();
334
+ const learnCard = getMockIssuingLearnCard();
335
+ const plugin = await getPlugin(learnCard, client);
336
+
337
+ await expect(
338
+ plugin.methods?.send(learnCard, {
339
+ type: 'boost',
340
+ recipient: 'userb',
341
+ templateUri: 'did:web:network.example:boost:1',
342
+ encryption: 'holder',
343
+ })
344
+ ).rejects.toThrow('requires refresh: true');
345
+
346
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
347
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
348
+ });
349
+
350
+ it.each(['holder@example.com', '+15551234567'])(
351
+ 'rejects an inbox recipient (%s) client-side, before any server call',
352
+ async recipient => {
353
+ const client = getMockClient();
354
+ const learnCard = getMockIssuingLearnCard();
355
+ const plugin = await getPlugin(learnCard, client);
356
+
357
+ await expect(
358
+ plugin.methods?.send(learnCard, { ...HOLDER_SEND_INPUT, recipient })
359
+ ).rejects.toThrow('inbox');
360
+
361
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
362
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
363
+ }
364
+ );
365
+
366
+ it('rejects a signedCredential send client-side, before any server call', async () => {
367
+ const client = getMockClient();
368
+ const learnCard = getMockIssuingLearnCard();
369
+ const plugin = await getPlugin(learnCard, client);
370
+
371
+ const signedCredential = {
372
+ ...getUnsignedBoostTemplate({ id: 'urn:uuid:caller-signed' }),
373
+ issuer: PROFILE.did,
374
+ credentialSubject: { id: TARGET_PROFILE.did },
375
+ refreshService: REFRESH_SERVICE,
376
+ proof: { type: 'DataIntegrityProof' },
377
+ };
378
+
379
+ await expect(
380
+ plugin.methods?.send(learnCard, {
381
+ type: 'boost',
382
+ recipient: 'userb',
383
+ signedCredential: signedCredential as any,
384
+ refresh: true,
385
+ encryption: 'holder',
386
+ })
387
+ ).rejects.toThrow('signedCredential');
388
+
389
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
390
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
391
+ });
392
+
393
+ it('rejects holder-mode encryption when local signing is unavailable', async () => {
394
+ const client = getMockClient();
395
+ const learnCard = getMockIssuingLearnCard();
396
+ delete learnCard.invoke.issueCredential;
397
+ const plugin = await getPlugin(learnCard, client);
398
+
399
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
400
+ 'local signing'
401
+ );
402
+
403
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
404
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
405
+ });
406
+
407
+ it('rejects holder-mode encryption when the Encryption plugin is unavailable', async () => {
408
+ const client = getMockClient();
409
+ const learnCard = getMockIssuingLearnCard();
410
+ delete learnCard.invoke.createDagJweExact;
411
+ const plugin = await getPlugin(learnCard, client);
412
+
413
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
414
+ 'Encryption plugin'
415
+ );
416
+
417
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
418
+ expect(client.boost.prepareRefreshableSend.mutate).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it('throws against an old server missing recipientKeyIds, and never falls back to network-mode plaintext delivery', async () => {
422
+ const client = getMockClient();
423
+ const { recipientKeyIds: _omitted, ...oldServerPrepared } = defaultPreparedHolder;
424
+ client.boost.prepareRefreshableSend.mutate.mockResolvedValue(oldServerPrepared);
425
+ const learnCard = getMockIssuingLearnCard();
426
+ const plugin = await getPlugin(learnCard, client);
427
+
428
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
429
+ 'does not support holder-mode encryption'
430
+ );
431
+
432
+ expect(learnCard.invoke.issueCredential).not.toHaveBeenCalled();
433
+ expect(client.boost.send.mutate).not.toHaveBeenCalled();
434
+ expect(
435
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
436
+ ).not.toHaveBeenCalled();
437
+ });
438
+
439
+ it('throws when the server did not bind status entries for the holder-mode send', async () => {
440
+ const client = getMockClient();
441
+ client.boost.prepareRefreshableSend.mutate.mockResolvedValue({
442
+ ...defaultPreparedHolder,
443
+ statusEntries: undefined,
444
+ });
445
+ const learnCard = getMockIssuingLearnCard();
446
+ const plugin = await getPlugin(learnCard, client);
447
+
448
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
449
+ 'status binding'
450
+ );
451
+
452
+ expect(
453
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
454
+ ).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it('preserves external template status without requiring allocated entries', async () => {
458
+ const client = getMockClient();
459
+ const credentialStatus = {
460
+ ...STATUS_ENTRY,
461
+ id: 'https://external.example/status#7',
462
+ statusListCredential: 'https://external.example/status',
463
+ statusListIndex: '7',
464
+ };
465
+ client.boost.prepareRefreshableSend.mutate.mockResolvedValue({
466
+ ...defaultPreparedHolder,
467
+ statusEntries: undefined,
468
+ });
469
+ client.boost.getBoost.query.mockResolvedValue({
470
+ boost: getUnsignedBoostTemplate({ credentialStatus }),
471
+ });
472
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mockResolvedValue({
473
+ uri: 'did:web:network.example:credentials:holder-1',
474
+ activityId: 'activity-holder-1',
475
+ statusManagement: 'external',
476
+ });
477
+ const learnCard = getMockIssuingLearnCard();
478
+ const plugin = await getPlugin(learnCard, client);
479
+ const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
480
+ expect(result?.statusManagement).toBe('external');
481
+ expect(result?.refresh?.credentialStatus).toEqual(credentialStatus);
482
+ expect(client.boost.allocateCredentialStatus.mutate).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it('aborts before sending when the produced JWE recipient set does not match recipientKeyIds', async () => {
486
+ const client = getMockClient();
487
+ const learnCard = getMockIssuingLearnCard({
488
+ createDagJweExact: vi.fn().mockResolvedValue(jweForDids([HOLDER_DID])), // missing the manager
489
+ });
490
+ const plugin = await getPlugin(learnCard, client);
491
+
492
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
493
+ 'ENCRYPTION_RECIPIENTS_MISMATCH'
494
+ );
495
+
496
+ expect(
497
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
498
+ ).not.toHaveBeenCalled();
499
+ });
500
+
501
+ it('retries once after RECIPIENTS_CHANGED, re-encrypting to the freshly fetched recipient set', async () => {
502
+ const client = getMockClient();
503
+ const conflict = Object.assign(new Error('RECIPIENTS_CHANGED'), {
504
+ data: { code: 'CONFLICT' },
505
+ });
506
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
507
+ .mockRejectedValueOnce(conflict)
508
+ .mockResolvedValueOnce({
509
+ uri: 'did:web:network.example:credentials:holder-2',
510
+ activityId: 'activity-holder-2',
511
+ });
512
+ const NEW_MANAGER_DID = 'did:key:zNewManager';
513
+ const freshKeyIds = [keyIdForDid(HOLDER_DID), keyIdForDid(NEW_MANAGER_DID)];
514
+ client.credentialRefresh.getEncryptionRecipients.query.mockResolvedValue({
515
+ recipientKeyIds: freshKeyIds,
516
+ });
517
+ const learnCard = getMockIssuingLearnCard();
518
+ const plugin = await getPlugin(learnCard, client);
519
+
520
+ const result = await plugin.methods?.send(learnCard, HOLDER_SEND_INPUT);
521
+
522
+ expect(
523
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
524
+ ).toHaveBeenCalledTimes(2);
525
+ expect(
526
+ client.credentialRefresh.getEncryptionRecipients.query
527
+ ).toHaveBeenCalledExactlyOnceWith({ refreshId: defaultPreparedHolder.refreshId });
528
+
529
+ const retryHandoff = client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mock
530
+ .calls[1]?.[0] as any;
531
+ expect(
532
+ retryHandoff.handoff.jwe.recipients.map((recipient: any) => recipient.header.kid).sort()
533
+ ).toEqual([...freshKeyIds].sort());
534
+ expect((result as any).credentialUri).toBe('did:web:network.example:credentials:holder-2');
535
+ });
536
+
537
+ it('fails after a second RECIPIENTS_CHANGED without a further retry', async () => {
538
+ const client = getMockClient();
539
+ const conflict = Object.assign(new Error('RECIPIENTS_CHANGED'), {
540
+ data: { code: 'CONFLICT' },
541
+ });
542
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mockRejectedValue(conflict);
543
+ const learnCard = getMockIssuingLearnCard();
544
+ const plugin = await getPlugin(learnCard, client);
545
+
546
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
547
+ 'RECIPIENTS_CHANGED'
548
+ );
549
+
550
+ expect(
551
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
552
+ ).toHaveBeenCalledTimes(2);
553
+ expect(client.credentialRefresh.getEncryptionRecipients.query).toHaveBeenCalledTimes(1);
554
+ });
555
+
556
+ it('propagates a non-conflict failure from sendHolderEncryptedRefreshable without retrying', async () => {
557
+ const client = getMockClient();
558
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate.mockRejectedValue(
559
+ new Error('Credential refresh is not available')
560
+ );
561
+ const learnCard = getMockIssuingLearnCard();
562
+ const plugin = await getPlugin(learnCard, client);
563
+
564
+ await expect(plugin.methods?.send(learnCard, HOLDER_SEND_INPUT)).rejects.toThrow(
565
+ 'Credential refresh is not available'
566
+ );
567
+
568
+ expect(
569
+ client.credentialRefresh.sendHolderEncryptedRefreshable.mutate
570
+ ).toHaveBeenCalledTimes(1);
571
+ expect(client.credentialRefresh.getEncryptionRecipients.query).not.toHaveBeenCalled();
572
+ });
573
+ });
574
+
575
+ describe('publishHolderEncryptedRefreshable', () => {
576
+ const RECEIPT = {
577
+ refreshId: defaultPreparedHolder.refreshId,
578
+ refreshService: defaultPreparedHolder.refreshService,
579
+ credentialId: defaultPreparedHolder.credentialId,
580
+ issuerDid: PROFILE.did,
581
+ holderDid: defaultPreparedHolder.holderDid,
582
+ credentialStatus: STATUS_ENTRY,
583
+ };
584
+
585
+ it.each([false, true])('publishes the receipt status shape (array=%s)', async arrayShape => {
586
+ const credentialStatus = arrayShape ? [STATUS_ENTRY] : STATUS_ENTRY;
587
+ const client = getMockClient();
588
+ const learnCard = getMockIssuingLearnCard();
589
+ const plugin = await getPlugin(learnCard, client);
590
+
591
+ await plugin.methods?.publishHolderEncryptedRefreshable(
592
+ learnCard,
593
+ { ...RECEIPT, credentialStatus },
594
+ UnsignedVCValidator.parse(
595
+ getUnsignedBoostTemplate({
596
+ credentialStatus: arrayShape ? STATUS_ENTRY : [STATUS_ENTRY],
597
+ })
598
+ ),
599
+ { notify: false }
600
+ );
601
+
602
+ expect(
603
+ client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
604
+ ).toHaveBeenCalledWith(
605
+ expect.objectContaining({ handoff: expect.objectContaining({ credentialStatus }) })
606
+ );
607
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
608
+ expect.objectContaining({ credentialStatus })
609
+ );
610
+ });
611
+
612
+ it.each([false, true])(
613
+ 'publishes the receipt service shape rather than the template shape (array=%s)',
614
+ async arrayShape => {
615
+ const other = {
616
+ id: 'https://issuer.example/refresh/other',
617
+ type: '1EdTechCredentialRefresh',
618
+ };
619
+ const refreshService = arrayShape ? [REFRESH_SERVICE, other] : REFRESH_SERVICE;
620
+ const client = getMockClient();
621
+ const learnCard = getMockIssuingLearnCard();
622
+ const plugin = await getPlugin(learnCard, client);
623
+ await plugin.methods?.publishHolderEncryptedRefreshable(
624
+ learnCard,
625
+ { ...RECEIPT, refreshService },
626
+ UnsignedVCValidator.parse(getUnsignedBoostTemplate({ refreshService: other })),
627
+ { notify: false }
628
+ );
629
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
630
+ expect.objectContaining({ refreshService })
631
+ );
632
+ expect(
633
+ client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
634
+ ).toHaveBeenCalledWith(
635
+ expect.objectContaining({ handoff: expect.objectContaining({ refreshService }) })
636
+ );
637
+ }
638
+ );
639
+
640
+ it('publishes a new version, encrypted to the current recipients, with explicit notify', async () => {
641
+ const client = getMockClient();
642
+ const learnCard = getMockIssuingLearnCard();
643
+ const plugin = await getPlugin(learnCard, client);
644
+
645
+ const result = await plugin.methods?.publishHolderEncryptedRefreshable(
646
+ learnCard,
647
+ RECEIPT,
648
+ getUnsignedBoostTemplate({ id: RECEIPT.credentialId }) as any,
649
+ { notify: true }
650
+ );
651
+
652
+ expect(
653
+ client.credentialRefresh.getEncryptionRecipients.query
654
+ ).toHaveBeenCalledExactlyOnceWith({ refreshId: RECEIPT.refreshId });
655
+
656
+ const publishCall = client.credentialRefresh.publishHolderEncryptedRefreshable.mutate.mock
657
+ .calls[0]?.[0] as any;
658
+ expect(publishCall.notify).toBe(true);
659
+ expect(publishCall.handoff.credentialId).toBe(RECEIPT.credentialId);
660
+ expect(publishCall.handoff.holderDid).toBe(RECEIPT.holderDid);
661
+ expect(
662
+ publishCall.handoff.jwe.recipients.map((recipient: any) => recipient.header.kid).sort()
663
+ ).toEqual([...RECIPIENT_KEY_IDS].sort());
664
+ expect(result).toEqual({
665
+ refreshId: defaultPreparedHolder.refreshId,
666
+ version: 2,
667
+ publishedAt: '2024-01-01T00:00:00.000Z',
668
+ notification: 'queued',
669
+ });
670
+ });
671
+
672
+ it('never includes templateData-derived claim values in a client call payload', async () => {
673
+ const SENTINEL = 'SENTINEL_PUBLISH_DO_NOT_LEAK_2b61';
674
+ const client = getMockClient();
675
+ const learnCard = getMockIssuingLearnCard();
676
+ const plugin = await getPlugin(learnCard, client);
677
+
678
+ await plugin.methods?.publishHolderEncryptedRefreshable(
679
+ learnCard,
680
+ RECEIPT,
681
+ getUnsignedBoostTemplate({
682
+ id: RECEIPT.credentialId,
683
+ credentialSubject: { id: 'did:example:template-subject', secret: '{{secret}}' },
684
+ }) as any,
685
+ { notify: false, templateData: { secret: SENTINEL } }
686
+ );
687
+
688
+ expect(learnCard.invoke.issueCredential).toHaveBeenCalledWith(
689
+ expect.objectContaining({
690
+ credentialSubject: expect.objectContaining({ secret: SENTINEL }),
691
+ })
692
+ );
693
+ expect(allClientCallArgsJson(client)).not.toContain(SENTINEL);
694
+ });
695
+
696
+ it('rejects when local signing is unavailable', async () => {
697
+ const client = getMockClient();
698
+ const learnCard = getMockIssuingLearnCard();
699
+ delete learnCard.invoke.issueCredential;
700
+ const plugin = await getPlugin(learnCard, client);
701
+
702
+ await expect(
703
+ plugin.methods?.publishHolderEncryptedRefreshable(
704
+ learnCard,
705
+ RECEIPT,
706
+ getUnsignedBoostTemplate({ id: RECEIPT.credentialId }) as any,
707
+ { notify: true }
708
+ )
709
+ ).rejects.toThrow('local signing');
710
+
711
+ expect(
712
+ client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
713
+ ).not.toHaveBeenCalled();
714
+ });
715
+
716
+ it('rejects when the receipt carries no credentialStatus binding', async () => {
717
+ const client = getMockClient();
718
+ const learnCard = getMockIssuingLearnCard();
719
+ const plugin = await getPlugin(learnCard, client);
720
+ const { credentialStatus: _omitted, ...receiptWithoutStatus } = RECEIPT;
721
+
722
+ await expect(
723
+ plugin.methods?.publishHolderEncryptedRefreshable(
724
+ learnCard,
725
+ receiptWithoutStatus as any,
726
+ getUnsignedBoostTemplate({ id: RECEIPT.credentialId }) as any,
727
+ { notify: true }
728
+ )
729
+ ).rejects.toThrow('credentialStatus');
730
+
731
+ expect(
732
+ client.credentialRefresh.publishHolderEncryptedRefreshable.mutate
733
+ ).not.toHaveBeenCalled();
734
+ });
735
+ });