Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)

* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-29 12:35:51 -07:00 • committed by GitHub
commit 13dafe457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 5702 additions and 4444 deletions

View file

@ -52,11 +52,9 @@ const h = vi.hoisted(() => {
authenticate: vi.fn(
async (): Promise<string | undefined> => "https://auth.example/start",
),
beginGuidedAuth: vi.fn(async (): Promise<void> => {}),
runGuidedAuth: vi.fn(async (): Promise<string | undefined> => undefined),
proceedOAuthStep: vi.fn(async (): Promise<void> => {}),
clearOAuthTokens: vi.fn(),
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
getOAuthState: vi.fn(async () => undefined),
};
// Captured options from the most recent callbackServer.start(), so a test can
// drive the onCallback / onError handlers the OAuth flows register.
@ -92,14 +90,11 @@ const h = vi.hoisted(() => {
| "sse"
| "streamable-http",
);
getOAuthFlowState = vi.fn(() => ctrl.oauthFlowState);
authenticate = (...a: unknown[]) => clientSpies.authenticate(...a);
beginGuidedAuth = (...a: unknown[]) => clientSpies.beginGuidedAuth(...a);
runGuidedAuth = (...a: unknown[]) => clientSpies.runGuidedAuth(...a);
proceedOAuthStep = (...a: unknown[]) => clientSpies.proceedOAuthStep(...a);
clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a);
completeOAuthFlow = (...a: unknown[]) =>
clientSpies.completeOAuthFlow(...a);
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
readResource = vi.fn(async () => ({
result: { contents: [{ uri: "file://x", text: "hello" }] },
}));
@ -179,12 +174,17 @@ vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
vi.mock("@inspector/core/react/useStderrLog.js", () => ({
useStderrLog: h.useStderrLog,
}));
vi.mock("@inspector/core/auth/index.js", () => ({
CallbackNavigation: class {},
MutableRedirectUrlProvider: class {
redirectUrl = "";
},
}));
vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/auth/index.js")>();
return {
...actual,
CallbackNavigation: class {},
MutableRedirectUrlProvider: class {
redirectUrl = "";
},
};
});
vi.mock("@inspector/core/auth/node/index.js", () => ({
createOAuthCallbackServer: h.createOAuthCallbackServer,
NodeOAuthStorage: class {},
@ -198,6 +198,7 @@ import type { TuiServer } from "../src/tui-servers.js";
const tick = () => new Promise((r) => setTimeout(r, 25));
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
const emptyClientConfig = {};
function stdioServer(): Record<string, TuiServer> {
return {
@ -234,7 +235,7 @@ function oneHttp(): Record<string, TuiServer> {
}
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
// a stdio server — drives per-server tab gating + the tab-switch-away effects.
// a stdio server <EFBFBD> drives per-server tab gating + the tab-switch-away effects.
function httpThenStdio(): Record<string, TuiServer> {
return {
web: { config: { type: "streamable-http", url: "http://x" } } as never,
@ -351,13 +352,17 @@ const bareRequest = {
};
const stderrLog = { timestamp: new Date(0), message: "log line" };
// Track rendered instances so each is unmounted after its test — concurrent
// Track rendered instances so each is unmounted after its test <EFBFBD> concurrent
// mounted ink apps share raw-mode stdin handling and interfere with useInput.
const mounted: RenderResult[] = [];
function renderApp(servers: Record<string, TuiServer>) {
const r = render(
<App mcpServers={servers} callbackUrlConfig={callbackUrlConfig} />,
<App
mcpServers={servers}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
/>,
);
mounted.push(r);
return r;
@ -390,7 +395,7 @@ const ENTER = "\r";
/**
* Write each key in order. ink re-subscribes the active component's useInput on
* re-render, so a key that changes focus/tab must let that re-render flush
* before the next key — otherwise the next key is routed to the old handler.
* before the next key <EFBFBD> otherwise the next key is routed to the old handler.
* Two ticks per key keeps multi-step navigation deterministic under the heavier
* v8 coverage instrumentation (where a single tick can race the render).
*/
@ -423,7 +428,7 @@ async function waitForFrame(r: RenderResult, substr: string, tries = 25) {
await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries);
}
/** Poll until the frame contains `substr`, then assert it — stable under load. */
/** Poll until the frame contains `substr`, then assert it <EFBFBD> stable under load. */
async function expectFrame(r: RenderResult, substr: string) {
await waitForFrame(r, substr);
expect(r.lastFrame() ?? "").toContain(substr);
@ -456,15 +461,11 @@ beforeEach(() => {
h.callbackStop.mockClear();
h.clientSpies.authenticate.mockReset();
h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start");
h.clientSpies.beginGuidedAuth.mockReset();
h.clientSpies.beginGuidedAuth.mockResolvedValue(undefined);
h.clientSpies.runGuidedAuth.mockReset();
h.clientSpies.runGuidedAuth.mockResolvedValue(undefined);
h.clientSpies.proceedOAuthStep.mockReset();
h.clientSpies.proceedOAuthStep.mockResolvedValue(undefined);
h.clientSpies.clearOAuthTokens.mockReset();
h.clientSpies.completeOAuthFlow.mockReset();
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
h.clientSpies.getOAuthState.mockReset();
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
});
afterEach(() => {
@ -522,14 +523,14 @@ describe("App (foundation)", () => {
// shift+tab is delivered as ESC [ Z
stdin.write("");
await tick();
// no assertion on hidden focus state — exercising the branches
// no assertion on hidden focus state <EFBFBD> exercising the branches
});
it("shows the Auth tab and G/Q/S accelerators for an OAuth-capable server", async () => {
it("shows the Auth tab via the accelerator for an OAuth-capable server", async () => {
h.ctrl.serverType = "streamable-http";
const r = await mount(httpServer());
await press(r, ["g"]); // guided auth accelerator -> Auth tab
await expectFrame(r, "Auth");
await press(r, ["a"]);
await expectFrame(r, "OAuth");
});
it("renders connected status with capabilities", async () => {
@ -554,11 +555,12 @@ describe("App (status, layout, modals)", () => {
await expectFrame(r, "error");
});
it("shows the 401 auth hint for an http server with a 401 response", async () => {
it("shows error status when an http server has a 401 response logged", async () => {
h.ctrl.status = "error";
h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }];
const r = await mount(oneHttp());
await expectFrame(r, "401 Unauthorized");
await expectFrame(r, "error");
await expectFrame(r, "HTTP Requests (1)");
});
it("updates dimensions when the terminal resizes", async () => {
@ -803,10 +805,10 @@ describe("App (input handling, focus, effects)", () => {
it("switches away from the Auth tab when selecting a non-OAuth server", async () => {
const r = await mount(httpThenStdio());
await press(r, ["a"]); // Auth tab (http is OAuth-capable)
await press(r, [STAB]); // tabs -> serverList
await press(r, [DOWN]); // select the stdio server -> effect leaves Auth
await press(r, ["a", STAB, STAB, DOWN]);
await waitUntil(() => (r.lastFrame() ?? "").includes("Type: stdio"));
await expectFrame(r, "Server Configuration");
expect(r.lastFrame() ?? "").not.toContain("No OAuth information yet");
});
it("switches away from the Logging tab when selecting a non-stdio server", async () => {
@ -834,6 +836,7 @@ describe("App (input handling, focus, effects)", () => {
const r = render(
<App
mcpServers={oneHttp()}
clientConfig={emptyClientConfig}
callbackUrlConfig={callbackUrlConfig}
clientId="cid"
clientSecret="sec"
@ -847,287 +850,90 @@ describe("App (input handling, focus, effects)", () => {
});
describe("App (OAuth flows)", () => {
it("runs quick auth to success when no auth URL is returned", async () => {
h.clientSpies.authenticate.mockResolvedValue(undefined);
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await tick();
expect(h.callbackStart).toHaveBeenCalled();
expect(h.clientSpies.authenticate).toHaveBeenCalled();
await expectFrame(r, "OAuth complete");
const unauthorized = Object.assign(new Error("request failed (401)"), {
status: 401,
});
it("completes quick auth when the OAuth callback fires", async () => {
it("runs OAuth on connect when the server returns 401", async () => {
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
h.clientSpies.authenticate.mockResolvedValue(undefined);
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await press(r, ["c"]);
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
expect(h.disconnect).toHaveBeenCalled();
expect(h.clientSpies.authenticate).toHaveBeenCalled();
expect(h.connect).toHaveBeenCalledTimes(2);
});
it("completes OAuth when the callback fires during a 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
await h.cb.opts!.onCallback({ code: "abc" });
await tick();
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc");
await expectFrame(r, "OAuth complete");
});
it("reports an error when the OAuth callback errors", async () => {
it("reports an OAuth callback error during a 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
h.cb.opts!.onError({ error_description: "denied" });
await tick();
await expectFrame(r, "denied");
});
it("runs guided auth to completion and opens the auth URL", async () => {
h.clientSpies.runGuidedAuth.mockResolvedValue("http://auth/x");
it("clears OAuth state from the Auth tab", async () => {
const r = await mount(oneHttp());
await press(r, ["g", ENTER]);
await tick();
expect(h.clientSpies.runGuidedAuth).toHaveBeenCalled();
expect(h.openUrl).toHaveBeenCalledWith("http://auth/x");
});
it("reports an error when guided-to-completion fails", async () => {
h.clientSpies.runGuidedAuth.mockRejectedValue(new Error("nope"));
const r = await mount(oneHttp());
await press(r, ["g", ENTER]);
await tick();
await expectFrame(r, "nope");
});
it("starts guided auth then advances a step, opening the auth URL", async () => {
const r = await mount(oneHttp());
await press(r, ["g", " "]); // Space starts the guided flow
await tick();
h.ctrl.oauthFlowState = {
oauthStep: "authorization_code",
authorizationUrl: "http://auth/code",
};
await press(r, [" "]); // Space again advances one step
await tick();
expect(h.clientSpies.beginGuidedAuth).toHaveBeenCalled();
expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled();
expect(h.openUrl).toHaveBeenCalledWith("http://auth/code");
});
it("advances guided auth without opening a URL", async () => {
h.ctrl.oauthFlowState = { oauthStep: "token_request" };
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await tick();
await press(r, [" "]);
await tick();
expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled();
expect(h.openUrl).not.toHaveBeenCalled();
});
it("reports an error when guided start fails", async () => {
h.clientSpies.beginGuidedAuth.mockRejectedValue(new Error("startfail"));
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await tick();
await expectFrame(r, "startfail");
});
it("reports an error when advancing guided auth fails", async () => {
h.clientSpies.proceedOAuthStep.mockRejectedValue(new Error("advfail"));
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await tick();
await press(r, [" "]);
await tick();
await expectFrame(r, "advfail");
});
it("clears OAuth state via the Clear action", async () => {
const r = await mount(oneHttp());
await press(r, ["s", ENTER]);
await press(r, ["a", "s"]);
await tick();
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled();
await expectFrame(r, "OAuth state cleared");
});
it("reports an error when quick callback completion fails", async () => {
it("reports callback completion failure during a 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail"));
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
await h.cb.opts!.onCallback({ code: "x" });
await tick();
await expectFrame(r, "qcfail");
});
it("stops a prior callback server before starting quick auth again", async () => {
h.clientSpies.authenticate.mockResolvedValue(undefined);
const r = await mount(oneHttp());
await press(r, ["q", ENTER]); // first run completes, leaving the server set
await tick();
await press(r, ["q", ENTER]); // second run stops the prior server
await tick();
expect(h.callbackStop).toHaveBeenCalled();
});
it("completes guided auth when the callback fires", async () => {
const r = await mount(oneHttp());
await press(r, ["g", " "]); // Space starts guided + registers callback server
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
await h.cb.opts!.onCallback({ code: "gc" });
await tick();
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("gc");
await expectFrame(r, "OAuth complete");
});
it("reports an error when the guided callback errors", async () => {
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
h.cb.opts!.onError({ error: "guided-bad" });
await tick();
await expectFrame(r, "guided-bad");
});
it("reports an error when guided callback completion fails", async () => {
h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("gfail"));
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await waitUntil(() => h.cb.opts !== null);
await h.cb.opts!.onCallback({ code: "x" });
await tick();
await expectFrame(r, "gfail");
});
it("completes run-to-completion auth when the callback fires", async () => {
const r = await mount(oneHttp());
await press(r, ["g", ENTER]); // runs to completion -> ensureCallbackServer
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
await h.cb.opts!.onCallback({ code: "rc" });
await tick();
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("rc");
await expectFrame(r, "OAuth complete");
});
it("reports an error when the run-to-completion callback errors", async () => {
const r = await mount(oneHttp());
await press(r, ["g", ENTER]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
h.cb.opts!.onError({ error: "rc-bad" });
await tick();
await expectFrame(r, "rc-bad");
});
it("stringifies a non-Error quick auth rejection", async () => {
it("stringifies a non-Error authenticate rejection on 401 connect", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
h.clientSpies.authenticate.mockRejectedValue("plainstring");
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await tick();
await press(r, ["c"]);
await expectFrame(r, "plainstring");
});
it("uses the default OAuth error label when the callback error is empty", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
h.cb.opts!.onError({});
await tick();
await expectFrame(r, "OAuth error");
});
it("stringifies a non-Error guided callback completion failure", async () => {
h.clientSpies.completeOAuthFlow.mockRejectedValue("guided-string");
it("falls back to params.error when the callback has no description", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({ error: "oauth-error-code" });
await expectFrame(r, "oauth-error-code");
});
it("wraps a non-Error callback completion failure into an Error", async () => {
h.connect.mockRejectedValueOnce(unauthorized);
h.clientSpies.completeOAuthFlow.mockRejectedValue("cb-string");
const r = await mount(oneHttp());
await press(r, ["c"]);
await waitUntil(() => h.cb.opts !== null);
await h.cb.opts!.onCallback({ code: "x" });
await tick();
await expectFrame(r, "guided-string");
});
it("falls back to params.error when the quick callback has no description", async () => {
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({ error: "quick-error-code" });
await tick();
await expectFrame(r, "quick-error-code");
});
it("uses the default label when the guided callback error is empty", async () => {
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({});
await tick();
await expectFrame(r, "OAuth error");
});
it("uses the default label when the run-to-completion error is empty", async () => {
const r = await mount(oneHttp());
await press(r, ["g", ENTER]);
await waitUntil(() => h.cb.opts !== null);
h.cb.opts!.onError({});
await tick();
await expectFrame(r, "OAuth error");
});
it("wraps a non-Error quick callback rejection into an Error", async () => {
h.clientSpies.completeOAuthFlow.mockRejectedValue("quick-cb-string");
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await waitUntil(() => h.cb.opts !== null);
expect(h.cb.opts).not.toBeNull();
await h.cb.opts!.onCallback({ code: "x" });
await tick();
// quick auth's onCallback wraps the throw via new Error(String(err)); the
// flow rejects and the catch surfaces the stringified message.
await expectFrame(r, "quick-cb-string");
});
it("stringifies a non-Error guided-start rejection", async () => {
h.clientSpies.beginGuidedAuth.mockRejectedValue("startfail-string");
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await tick();
await expectFrame(r, "startfail-string");
});
it("stringifies a non-Error guided-advance rejection", async () => {
h.clientSpies.proceedOAuthStep.mockRejectedValue("advfail-string");
const r = await mount(oneHttp());
await press(r, ["g", " "]);
await tick();
await press(r, [" "]);
await tick();
await expectFrame(r, "advfail-string");
});
it("stringifies a non-Error run-to-completion rejection", async () => {
h.clientSpies.runGuidedAuth.mockRejectedValue("runfail-string");
const r = await mount(oneHttp());
await press(r, ["g", ENTER]);
await tick();
await expectFrame(r, "runfail-string");
});
it("ignores a re-entrant quick-auth trigger while one is in progress", async () => {
// Hold the first flow open by leaving authenticate pending, so the second
// 'q' hits the `if (oauthInProgressRef.current) return` guard.
let release!: () => void;
h.clientSpies.authenticate.mockImplementation(
() => new Promise<string>((res) => (release = () => res(undefined))),
);
const r = await mount(oneHttp());
await press(r, ["q", ENTER]);
await tick();
await press(r, ["q", ENTER]); // re-entrant: guarded out
await tick();
expect(h.callbackStart).toHaveBeenCalledTimes(1);
release();
await tick();
await expectFrame(r, "cb-string");
});
});

View file

@ -1,23 +1,14 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import {
EMPTY_OAUTH_FLOW_STATE,
type OAuthFlowState,
} from "@inspector/core/auth/index.js";
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { AuthTab } from "../src/components/AuthTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
@ -25,16 +16,32 @@ const tick = async () => {
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const LEFT = `${ESC}[D`;
const RIGHT = `${ESC}[C`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;
/** Minimal fake InspectorClient that only implements the surface AuthTab uses. */
function makeClient(state?: OAuthFlowState) {
const sampleOAuthState: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "http://x/mcp",
client: {
clientId: "abc123",
registrationKind: "dcr",
},
tokens: {
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
token_type: "Bearer",
},
authorizationServerMetadata: {
authorization_endpoint: "https://auth.example.com/authorize",
},
configuredScope: "read write",
};
function makeClient(oauthState?: OAuthConnectionState) {
const listeners = new Map<string, Set<() => void>>();
const getOAuthState = vi.fn(async () => oauthState);
const client = {
getOAuthFlowState: () => state,
getOAuthState,
addEventListener: (event: string, fn: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(fn);
@ -48,51 +55,20 @@ function makeClient(state?: OAuthFlowState) {
};
return {
client: client as unknown as InspectorClient,
getOAuthState,
fire,
listeners,
};
}
const flow = (over: Partial<OAuthFlowState>): OAuthFlowState => ({
...EMPTY_OAUTH_FLOW_STATE,
...over,
});
// A state at "complete" with every detail field populated so getStepDetails
// returns a non-null value for every step (all rendered as completed).
const completeState = flow({
execution: "guided",
oauthStep: "complete",
resourceMetadata: {
resource: "https://api.example.com",
} as OAuthFlowState["resourceMetadata"],
oauthMetadata: {
issuer: "https://issuer.example.com",
} as OAuthFlowState["oauthMetadata"],
oauthClientInfo: {
client_id: "abc123",
} as OAuthFlowState["oauthClientInfo"],
authorizationUrl: new URL("https://auth.example.com/authorize?x=1"),
authorizationCode: "code-abcdef1234567890",
oauthTokens: {
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
token_type: "Bearer",
},
});
const baseProps = {
serverName: "srv" as string | null,
serverConfig: null,
width: 120,
height: 30,
isOAuthCapable: true,
selectedAction: "guided" as "guided" | "quick" | "clear",
onSelectedActionChange: vi.fn(),
onQuickAuth: vi.fn(async () => {}),
onGuidedStart: vi.fn(async () => {}),
onGuidedAdvance: vi.fn(async () => {}),
onRunGuidedToCompletion: vi.fn(async () => {}),
oauthRevision: 0,
onClearOAuth: vi.fn(),
connectionStatus: "disconnected" as const,
};
describe("AuthTab", () => {
@ -106,30 +82,16 @@ describe("AuthTab", () => {
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
});
it("renders the placeholder when the server is not OAuth-capable", () => {
const { lastFrame } = render(
<AuthTab
{...baseProps}
isOAuthCapable={false}
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
/>,
expect(lastFrame() ?? "").toContain(
"Select a server to view authentication",
);
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
});
it("renders the guided action bar, hint, and progress (unfocused)", async () => {
const { client } = makeClient(completeState);
// tall enough that no step detail is clipped by the fixed-height Box
it("renders OAuth details from getOAuthState", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
height={80}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
@ -137,44 +99,19 @@ describe("AuthTab", () => {
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Authentication");
expect(frame).toContain("uided Auth");
expect(frame).toContain("uick Auth");
expect(frame).toContain("Clear OAuth");
expect(frame).toContain("Press [Space] to advance one step");
expect(frame).toContain("Press [Enter] to run guided auth to completion");
expect(frame).toContain("Guided OAuth Flow Progress");
// every step label, all completed (✓)
expect(frame).toContain("Metadata Discovery");
expect(frame).toContain("Client Registration");
expect(frame).toContain("Preparing Authorization");
expect(frame).toContain("Request Authorization Code");
expect(frame).toContain("Token Request");
expect(frame).toContain("Authentication Complete");
expect(frame).toContain("✓");
// completed detail strings from getStepDetails
expect(frame).toContain("Resource:");
expect(frame).toContain("OAuth:");
expect(frame).toContain("Code received:");
expect(frame).toContain("Exchanging code for tokens...");
expect(frame).toContain("Tokens: access_token=");
// no focused footer
expect(frame).not.toContain("select, G/Q/S or Enter run");
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("renders an in-progress step (cyan →) and not-started steps (○)", async () => {
const midState = flow({
execution: "guided",
oauthStep: "client_registration",
oauthClientInfo: {
client_id: "mid-client",
} as OAuthFlowState["oauthClientInfo"],
});
const { client } = makeClient(midState);
it("shows the not-yet-authorized hint when getOAuthState is empty", async () => {
const { client } = makeClient(undefined);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
@ -182,133 +119,25 @@ describe("AuthTab", () => {
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("(in progress)");
expect(frame).toContain("→");
expect(frame).toContain("○");
// in-progress detail (client_registration → oauthClientInfo JSON)
expect(frame).toContain("mid-client");
expect(frame).toContain("No OAuth information yet");
expect(frame).toContain("Connect (C) to authorize");
});
it("renders the 'authorization URL opened' block when awaiting an auth code", async () => {
const awaitingState = flow({
execution: "guided",
oauthStep: "authorization_code",
authorizationUrl: new URL("https://auth.example.com/go?code=here"),
});
const { client } = makeClient(awaitingState);
const { lastFrame } = render(
it("renders authenticating and error status messages", async () => {
const { client } = makeClient(undefined);
const { lastFrame, rerender } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Authorization URL opened in browser");
expect(frame).toContain("auth.example.com/go");
expect(frame).toContain("Complete authorization in the browser");
});
it("covers metadata details when only the resource metadata is present", async () => {
const resourceOnly = flow({
oauthStep: "complete",
resourceMetadata: {
resource: "https://only-resource.example.com",
} as OAuthFlowState["resourceMetadata"],
});
const { client } = makeClient(resourceOnly);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Resource:");
expect(frame).not.toContain("OAuth: {");
});
it("covers metadata details when only the oauth metadata is present", async () => {
const oauthOnly = flow({
oauthStep: "complete",
oauthMetadata: {
issuer: "https://only-issuer.example.com",
} as OAuthFlowState["oauthMetadata"],
});
const { client } = makeClient(oauthOnly);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(lastFrame() ?? "").toContain("OAuth:");
});
it("renders guided progress with no details when the flow state is empty", () => {
const { client } = makeClient(flow({}));
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Guided OAuth Flow Progress");
expect(frame).not.toContain("Resource:");
});
it("renders guided progress with no inspector client (no flow state)", () => {
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Guided OAuth Flow Progress");
expect(frame).toContain("○");
});
it("renders the quick hint and 'Authenticating...' status", () => {
const { client } = makeClient(flow({ execution: "quick" }));
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="quick"
inspectorClient={client}
oauthStatus="authenticating"
oauthMessage={null}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Press [Enter] to run quick auth");
expect(frame).toContain("Authenticating...");
});
expect(lastFrame() ?? "").toContain("Authenticating");
it("renders the quick error message", () => {
const { client } = makeClient(flow({ execution: "quick" }));
const { lastFrame } = render(
rerender(
<AuthTab
{...baseProps}
selectedAction="quick"
inspectorClient={client}
oauthStatus="error"
oauthMessage="Something went wrong"
@ -317,178 +146,69 @@ describe("AuthTab", () => {
expect(lastFrame() ?? "").toContain("Something went wrong");
});
it("renders quick auth results with client info and tokens", async () => {
const quickSuccess = flow({
execution: "quick",
oauthClientInfo: {
client_id: "quick-client",
} as OAuthFlowState["oauthClientInfo"],
oauthTokens: {
access_token: "quick-token-abcdefghijklmnop",
token_type: "Bearer",
},
});
const { client } = makeClient(quickSuccess);
const { lastFrame } = render(
<AuthTab
{...baseProps}
selectedAction="quick"
inspectorClient={client}
oauthStatus="success"
oauthMessage={null}
/>,
);
await tick();
const frame = lastFrame() ?? "";
expect(frame).toContain("Quick Auth Results");
expect(frame).toContain("quick-client");
expect(frame).toContain("Access Token:");
expect(frame).toContain("quick-token-abcdefgh");
});
it("renders the clear hint and the confirmation after pressing Enter", async () => {
it("clears OAuth state on S and shows confirmation", async () => {
const onClearOAuth = vi.fn();
const { client } = makeClient(flow({}));
const { client } = makeClient(sampleOAuthState);
const { lastFrame, stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="clear"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onClearOAuth={onClearOAuth}
/>,
);
expect(lastFrame() ?? "").toContain("Press [Enter] to clear OAuth state");
// a leading no-op key absorbs any dropped first keypress
stdin.write("x");
await tick();
stdin.write("\r");
await tick();
stdin.write("s");
await tick();
expect(onClearOAuth).toHaveBeenCalled();
expect(lastFrame() ?? "").toContain("OAuth state cleared.");
expect(lastFrame() ?? "").toContain("OAuth state cleared");
});
it("shows the focused footer and header highlight when focused", () => {
const { client } = makeClient(completeState);
it("shows clear+disconnect label when connected", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
connectionStatus="connected"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
expect(lastFrame() ?? "").toContain("select, G/Q/S or Enter run");
await tick();
expect(lastFrame() ?? "").toContain("clear+disconnect");
});
it("selects actions via G/Q/S keys when focused", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({}));
const { stdin } = render(
it("shows the focused footer when focused", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write("g");
await tick();
stdin.write("q");
await tick();
stdin.write("s");
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
expect(lastFrame() ?? "").toContain("S clear");
});
it("cycles selection with left/right arrows from 'guided'", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write(LEFT);
await tick();
stdin.write(RIGHT);
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
});
it("cycles selection with left/right arrows from 'quick'", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({ execution: "quick" }));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="quick"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write(LEFT);
await tick();
stdin.write(RIGHT);
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
});
it("cycles selection with left/right arrows from 'clear'", async () => {
const onSelectedActionChange = vi.fn();
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="clear"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write(LEFT);
await tick();
stdin.write(RIGHT);
await tick();
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
});
it("scrolls with up/down/pageUp/pageDown when focused", async () => {
const { client } = makeClient(completeState);
it("scrolls with arrow and page keys when focused", async () => {
const { client } = makeClient(sampleOAuthState);
const { lastFrame, stdin } = render(
<AuthTab
{...baseProps}
height={80}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
stdin.write(UP);
await tick();
stdin.write(DOWN);
@ -497,126 +217,28 @@ describe("AuthTab", () => {
await tick();
stdin.write(PAGE_DOWN);
await tick();
// still rendered (scroll stubs are no-ops)
expect(lastFrame() ?? "").toContain("Guided OAuth Flow Progress");
expect(lastFrame() ?? "").toContain("OAuth Details");
});
it("runs guided to completion on Enter when 'guided' is selected", async () => {
const onRunGuidedToCompletion = vi.fn(async () => {});
const { client } = makeClient(flow({}));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onRunGuidedToCompletion={onRunGuidedToCompletion}
/>,
);
// a leading no-op key absorbs any dropped first keypress
stdin.write("x");
await tick();
stdin.write("\r");
await tick();
expect(onRunGuidedToCompletion).toHaveBeenCalled();
});
it("runs quick auth on Enter when 'quick' is selected", async () => {
const onQuickAuth = vi.fn(async () => {});
const { client } = makeClient(flow({ execution: "quick" }));
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="quick"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onQuickAuth={onQuickAuth}
/>,
);
stdin.write("x");
await tick();
stdin.write("\r");
await tick();
expect(onQuickAuth).toHaveBeenCalled();
});
it("advances guided one step on Space (start then advance)", async () => {
const onGuidedStart = vi.fn(async () => {});
const onGuidedAdvance = vi.fn(async () => {});
// mid-flow state so needsAuthCode/isComplete are both false on advance
const { client } = makeClient(
flow({ execution: "guided", oauthStep: "client_registration" }),
);
const { stdin } = render(
<AuthTab
{...baseProps}
focused
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
onGuidedStart={onGuidedStart}
onGuidedAdvance={onGuidedAdvance}
/>,
);
// first space starts the flow
stdin.write(" ");
await tick();
// second space advances one step
stdin.write(" ");
await tick();
// third space (in case the first was dropped) keeps both reachable
stdin.write(" ");
await tick();
expect(onGuidedStart).toHaveBeenCalled();
expect(onGuidedAdvance).toHaveBeenCalled();
});
it("does not act on input when not OAuth-capable but focused", async () => {
const onSelectedActionChange = vi.fn();
const { stdin, lastFrame } = render(
<AuthTab
{...baseProps}
focused
isOAuthCapable={false}
selectedAction="guided"
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
onSelectedActionChange={onSelectedActionChange}
/>,
);
stdin.write("g");
await tick();
expect(onSelectedActionChange).not.toHaveBeenCalled();
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
});
it("subscribes to oauth events and refreshes when they fire", async () => {
const { client, fire, listeners } = makeClient(completeState);
it("refreshes OAuth state when oauthComplete fires", async () => {
const { client, getOAuthState, fire, listeners } =
makeClient(sampleOAuthState);
const { unmount } = render(
<AuthTab
{...baseProps}
selectedAction="guided"
inspectorClient={client}
oauthStatus="idle"
oauthMessage={null}
/>,
);
await tick();
expect(listeners.get("oauthStepChange")?.size).toBe(1);
expect(getOAuthState).toHaveBeenCalled();
expect(listeners.get("oauthComplete")?.size).toBe(1);
// firing the listeners runs the update() callback
fire("oauthStepChange");
getOAuthState.mockClear();
fire("oauthComplete");
await tick();
// unmount runs the cleanup (removeEventListener)
expect(getOAuthState).toHaveBeenCalled();
unmount();
expect(listeners.get("oauthStepChange")?.size).toBe(0);
expect(listeners.get("oauthComplete")?.size).toBe(0);
});
});

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import {
formatAuthProtocol,
formatClientRegistrationKind,
formatIdpSession,
formatScopes,
} from "../src/utils/oauthDisplay.js";
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
describe("oauthDisplay", () => {
it("formatAuthProtocol distinguishes standard and EMA", () => {
expect(formatAuthProtocol("standard")).toBe("Standard");
expect(formatAuthProtocol("ema")).toBe("Enterprise-managed");
});
it("formatClientRegistrationKind covers registration kinds", () => {
expect(formatClientRegistrationKind("static")).toBe(
"Static (preregistered)",
);
expect(formatClientRegistrationKind("dcr")).toBe("Dynamic (DCR)");
expect(formatClientRegistrationKind("cimd")).toBe(
"Client ID Metadata (CIMD)",
);
});
it("formatIdpSession maps session states", () => {
expect(formatIdpSession("logged_in")).toBe("Signed in");
expect(formatIdpSession("expired")).toBe("Session expired");
expect(formatIdpSession("none")).toBe("Not signed in");
});
it("formatScopes joins granted scope", () => {
const state = {
grantedScope: "openid profile",
} as OAuthConnectionState;
expect(formatScopes(state)).toBe("openid, profile");
});
it("formatScopes falls back to configured scope", () => {
const state = {
configuredScope: "read write",
} as OAuthConnectionState;
expect(formatScopes(state)).toBe("read, write");
});
it("formatScopes returns undefined when no scopes are present", () => {
expect(formatScopes({} as OAuthConnectionState)).toBeUndefined();
expect(
formatScopes({ grantedScope: " " } as OAuthConnectionState),
).toBeUndefined();
});
});

View file

@ -21,7 +21,7 @@ describe("loadTuiServers", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it("loads named servers from a read-only --config file", () => {
it("loads named servers from a read-only --config file", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
@ -29,7 +29,7 @@ describe("loadTuiServers", () => {
mcpServers: { foo: { command: "node", args: ["foo.js"] } },
}),
);
const servers = loadTuiServers({ configPath });
const servers = await loadTuiServers({ configPath });
expect(Object.keys(servers)).toEqual(["foo"]);
expect(servers.foo?.config).toMatchObject({
type: "stdio",
@ -37,9 +37,9 @@ describe("loadTuiServers", () => {
});
});
it("seeds an empty writable catalog when --catalog is missing", () => {
it("seeds an empty writable catalog when --catalog is missing", async () => {
const catalogPath = join(tempDir, "catalog.json");
const servers = loadTuiServers({ catalogPath });
const servers = await loadTuiServers({ catalogPath });
expect(servers).toEqual({});
expect(existsSync(catalogPath)).toBe(true);
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
@ -47,34 +47,34 @@ describe("loadTuiServers", () => {
});
});
it("throws when a read-only --config file is missing (never seeds)", () => {
it("throws when a read-only --config file is missing (never seeds)", async () => {
const configPath = join(tempDir, "absent.json");
expect(() => loadTuiServers({ configPath })).toThrow(
await expect(loadTuiServers({ configPath })).rejects.toThrow(
/Config file not found/,
);
expect(existsSync(configPath)).toBe(false);
});
it("rejects --catalog and --config together", () => {
it("rejects --catalog and --config together", async () => {
const catalogPath = join(tempDir, "catalog.json");
const configPath = join(tempDir, "config.json");
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
expect(() => loadTuiServers({ catalogPath, configPath })).toThrow(
await expect(loadTuiServers({ catalogPath, configPath })).rejects.toThrow(
/mutually exclusive/,
);
});
it("rejects --catalog combined with an ad-hoc target", () => {
it("rejects --catalog combined with an ad-hoc target", async () => {
const catalogPath = join(tempDir, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
expect(() =>
await expect(
loadTuiServers({ catalogPath, target: ["my-server"] }),
).toThrow(/--catalog cannot be combined/);
).rejects.toThrow(/--catalog cannot be combined/);
});
it("builds a single ad-hoc server from a positional target", () => {
const servers = loadTuiServers({ target: ["my-server", "--flag"] });
it("builds a single ad-hoc server from a positional target", async () => {
const servers = await loadTuiServers({ target: ["my-server", "--flag"] });
expect(Object.keys(servers)).toEqual(["default"]);
expect(servers.default?.config).toMatchObject({
type: "stdio",
@ -83,7 +83,7 @@ describe("loadTuiServers", () => {
});
});
it("merges --header into per-server settings for catalog servers", () => {
it("merges --header into per-server settings for catalog servers", async () => {
const catalogPath = join(tempDir, "catalog.json");
writeFileSync(
catalogPath,
@ -91,7 +91,7 @@ describe("loadTuiServers", () => {
mcpServers: { web: { type: "streamable-http", url: "http://x/mcp" } },
}),
);
const servers = loadTuiServers({
const servers = await loadTuiServers({
catalogPath,
headers: { Authorization: "Bearer t" },
});