@every-app/sdk 0.0.3 → 0.0.5

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,796 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // Mock import.meta.env before any imports
4
+ vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
5
+
6
+ describe("SessionManager", () => {
7
+ let SessionManager: typeof import("./session-manager").SessionManager;
8
+ let mockPostMessage: ReturnType<typeof vi.fn>;
9
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
10
+ let addEventListenerSpy: ReturnType<typeof vi.fn>;
11
+ let removeEventListenerSpy: ReturnType<typeof vi.fn>;
12
+
13
+ // Helper function to simulate token responses from parent window
14
+ function simulateTokenResponse(options: {
15
+ token?: string;
16
+ error?: string;
17
+ expiresAt?: string;
18
+ requestId?: string;
19
+ origin?: string;
20
+ type?: string;
21
+ }) {
22
+ if (!messageHandler) {
23
+ throw new Error("messageHandler not initialized");
24
+ }
25
+ messageHandler({
26
+ origin: options.origin ?? "https://gateway.example.com",
27
+ data: {
28
+ type: options.type ?? "SESSION_TOKEN_RESPONSE",
29
+ requestId: options.requestId ?? "test-uuid-123",
30
+ ...(options.token !== undefined && { token: options.token }),
31
+ ...(options.error !== undefined && { error: options.error }),
32
+ ...(options.expiresAt !== undefined && {
33
+ expiresAt: options.expiresAt,
34
+ }),
35
+ },
36
+ } as MessageEvent);
37
+ }
38
+
39
+ // Helper function to simulate malformed message events (for security edge case testing)
40
+ function simulateMalformedMessage(origin: string | null, data: unknown) {
41
+ if (!messageHandler) {
42
+ throw new Error("messageHandler not initialized");
43
+ }
44
+ messageHandler({
45
+ origin,
46
+ data,
47
+ } as MessageEvent);
48
+ }
49
+
50
+ beforeEach(async () => {
51
+ vi.resetModules();
52
+ vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
53
+
54
+ // Mock window and postMessage
55
+ mockPostMessage = vi.fn();
56
+ messageHandler = null;
57
+ addEventListenerSpy = vi.fn((event: string, handler: Function) => {
58
+ if (event === "message") {
59
+ messageHandler = handler as (event: MessageEvent) => void;
60
+ }
61
+ });
62
+ removeEventListenerSpy = vi.fn();
63
+
64
+ vi.stubGlobal("window", {
65
+ addEventListener: addEventListenerSpy,
66
+ removeEventListener: removeEventListenerSpy,
67
+ parent: {
68
+ postMessage: mockPostMessage,
69
+ },
70
+ });
71
+
72
+ vi.stubGlobal("crypto", {
73
+ randomUUID: () => "test-uuid-123",
74
+ });
75
+
76
+ // Import fresh module
77
+ const module = await import("./session-manager");
78
+ SessionManager = module.SessionManager;
79
+ });
80
+
81
+ afterEach(() => {
82
+ vi.useRealTimers(); // Safe to call even if real timers are active
83
+ vi.unstubAllEnvs();
84
+ vi.unstubAllGlobals();
85
+ vi.restoreAllMocks();
86
+ messageHandler = null;
87
+ });
88
+
89
+ describe("constructor", () => {
90
+ it("throws error when appId is not provided", () => {
91
+ expect(() => new SessionManager({ appId: "" })).toThrow(
92
+ "[SessionManager] appId is required.",
93
+ );
94
+ });
95
+
96
+ it("throws error when VITE_GATEWAY_URL is not set", async () => {
97
+ vi.resetModules();
98
+ vi.stubEnv("VITE_GATEWAY_URL", "");
99
+
100
+ const module = await import("./session-manager");
101
+
102
+ expect(() => new module.SessionManager({ appId: "test-app" })).toThrow(
103
+ "[SessionManager] VITE_GATEWAY_URL env var is required.",
104
+ );
105
+ });
106
+
107
+ it("throws error for invalid gateway URL", async () => {
108
+ vi.resetModules();
109
+ vi.stubEnv("VITE_GATEWAY_URL", "not-a-valid-url");
110
+
111
+ const module = await import("./session-manager");
112
+
113
+ expect(() => new module.SessionManager({ appId: "test-app" })).toThrow(
114
+ "[SessionManager] Invalid gateway URL: not-a-valid-url",
115
+ );
116
+ });
117
+
118
+ it("successfully creates instance with valid config", () => {
119
+ const manager = new SessionManager({ appId: "test-app" });
120
+
121
+ expect(manager.appId).toBe("test-app");
122
+ expect(manager.parentOrigin).toBe("https://gateway.example.com");
123
+ });
124
+ });
125
+
126
+ describe("getTokenState", () => {
127
+ it("returns NO_TOKEN when no token exists", () => {
128
+ const manager = new SessionManager({ appId: "test-app" });
129
+
130
+ const state = manager.getTokenState();
131
+
132
+ expect(state.status).toBe("NO_TOKEN");
133
+ expect(state.token).toBeNull();
134
+ });
135
+
136
+ it("returns VALID when token exists and is not expiring", async () => {
137
+ const manager = new SessionManager({ appId: "test-app" });
138
+
139
+ const tokenPromise = manager.requestNewToken();
140
+
141
+ simulateTokenResponse({
142
+ token: "valid-token",
143
+ expiresAt: new Date(Date.now() + 60000).toISOString(), // Expires in 60 seconds
144
+ });
145
+
146
+ await tokenPromise;
147
+
148
+ const state = manager.getTokenState();
149
+
150
+ expect(state.status).toBe("VALID");
151
+ expect(state.token).toBe("valid-token");
152
+ });
153
+
154
+ it("returns EXPIRED when token is past expiration", async () => {
155
+ const manager = new SessionManager({ appId: "test-app" });
156
+
157
+ const tokenPromise = manager.requestNewToken();
158
+
159
+ simulateTokenResponse({
160
+ token: "expired-token",
161
+ expiresAt: new Date(Date.now() - 1000).toISOString(), // Already expired
162
+ });
163
+
164
+ await tokenPromise;
165
+
166
+ const state = manager.getTokenState();
167
+
168
+ expect(state.status).toBe("EXPIRED");
169
+ expect(state.token).toBe("expired-token");
170
+ });
171
+
172
+ it("returns REFRESHING when token request is in progress", async () => {
173
+ const manager = new SessionManager({ appId: "test-app" });
174
+
175
+ // Start a request but don't complete it yet
176
+ const tokenPromise = manager.requestNewToken();
177
+
178
+ // Check state while request is pending
179
+ const state = manager.getTokenState();
180
+
181
+ expect(state.status).toBe("REFRESHING");
182
+ expect(state.token).toBeNull();
183
+
184
+ // Complete the request to clean up
185
+ simulateTokenResponse({
186
+ token: "token",
187
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
188
+ });
189
+
190
+ await tokenPromise;
191
+ });
192
+ });
193
+
194
+ describe("requestNewToken", () => {
195
+ it("sends correct postMessage to parent", async () => {
196
+ const manager = new SessionManager({ appId: "my-app" });
197
+
198
+ const tokenPromise = manager.requestNewToken();
199
+
200
+ expect(mockPostMessage).toHaveBeenCalledWith(
201
+ {
202
+ type: "SESSION_TOKEN_REQUEST",
203
+ requestId: "test-uuid-123",
204
+ appId: "my-app",
205
+ },
206
+ "https://gateway.example.com",
207
+ );
208
+
209
+ // Complete the promise
210
+ simulateTokenResponse({
211
+ token: "new-token",
212
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
213
+ });
214
+
215
+ await tokenPromise;
216
+ });
217
+
218
+ it("rejects when response contains error", async () => {
219
+ const manager = new SessionManager({ appId: "test-app" });
220
+
221
+ const tokenPromise = manager.requestNewToken();
222
+
223
+ simulateTokenResponse({ error: "Token request denied" });
224
+
225
+ await expect(tokenPromise).rejects.toThrow("Token request denied");
226
+ });
227
+
228
+ it("rejects when response has no token", async () => {
229
+ const manager = new SessionManager({ appId: "test-app" });
230
+
231
+ const tokenPromise = manager.requestNewToken();
232
+
233
+ // Simulate response with no token field
234
+ simulateTokenResponse({});
235
+
236
+ await expect(tokenPromise).rejects.toThrow("No token in response");
237
+ });
238
+
239
+ it("times out after MESSAGE_TIMEOUT_MS", async () => {
240
+ vi.useFakeTimers();
241
+
242
+ const manager = new SessionManager({ appId: "test-app" });
243
+
244
+ const tokenPromise = manager.requestNewToken();
245
+
246
+ // Fast-forward past the timeout (5000ms)
247
+ vi.advanceTimersByTime(5001);
248
+
249
+ await expect(tokenPromise).rejects.toThrow(
250
+ "Token request timeout - parent did not respond",
251
+ );
252
+ });
253
+
254
+ it("ignores messages from wrong origin (security-critical)", async () => {
255
+ vi.useFakeTimers();
256
+
257
+ const manager = new SessionManager({ appId: "test-app" });
258
+
259
+ const tokenPromise = manager.requestNewToken();
260
+
261
+ // Send message from wrong origin - this should be ignored
262
+ simulateTokenResponse({
263
+ token: "malicious-token",
264
+ origin: "https://malicious.example.com",
265
+ });
266
+
267
+ // Should still timeout because the message was ignored
268
+ vi.advanceTimersByTime(5001);
269
+
270
+ await expect(tokenPromise).rejects.toThrow(
271
+ "Token request timeout - parent did not respond",
272
+ );
273
+ });
274
+
275
+ it("ignores messages with wrong requestId (security-critical)", async () => {
276
+ vi.useFakeTimers();
277
+
278
+ const manager = new SessionManager({ appId: "test-app" });
279
+
280
+ const tokenPromise = manager.requestNewToken();
281
+
282
+ // Send message with wrong requestId - this should be ignored
283
+ simulateTokenResponse({
284
+ token: "wrong-token",
285
+ requestId: "wrong-request-id",
286
+ });
287
+
288
+ // Should still timeout
289
+ vi.advanceTimersByTime(5001);
290
+
291
+ await expect(tokenPromise).rejects.toThrow(
292
+ "Token request timeout - parent did not respond",
293
+ );
294
+ });
295
+
296
+ it("ignores messages with wrong type", async () => {
297
+ vi.useFakeTimers();
298
+
299
+ const manager = new SessionManager({ appId: "test-app" });
300
+
301
+ const tokenPromise = manager.requestNewToken();
302
+
303
+ // Send message with wrong type
304
+ simulateTokenResponse({
305
+ token: "wrong-token",
306
+ type: "WRONG_MESSAGE_TYPE",
307
+ });
308
+
309
+ vi.advanceTimersByTime(5001);
310
+
311
+ await expect(tokenPromise).rejects.toThrow(
312
+ "Token request timeout - parent did not respond",
313
+ );
314
+ });
315
+
316
+ it("deduplicates concurrent token requests", async () => {
317
+ const manager = new SessionManager({ appId: "test-app" });
318
+
319
+ // Start two concurrent requests
320
+ const promise1 = manager.requestNewToken();
321
+ const promise2 = manager.requestNewToken();
322
+
323
+ // Should only send one postMessage
324
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
325
+
326
+ // Complete the request
327
+ simulateTokenResponse({
328
+ token: "shared-token",
329
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
330
+ });
331
+
332
+ // Both promises should resolve with the same token
333
+ const [token1, token2] = await Promise.all([promise1, promise2]);
334
+
335
+ expect(token1).toBe("shared-token");
336
+ expect(token2).toBe("shared-token");
337
+ });
338
+ });
339
+
340
+ describe("getToken", () => {
341
+ it("requests new token when no token exists", async () => {
342
+ const manager = new SessionManager({ appId: "test-app" });
343
+
344
+ const tokenPromise = manager.getToken();
345
+
346
+ simulateTokenResponse({
347
+ token: "new-token",
348
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
349
+ });
350
+
351
+ const token = await tokenPromise;
352
+
353
+ expect(token).toBe("new-token");
354
+ expect(mockPostMessage).toHaveBeenCalled();
355
+ });
356
+
357
+ it("returns cached token when not expiring soon", async () => {
358
+ const manager = new SessionManager({ appId: "test-app" });
359
+
360
+ // First, get a token
361
+ const firstPromise = manager.getToken();
362
+
363
+ simulateTokenResponse({
364
+ token: "cached-token",
365
+ expiresAt: new Date(Date.now() + 60000).toISOString(), // 60 seconds, well above 10s buffer
366
+ });
367
+
368
+ await firstPromise;
369
+
370
+ // Reset mock to check if second call makes new request
371
+ mockPostMessage.mockClear();
372
+
373
+ // Get token again - should use cache
374
+ const cachedToken = await manager.getToken();
375
+
376
+ expect(cachedToken).toBe("cached-token");
377
+ expect(mockPostMessage).not.toHaveBeenCalled();
378
+ });
379
+
380
+ it("requests new token when token is expiring soon", async () => {
381
+ const manager = new SessionManager({ appId: "test-app" });
382
+
383
+ // Get initial token that's about to expire
384
+ const firstPromise = manager.getToken();
385
+
386
+ simulateTokenResponse({
387
+ token: "expiring-token",
388
+ expiresAt: new Date(Date.now() + 5000).toISOString(), // 5 seconds, under 10s buffer
389
+ });
390
+
391
+ await firstPromise;
392
+
393
+ mockPostMessage.mockClear();
394
+
395
+ // Request new UUID for second request
396
+ vi.stubGlobal("crypto", {
397
+ randomUUID: () => "second-uuid-456",
398
+ });
399
+
400
+ // Get token again - should request new token since current is expiring soon
401
+ const secondPromise = manager.getToken();
402
+
403
+ expect(mockPostMessage).toHaveBeenCalled();
404
+
405
+ simulateTokenResponse({
406
+ token: "fresh-token",
407
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
408
+ requestId: "second-uuid-456",
409
+ });
410
+
411
+ const newToken = await secondPromise;
412
+
413
+ expect(newToken).toBe("fresh-token");
414
+ });
415
+ });
416
+
417
+ describe("getUser", () => {
418
+ it("returns null when no token exists", () => {
419
+ const manager = new SessionManager({ appId: "test-app" });
420
+
421
+ const user = manager.getUser();
422
+
423
+ expect(user).toBeNull();
424
+ });
425
+
426
+ it("returns null for malformed JWT", async () => {
427
+ const manager = new SessionManager({ appId: "test-app" });
428
+
429
+ const tokenPromise = manager.requestNewToken();
430
+
431
+ simulateTokenResponse({
432
+ token: "not-a-valid-jwt",
433
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
434
+ });
435
+
436
+ await tokenPromise;
437
+
438
+ const user = manager.getUser();
439
+
440
+ expect(user).toBeNull();
441
+ });
442
+
443
+ it("returns null for JWT with only two parts", async () => {
444
+ const manager = new SessionManager({ appId: "test-app" });
445
+
446
+ const tokenPromise = manager.requestNewToken();
447
+
448
+ simulateTokenResponse({
449
+ token: "header.payload", // Missing signature
450
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
451
+ });
452
+
453
+ await tokenPromise;
454
+
455
+ const user = manager.getUser();
456
+
457
+ expect(user).toBeNull();
458
+ });
459
+
460
+ it("returns null for JWT with invalid base64 payload", async () => {
461
+ const manager = new SessionManager({ appId: "test-app" });
462
+
463
+ const tokenPromise = manager.requestNewToken();
464
+
465
+ // Invalid base64 that will throw in atob()
466
+ simulateTokenResponse({
467
+ token: "header.!!!invalid-base64!!!.signature",
468
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
469
+ });
470
+
471
+ await tokenPromise;
472
+
473
+ const user = manager.getUser();
474
+
475
+ expect(user).toBeNull();
476
+ });
477
+
478
+ it("returns null for JWT with missing sub claim", async () => {
479
+ const manager = new SessionManager({ appId: "test-app" });
480
+
481
+ // Create a JWT-like token with no sub claim
482
+ const payload = btoa(JSON.stringify({ email: "test@example.com" }));
483
+ const fakeToken = `header.${payload}.signature`;
484
+
485
+ const tokenPromise = manager.requestNewToken();
486
+
487
+ simulateTokenResponse({
488
+ token: fakeToken,
489
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
490
+ });
491
+
492
+ await tokenPromise;
493
+
494
+ const user = manager.getUser();
495
+
496
+ expect(user).toBeNull();
497
+ });
498
+
499
+ it("extracts user info from valid JWT", async () => {
500
+ const manager = new SessionManager({ appId: "test-app" });
501
+
502
+ // Create a valid JWT-like token
503
+ const payload = btoa(
504
+ JSON.stringify({
505
+ sub: "user-123",
506
+ email: "test@example.com",
507
+ }),
508
+ );
509
+ const fakeToken = `header.${payload}.signature`;
510
+
511
+ const tokenPromise = manager.requestNewToken();
512
+
513
+ simulateTokenResponse({
514
+ token: fakeToken,
515
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
516
+ });
517
+
518
+ await tokenPromise;
519
+
520
+ const user = manager.getUser();
521
+
522
+ expect(user).toEqual({
523
+ userId: "user-123",
524
+ email: "test@example.com",
525
+ });
526
+ });
527
+
528
+ it("handles missing email in JWT", async () => {
529
+ const manager = new SessionManager({ appId: "test-app" });
530
+
531
+ const payload = btoa(
532
+ JSON.stringify({
533
+ sub: "user-123",
534
+ // No email field
535
+ }),
536
+ );
537
+ const fakeToken = `header.${payload}.signature`;
538
+
539
+ const tokenPromise = manager.requestNewToken();
540
+
541
+ simulateTokenResponse({
542
+ token: fakeToken,
543
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
544
+ });
545
+
546
+ await tokenPromise;
547
+
548
+ const user = manager.getUser();
549
+
550
+ expect(user).toEqual({
551
+ userId: "user-123",
552
+ email: "", // Defaults to empty string
553
+ });
554
+ });
555
+ });
556
+
557
+ describe("default token lifetime", () => {
558
+ it("uses DEFAULT_TOKEN_LIFETIME_MS when expiresAt not provided", async () => {
559
+ const manager = new SessionManager({ appId: "test-app" });
560
+
561
+ const tokenPromise = manager.requestNewToken();
562
+
563
+ // Simulate response with no expiresAt field
564
+ simulateTokenResponse({
565
+ token: "token-without-expiry",
566
+ });
567
+
568
+ await tokenPromise;
569
+
570
+ const state = manager.getTokenState();
571
+
572
+ // Token should be valid (default lifetime is 60000ms)
573
+ expect(state.status).toBe("VALID");
574
+ });
575
+ });
576
+
577
+ describe("security edge cases", () => {
578
+ it("cleans up event listener on successful response", async () => {
579
+ const manager = new SessionManager({ appId: "test-app" });
580
+
581
+ const tokenPromise = manager.requestNewToken();
582
+
583
+ simulateTokenResponse({
584
+ token: "valid-token",
585
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
586
+ });
587
+
588
+ await tokenPromise;
589
+
590
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
591
+ "message",
592
+ expect.any(Function),
593
+ );
594
+ });
595
+
596
+ it("cleans up event listener on timeout", async () => {
597
+ vi.useFakeTimers();
598
+
599
+ const manager = new SessionManager({ appId: "test-app" });
600
+
601
+ const tokenPromise = manager.requestNewToken();
602
+
603
+ vi.advanceTimersByTime(5001);
604
+
605
+ await expect(tokenPromise).rejects.toThrow(
606
+ "Token request timeout - parent did not respond",
607
+ );
608
+
609
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
610
+ "message",
611
+ expect.any(Function),
612
+ );
613
+ });
614
+
615
+ it("cleans up event listener on error response", async () => {
616
+ const manager = new SessionManager({ appId: "test-app" });
617
+
618
+ const tokenPromise = manager.requestNewToken();
619
+
620
+ simulateTokenResponse({ error: "Access denied" });
621
+
622
+ await expect(tokenPromise).rejects.toThrow("Access denied");
623
+
624
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
625
+ "message",
626
+ expect.any(Function),
627
+ );
628
+ });
629
+
630
+ it("ignores messages with null origin (sandboxed iframes, file:// URLs)", async () => {
631
+ vi.useFakeTimers();
632
+
633
+ const manager = new SessionManager({ appId: "test-app" });
634
+
635
+ const tokenPromise = manager.requestNewToken();
636
+
637
+ // Null origin occurs with sandboxed iframes and file:// URLs
638
+ simulateMalformedMessage(null, {
639
+ type: "SESSION_TOKEN_RESPONSE",
640
+ requestId: "test-uuid-123",
641
+ token: "suspicious-token",
642
+ });
643
+
644
+ // Should timeout because null origin doesn't match
645
+ vi.advanceTimersByTime(5001);
646
+
647
+ await expect(tokenPromise).rejects.toThrow(
648
+ "Token request timeout - parent did not respond",
649
+ );
650
+ });
651
+
652
+ it("ignores messages with null event.data", async () => {
653
+ vi.useFakeTimers();
654
+
655
+ const manager = new SessionManager({ appId: "test-app" });
656
+
657
+ const tokenPromise = manager.requestNewToken();
658
+
659
+ // Send message with null data - should not crash
660
+ simulateMalformedMessage("https://gateway.example.com", null);
661
+
662
+ vi.advanceTimersByTime(5001);
663
+
664
+ await expect(tokenPromise).rejects.toThrow(
665
+ "Token request timeout - parent did not respond",
666
+ );
667
+ });
668
+
669
+ it("ignores messages with primitive event.data", async () => {
670
+ vi.useFakeTimers();
671
+
672
+ const manager = new SessionManager({ appId: "test-app" });
673
+
674
+ const tokenPromise = manager.requestNewToken();
675
+
676
+ // Send message with string data - should not crash
677
+ simulateMalformedMessage("https://gateway.example.com", "not an object");
678
+
679
+ // Send message with number data
680
+ simulateMalformedMessage("https://gateway.example.com", 12345);
681
+
682
+ vi.advanceTimersByTime(5001);
683
+
684
+ await expect(tokenPromise).rejects.toThrow(
685
+ "Token request timeout - parent did not respond",
686
+ );
687
+ });
688
+
689
+ it("ignores messages with undefined event.data", async () => {
690
+ vi.useFakeTimers();
691
+
692
+ const manager = new SessionManager({ appId: "test-app" });
693
+
694
+ const tokenPromise = manager.requestNewToken();
695
+
696
+ // Send message with undefined data - should not crash
697
+ simulateMalformedMessage("https://gateway.example.com", undefined);
698
+
699
+ vi.advanceTimersByTime(5001);
700
+
701
+ await expect(tokenPromise).rejects.toThrow(
702
+ "Token request timeout - parent did not respond",
703
+ );
704
+ });
705
+ });
706
+
707
+ describe("expiresAt handling", () => {
708
+ it("uses default lifetime when expiresAt is invalid date string", async () => {
709
+ const manager = new SessionManager({ appId: "test-app" });
710
+
711
+ const tokenPromise = manager.requestNewToken();
712
+
713
+ simulateTokenResponse({
714
+ token: "token-with-invalid-expiry",
715
+ expiresAt: "not-a-valid-date",
716
+ });
717
+
718
+ await tokenPromise;
719
+
720
+ const state = manager.getTokenState();
721
+
722
+ // Should be valid because it fell back to default lifetime (60s)
723
+ expect(state.status).toBe("VALID");
724
+ expect(state.token).toBe("token-with-invalid-expiry");
725
+ });
726
+
727
+ it("uses default lifetime when expiresAt is empty string", async () => {
728
+ const manager = new SessionManager({ appId: "test-app" });
729
+
730
+ const tokenPromise = manager.requestNewToken();
731
+
732
+ simulateTokenResponse({
733
+ token: "token-with-empty-expiry",
734
+ expiresAt: "",
735
+ });
736
+
737
+ await tokenPromise;
738
+
739
+ const state = manager.getTokenState();
740
+
741
+ // Empty string is falsy, so should use default lifetime
742
+ expect(state.status).toBe("VALID");
743
+ });
744
+ });
745
+
746
+ describe("concurrent request error handling", () => {
747
+ it("propagates error to all waiting callers when request fails", async () => {
748
+ const manager = new SessionManager({ appId: "test-app" });
749
+
750
+ // Start three concurrent requests
751
+ const promise1 = manager.requestNewToken();
752
+ const promise2 = manager.requestNewToken();
753
+ const promise3 = manager.requestNewToken();
754
+
755
+ // Should only send one postMessage (deduplication)
756
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
757
+
758
+ // Simulate error response
759
+ simulateTokenResponse({ error: "Authentication failed" });
760
+
761
+ // All three promises should reject with the same error
762
+ await expect(promise1).rejects.toThrow("Authentication failed");
763
+ await expect(promise2).rejects.toThrow("Authentication failed");
764
+ await expect(promise3).rejects.toThrow("Authentication failed");
765
+ });
766
+
767
+ it("allows new request after previous request failed", async () => {
768
+ const manager = new SessionManager({ appId: "test-app" });
769
+
770
+ // First request fails
771
+ const failedPromise = manager.requestNewToken();
772
+ simulateTokenResponse({ error: "Temporary failure" });
773
+ await expect(failedPromise).rejects.toThrow("Temporary failure");
774
+
775
+ // Reset mock and UUID for second request
776
+ mockPostMessage.mockClear();
777
+ vi.stubGlobal("crypto", {
778
+ randomUUID: () => "second-uuid-456",
779
+ });
780
+
781
+ // Second request should work
782
+ const successPromise = manager.requestNewToken();
783
+
784
+ expect(mockPostMessage).toHaveBeenCalledTimes(1);
785
+
786
+ simulateTokenResponse({
787
+ token: "success-token",
788
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
789
+ requestId: "second-uuid-456",
790
+ });
791
+
792
+ const token = await successPromise;
793
+ expect(token).toBe("success-token");
794
+ });
795
+ });
796
+ });