@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,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
|
-
});
|