inspector/clients/tui/__tests__/AuthTab.test.tsx
Bob Dickinson 13dafe457b
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>
2026-06-29 15:35:51 -04:00

244 lines
6.6 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",
},
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 = {
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,
};
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",
);
});
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("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);
});
});