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:
parent
4a927d3978
commit
13dafe457b
4 changed files with 5702 additions and 4444 deletions
|
|
@ -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("[Z");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
52
clients/tui/__tests__/oauthDisplay.test.ts
Normal file
52
clients/tui/__tests__/oauthDisplay.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue