@iloveagents/foundry-agent 0.3.0 → 0.4.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.
Files changed (51) hide show
  1. package/README.md +16 -0
  2. package/dist/client/agui-runner.d.ts +53 -0
  3. package/dist/client/agui-runner.js +320 -0
  4. package/dist/client/runner-events.d.ts +54 -0
  5. package/dist/client/runner-events.js +1 -0
  6. package/dist/client/service-fetch.d.ts +112 -0
  7. package/dist/client/service-fetch.js +244 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +10 -0
  10. package/dist/msal/auth-config.d.ts +91 -0
  11. package/dist/msal/auth-config.js +70 -0
  12. package/dist/msal/auth-store.d.ts +95 -0
  13. package/dist/msal/auth-store.js +372 -0
  14. package/dist/msal/index.d.ts +3 -0
  15. package/dist/msal/index.js +3 -0
  16. package/dist/msal/token-fetch.d.ts +16 -0
  17. package/dist/msal/token-fetch.js +57 -0
  18. package/dist/store/citation-store.d.ts +42 -0
  19. package/dist/store/citation-store.js +14 -0
  20. package/dist/store/link-store.d.ts +29 -0
  21. package/dist/store/link-store.js +28 -0
  22. package/dist/store/streaming-status-store.d.ts +15 -0
  23. package/dist/store/streaming-status-store.js +9 -0
  24. package/dist/tools/registry.d.ts +48 -0
  25. package/dist/tools/registry.js +50 -0
  26. package/package.json +23 -9
  27. package/AGENTS.md +0 -91
  28. package/CHANGELOG.md +0 -180
  29. package/CLAUDE.md +0 -1
  30. package/src/__tests__/agui-runner.test.ts +0 -404
  31. package/src/__tests__/auth-store.test.ts +0 -596
  32. package/src/__tests__/citation-store.test.ts +0 -52
  33. package/src/__tests__/client-tool-registry.test.ts +0 -84
  34. package/src/__tests__/link-store.test.ts +0 -48
  35. package/src/__tests__/service-fetch.test.ts +0 -525
  36. package/src/__tests__/streaming-status-store.test.ts +0 -22
  37. package/src/__tests__/token-fetch.test.ts +0 -134
  38. package/src/client/agui-runner.ts +0 -382
  39. package/src/client/runner-events.ts +0 -27
  40. package/src/client/service-fetch.ts +0 -318
  41. package/src/index.ts +0 -27
  42. package/src/msal/auth-config.ts +0 -150
  43. package/src/msal/auth-store.ts +0 -517
  44. package/src/msal/index.ts +0 -14
  45. package/src/msal/token-fetch.ts +0 -68
  46. package/src/store/citation-store.ts +0 -52
  47. package/src/store/link-store.ts +0 -53
  48. package/src/store/streaming-status-store.ts +0 -21
  49. package/src/tools/registry.ts +0 -112
  50. package/tsconfig.json +0 -15
  51. package/vitest.config.ts +0 -8
