@learncard/network-plugin 2.14.1 → 3.0.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/README.md +12 -0
- package/dist/inbox-batch.d.ts +5 -0
- package/dist/inbox-batch.d.ts.map +1 -0
- package/dist/inbox-batch.test.d.ts +2 -0
- package/dist/inbox-batch.test.d.ts.map +1 -0
- package/dist/lcn-plugin.cjs.development.cjs +1091 -203
- package/dist/lcn-plugin.cjs.development.cjs.map +4 -4
- package/dist/lcn-plugin.cjs.production.min.cjs +36 -36
- package/dist/lcn-plugin.cjs.production.min.cjs.map +4 -4
- package/dist/lcn-plugin.esm.js +1093 -203
- package/dist/lcn-plugin.esm.js.map +4 -4
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/test/act-as.test.d.ts +2 -0
- package/dist/test/act-as.test.d.ts.map +1 -0
- package/dist/test/refresh-send.test-d.d.ts +2 -0
- package/dist/test/refresh-send.test-d.d.ts.map +1 -0
- package/dist/test/refresh-send.test.d.ts +2 -0
- package/dist/test/refresh-send.test.d.ts.map +1 -0
- package/dist/types.d.ts +129 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/inbox-batch.test.ts +105 -0
- package/src/inbox-batch.ts +66 -0
- package/src/plugin.ts +402 -55
- package/src/test/act-as.test.ts +323 -0
- package/src/test/index.test.ts +161 -10
- package/src/test/refresh-send.test-d.ts +152 -0
- package/src/test/refresh-send.test.ts +646 -0
- package/src/types.ts +178 -23
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- plugin integration mocks intentionally expose dynamic method bags */
|
|
2
|
+
import { getClient as getBrainClient, getApiTokenClient } from '@learncard/network-brain-client';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ACT_AS_HEADER, getLearnCardNetworkPlugin } from '../plugin';
|
|
6
|
+
|
|
7
|
+
vi.mock('@learncard/network-brain-client', () => ({
|
|
8
|
+
getClient: vi.fn(),
|
|
9
|
+
getApiTokenClient: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const PROFILE = {
|
|
13
|
+
profileId: 'usera',
|
|
14
|
+
displayName: 'User A',
|
|
15
|
+
shortBio: '',
|
|
16
|
+
bio: '',
|
|
17
|
+
did: 'did:web:network.example:users:usera',
|
|
18
|
+
isPrivate: false,
|
|
19
|
+
profileVisibility: 'public',
|
|
20
|
+
showEmail: false,
|
|
21
|
+
allowConnectionRequests: 'anyone',
|
|
22
|
+
isServiceProfile: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const URL = 'https://network.example/trpc';
|
|
26
|
+
|
|
27
|
+
const getMockClient = () => ({
|
|
28
|
+
profile: { getProfile: { query: vi.fn().mockResolvedValue(PROFILE) } },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const getMockLearnCard = () => {
|
|
32
|
+
const learnCard: any = {
|
|
33
|
+
plugins: [],
|
|
34
|
+
id: { did: () => PROFILE.did },
|
|
35
|
+
invoke: { getDidAuthVp: vi.fn().mockResolvedValue('mock-jwt') },
|
|
36
|
+
debug: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
learnCard.addPlugin = vi.fn(async (plugin: any) => ({
|
|
39
|
+
...learnCard,
|
|
40
|
+
plugins: [...learnCard.plugins, plugin],
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
return learnCard;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('actAs', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('extraHeaders wiring', () => {
|
|
52
|
+
it('omits the act-as header for the API-token client when actAs is not set', async () => {
|
|
53
|
+
vi.mocked(getApiTokenClient).mockResolvedValue(getMockClient() as never);
|
|
54
|
+
const learnCard = getMockLearnCard();
|
|
55
|
+
|
|
56
|
+
await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
57
|
+
|
|
58
|
+
expect(getApiTokenClient).toHaveBeenCalledWith(
|
|
59
|
+
URL,
|
|
60
|
+
'api-token-value',
|
|
61
|
+
undefined,
|
|
62
|
+
undefined
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('adds the act-as header for the API-token client when actAs is set', async () => {
|
|
67
|
+
vi.mocked(getApiTokenClient).mockResolvedValue(getMockClient() as never);
|
|
68
|
+
const learnCard = getMockLearnCard();
|
|
69
|
+
|
|
70
|
+
await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value', {
|
|
71
|
+
actAs: 'managed-child',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(getApiTokenClient).toHaveBeenCalledWith(URL, 'api-token-value', undefined, {
|
|
75
|
+
[ACT_AS_HEADER]: 'managed-child',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('merges the act-as header alongside other extraHeaders', async () => {
|
|
80
|
+
vi.mocked(getApiTokenClient).mockResolvedValue(getMockClient() as never);
|
|
81
|
+
const learnCard = getMockLearnCard();
|
|
82
|
+
|
|
83
|
+
await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value', {
|
|
84
|
+
actAs: 'managed-child',
|
|
85
|
+
extraHeaders: { 'X-Custom': '1' },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(getApiTokenClient).toHaveBeenCalledWith(URL, 'api-token-value', undefined, {
|
|
89
|
+
'X-Custom': '1',
|
|
90
|
+
[ACT_AS_HEADER]: 'managed-child',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('omits the act-as header for the seed/DID-auth client when actAs is not set', async () => {
|
|
95
|
+
vi.mocked(getBrainClient).mockResolvedValue(getMockClient() as never);
|
|
96
|
+
const learnCard = getMockLearnCard();
|
|
97
|
+
|
|
98
|
+
await getLearnCardNetworkPlugin(learnCard, URL);
|
|
99
|
+
|
|
100
|
+
expect(getBrainClient).toHaveBeenCalledWith(
|
|
101
|
+
URL,
|
|
102
|
+
expect.any(Function),
|
|
103
|
+
undefined,
|
|
104
|
+
undefined
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('adds the act-as header for the seed/DID-auth client when actAs is set', async () => {
|
|
109
|
+
vi.mocked(getBrainClient).mockResolvedValue(getMockClient() as never);
|
|
110
|
+
const learnCard = getMockLearnCard();
|
|
111
|
+
|
|
112
|
+
await getLearnCardNetworkPlugin(learnCard, URL, { actAs: 'managed-child' });
|
|
113
|
+
|
|
114
|
+
expect(getBrainClient).toHaveBeenCalledWith(URL, expect.any(Function), undefined, {
|
|
115
|
+
[ACT_AS_HEADER]: 'managed-child',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('.actAs(profileId) method', () => {
|
|
121
|
+
it('api-token mode: returns a new LearnCard carrying the header, without mutating the original', async () => {
|
|
122
|
+
const originalClient = getMockClient();
|
|
123
|
+
const actingClient = getMockClient();
|
|
124
|
+
vi.mocked(getApiTokenClient)
|
|
125
|
+
.mockResolvedValueOnce(originalClient as never)
|
|
126
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
127
|
+
|
|
128
|
+
const learnCard = getMockLearnCard();
|
|
129
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
130
|
+
|
|
131
|
+
expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
|
|
132
|
+
|
|
133
|
+
const newLearnCard = await plugin.methods.actAs(learnCard, 'managed-child');
|
|
134
|
+
|
|
135
|
+
expect(getApiTokenClient).toHaveBeenNthCalledWith(
|
|
136
|
+
2,
|
|
137
|
+
URL,
|
|
138
|
+
'api-token-value',
|
|
139
|
+
undefined,
|
|
140
|
+
{
|
|
141
|
+
[ACT_AS_HEADER]: 'managed-child',
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
|
|
145
|
+
|
|
146
|
+
const [actingPlugin] = vi.mocked(learnCard.addPlugin).mock.calls[0] as [any];
|
|
147
|
+
expect(actingPlugin.name).toBe('LearnCard Network');
|
|
148
|
+
expect(actingPlugin.methods.getLCNClient(learnCard)).toBe(actingClient);
|
|
149
|
+
|
|
150
|
+
// The original instance/plugin must be untouched by the .actAs call.
|
|
151
|
+
expect(newLearnCard).not.toBe(learnCard);
|
|
152
|
+
expect(learnCard.plugins).toEqual([]);
|
|
153
|
+
expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('seed/DID-auth mode: returns a new LearnCard carrying the header, without mutating the original', async () => {
|
|
157
|
+
const originalClient = getMockClient();
|
|
158
|
+
const actingClient = getMockClient();
|
|
159
|
+
vi.mocked(getBrainClient)
|
|
160
|
+
.mockResolvedValueOnce(originalClient as never)
|
|
161
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
162
|
+
|
|
163
|
+
const learnCard = getMockLearnCard();
|
|
164
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL);
|
|
165
|
+
|
|
166
|
+
expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
|
|
167
|
+
|
|
168
|
+
await plugin.methods.actAs(learnCard, 'managed-child');
|
|
169
|
+
|
|
170
|
+
expect(getBrainClient).toHaveBeenNthCalledWith(
|
|
171
|
+
2,
|
|
172
|
+
URL,
|
|
173
|
+
expect.any(Function),
|
|
174
|
+
undefined,
|
|
175
|
+
{
|
|
176
|
+
[ACT_AS_HEADER]: 'managed-child',
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const [actingPlugin] = vi.mocked(learnCard.addPlugin).mock.calls[0] as [any];
|
|
181
|
+
expect(actingPlugin.methods.getLCNClient(learnCard)).toBe(actingClient);
|
|
182
|
+
|
|
183
|
+
// Original plugin's client reference is unaffected.
|
|
184
|
+
expect(plugin.methods.getLCNClient(learnCard)).toBe(originalClient);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('preserves pre-existing extraHeaders and federation options when composing the acting plugin', async () => {
|
|
188
|
+
vi.mocked(getApiTokenClient)
|
|
189
|
+
.mockResolvedValueOnce(getMockClient() as never)
|
|
190
|
+
.mockResolvedValueOnce(getMockClient() as never);
|
|
191
|
+
|
|
192
|
+
const learnCard = getMockLearnCard();
|
|
193
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value', {
|
|
194
|
+
extraHeaders: { 'X-Custom': '1' },
|
|
195
|
+
trustedFederationHosts: ['trusted.example'],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await plugin.methods.actAs(learnCard, 'managed-child');
|
|
199
|
+
|
|
200
|
+
expect(getApiTokenClient).toHaveBeenNthCalledWith(
|
|
201
|
+
2,
|
|
202
|
+
URL,
|
|
203
|
+
'api-token-value',
|
|
204
|
+
undefined,
|
|
205
|
+
{
|
|
206
|
+
'X-Custom': '1',
|
|
207
|
+
[ACT_AS_HEADER]: 'managed-child',
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it.each(['FORBIDDEN', 'BAD_REQUEST'])(
|
|
213
|
+
'rethrows a %s act-as denial, wrapping the original error as `cause`',
|
|
214
|
+
async code => {
|
|
215
|
+
const denial = Object.assign(new Error(`act-as ${code}`), { data: { code } });
|
|
216
|
+
const actingClient = {
|
|
217
|
+
profile: { getProfile: { query: vi.fn().mockRejectedValue(denial) } },
|
|
218
|
+
};
|
|
219
|
+
vi.mocked(getApiTokenClient)
|
|
220
|
+
.mockResolvedValueOnce(getMockClient() as never)
|
|
221
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
222
|
+
|
|
223
|
+
const learnCard = getMockLearnCard();
|
|
224
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
225
|
+
|
|
226
|
+
const rejection: any = await plugin.methods
|
|
227
|
+
.actAs(learnCard, 'not-mine')
|
|
228
|
+
.catch(e => e);
|
|
229
|
+
expect(rejection).toBeInstanceOf(Error);
|
|
230
|
+
expect(rejection.cause).toBe(denial);
|
|
231
|
+
expect(learnCard.addPlugin).not.toHaveBeenCalled();
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
it('rethrows a NOT_FOUND act-as denial when the message matches resolveActAs', async () => {
|
|
236
|
+
const denial = Object.assign(new Error('Act-as target profile not found'), {
|
|
237
|
+
data: { code: 'NOT_FOUND' },
|
|
238
|
+
});
|
|
239
|
+
const actingClient = {
|
|
240
|
+
profile: { getProfile: { query: vi.fn().mockRejectedValue(denial) } },
|
|
241
|
+
};
|
|
242
|
+
vi.mocked(getApiTokenClient)
|
|
243
|
+
.mockResolvedValueOnce(getMockClient() as never)
|
|
244
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
245
|
+
|
|
246
|
+
const learnCard = getMockLearnCard();
|
|
247
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
248
|
+
|
|
249
|
+
const rejection: any = await plugin.methods.actAs(learnCard, 'not-mine').catch(e => e);
|
|
250
|
+
expect(rejection).toBeInstanceOf(Error);
|
|
251
|
+
expect(rejection.cause).toBe(denial);
|
|
252
|
+
expect(learnCard.addPlugin).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('does not treat a NOT_FOUND with an unrelated message as an act-as denial', async () => {
|
|
256
|
+
const unrelated = Object.assign(
|
|
257
|
+
new Error('Profile not found. Please make a profile!'),
|
|
258
|
+
{ data: { code: 'NOT_FOUND' } }
|
|
259
|
+
);
|
|
260
|
+
const actingClient = {
|
|
261
|
+
profile: { getProfile: { query: vi.fn().mockRejectedValue(unrelated) } },
|
|
262
|
+
};
|
|
263
|
+
vi.mocked(getApiTokenClient)
|
|
264
|
+
.mockResolvedValueOnce(getMockClient() as never)
|
|
265
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
266
|
+
|
|
267
|
+
const learnCard = getMockLearnCard();
|
|
268
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
269
|
+
|
|
270
|
+
await expect(plugin.methods.actAs(learnCard, 'managed-child')).resolves.toBeDefined();
|
|
271
|
+
expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(learnCard.debug).toHaveBeenCalledWith(
|
|
273
|
+
'LCN actAs: getProfile failed (non-fatal)',
|
|
274
|
+
unrelated
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('does not treat an INTERNAL_SERVER_ERROR as an act-as denial', async () => {
|
|
279
|
+
const serverError = Object.assign(new Error('boom'), {
|
|
280
|
+
data: { code: 'INTERNAL_SERVER_ERROR' },
|
|
281
|
+
});
|
|
282
|
+
const actingClient = {
|
|
283
|
+
profile: { getProfile: { query: vi.fn().mockRejectedValue(serverError) } },
|
|
284
|
+
};
|
|
285
|
+
vi.mocked(getApiTokenClient)
|
|
286
|
+
.mockResolvedValueOnce(getMockClient() as never)
|
|
287
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
288
|
+
|
|
289
|
+
const learnCard = getMockLearnCard();
|
|
290
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
291
|
+
|
|
292
|
+
await expect(plugin.methods.actAs(learnCard, 'managed-child')).resolves.toBeDefined();
|
|
293
|
+
expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(learnCard.debug).toHaveBeenCalledWith(
|
|
295
|
+
'LCN actAs: getProfile failed (non-fatal)',
|
|
296
|
+
serverError
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('still returns an instance when the profile fetch fails for an unrelated reason', async () => {
|
|
301
|
+
const actingClient = {
|
|
302
|
+
profile: {
|
|
303
|
+
getProfile: {
|
|
304
|
+
query: vi.fn().mockRejectedValue(
|
|
305
|
+
Object.assign(new Error('no scope'), {
|
|
306
|
+
data: { code: 'UNAUTHORIZED' },
|
|
307
|
+
})
|
|
308
|
+
),
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
vi.mocked(getApiTokenClient)
|
|
313
|
+
.mockResolvedValueOnce(getMockClient() as never)
|
|
314
|
+
.mockResolvedValueOnce(actingClient as never);
|
|
315
|
+
|
|
316
|
+
const learnCard = getMockLearnCard();
|
|
317
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, URL, 'api-token-value');
|
|
318
|
+
|
|
319
|
+
await expect(plugin.methods.actAs(learnCard, 'managed-child')).resolves.toBeDefined();
|
|
320
|
+
expect(learnCard.addPlugin).toHaveBeenCalledTimes(1);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/test/index.test.ts
CHANGED
|
@@ -17,17 +17,13 @@ vi.mock('@learncard/core', () => ({ generateLearnCard: vi.fn() }));
|
|
|
17
17
|
vi.mock('@learncard/didkit-plugin', () => ({ getDidKitPlugin: vi.fn() }));
|
|
18
18
|
vi.mock('@learncard/didkey-plugin', () => ({ getDidKeyPlugin: vi.fn() }));
|
|
19
19
|
vi.mock('@learncard/vc-plugin', () => ({ getVCPlugin: vi.fn() }));
|
|
20
|
-
vi.mock('@learncard/helpers',
|
|
21
|
-
|
|
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
|
-
},
|
|
20
|
+
vi.mock('@learncard/helpers', async importOriginal => ({
|
|
21
|
+
...(await importOriginal<typeof import('@learncard/helpers')>()),
|
|
27
22
|
getCredentialStatusArray: () => [],
|
|
28
23
|
resolveStorageReadResult: (value: any) => value,
|
|
29
24
|
}));
|
|
30
|
-
vi.mock('@learncard/types',
|
|
25
|
+
vi.mock('@learncard/types', async importOriginal => ({
|
|
26
|
+
...(await importOriginal<object>()),
|
|
31
27
|
VCValidator: {
|
|
32
28
|
parse: (value: any) => {
|
|
33
29
|
if (!value?.type) throw new Error('Invalid credential');
|
|
@@ -184,6 +180,124 @@ const getLearnCard = async (seed = 'a'.repeat(64)) => {
|
|
|
184
180
|
};
|
|
185
181
|
};
|
|
186
182
|
|
|
183
|
+
describe('inbox batch method', () => {
|
|
184
|
+
beforeEach(() => vi.clearAllMocks());
|
|
185
|
+
|
|
186
|
+
it('forwards configuration, keys and items to issueBatch and returns an acceptance receipt', async () => {
|
|
187
|
+
const response = {
|
|
188
|
+
batchId: 'batch-1',
|
|
189
|
+
status: 'QUEUED',
|
|
190
|
+
createdAt: '2026-09-17T00:00:00.000Z',
|
|
191
|
+
};
|
|
192
|
+
const mutate = vi.fn().mockResolvedValue(response);
|
|
193
|
+
const client = { ...getMockClient(), inbox: { issueBatch: { mutate } } };
|
|
194
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
195
|
+
const learnCard = getMockLearnCard();
|
|
196
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
197
|
+
const batch = {
|
|
198
|
+
configuration: { delivery: { suppress: true } },
|
|
199
|
+
items: [
|
|
200
|
+
{
|
|
201
|
+
recipient: { type: 'email' as const, value: 'student@example.test' },
|
|
202
|
+
templateUri: 'test-template',
|
|
203
|
+
idempotencyKey: 'test-key',
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
await expect(plugin.methods?.sendCredentialsViaInbox(learnCard, batch)).resolves.toEqual(
|
|
208
|
+
response
|
|
209
|
+
);
|
|
210
|
+
expect(mutate).toHaveBeenCalledExactlyOnceWith(batch);
|
|
211
|
+
await expect(
|
|
212
|
+
plugin.methods?.sendCredentialBatchViaInbox(learnCard, batch)
|
|
213
|
+
).resolves.toEqual(response);
|
|
214
|
+
expect(mutate).toHaveBeenLastCalledWith(batch);
|
|
215
|
+
expect(client.profile.getProfile.query).toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('submits once, persists the receipt, and waits for completion through the SDK', async () => {
|
|
219
|
+
const receipt = { batchId: 'batch-1', status: 'QUEUED', createdAt: '2026-09-17' };
|
|
220
|
+
const response = { ...receipt, done: true, status: 'COMPLETED', items: [] };
|
|
221
|
+
const mutate = vi.fn().mockResolvedValue(receipt);
|
|
222
|
+
let saved = false;
|
|
223
|
+
const query = vi.fn(async () => {
|
|
224
|
+
expect(saved).toBe(true);
|
|
225
|
+
return response;
|
|
226
|
+
});
|
|
227
|
+
const client = {
|
|
228
|
+
...getMockClient(),
|
|
229
|
+
inbox: { issueBatch: { mutate }, getBatch: { query } },
|
|
230
|
+
};
|
|
231
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
232
|
+
const learnCard = getMockLearnCard();
|
|
233
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
234
|
+
const batch = {
|
|
235
|
+
items: [
|
|
236
|
+
{
|
|
237
|
+
recipient: { type: 'email' as const, value: 'a@example.test' },
|
|
238
|
+
templateUri: 'template',
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
await expect(
|
|
243
|
+
plugin.methods?.sendCredentialsViaInboxAndWait(learnCard, batch, {
|
|
244
|
+
onSubmitted: async received => {
|
|
245
|
+
expect(received).toEqual(receipt);
|
|
246
|
+
await Promise.resolve();
|
|
247
|
+
saved = true;
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
).resolves.toEqual(response);
|
|
251
|
+
expect(mutate).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(query).toHaveBeenCalledWith(
|
|
253
|
+
{ batchId: receipt.batchId },
|
|
254
|
+
{ signal: expect.any(AbortSignal) }
|
|
255
|
+
);
|
|
256
|
+
await expect(
|
|
257
|
+
plugin.methods?.waitForInboxCredentialBatch(learnCard, receipt.batchId)
|
|
258
|
+
).resolves.toEqual(response);
|
|
259
|
+
expect(mutate).toHaveBeenCalledTimes(1);
|
|
260
|
+
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
controller.abort();
|
|
263
|
+
await expect(
|
|
264
|
+
plugin.methods?.sendCredentialsViaInboxAndWait(learnCard, batch, {
|
|
265
|
+
signal: controller.signal,
|
|
266
|
+
})
|
|
267
|
+
).rejects.toMatchObject({ name: 'AbortError' });
|
|
268
|
+
expect(mutate).toHaveBeenCalledTimes(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('polls the batch endpoint and preserves progress and item results', async () => {
|
|
272
|
+
const response = { batchId: 'batch-1', status: 'PROCESSING', items: [] };
|
|
273
|
+
const query = vi.fn().mockResolvedValue(response);
|
|
274
|
+
const client = { ...getMockClient(), inbox: { getBatch: { query } } };
|
|
275
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
276
|
+
const learnCard = getMockLearnCard();
|
|
277
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
278
|
+
await expect(
|
|
279
|
+
plugin.methods?.getInboxCredentialBatch(learnCard, 'batch-1')
|
|
280
|
+
).resolves.toEqual(response);
|
|
281
|
+
expect(query).toHaveBeenCalledExactlyOnceWith({ batchId: 'batch-1' });
|
|
282
|
+
query.mockRejectedValueOnce(new Error('Not found'));
|
|
283
|
+
await expect(plugin.methods?.getInboxCredentialBatch(learnCard, 'missing')).rejects.toThrow(
|
|
284
|
+
'Not found'
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('does not submit a batch when the issuer profile is missing', async () => {
|
|
289
|
+
const mutate = vi.fn();
|
|
290
|
+
const client = { ...getMockClient(null), inbox: { issueBatch: { mutate } } };
|
|
291
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
292
|
+
const learnCard = getMockLearnCard();
|
|
293
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
294
|
+
await expect(
|
|
295
|
+
plugin.methods?.sendCredentialsViaInbox(learnCard, { items: [] })
|
|
296
|
+
).rejects.toThrow();
|
|
297
|
+
expect(mutate).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
187
301
|
describe('connection prompt methods', () => {
|
|
188
302
|
beforeEach(() => {
|
|
189
303
|
vi.clearAllMocks();
|
|
@@ -434,12 +548,25 @@ describe('credential refresh methods', () => {
|
|
|
434
548
|
const { learnCard, issuedCredentials } = getMockIssuingLearnCard();
|
|
435
549
|
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
436
550
|
|
|
437
|
-
const
|
|
551
|
+
const result = await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
|
|
438
552
|
enableRefresh: true,
|
|
439
553
|
skipNotification: true,
|
|
440
554
|
});
|
|
441
555
|
|
|
442
|
-
|
|
556
|
+
// Refresh-enabled callers receive the issuance receipt alongside the
|
|
557
|
+
// credential URI (LC-2198 decision 4).
|
|
558
|
+
expect(result).toEqual({
|
|
559
|
+
credentialUri: 'managed-credential-uri',
|
|
560
|
+
refresh: {
|
|
561
|
+
refreshId: REFRESH_ID,
|
|
562
|
+
refreshService: ALLOCATION.refreshService,
|
|
563
|
+
credentialId: expect.stringMatching(
|
|
564
|
+
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
565
|
+
),
|
|
566
|
+
issuerDid: PROFILE.did,
|
|
567
|
+
holderDid: TARGET_DID,
|
|
568
|
+
},
|
|
569
|
+
});
|
|
443
570
|
|
|
444
571
|
// A stable UUID credential ID is generated when the template has none
|
|
445
572
|
expect(client.credentialRefresh.allocateCredentialRefresh.mutate).toHaveBeenCalledTimes(1);
|
|
@@ -449,6 +576,9 @@ describe('credential refresh methods', () => {
|
|
|
449
576
|
expect(allocateInput.credentialId).toMatch(
|
|
450
577
|
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
451
578
|
);
|
|
579
|
+
expect(
|
|
580
|
+
(result as unknown as { refresh: { credentialId: string } }).refresh.credentialId
|
|
581
|
+
).toEqual(allocateInput.credentialId);
|
|
452
582
|
|
|
453
583
|
// Allocation happens before signing so the service lands in the signed payload
|
|
454
584
|
const allocateOrder =
|
|
@@ -483,6 +613,27 @@ describe('credential refresh methods', () => {
|
|
|
483
613
|
expect(client.utilities.getDid.query).not.toHaveBeenCalled();
|
|
484
614
|
});
|
|
485
615
|
|
|
616
|
+
it('sendBoost receipt preserves the exact credentialStatus descriptor from the signed VC', async () => {
|
|
617
|
+
const client = getMockClient();
|
|
618
|
+
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
619
|
+
const { learnCard } = getMockIssuingLearnCard();
|
|
620
|
+
const credentialStatus = {
|
|
621
|
+
id: 'https://network.example/status/3#94567',
|
|
622
|
+
type: 'BitstringStatusListEntry',
|
|
623
|
+
statusPurpose: 'revocation',
|
|
624
|
+
statusListIndex: '94567',
|
|
625
|
+
statusListCredential: 'https://network.example/status/3',
|
|
626
|
+
};
|
|
627
|
+
client.boost.allocateCredentialStatus.mutate.mockResolvedValue([credentialStatus]);
|
|
628
|
+
const plugin = await getLearnCardNetworkPlugin(learnCard, 'https://network.example/trpc');
|
|
629
|
+
|
|
630
|
+
const result = (await plugin.methods?.sendBoost(learnCard, 'userb', 'did:example:boost:1', {
|
|
631
|
+
enableRefresh: true,
|
|
632
|
+
})) as unknown as { refresh: Record<string, unknown> };
|
|
633
|
+
|
|
634
|
+
expect(result.refresh.credentialStatus).toEqual(credentialStatus);
|
|
635
|
+
});
|
|
636
|
+
|
|
486
637
|
it('sendBoost with enableRefresh reuses an existing credential ID', async () => {
|
|
487
638
|
const client = getMockClient();
|
|
488
639
|
vi.mocked(getBrainClient).mockResolvedValue(client as never);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-2198 Task 3 — compile-time verification of the `sendBoost` return-shape opt-in
|
|
3
|
+
* and the unified `send` refresh contract, exercised through the REAL plugin type
|
|
4
|
+
* system (`LearnCard<...>['invoke']` composition).
|
|
5
|
+
*
|
|
6
|
+
* The package Vitest config enables typechecking, so `bun run test` checks
|
|
7
|
+
* these assertions with the TypeScript compiler alongside the runtime tests.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
10
|
+
import type { LearnCard, Plugin } from '@learncard/core';
|
|
11
|
+
import type { ManagedCredentialRefreshReceipt } from '@learncard/types';
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
LearnCardNetworkPlugin,
|
|
15
|
+
SendBoostNetworkOptions,
|
|
16
|
+
SendBoostRefreshResult,
|
|
17
|
+
} from '../types';
|
|
18
|
+
|
|
19
|
+
/** Minimal host plugin so the network plugin is composed through the real machinery. */
|
|
20
|
+
declare const basePlugin: Plugin<'Minimal'>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The composed wallet type — identical to how `initLearnCard` / `addPlugin` derive
|
|
24
|
+
* `learnCard.invoke` (`GetPluginMethods` → `UnionToIntersection` over the plugins'
|
|
25
|
+
* declared methods).
|
|
26
|
+
*/
|
|
27
|
+
type NetworkLearnCard = LearnCard<[typeof basePlugin, LearnCardNetworkPlugin]>;
|
|
28
|
+
type NetworkInvoke = NetworkLearnCard['invoke'];
|
|
29
|
+
|
|
30
|
+
describe('learnCard.invoke.sendBoost return-shape opt-in', () => {
|
|
31
|
+
const sendBoost = {} as NetworkInvoke['sendBoost'];
|
|
32
|
+
|
|
33
|
+
it('keeps the legacy string result for omitted options', () => {
|
|
34
|
+
expectTypeOf(sendBoost('userb', 'did:web:example:boost:1')).toEqualTypeOf<
|
|
35
|
+
Promise<string>
|
|
36
|
+
>();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps the legacy string result for legacy boolean options', () => {
|
|
40
|
+
expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', true)).toEqualTypeOf<
|
|
41
|
+
Promise<string>
|
|
42
|
+
>();
|
|
43
|
+
expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', false)).toEqualTypeOf<
|
|
44
|
+
Promise<string>
|
|
45
|
+
>();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps the legacy string result when enableRefresh is absent or false', () => {
|
|
49
|
+
expectTypeOf(
|
|
50
|
+
sendBoost('userb', 'did:web:example:boost:1', {
|
|
51
|
+
encrypt: false,
|
|
52
|
+
skipNotification: true,
|
|
53
|
+
})
|
|
54
|
+
).toEqualTypeOf<Promise<string>>();
|
|
55
|
+
expectTypeOf(
|
|
56
|
+
sendBoost('userb', 'did:web:example:boost:1', { enableRefresh: false })
|
|
57
|
+
).toEqualTypeOf<Promise<string>>();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns the refreshable issuance result for literal enableRefresh: true', () => {
|
|
61
|
+
expectTypeOf(
|
|
62
|
+
sendBoost('userb', 'did:web:example:boost:1', { enableRefresh: true })
|
|
63
|
+
).toEqualTypeOf<Promise<SendBoostRefreshResult>>();
|
|
64
|
+
expectTypeOf(
|
|
65
|
+
sendBoost('userb', 'did:web:example:boost:1', {
|
|
66
|
+
enableRefresh: true,
|
|
67
|
+
skipNotification: true,
|
|
68
|
+
})
|
|
69
|
+
).toEqualTypeOf<Promise<SendBoostRefreshResult>>();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('preserves the literal opt-in with status purposes and nested template data', () => {
|
|
73
|
+
expectTypeOf(
|
|
74
|
+
sendBoost('userb', 'did:web:example:boost:1', {
|
|
75
|
+
enableRefresh: true,
|
|
76
|
+
statusPurposes: ['revocation'],
|
|
77
|
+
templateData: { grade: 'A' },
|
|
78
|
+
})
|
|
79
|
+
).toEqualTypeOf<Promise<SendBoostRefreshResult>>();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('degrades to the union for a dynamically typed enableRefresh boolean', () => {
|
|
83
|
+
const dynamic: boolean = Math.random() > 0.5;
|
|
84
|
+
|
|
85
|
+
expectTypeOf(
|
|
86
|
+
sendBoost('userb', 'did:web:example:boost:1', { enableRefresh: dynamic })
|
|
87
|
+
).toEqualTypeOf<Promise<string | SendBoostRefreshResult>>();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('degrades to the union when options are typed with an optional enableRefresh', () => {
|
|
91
|
+
const options: SendBoostNetworkOptions = { enableRefresh: true };
|
|
92
|
+
|
|
93
|
+
expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', options)).toEqualTypeOf<
|
|
94
|
+
Promise<string | SendBoostRefreshResult>
|
|
95
|
+
>();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('degrades to the union for boolean-or-object option variables', () => {
|
|
99
|
+
const options = {} as boolean | SendBoostNetworkOptions;
|
|
100
|
+
|
|
101
|
+
expectTypeOf(sendBoost('userb', 'did:web:example:boost:1', options)).toEqualTypeOf<
|
|
102
|
+
Promise<string | SendBoostRefreshResult>
|
|
103
|
+
>();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps the string result for object options that omit enableRefresh', () => {
|
|
107
|
+
expectTypeOf(
|
|
108
|
+
sendBoost('userb', 'did:web:example:boost:1', { encrypt: true, skipNotification: true })
|
|
109
|
+
).toEqualTypeOf<Promise<string>>();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns a metadata-only receipt carrying the publication inputs', () => {
|
|
113
|
+
expectTypeOf<
|
|
114
|
+
SendBoostRefreshResult['refresh']
|
|
115
|
+
>().toEqualTypeOf<ManagedCredentialRefreshReceipt>();
|
|
116
|
+
|
|
117
|
+
// Required publication inputs (identity fields are mandatory; the exact
|
|
118
|
+
// credentialStatus descriptor is optional because not every VC carries one).
|
|
119
|
+
expectTypeOf<
|
|
120
|
+
Pick<
|
|
121
|
+
SendBoostRefreshResult['refresh'],
|
|
122
|
+
'refreshId' | 'refreshService' | 'credentialId' | 'issuerDid' | 'holderDid'
|
|
123
|
+
>
|
|
124
|
+
>().toBeObject();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('learnCard.invoke.send unified refresh contract', () => {
|
|
129
|
+
const send = {} as NetworkInvoke['send'];
|
|
130
|
+
|
|
131
|
+
it('accepts refresh: true on boost sends and may resolve with the receipt', async () => {
|
|
132
|
+
const result = await send({
|
|
133
|
+
type: 'boost',
|
|
134
|
+
recipient: 'userb',
|
|
135
|
+
templateUri: 'did:web:example:boost:1',
|
|
136
|
+
refresh: true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expectTypeOf(result).toMatchTypeOf<{
|
|
140
|
+
uri: string;
|
|
141
|
+
credentialUri: string;
|
|
142
|
+
activityId: string;
|
|
143
|
+
}>();
|
|
144
|
+
expectTypeOf(result.refresh).toEqualTypeOf<ManagedCredentialRefreshReceipt | undefined>();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('keeps the refresh flag optional on the unified send input', () => {
|
|
148
|
+
expectTypeOf<Parameters<NetworkInvoke['send']>[0]['refresh']>().toEqualTypeOf<
|
|
149
|
+
boolean | undefined
|
|
150
|
+
>();
|
|
151
|
+
});
|
|
152
|
+
});
|