inspector/clients/web/src/hooks/useOAuthRecovery.test.tsx
cliffhall 5f875e3393 chore(web): address Copilot review on #2153
- Publish `setupClientForServerRef` from a layout effect rather than during
  render. React runs every layout effect before any passive effect of the same
  commit, and the /oauth/callback effect is passive, so the seam is still
  published before its first read — without the render-phase ref mutation the
  compiler rule rejects. This was the one `react-hooks/refs` finding the PR
  introduced, so its suppression is gone rather than deferred to #2161 under a
  justification that did not describe it.
- Narrow the hook's `fetchLogRef` option to `FetchRequestSource`, the
  `getFetchRequests` slice it actually consumes, mirroring
  `InspectorClientEventSource` in useProgressToasts. That removes the test
  harness's `as unknown as` cast, and the narrowed contract is real: it
  immediately rejected the loosely-typed fixture, which is now a properly typed
  `FetchRequestEntry`.
- Make the test harness hold both refs across renders and publish them from a
  layout effect, as App.tsx does. The deferred-publication test previously
  passed because `Probe` built a fresh ref each render and the rerender also
  changed `servers`; it now asserts what the guard is actually for — a run that
  cannot exchange must not spend the single-use code — and re-triggers on the
  `servers` change that carries it in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 23:19:49 -04:00

