@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,84 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { clientToolRegistry } from "../tools/registry.ts";
3
- import type { ClientToolEntry } from "../tools/registry.ts";
4
-
5
- function makeTool(name: string): ClientToolEntry {
6
- return {
7
- name,
8
- description: `${name} tool`,
9
- parameters: { type: "object", properties: {} },
10
- execute: async () => JSON.stringify({ ok: true }),
11
- };
12
- }
13
-
14
- describe("clientToolRegistry", () => {
15
- beforeEach(() => {
16
- clientToolRegistry.setState({ globalTools: new Map(), pageTools: new Map() });
17
- });
18
-
19
- describe("registerGlobal", () => {
20
- it("adds a global tool", () => {
21
- clientToolRegistry.getState().registerGlobal(makeTool("ui_navigate"));
22
- expect(clientToolRegistry.getState().isRegistered("ui_navigate")).toBe(true);
23
- });
24
- });
25
-
26
- describe("registerPageTools / clearPageTools", () => {
27
- it("registers page-scoped tools", () => {
28
- clientToolRegistry.getState().registerPageTools([makeTool("page_search")]);
29
- expect(clientToolRegistry.getState().isRegistered("page_search")).toBe(true);
30
- });
31
-
32
- it("clears page tools", () => {
33
- clientToolRegistry.getState().registerPageTools([makeTool("page_search")]);
34
- clientToolRegistry.getState().clearPageTools();
35
- expect(clientToolRegistry.getState().isRegistered("page_search")).toBe(false);
36
- });
37
- });
38
-
39
- describe("getActiveSchemas", () => {
40
- it("merges global and page tools", () => {
41
- clientToolRegistry.getState().registerGlobal(makeTool("ui_navigate"));
42
- clientToolRegistry.getState().registerPageTools([makeTool("page_search")]);
43
- const schemas = clientToolRegistry.getState().getActiveSchemas();
44
- expect(schemas.map((s) => s.name).sort()).toEqual(["page_search", "ui_navigate"]);
45
- });
46
-
47
- it("page tools override global tools with same name", () => {
48
- clientToolRegistry.getState().registerGlobal(makeTool("shared_tool"));
49
- const pageTool = makeTool("shared_tool");
50
- pageTool.description = "page version";
51
- clientToolRegistry.getState().registerPageTools([pageTool]);
52
-
53
- const schemas = clientToolRegistry.getState().getActiveSchemas();
54
- const match = schemas.find((s) => s.name === "shared_tool");
55
- expect(match?.description).toBe("page version");
56
- });
57
- });
58
-
59
- describe("executeTool", () => {
60
- it("executes a registered tool", async () => {
61
- clientToolRegistry.getState().registerGlobal(makeTool("ui_navigate"));
62
- const result = await clientToolRegistry.getState().executeTool("ui_navigate", "{}");
63
- expect(JSON.parse(result)).toEqual({ ok: true });
64
- });
65
-
66
- it("returns error for unknown tool", async () => {
67
- const result = await clientToolRegistry.getState().executeTool("unknown_tool", "{}");
68
- expect(JSON.parse(result).error).toContain("Unknown client tool");
69
- });
70
-
71
- it("page tool takes precedence over global", async () => {
72
- const globalTool = makeTool("shared");
73
- globalTool.execute = async () => "global";
74
- const pageTool = makeTool("shared");
75
- pageTool.execute = async () => "page";
76
-
77
- clientToolRegistry.getState().registerGlobal(globalTool);
78
- clientToolRegistry.getState().registerPageTools([pageTool]);
79
-
80
- const result = await clientToolRegistry.getState().executeTool("shared", "{}");
81
- expect(result).toBe("page");
82
- });
83
- });
84
- });
@@ -1,48 +0,0 @@
1
- import { describe, expect, it, beforeEach, vi } from "vitest";
2
- import { linkStore, resolveLinkHandler, type LinkHandler } from "../store/link-store.ts";
3
-
4
- describe("linkStore", () => {
5
- beforeEach(() => {
6
- linkStore.getState().clear();
7
- });
8
-
9
- it("registers and clears a markdown link handler", () => {
10
- const handler: LinkHandler = {
11
- canHandle: (href) => href.startsWith("/spaces/"),
12
- openLink: vi.fn(),
13
- };
14
-
15
- linkStore.getState().setHandler(handler);
16
- expect(linkStore.getState().handler).toBe(handler);
17
- expect(linkStore.getState().handlers).toEqual([handler]);
18
-
19
- linkStore.getState().clear();
20
- expect(linkStore.getState().handler).toBeNull();
21
- expect(linkStore.getState().handlers).toEqual([]);
22
- });
23
-
24
- it("keeps multiple handlers and resolves the matching namespace", () => {
25
- const spacesHandler: LinkHandler = {
26
- canHandle: (href) => href.startsWith("/spaces/"),
27
- openLink: vi.fn(),
28
- };
29
- const docsHandler: LinkHandler = {
30
- normalizeHref: (href) => (href.startsWith("docs:") ? `/docs/${href.slice(5)}` : href),
31
- canHandle: (href) => href.startsWith("/docs/"),
32
- openLink: vi.fn(),
33
- };
34
-
35
- linkStore.getState().setHandler(spacesHandler);
36
- linkStore.getState().setHandler(docsHandler);
37
-
38
- expect(resolveLinkHandler("/spaces/page-1")).toEqual({
39
- handler: spacesHandler,
40
- href: "/spaces/page-1",
41
- });
42
- expect(resolveLinkHandler("docs:setup")).toEqual({
43
- handler: docsHandler,
44
- href: "/docs/setup",
45
- });
46
- expect(resolveLinkHandler("/unknown")).toBeNull();
47
- });
48
- });
@@ -1,525 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import {
3
- createServiceFetch,
4
- parseClaimsChallengeFromWwwAuthenticate,
5
- } from "../client/service-fetch.ts";
6
-
7
- describe("createServiceFetch", () => {
8
- let fetchSpy: ReturnType<typeof vi.spyOn>;
9
-
10
- beforeEach(() => {
11
- fetchSpy = vi
12
- .spyOn(globalThis, "fetch")
13
- .mockResolvedValue(new Response(null, { status: 200 }));
14
- });
15
-
16
- afterEach(() => {
17
- vi.restoreAllMocks();
18
- });
19
-
20
- it("attaches Bearer token from acquireToken", async () => {
21
- const acquireToken = vi.fn().mockResolvedValue("test-token");
22
- const serviceFetch = createServiceFetch({ acquireToken });
23
-
24
- await serviceFetch("/api/spaces/entities");
25
-
26
- expect(fetchSpy).toHaveBeenCalledTimes(1);
27
- const [url, init] = fetchSpy.mock.calls[0]!;
28
- expect(url).toBe("/api/spaces/entities");
29
- const headers = new Headers(init?.headers);
30
- expect(headers.get("Authorization")).toBe("Bearer test-token");
31
- });
32
-
33
- it("skips token attachment when acquireToken returns null", async () => {
34
- const acquireToken = vi.fn().mockResolvedValue(null);
35
- const serviceFetch = createServiceFetch({ acquireToken });
36
-
37
- await serviceFetch("/api/spaces/entities");
38
-
39
- expect(fetchSpy).toHaveBeenCalledTimes(1);
40
- const [, init] = fetchSpy.mock.calls[0]!;
41
- const headers = new Headers(init?.headers);
42
- expect(headers.get("Authorization")).toBeNull();
43
- });
44
-
45
- it("skips token attachment when Authorization header already present", async () => {
46
- const acquireToken = vi.fn().mockResolvedValue("test-token");
47
- const serviceFetch = createServiceFetch({ acquireToken });
48
-
49
- await serviceFetch("/api/spaces/entities", {
50
- headers: { Authorization: "Bearer pre-existing" },
51
- });
52
-
53
- expect(acquireToken).not.toHaveBeenCalled();
54
- const [, init] = fetchSpy.mock.calls[0]!;
55
- const headers = new Headers(init?.headers);
56
- expect(headers.get("Authorization")).toBe("Bearer pre-existing");
57
- });
58
-
59
- it("rewrites local-relative URLs to baseUrl when configured", async () => {
60
- const acquireToken = vi.fn().mockResolvedValue(null);
61
- const serviceFetch = createServiceFetch({
62
- acquireToken,
63
- baseUrl: "https://api.example.com",
64
- });
65
-
66
- await serviceFetch("/api/spaces/entities");
67
-
68
- const [url] = fetchSpy.mock.calls[0]!;
69
- expect(url).toBe("https://api.example.com/api/spaces/entities");
70
- });
71
-
72
- it("strips origin prefix before applying baseUrl", async () => {
73
- const acquireToken = vi.fn().mockResolvedValue(null);
74
- const serviceFetch = createServiceFetch({
75
- acquireToken,
76
- baseUrl: "https://api.example.com",
77
- originResolver: () => "https://app.example.com",
78
- });
79
-
80
- await serviceFetch("https://app.example.com/api/spaces/entities");
81
-
82
- const [url] = fetchSpy.mock.calls[0]!;
83
- expect(url).toBe("https://api.example.com/api/spaces/entities");
84
- });
85
-
86
- it("leaves URL untouched when baseUrl is empty (dev mode)", async () => {
87
- const acquireToken = vi.fn().mockResolvedValue(null);
88
- const serviceFetch = createServiceFetch({ acquireToken });
89
-
90
- await serviceFetch("/api/spaces/entities");
91
-
92
- const [url] = fetchSpy.mock.calls[0]!;
93
- expect(url).toBe("/api/spaces/entities");
94
- });
95
-
96
- it("strips trailing slashes from baseUrl", async () => {
97
- const acquireToken = vi.fn().mockResolvedValue(null);
98
- const serviceFetch = createServiceFetch({
99
- acquireToken,
100
- baseUrl: "https://api.example.com//",
101
- });
102
-
103
- await serviceFetch("/api/spaces/entities");
104
-
105
- const [url] = fetchSpy.mock.calls[0]!;
106
- expect(url).toBe("https://api.example.com/api/spaces/entities");
107
- });
108
-
109
- it("passes URL objects through correctly", async () => {
110
- const acquireToken = vi.fn().mockResolvedValue(null);
111
- const serviceFetch = createServiceFetch({ acquireToken });
112
-
113
- await serviceFetch(new URL("https://example.com/api/items"));
114
-
115
- const [url] = fetchSpy.mock.calls[0]!;
116
- expect(url).toBe("https://example.com/api/items");
117
- });
118
-
119
- it("preserves Request method/body/headers when input is a Request", async () => {
120
- const acquireToken = vi.fn().mockResolvedValue("test-token");
121
- const serviceFetch = createServiceFetch({ acquireToken });
122
-
123
- const req = new Request("https://example.com/api/items", {
124
- method: "POST",
125
- headers: { "Content-Type": "application/json", "X-Custom": "from-request" },
126
- body: JSON.stringify({ ok: true }),
127
- });
128
-
129
- await serviceFetch(req);
130
-
131
- expect(fetchSpy).toHaveBeenCalledTimes(1);
132
- const [url, init] = fetchSpy.mock.calls[0]!;
133
- expect(url).toBe("https://example.com/api/items");
134
- expect(init?.method).toBe("POST");
135
-
136
- // Body is preserved (as a stream or buffer in the runtime).
137
- expect(init?.body).toBeDefined();
138
-
139
- const merged = new Headers(init?.headers);
140
- expect(merged.get("Authorization")).toBe("Bearer test-token");
141
- expect(merged.get("X-Custom")).toBe("from-request");
142
- expect(merged.get("Content-Type")).toBe("application/json");
143
- });
144
-
145
- it("does not attach a body for GET Requests", async () => {
146
- const acquireToken = vi.fn().mockResolvedValue(null);
147
- const serviceFetch = createServiceFetch({ acquireToken });
148
-
149
- const req = new Request("https://example.com/api/items", {
150
- method: "GET",
151
- headers: { "X-Custom": "from-request" },
152
- });
153
-
154
- await serviceFetch(req);
155
-
156
- const [, init] = fetchSpy.mock.calls[0]!;
157
- expect(init?.method).toBe("GET");
158
- expect(init?.body).toBeUndefined();
159
- });
160
-
161
- it("init headers override Request headers when both supplied", async () => {
162
- const acquireToken = vi.fn().mockResolvedValue(null);
163
- const serviceFetch = createServiceFetch({ acquireToken });
164
-
165
- const req = new Request("https://example.com/api/items", {
166
- headers: { "X-Override": "from-request", "X-Request-Only": "req" },
167
- });
168
-
169
- await serviceFetch(req, { headers: { "X-Override": "from-init", "X-Init-Only": "init" } });
170
-
171
- const [, init] = fetchSpy.mock.calls[0]!;
172
- const merged = new Headers(init?.headers);
173
- expect(merged.get("X-Override")).toBe("from-init");
174
- expect(merged.get("X-Request-Only")).toBe("req");
175
- expect(merged.get("X-Init-Only")).toBe("init");
176
- });
177
-
178
- it("does not downgrade token failures to anonymous protected calls", async () => {
179
- const acquireToken = vi.fn().mockRejectedValue(new Error("token boom"));
180
- const serviceFetch = createServiceFetch({ acquireToken });
181
-
182
- await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow("token boom");
183
-
184
- expect(fetchSpy).not.toHaveBeenCalled();
185
- });
186
-
187
- describe("401 retry with forceRefresh", () => {
188
- it("retries once with forceRefresh when first attempt returns 401", async () => {
189
- // Resource server rejects the first (stale-cached) token, then accepts
190
- // the freshly-acquired one. This is the long-lived-tab recovery path.
191
- fetchSpy.mockReset();
192
- fetchSpy
193
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
194
- .mockResolvedValueOnce(new Response(null, { status: 200 }));
195
- const acquireToken = vi
196
- .fn()
197
- .mockResolvedValueOnce("stale-token")
198
- .mockResolvedValueOnce("fresh-token");
199
- const serviceFetch = createServiceFetch({ acquireToken });
200
-
201
- const res = await serviceFetch("/api/spaces/entities");
202
-
203
- expect(res.status).toBe(200);
204
- expect(fetchSpy).toHaveBeenCalledTimes(2);
205
- expect(acquireToken).toHaveBeenCalledTimes(2);
206
- // First call: no forceRefresh (use cached token)
207
- expect(acquireToken).toHaveBeenNthCalledWith(1, undefined);
208
- // Second call: forceRefresh: true (skip MSAL cache, hit token endpoint)
209
- expect(acquireToken).toHaveBeenNthCalledWith(2, { forceRefresh: true });
210
-
211
- const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
212
- "Authorization",
213
- );
214
- const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
215
- "Authorization",
216
- );
217
- expect(firstAuth).toBe("Bearer stale-token");
218
- expect(secondAuth).toBe("Bearer fresh-token");
219
- });
220
-
221
- it("does not retry on non-401 responses", async () => {
222
- fetchSpy.mockReset();
223
- fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500 }));
224
- const acquireToken = vi.fn().mockResolvedValue("token");
225
- const serviceFetch = createServiceFetch({ acquireToken });
226
-
227
- const res = await serviceFetch("/api/spaces/entities");
228
-
229
- expect(res.status).toBe(500);
230
- expect(fetchSpy).toHaveBeenCalledTimes(1);
231
- expect(acquireToken).toHaveBeenCalledTimes(1);
232
- });
233
-
234
- it("does not retry when caller supplied their own Authorization header", async () => {
235
- // The caller owns the token in this case; retrying would let our auth
236
- // layer overwrite their explicit header on the second attempt.
237
- fetchSpy.mockReset();
238
- fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }));
239
- const acquireToken = vi.fn();
240
- const serviceFetch = createServiceFetch({ acquireToken });
241
-
242
- const res = await serviceFetch("/api/spaces/entities", {
243
- headers: { Authorization: "Bearer caller-supplied" },
244
- });
245
-
246
- expect(res.status).toBe(401);
247
- expect(fetchSpy).toHaveBeenCalledTimes(1);
248
- expect(acquireToken).not.toHaveBeenCalled();
249
- });
250
-
251
- it("preserves a POST body across the 401 retry", async () => {
252
- // Regression for the body-stream-consumed bug: ReadableStream bodies
253
- // are single-consume, so the retry would otherwise see an empty body
254
- // and a server-side validation failure that masks the real auth
255
- // recovery. We assert the second attempt receives the same body.
256
- fetchSpy.mockReset();
257
- fetchSpy
258
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
259
- .mockResolvedValueOnce(new Response(null, { status: 200 }));
260
- const acquireToken = vi
261
- .fn()
262
- .mockResolvedValueOnce("stale-token")
263
- .mockResolvedValueOnce("fresh-token");
264
- const serviceFetch = createServiceFetch({ acquireToken });
265
-
266
- const payload = JSON.stringify({ name: "create-this", id: 42 });
267
- const request = new Request("https://example.com/api/items", {
268
- method: "POST",
269
- headers: { "Content-Type": "application/json" },
270
- body: payload,
271
- });
272
-
273
- const res = await serviceFetch(request);
274
-
275
- expect(res.status).toBe(200);
276
- expect(fetchSpy).toHaveBeenCalledTimes(2);
277
-
278
- // Each fetch call's body must be readable and equal to the original.
279
- // Awaiting both confirms neither call's body stream was already
280
- // drained by the time fetch received it.
281
- const firstAttempt = fetchSpy.mock.calls[0]![1];
282
- const secondAttempt = fetchSpy.mock.calls[1]![1];
283
- const firstBody = await new Response(firstAttempt?.body as BodyInit).text();
284
- const secondBody = await new Response(secondAttempt?.body as BodyInit).text();
285
- expect(firstBody).toBe(payload);
286
- expect(secondBody).toBe(payload);
287
- });
288
-
289
- it("only retries once even if the second attempt also returns 401", async () => {
290
- // Hard auth failure (refresh token expired). With no
291
- // ``recoverFromHardAuthFailure`` configured, the second 401
292
- // propagates to the caller. (Tests cover the recovery path
293
- // separately below.)
294
- fetchSpy.mockReset();
295
- fetchSpy
296
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
297
- .mockResolvedValueOnce(new Response(null, { status: 401 }));
298
- const acquireToken = vi.fn().mockResolvedValue("token");
299
- const serviceFetch = createServiceFetch({ acquireToken });
300
-
301
- const res = await serviceFetch("/api/spaces/entities");
302
-
303
- expect(res.status).toBe(401);
304
- expect(fetchSpy).toHaveBeenCalledTimes(2);
305
- expect(acquireToken).toHaveBeenCalledTimes(2);
306
- });
307
-
308
- it("kicks off interactive recovery after a second 401", async () => {
309
- // Server-policy drift: even the force-refreshed token is rejected.
310
- // The fetch interceptor must escalate to loginRedirect via the
311
- // recoverFromHardAuthFailure callback — without it the user is
312
- // stuck in a silent 401 loop forever (the bug this fix exists for).
313
- fetchSpy.mockReset();
314
- fetchSpy
315
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
316
- .mockResolvedValueOnce(new Response(null, { status: 401 }));
317
- const acquireToken = vi
318
- .fn()
319
- .mockResolvedValueOnce("stale-token")
320
- .mockResolvedValueOnce("force-refreshed-but-still-bad-token");
321
- const recoverFromHardAuthFailure = vi
322
- .fn()
323
- .mockRejectedValue(new Error("loginRedirect in flight"));
324
- const serviceFetch = createServiceFetch({
325
- acquireToken,
326
- recoverFromHardAuthFailure,
327
- });
328
-
329
- await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow(
330
- "loginRedirect in flight",
331
- );
332
- expect(fetchSpy).toHaveBeenCalledTimes(2);
333
- expect(acquireToken).toHaveBeenCalledTimes(2);
334
- expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
335
- // Reason carries enough context to log/alert without leaking tokens.
336
- const reason = recoverFromHardAuthFailure.mock.calls[0]![0];
337
- expect(reason).toBeInstanceOf(Error);
338
- expect((reason as Error).message).toMatch(/401/);
339
- });
340
-
341
- it("does NOT call recoverFromHardAuthFailure on first-attempt success", async () => {
342
- // Sanity: when the first request succeeds, recovery callback must
343
- // never fire.
344
- fetchSpy.mockReset();
345
- fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 }));
346
- const acquireToken = vi.fn().mockResolvedValue("good-token");
347
- const recoverFromHardAuthFailure = vi.fn();
348
- const serviceFetch = createServiceFetch({
349
- acquireToken,
350
- recoverFromHardAuthFailure,
351
- });
352
-
353
- const res = await serviceFetch("/api/spaces/entities");
354
- expect(res.status).toBe(200);
355
- expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
356
- });
357
-
358
- it("kicks off interactive recovery when acquireToken throws on retry", async () => {
359
- // Hard expiry path: refresh-token window closed (24h SPA cap) or
360
- // claims challenge pending. The force-refresh retry's
361
- // ``acquireToken`` rejects BEFORE the second fetch leaves the
362
- // browser. Without this branch the throw bubbles up to callers
363
- // that have bare ``catch {}`` (e.g. useContentTreeStore.fetchSchemas)
364
- // and the user is stuck in a silent 401 loop with no re-auth UI.
365
- fetchSpy.mockReset();
366
- fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }));
367
- const msalError = new Error("InteractionRequiredAuthError: refresh token expired");
368
- const acquireToken = vi
369
- .fn()
370
- .mockResolvedValueOnce("stale-token")
371
- .mockRejectedValueOnce(msalError);
372
- const recoverFromHardAuthFailure = vi
373
- .fn()
374
- .mockRejectedValue(new Error("loginRedirect in flight"));
375
- const serviceFetch = createServiceFetch({
376
- acquireToken,
377
- recoverFromHardAuthFailure,
378
- });
379
-
380
- // The wrapped TokenAcquisitionError propagates as the thrown
381
- // error; recovery has already kicked off by then.
382
- await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow();
383
- expect(fetchSpy).toHaveBeenCalledTimes(1); // second fetch never left
384
- expect(acquireToken).toHaveBeenCalledTimes(2);
385
- expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
386
- // Recovery receives the ORIGINAL MSAL error, not the wrapper —
387
- // telemetry / logging downstream should see the real cause.
388
- expect(recoverFromHardAuthFailure.mock.calls[0]![0]).toBe(msalError);
389
- });
390
-
391
- it("does NOT call recoverFromHardAuthFailure on transport errors during retry", async () => {
392
- // Regression for review feedback on PR #30: the retry-catch must
393
- // distinguish auth failures (TokenAcquisitionError) from
394
- // generic transport failures (network drop, AbortError, CORS
395
- // preflight reject). The latter must NOT trigger loginRedirect
396
- // — that would bounce users through Entra ID on any flaky-wifi
397
- // moment.
398
- fetchSpy.mockReset();
399
- const networkErr = new TypeError("NetworkError: Failed to fetch");
400
- fetchSpy
401
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
402
- .mockRejectedValueOnce(networkErr);
403
- const acquireToken = vi.fn().mockResolvedValue("token");
404
- const recoverFromHardAuthFailure = vi.fn();
405
- const serviceFetch = createServiceFetch({
406
- acquireToken,
407
- recoverFromHardAuthFailure,
408
- });
409
-
410
- // Network error propagates as-is; no recovery attempted.
411
- await expect(serviceFetch("/api/spaces/entities")).rejects.toBe(networkErr);
412
- expect(fetchSpy).toHaveBeenCalledTimes(2);
413
- expect(acquireToken).toHaveBeenCalledTimes(2); // both attempts got a token
414
- expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
415
- });
416
-
417
- it("does NOT call recoverFromHardAuthFailure when the retry recovers", async () => {
418
- // Standard recovery — first 401, retry with forceRefresh succeeds.
419
- // Recovery callback must not fire (would needlessly bounce the user
420
- // through loginRedirect).
421
- fetchSpy.mockReset();
422
- fetchSpy
423
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
424
- .mockResolvedValueOnce(new Response(null, { status: 200 }));
425
- const acquireToken = vi
426
- .fn()
427
- .mockResolvedValueOnce("stale")
428
- .mockResolvedValueOnce("fresh");
429
- const recoverFromHardAuthFailure = vi.fn();
430
- const serviceFetch = createServiceFetch({
431
- acquireToken,
432
- recoverFromHardAuthFailure,
433
- });
434
-
435
- const res = await serviceFetch("/api/spaces/entities");
436
- expect(res.status).toBe(200);
437
- expect(recoverFromHardAuthFailure).not.toHaveBeenCalled();
438
- });
439
- });
440
- });
441
-
442
- describe("parseClaimsChallengeFromWwwAuthenticate", () => {
443
- it("returns undefined when header is missing", () => {
444
- expect(parseClaimsChallengeFromWwwAuthenticate(null)).toBeUndefined();
445
- expect(parseClaimsChallengeFromWwwAuthenticate(undefined)).toBeUndefined();
446
- expect(parseClaimsChallengeFromWwwAuthenticate("")).toBeUndefined();
447
- });
448
-
449
- it("returns undefined when Bearer challenge carries no claims", () => {
450
- const header = 'Bearer realm="example", error="invalid_token"';
451
- expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBeUndefined();
452
- });
453
-
454
- it("extracts quoted claims payload", () => {
455
- // Microsoft Identity Platform CAE / Conditional Access challenge
456
- // shape — base64url payload inside double quotes.
457
- const claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjAwMDAwMDAwIn19fQ";
458
- const header = `Bearer realm="example", error="insufficient_claims", claims="${claims}"`;
459
- expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBe(claims);
460
- });
461
-
462
- it("extracts unquoted token68 claims payload", () => {
463
- const claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZX19fQ";
464
- const header = `Bearer claims=${claims}`;
465
- expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBe(claims);
466
- });
467
-
468
- it("ignores non-Bearer challenges", () => {
469
- const header = 'Basic realm="x", Digest realm="y", claims="ignored"';
470
- expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBeUndefined();
471
- });
472
-
473
- it("picks the Bearer claims even when other challenges are present", () => {
474
- const header = 'Basic realm="x", Bearer realm="api", error="insufficient_claims", claims="ABC123"';
475
- expect(parseClaimsChallengeFromWwwAuthenticate(header)).toBe("ABC123");
476
- });
477
- });
478
-
479
- describe("createServiceFetch — claims-challenge passthrough", () => {
480
- let fetchSpy: ReturnType<typeof vi.fn>;
481
- beforeEach(() => {
482
- fetchSpy = vi.fn();
483
- globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch;
484
- });
485
- afterEach(() => {
486
- vi.restoreAllMocks();
487
- });
488
-
489
- it("forwards a WWW-Authenticate claims challenge through recoverFromHardAuthFailure", async () => {
490
- // Resource server returned 401 with a CAE claims challenge. The
491
- // retry succeeds at the token level (forceRefresh produced a
492
- // fresh access token) but the server STILL rejects because the
493
- // user needs a step-up. Recovery must carry the challenge to
494
- // MSAL so the next token explicitly satisfies it.
495
- const claims = "eyJjbGFpbXMiOiJBQkMifQ";
496
- fetchSpy
497
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
498
- .mockResolvedValueOnce(
499
- new Response(null, {
500
- status: 401,
501
- headers: {
502
- "WWW-Authenticate": `Bearer realm="api", error="insufficient_claims", claims="${claims}"`,
503
- },
504
- }),
505
- );
506
- const acquireToken = vi
507
- .fn()
508
- .mockResolvedValueOnce("stale")
509
- .mockResolvedValueOnce("fresh");
510
- const recoverFromHardAuthFailure = vi
511
- .fn()
512
- .mockRejectedValue(new Error("redirecting"));
513
- const serviceFetch = createServiceFetch({
514
- acquireToken,
515
- recoverFromHardAuthFailure,
516
- });
517
-
518
- await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow();
519
- expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
520
- const reason = recoverFromHardAuthFailure.mock.calls[0]![0];
521
- // Stamped onto the recovery reason so auth-store can forward it
522
- // to MSAL.acquireTokenRedirect({ claims }).
523
- expect((reason as { claims?: string }).claims).toBe(claims);
524
- });
525
- });
@@ -1,22 +0,0 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
2
- import { streamingStatusStore } from "../store/streaming-status-store.ts";
3
-
4
- const store = () => streamingStatusStore.getState();
5
-
6
- describe("streamingStatusStore", () => {
7
- beforeEach(() => {
8
- streamingStatusStore.setState({ streamingStatus: { status: "idle" } });
9
- });
10
-
11
- it("starts with idle status", () => {
12
- expect(store().streamingStatus).toEqual({ status: "idle" });
13
- });
14
-
15
- it("updates streaming status", () => {
16
- store().setStreamingStatus({ status: "calling", toolName: "get_weather" });
17
- expect(store().streamingStatus).toEqual({
18
- status: "calling",
19
- toolName: "get_weather",
20
- });
21
- });
22
- });