@learncard/sss-key-manager 0.1.14 → 0.1.16

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,548 @@
1
+ /**
2
+ * Tests for QR Login Client
3
+ *
4
+ * Tests the full flow: session creation → approval → polling + decryption.
5
+ * Uses a mock fetch to simulate the relay server.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+
10
+ import {
11
+ createQrLoginSession,
12
+ pollQrLoginSession,
13
+ getQrLoginSessionInfo,
14
+ approveQrLoginSession,
15
+ pollUntilApproved,
16
+ } from './qr-login';
17
+
18
+ import {
19
+ generateEphemeralKeypair,
20
+ encryptShareForTransfer,
21
+ } from './qr-crypto';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const SERVER_URL = 'https://api.example.com';
28
+
29
+ const supportsX25519 = async (): Promise<boolean> => {
30
+ try {
31
+ await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ };
37
+
38
+ // In-memory "server" state for mock fetch
39
+ let sessions: Map<string, {
40
+ sessionId: string;
41
+ shortCode: string;
42
+ publicKey: string;
43
+ status: 'pending' | 'approved';
44
+ encryptedPayload?: string;
45
+ approverDid?: string;
46
+ }>;
47
+
48
+ let shortCodeMap: Map<string, string>;
49
+
50
+ const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
51
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
52
+ const method = init?.method ?? 'GET';
53
+ const body = init?.body ? JSON.parse(init.body as string) : undefined;
54
+
55
+ // POST /qr-login/session — create session
56
+ if (urlStr.endsWith('/qr-login/session') && method === 'POST') {
57
+ const sessionId = `session-${Math.random().toString(36).slice(2, 8)}`;
58
+ const shortCode = Math.floor(Math.random() * 100_000_000).toString().padStart(8, '0');
59
+
60
+ sessions.set(sessionId, {
61
+ sessionId,
62
+ shortCode,
63
+ publicKey: body.publicKey,
64
+ status: 'pending',
65
+ });
66
+
67
+ shortCodeMap.set(shortCode, sessionId);
68
+
69
+ return new Response(JSON.stringify({
70
+ sessionId,
71
+ shortCode,
72
+ expiresInSeconds: 120,
73
+ }), { status: 200 });
74
+ }
75
+
76
+ // GET /qr-login/session/:lookup — get session
77
+ const getMatch = urlStr.match(/\/qr-login\/session\/([^/]+)$/);
78
+
79
+ if (getMatch && method === 'GET') {
80
+ let sessionId = getMatch[1]!;
81
+
82
+ if (/^\d{8}$/.test(sessionId)) {
83
+ const resolved = shortCodeMap.get(sessionId);
84
+
85
+ if (!resolved) {
86
+ return new Response('Not found', { status: 404 });
87
+ }
88
+
89
+ sessionId = resolved;
90
+ }
91
+
92
+ const session = sessions.get(sessionId);
93
+
94
+ if (!session) {
95
+ return new Response('Not found', { status: 404 });
96
+ }
97
+
98
+ return new Response(JSON.stringify({
99
+ sessionId: session.sessionId,
100
+ publicKey: session.publicKey,
101
+ status: session.status,
102
+ encryptedPayload: session.encryptedPayload,
103
+ approverDid: session.approverDid,
104
+ }), { status: 200 });
105
+ }
106
+
107
+ // POST /qr-login/session/:id/approve
108
+ const approveMatch = urlStr.match(/\/qr-login\/session\/([^/]+)\/approve$/);
109
+
110
+ if (approveMatch && method === 'POST') {
111
+ const sessionId = approveMatch[1]!;
112
+ const session = sessions.get(sessionId);
113
+
114
+ if (!session) {
115
+ return new Response('Not found', { status: 404 });
116
+ }
117
+
118
+ session.status = 'approved';
119
+ session.encryptedPayload = body.encryptedPayload;
120
+ session.approverDid = body.approverDid;
121
+
122
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
123
+ }
124
+
125
+ return new Response('Not found', { status: 404 });
126
+ });
127
+
128
+ describe('QR Login Client', async () => {
129
+ const supported = await supportsX25519();
130
+
131
+ beforeEach(() => {
132
+ sessions = new Map();
133
+ shortCodeMap = new Map();
134
+ vi.stubGlobal('fetch', mockFetch);
135
+ mockFetch.mockClear();
136
+ });
137
+
138
+ afterEach(() => {
139
+ vi.unstubAllGlobals();
140
+ });
141
+
142
+ // -----------------------------------------------------------------------
143
+ // Session creation
144
+ // -----------------------------------------------------------------------
145
+
146
+ it.skipIf(!supported)('createQrLoginSession returns session info and ephemeral keypair', async () => {
147
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
148
+ serverUrl: SERVER_URL,
149
+ });
150
+
151
+ expect(session.sessionId).toBeTruthy();
152
+ expect(session.shortCode).toMatch(/^\d{8}$/);
153
+ expect(session.expiresInSeconds).toBe(120);
154
+
155
+ expect(ephemeralKeypair.publicKey).toBeTruthy();
156
+ expect(ephemeralKeypair.privateKey.type).toBe('private');
157
+
158
+ expect(qrPayload.sessionId).toBe(session.sessionId);
159
+ expect(qrPayload.publicKey).toBe(ephemeralKeypair.publicKey);
160
+ expect(qrPayload.serverUrl).toBe(SERVER_URL);
161
+ });
162
+
163
+ // -----------------------------------------------------------------------
164
+ // Session info retrieval
165
+ // -----------------------------------------------------------------------
166
+
167
+ it.skipIf(!supported)('getQrLoginSessionInfo retrieves session by ID', async () => {
168
+ const { session } = await createQrLoginSession({ serverUrl: SERVER_URL });
169
+
170
+ const info = await getQrLoginSessionInfo({ serverUrl: SERVER_URL }, session.sessionId);
171
+
172
+ expect(info.sessionId).toBe(session.sessionId);
173
+ expect(info.status).toBe('pending');
174
+ expect(info.publicKey).toBeTruthy();
175
+ });
176
+
177
+ it.skipIf(!supported)('getQrLoginSessionInfo retrieves session by short code', async () => {
178
+ const { session } = await createQrLoginSession({ serverUrl: SERVER_URL });
179
+
180
+ const info = await getQrLoginSessionInfo({ serverUrl: SERVER_URL }, session.shortCode);
181
+
182
+ expect(info.sessionId).toBe(session.sessionId);
183
+ });
184
+
185
+ // -----------------------------------------------------------------------
186
+ // Full flow: create → approve → poll → decrypt
187
+ // -----------------------------------------------------------------------
188
+
189
+ it.skipIf(!supported)('full cross-device flow: Device B creates, Device A approves, Device B decrypts', async () => {
190
+ const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
191
+ const approverDid = 'did:key:zApprover123';
192
+
193
+ // --- Device B: create session ---
194
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
195
+ serverUrl: SERVER_URL,
196
+ });
197
+
198
+ // --- Device A: scan QR, get session info ---
199
+ const sessionInfo = await getQrLoginSessionInfo(
200
+ { serverUrl: SERVER_URL },
201
+ qrPayload.sessionId
202
+ );
203
+
204
+ expect(sessionInfo.status).toBe('pending');
205
+
206
+ // --- Device A: approve (encrypt + push) ---
207
+ await approveQrLoginSession(
208
+ { serverUrl: SERVER_URL },
209
+ session.sessionId,
210
+ deviceShare,
211
+ approverDid,
212
+ sessionInfo.publicKey
213
+ );
214
+
215
+ // --- Device B: poll and decrypt ---
216
+ const result = await pollQrLoginSession(
217
+ { serverUrl: SERVER_URL },
218
+ session.sessionId,
219
+ ephemeralKeypair.privateKey
220
+ );
221
+
222
+ expect(result.status).toBe('approved');
223
+
224
+ if (result.status === 'approved') {
225
+ expect(result.deviceShare).toBe(deviceShare);
226
+ expect(result.approverDid).toBe(approverDid);
227
+ }
228
+ });
229
+
230
+ // -----------------------------------------------------------------------
231
+ // Polling
232
+ // -----------------------------------------------------------------------
233
+
234
+ it.skipIf(!supported)('pollQrLoginSession returns pending when not yet approved', async () => {
235
+ const { session, ephemeralKeypair } = await createQrLoginSession({
236
+ serverUrl: SERVER_URL,
237
+ });
238
+
239
+ const result = await pollQrLoginSession(
240
+ { serverUrl: SERVER_URL },
241
+ session.sessionId,
242
+ ephemeralKeypair.privateKey
243
+ );
244
+
245
+ expect(result.status).toBe('pending');
246
+ });
247
+
248
+ it.skipIf(!supported)('pollUntilApproved resolves when session is approved', async () => {
249
+ const deviceShare = 'share-for-polling-test';
250
+ const approverDid = 'did:key:zPoller';
251
+
252
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
253
+ serverUrl: SERVER_URL,
254
+ });
255
+
256
+ // Simulate Device A approving after a short delay
257
+ setTimeout(async () => {
258
+ const info = await getQrLoginSessionInfo(
259
+ { serverUrl: SERVER_URL },
260
+ qrPayload.sessionId
261
+ );
262
+
263
+ await approveQrLoginSession(
264
+ { serverUrl: SERVER_URL },
265
+ session.sessionId,
266
+ deviceShare,
267
+ approverDid,
268
+ info.publicKey
269
+ );
270
+ }, 50);
271
+
272
+ const result = await pollUntilApproved(
273
+ { serverUrl: SERVER_URL },
274
+ session.sessionId,
275
+ ephemeralKeypair.privateKey,
276
+ { intervalMs: 30, timeoutMs: 5000 }
277
+ );
278
+
279
+ expect(result.deviceShare).toBe(deviceShare);
280
+ expect(result.approverDid).toBe(approverDid);
281
+ });
282
+
283
+ it.skipIf(!supported)('pollUntilApproved can be aborted', async () => {
284
+ const { session, ephemeralKeypair } = await createQrLoginSession({
285
+ serverUrl: SERVER_URL,
286
+ });
287
+
288
+ const controller = new AbortController();
289
+
290
+ // Abort after 100ms
291
+ setTimeout(() => controller.abort(), 100);
292
+
293
+ await expect(
294
+ pollUntilApproved(
295
+ { serverUrl: SERVER_URL },
296
+ session.sessionId,
297
+ ephemeralKeypair.privateKey,
298
+ { intervalMs: 30, timeoutMs: 10000, signal: controller.signal }
299
+ )
300
+ ).rejects.toThrow('aborted');
301
+ });
302
+
303
+ // -----------------------------------------------------------------------
304
+ // Account hint in encrypted payload
305
+ // -----------------------------------------------------------------------
306
+
307
+ it.skipIf(!supported)('full flow with accountHint: hint is encrypted and returned to Device B', async () => {
308
+ const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
309
+ const approverDid = 'did:key:zHintTest';
310
+ const accountHint = 'user@example.com';
311
+
312
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
313
+ serverUrl: SERVER_URL,
314
+ });
315
+
316
+ const sessionInfo = await getQrLoginSessionInfo(
317
+ { serverUrl: SERVER_URL },
318
+ qrPayload.sessionId
319
+ );
320
+
321
+ // Device A approves WITH accountHint
322
+ await approveQrLoginSession(
323
+ { serverUrl: SERVER_URL },
324
+ session.sessionId,
325
+ deviceShare,
326
+ approverDid,
327
+ sessionInfo.publicKey,
328
+ accountHint
329
+ );
330
+
331
+ const result = await pollQrLoginSession(
332
+ { serverUrl: SERVER_URL },
333
+ session.sessionId,
334
+ ephemeralKeypair.privateKey
335
+ );
336
+
337
+ expect(result.status).toBe('approved');
338
+
339
+ if (result.status === 'approved') {
340
+ expect(result.deviceShare).toBe(deviceShare);
341
+ expect(result.approverDid).toBe(approverDid);
342
+ expect(result.accountHint).toBe(accountHint);
343
+ }
344
+ });
345
+
346
+ // -----------------------------------------------------------------------
347
+ // shareVersion in encrypted envelope
348
+ // -----------------------------------------------------------------------
349
+
350
+ it.skipIf(!supported)('full flow with shareVersion: version is encrypted and returned to Device B', async () => {
351
+ const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
352
+ const approverDid = 'did:key:zVersionTest';
353
+ const shareVersion = 5;
354
+
355
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
356
+ serverUrl: SERVER_URL,
357
+ });
358
+
359
+ const sessionInfo = await getQrLoginSessionInfo(
360
+ { serverUrl: SERVER_URL },
361
+ qrPayload.sessionId
362
+ );
363
+
364
+ // Device A approves WITH shareVersion
365
+ await approveQrLoginSession(
366
+ { serverUrl: SERVER_URL },
367
+ session.sessionId,
368
+ deviceShare,
369
+ approverDid,
370
+ sessionInfo.publicKey,
371
+ undefined, // no accountHint
372
+ shareVersion
373
+ );
374
+
375
+ const result = await pollQrLoginSession(
376
+ { serverUrl: SERVER_URL },
377
+ session.sessionId,
378
+ ephemeralKeypair.privateKey
379
+ );
380
+
381
+ expect(result.status).toBe('approved');
382
+
383
+ if (result.status === 'approved') {
384
+ expect(result.deviceShare).toBe(deviceShare);
385
+ expect(result.approverDid).toBe(approverDid);
386
+ expect(result.shareVersion).toBe(shareVersion);
387
+ }
388
+ });
389
+
390
+ it.skipIf(!supported)('shareVersion is undefined when not provided by Device A', async () => {
391
+ const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
392
+ const approverDid = 'did:key:zNoVersion';
393
+
394
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
395
+ serverUrl: SERVER_URL,
396
+ });
397
+
398
+ const sessionInfo = await getQrLoginSessionInfo(
399
+ { serverUrl: SERVER_URL },
400
+ qrPayload.sessionId
401
+ );
402
+
403
+ // Device A approves WITHOUT shareVersion
404
+ await approveQrLoginSession(
405
+ { serverUrl: SERVER_URL },
406
+ session.sessionId,
407
+ deviceShare,
408
+ approverDid,
409
+ sessionInfo.publicKey
410
+ // no accountHint, no shareVersion
411
+ );
412
+
413
+ const result = await pollQrLoginSession(
414
+ { serverUrl: SERVER_URL },
415
+ session.sessionId,
416
+ ephemeralKeypair.privateKey
417
+ );
418
+
419
+ expect(result.status).toBe('approved');
420
+
421
+ if (result.status === 'approved') {
422
+ expect(result.deviceShare).toBe(deviceShare);
423
+ expect(result.shareVersion).toBeUndefined();
424
+ }
425
+ });
426
+
427
+ it.skipIf(!supported)('full flow with both accountHint and shareVersion', async () => {
428
+ const deviceShare = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab';
429
+ const approverDid = 'did:key:zBothFields';
430
+ const accountHint = 'user@example.com';
431
+ const shareVersion = 3;
432
+
433
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
434
+ serverUrl: SERVER_URL,
435
+ });
436
+
437
+ const sessionInfo = await getQrLoginSessionInfo(
438
+ { serverUrl: SERVER_URL },
439
+ qrPayload.sessionId
440
+ );
441
+
442
+ await approveQrLoginSession(
443
+ { serverUrl: SERVER_URL },
444
+ session.sessionId,
445
+ deviceShare,
446
+ approverDid,
447
+ sessionInfo.publicKey,
448
+ accountHint,
449
+ shareVersion
450
+ );
451
+
452
+ const result = await pollQrLoginSession(
453
+ { serverUrl: SERVER_URL },
454
+ session.sessionId,
455
+ ephemeralKeypair.privateKey
456
+ );
457
+
458
+ expect(result.status).toBe('approved');
459
+
460
+ if (result.status === 'approved') {
461
+ expect(result.deviceShare).toBe(deviceShare);
462
+ expect(result.approverDid).toBe(approverDid);
463
+ expect(result.accountHint).toBe(accountHint);
464
+ expect(result.shareVersion).toBe(shareVersion);
465
+ }
466
+ });
467
+
468
+ it.skipIf(!supported)('pollUntilApproved returns shareVersion', async () => {
469
+ const deviceShare = 'share-for-poll-version';
470
+ const approverDid = 'did:key:zPollVersion';
471
+ const shareVersion = 7;
472
+
473
+ const { session, ephemeralKeypair, qrPayload } = await createQrLoginSession({
474
+ serverUrl: SERVER_URL,
475
+ });
476
+
477
+ // Simulate Device A approving after a short delay
478
+ setTimeout(async () => {
479
+ const info = await getQrLoginSessionInfo(
480
+ { serverUrl: SERVER_URL },
481
+ qrPayload.sessionId
482
+ );
483
+
484
+ await approveQrLoginSession(
485
+ { serverUrl: SERVER_URL },
486
+ session.sessionId,
487
+ deviceShare,
488
+ approverDid,
489
+ info.publicKey,
490
+ undefined,
491
+ shareVersion
492
+ );
493
+ }, 50);
494
+
495
+ const result = await pollUntilApproved(
496
+ { serverUrl: SERVER_URL },
497
+ session.sessionId,
498
+ ephemeralKeypair.privateKey,
499
+ { intervalMs: 30, timeoutMs: 5000 }
500
+ );
501
+
502
+ expect(result.deviceShare).toBe(deviceShare);
503
+ expect(result.approverDid).toBe(approverDid);
504
+ expect(result.shareVersion).toBe(shareVersion);
505
+ });
506
+
507
+ // -----------------------------------------------------------------------
508
+ // Short code flow (typed code instead of QR scan)
509
+ // -----------------------------------------------------------------------
510
+
511
+ it.skipIf(!supported)('full flow via short code (phone → desktop)', async () => {
512
+ const deviceShare = 'share-via-short-code';
513
+ const approverDid = 'did:key:zDesktopUser';
514
+
515
+ // Device B (phone) creates session
516
+ const { session, ephemeralKeypair } = await createQrLoginSession({
517
+ serverUrl: SERVER_URL,
518
+ });
519
+
520
+ // Device A (desktop) types in the 6-digit code
521
+ const info = await getQrLoginSessionInfo(
522
+ { serverUrl: SERVER_URL },
523
+ session.shortCode
524
+ );
525
+
526
+ // Device A approves
527
+ await approveQrLoginSession(
528
+ { serverUrl: SERVER_URL },
529
+ info.sessionId,
530
+ deviceShare,
531
+ approverDid,
532
+ info.publicKey
533
+ );
534
+
535
+ // Device B polls and decrypts
536
+ const result = await pollQrLoginSession(
537
+ { serverUrl: SERVER_URL },
538
+ session.sessionId,
539
+ ephemeralKeypair.privateKey
540
+ );
541
+
542
+ expect(result.status).toBe('approved');
543
+
544
+ if (result.status === 'approved') {
545
+ expect(result.deviceShare).toBe(deviceShare);
546
+ }
547
+ });
548
+ });