1474 lines
50 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AuthorizationServerMismatchError } from "@modelcontextprotocol/client";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type {
ConnectionStatus,
FetchRequestEntry,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { useLayoutEffect, useRef } from "react";
import { renderWithMantine, act, waitFor } from "../test/renderWithMantine";
import {
OAUTH_RESUME_KEY,
writeOAuthResumeSnapshot,
type OAuthResumeSnapshot,
} from "../lib/oauthResume";
import type { StepUpSource } from "../utils/stepUp";
import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults";
import { useSessionRef } from "./useSessionRef";
import { useTabUiState } from "./useTabUiState";
import {
useOAuthRecovery,
type FetchRequestSource,
type OAuthRecovery,
type SetupClientForServer,
} from "./useOAuthRecovery";
// --- Module doubles ---------------------------------------------------------
// Everything the hook reaches outside React: the toast layer, the two storage
// adapters, and the tab-visibility listener. `lib/oauthResume` is deliberately
// NOT mocked — it is a thin sessionStorage round-trip, and driving the real one
// is what proves the snapshot the redirect writes is the snapshot the callback
// reads back.
const { notificationsMock } = vi.hoisted(() => ({
notificationsMock: { show: vi.fn(), update: vi.fn(), hide: vi.fn() },
}));
vi.mock("@mantine/notifications", () => ({ notifications: notificationsMock }));
const { oauthStorageMock } = vi.hoisted(() => ({
oauthStorageMock: { load: vi.fn(), clear: vi.fn() },
}));
vi.mock("../lib/remoteOAuthStorage", () => ({
getWebRemoteOAuthStorage: () => oauthStorageMock,
}));
const { clearServerOAuthStateMock } = vi.hoisted(() => ({
clearServerOAuthStateMock: vi.fn(),
}));
vi.mock("../lib/clearServerOAuthState", () => ({
clearServerOAuthState: clearServerOAuthStateMock,
}));
const { saveSessionMock } = vi.hoisted(() => ({ saveSessionMock: vi.fn() }));
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
RemoteInspectorClientStorage: class {
saveSession = saveSessionMock;
},
}));
vi.mock("../lib/authToken", () => ({ getAuthToken: () => "test-token" }));
const { visibility } = vi.hoisted(() => ({
visibility: {
visible: true,
listeners: new Set<() => void>(),
},
}));
vi.mock("../lib/browserTabVisibility", () => ({
isBrowserTabVisible: () => visibility.visible,
onBrowserTabVisible: (cb: () => void) => {
visibility.listeners.add(cb);
return () => visibility.listeners.delete(cb);
},
}));
/** Fire the tab-became-visible signal the deferred-recovery path waits on. */
function becomeVisible(): void {
visibility.visible = true;
for (const cb of visibility.listeners) cb();
}
// --- Fixtures ---------------------------------------------------------------
const AUTH_URL = new URL("https://as.example/authorize");
/** A 64-char-hex authId, the only `state` shape the callback path accepts. */
const AUTH_ID = "a".repeat(64);
const entry = (
id: string,
over: Partial<ServerEntry> = {},
enterpriseManaged?: boolean,
): ServerEntry => ({
id,
name: `Server ${id}`,
config: { type: "streamable-http", url: "https://mcp.example/mcp" },
connection: { status: "disconnected" },
...(enterpriseManaged !== undefined && {
settings: { ...EMPTY_SETTINGS, enterpriseManaged },
}),
...over,
});
const challenge = (
reason: AuthChallenge["reason"] = "unauthorized",
): AuthChallenge => ({ reason });
/** One Network-log row, as the pre-redirect flush would find it. */
const fetchEntry = (id: string): FetchRequestEntry => ({
id,
timestamp: new Date(0),
method: "POST",
url: "https://as.example/token",
requestHeaders: {},
category: "auth",
});
/**
* A stand-in `InspectorClient`.
*
* Built on the real prototype (`Object.create`) so the cast is a single one
* rather than an `as unknown as`: the object genuinely is an `InspectorClient`
* by prototype, and the assignment below overrides the handful of members the
* hook calls. The event surface delegates to a real `EventTarget`, since the
* native one cannot be inherited without running its constructor.
*/
function fakeClient(over: Partial<Record<string, unknown>> = {}) {
const bus = new EventTarget();
const client = Object.create(InspectorClient.prototype) as InspectorClient;
return Object.assign(client, {
addEventListener: bus.addEventListener.bind(bus),
removeEventListener: bus.removeEventListener.bind(bus),
emit: (type: string, detail: unknown) =>
bus.dispatchEvent(new CustomEvent(type, { detail })),
getOAuthState: vi.fn().mockResolvedValue(null),
getRemoteBackendSessionId: vi.fn().mockReturnValue("remote-1"),
beginInteractiveAuthorization: vi.fn().mockResolvedValue(undefined),
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
pushRemoteAuthState: vi.fn().mockResolvedValue(undefined),
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "failed" }),
disconnect: vi.fn().mockResolvedValue(undefined),
resumeAfterOAuth: vi.fn().mockResolvedValue(undefined),
...over,
});
}
type FakeClient = ReturnType<typeof fakeClient>;
interface HarnessProps {
servers?: ServerEntry[];
activeServerId?: string;
client?: FakeClient | null;
connectionStatus?: ConnectionStatus;
/** Left null to exercise the "connect path not wired yet" callback arm. */
setupClient?: (server: ServerEntry) => InspectorClient;
fetchRequests?: FetchRequestEntry[];
/** Set to drop the Network log entirely, as before the first connect. */
noFetchLog?: boolean;
}
interface Harness {
api: () => OAuthRecovery;
rerender: (next: HarnessProps) => void;
spies: {
setActiveServerId: ReturnType<typeof vi.fn>;
setFailedServerId: ReturnType<typeof vi.fn>;
clearResultPanels: ReturnType<typeof vi.fn>;
setSourceScopedError: ReturnType<typeof vi.fn>;
setupClient: ReturnType<typeof vi.fn>;
};
}
function harness(initial: HarnessProps = {}): Harness {
let latest: OAuthRecovery | undefined;
const spies = {
setActiveServerId: vi.fn(),
setFailedServerId: vi.fn(),
clearResultPanels: vi.fn(),
setSourceScopedError: vi.fn(),
setupClient: vi.fn(),
};
function Probe({ p }: { p: HarnessProps }) {
const client = p.client ?? null;
const sessionRef = useSessionRef({
activeServerId: p.activeServerId,
servers: p.servers ?? [],
inspectorClient: client,
});
const { ui, setUi, activeTab, setActiveTab } = useTabUiState();
const fetchLogRef = useRef<FetchRequestSource | null>(null);
// Both refs are held across renders and published from a layout effect,
// exactly as App.tsx does — a fresh object per render would give the
// callback effect a changing dependency the real one never has, and the
// deferred-publication test below would then be passing for the wrong
// reason.
const setupClientForServerRef = useRef<SetupClientForServer | null>(null);
useLayoutEffect(() => {
fetchLogRef.current = p.noFetchLog
? null
: { getFetchRequests: () => p.fetchRequests ?? [] };
setupClientForServerRef.current = p.setupClient
? (server: ServerEntry) => {
spies.setupClient(server);
return p.setupClient!(server);
}
: null;
});
latest = useOAuthRecovery({
sessionRef,
servers: p.servers ?? [],
activeServerId: p.activeServerId,
inspectorClient: client,
connectionStatus: p.connectionStatus ?? "connected",
activeTab,
ui,
setUi,
setActiveTab,
fetchLogRef,
connectStartRef: { current: undefined },
initialConfigSettledRef: { current: { promise: Promise.resolve() } },
setupClientForServerRef,
setActiveServerId: spies.setActiveServerId,
setFailedServerId: spies.setFailedServerId,
clearResultPanels: spies.clearResultPanels,
setSourceScopedError: spies.setSourceScopedError,
});
return null;
}
const { rerender } = renderWithMantine(<Probe p={initial} />);
return {
api: () => {
if (!latest) throw new Error("hook did not render");
return latest;
},
rerender: (next) => rerender(<Probe p={next} />),
spies,
};
}
/** Every toast title raised so far, for order-insensitive assertions. */
const toastTitles = (): string[] =>
notificationsMock.show.mock.calls.map((c) => String(c[0]?.title));
const toastWith = (fragment: string) =>
notificationsMock.show.mock.calls.find((c) =>
String(c[0]?.message ?? "").includes(fragment),
);
/** Put the page on `/oauth/callback` with the given query. */
function onCallbackUrl(search: string): void {
window.history.replaceState({}, "", `/oauth/callback${search}`);
}
beforeEach(() => {
vi.clearAllMocks();
visibility.visible = true;
visibility.listeners.clear();
window.sessionStorage.clear();
window.history.replaceState({}, "", "/");
oauthStorageMock.load.mockResolvedValue(undefined);
clearServerOAuthStateMock.mockResolvedValue(true);
saveSessionMock.mockResolvedValue(undefined);
});
afterEach(() => {
window.history.replaceState({}, "", "/");
});
describe("useOAuthRecovery", () => {
describe("pending slots", () => {
it("mirrors the step-up slot into the session ref and clears it on a server switch", async () => {
const client = fakeClient();
const servers = [entry("a"), entry("b")];
const h = harness({ servers, activeServerId: "a", client });
await act(async () => {
await h
.api()
.handleCommandScopedAuthRecovery(
new AuthRecoveryRequiredError(
AUTH_URL,
challenge("insufficient_scope"),
),
{ serverId: "a", source: "tool" },
);
});
expect(h.api().pendingStepUp?.serverId).toBe("a");
h.rerender({ servers, activeServerId: "b", client });
await waitFor(() => expect(h.api().pendingStepUp).toBeNull());
});
it("drops a deferred recovery when the user switches server", async () => {
visibility.visible = false;
const client = fakeClient();
const servers = [entry("a"), entry("b")];
const h = harness({ servers, activeServerId: "a", client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
h.rerender({ servers, activeServerId: "b", client });
await act(async () => {
becomeVisible();
});
// Cleared rather than resumed against the server the user left.
expect(client.handleAuthChallenge).not.toHaveBeenCalled();
});
it("refuses a second step-up while one is open", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
const raise = () =>
h
.api()
.handleCommandScopedAuthRecovery(
new AuthRecoveryRequiredError(
AUTH_URL,
challenge("insufficient_scope"),
),
{ serverId: "a", source: "tool" },
);
await act(async () => {
await raise();
});
const first = h.api().pendingStepUp;
await act(async () => {
await raise();
});
expect(h.api().pendingStepUp).toBe(first);
expect(toastTitles()).toContain("Step-up authorization in progress");
});
it("resets the banner and both slots on a session reset", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await h
.api()
.handleCommandScopedAuthRecovery(
new AuthRecoveryRequiredError(
AUTH_URL,
challenge("insufficient_scope"),
),
{ serverId: "a", source: "tool" },
);
});
act(() => h.api().resetOAuthRecoveryState());
expect(h.api().pendingStepUp).toBeNull();
expect(h.api().reAuthBanner).toBeNull();
});
});
describe("connection-info OAuth details", () => {
it("loads details while connected and refreshes on oauthComplete", async () => {
const client = fakeClient({
getOAuthState: vi
.fn()
.mockResolvedValue({ tokens: { access_token: "t" } }),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await waitFor(() => expect(h.api().connectionInfoOAuth).toBeDefined());
expect(client.getOAuthState).toHaveBeenCalledTimes(1);
await act(async () => {
client.emit("oauthComplete", {});
});
await waitFor(() =>
expect(client.getOAuthState).toHaveBeenCalledTimes(2),
);
});
it("clears the details when the state read comes back empty", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await waitFor(() => expect(client.getOAuthState).toHaveBeenCalled());
expect(h.api().connectionInfoOAuth).toBeUndefined();
});
it("drops a state read that lands after the session ended", async () => {
let settle: (value: unknown) => void = () => {};
const client = fakeClient({
getOAuthState: vi.fn(
() =>
new Promise((resolve) => {
settle = resolve;
}),
),
});
const props: HarnessProps = {
servers: [entry("a")],
activeServerId: "a",
client,
};
const h = harness(props);
h.rerender({ ...props, connectionStatus: "disconnected" });
await act(async () => {
settle({ tokens: { access_token: "t" } });
});
expect(h.api().connectionInfoOAuth).toBeUndefined();
});
it("stays undefined while disconnected", async () => {
const client = fakeClient();
const h = harness({
servers: [entry("a")],
activeServerId: "a",
client,
connectionStatus: "disconnected",
});
expect(h.api().connectionInfoOAuth).toBeUndefined();
expect(client.getOAuthState).not.toHaveBeenCalled();
});
it("toasts an ambient refresh, naming the active server", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await waitFor(() => expect(client.getOAuthState).toHaveBeenCalled());
await act(async () => {
client.emit("authChallengeAmbient", {});
});
expect(toastTitles()).toContain(
'Refreshing authorization for "Server a"',
);
h.rerender({ servers: [], activeServerId: undefined, client });
});
it("falls back to the unnamed ambient copy with no active server", async () => {
const client = fakeClient();
harness({ servers: [], activeServerId: undefined, client });
await waitFor(() => expect(client.getOAuthState).toHaveBeenCalled());
await act(async () => {
client.emit("authChallengeAmbient", {});
});
expect(toastTitles()).toContain("Refreshing authorization");
});
});
describe("onBeforeOAuthRedirect", () => {
it("saves the pre-redirect fetch log under the authorization state's authId", () => {
const entries = [fetchEntry("f1")];
const h = harness({ fetchRequests: entries });
const url = new URL(`https://as.example/authorize?state=${AUTH_ID}`);
act(() => h.api().onBeforeOAuthRedirect(url));
expect(saveSessionMock).toHaveBeenCalledWith(
AUTH_ID,
expect.objectContaining({ fetchRequests: entries }),
);
});
it("does nothing without a parseable state parameter", () => {
const h = harness({ fetchRequests: [fetchEntry("f1")] });
act(() => h.api().onBeforeOAuthRedirect(new URL(AUTH_URL)));
act(() =>
h
.api()
.onBeforeOAuthRedirect(
new URL("https://as.example/authorize?state=nope"),
),
);
expect(saveSessionMock).not.toHaveBeenCalled();
});
it("does nothing when the log is empty", () => {
const h = harness({ fetchRequests: [] });
act(() =>
h
.api()
.onBeforeOAuthRedirect(
new URL(`https://as.example/authorize?state=${AUTH_ID}`),
),
);
expect(saveSessionMock).not.toHaveBeenCalled();
});
it("does nothing before the Network log exists", () => {
const h = harness({ noFetchLog: true });
act(() =>
h
.api()
.onBeforeOAuthRedirect(
new URL(`https://as.example/authorize?state=${AUTH_ID}`),
),
);
expect(saveSessionMock).not.toHaveBeenCalled();
});
it("swallows a failed save", async () => {
saveSessionMock.mockRejectedValue(new Error("offline"));
const h = harness({ fetchRequests: [fetchEntry("f1")] });
act(() =>
h
.api()
.onBeforeOAuthRedirect(
new URL(`https://as.example/authorize?state=${AUTH_ID}`),
),
);
await waitFor(() => expect(saveSessionMock).toHaveBeenCalled());
});
});
describe("prepareOAuthRedirect", () => {
it("writes the resume snapshot and starts the authorization", () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
act(() =>
h.api().prepareOAuthRedirect({
serverId: "a",
authKind: "reauth",
authorizationUrl: AUTH_URL,
recoverySource: "tool",
}),
);
const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY);
expect(raw).toBeTruthy();
const snapshot = JSON.parse(String(raw)) as OAuthResumeSnapshot;
expect(snapshot).toMatchObject({
serverId: "a",
authKind: "reauth",
remoteSessionId: "remote-1",
recoverySource: "tool",
});
expect(client.beginInteractiveAuthorization).toHaveBeenCalledWith(
AUTH_URL,
);
});
it("carries the step-up challenge and prefers an explicitly passed client", () => {
const active = fakeClient();
const explicit = fakeClient();
const h = harness({
servers: [entry("a")],
activeServerId: "a",
client: active,
});
const stepUpChallenge = challenge("insufficient_scope");
act(() =>
h.api().prepareOAuthRedirect({
serverId: "a",
authKind: "step_up",
authorizationUrl: AUTH_URL,
authChallenge: stepUpChallenge,
client: explicit,
preRedirectContext: "connect",
}),
);
const snapshot = JSON.parse(
String(window.sessionStorage.getItem(OAUTH_RESUME_KEY)),
) as OAuthResumeSnapshot;
expect(snapshot.authChallenge).toMatchObject({
reason: "insufficient_scope",
});
expect(explicit.beginInteractiveAuthorization).toHaveBeenCalled();
expect(active.beginInteractiveAuthorization).not.toHaveBeenCalled();
});
it("tolerates having no client at all", () => {
const h = harness({ servers: [entry("a")], client: null });
act(() =>
h.api().prepareOAuthRedirect({
serverId: "a",
authKind: "reauth",
authorizationUrl: AUTH_URL,
}),
);
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeTruthy();
});
});
describe("command-scoped recovery", () => {
const recoveryError = (reason: AuthChallenge["reason"] = "unauthorized") =>
new AuthRecoveryRequiredError(AUTH_URL, challenge(reason));
it("returns false with no live client", async () => {
const h = harness({ servers: [entry("a")], client: null });
await act(async () => {
await expect(
h.api().handleCommandScopedAuthRecovery(recoveryError(), {
serverId: "a",
source: "tool",
}),
).resolves.toBe(false);
});
});
it("reports a stored token that already satisfies the challenge", async () => {
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await expect(
h.api().handleCommandScopedAuthRecovery(recoveryError(), {
serverId: "a",
source: "tool",
}),
).resolves.toBe(true);
});
expect(client.pushRemoteAuthState).toHaveBeenCalled();
expect(toastTitles()).toContain("Authorization restored");
});
it("short-circuits an EMA step-up whose scopes are already stored", async () => {
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
});
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
await act(async () => {
await expect(
h.api().handleCommandScopedAuthRecovery(
new AuthRecoveryRequiredError(AUTH_URL, challenge("unauthorized"), {
emaStepUpConfirm: true,
}),
{ serverId: "a", source: "tool" },
),
).resolves.toBe(true);
});
expect(h.api().pendingStepUp).toBeNull();
});
it("redirects for a plain re-auth challenge", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await expect(
h.api().handleCommandScopedAuthRecovery(recoveryError(), {
serverId: "a",
source: "prompt",
}),
).resolves.toBe(false);
});
expect(client.beginInteractiveAuthorization).toHaveBeenCalled();
});
it("runs an operation unchanged when there is no session to recover", async () => {
const h = harness({ servers: [entry("a")], client: null });
const op = vi.fn().mockResolvedValue("value");
await act(async () => {
await expect(
h.api().runWithCommandAuthRecovery(op, "tool"),
).resolves.toBe("value");
});
expect(op).toHaveBeenCalledTimes(1);
});
it("retries the operation once recovery is satisfied", async () => {
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
const op = vi
.fn()
.mockRejectedValueOnce(recoveryError())
.mockResolvedValue("second");
await act(async () => {
await expect(
h.api().runWithCommandAuthRecovery(op, "tool"),
).resolves.toBe("second");
});
expect(op).toHaveBeenCalledTimes(2);
});
it("gives up quietly when recovery is not satisfied", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
const op = vi.fn().mockRejectedValue(recoveryError());
await act(async () => {
await expect(
h.api().runWithCommandAuthRecovery(op, "tool"),
).resolves.toBeUndefined();
});
expect(op).toHaveBeenCalledTimes(1);
});
it("rethrows anything that is not an auth-recovery error", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await expect(
h
.api()
.runWithCommandAuthRecovery(
() => Promise.reject(new Error("boom")),
"tool",
),
).rejects.toThrow("boom");
});
});
it("toasts a background failure only when given a title", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
h.api().runCommandInBackground(
() => Promise.reject(new Error("boom")),
"ambient",
"Refresh failed",
);
await Promise.resolve();
});
await waitFor(() => expect(toastTitles()).toContain("Refresh failed"));
notificationsMock.show.mockClear();
await act(async () => {
h.api().runCommandInBackground(
() => Promise.reject("plain string"),
"ambient",
);
await Promise.resolve();
});
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("stringifies a non-Error background failure into the toast", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
h.api().runCommandInBackground(
() => Promise.reject("plain string"),
"ambient",
"Refresh failed",
);
await Promise.resolve();
});
await waitFor(() => expect(toastWith("plain string")).toBeDefined());
});
});
describe("ambient challenges", () => {
it("defers recovery while the tab is hidden, then resumes when it returns", async () => {
visibility.visible = false;
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
});
harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
expect(toastTitles()).toContain("Authorization pending");
await act(async () => {
becomeVisible();
});
await waitFor(() =>
expect(client.checkAuthChallengeSatisfied).toHaveBeenCalled(),
);
expect(toastTitles()).toContain("Authorization restored");
});
it("warns when a second deferral replaces the first", async () => {
visibility.visible = false;
const client = fakeClient();
harness({ servers: [entry("a")], activeServerId: "a", client });
const fire = () =>
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
await act(async () => {
fire();
});
await act(async () => {
fire();
});
expect(toastTitles()).toContain("Authorization update pending");
});
it("ignores an ambient challenge with no active server", async () => {
const client = fakeClient();
harness({ servers: [], activeServerId: undefined, client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
expect(client.checkAuthChallengeSatisfied).not.toHaveBeenCalled();
});
it("redirects immediately for a visible ambient challenge", async () => {
const client = fakeClient();
harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
await waitFor(() =>
expect(client.beginInteractiveAuthorization).toHaveBeenCalled(),
);
});
it("opens the step-up prompt instead of redirecting for a scope challenge", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge("insufficient_scope"),
authorizationUrl: AUTH_URL,
});
});
await waitFor(() => expect(h.api().pendingStepUp).not.toBeNull());
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
});
it("does nothing when the stored token already satisfies it", async () => {
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
});
harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
await waitFor(() =>
expect(toastTitles()).toContain("Authorization restored"),
);
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
});
it("raises the banner on an oauthError event", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
client.emit("oauthError", { error: new Error("token endpoint 500") });
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
});
it("ignores an oauthError with no active server", async () => {
const client = fakeClient();
const h = harness({ servers: [], activeServerId: undefined, client });
await act(async () => {
client.emit("oauthError", { error: new Error("nope") });
});
expect(h.api().reAuthBanner).toBeNull();
});
});
describe("resuming a deferred recovery", () => {
const defer = async (client: FakeClient, h: Harness) => {
visibility.visible = false;
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge(),
authorizationUrl: AUTH_URL,
});
});
expect(h.api()).toBeDefined();
};
it("re-runs the challenge and reports a satisfied outcome", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await defer(client, h);
await act(async () => {
becomeVisible();
});
await waitFor(() =>
expect(client.handleAuthChallenge).toHaveBeenCalled(),
);
expect(toastTitles()).toContain("Authorization restored");
});
it("redirects on an interactive outcome", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({
kind: "interactive",
challenge: challenge(),
authorizationUrl: AUTH_URL,
}),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await defer(client, h);
await act(async () => {
becomeVisible();
});
await waitFor(() =>
expect(client.beginInteractiveAuthorization).toHaveBeenCalled(),
);
});
it("raises the banner on a failed outcome", async () => {
const client = fakeClient({
handleAuthChallenge: vi
.fn()
.mockResolvedValue({ kind: "failed", error: new Error("nope") }),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await defer(client, h);
await act(async () => {
becomeVisible();
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
});
it("goes back through the step-up prompt when that is what was deferred", async () => {
const client = fakeClient();
const h = harness({
servers: [entry("a")],
activeServerId: "a",
client,
});
visibility.visible = false;
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge("insufficient_scope"),
authorizationUrl: AUTH_URL,
});
});
await act(async () => {
becomeVisible();
});
await waitFor(() => expect(h.api().pendingStepUp).not.toBeNull());
expect(client.handleAuthChallenge).not.toHaveBeenCalled();
});
it("does nothing when the tab returns with nothing deferred", async () => {
const client = fakeClient();
harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
becomeVisible();
});
expect(client.handleAuthChallenge).not.toHaveBeenCalled();
});
it("does nothing once the client is gone", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const props: HarnessProps = {
servers: [entry("a")],
activeServerId: "a",
client,
};
const h = harness(props);
await defer(client, h);
h.rerender({ ...props, client: null });
await act(async () => {
becomeVisible();
});
expect(client.handleAuthChallenge).not.toHaveBeenCalled();
});
it("runs one resume at a time", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await defer(client, h);
// Both fire before the first resume's `await` settles, so the second one
// still sees the deferred slot in the session ref — which is exactly the
// re-entrancy `reauthResumeInProgressRef` exists to stop.
await act(async () => {
becomeVisible();
becomeVisible();
});
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
});
it("does not resume while disconnected, and resumes on the reconnect", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const props: HarnessProps = {
servers: [entry("a")],
activeServerId: "a",
client,
};
const h = harness(props);
await defer(client, h);
h.rerender({ ...props, connectionStatus: "disconnected" });
await act(async () => {
becomeVisible();
});
expect(client.handleAuthChallenge).not.toHaveBeenCalled();
h.rerender({ ...props, connectionStatus: "connected" });
await waitFor(() =>
expect(client.handleAuthChallenge).toHaveBeenCalled(),
);
});
});
describe("abandoned redirect", () => {
it("raises the banner for an abandoned re-auth", async () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
const h = harness({ servers: [entry("a")], activeServerId: "a" });
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
});
it("raises the banner for an abandoned step-up", async () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "step_up",
tabUi: {},
recoverySource: "tool",
});
const h = harness({ servers: [entry("a")], activeServerId: "a" });
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
});
it("does nothing without a snapshot, or before the list hydrates", async () => {
const h = harness({ servers: [] });
await waitFor(() => expect(h.api().reAuthBanner).toBeNull());
h.rerender({ servers: [entry("a")] });
await waitFor(() => expect(h.api().reAuthBanner).toBeNull());
});
it("checks once per load", async () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
const h = harness({ servers: [entry("a")], activeServerId: "a" });
await waitFor(() => expect(h.api().reAuthBanner).not.toBeNull());
act(() => h.api().setReAuthBanner(null));
h.rerender({ servers: [entry("a"), entry("b")], activeServerId: "a" });
await waitFor(() => expect(h.api().reAuthBanner).toBeNull());
});
it("skips the check on the callback path", async () => {
onCallbackUrl("");
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
const h = harness({ servers: [entry("a")] });
await waitFor(() => expect(h.api().reAuthBanner).toBeNull());
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeTruthy();
});
});
describe("/oauth/callback", () => {
const snapshot = (over: Partial<OAuthResumeSnapshot> = {}) =>
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
...over,
} as OAuthResumeSnapshot);
const callbackHarness = (
search: string,
over: Partial<HarnessProps> = {},
client = fakeClient(),
) => {
onCallbackUrl(search);
return harness({
servers: [entry("a")],
setupClient: () => client,
...over,
});
};
it("keeps the code unspent when no client builder has been published", async () => {
onCallbackUrl(`?code=abc&state=${AUTH_ID}`);
snapshot();
const client = fakeClient();
// App.tsx publishes the builder from a layout effect, so this arm is
// defensive rather than reachable there. What it has to guarantee is
// that the guard returns *before* the one-shot latch: a run that cannot
// exchange must not spend the single-use authorization code.
const h = harness({ servers: [entry("a")] });
await waitFor(() =>
expect(window.location.pathname).toBe("/oauth/callback"),
);
expect(client.resumeAfterOAuth).not.toHaveBeenCalled();
// The next `servers` change — the same dependency the hydration wait
// rides in production — still finds the latch open.
h.rerender({
servers: [entry("a"), entry("b")],
setupClient: () => client,
});
await waitFor(() => expect(client.resumeAfterOAuth).toHaveBeenCalled());
});
it("exchanges the single-use code exactly once", async () => {
snapshot();
const client = fakeClient();
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() => expect(client.resumeAfterOAuth).toHaveBeenCalled());
h.rerender({
servers: [entry("a"), entry("b")],
setupClient: () => client,
});
await waitFor(() => expect(h.spies.setupClient).toHaveBeenCalledTimes(1));
expect(client.resumeAfterOAuth).toHaveBeenCalledTimes(1);
});
it("rejects a state parameter that did not originate here", async () => {
snapshot();
const client = fakeClient();
callbackHarness("?code=abc&state=forged", {}, client);
await waitFor(() =>
expect(toastTitles()).toContain("OAuth callback rejected"),
);
expect(client.resumeAfterOAuth).not.toHaveBeenCalled();
});
it("flags the server and banners a provider error", async () => {
snapshot();
const h = callbackHarness("?error=access_denied");
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
});
it("toasts a provider error that cannot be matched to a server", async () => {
callbackHarness("?error=access_denied");
await waitFor(() =>
expect(toastTitles()).toContain("OAuth authorization failed"),
);
});
it("toasts a success that cannot be matched to a server", async () => {
callbackHarness(`?code=abc&state=${AUTH_ID}`);
await waitFor(() =>
expect(toastTitles()).toContain("OAuth callback could not be matched"),
);
});
it("restores the shell, rebuilds the client and completes", async () => {
snapshot({ remoteSessionId: "remote-9" });
const client = fakeClient();
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() => expect(client.resumeAfterOAuth).toHaveBeenCalled());
expect(client.resumeAfterOAuth).toHaveBeenCalledWith(
"abc",
expect.objectContaining({ remoteSessionId: "remote-9" }),
);
expect(h.spies.setActiveServerId).toHaveBeenCalledWith("a");
expect(h.spies.clearResultPanels).toHaveBeenCalled();
await waitFor(() =>
expect(toastTitles()).toContain("Authorization complete"),
);
});
it("banners a failure to read the OAuth store", async () => {
snapshot();
oauthStorageMock.load.mockRejectedValue(new Error("disk gone"));
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`);
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
});
it("reports an unconfigured enterprise client without flagging the server", async () => {
snapshot();
const client = fakeClient({
resumeAfterOAuth: vi
.fn()
.mockRejectedValue(new EmaClientNotConfiguredError("disabled")),
});
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() =>
expect(toastTitles()).toContain('Cannot connect to "Server a"'),
);
expect(client.disconnect).toHaveBeenCalled();
expect(h.spies.setFailedServerId).not.toHaveBeenCalled();
});
it("offers one-click recovery when the authorization state was lost", async () => {
snapshot();
const client = fakeClient({
resumeAfterOAuth: vi
.fn()
.mockRejectedValue(
new AuthorizationServerMismatchError(
"discoveryState was not available on the callback leg",
"https://as.example",
),
),
});
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() =>
expect(h.api().reAuthBanner?.kind).toBe("lost_authorization_state"),
);
expect(h.api().reAuthBanner?.actionLabel).toBeTruthy();
});
it("refuses one-click recovery on a genuine issuer mismatch", async () => {
snapshot();
const client = fakeClient({
resumeAfterOAuth: vi
.fn()
.mockRejectedValue(
new AuthorizationServerMismatchError(
"https://recorded.example",
"https://other.example",
),
),
});
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() => expect(notificationsMock.show).toHaveBeenCalled());
expect(h.api().reAuthBanner).toBeNull();
});
it("banners any other exchange failure", async () => {
snapshot();
const client = fakeClient({
resumeAfterOAuth: vi.fn().mockRejectedValue("token endpoint said no"),
});
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
});
it("reports a step-up whose extra scopes were not granted", async () => {
snapshot({
authKind: "step_up",
authChallenge: challenge("insufficient_scope"),
});
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
});
callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() =>
expect(toastTitles()).toContain("Additional permissions not granted"),
);
});
it("completes a step-up whose extra scopes were granted", async () => {
snapshot({
authKind: "step_up",
authChallenge: challenge("insufficient_scope"),
});
const client = fakeClient({
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
});
callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
await waitFor(() =>
expect(toastTitles()).toContain("Authorization complete"),
);
});
});
describe("clearing stored OAuth state", () => {
it("says nothing when there was nothing to clear", async () => {
clearServerOAuthStateMock.mockResolvedValue(false);
const h = harness({ servers: [entry("a")], activeServerId: "a" });
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("a"));
});
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("disconnects the active session and resets the shell", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("a"));
});
expect(client.disconnect).toHaveBeenCalled();
expect(
toastWith("Reconnect to run a fresh authorization flow."),
).toBeDefined();
});
it("leaves a live session alone when clearing another server", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await h.api().clearServerOAuthAndDisconnect(entry("b"));
});
expect(client.disconnect).not.toHaveBeenCalled();
expect(
toastWith('Stored OAuth state was removed for "Server b"'),
).toBeDefined();
});
it("clears the resume snapshot on an explicit disconnect", () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
const h = harness({ servers: [entry("a")] });
act(() => h.api().finalizeExplicitDisconnect());
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
});
});
describe("step-up prompt", () => {
const openStepUp = async (
h: Harness,
source: StepUpSource = "tool",
retryOperation?: () => Promise<unknown>,
) => {
await act(async () => {
await h
.api()
.handleCommandScopedAuthRecovery(
new AuthRecoveryRequiredError(
AUTH_URL,
challenge("insufficient_scope"),
),
{ serverId: "a", source, retryOperation },
);
});
};
it("does nothing with no prompt open", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
});
it("does nothing once the client is gone", async () => {
const client = fakeClient();
const props = { servers: [entry("a")], activeServerId: "a", client };
const h = harness(props);
await openStepUp(h);
h.rerender({ ...props, client: null });
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(client.beginInteractiveAuthorization).not.toHaveBeenCalled();
});
it("redirects for a standard step-up", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await openStepUp(h);
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(client.beginInteractiveAuthorization).toHaveBeenCalled();
expect(h.api().pendingStepUp).toBeNull();
});
it("runs the stored retry when an EMA step-up is satisfied in place", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
const retry = vi.fn().mockResolvedValue(undefined);
await openStepUp(h, "tool", retry);
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(retry).toHaveBeenCalledTimes(1);
expect(toastTitles()).toContain("Permissions updated");
});
it("completes an EMA step-up with no operation to retry", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
await openStepUp(h, "ambient");
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(toastTitles()).toContain("Permissions updated");
});
it("redirects when an EMA step-up needs the browser", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({
kind: "interactive",
challenge: challenge("insufficient_scope"),
authorizationUrl: AUTH_URL,
}),
});
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
await openStepUp(h);
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(client.beginInteractiveAuthorization).toHaveBeenCalled();
});
it("routes an EMA step-up failure back to the panel that asked", async () => {
const client = fakeClient({
handleAuthChallenge: vi
.fn()
.mockResolvedValue({ kind: "failed", error: new Error("denied") }),
});
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
await openStepUp(h, "prompt");
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(h.spies.setSourceScopedError).toHaveBeenCalledWith(
"prompt",
expect.stringContaining("denied"),
);
});
it("cancels back to the panel that asked", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await openStepUp(h, "resource");
act(() => h.api().handleStepUpCancel());
expect(h.spies.setSourceScopedError).toHaveBeenCalledWith(
"resource",
"Authorization cancelled.",
);
expect(h.api().pendingStepUp).toBeNull();
});
it("toasts a cancel that came from an App", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await openStepUp(h, "app");
act(() => h.api().handleStepUpCancel());
expect(toastTitles()).toContain("Authorization cancelled");
});
it("is a no-op with no prompt open", () => {
const h = harness({ servers: [entry("a")], activeServerId: "a" });
act(() => h.api().handleStepUpCancel());
expect(h.spies.setSourceScopedError).not.toHaveBeenCalled();
});
});
});