Copilot review round 6. - The Basic credential is now form-urlencoded per RFC 6749 §2.3.1. Round 1 matched the SDK's raw `id:secret` on the theory that a credential should be presented the same way here as at the token endpoint; that does not hold — a client authenticated with `client_secret_post` never exercised the SDK's Basic path, so there is no precedent to match, and the raw form is ambiguous for an id containing `:` and makes `btoa` throw on a non-Latin-1 secret. The fixture decodes each half. - The ctx-less grant read is suppressed on the store's issuer *stamp* rather than on the token's value. The active slot can hold client information without a token, in which case the read falls back to the legacy grant — and a value collision with any other issuer would have dropped that grant unrevoked and unreported. - The TUI announced "OAuth state cleared" on the keypress, so with a bounded revocation request it said so while the work was still in flight and then removed it when the work finished. `onClearOAuth` returns a promise; `AuthTab` shows "Clearing OAuth state…" until it settles, ignores repeats while one is in flight, and skips exactly the revision bump its own clear caused so the confirmation is not immediately reset by it. - `handleClearOAuth` now claims an attempt and retires a superseded one, the way `handleDisconnect` does: the await can keep it suspended for seconds, long enough for the user to switch servers and have server A's outcome — including its disconnect — land on server B. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
484 lines
14 KiB
TypeScript
484 lines
14 KiB
TypeScript
import React from "react";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render } from "ink-testing-library";
|
|
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
|
|
|
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
|
|
|
import { AuthTab } from "../src/components/AuthTab.js";
|
|
|
|
const tick = async () => {
|
|
for (let i = 0; i < 8; i++)
|
|
await new Promise((resolve) => setTimeout(resolve, 4));
|
|
};
|
|
|
|
const ESC = String.fromCharCode(27);
|
|
const UP = `${ESC}[A`;
|
|
const DOWN = `${ESC}[B`;
|
|
const PAGE_UP = `${ESC}[5~`;
|
|
const PAGE_DOWN = `${ESC}[6~`;
|
|
|
|
const sampleOAuthState: OAuthConnectionState = {
|
|
authorized: true,
|
|
protocol: "standard",
|
|
serverUrl: "http://x/mcp",
|
|
client: {
|
|
clientId: "abc123",
|
|
registrationKind: "dcr",
|
|
hasClientSecret: false,
|
|
},
|
|
tokens: {
|
|
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
|
|
token_type: "Bearer",
|
|
},
|
|
authorizationServerMetadata: {
|
|
issuer: "https://auth.example.com",
|
|
authorization_endpoint: "https://auth.example.com/authorize",
|
|
token_endpoint: "https://auth.example.com/token",
|
|
response_types_supported: ["code"],
|
|
},
|
|
configuredScope: "read write",
|
|
};
|
|
|
|
function makeClient(oauthState?: OAuthConnectionState) {
|
|
const listeners = new Map<string, Set<() => void>>();
|
|
const getOAuthState = vi.fn(async () => oauthState);
|
|
const client = {
|
|
getOAuthState,
|
|
addEventListener: (event: string, fn: () => void) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event)!.add(fn);
|
|
},
|
|
removeEventListener: (event: string, fn: () => void) => {
|
|
listeners.get(event)?.delete(fn);
|
|
},
|
|
};
|
|
const fire = (event: string) => {
|
|
listeners.get(event)?.forEach((fn) => fn());
|
|
};
|
|
return {
|
|
client: client as unknown as InspectorClient,
|
|
getOAuthState,
|
|
fire,
|
|
listeners,
|
|
};
|
|
}
|
|
|
|
const baseProps = {
|
|
serverName: "srv" as string | null,
|
|
serverConfig: null,
|
|
width: 120,
|
|
height: 30,
|
|
oauthRevision: 0,
|
|
onClearOAuth: vi.fn(),
|
|
connectionStatus: "disconnected" as const,
|
|
};
|
|
|
|
const pendingStepUp = {
|
|
challenge: {
|
|
reason: "insufficient_scope" as const,
|
|
requiredScopes: ["env:read"],
|
|
authorizationScopes: ["tools:read", "env:read"],
|
|
},
|
|
enterpriseManaged: true,
|
|
};
|
|
|
|
describe("AuthTab", () => {
|
|
it("renders the placeholder when there is no server", () => {
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
serverName={null}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain(
|
|
"Select a server to view authentication",
|
|
);
|
|
});
|
|
|
|
// #2144 — the clear is now a bounded network request, so announcing "cleared"
|
|
// on the keypress would say it while the work was still in flight.
|
|
it("shows a pending state until the clear settles, and ignores repeats", async () => {
|
|
let settle: () => void = () => {};
|
|
const onClearOAuth = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
settle = resolve;
|
|
}),
|
|
);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("Clearing OAuth state");
|
|
expect(lastFrame() ?? "").not.toContain("OAuth state cleared");
|
|
|
|
// A second press while one is in flight would race the first over the same
|
|
// store entry, and nothing on screen tells the user the first is running.
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(onClearOAuth).toHaveBeenCalledTimes(1);
|
|
|
|
settle();
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
|
});
|
|
|
|
// A failed revocation still cleared local state, and the failure is reported
|
|
// through the message line — so the confirmation must not hang forever.
|
|
it("settles the confirmation even when the clear rejects", async () => {
|
|
const onClearOAuth = vi.fn(() => Promise.reject(new Error("nope")));
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
onClearOAuth={onClearOAuth}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
focused
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
|
});
|
|
|
|
// #2144 — a revocation failure is a *partial* success: the local state really
|
|
// was cleared, so it is not an `error` status, but the grant may still be
|
|
// live at the authorization server and the informational tone understates
|
|
// that. Both tones are exercised so neither branch can rot.
|
|
it("renders an idle message in the warning tone when asked", () => {
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage="Cleared locally, but revoking failed."
|
|
oauthMessageTone="warning"
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Cleared locally");
|
|
});
|
|
|
|
it("renders an idle message in the default informational tone", () => {
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={null}
|
|
oauthStatus="idle"
|
|
oauthMessage="Stored OAuth state cleared."
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Stored OAuth state cleared.");
|
|
});
|
|
|
|
it("renders OAuth details from getOAuthState", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
const frame = lastFrame() ?? "";
|
|
expect(frame).toContain("OAuth Details");
|
|
expect(frame).toContain("Authorized");
|
|
expect(frame).toContain("abc123");
|
|
expect(frame).toContain("Dynamic (DCR)");
|
|
expect(frame).toContain("read, write");
|
|
expect(frame).toContain("tok-abcdefghijklmnopqrst");
|
|
});
|
|
|
|
it("shows the not-yet-authorized hint when getOAuthState is empty", async () => {
|
|
const { client } = makeClient(undefined);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
const frame = lastFrame() ?? "";
|
|
expect(frame).toContain("No OAuth information yet");
|
|
expect(frame).toContain("Connect (C) to authorize");
|
|
});
|
|
|
|
it("renders authenticating and error status messages", async () => {
|
|
const { client } = makeClient(undefined);
|
|
const { lastFrame, rerender } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="authenticating"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Authenticating");
|
|
|
|
rerender(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="error"
|
|
oauthMessage="Something went wrong"
|
|
/>,
|
|
);
|
|
expect(lastFrame() ?? "").toContain("Something went wrong");
|
|
});
|
|
|
|
it("clears OAuth state on S and shows confirmation", async () => {
|
|
const onClearOAuth = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
onClearOAuth={onClearOAuth}
|
|
/>,
|
|
);
|
|
stdin.write("s");
|
|
await tick();
|
|
expect(onClearOAuth).toHaveBeenCalled();
|
|
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
|
});
|
|
|
|
it("shows clear+disconnect label when connected", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
connectionStatus="connected"
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("clear+disconnect");
|
|
});
|
|
|
|
it("shows the focused footer when focused", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("S clear");
|
|
});
|
|
|
|
it("scrolls with arrow and page keys when focused", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame, stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
height={80}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write(UP);
|
|
await tick();
|
|
stdin.write(DOWN);
|
|
await tick();
|
|
stdin.write(PAGE_UP);
|
|
await tick();
|
|
stdin.write(PAGE_DOWN);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
|
});
|
|
|
|
it("navigates step-up choices with arrows and activates with Enter", async () => {
|
|
const onAuthorizeStepUp = vi.fn();
|
|
const onCancelStepUp = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
|
onCancelStepUp={onCancelStepUp}
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("\r");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onCancelStepUp).not.toHaveBeenCalled();
|
|
|
|
onAuthorizeStepUp.mockClear();
|
|
stdin.write(DOWN);
|
|
await tick();
|
|
stdin.write("\r");
|
|
await tick();
|
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onAuthorizeStepUp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows step-up footer with selection hints when focused", async () => {
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { lastFrame } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={vi.fn()}
|
|
onCancelStepUp={vi.fn()}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(lastFrame() ?? "").toContain("↑/↓ select, Enter confirm");
|
|
});
|
|
|
|
it("refreshes OAuth state when connection becomes connected", async () => {
|
|
const { client, getOAuthState } = makeClient(undefined);
|
|
const { lastFrame, rerender } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
connectionStatus="disconnected"
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalledTimes(1);
|
|
expect(lastFrame() ?? "").toContain("No OAuth information yet");
|
|
|
|
getOAuthState.mockResolvedValue(sampleOAuthState);
|
|
rerender(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
connectionStatus="connected"
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalledTimes(2);
|
|
expect(lastFrame() ?? "").toContain("Authorized");
|
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
|
});
|
|
|
|
it("moves the step-up selection back up with the up arrow", async () => {
|
|
const onAuthorizeStepUp = vi.fn();
|
|
const onCancelStepUp = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
|
onCancelStepUp={onCancelStepUp}
|
|
/>,
|
|
);
|
|
await tick();
|
|
// Move down to "cancel" (index 1), then back up to "authorize" (index 0).
|
|
stdin.write(DOWN);
|
|
await tick();
|
|
stdin.write(UP);
|
|
await tick();
|
|
stdin.write("\r");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onCancelStepUp).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("authorizes step-up with 'a', cancels with 'c', and ignores other keys", async () => {
|
|
const onAuthorizeStepUp = vi.fn();
|
|
const onCancelStepUp = vi.fn();
|
|
const { client } = makeClient(sampleOAuthState);
|
|
const { stdin } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
focused
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
pendingStepUp={pendingStepUp}
|
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
|
onCancelStepUp={onCancelStepUp}
|
|
/>,
|
|
);
|
|
await tick();
|
|
stdin.write("a");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
|
|
stdin.write("c");
|
|
await tick();
|
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
|
|
|
// An unrelated key is swallowed while the step-up prompt is pending.
|
|
stdin.write("x");
|
|
await tick();
|
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refreshes OAuth state when oauthComplete fires", async () => {
|
|
const { client, getOAuthState, fire, listeners } =
|
|
makeClient(sampleOAuthState);
|
|
const { unmount } = render(
|
|
<AuthTab
|
|
{...baseProps}
|
|
inspectorClient={client}
|
|
oauthStatus="idle"
|
|
oauthMessage={null}
|
|
/>,
|
|
);
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalled();
|
|
expect(listeners.get("oauthComplete")?.size).toBe(1);
|
|
getOAuthState.mockClear();
|
|
fire("oauthComplete");
|
|
await tick();
|
|
expect(getOAuthState).toHaveBeenCalled();
|
|
unmount();
|
|
expect(listeners.get("oauthComplete")?.size).toBe(0);
|
|
});
|
|
});
|