@@ -1,596 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { AuthInteractionRequiredError, authStore } from "../msal/auth-store.ts";
3
- import * as authConfig from "../msal/auth-config.ts";
4
-
5
- const store = () => authStore.getState();
6
-
7
- describe("authStore", () => {
8
- beforeEach(() => {
9
- authStore.setState({ user: null, isAuthenticated: false });
10
- });
11
-
12
- it("starts unauthenticated", () => {
13
- expect(store().isAuthenticated).toBe(false);
14
- expect(store().user).toBeNull();
15
- });
16
-
17
- it("signs in with a user", () => {
18
- store().signIn({ name: "Alice", email: "alice@example.com" });
19
- expect(store().isAuthenticated).toBe(true);
20
- expect(store().user?.name).toBe("Alice");
21
- });
22
-
23
- it("signs out and clears user", () => {
24
- store().signIn({ name: "Alice", email: "alice@example.com" });
25
- store().signOut();
26
- expect(store().isAuthenticated).toBe(false);
27
- expect(store().user).toBeNull();
28
- });
29
-
30
- it("signs in with avatar", () => {
31
- store().signIn({
32
- name: "Bob",
33
- email: "bob@example.com",
34
- avatar: "https://example.com/bob.png",
35
- });
36
- expect(store().user?.avatar).toBe("https://example.com/bob.png");
37
- });
38
- });
39
-
40
- describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
41
- // Reference: https://learn.microsoft.com/entra/msal/javascript/browser/errors
42
- const config = {
43
- clientId: "test-client",
44
- authority: "https://login.microsoftonline.com/test-tenant",
45
- redirectUri: "http://localhost:8010",
46
- apiScope: "api://test-api/access_as_user",
47
- };
48
-
49
- function mockMsal(overrides: Record<string, unknown> = {}) {
50
- const account = { username: "alice@example.com", localAccountId: "alice-oid" };
51
- const mock = {
52
- initialize: vi.fn().mockResolvedValue(undefined),
53
- handleRedirectPromise: vi.fn().mockResolvedValue(null),
54
- clearCache: vi.fn().mockResolvedValue(undefined),
55
- getAllAccounts: vi.fn().mockReturnValue([account]),
56
- loginRedirect: vi.fn().mockResolvedValue(undefined),
57
- logoutRedirect: vi.fn().mockResolvedValue(undefined),
58
- setActiveAccount: vi.fn(),
59
- acquireTokenSilent: vi.fn().mockResolvedValue({ accessToken: "fresh-token" }),
60
- acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
61
- acquireTokenPopup: vi.fn().mockResolvedValue({ accessToken: "popup-token" }),
62
- ...overrides,
63
- };
64
- vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(mock);
65
- vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
66
- return mock;
67
- }
68
-
69
- async function expectInteractiveRecoveryError(
70
- promise: Promise<unknown>,
71
- expected: {
72
- code?: string;
73
- cause: unknown;
74
- messageIncludes: string[];
75
- },
76
- ) {
77
- let thrown: unknown;
78
- try {
79
- await promise;
80
- } catch (err) {
81
- thrown = err;
82
- }
83
-
84
- expect(thrown).toBeInstanceOf(AuthInteractionRequiredError);
85
- const authError = thrown as AuthInteractionRequiredError;
86
- expect(authError.code).toBe(expected.code);
87
- expect(authError.cause).toBe(expected.cause);
88
- for (const value of expected.messageIncludes) {
89
- expect(authError.message).toContain(value);
90
- }
91
- }
92
-
93
- afterEach(() => {
94
- vi.restoreAllMocks();
95
- });
96
-
97
- it("returns null without navigating when MSAL isn't configured", async () => {
98
- vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(null);
99
- vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(null);
100
- expect(await store().getAccessToken()).toBeNull();
101
- });
102
-
103
- it("returns the access token on a normal silent acquire", async () => {
104
- const msal = mockMsal();
105
- expect(await store().getAccessToken()).toBe("fresh-token");
106
- expect(msal.acquireTokenSilent).toHaveBeenCalledOnce();
107
- expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
108
- expect.objectContaining({ forceRefresh: false }),
109
- );
110
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
111
- expect(msal.loginRedirect).not.toHaveBeenCalled();
112
- });
113
-
114
- it("forwards forceRefresh to acquireTokenSilent on the 401-retry path", async () => {
115
- // The fetch interceptor passes ``{ forceRefresh: true }`` after a
116
- // protected API returns 401. MSAL must skip its local cache and round-
117
- // trip the token endpoint so we stop sending the same stale-but-cached
118
- // access token.
119
- const msal = mockMsal();
120
- expect(await store().getAccessToken("api", { forceRefresh: true })).toBe(
121
- "fresh-token",
122
- );
123
- expect(msal.acquireTokenSilent).toHaveBeenCalledWith(
124
- expect.objectContaining({ forceRefresh: true }),
125
- );
126
- });
127
-
128
- it("recoverFromHardAuthFailure evicts bad account + redirects with prompt:'login'", async () => {
129
- // When the fetch interceptor's force-refresh retry STILL gets 401,
130
- // server-side drift (audience / claims / conditional-access) means
131
- // we need a fresh interactive auth. Two non-negotiable behaviours
132
- // that prevent the "silent redirect loop":
133
- // 1. ``clearCache({ account })`` BEFORE redirect — evicts the
134
- // bad refresh-token / claims so MSAL doesn't reuse the same
135
- // poisoned material after the redirect lands.
136
- // 2. ``prompt: "login"`` on BOTH redirect primitives — forces
137
- // Entra to re-prompt for credentials instead of silently
138
- // returning the SAME stale token from its server-side SSO
139
- // session.
140
- // See GitHub #6840 and the Microsoft Identity Platform "claims
141
- // challenge" guidance.
142
- const msal = mockMsal();
143
- const reason = new Error("API returned 401 after force-refresh retry");
144
- await expectInteractiveRecoveryError(
145
- store().recoverFromHardAuthFailure(reason),
146
- {
147
- code: undefined,
148
- cause: reason,
149
- messageIncludes: ["interaction required"],
150
- },
151
- );
152
- expect(msal.clearCache).toHaveBeenCalledOnce();
153
- expect(msal.clearCache).toHaveBeenCalledWith({
154
- account: { username: "alice@example.com", localAccountId: "alice-oid" },
155
- });
156
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
157
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
158
- expect.objectContaining({ prompt: "login" }),
159
- );
160
- });
161
-
162
- it("forwards a claims-challenge payload from the failure reason to MSAL", async () => {
163
- // Continuous Access Evaluation / Conditional-Access challenge:
164
- // the fetch interceptor extracted ``claims`` from the resource
165
- // server's ``WWW-Authenticate`` header and stamped it onto the
166
- // recovery reason. The redirect must carry it through so Entra
167
- // mints a token that explicitly satisfies the challenge.
168
- const msal = mockMsal();
169
- const reason = Object.assign(
170
- new Error("API returned 401 after force-refresh retry"),
171
- { claims: "eyJjbGFpbXMiOiIuLi4ifQ" },
172
- );
173
- await expectInteractiveRecoveryError(
174
- store().recoverFromHardAuthFailure(reason),
175
- {
176
- code: undefined,
177
- cause: reason,
178
- messageIncludes: ["interaction required"],
179
- },
180
- );
181
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
182
- expect.objectContaining({
183
- claims: "eyJjbGFpbXMiOiIuLi4ifQ",
184
- prompt: "login",
185
- }),
186
- );
187
- });
188
-
189
- it("returns null without redirecting when no account is cached", async () => {
190
- // AuthGuard calls loginRedirect when accounts.length === 0; double-
191
- // redirecting from getAccessToken would race with AuthGuard.
192
- const msal = mockMsal({ getAllAccounts: vi.fn().mockReturnValue([]) });
193
- expect(await store().getAccessToken()).toBeNull();
194
- expect(msal.acquireTokenSilent).not.toHaveBeenCalled();
195
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
196
- expect(msal.loginRedirect).not.toHaveBeenCalled();
197
- });
198
-
199
- it("uses acquireTokenRedirect on InteractionRequiredAuthError, evicting the bad account first", async () => {
200
- // Canonical 2026 MSAL.js recovery pattern (per Microsoft Identity
201
- // Platform docs + GitHub #6840):
202
- // 1. ``clearCache({ account })`` — surgical eviction of the bad
203
- // token + claims, BEFORE redirect. The earlier
204
- // "no-clearCache-before-redirect" rule was about the broad
205
- // ``clearCache()`` form (which produces the orphaned-state
206
- // bug); the per-account form is the documented fix.
207
- // 2. ``acquireTokenRedirect`` with ``prompt: "login"`` — forces
208
- // re-prompt instead of letting Entra silently reuse the SSO
209
- // session.
210
- const err = Object.assign(new Error("MFA required"), {
211
- name: "InteractionRequiredAuthError",
212
- errorCode: "interaction_required",
213
- });
214
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
215
- await expectInteractiveRecoveryError(store().getAccessToken(), {
216
- code: "interaction_required",
217
- cause: err,
218
- messageIncludes: [
219
- "Authentication interaction required",
220
- "InteractionRequiredAuthError",
221
- "interaction_required",
222
- "MFA required",
223
- ],
224
- });
225
- expect(msal.clearCache).toHaveBeenCalledOnce();
226
- expect(msal.clearCache).toHaveBeenCalledWith({
227
- account: { username: "alice@example.com", localAccountId: "alice-oid" },
228
- });
229
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
230
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
231
- scopes: [config.apiScope],
232
- account: { username: "alice@example.com", localAccountId: "alice-oid" },
233
- prompt: "login",
234
- });
235
- expect(msal.setActiveAccount).not.toHaveBeenCalled();
236
- });
237
-
238
- it("falls back to loginRedirect when acquireTokenRedirect returns without navigating", async () => {
239
- // ``acquireTokenRedirect`` resolves before navigation in some
240
- // environments (test runners, blocked redirects). When it settles
241
- // without throwing AND without navigating, fall through to a full
242
- // ``loginRedirect`` so the user still gets through the auth flow.
243
- const err = Object.assign(new Error("interaction"), {
244
- name: "InteractionRequiredAuthError",
245
- errorCode: "interaction_required",
246
- });
247
- const msal = mockMsal({
248
- acquireTokenSilent: vi.fn().mockRejectedValue(err),
249
- acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
250
- });
251
- await expectInteractiveRecoveryError(store().getAccessToken(), {
252
- code: "interaction_required",
253
- cause: err,
254
- messageIncludes: ["Authentication interaction required"],
255
- });
256
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
257
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
258
- });
259
-
260
- it("recovers on consent_required by evicting bad account + acquireTokenRedirect", async () => {
261
- const err = Object.assign(new Error("consent required"), {
262
- name: "InteractionRequiredAuthError",
263
- errorCode: "consent_required",
264
- });
265
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
266
- await expectInteractiveRecoveryError(store().getAccessToken(), {
267
- code: "consent_required",
268
- cause: err,
269
- messageIncludes: ["Authentication interaction required", "consent_required"],
270
- });
271
- expect(msal.clearCache).toHaveBeenCalledWith(
272
- expect.objectContaining({ account: expect.objectContaining({ localAccountId: "alice-oid" }) }),
273
- );
274
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
275
- expect.objectContaining({ prompt: "login" }),
276
- );
277
- });
278
-
279
- it("recovers on monitor_window_timeout by evicting bad account + acquireTokenRedirect", async () => {
280
- // Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
281
- // → documented remedy includes "Invoke an interactive API".
282
- const err = Object.assign(new Error("monitor_window_timeout"), {
283
- name: "BrowserAuthError",
284
- errorCode: "monitor_window_timeout",
285
- });
286
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
287
- await expectInteractiveRecoveryError(store().getAccessToken(), {
288
- code: "monitor_window_timeout",
289
- cause: err,
290
- messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
291
- });
292
- expect(msal.clearCache).toHaveBeenCalledWith(
293
- expect.objectContaining({ account: expect.objectContaining({ localAccountId: "alice-oid" }) }),
294
- );
295
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
296
- expect.objectContaining({ prompt: "login" }),
297
- );
298
- });
299
-
300
- it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
301
- const err = Object.assign(new Error("interaction_in_progress"), {
302
- name: "BrowserAuthError",
303
- errorCode: "interaction_in_progress",
304
- });
305
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
306
- expect(await store().getAccessToken()).toBeNull();
307
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
308
- });
309
-
310
- it("does NOT redirect on hash_empty_error (config bug, redirect won't fix)", async () => {
311
- const err = Object.assign(new Error("hash_empty_error"), {
312
- name: "BrowserAuthError",
313
- errorCode: "hash_empty_error",
314
- });
315
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
316
- expect(await store().getAccessToken()).toBeNull();
317
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
318
- });
319
-
320
- it("does NOT redirect on a transient/unknown error", async () => {
321
- // A network blip shouldn't bounce the user through a login flow.
322
- const err = new Error("connection reset");
323
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
324
- expect(await store().getAccessToken()).toBeNull();
325
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
326
- expect(msal.loginRedirect).not.toHaveBeenCalled();
327
- });
328
-
329
- it("matches InteractionRequiredAuthError by class name when no errorCode is set", async () => {
330
- const err = Object.assign(new Error("interaction"), {
331
- name: "InteractionRequiredAuthError",
332
- });
333
- const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
334
- await expectInteractiveRecoveryError(store().getAccessToken(), {
335
- cause: err,
336
- messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
337
- });
338
- expect(msal.clearCache).toHaveBeenCalledOnce();
339
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
340
- expect.objectContaining({ prompt: "login" }),
341
- );
342
- });
343
-
344
- it("preserves redirect failure details when both redirect attempts fail", async () => {
345
- const tokenErr = Object.assign(new Error("MFA required"), {
346
- name: "InteractionRequiredAuthError",
347
- errorCode: "interaction_required",
348
- });
349
- const acquireErr = Object.assign(new Error("acquireToken redirect blocked"), {
350
- name: "BrowserAuthError",
351
- errorCode: "redirect_failed",
352
- });
353
- const loginErr = Object.assign(new Error("login redirect blocked"), {
354
- name: "BrowserAuthError",
355
- errorCode: "redirect_failed_login",
356
- });
357
- const msal = mockMsal({
358
- acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
359
- acquireTokenRedirect: vi.fn().mockRejectedValue(acquireErr),
360
- loginRedirect: vi.fn().mockRejectedValue(loginErr),
361
- });
362
-
363
- await expectInteractiveRecoveryError(store().getAccessToken(), {
364
- code: "redirect_failed_login",
365
- cause: loginErr,
366
- messageIncludes: [
367
- "Authentication interaction required",
368
- "redirect failed",
369
- "redirect_failed_login",
370
- "Original token error",
371
- "interaction_required",
372
- ],
373
- });
374
- expect(msal.acquireTokenRedirect).toHaveBeenCalledWith(
375
- expect.objectContaining({ prompt: "login" }),
376
- );
377
- expect(msal.loginRedirect).toHaveBeenCalledWith(
378
- expect.objectContaining({ prompt: "login" }),
379
- );
380
- // Per-account cache eviction DID happen as the canonical pre-
381
- // redirect step (see GitHub #6840). The "no clearCache" rule
382
- // applies only to the broad ``clearCache()`` form, not the
383
- // ``clearCache({ account })`` variant we use here.
384
- expect(msal.clearCache).toHaveBeenCalledOnce();
385
- });
386
- });
387
-
388
- describe("authStore — orphaned-state recovery", () => {
389
- // Production bug: a previous recovery cleared the account record but
390
- // its redirect didn't navigate (browser policy, popup blocker, etc.).
391
- // localStorage is left with token entries but ``getAllAccounts()``
392
- // returns []. Every subsequent API call gets null token + 401 forever
393
- // until manual ``localStorage.clear()``.
394
-
395
- const config = {
396
- clientId: "test-client",
397
- authority: "https://login.microsoftonline.com/test-tenant",
398
- redirectUri: "http://localhost:8010",
399
- apiScope: "api://test-api/access_as_user",
400
- };
401
-
402
- beforeEach(() => {
403
- if (typeof localStorage !== "undefined") {
404
- localStorage.clear();
405
- }
406
- });
407
-
408
- afterEach(() => {
409
- vi.restoreAllMocks();
410
- if (typeof localStorage !== "undefined") {
411
- localStorage.clear();
412
- }
413
- });
414
-
415
- it("detects orphaned state, nukes localStorage, and triggers fresh login", async () => {
416
- // Seed the orphan: token-shaped MSAL keys but no account record.
417
- localStorage.setItem(
418
- "msal.3|home-id.tenant|login.windows.net|accesstoken|client|tenant|scope|",
419
- JSON.stringify({ id: "x", nonce: "n", data: "encrypted-blob", lastUpdatedAt: "0" }),
420
- );
421
- localStorage.setItem(
422
- "msal.3|home-id.tenant|login.windows.net|refreshtoken|client|||",
423
- JSON.stringify({ id: "x", nonce: "n", data: "encrypted-blob", lastUpdatedAt: "0" }),
424
- );
425
- localStorage.setItem(
426
- "msal.client.active-account-filters",
427
- JSON.stringify({ homeAccountId: "home-id.tenant" }),
428
- );
429
-
430
- const msal = {
431
- initialize: vi.fn().mockResolvedValue(undefined),
432
- handleRedirectPromise: vi.fn().mockResolvedValue(null),
433
- clearCache: vi.fn().mockResolvedValue(undefined),
434
- // CRITICAL: the orphaned-state — empty accounts despite localStorage entries.
435
- getAllAccounts: vi.fn().mockReturnValue([]),
436
- loginRedirect: vi.fn().mockResolvedValue(undefined),
437
- logoutRedirect: vi.fn().mockResolvedValue(undefined),
438
- setActiveAccount: vi.fn(),
439
- acquireTokenSilent: vi.fn(),
440
- acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
441
- acquireTokenPopup: vi.fn(),
442
- };
443
- vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(msal);
444
- vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
445
-
446
- let thrown: unknown;
447
- try {
448
- await authStore.getState().getAccessToken();
449
- } catch (err) {
450
- thrown = err;
451
- }
452
- expect(thrown).toBeInstanceOf(AuthInteractionRequiredError);
453
- expect((thrown as AuthInteractionRequiredError).message).toMatch(/orphaned/i);
454
-
455
- // localStorage should have been nuked of all msal.* keys.
456
- const remainingMsalKeys = Object.keys(localStorage).filter((k) =>
457
- k.startsWith("msal."),
458
- );
459
- expect(remainingMsalKeys).toEqual([]);
460
-
461
- // No account → recovery skips acquireTokenRedirect and goes
462
- // straight to loginRedirect.
463
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
464
- expect(msal.loginRedirect).toHaveBeenCalledOnce();
465
- });
466
-
467
- it("returns null on truly clean no-account state (no orphan)", async () => {
468
- // No MSAL keys in localStorage and no accounts — user genuinely
469
- // hasn't logged in yet. AuthGuard handles ``loginRedirect`` on
470
- // its render; we don't double-redirect from here.
471
- const msal = {
472
- initialize: vi.fn().mockResolvedValue(undefined),
473
- handleRedirectPromise: vi.fn().mockResolvedValue(null),
474
- clearCache: vi.fn().mockResolvedValue(undefined),
475
- getAllAccounts: vi.fn().mockReturnValue([]),
476
- loginRedirect: vi.fn().mockResolvedValue(undefined),
477
- logoutRedirect: vi.fn().mockResolvedValue(undefined),
478
- setActiveAccount: vi.fn(),
479
- acquireTokenSilent: vi.fn(),
480
- acquireTokenRedirect: vi.fn(),
481
- acquireTokenPopup: vi.fn(),
482
- };
483
- vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(msal);
484
- vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
485
-
486
- expect(await authStore.getState().getAccessToken()).toBeNull();
487
- expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
488
- expect(msal.loginRedirect).not.toHaveBeenCalled();
489
- });
490
- });
491
-
492
- describe("authStore — block_iframe_reload defuse", () => {
493
- // MSAL.js refuses to redirect when the current URL fragment still
494
- // carries a prior silent-auth failure (``#error=…``). In production
495
- // we shipped clearCache + prompt:'login' but users were STILL stuck
496
- // because the redirect itself was aborting with
497
- // ``BrowserAuthError: block_iframe_reload``. The fix:
498
- // ``startInteractiveRecovery`` must strip the error fragment from
499
- // the URL with ``history.replaceState`` BEFORE calling
500
- // ``acquireTokenRedirect`` / ``loginRedirect``.
501
-
502
- const config = {
503
- clientId: "test-client",
504
- authority: "https://login.microsoftonline.com/test-tenant",
505
- redirectUri: "http://localhost:8010",
506
- apiScope: "api://test-api/access_as_user",
507
- };
508
-
509
- function mockMsal(overrides: Record<string, unknown> = {}) {
510
- const account = { username: "alice@example.com", localAccountId: "alice-oid" };
511
- const mock = {
512
- initialize: vi.fn().mockResolvedValue(undefined),
513
- handleRedirectPromise: vi.fn().mockResolvedValue(null),
514
- clearCache: vi.fn().mockResolvedValue(undefined),
515
- getAllAccounts: vi.fn().mockReturnValue([account]),
516
- loginRedirect: vi.fn().mockResolvedValue(undefined),
517
- logoutRedirect: vi.fn().mockResolvedValue(undefined),
518
- setActiveAccount: vi.fn(),
519
- acquireTokenSilent: vi.fn().mockResolvedValue({ accessToken: "fresh" }),
520
- acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
521
- acquireTokenPopup: vi.fn().mockResolvedValue({ accessToken: "popup" }),
522
- ...overrides,
523
- };
524
- vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(mock);
525
- vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
526
- return mock;
527
- }
528
-
529
- beforeEach(() => {
530
- // Reset URL fragment between tests.
531
- window.history.replaceState(null, "", "/");
532
- });
533
-
534
- afterEach(() => {
535
- vi.restoreAllMocks();
536
- window.history.replaceState(null, "", "/");
537
- });
538
-
539
- it("strips a stale MSAL error fragment before calling acquireTokenRedirect", async () => {
540
- window.history.replaceState(
541
- null,
542
- "",
543
- "/spaces/some-entity?foo=bar" +
544
- "#error=interaction_required" +
545
- "&error_description=AADSTS160021%3a+Application+requested+a+user+session+which+does+not+exist" +
546
- "&state=eyJpZCI6IngifQ%3D%3D",
547
- );
548
- expect(window.location.hash).toContain("error=interaction_required");
549
-
550
- const msal = mockMsal();
551
- const reason = new Error("API returned 401 after force-refresh retry");
552
- let acquireRedirectHash: string | null = null;
553
- msal.acquireTokenRedirect = vi.fn(async () => {
554
- acquireRedirectHash = window.location.hash;
555
- });
556
-
557
- await expect(authStore.getState().recoverFromHardAuthFailure(reason)).rejects.toBeInstanceOf(AuthInteractionRequiredError);
558
-
559
- // By the time MSAL's redirect API is called, the page fragment
560
- // has been wiped. MSAL won't see the stale ``error=`` and won't
561
- // trip its ``block_iframe_reload`` guard.
562
- expect(acquireRedirectHash).toBe("");
563
- // Path + query string are preserved so the post-redirect landing
564
- // page is the SAME page the user was on when their session went
565
- // bad.
566
- expect(window.location.pathname).toBe("/spaces/some-entity");
567
- expect(window.location.search).toBe("?foo=bar");
568
- });
569
-
570
- it("leaves a clean URL alone (no spurious history mutation)", async () => {
571
- window.history.replaceState(null, "", "/spaces?clean=1");
572
- const original = window.location.href;
573
- const msal = mockMsal();
574
- await expect(authStore
575
- .getState()
576
- .recoverFromHardAuthFailure(new Error("policy drift"))).rejects.toBeInstanceOf(AuthInteractionRequiredError);
577
- // Path + query stay identical (no replaceState invocation).
578
- expect(window.location.href).toBe(original);
579
- expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
580
- });
581
-
582
- it("strips error_code / error_uri fragments too (not just error_description)", async () => {
583
- window.history.replaceState(
584
- null,
585
- "",
586
- "/spaces#error_code=AADSTS160021&error_uri=https%3a%2f%2flogin.example",
587
- );
588
- const msal = mockMsal();
589
- let hashAtRedirect: string | null = null;
590
- msal.acquireTokenRedirect = vi.fn(async () => {
591
- hashAtRedirect = window.location.hash;
592
- });
593
- await expect(authStore.getState().recoverFromHardAuthFailure(new Error("x"))).rejects.toBeInstanceOf(AuthInteractionRequiredError);
594
- expect(hashAtRedirect).toBe("");
595
- });
596
- });
@@ -1,52 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { citationStore } from "../store/citation-store.ts";
3
- import type { CitationResult, CitationHandler } from "../store/citation-store.ts";
4
-
5
- const mockResult: CitationResult = {
6
- chunk_id: "chunk-1",
7
- entity_id: "e-1",
8
- entity_name: "Test Doc",
9
- content: "Some content",
10
- page_number: 1,
11
- bounding_regions: "0,0,100,100",
12
- score: 0.95,
13
- };
14
-
15
- describe("citationStore", () => {
16
- beforeEach(() => {
17
- citationStore.setState({ results: [], handler: null });
18
- });
19
-
20
- describe("setResults", () => {
21
- it("stores citation results", () => {
22
- citationStore.getState().setResults([mockResult]);
23
- expect(citationStore.getState().results).toEqual([mockResult]);
24
- });
25
- });
26
-
27
- describe("setHandler", () => {
28
- it("registers a citation handler", () => {
29
- const handler: CitationHandler = { openCitation: () => {} };
30
- citationStore.getState().setHandler(handler);
31
- expect(citationStore.getState().handler).toBe(handler);
32
- });
33
- });
34
-
35
- describe("clear", () => {
36
- it("resets results but preserves the registered handler", () => {
37
- // The handler is registered once at app boot by a feature module
38
- // (e.g. SPACES). It MUST survive "New Thread" / per-conversation
39
- // resets — otherwise every [n] marker in chat becomes inert after
40
- // the first new-thread click, since the registration guard
41
- // suppresses re-binding for the rest of the session.
42
- const handler: CitationHandler = { openCitation: () => {} };
43
- citationStore.getState().setResults([mockResult]);
44
- citationStore.getState().setHandler(handler);
45
-
46
- citationStore.getState().clear();
47
-
48
- expect(citationStore.getState().results).toEqual([]);
49
- expect(citationStore.getState().handler).toBe(handler);
50
- });
51
- });
52
- });