@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.
- package/README.md +16 -0
- package/dist/client/agui-runner.d.ts +53 -0
- package/dist/client/agui-runner.js +320 -0
- package/dist/client/runner-events.d.ts +54 -0
- package/dist/client/runner-events.js +1 -0
- package/dist/client/service-fetch.d.ts +112 -0
- package/dist/client/service-fetch.js +244 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/msal/auth-config.d.ts +91 -0
- package/dist/msal/auth-config.js +70 -0
- package/dist/msal/auth-store.d.ts +95 -0
- package/dist/msal/auth-store.js +372 -0
- package/dist/msal/index.d.ts +3 -0
- package/dist/msal/index.js +3 -0
- package/dist/msal/token-fetch.d.ts +16 -0
- package/dist/msal/token-fetch.js +57 -0
- package/dist/store/citation-store.d.ts +42 -0
- package/dist/store/citation-store.js +14 -0
- package/dist/store/link-store.d.ts +29 -0
- package/dist/store/link-store.js +28 -0
- package/dist/store/streaming-status-store.d.ts +15 -0
- package/dist/store/streaming-status-store.js +9 -0
- package/dist/tools/registry.d.ts +48 -0
- package/dist/tools/registry.js +50 -0
- package/package.json +23 -9
- package/AGENTS.md +0 -91
- package/CHANGELOG.md +0 -180
- package/CLAUDE.md +0 -1
- package/src/__tests__/agui-runner.test.ts +0 -404
- package/src/__tests__/auth-store.test.ts +0 -596
- package/src/__tests__/citation-store.test.ts +0 -52
- package/src/__tests__/client-tool-registry.test.ts +0 -84
- package/src/__tests__/link-store.test.ts +0 -48
- package/src/__tests__/service-fetch.test.ts +0 -525
- package/src/__tests__/streaming-status-store.test.ts +0 -22
- package/src/__tests__/token-fetch.test.ts +0 -134
- package/src/client/agui-runner.ts +0 -382
- package/src/client/runner-events.ts +0 -27
- package/src/client/service-fetch.ts +0 -318
- package/src/index.ts +0 -27
- package/src/msal/auth-config.ts +0 -150
- package/src/msal/auth-store.ts +0 -517
- package/src/msal/index.ts +0 -14
- package/src/msal/token-fetch.ts +0 -68
- package/src/store/citation-store.ts +0 -52
- package/src/store/link-store.ts +0 -53
- package/src/store/streaming-status-store.ts +0 -21
- package/src/tools/registry.ts +0 -112
- package/tsconfig.json +0 -15
- 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
|
-
});
|