@learncard/cli 3.5.1 → 3.6.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.
- package/CHANGELOG.md +44 -0
- package/README.md +230 -2
- package/dist/index.js +4337 -986
- package/examples/branded.network.yaml +20 -0
- package/examples/delegated-service-account.network.yaml +23 -0
- package/examples/minimal.network.yaml +7 -0
- package/examples/self-hosted-signing.network.yaml +10 -0
- package/examples/service-account.network.yaml +13 -0
- package/examples/state-districts.network.yaml +36 -0
- package/package.json +21 -17
- package/src/auth-grant.test.ts +54 -0
- package/src/auth-grant.ts +34 -0
- package/src/clr/validate.test.ts +65 -0
- package/src/clr/validate.ts +242 -0
- package/src/clr.ts +119 -0
- package/src/demo-inbox-refresh.test.ts +737 -0
- package/src/demo-inbox-refresh.ts +804 -0
- package/src/demo-refresh-command.test.ts +57 -0
- package/src/demo-refresh-command.ts +22 -0
- package/src/demo-refresh-ui.test.ts +66 -0
- package/src/demo-refresh-ui.ts +65 -0
- package/src/demo-refresh.test.ts +140 -0
- package/src/demo-refresh.ts +309 -0
- package/src/doctor/checks.test.ts +448 -0
- package/src/doctor/checks.ts +497 -0
- package/src/doctor.test.ts +67 -0
- package/src/doctor.ts +118 -0
- package/src/inbox.test.ts +257 -0
- package/src/inbox.ts +221 -0
- package/src/index.tsx +70 -8
- package/src/init.ts +1 -1
- package/src/open.ts +1 -1
- package/src/org/apply.test.ts +1108 -0
- package/src/org/apply.ts +924 -0
- package/src/org/branding.test.ts +60 -0
- package/src/org/diff.ts +14 -0
- package/src/org/load.ts +50 -0
- package/src/org/schema.test.ts +256 -0
- package/src/org/schema.ts +216 -0
- package/src/org.ts +124 -0
- package/src/project.test.ts +26 -1
- package/src/project.ts +105 -10
- package/src/promote.test.ts +142 -0
- package/src/promote.ts +202 -0
- package/src/refresh.test.ts +86 -0
- package/src/refresh.ts +93 -0
- package/src/send.test.ts +278 -2
- package/src/send.ts +152 -24
- package/src/setup-signing.ts +1 -1
- package/src/status.ts +2 -4
- package/src/whoami.test.ts +67 -0
- package/src/whoami.ts +129 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import type { IssueInboxCredentialType } from '@learncard/types';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
init: vi.fn(),
|
|
6
|
+
lca: vi.fn(),
|
|
7
|
+
getClient: vi.fn(),
|
|
8
|
+
question: vi.fn(),
|
|
9
|
+
close: vi.fn(),
|
|
10
|
+
log: vi.fn(),
|
|
11
|
+
set: vi.fn(),
|
|
12
|
+
preflight: vi.fn(),
|
|
13
|
+
validateEmail: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('@learncard/init', () => ({ initLearnCard: mocks.init }));
|
|
17
|
+
vi.mock('@learncard/lca-api-plugin', () => ({ getLCAPlugin: mocks.lca }));
|
|
18
|
+
vi.mock('@learncard/network-brain-client', () => ({ getClient: mocks.getClient }));
|
|
19
|
+
vi.mock('@learncard/types', () => ({
|
|
20
|
+
ContactMethodQueryValidator: { safeParse: mocks.validateEmail },
|
|
21
|
+
VCValidator: {
|
|
22
|
+
parse: (v: unknown) => v,
|
|
23
|
+
safeParse: (v: unknown) => ({ success: true, data: v }),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('node:readline/promises', () => ({
|
|
27
|
+
createInterface: () => ({ question: mocks.question, close: mocks.close }),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('./out', () => ({ out: { log: mocks.log, set: mocks.set } }));
|
|
30
|
+
vi.mock('./project', () => ({
|
|
31
|
+
resolveServices: (_env: unknown, network: string) => ({
|
|
32
|
+
network,
|
|
33
|
+
cloud: undefined,
|
|
34
|
+
lcaAPI: undefined,
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('./demo-refresh-ui', () => ({
|
|
38
|
+
getRefreshDemoUiConfig: mocks.preflight,
|
|
39
|
+
requireLoopbackUrl: (value: unknown, label: string) => {
|
|
40
|
+
const url = new URL(String(value));
|
|
41
|
+
if (
|
|
42
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
43
|
+
!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ||
|
|
44
|
+
url.username ||
|
|
45
|
+
url.password ||
|
|
46
|
+
url.search ||
|
|
47
|
+
url.hash
|
|
48
|
+
)
|
|
49
|
+
throw new Error(`${label} must be an HTTP(S) loopback URL`);
|
|
50
|
+
return url;
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import { buildInboxAppLinks, mapInboxClaimPath, runInboxRefreshDemo } from './demo-inbox-refresh';
|
|
55
|
+
import type { InboxRefreshDemoOptions } from './demo-inbox-refresh';
|
|
56
|
+
|
|
57
|
+
const RECEIPT = {
|
|
58
|
+
refreshId: 'refresh:demo',
|
|
59
|
+
credentialId: 'urn:uuid:demo',
|
|
60
|
+
issuerDid: 'did:key:sa',
|
|
61
|
+
refreshService: {
|
|
62
|
+
id: 'http://localhost:4000/refresh/demo',
|
|
63
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
64
|
+
},
|
|
65
|
+
credentialStatus: {
|
|
66
|
+
id: 'http://localhost:4000/status/demo',
|
|
67
|
+
type: 'BitstringStatusListEntry',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const CLAIM_URL = 'http://localhost:4000/interactions/inbox-claim/token-123?iuv=1';
|
|
71
|
+
const FINAL = { id: RECEIPT.credentialId, name: 'Final Course Certificate' };
|
|
72
|
+
const HONORS = { id: RECEIPT.credentialId, name: 'Honors Course Certificate' };
|
|
73
|
+
|
|
74
|
+
type TestReceipt = typeof RECEIPT & { holderDid?: string };
|
|
75
|
+
type TestIssue = {
|
|
76
|
+
status: string;
|
|
77
|
+
issuanceId: string;
|
|
78
|
+
claimUrl?: string;
|
|
79
|
+
recipient: { type: string; value: string };
|
|
80
|
+
refresh: TestReceipt;
|
|
81
|
+
};
|
|
82
|
+
type TestCredential = {
|
|
83
|
+
id?: string;
|
|
84
|
+
issuer?: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
boostId?: string;
|
|
87
|
+
refreshService?: unknown;
|
|
88
|
+
credentialStatus?: unknown;
|
|
89
|
+
credentialSubject: { id?: string; [key: string]: unknown };
|
|
90
|
+
};
|
|
91
|
+
type Published = { credential: TestCredential };
|
|
92
|
+
|
|
93
|
+
const makeIssuer = (order: string[], published: Published[]) => {
|
|
94
|
+
const issuer = {
|
|
95
|
+
id: { did: () => 'did:key:issuer' },
|
|
96
|
+
addPlugin: vi.fn(),
|
|
97
|
+
invoke: {
|
|
98
|
+
createProfile: vi.fn(async () => undefined),
|
|
99
|
+
getProfile: vi.fn(
|
|
100
|
+
async (_did: string): Promise<{ profileId: string; did: string } | undefined> => ({
|
|
101
|
+
profileId: 'email-owner',
|
|
102
|
+
did: 'did:key:owner',
|
|
103
|
+
})
|
|
104
|
+
),
|
|
105
|
+
createSigningAuthority: vi.fn(async (_name: string) => ({
|
|
106
|
+
name: 'inbox-demo-sa',
|
|
107
|
+
endpoint: 'http://localhost:5100/api',
|
|
108
|
+
did: 'did:key:sa',
|
|
109
|
+
})),
|
|
110
|
+
registerSigningAuthority: vi.fn(async () => true),
|
|
111
|
+
setPrimaryRegisteredSigningAuthority: vi.fn(async () => true),
|
|
112
|
+
createBoost: vi.fn(async () => 'boost:demo'),
|
|
113
|
+
sendCredentialViaInbox: vi.fn(
|
|
114
|
+
async (_input: IssueInboxCredentialType): Promise<TestIssue> => {
|
|
115
|
+
order.push('issue');
|
|
116
|
+
return {
|
|
117
|
+
status: 'PENDING',
|
|
118
|
+
issuanceId: 'issuance:demo',
|
|
119
|
+
claimUrl: CLAIM_URL,
|
|
120
|
+
recipient: { type: 'email', value: 'inbox-demo-deadbeef@example.com' },
|
|
121
|
+
refresh: RECEIPT,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
),
|
|
125
|
+
publishCredentialRefresh: vi.fn(
|
|
126
|
+
async (input: Published): Promise<{ version: number; notification?: string }> => {
|
|
127
|
+
published.push(input);
|
|
128
|
+
order.push(`publish-${published.length + 1}`);
|
|
129
|
+
return published.length === 1
|
|
130
|
+
? { version: 2, notification: 'not-applicable' }
|
|
131
|
+
: { version: 3, notification: 'queued' };
|
|
132
|
+
}
|
|
133
|
+
),
|
|
134
|
+
getInboxCredential: vi.fn(
|
|
135
|
+
async (): Promise<{
|
|
136
|
+
refresh?: { holderDid?: string };
|
|
137
|
+
currentStatus?: string;
|
|
138
|
+
}> => ({
|
|
139
|
+
refresh: { holderDid: 'did:key:holder' },
|
|
140
|
+
})
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
issuer.addPlugin.mockImplementation(async () => issuer);
|
|
145
|
+
return issuer;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const makeHolder = (order: string[], state: { indexCalls: number }) => {
|
|
149
|
+
const holder = {
|
|
150
|
+
id: { did: () => 'did:key:holder' },
|
|
151
|
+
invoke: {
|
|
152
|
+
createProfile: vi.fn(async () => {
|
|
153
|
+
order.push('holderProfile');
|
|
154
|
+
}),
|
|
155
|
+
verifyCredential: vi.fn(async () => ({
|
|
156
|
+
checks: ['proof'],
|
|
157
|
+
errors: [] as string[],
|
|
158
|
+
warnings: [] as string[],
|
|
159
|
+
})),
|
|
160
|
+
acceptCredential: vi.fn(),
|
|
161
|
+
refreshCredential: vi.fn(),
|
|
162
|
+
getDidAuthVp: vi.fn(async () => 'did-auth'),
|
|
163
|
+
resolveDid: vi.fn(async () => undefined),
|
|
164
|
+
},
|
|
165
|
+
index: {
|
|
166
|
+
LearnCloud: {
|
|
167
|
+
get: vi.fn(async () => {
|
|
168
|
+
state.indexCalls++;
|
|
169
|
+
return state.indexCalls === 1 ? [] : [{ uri: 'stored:demo' }];
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
read: {
|
|
174
|
+
get: vi.fn(async () => (state.indexCalls >= 4 ? HONORS : FINAL)),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
return holder;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type FakeIssuer = ReturnType<typeof makeIssuer>;
|
|
181
|
+
type FakeHolder = ReturnType<typeof makeHolder>;
|
|
182
|
+
|
|
183
|
+
const oldTTY = process.stdin.isTTY;
|
|
184
|
+
const oldYes = process.env.LC_YES;
|
|
185
|
+
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
vi.resetAllMocks();
|
|
188
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
189
|
+
delete process.env.LC_YES;
|
|
190
|
+
mocks.question.mockResolvedValue('');
|
|
191
|
+
mocks.lca.mockResolvedValue({});
|
|
192
|
+
// Stand-in for the real ContactMethodQueryValidator email branch.
|
|
193
|
+
mocks.validateEmail.mockImplementation((input: { value?: string }) =>
|
|
194
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input?.value ?? '')
|
|
195
|
+
? { success: true, data: { type: 'email', value: input.value } }
|
|
196
|
+
: { success: false, error: { message: 'invalid email' } }
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: oldTTY, configurable: true });
|
|
202
|
+
if (oldYes === undefined) delete process.env.LC_YES;
|
|
203
|
+
else process.env.LC_YES = oldYes;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const runTerminal = async (
|
|
207
|
+
options: { lcaUrl?: string; network?: string } = {}
|
|
208
|
+
): Promise<{
|
|
209
|
+
order: string[];
|
|
210
|
+
published: Published[];
|
|
211
|
+
issuer: FakeIssuer;
|
|
212
|
+
holder: FakeHolder;
|
|
213
|
+
}> => {
|
|
214
|
+
const order: string[] = [];
|
|
215
|
+
const published: Published[] = [];
|
|
216
|
+
const state = { indexCalls: 0 };
|
|
217
|
+
const issuer = makeIssuer(order, published);
|
|
218
|
+
const holder = makeHolder(order, state);
|
|
219
|
+
mocks.init.mockResolvedValueOnce(issuer).mockResolvedValueOnce(holder);
|
|
220
|
+
const mutate = vi
|
|
221
|
+
.fn()
|
|
222
|
+
.mockResolvedValueOnce({
|
|
223
|
+
verifiablePresentationRequest: { challenge: 'challenge-1', domain: 'localhost' },
|
|
224
|
+
})
|
|
225
|
+
.mockResolvedValueOnce({
|
|
226
|
+
verifiablePresentation: { verifiableCredential: [FINAL] },
|
|
227
|
+
});
|
|
228
|
+
mocks.getClient.mockResolvedValue({
|
|
229
|
+
workflows: { participateInExchange: { mutate } },
|
|
230
|
+
});
|
|
231
|
+
holder.invoke.refreshCredential.mockResolvedValue({ status: 'updated', credential: HONORS });
|
|
232
|
+
await runInboxRefreshDemo({ yes: true, ...options });
|
|
233
|
+
return { order, published, issuer, holder };
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
describe('Universal Inbox refresh demonstration', () => {
|
|
237
|
+
it('issues and publishes before the recipient profile exists, then guides a real claim', async () => {
|
|
238
|
+
const order: string[] = [];
|
|
239
|
+
const published: Published[] = [];
|
|
240
|
+
const state = { indexCalls: 0 };
|
|
241
|
+
const issuer = makeIssuer(order, published);
|
|
242
|
+
const holder = makeHolder(order, state);
|
|
243
|
+
mocks.init.mockResolvedValueOnce(issuer).mockResolvedValueOnce(holder);
|
|
244
|
+
mocks.preflight.mockResolvedValue({
|
|
245
|
+
appOrigin: 'http://localhost:3000',
|
|
246
|
+
cloud: 'http://localhost:4100/trpc',
|
|
247
|
+
lcaApi: 'http://localhost:5200/trpc',
|
|
248
|
+
notificationsWebhook: 'http://localhost:5200/api/notifications/send',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await runInboxRefreshDemo({ ui: true });
|
|
252
|
+
|
|
253
|
+
expect(issuer.invoke.createSigningAuthority.mock.calls[0]?.[0]).toMatch(
|
|
254
|
+
/^[a-z0-9-]{1,15}$/
|
|
255
|
+
);
|
|
256
|
+
expect(mocks.question).toHaveBeenCalledWith(
|
|
257
|
+
expect.stringContaining('publish final results before anyone claims')
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// Pre-claim: nobody existed when the credential and its update were queued.
|
|
261
|
+
expect(order.indexOf('issue')).toBeLessThan(order.indexOf('publish-2'));
|
|
262
|
+
expect(order.indexOf('publish-2')).toBeLessThan(order.indexOf('holderProfile'));
|
|
263
|
+
expect(order.indexOf('holderProfile')).toBeLessThan(order.indexOf('publish-3'));
|
|
264
|
+
|
|
265
|
+
// Receipt identity, status, and service are kept across versions.
|
|
266
|
+
expect(published[0]!.credential).toMatchObject({
|
|
267
|
+
id: RECEIPT.credentialId,
|
|
268
|
+
issuer: RECEIPT.issuerDid,
|
|
269
|
+
refreshService: RECEIPT.refreshService,
|
|
270
|
+
credentialStatus: RECEIPT.credentialStatus,
|
|
271
|
+
boostId: 'boost:demo',
|
|
272
|
+
});
|
|
273
|
+
expect(published[0]!.credential.credentialSubject.id).toBeUndefined();
|
|
274
|
+
expect(published[1]!.credential.credentialSubject.id).toBe('did:key:holder');
|
|
275
|
+
|
|
276
|
+
// Issue request really exercised the no-account path and suppressed email.
|
|
277
|
+
const issueInput = issuer.invoke.sendCredentialViaInbox.mock.calls[0]![0];
|
|
278
|
+
expect(issueInput.recipient).toEqual({
|
|
279
|
+
type: 'email',
|
|
280
|
+
value: expect.stringMatching(/^inbox-demo-[0-9a-f]{8}@example\.com$/),
|
|
281
|
+
});
|
|
282
|
+
expect(issueInput.refresh).toBe(true);
|
|
283
|
+
expect(issueInput.templateUri).toBe('boost:demo');
|
|
284
|
+
expect(issueInput.idempotencyKey).toBeTruthy();
|
|
285
|
+
expect(issueInput.configuration?.delivery?.suppress).toBe(true);
|
|
286
|
+
|
|
287
|
+
// The CLI never claims, accepts, refreshes, or saves on the holder's behalf.
|
|
288
|
+
expect(holder.invoke.acceptCredential).not.toHaveBeenCalled();
|
|
289
|
+
expect(holder.invoke.refreshCredential).not.toHaveBeenCalled();
|
|
290
|
+
expect(holder.invoke.verifyCredential).toHaveBeenCalledWith(FINAL);
|
|
291
|
+
expect(holder.invoke.verifyCredential).toHaveBeenCalledWith(HONORS);
|
|
292
|
+
|
|
293
|
+
// A premature Enter retried; the same entry was replaced, not duplicated.
|
|
294
|
+
expect(holder.index.LearnCloud.get).toHaveBeenCalledTimes(4);
|
|
295
|
+
|
|
296
|
+
const logs = mocks.log.mock.calls.flat().join('\n');
|
|
297
|
+
expect(logs).toMatch(
|
|
298
|
+
/http:\/\/localhost:3000\/developer\/sign-in\?next=%2Finteractions%2Finbox-claim%2Ftoken-123%3Fiuv%3D1#seed=[0-9a-f]{64}/
|
|
299
|
+
);
|
|
300
|
+
expect(logs).toContain('http://localhost:3000/interactions/inbox-claim/token-123?iuv=1');
|
|
301
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
302
|
+
expect.objectContaining({ ui: true, status: 'updated', sameCredentialId: true })
|
|
303
|
+
);
|
|
304
|
+
// No seed or claim link ever leaks into the machine-readable result.
|
|
305
|
+
expect(JSON.stringify(mocks.set.mock.calls)).not.toMatch(/[0-9a-f]{64}/);
|
|
306
|
+
expect(JSON.stringify(mocks.set.mock.calls)).not.toContain('inbox-claim');
|
|
307
|
+
expect(mocks.close).toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('uses the tenant-configured LCA service in UI mode', async () => {
|
|
311
|
+
const order: string[] = [];
|
|
312
|
+
const published: Published[] = [];
|
|
313
|
+
const issuer = makeIssuer(order, published);
|
|
314
|
+
const holder = makeHolder(order, { indexCalls: 0 });
|
|
315
|
+
mocks.init.mockResolvedValueOnce(issuer).mockResolvedValueOnce(holder);
|
|
316
|
+
mocks.preflight.mockResolvedValue({
|
|
317
|
+
appOrigin: 'http://localhost:3000',
|
|
318
|
+
cloud: 'http://localhost:4100/trpc',
|
|
319
|
+
lcaApi: 'http://localhost:5200/trpc',
|
|
320
|
+
notificationsWebhook: 'http://localhost:5200/api/notifications/send',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await runInboxRefreshDemo({ ui: true });
|
|
324
|
+
expect(mocks.lca).toHaveBeenCalledWith(expect.anything(), 'http://localhost:5200/trpc');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('claims with DIDAuth and refreshes in terminal mode without leaking secrets', async () => {
|
|
328
|
+
const { order, published, holder } = await runTerminal();
|
|
329
|
+
|
|
330
|
+
expect(order).toEqual(['issue', 'publish-2', 'publish-3']);
|
|
331
|
+
expect(published[1]!.credential.credentialSubject.id).toBe('did:key:holder');
|
|
332
|
+
expect(published[1]!.credential.name).toBe('Honors Course Certificate');
|
|
333
|
+
expect(published[1]).toMatchObject({ notifyHolder: false });
|
|
334
|
+
expect(holder.invoke.refreshCredential).toHaveBeenCalledWith(
|
|
335
|
+
FINAL,
|
|
336
|
+
expect.objectContaining({ allowInsecureHttp: true, allowPrivateAddresses: true })
|
|
337
|
+
);
|
|
338
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
before: 'Final Course Certificate',
|
|
341
|
+
after: 'Honors Course Certificate',
|
|
342
|
+
version: 3,
|
|
343
|
+
status: 'updated',
|
|
344
|
+
sameCredentialId: true,
|
|
345
|
+
})
|
|
346
|
+
);
|
|
347
|
+
const result = JSON.stringify(mocks.set.mock.calls);
|
|
348
|
+
expect(result).not.toMatch(/[0-9a-f]{64}/);
|
|
349
|
+
expect(result).not.toContain('inbox-claim');
|
|
350
|
+
expect(result).not.toContain('seed');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('defaults the terminal LCA service to 5100 and allows a 5200 override', async () => {
|
|
354
|
+
await runTerminal();
|
|
355
|
+
expect(mocks.lca).toHaveBeenLastCalledWith(expect.anything(), 'http://localhost:5100/trpc');
|
|
356
|
+
vi.resetAllMocks();
|
|
357
|
+
mocks.question.mockResolvedValue('');
|
|
358
|
+
mocks.lca.mockResolvedValue({});
|
|
359
|
+
await runTerminal({ lcaUrl: 'http://localhost:5200/trpc' });
|
|
360
|
+
expect(mocks.lca).toHaveBeenLastCalledWith(expect.anything(), 'http://localhost:5200/trpc');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it.each([{ yes: true }, { json: true }])(
|
|
364
|
+
'rejects auto-advance UI flags before any writes: %j',
|
|
365
|
+
async flags => {
|
|
366
|
+
await expect(runInboxRefreshDemo({ ui: true, ...flags })).rejects.toThrow(
|
|
367
|
+
'interactive terminal'
|
|
368
|
+
);
|
|
369
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
370
|
+
expect(mocks.preflight).not.toHaveBeenCalled();
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
it('requires a loopback network and rejects --lca-url together with --ui', async () => {
|
|
375
|
+
await expect(
|
|
376
|
+
runInboxRefreshDemo({ yes: true, network: 'https://network.learncard.com/trpc' })
|
|
377
|
+
).rejects.toThrow('local-only');
|
|
378
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
379
|
+
|
|
380
|
+
await expect(
|
|
381
|
+
runInboxRefreshDemo({ ui: true, lcaUrl: 'http://localhost:5200/trpc' })
|
|
382
|
+
).rejects.toThrow('--lca-url is for terminal mode');
|
|
383
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('fails when a holder is already bound or the publication is the wrong version', async () => {
|
|
387
|
+
const order: string[] = [];
|
|
388
|
+
const published: Published[] = [];
|
|
389
|
+
const state = { indexCalls: 0 };
|
|
390
|
+
const issuer = makeIssuer(order, published);
|
|
391
|
+
issuer.invoke.sendCredentialViaInbox.mockResolvedValueOnce({
|
|
392
|
+
status: 'PENDING',
|
|
393
|
+
issuanceId: 'issuance:demo',
|
|
394
|
+
claimUrl: CLAIM_URL,
|
|
395
|
+
recipient: { type: 'email', value: 'inbox-demo-deadbeef@example.com' },
|
|
396
|
+
refresh: { ...RECEIPT, holderDid: 'did:key:someone' },
|
|
397
|
+
});
|
|
398
|
+
mocks.init.mockResolvedValueOnce(issuer).mockResolvedValueOnce(makeHolder(order, state));
|
|
399
|
+
await expect(runInboxRefreshDemo({ yes: true })).rejects.toThrow(
|
|
400
|
+
'holder was bound before anyone claimed'
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
vi.resetAllMocks();
|
|
404
|
+
mocks.question.mockResolvedValue('');
|
|
405
|
+
mocks.lca.mockResolvedValue({});
|
|
406
|
+
const order2: string[] = [];
|
|
407
|
+
const published2: Published[] = [];
|
|
408
|
+
const issuer2 = makeIssuer(order2, published2);
|
|
409
|
+
issuer2.invoke.publishCredentialRefresh.mockResolvedValue({ version: 5 });
|
|
410
|
+
mocks.init
|
|
411
|
+
.mockResolvedValueOnce(issuer2)
|
|
412
|
+
.mockResolvedValueOnce(makeHolder(order2, { indexCalls: 0 }));
|
|
413
|
+
await expect(runInboxRefreshDemo({ yes: true })).rejects.toThrow('Expected version 2');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('refuses to advance when the app certificate fails proof verification', async () => {
|
|
417
|
+
const order: string[] = [];
|
|
418
|
+
const published: Published[] = [];
|
|
419
|
+
const state = { indexCalls: 1 };
|
|
420
|
+
const holder = makeHolder(order, state);
|
|
421
|
+
holder.invoke.verifyCredential.mockResolvedValue({
|
|
422
|
+
checks: [],
|
|
423
|
+
errors: ['bad proof'],
|
|
424
|
+
warnings: [],
|
|
425
|
+
});
|
|
426
|
+
mocks.init
|
|
427
|
+
.mockResolvedValueOnce(makeIssuer(order, published))
|
|
428
|
+
.mockResolvedValueOnce(holder);
|
|
429
|
+
mocks.preflight.mockResolvedValue({
|
|
430
|
+
appOrigin: 'http://localhost:3000',
|
|
431
|
+
cloud: 'http://localhost:4100/trpc',
|
|
432
|
+
lcaApi: 'http://localhost:5200/trpc',
|
|
433
|
+
notificationsWebhook: 'http://localhost:5200/api/notifications/send',
|
|
434
|
+
});
|
|
435
|
+
await expect(runInboxRefreshDemo({ ui: true })).rejects.toThrow(
|
|
436
|
+
'did not pass verification'
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('fails when the DIDAuth claim returns the wrong credential', async () => {
|
|
441
|
+
const order: string[] = [];
|
|
442
|
+
const published: Published[] = [];
|
|
443
|
+
const issuer = makeIssuer(order, published);
|
|
444
|
+
const holder = makeHolder(order, { indexCalls: 0 });
|
|
445
|
+
mocks.init.mockResolvedValueOnce(issuer).mockResolvedValueOnce(holder);
|
|
446
|
+
mocks.getClient.mockResolvedValue({
|
|
447
|
+
workflows: {
|
|
448
|
+
participateInExchange: {
|
|
449
|
+
mutate: vi
|
|
450
|
+
.fn()
|
|
451
|
+
.mockResolvedValueOnce({
|
|
452
|
+
verifiablePresentationRequest: { challenge: 'c', domain: 'd' },
|
|
453
|
+
})
|
|
454
|
+
.mockResolvedValueOnce({
|
|
455
|
+
verifiablePresentation: {
|
|
456
|
+
verifiableCredential: [{ ...FINAL, id: 'urn:uuid:other' }],
|
|
457
|
+
},
|
|
458
|
+
}),
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
await expect(runInboxRefreshDemo({ yes: true })).rejects.toThrow(
|
|
463
|
+
'different credential identity'
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('real-email inbox refresh (opt-in)', () => {
|
|
469
|
+
const EMAIL = 'owner@example.com';
|
|
470
|
+
const UI_CONFIG = {
|
|
471
|
+
appOrigin: 'http://localhost:3000',
|
|
472
|
+
cloud: 'http://localhost:4100/trpc',
|
|
473
|
+
lcaApi: 'http://localhost:5200/trpc',
|
|
474
|
+
notificationsWebhook: 'http://localhost:5200/api/notifications/send',
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const makeEmailIssuer = (
|
|
478
|
+
order: string[],
|
|
479
|
+
published: Published[],
|
|
480
|
+
options: { status: 'PENDING' | 'DELIVERED'; holderDid?: string }
|
|
481
|
+
): FakeIssuer => {
|
|
482
|
+
const issuer = makeIssuer(order, published);
|
|
483
|
+
issuer.invoke.sendCredentialViaInbox.mockImplementation(
|
|
484
|
+
async (input: IssueInboxCredentialType): Promise<TestIssue> => {
|
|
485
|
+
order.push('issue');
|
|
486
|
+
return {
|
|
487
|
+
status: options.status,
|
|
488
|
+
issuanceId: 'issuance:email',
|
|
489
|
+
...(options.holderDid ? {} : { claimUrl: CLAIM_URL }),
|
|
490
|
+
recipient: input.recipient,
|
|
491
|
+
refresh: {
|
|
492
|
+
...RECEIPT,
|
|
493
|
+
...(options.holderDid && { holderDid: options.holderDid }),
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
issuer.invoke.publishCredentialRefresh.mockImplementation(
|
|
499
|
+
async (input: Published): Promise<{ version: number; notification?: string }> => {
|
|
500
|
+
published.push(input);
|
|
501
|
+
order.push('publish-2');
|
|
502
|
+
return { version: 2, notification: 'queued' };
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
return issuer;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const runEmail = async (
|
|
509
|
+
issuer: FakeIssuer,
|
|
510
|
+
overrides: Partial<InboxRefreshDemoOptions> = {}
|
|
511
|
+
): Promise<void> => {
|
|
512
|
+
mocks.init.mockResolvedValueOnce(issuer);
|
|
513
|
+
mocks.preflight.mockResolvedValue(UI_CONFIG);
|
|
514
|
+
await runInboxRefreshDemo({ ui: true, email: EMAIL, ...overrides });
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
it('emails the presenter, waits for the claim, then publishes one version 2 update', async () => {
|
|
518
|
+
const order: string[] = [];
|
|
519
|
+
const published: Published[] = [];
|
|
520
|
+
const issuer = makeEmailIssuer(order, published, { status: 'PENDING' });
|
|
521
|
+
issuer.invoke.getInboxCredential
|
|
522
|
+
.mockResolvedValueOnce({ refresh: {}, currentStatus: 'PENDING' })
|
|
523
|
+
.mockResolvedValueOnce({
|
|
524
|
+
refresh: { holderDid: 'did:key:owner' },
|
|
525
|
+
currentStatus: 'ISSUED',
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
await runEmail(issuer);
|
|
529
|
+
|
|
530
|
+
// No recipient wallet is created: the human does the claiming.
|
|
531
|
+
expect(mocks.init).toHaveBeenCalledTimes(1);
|
|
532
|
+
expect(mocks.validateEmail).toHaveBeenCalledWith({ type: 'email', value: EMAIL });
|
|
533
|
+
expect(mocks.validateEmail.mock.invocationCallOrder[0]).toBeLessThan(
|
|
534
|
+
issuer.invoke.createProfile.mock.invocationCallOrder[0]!
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// Delivery is requested through the operator's adapter, never suppressed.
|
|
538
|
+
const issueInput = issuer.invoke.sendCredentialViaInbox.mock.calls[0]![0];
|
|
539
|
+
expect(issueInput.recipient).toEqual({ type: 'email', value: EMAIL });
|
|
540
|
+
expect(issueInput.refresh).toBe(true);
|
|
541
|
+
expect(issueInput.configuration?.delivery?.suppress).toBe(false);
|
|
542
|
+
|
|
543
|
+
// Exactly one visible update, version 2, addressed to the reported holder.
|
|
544
|
+
expect(published).toHaveLength(1);
|
|
545
|
+
expect(published[0]!.credential.name).toBe('Final Course Certificate');
|
|
546
|
+
expect(published[0]!.credential.credentialSubject.id).toBe('did:key:owner');
|
|
547
|
+
expect(issuer.invoke.publishCredentialRefresh).toHaveBeenCalledTimes(1);
|
|
548
|
+
expect(issuer.invoke.getInboxCredential).toHaveBeenCalledTimes(2);
|
|
549
|
+
|
|
550
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({
|
|
552
|
+
realEmail: true,
|
|
553
|
+
deliveryRequested: true,
|
|
554
|
+
version: 2,
|
|
555
|
+
status: 'ISSUED',
|
|
556
|
+
notification: 'queued',
|
|
557
|
+
before: 'Provisional Course Certificate',
|
|
558
|
+
after: 'Final Course Certificate',
|
|
559
|
+
})
|
|
560
|
+
);
|
|
561
|
+
// Raw addresses stay out of machine-readable output.
|
|
562
|
+
expect(JSON.stringify(mocks.set.mock.calls)).not.toContain(EMAIL);
|
|
563
|
+
expect(JSON.stringify(mocks.set.mock.calls)).toContain('o****@example.com');
|
|
564
|
+
|
|
565
|
+
const logs = mocks.log.mock.calls.flat().join('\n');
|
|
566
|
+
expect(logs).toContain('Email delivery uses your local Postmark configuration');
|
|
567
|
+
expect(logs).toContain('did not read the recipient wallet');
|
|
568
|
+
expect(mocks.close).toHaveBeenCalled();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('publishes version 2 after the existing recipient confirms claiming', async () => {
|
|
572
|
+
const order: string[] = [];
|
|
573
|
+
const published: Published[] = [];
|
|
574
|
+
const issuer = makeEmailIssuer(order, published, {
|
|
575
|
+
status: 'DELIVERED',
|
|
576
|
+
holderDid: 'did:key:known',
|
|
577
|
+
});
|
|
578
|
+
issuer.invoke.getInboxCredential.mockResolvedValue({
|
|
579
|
+
refresh: { holderDid: 'did:key:known' },
|
|
580
|
+
currentStatus: 'ISSUED',
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await runEmail(issuer);
|
|
584
|
+
|
|
585
|
+
expect(mocks.init).toHaveBeenCalledTimes(1);
|
|
586
|
+
expect(
|
|
587
|
+
issuer.invoke.sendCredentialViaInbox.mock.calls[0]![0].configuration?.delivery?.suppress
|
|
588
|
+
).toBe(false);
|
|
589
|
+
expect(issuer.invoke.getInboxCredential).toHaveBeenCalledTimes(1);
|
|
590
|
+
expect(published).toHaveLength(1);
|
|
591
|
+
expect(published[0]!.credential.name).toBe('Final Course Certificate');
|
|
592
|
+
expect(published[0]!.credential.credentialSubject.id).toBe('did:key:known');
|
|
593
|
+
|
|
594
|
+
const logs = mocks.log.mock.calls.flat().join('\n');
|
|
595
|
+
expect(logs).toContain('already has a LearnCard account');
|
|
596
|
+
expect(mocks.question).toHaveBeenCalledWith(
|
|
597
|
+
expect.stringContaining('open the email, sign in with that address, and claim')
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('does not publish when the certificate was claimed without creating an account', async () => {
|
|
602
|
+
const published: Published[] = [];
|
|
603
|
+
const issuer = makeEmailIssuer([], published, {
|
|
604
|
+
status: 'DELIVERED',
|
|
605
|
+
holderDid: 'did:key:owner',
|
|
606
|
+
});
|
|
607
|
+
issuer.invoke.getInboxCredential.mockResolvedValue({
|
|
608
|
+
refresh: { holderDid: 'did:key:owner' },
|
|
609
|
+
});
|
|
610
|
+
issuer.invoke.getProfile.mockResolvedValueOnce(undefined);
|
|
611
|
+
await expect(runEmail(issuer)).rejects.toThrow('claimed before account setup finished');
|
|
612
|
+
expect(issuer.invoke.getProfile).toHaveBeenCalledWith('did:key:owner');
|
|
613
|
+
expect(published).toHaveLength(0);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('explains a missing recipient account when the network returns NOT_FOUND', async () => {
|
|
617
|
+
const published: Published[] = [];
|
|
618
|
+
const issuer = makeEmailIssuer([], published, {
|
|
619
|
+
status: 'DELIVERED',
|
|
620
|
+
holderDid: 'did:key:owner',
|
|
621
|
+
});
|
|
622
|
+
issuer.invoke.getInboxCredential.mockResolvedValue({
|
|
623
|
+
refresh: { holderDid: 'did:key:owner' },
|
|
624
|
+
});
|
|
625
|
+
issuer.invoke.getProfile.mockRejectedValueOnce({ data: { code: 'NOT_FOUND' } });
|
|
626
|
+
await expect(runEmail(issuer)).rejects.toThrow('No update was published');
|
|
627
|
+
expect(published).toHaveLength(0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('does not mistake profile lookup failures for an account that needs setup', async () => {
|
|
631
|
+
const published: Published[] = [];
|
|
632
|
+
const issuer = makeEmailIssuer([], published, {
|
|
633
|
+
status: 'DELIVERED',
|
|
634
|
+
holderDid: 'did:key:owner',
|
|
635
|
+
});
|
|
636
|
+
issuer.invoke.getInboxCredential.mockResolvedValue({
|
|
637
|
+
refresh: { holderDid: 'did:key:owner' },
|
|
638
|
+
});
|
|
639
|
+
issuer.invoke.getProfile.mockRejectedValueOnce(new Error('Network unavailable'));
|
|
640
|
+
await expect(runEmail(issuer)).rejects.toThrow('Network unavailable');
|
|
641
|
+
expect(published).toHaveLength(0);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('prompts for the address when --email is passed without a value', async () => {
|
|
645
|
+
const order: string[] = [];
|
|
646
|
+
const published: Published[] = [];
|
|
647
|
+
const issuer = makeEmailIssuer(order, published, { status: 'PENDING' });
|
|
648
|
+
issuer.invoke.getInboxCredential.mockResolvedValue({
|
|
649
|
+
refresh: { holderDid: 'did:key:owner' },
|
|
650
|
+
currentStatus: 'ISSUED',
|
|
651
|
+
});
|
|
652
|
+
mocks.question.mockResolvedValueOnce('typed@example.com');
|
|
653
|
+
|
|
654
|
+
await runEmail(issuer, { email: true });
|
|
655
|
+
|
|
656
|
+
expect(mocks.validateEmail).toHaveBeenCalledWith({
|
|
657
|
+
type: 'email',
|
|
658
|
+
value: 'typed@example.com',
|
|
659
|
+
});
|
|
660
|
+
expect(issuer.invoke.sendCredentialViaInbox.mock.calls[0]![0].recipient).toEqual({
|
|
661
|
+
type: 'email',
|
|
662
|
+
value: 'typed@example.com',
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it.each([
|
|
667
|
+
['invalid --email value', { ui: true, email: 'not-an-email' }, undefined],
|
|
668
|
+
['invalid typed address', { ui: true, email: true }, 'still-not-an-email'],
|
|
669
|
+
] as const)('rejects an %s before creating anything', async (_label, options, prompt) => {
|
|
670
|
+
if (prompt) mocks.question.mockResolvedValueOnce(prompt);
|
|
671
|
+
mocks.preflight.mockResolvedValue(UI_CONFIG);
|
|
672
|
+
await expect(runInboxRefreshDemo(options)).rejects.toThrow('valid email address');
|
|
673
|
+
expect(mocks.validateEmail).toHaveBeenCalled();
|
|
674
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('requires --ui, an interactive terminal, and no --lca-url', async () => {
|
|
678
|
+
await expect(runInboxRefreshDemo({ email: EMAIL })).rejects.toThrow(
|
|
679
|
+
'--email requires --ui'
|
|
680
|
+
);
|
|
681
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
682
|
+
|
|
683
|
+
await expect(runInboxRefreshDemo({ ui: true, yes: true, email: EMAIL })).rejects.toThrow(
|
|
684
|
+
'interactive terminal'
|
|
685
|
+
);
|
|
686
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
687
|
+
|
|
688
|
+
await expect(
|
|
689
|
+
runInboxRefreshDemo({ ui: true, email: EMAIL, lcaUrl: 'http://localhost:5200/trpc' })
|
|
690
|
+
).rejects.toThrow('--lca-url is for terminal mode');
|
|
691
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
692
|
+
expect(mocks.preflight).not.toHaveBeenCalled();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('refuses a holder-bound publication that reports no notification target', async () => {
|
|
696
|
+
const order: string[] = [];
|
|
697
|
+
const published: Published[] = [];
|
|
698
|
+
const issuer = makeEmailIssuer(order, published, {
|
|
699
|
+
status: 'DELIVERED',
|
|
700
|
+
holderDid: 'did:key:known',
|
|
701
|
+
});
|
|
702
|
+
issuer.invoke.publishCredentialRefresh.mockResolvedValue({
|
|
703
|
+
version: 2,
|
|
704
|
+
notification: 'not-applicable',
|
|
705
|
+
});
|
|
706
|
+
issuer.invoke.getInboxCredential.mockResolvedValue({
|
|
707
|
+
refresh: { holderDid: 'did:key:known' },
|
|
708
|
+
currentStatus: 'ISSUED',
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
await expect(runEmail(issuer)).rejects.toThrow('no notification target');
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
describe('inbox claim link mapping', () => {
|
|
716
|
+
it('maps the backend claim path onto the validated app origin', () => {
|
|
717
|
+
expect(mapInboxClaimPath(CLAIM_URL)).toBe('/interactions/inbox-claim/token-123?iuv=1');
|
|
718
|
+
expect(
|
|
719
|
+
buildInboxAppLinks(
|
|
720
|
+
'http://localhost:3000',
|
|
721
|
+
'/interactions/inbox-claim/token-123?iuv=1',
|
|
722
|
+
'a'.repeat(64)
|
|
723
|
+
)
|
|
724
|
+
).toEqual({
|
|
725
|
+
signIn: `http://localhost:3000/developer/sign-in?next=%2Finteractions%2Finbox-claim%2Ftoken-123%3Fiuv%3D1#seed=${'a'.repeat(64)}`,
|
|
726
|
+
claim: 'http://localhost:3000/interactions/inbox-claim/token-123?iuv=1',
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it.each([
|
|
731
|
+
'https://evil.example/interactions/inbox-claim/token/extra',
|
|
732
|
+
'http://localhost:4000/other/path',
|
|
733
|
+
'not-a-url',
|
|
734
|
+
])('refuses an unexpected claim link: %s', claimUrl => {
|
|
735
|
+
expect(() => mapInboxClaimPath(claimUrl)).toThrow();
|
|
736
|
+
});
|
|
737
|
+
});
|