@iloveagents/foundry-agent 0.1.0 → 0.1.2
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/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/src/__tests__/agui-runner.test.ts +88 -13
- package/src/__tests__/auth-store.test.ts +231 -2
- package/src/__tests__/link-store.test.ts +48 -0
- package/src/__tests__/service-fetch.test.ts +124 -6
- package/src/__tests__/token-fetch.test.ts +56 -0
- package/src/client/agui-runner.ts +55 -3
- package/src/client/service-fetch.ts +81 -36
- package/src/index.ts +8 -9
- package/src/msal/auth-config.ts +36 -0
- package/src/msal/auth-store.ts +177 -13
- package/src/msal/index.ts +5 -1
- package/src/msal/token-fetch.ts +30 -8
- package/src/store/link-store.ts +53 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @iloveagents/foundry-agent
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 30a4346: fix(agent): retry protected fetch with `forceRefresh` on 401
|
|
8
|
+
|
|
9
|
+
Closes the long-lived-tab 401 loop where the only recovery the user had was logging out or clearing localStorage.
|
|
10
|
+
|
|
11
|
+
The token-fetch wrapper used to ask MSAL for a cached token and never retry. If the resource server rejected that token (claims challenge, conditional-access re-eval, audience drift, or staleness MSAL's clock-offset check missed), every subsequent request kept re-sending the same token.
|
|
12
|
+
|
|
13
|
+
Fix: on 401 from a protected API, retry once with `acquireTokenSilent({ forceRefresh: true })`. If that also fails, the existing `InteractionRequiredAuthError` path bounces the user through Entra ID. Single-retry cap so we never loop. Request bodies are cloned per-attempt (ReadableStream is single-consume) so POST/PUT payloads survive the retry intact.
|
|
14
|
+
|
|
15
|
+
The `acquireToken` callback type now accepts an optional `{ forceRefresh? }` argument — non-breaking; existing callbacks continue to work, but they should forward the option to their token source so the retry path actually reaches the auth backend.
|
|
16
|
+
|
|
17
|
+
Pair: `lastspace#205` (forwards the option through `spacesFetch.acquireToken`).
|
|
18
|
+
|
|
19
|
+
## 0.1.1
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 689d3e9: feat(sidebar): distinct file-drop affordance for nav containers
|
|
24
|
+
|
|
25
|
+
`useNavItemDnd` now exposes `isFileDragOver` separately from `isDragOver`
|
|
26
|
+
so consuming nav items can render a prominent file-drop visual (dashed
|
|
27
|
+
primary outline + soft primary background + Upload icon) when the user
|
|
28
|
+
is dragging native files over the container. Previously file drags
|
|
29
|
+
shared the same subtle ring as entity-move drags, so users couldn't
|
|
30
|
+
tell that a folder accepted external files. Applies to all five nav-
|
|
31
|
+
item shapes (action-row leaf, button leaf, `NestedFolderItem`,
|
|
32
|
+
`CollapsibleNavItem` with children/actions, `NavLink` fallthrough) and
|
|
33
|
+
updates `ContainerDropZone` for visual parity.
|
|
34
|
+
|
|
35
|
+
Also fixes a related regression: the capture-phase
|
|
36
|
+
`onDragEnterCapture` / `onDragOverCapture` handlers were calling
|
|
37
|
+
`e.stopPropagation()`, which short-circuits React's synthetic dispatch
|
|
38
|
+
and prevented the bubble-phase `onDragEnter` (where state actually
|
|
39
|
+
mutates) from running. Removed — `preventDefault()` alone is enough to
|
|
40
|
+
mark the element as droppable.
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import type { AgentSubscriber } from "@ag-ui/client";
|
|
|
6
6
|
// runAgent fires them in order, synchronously yielding the loop between
|
|
7
7
|
// each one so the runner's queue can drain.
|
|
8
8
|
|
|
9
|
-
type ScriptedRun = (subscriber: AgentSubscriber) => Promise<void>;
|
|
9
|
+
type ScriptedRun = (subscriber: AgentSubscriber, agent: { messages: unknown[] }) => Promise<void>;
|
|
10
10
|
|
|
11
11
|
let nextScript: ScriptedRun[] = [];
|
|
12
12
|
let runCount = 0;
|
|
@@ -38,7 +38,7 @@ vi.mock("@ag-ui/client", () => {
|
|
|
38
38
|
const turnIdx = runCount;
|
|
39
39
|
runCount++;
|
|
40
40
|
const script = nextScript[turnIdx];
|
|
41
|
-
if (script) await script(subscriber);
|
|
41
|
+
if (script) await script(subscriber, this);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
abortRun() {
|
|
@@ -52,7 +52,10 @@ import { AGUIRunner } from "../client/agui-runner.ts";
|
|
|
52
52
|
import { clientToolRegistry } from "../tools/registry.ts";
|
|
53
53
|
import type { RunnerEvent } from "../client/runner-events.ts";
|
|
54
54
|
|
|
55
|
-
function makeRegistry(client: {
|
|
55
|
+
function makeRegistry(client: {
|
|
56
|
+
isRegistered: (name: string) => boolean;
|
|
57
|
+
executeTool?: (name: string, args: string) => Promise<string>;
|
|
58
|
+
}) {
|
|
56
59
|
return {
|
|
57
60
|
isRegistered: client.isRegistered,
|
|
58
61
|
executeTool: client.executeTool ?? (async () => JSON.stringify({})),
|
|
@@ -60,7 +63,10 @@ function makeRegistry(client: { isRegistered: (name: string) => boolean; execute
|
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
async function collect(
|
|
66
|
+
async function collect(
|
|
67
|
+
runner: AGUIRunner,
|
|
68
|
+
registry: ReturnType<typeof makeRegistry>,
|
|
69
|
+
): Promise<RunnerEvent[]> {
|
|
64
70
|
const out: RunnerEvent[] = [];
|
|
65
71
|
for await (const evt of runner.run({ messages: [], registry })) {
|
|
66
72
|
out.push(evt);
|
|
@@ -68,6 +74,18 @@ async function collect(runner: AGUIRunner, registry: ReturnType<typeof makeRegis
|
|
|
68
74
|
return out;
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
async function collectWithMessages(
|
|
78
|
+
runner: AGUIRunner,
|
|
79
|
+
messages: Parameters<AGUIRunner["run"]>[0]["messages"],
|
|
80
|
+
registry: ReturnType<typeof makeRegistry>,
|
|
81
|
+
): Promise<RunnerEvent[]> {
|
|
82
|
+
const out: RunnerEvent[] = [];
|
|
83
|
+
for await (const evt of runner.run({ messages, registry })) {
|
|
84
|
+
out.push(evt);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
describe("AGUIRunner", () => {
|
|
72
90
|
beforeEach(() => {
|
|
73
91
|
nextScript = [];
|
|
@@ -103,8 +121,8 @@ describe("AGUIRunner", () => {
|
|
|
103
121
|
expect(types).toContain("text-message-end");
|
|
104
122
|
expect(types).toContain("run-finished");
|
|
105
123
|
|
|
106
|
-
const deltas = events.filter(
|
|
107
|
-
e.type === "text-delta",
|
|
124
|
+
const deltas = events.filter(
|
|
125
|
+
(e): e is Extract<RunnerEvent, { type: "text-delta" }> => e.type === "text-delta",
|
|
108
126
|
);
|
|
109
127
|
expect(deltas.map((d) => d.delta).join("")).toBe("Hello world");
|
|
110
128
|
});
|
|
@@ -121,7 +139,7 @@ describe("AGUIRunner", () => {
|
|
|
121
139
|
} as never,
|
|
122
140
|
} as never);
|
|
123
141
|
await s.onToolCallArgsEvent?.({
|
|
124
|
-
event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta:
|
|
142
|
+
event: { type: "TOOL_CALL_ARGS", toolCallId: "tc-1", delta: '{"q":"x"}' } as never,
|
|
125
143
|
} as never);
|
|
126
144
|
await s.onToolCallEndEvent?.({
|
|
127
145
|
event: { type: "TOOL_CALL_END", toolCallId: "tc-1" } as never,
|
|
@@ -140,8 +158,8 @@ describe("AGUIRunner", () => {
|
|
|
140
158
|
const runner = new AGUIRunner();
|
|
141
159
|
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
142
160
|
|
|
143
|
-
const start = events.find(
|
|
144
|
-
e.type === "tool-call-start",
|
|
161
|
+
const start = events.find(
|
|
162
|
+
(e): e is Extract<RunnerEvent, { type: "tool-call-start" }> => e.type === "tool-call-start",
|
|
145
163
|
);
|
|
146
164
|
expect(start).toBeDefined();
|
|
147
165
|
expect(start!.isClientSide).toBe(false);
|
|
@@ -212,8 +230,8 @@ describe("AGUIRunner", () => {
|
|
|
212
230
|
expect(lastAssistantMsg?.toolCalls?.[0]?.id).toBe("tc-1");
|
|
213
231
|
|
|
214
232
|
// tool-call-start should have isClientSide=true
|
|
215
|
-
const start = events.find(
|
|
216
|
-
e.type === "tool-call-start",
|
|
233
|
+
const start = events.find(
|
|
234
|
+
(e): e is Extract<RunnerEvent, { type: "tool-call-start" }> => e.type === "tool-call-start",
|
|
217
235
|
);
|
|
218
236
|
expect(start!.isClientSide).toBe(true);
|
|
219
237
|
});
|
|
@@ -276,6 +294,63 @@ describe("AGUIRunner", () => {
|
|
|
276
294
|
expect(lastAssistant?.content).toBe("Navigating...");
|
|
277
295
|
});
|
|
278
296
|
|
|
297
|
+
it("preserves hidden protocol messages from AG-UI snapshots across visible UI turns", async () => {
|
|
298
|
+
const registry = makeRegistry({ isRegistered: () => false });
|
|
299
|
+
const runner = new AGUIRunner();
|
|
300
|
+
|
|
301
|
+
nextScript = [
|
|
302
|
+
async (s, agent) => {
|
|
303
|
+
agent.messages = [
|
|
304
|
+
{
|
|
305
|
+
id: "spaces-global-guidance:abc",
|
|
306
|
+
role: "system",
|
|
307
|
+
content: "Tenant guidance",
|
|
308
|
+
},
|
|
309
|
+
{ id: "u-1", role: "user", content: "What is your name?" },
|
|
310
|
+
{ id: "a-1", role: "assistant", content: "Magic Luna." },
|
|
311
|
+
];
|
|
312
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
await collectWithMessages(
|
|
317
|
+
runner,
|
|
318
|
+
[{ id: "u-1", role: "user", content: "What is your name?" }],
|
|
319
|
+
registry,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
nextScript = [
|
|
323
|
+
async (s) => {
|
|
324
|
+
await s.onRunFinishedEvent?.({ event: { type: "RUN_FINISHED" } } as never);
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
const events = await collectWithMessages(
|
|
329
|
+
runner,
|
|
330
|
+
[
|
|
331
|
+
{ id: "u-1", role: "user", content: "What is your name?" },
|
|
332
|
+
{ id: "a-1", role: "assistant", content: "Magic Luna." },
|
|
333
|
+
{ id: "u-2", role: "user", content: "Say your name again." },
|
|
334
|
+
],
|
|
335
|
+
registry,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const request = events.find(
|
|
339
|
+
(event): event is Extract<RunnerEvent, { type: "request-sent" }> =>
|
|
340
|
+
event.type === "request-sent",
|
|
341
|
+
);
|
|
342
|
+
expect(request?.input.messages).toEqual([
|
|
343
|
+
{
|
|
344
|
+
id: "spaces-global-guidance:abc",
|
|
345
|
+
role: "system",
|
|
346
|
+
content: "Tenant guidance",
|
|
347
|
+
},
|
|
348
|
+
{ id: "u-1", role: "user", content: "What is your name?" },
|
|
349
|
+
{ id: "a-1", role: "assistant", content: "Magic Luna." },
|
|
350
|
+
{ id: "u-2", role: "user", content: "Say your name again." },
|
|
351
|
+
]);
|
|
352
|
+
});
|
|
353
|
+
|
|
279
354
|
it("yields turn-started exactly once per run iteration", async () => {
|
|
280
355
|
nextScript = [
|
|
281
356
|
async (s) => {
|
|
@@ -320,8 +395,8 @@ describe("AGUIRunner", () => {
|
|
|
320
395
|
const runner = new AGUIRunner();
|
|
321
396
|
const events = await collect(runner, makeRegistry({ isRegistered: () => false }));
|
|
322
397
|
|
|
323
|
-
const err = events.find(
|
|
324
|
-
e.type === "run-error",
|
|
398
|
+
const err = events.find(
|
|
399
|
+
(e): e is Extract<RunnerEvent, { type: "run-error" }> => e.type === "run-error",
|
|
325
400
|
);
|
|
326
401
|
expect(err).toBeDefined();
|
|
327
402
|
expect(err!.message).toBe("boom");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { authStore } from "../msal/auth-store.ts";
|
|
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";
|
|
3
4
|
|
|
4
5
|
const store = () => authStore.getState();
|
|
5
6
|
|
|
@@ -35,3 +36,231 @@ describe("authStore", () => {
|
|
|
35
36
|
expect(store().user?.avatar).toBe("https://example.com/bob.png");
|
|
36
37
|
});
|
|
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("returns null without redirecting when no account is cached", async () => {
|
|
129
|
+
// AuthGuard calls loginRedirect when accounts.length === 0; double-
|
|
130
|
+
// redirecting from getAccessToken would race with AuthGuard.
|
|
131
|
+
const msal = mockMsal({ getAllAccounts: vi.fn().mockReturnValue([]) });
|
|
132
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
133
|
+
expect(msal.acquireTokenSilent).not.toHaveBeenCalled();
|
|
134
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
135
|
+
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("clears stale cache and redirects on InteractionRequiredAuthError", async () => {
|
|
139
|
+
const err = Object.assign(new Error("MFA required"), {
|
|
140
|
+
name: "InteractionRequiredAuthError",
|
|
141
|
+
errorCode: "interaction_required",
|
|
142
|
+
});
|
|
143
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
144
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
145
|
+
code: "interaction_required",
|
|
146
|
+
cause: err,
|
|
147
|
+
messageIncludes: [
|
|
148
|
+
"Authentication interaction required",
|
|
149
|
+
"InteractionRequiredAuthError",
|
|
150
|
+
"interaction_required",
|
|
151
|
+
"MFA required",
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
expect(msal.clearCache).toHaveBeenCalledWith({
|
|
155
|
+
account: { username: "alice@example.com", localAccountId: "alice-oid" },
|
|
156
|
+
});
|
|
157
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
158
|
+
expect(msal.loginRedirect).toHaveBeenCalledWith({
|
|
159
|
+
scopes: [config.apiScope],
|
|
160
|
+
});
|
|
161
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("clears stale cache and redirects on consent_required", async () => {
|
|
165
|
+
const err = Object.assign(new Error("consent required"), {
|
|
166
|
+
name: "InteractionRequiredAuthError",
|
|
167
|
+
errorCode: "consent_required",
|
|
168
|
+
});
|
|
169
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
170
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
171
|
+
code: "consent_required",
|
|
172
|
+
cause: err,
|
|
173
|
+
messageIncludes: ["Authentication interaction required", "consent_required"],
|
|
174
|
+
});
|
|
175
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
176
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("clears stale cache and redirects on monitor_window_timeout", async () => {
|
|
180
|
+
// Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
|
|
181
|
+
// → documented remedy includes "Invoke an interactive API".
|
|
182
|
+
const err = Object.assign(new Error("monitor_window_timeout"), {
|
|
183
|
+
name: "BrowserAuthError",
|
|
184
|
+
errorCode: "monitor_window_timeout",
|
|
185
|
+
});
|
|
186
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
187
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
188
|
+
code: "monitor_window_timeout",
|
|
189
|
+
cause: err,
|
|
190
|
+
messageIncludes: ["Authentication interaction required", "monitor_window_timeout"],
|
|
191
|
+
});
|
|
192
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
193
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
|
|
197
|
+
const err = Object.assign(new Error("interaction_in_progress"), {
|
|
198
|
+
name: "BrowserAuthError",
|
|
199
|
+
errorCode: "interaction_in_progress",
|
|
200
|
+
});
|
|
201
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
202
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
203
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("does NOT redirect on hash_empty_error (config bug, redirect won't fix)", async () => {
|
|
207
|
+
const err = Object.assign(new Error("hash_empty_error"), {
|
|
208
|
+
name: "BrowserAuthError",
|
|
209
|
+
errorCode: "hash_empty_error",
|
|
210
|
+
});
|
|
211
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
212
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
213
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does NOT redirect on a transient/unknown error", async () => {
|
|
217
|
+
// A network blip shouldn't bounce the user through a login flow.
|
|
218
|
+
const err = new Error("connection reset");
|
|
219
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
220
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
221
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
222
|
+
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("matches InteractionRequiredAuthError by class name when no errorCode is set", async () => {
|
|
226
|
+
const err = Object.assign(new Error("interaction"), {
|
|
227
|
+
name: "InteractionRequiredAuthError",
|
|
228
|
+
});
|
|
229
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
230
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
231
|
+
cause: err,
|
|
232
|
+
messageIncludes: ["Authentication interaction required", "InteractionRequiredAuthError"],
|
|
233
|
+
});
|
|
234
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
235
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("preserves redirect failure details when interactive recovery cannot start", async () => {
|
|
239
|
+
const tokenErr = Object.assign(new Error("MFA required"), {
|
|
240
|
+
name: "InteractionRequiredAuthError",
|
|
241
|
+
errorCode: "interaction_required",
|
|
242
|
+
});
|
|
243
|
+
const redirectErr = Object.assign(new Error("redirect blocked"), {
|
|
244
|
+
name: "BrowserAuthError",
|
|
245
|
+
errorCode: "redirect_failed",
|
|
246
|
+
});
|
|
247
|
+
const msal = mockMsal({
|
|
248
|
+
acquireTokenSilent: vi.fn().mockRejectedValue(tokenErr),
|
|
249
|
+
loginRedirect: vi.fn().mockRejectedValue(redirectErr),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await expectInteractiveRecoveryError(store().getAccessToken(), {
|
|
253
|
+
code: "redirect_failed",
|
|
254
|
+
cause: redirectErr,
|
|
255
|
+
messageIncludes: [
|
|
256
|
+
"Authentication interaction required",
|
|
257
|
+
"login redirect failed",
|
|
258
|
+
"redirect_failed",
|
|
259
|
+
"Original token error",
|
|
260
|
+
"interaction_required",
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
expect(msal.clearCache).toHaveBeenCalledOnce();
|
|
264
|
+
expect(msal.loginRedirect).toHaveBeenCalledOnce();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
});
|
|
@@ -172,15 +172,133 @@ describe("createServiceFetch", () => {
|
|
|
172
172
|
expect(merged.get("X-Init-Only")).toBe("init");
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
it("
|
|
175
|
+
it("does not downgrade token failures to anonymous protected calls", async () => {
|
|
176
176
|
const acquireToken = vi.fn().mockRejectedValue(new Error("token boom"));
|
|
177
177
|
const serviceFetch = createServiceFetch({ acquireToken });
|
|
178
178
|
|
|
179
|
-
await serviceFetch("/api/spaces/entities");
|
|
179
|
+
await expect(serviceFetch("/api/spaces/entities")).rejects.toThrow("token boom");
|
|
180
180
|
|
|
181
|
-
expect(fetchSpy).
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("401 retry with forceRefresh", () => {
|
|
185
|
+
it("retries once with forceRefresh when first attempt returns 401", async () => {
|
|
186
|
+
// Resource server rejects the first (stale-cached) token, then accepts
|
|
187
|
+
// the freshly-acquired one. This is the long-lived-tab recovery path.
|
|
188
|
+
fetchSpy.mockReset();
|
|
189
|
+
fetchSpy
|
|
190
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
191
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
192
|
+
const acquireToken = vi
|
|
193
|
+
.fn()
|
|
194
|
+
.mockResolvedValueOnce("stale-token")
|
|
195
|
+
.mockResolvedValueOnce("fresh-token");
|
|
196
|
+
const serviceFetch = createServiceFetch({ acquireToken });
|
|
197
|
+
|
|
198
|
+
const res = await serviceFetch("/api/spaces/entities");
|
|
199
|
+
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
202
|
+
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
203
|
+
// First call: no forceRefresh (use cached token)
|
|
204
|
+
expect(acquireToken).toHaveBeenNthCalledWith(1, undefined);
|
|
205
|
+
// Second call: forceRefresh: true (skip MSAL cache, hit token endpoint)
|
|
206
|
+
expect(acquireToken).toHaveBeenNthCalledWith(2, { forceRefresh: true });
|
|
207
|
+
|
|
208
|
+
const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
|
|
209
|
+
"Authorization",
|
|
210
|
+
);
|
|
211
|
+
const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
|
|
212
|
+
"Authorization",
|
|
213
|
+
);
|
|
214
|
+
expect(firstAuth).toBe("Bearer stale-token");
|
|
215
|
+
expect(secondAuth).toBe("Bearer fresh-token");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not retry on non-401 responses", async () => {
|
|
219
|
+
fetchSpy.mockReset();
|
|
220
|
+
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
|
221
|
+
const acquireToken = vi.fn().mockResolvedValue("token");
|
|
222
|
+
const serviceFetch = createServiceFetch({ acquireToken });
|
|
223
|
+
|
|
224
|
+
const res = await serviceFetch("/api/spaces/entities");
|
|
225
|
+
|
|
226
|
+
expect(res.status).toBe(500);
|
|
227
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(acquireToken).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not retry when caller supplied their own Authorization header", async () => {
|
|
232
|
+
// The caller owns the token in this case; retrying would let our auth
|
|
233
|
+
// layer overwrite their explicit header on the second attempt.
|
|
234
|
+
fetchSpy.mockReset();
|
|
235
|
+
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
236
|
+
const acquireToken = vi.fn();
|
|
237
|
+
const serviceFetch = createServiceFetch({ acquireToken });
|
|
238
|
+
|
|
239
|
+
const res = await serviceFetch("/api/spaces/entities", {
|
|
240
|
+
headers: { Authorization: "Bearer caller-supplied" },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(res.status).toBe(401);
|
|
244
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(acquireToken).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("preserves a POST body across the 401 retry", async () => {
|
|
249
|
+
// Regression for the body-stream-consumed bug: ReadableStream bodies
|
|
250
|
+
// are single-consume, so the retry would otherwise see an empty body
|
|
251
|
+
// and a server-side validation failure that masks the real auth
|
|
252
|
+
// recovery. We assert the second attempt receives the same body.
|
|
253
|
+
fetchSpy.mockReset();
|
|
254
|
+
fetchSpy
|
|
255
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
256
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
257
|
+
const acquireToken = vi
|
|
258
|
+
.fn()
|
|
259
|
+
.mockResolvedValueOnce("stale-token")
|
|
260
|
+
.mockResolvedValueOnce("fresh-token");
|
|
261
|
+
const serviceFetch = createServiceFetch({ acquireToken });
|
|
262
|
+
|
|
263
|
+
const payload = JSON.stringify({ name: "create-this", id: 42 });
|
|
264
|
+
const request = new Request("https://example.com/api/items", {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: { "Content-Type": "application/json" },
|
|
267
|
+
body: payload,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const res = await serviceFetch(request);
|
|
271
|
+
|
|
272
|
+
expect(res.status).toBe(200);
|
|
273
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
274
|
+
|
|
275
|
+
// Each fetch call's body must be readable and equal to the original.
|
|
276
|
+
// Awaiting both confirms neither call's body stream was already
|
|
277
|
+
// drained by the time fetch received it.
|
|
278
|
+
const firstAttempt = fetchSpy.mock.calls[0]![1];
|
|
279
|
+
const secondAttempt = fetchSpy.mock.calls[1]![1];
|
|
280
|
+
const firstBody = await new Response(firstAttempt?.body as BodyInit).text();
|
|
281
|
+
const secondBody = await new Response(secondAttempt?.body as BodyInit).text();
|
|
282
|
+
expect(firstBody).toBe(payload);
|
|
283
|
+
expect(secondBody).toBe(payload);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("only retries once even if the second attempt also returns 401", async () => {
|
|
287
|
+
// Hard auth failure (refresh token expired). The second 401 propagates
|
|
288
|
+
// to the caller; the auth layer's interaction-required path is what
|
|
289
|
+
// kicks the user to loginRedirect, not an infinite retry loop here.
|
|
290
|
+
fetchSpy.mockReset();
|
|
291
|
+
fetchSpy
|
|
292
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
|
293
|
+
.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
|
294
|
+
const acquireToken = vi.fn().mockResolvedValue("token");
|
|
295
|
+
const serviceFetch = createServiceFetch({ acquireToken });
|
|
296
|
+
|
|
297
|
+
const res = await serviceFetch("/api/spaces/entities");
|
|
298
|
+
|
|
299
|
+
expect(res.status).toBe(401);
|
|
300
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
301
|
+
expect(acquireToken).toHaveBeenCalledTimes(2);
|
|
302
|
+
});
|
|
185
303
|
});
|
|
186
304
|
});
|