diff --git a/AGENTS.md b/AGENTS.md index 4a0201cf..aeec0408 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ All work should be driven by items on the project board. - Run TUI tests with `npm run test` from `clients/tui/` - The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/ && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration. - **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want the gate. **CI does NOT run `coverage`** — the gate is local-only; CI runs `validate` (fast) plus the web integration suite (`clients/web` `test:integration`, no coverage) so the integration paths are still exercised. -- Each client's `test:coverage` enforces a per-file gate: lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50 (CI enforces this gate). Branches is intentionally relaxed because Mantine portal/media-query branches are not exercisable under happy-dom; new business-logic branches should still be covered. +- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index c833bf48..ca393100 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runCli } from "./helpers/cli-runner.js"; +import { runCli as runCliDirect } from "../src/cli.js"; import { expectCliSuccess, expectCliFailure, @@ -1018,4 +1022,33 @@ describe("CLI Tests", () => { expectCliFailure(result); }); }); + + describe("argv placeholder fallbacks", () => { + // The cli-runner helper always prepends `["node", "inspector-cli", ...]`, so + // `rawArgs[0]`/`rawArgs[1]` are never undefined through it. Call `runCli` + // directly with a too-short argv to exercise the `?? "node"` / + // `?? "inspector-cli"` fallbacks in parseArgs (commander's `name()`/program + // bootstrap). With no method present, parseArgs throws "Method is required" + // before any connection, so this stays fully in-process and fast. + it("defaults the node/script argv slots when rawArgs has fewer than two entries", async () => { + // Point the catalog at a fresh temp file so the empty-argv run resolves + // deterministically (the default ~/.mcp-inspector catalog may already hold + // several servers on the dev machine). Either way the rawArgs[0]/[1] + // fallbacks have already run by the time parseArgs throws. + const tempCatalog = join( + mkdtempSync(join(tmpdir(), "cli-argv-")), + "mcp.json", + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const prev = process.env.MCP_CATALOG_PATH; + process.env.MCP_CATALOG_PATH = tempCatalog; + try { + await expect(runCliDirect([])).rejects.toThrow(); + } finally { + if (prev === undefined) delete process.env.MCP_CATALOG_PATH; + else process.env.MCP_CATALOG_PATH = prev; + errorSpy.mockRestore(); + } + }); + }); }); diff --git a/clients/cli/__tests__/methods.test.ts b/clients/cli/__tests__/methods.test.ts index 25f9bf86..0fb720b3 100644 --- a/clients/cli/__tests__/methods.test.ts +++ b/clients/cli/__tests__/methods.test.ts @@ -59,6 +59,22 @@ describe("CLI method coverage", () => { expect(Array.isArray(json.tools)).toBe(true); }); + it("throws for an unsupported method after connecting", async () => { + // Reaches the final `else` in callMethod: the connection succeeds, none of + // the known method branches match, so it throws "Unsupported method". + const result = await runCli([ + command, + ...args, + "--cli", + "--method", + "definitely/not-a-real-method", + ]); + + expectCliFailure(result); + expectOutputContains(result, "Unsupported method"); + expectOutputContains(result, "definitely/not-a-real-method"); + }); + it("rejects an invalid --transport value before connecting", async () => { const result = await runCli([ command, diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 6539da8d..3bb6fedf 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -139,6 +139,11 @@ async function callMethod( if (invocation.result !== null) { result = invocation.result; } else { + /* v8 ignore next 9 -- unreachable: InspectorClient.callTool either + throws on a tool-execution/transport error (caught by the outer + try) or returns an invocation whose `result` is guaranteed + non-null (attemptToolCall throws if finalResult is falsy). This + else is defensive only. */ result = { content: [ { @@ -250,6 +255,10 @@ function parseArgs(argv?: string[]): { // (exitCode 0) we return without throwing, so commander falls through to its // normal clean process.exit(0) after printing — preserving that UX. See #1484. program.exitOverride((err) => { + /* v8 ignore next -- the `exitCode === 0` arm only fires for --help/--version, + which cannot run through the in-process test runner (it would call the + real process.exit(0) and tear down the vitest worker). That UX is covered + out-of-process in e2e.test.ts; here only the throwing arm is exercised. */ if (err.exitCode !== 0) throw err; }); const rawArgs = argv ?? process.argv; diff --git a/clients/cli/vitest.config.ts b/clients/cli/vitest.config.ts index 406e5237..4480e1bd 100644 --- a/clients/cli/vitest.config.ts +++ b/clients/cli/vitest.config.ts @@ -35,9 +35,9 @@ export default defineConfig({ thresholds: { perFile: true, lines: 90, - statements: 85, - functions: 80, - branches: 50, + statements: 90, + functions: 90, + branches: 90, }, }, }, diff --git a/clients/launcher/vitest.config.ts b/clients/launcher/vitest.config.ts index 8e5edbbf..b6492000 100644 --- a/clients/launcher/vitest.config.ts +++ b/clients/launcher/vitest.config.ts @@ -21,9 +21,9 @@ export default defineConfig({ thresholds: { perFile: true, lines: 90, - statements: 85, - functions: 80, - branches: 50, + statements: 90, + functions: 90, + branches: 90, }, }, }, diff --git a/clients/tui/__tests__/logger.test.ts b/clients/tui/__tests__/logger.test.ts index 09bcd234..426f2a16 100644 --- a/clients/tui/__tests__/logger.test.ts +++ b/clients/tui/__tests__/logger.test.ts @@ -66,4 +66,43 @@ describe("getTuiLogger", () => { expect(logger).toBeDefined(); await waitForFile(join(tempDir, ".mcp-inspector", "auth.log")); }); + + it("falls back to USERPROFILE/.mcp-inspector when HOME is unset", async () => { + // Exercises the second arm of `HOME || USERPROFILE || "."`: HOME empty so + // the chain continues to USERPROFILE (the Windows home var). + delete process.env.MCP_INSPECTOR_LOG_DIR; + delete process.env.HOME; + process.env.USERPROFILE = tempDir; + const { getTuiLogger } = await import("../src/logger.js"); + + const logger = getTuiLogger(); + + expect(logger).toBeDefined(); + await waitForFile(join(tempDir, ".mcp-inspector", "auth.log")); + }); + + it('falls back to "." when neither HOME nor USERPROFILE is set', async () => { + // Exercises the final arm of the chain: with both home vars unset, the dir + // resolves to "./.mcp-inspector" relative to the process cwd. chdir into the + // temp dir so the relative path pino opens lands there (and gets cleaned up). + delete process.env.MCP_INSPECTOR_LOG_DIR; + delete process.env.HOME; + delete process.env.USERPROFILE; + const originalCwd = process.cwd(); + process.chdir(tempDir); + // Re-read cwd after chdir: on macOS tmpdir is a symlink (/var -> /private/var), + // so the resolved cwd differs from `tempDir` and the file lands under it. + const resolvedCwd = process.cwd(); + try { + const { getTuiLogger } = await import("../src/logger.js"); + + const logger = getTuiLogger(); + + expect(logger).toBeDefined(); + // pino resolves the relative "./.mcp-inspector/auth.log" against cwd. + await waitForFile(join(resolvedCwd, ".mcp-inspector", "auth.log")); + } finally { + process.chdir(originalCwd); + } + }); }); diff --git a/clients/tui/src/utils/uriTemplateToForm.ts b/clients/tui/src/utils/uriTemplateToForm.ts index f8d2ee10..b1ec266d 100644 --- a/clients/tui/src/utils/uriTemplateToForm.ts +++ b/clients/tui/src/utils/uriTemplateToForm.ts @@ -16,6 +16,8 @@ export function uriTemplateToForm( try { const template = new UriTemplate(uriTemplate); + /* v8 ignore next -- UriTemplate.variableNames is a getter that always + returns a string[]; the `|| []` fallback is an unreachable guard. */ const variableNames = template.variableNames || []; for (const variableName of variableNames) { diff --git a/clients/tui/vitest.config.ts b/clients/tui/vitest.config.ts index c4100a43..2ead3aeb 100644 --- a/clients/tui/vitest.config.ts +++ b/clients/tui/vitest.config.ts @@ -44,9 +44,9 @@ export default defineConfig({ thresholds: { perFile: true, lines: 90, - statements: 85, - functions: 80, - branches: 50, + statements: 90, + functions: 90, + branches: 90, }, }, }, diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index 16ae5d3c..f007b405 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -70,6 +70,9 @@ export function createSandboxController( // first signal wins. let settled = false; const settle = (value: { port: number; url: string }) => { + /* v8 ignore next -- defensive double-settle guard: for a TCP listen + the `error` and `listening` events are mutually exclusive, so the + second-signal early-return is unreachable in practice. */ if (settled) return; settled = true; resolve(value); @@ -111,7 +114,11 @@ export function createSandboxController( const actualPort = typeof addr === "object" && addr !== null && "port" in addr ? addr.port - : (addr as unknown as number); + : /* v8 ignore next -- unreachable for a TCP listen: `address()` + always returns an `AddressInfo` object with `port`. The + string form only occurs for unix-socket/pipe listens, which + this controller never performs. */ + (addr as unknown as number); sandboxUrl = `http://${host}:${actualPort}/sandbox`; settle({ port: actualPort, url: sandboxUrl }); }); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index e6db5974..8a10a82d 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -481,6 +481,46 @@ describe("AppRenderer", () => { expect(first.close).toHaveBeenCalledTimes(1); }); + it("rebuilds the bridge when only the tool changes (same factory and sandboxPath)", async () => { + // Keeps bridgeFactory and sandboxPath stable while swapping the tool, so the + // sameInputs short-circuit evaluates all the way to `prev.tool === tool` and + // takes its false arm — driving a real rebuild, not a reuse. + const first = createMockBridge(); + const second = createMockBridge(); + const factory = vi + .fn() + .mockReturnValueOnce(asBridge(first)) + .mockReturnValueOnce(asBridge(second)); + const otherTool: Tool = { + name: "other_app", + title: "Other App", + inputSchema: { type: "object" }, + }; + + const { rerender } = renderWithMantine( + , + ); + await flushAsync(); + + rerender( + , + ); + await flushAsync(); + + expect(factory).toHaveBeenCalledTimes(2); + expect(factory.mock.calls[1]?.[1]).toBe(otherTool); + expect(first.teardownResource).toHaveBeenCalledTimes(1); + expect(first.close).toHaveBeenCalledTimes(1); + }); + it("swallows teardownResource errors but still closes the transport", async () => { const bridge = createMockBridge(); bridge.teardownResource.mockRejectedValueOnce(new Error("teardown failed")); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index da23751f..54d9f684 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -161,6 +161,13 @@ export function AppRenderer({ // a synchronous re-setup. If the inputs are identical (StrictMode's // double-invoke, or a transient re-render) keep the live bridge: cancel the // disposal and re-deliver any buffered input/result to it. + // This reuse path provably runs under React StrictMode's synchronous + // setup→cleanup→setup double-invoke (the "builds a single bridge… StrictMode" + // test proves the bridge is reused, not rebuilt — factory called once). v8 + // cannot attribute coverage to the body of an effect that React replays for + // the StrictMode dev-only double-invoke, so the branch + its three + // statements read as uncovered despite executing. + /* v8 ignore next 4 */ if (disposeScheduledRef.current && sameInputs) { disposeScheduledRef.current = false; flushPending(); diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.test.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.test.tsx index eed04a5a..283ef54f 100644 --- a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.test.tsx +++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.test.tsx @@ -1,8 +1,20 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { LogLevelBadge } from "./LogLevelBadge"; +// Drive the `colorScheme === "dark"` branch (which picks black vs white badge +// text). `useComputedColorScheme` otherwise resolves to "light" under +// happy-dom, leaving the dark arm of the ternary uncovered. +const colorSchemeMock = vi.hoisted(() => ({ + value: "light" as "light" | "dark", +})); +vi.mock("@mantine/core", async () => { + const actual = + await vi.importActual("@mantine/core"); + return { ...actual, useComputedColorScheme: () => colorSchemeMock.value }; +}); + describe("LogLevelBadge", () => { const levels: LoggingLevel[] = [ "debug", @@ -16,7 +28,17 @@ describe("LogLevelBadge", () => { ]; it.each(levels)("renders the %s level label", (level) => { + colorSchemeMock.value = "light"; renderWithMantine(); expect(screen.getByText(level)).toBeInTheDocument(); }); + + it("uses black text in dark mode", () => { + colorSchemeMock.value = "dark"; + // Rendering under the mocked dark scheme exercises the dark arm of the + // text-color ternary (line 23); the badge still renders its label. + renderWithMantine(); + expect(screen.getByText("error")).toBeInTheDocument(); + colorSchemeMock.value = "light"; + }); }); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx index a921b451..b616bdaf 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx @@ -42,4 +42,25 @@ describe("SortToggle", () => { await user.click(await screen.findByText("Oldest First")); expect(onChange).toHaveBeenCalledWith("oldest-first"); }); + + it("invokes onChange when picking the descending (newest-first) option", async () => { + // Covers the `value === "newest-first"` arm of the isSortDirection guard. + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("textbox", { name: "Sort direction" })); + await user.click(await screen.findByText("Newest First")); + expect(onChange).toHaveBeenCalledWith("newest-first"); + }); + + it("does not invoke onChange when re-selecting the already-current option", async () => { + // allowDeselect={false} means clicking the selected option is a no-op: + // Mantine does not fire onChange, so the parent callback stays silent. + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("textbox", { name: "Sort direction" })); + await user.click(await screen.findByText("Newest First")); + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx index ea87b497..d4827208 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -31,6 +31,10 @@ export function SortToggle({ data={OPTIONS} value={value} onChange={(next) => { + // The guard's false arm is unreachable through the UI: Mantine's `data` + // only holds the two valid SortDirection values and allowDeselect={false} + // prevents a null deselect, so isSortDirection() is always true here. + /* v8 ignore next */ if (isSortDirection(next)) onChange(next); }} allowDeselect={false} diff --git a/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.test.tsx b/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.test.tsx index c6c5c95d..cab813eb 100644 --- a/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.test.tsx +++ b/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.test.tsx @@ -1,8 +1,21 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { TaskStatus } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { TaskStatusBadge } from "./TaskStatusBadge"; +// Drive the `colorScheme === "dark"` branch in TaskStatusBadge (which picks +// black vs white badge text) deterministically. `useComputedColorScheme` +// otherwise resolves to "light" under happy-dom, leaving the dark arm +// uncovered. +const colorSchemeMock = vi.hoisted(() => ({ + value: "light" as "light" | "dark", +})); +vi.mock("@mantine/core", async () => { + const actual = + await vi.importActual("@mantine/core"); + return { ...actual, useComputedColorScheme: () => colorSchemeMock.value }; +}); + describe("TaskStatusBadge", () => { const cases: Array<[TaskStatus, string]> = [ ["working", "working"], @@ -13,7 +26,17 @@ describe("TaskStatusBadge", () => { ]; it.each(cases)("renders the label for %s", (status, label) => { + colorSchemeMock.value = "light"; renderWithMantine(); expect(screen.getByText(label)).toBeInTheDocument(); }); + + it("uses black text in dark mode", () => { + colorSchemeMock.value = "dark"; + renderWithMantine(); + // Rendering under the mocked dark scheme exercises the dark arm of the + // text-color ternary (line 26); the badge still renders its label. + expect(screen.getByText("working")).toBeInTheDocument(); + colorSchemeMock.value = "light"; + }); }); diff --git a/clients/web/src/components/groups/AppControls/AppControls.test.tsx b/clients/web/src/components/groups/AppControls/AppControls.test.tsx index ac7c15a5..dcf61c53 100644 --- a/clients/web/src/components/groups/AppControls/AppControls.test.tsx +++ b/clients/web/src/components/groups/AppControls/AppControls.test.tsx @@ -97,6 +97,20 @@ describe("AppControls", () => { expect(screen.getByText("No matching apps")).toBeInTheDocument(); }); + it("clears the search via the clear button, invoking onSearchChange with empty string", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderWithMantine( + , + ); + const input = screen.getByPlaceholderText("Search apps..."); + await user.type(input, "git"); + // The clear button only renders once searchText is non-empty (line 67). + const clearButton = screen.getByRole("button", { name: /clear/i }); + await user.click(clearButton); + expect(onSearchChange).toHaveBeenLastCalledWith(""); + }); + it("invokes onSelectApp when an unselected app is clicked", async () => { const user = userEvent.setup(); const onSelectApp = vi.fn(); diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index 2be09c98..29180ab6 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -72,3 +72,186 @@ describe("ClientSettingsForm EMA IdP session", () => { ).toBeInTheDocument(); }); }); + +describe("ClientSettingsForm interactions", () => { + it("propagates accordion expand/collapse changes", async () => { + const user = userEvent.setup(); + const onExpandedSectionsChange = vi.fn(); + renderWithMantine( + , + ); + + // Clicking the accordion control toggles the open section. Since it starts + // open, collapsing it should report an empty section list. + await user.click( + screen.getByRole("button", { name: /Enterprise-Managed Authorization/i }), + ); + expect(onExpandedSectionsChange).toHaveBeenCalledWith([]); + }); + + it("toggles the EMA enable checkbox via onSettingsChange", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + + await user.click( + screen.getByRole("checkbox", { + name: "Enable enterprise IdP configuration", + }), + ); + expect(onSettingsChange).toHaveBeenCalledWith({ + ...EMPTY_CLIENT_SETTINGS, + emaEnabled: true, + }); + }); + + it("edits the IdP text fields via onSettingsChange", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + + await user.type(screen.getByLabelText("Client ID"), "x"); + expect(onSettingsChange).toHaveBeenLastCalledWith({ + ...EMPTY_CLIENT_SETTINGS, + emaEnabled: true, + clientId: "x", + }); + + onSettingsChange.mockClear(); + await user.type(screen.getByLabelText("Client Secret"), "s"); + expect(onSettingsChange).toHaveBeenLastCalledWith({ + ...EMPTY_CLIENT_SETTINGS, + emaEnabled: true, + clientSecret: "s", + }); + }); + + it("clears each populated IdP field via its clear button", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const filled = { + ...EMPTY_CLIENT_SETTINGS, + emaEnabled: true, + issuer: "https://idp.test", + clientId: "client-1", + clientSecret: "secret-1", + }; + renderWithMantine( + , + ); + + // Each populated field renders a "Clear" button in its right section. + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + expect(clearButtons).toHaveLength(3); + + // Issuer clear -> patch({ issuer: "" }) + await user.click(clearButtons[0]); + expect(onSettingsChange).toHaveBeenCalledWith({ ...filled, issuer: "" }); + + // Client ID clear -> patch({ clientId: "" }) + onSettingsChange.mockClear(); + await user.click(clearButtons[1]); + expect(onSettingsChange).toHaveBeenCalledWith({ ...filled, clientId: "" }); + + // Client Secret clear -> patch({ clientSecret: "" }) + onSettingsChange.mockClear(); + await user.click(clearButtons[2]); + expect(onSettingsChange).toHaveBeenCalledWith({ + ...filled, + clientSecret: "", + }); + }); + + it("omits clear buttons when IdP fields are empty", () => { + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Clear" }), + ).not.toBeInTheDocument(); + }); + + it("hides the IdP section entirely when EMA is disabled", () => { + renderWithMantine( + , + ); + expect(screen.queryByLabelText("Issuer")).not.toBeInTheDocument(); + expect(screen.queryByText(/IdP sign-in/i)).not.toBeInTheDocument(); + }); + + it("defaults emaIdpLoginState to none and shows the not-signed-in hint", () => { + renderWithMantine( + , + ); + // emaIdpLoginState prop omitted -> defaults to "none"; sign-in section + // renders (issuer present) but with no sign-out button. + expect(screen.getByText("IdP sign-in")).toBeInTheDocument(); + expect(screen.getByText(/Not signed in/i)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Sign out" }), + ).not.toBeInTheDocument(); + }); + + it("renders no sign-out button when logged in but onEmaIdpLogout is absent", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Signed in")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Sign out" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts index c00391e1..7cb6cbba 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts @@ -2,10 +2,16 @@ import { describe, it, expect } from "vitest"; import { canPersistClientSettingsDraft, clientConfigToFormValues, + EMPTY_CLIENT_SETTINGS, formValuesToClientConfig, } from "./clientSettingsValues"; describe("clientSettingsValues", () => { + it("clientConfigToFormValues returns empty settings when there is no enterpriseManagedAuth", () => { + // Exercises the `!idp` early return (line 24) via an absent EMA block. + expect(clientConfigToFormValues({})).toEqual(EMPTY_CLIENT_SETTINGS); + }); + it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => { expect( clientConfigToFormValues({ diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx index 1411d217..eb737f22 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx @@ -87,4 +87,48 @@ describe("ClientSettingsModal", () => { onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0]; expect(call.issuer).toBe("h"); }); + + it("collapses then re-expands all sections via the list toggle", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + + // Starts fully expanded (all sections open), so the toggle offers "Collapse all". + const collapse = screen.getByRole("button", { name: "Collapse all" }); + expect(collapse).toBeInTheDocument(); + // The EMA section panel content is visible while expanded. + expect( + screen.getByText("Enable enterprise IdP configuration"), + ).toBeInTheDocument(); + + // Collapse all -> handleToggleAll sets expandedSections to []. + await user.click(collapse); + expect( + screen.getByRole("button", { name: "Expand all" }), + ).toBeInTheDocument(); + + // Expand all -> handleToggleAll restores ALL_SECTIONS. + await user.click(screen.getByRole("button", { name: "Expand all" })); + expect( + screen.getByRole("button", { name: "Collapse all" }), + ).toBeInTheDocument(); + }); + + it("does not render the modal body when closed", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("Client Settings")).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 27e1a970..7d5d59ca 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -141,6 +141,53 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("Signed in")).toBeInTheDocument(); }); + it("renders the EMA idp session as Session expired", () => { + renderWithMantine( + , + ); + expect(screen.getByText("IdP session")).toBeInTheDocument(); + expect(screen.getByText("Session expired")).toBeInTheDocument(); + }); + + it("renders the EMA idp session as Not signed in for the none state", () => { + renderWithMantine( + , + ); + expect(screen.getByText("IdP session")).toBeInTheDocument(); + expect(screen.getByText("Not signed in")).toBeInTheDocument(); + }); + + it("omits the IdP session row when EMA oauth has no idpSession", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Enterprise-managed")).toBeInTheDocument(); + expect(screen.queryByText("IdP session")).not.toBeInTheDocument(); + }); + it("hides optional OAuth fields that are not provided", () => { renderWithMantine( { expect(lastCall[1]).toBe("X-Auth"); }); + it("clears a header key via its clear button, invoking onHeaderChange with empty key", async () => { + const user = userEvent.setup(); + const onHeaderChange = vi.fn(); + renderWithMantine( + , + ); + // Both the key and value inputs render a Clear button when populated. + // The first one belongs to the key input (line 252). + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + await user.click(clearButtons[0]); + expect(onHeaderChange).toHaveBeenCalledWith(0, "", "Bearer 1"); + }); + + it("clears a header value via its clear button, invoking onHeaderChange with empty value", async () => { + const user = userEvent.setup(); + const onHeaderChange = vi.fn(); + renderWithMantine( + , + ); + // The second Clear button belongs to the value input (line 267). + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + await user.click(clearButtons[1]); + expect(onHeaderChange).toHaveBeenCalledWith(0, "X-Auth", ""); + }); + + it("clears the request draft via its clear button, invoking onRequestChange with empty string", async () => { + const user = userEvent.setup(); + const onRequestChange = vi.fn(); + renderWithMantine( + , + ); + // The request textarea shows a Clear button when non-empty (line 292). + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + await user.click(clearButtons[clearButtons.length - 1]); + expect(onRequestChange).toHaveBeenCalledWith(""); + }); + it("invokes onRemoveHeader with the header index when the remove icon is clicked", async () => { const user = userEvent.setup(); const onRemoveHeader = vi.fn(); diff --git a/clients/web/src/components/groups/LogControls/LogControls.test.tsx b/clients/web/src/components/groups/LogControls/LogControls.test.tsx index dc0356dd..1d7debdb 100644 --- a/clients/web/src/components/groups/LogControls/LogControls.test.tsx +++ b/clients/web/src/components/groups/LogControls/LogControls.test.tsx @@ -77,6 +77,22 @@ describe("LogControls", () => { expect(onFilterChange).toHaveBeenCalledWith("x"); }); + it("clears the search via the clear button, invoking onFilterChange with empty string", async () => { + const user = userEvent.setup(); + const onFilterChange = vi.fn(); + renderWithMantine( + , + ); + // The clear button only renders when filterText is non-empty (line 62). + const clearButton = screen.getByRole("button", { name: "Clear" }); + await user.click(clearButton); + expect(onFilterChange).toHaveBeenCalledWith(""); + }); + it("invokes onSetLevel when Set is clicked", async () => { const user = userEvent.setup(); const onSetLevel = vi.fn(); @@ -106,6 +122,23 @@ describe("LogControls", () => { expect(onSetLevel).toHaveBeenCalledWith("error"); }); + it("does not invoke onSetLevel when the dropdown selection is cleared", async () => { + // The Select allows deselect (no allowDeselect override): clicking the + // already-selected option fires onChange(null), exercising the falsy-`value` + // arm of the guard (line 73) so onSetLevel is not called. + const user = userEvent.setup(); + const onSetLevel = vi.fn(); + renderWithMantine(); + const inputs = screen.getAllByDisplayValue("info"); + await user.click(inputs[0]); + const infoOption = await screen.findByRole("option", { + name: "info", + hidden: true, + }); + await user.click(infoOption); + expect(onSetLevel).not.toHaveBeenCalled(); + }); + it("renders Deselect All when all levels are visible", () => { renderWithMantine(); expect( diff --git a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx index bd5540f8..679bd02e 100644 --- a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx +++ b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx @@ -344,14 +344,21 @@ describe("PromptArgumentsForm", () => { ); await new Promise((r) => setTimeout(r, 400)); - // The most recent call for "text" carries the up-to-date - // sibling value, even though it was scheduled before "es" was - // typed. (There's also a focus-fire call when the second input - // gained focus — separate stream, not asserted here.) + // The most recent call for "text" carries the up-to-date sibling + // value, even though it was scheduled before the sibling was typed. + // (There's also a focus-fire call when the second input gained focus — + // separate stream, not asserted here.) The exact sibling string depends + // on where the 300ms debounce lands relative to the two real-timer + // keystrokes ("e" then "es"), so assert the load-bearing fact: the fire + // captured a NON-EMPTY sibling, i.e. read at fire time rather than the + // empty value present when the call was scheduled. const textCalls = onCompleteArgument.mock.calls.filter( ([n]) => n === "text", ); - expect(textCalls.at(-1)).toEqual(["text", "h", { targetLanguage: "es" }]); + const lastTextCall = textCalls.at(-1); + expect(lastTextCall?.[0]).toBe("text"); + expect(lastTextCall?.[1]).toBe("h"); + expect(lastTextCall?.[2].targetLanguage).toMatch(/^es?$/); }); it("clears stale dropdown options the instant a new keystroke arrives", async () => { @@ -437,6 +444,85 @@ describe("PromptArgumentsForm", () => { expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument(); }); + it("surfaces an empty dropdown when the completion request rejects", async () => { + const user = userEvent.setup(); + // The first (focus) call resolves with options; the debounced + // keystroke call rejects. The rejection (not aborted) must clear the + // dropdown to [] rather than leave the stale options showing. + const onCompleteArgument = vi + .fn< + ( + argName: string, + value: string, + context: Record, + ) => Promise + >() + .mockResolvedValueOnce(["alpha", "alphabet"]) + .mockRejectedValueOnce(new Error("completion failed")); + + renderWithMantine( + , + ); + + const input = screen.getByRole("textbox", { name: /^text/ }); + await user.click(input); + await new Promise((r) => setTimeout(r, 0)); + expect(await screen.findByText("alpha")).toBeInTheDocument(); + + // Type a char → debounced call rejects → options reset to []. + await user.type(input, "z"); + await new Promise((r) => setTimeout(r, 400)); + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + expect(screen.queryByText("alphabet")).not.toBeInTheDocument(); + }); + + it("cancels a pending debounce timer when the input is re-focused", async () => { + const user = userEvent.setup(); + const onCompleteArgument = vi + .fn< + ( + argName: string, + value: string, + context: Record, + ) => Promise + >() + .mockResolvedValue([]); + + renderWithMantine( + , + ); + + const textInput = screen.getByRole("textbox", { name: /^text/ }); + const siblingInput = screen.getByRole("textbox", { + name: /targetLanguage/, + }); + + // Type into text → schedules a debounce timer. Immediately move focus + // away and back before the 300ms fires, so handleFocus sees a pending + // timer for "text" and clears it. + await user.type(textInput, "h"); + await user.click(siblingInput); + await user.click(textInput); + onCompleteArgument.mockClear(); + // The cancelled debounce must not fire a stale keystroke completion. + await new Promise((r) => setTimeout(r, 400)); + const textCalls = onCompleteArgument.mock.calls.filter( + ([n]) => n === "text", + ); + // Only the focus-fire call (value "h"), never the cancelled debounce. + expect(textCalls.every(([, v]) => v === "h")).toBe(true); + }); + it("does not call onCompleteArgument when completions are unsupported", async () => { const user = userEvent.setup(); const onCompleteArgument = vi.fn(); diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx index b8714dca..3979c9c5 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx @@ -139,6 +139,20 @@ describe("PromptControls", () => { expect(onRefreshList).toHaveBeenCalledTimes(1); }); + it("clears the search via the Clear button (onSearchChange with empty value)", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderWithMantine( + , + ); + // The Clear button only renders while searchText is non-empty. + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onSearchChange).toHaveBeenLastCalledWith(""); + }); + it("renders empty list when no prompts", () => { renderWithMantine(); expect(screen.getByText("Prompts")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx index 92a276e0..f78e84c8 100644 --- a/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx +++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx @@ -102,6 +102,22 @@ describe("ResourceLink", () => { expect(screen.getByText("nope")).toBeInTheDocument(); }); + it("stringifies a non-Error rejection in the failure alert", async () => { + const user = userEvent.setup(); + // Reject with a plain string (not an Error) so the `String(err)` + // branch of the catch handler is exercised. + const onReadResource = vi.fn().mockRejectedValue("boom-string"); + renderWithMantine( + , + ); + + await user.click(screen.getByRole("button")); + await waitFor(() => + expect(screen.getByText("Failed to read resource")).toBeInTheDocument(), + ); + expect(screen.getByText("boom-string")).toBeInTheDocument(); + }); + it("retries the read on re-expand after a failure", async () => { const user = userEvent.setup(); const onReadResource = vi diff --git a/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.test.tsx b/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.test.tsx index 8a144935..d862bb54 100644 --- a/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.test.tsx +++ b/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.test.tsx @@ -48,6 +48,18 @@ describe("ResourceSubscribedItem", () => { expect(screen.getByText("bar")).toBeInTheDocument(); }); + it("falls back to the raw URI when every path segment is empty", () => { + // A URI of only slashes filters down to an empty segment list, so the + // `?? uri` fallback in lastUriSegment is exercised. + renderWithMantine( + {}} + />, + ); + expect(screen.getByText("///")).toBeInTheDocument(); + }); + it("shows the full URI in a tooltip on hover", async () => { const user = userEvent.setup(); renderWithMantine( diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index 8fd292aa..0f534c1b 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -300,6 +300,167 @@ describe("ResourceTemplatePanel", () => { expect(screen.queryByText("alphabet")).not.toBeInTheDocument(); }); + it("surfaces an empty dropdown when the completion request rejects", async () => { + const user = userEvent.setup(); + // Focus call resolves with options; the debounced keystroke call + // rejects. The rejection (not aborted) must reset the dropdown to []. + const onCompleteArgument = vi + .fn< + ( + argName: string, + value: string, + context: Record, + ) => Promise + >() + .mockResolvedValueOnce(["alpha", "alphabet"]) + .mockRejectedValueOnce(new Error("completion failed")); + + renderWithMantine( + , + ); + + const input = screen.getByRole("textbox", { name: "userId" }); + await user.click(input); + await new Promise((r) => setTimeout(r, 0)); + expect(await screen.findByText("alpha")).toBeInTheDocument(); + + await user.type(input, "z"); + await new Promise((r) => setTimeout(r, 400)); + expect(screen.queryByText("alpha")).not.toBeInTheDocument(); + expect(screen.queryByText("alphabet")).not.toBeInTheDocument(); + }); + + it("cancels a pending debounce timer when the input is re-focused", async () => { + const user = userEvent.setup(); + const onCompleteArgument = vi + .fn< + ( + argName: string, + value: string, + context: Record, + ) => Promise + >() + .mockResolvedValue([]); + + renderWithMantine( + , + ); + + const tableInput = screen.getByRole("textbox", { name: "tableName" }); + const rowInput = screen.getByRole("textbox", { name: "rowId" }); + + // Type into tableName → schedules a debounce timer. Move focus away + // and back before the 300ms fires so handleVariableFocus sees a + // pending timer for "tableName" and clears it. + await user.type(tableInput, "u"); + await user.click(rowInput); + await user.click(tableInput); + onCompleteArgument.mockClear(); + await new Promise((r) => setTimeout(r, 400)); + const tableCalls = onCompleteArgument.mock.calls.filter( + ([n]) => n === "tableName", + ); + // Only the focus-fire call (value "u"), never the cancelled debounce. + expect(tableCalls.every(([, v]) => v === "u")).toBe(true); + }); + + it("drops a stale in-flight response when a faster keystroke arrives", async () => { + const user = userEvent.setup(); + const calls: Array<{ + value: string; + resolve: (values: string[]) => void; + }> = []; + const onCompleteArgument = vi.fn( + (_argName: string, value: string) => + new Promise((resolve) => { + calls.push({ value, resolve }); + }), + ); + + renderWithMantine( + , + ); + + // Focus → first call (value=""). Type "h" → second call after + // debounce. Type "i" → third call after debounce, which aborts the + // "h" controller. + const input = screen.getByRole("textbox", { name: "userId" }); + await user.type(input, "h"); + await new Promise((r) => setTimeout(r, 350)); + await user.type(input, "i"); + await new Promise((r) => setTimeout(r, 350)); + + const hi = calls.find((c) => c.value === "hi"); + const h = calls.find((c) => c.value === "h"); + expect(hi).toBeDefined(); + expect(h).toBeDefined(); + // Resolve the stale "h" after it was aborted — its signal.aborted + // guard drops the response so it can't overwrite the fresh one. + h?.resolve(["from-stale-h"]); + hi?.resolve(["from-fresh-hi"]); + await new Promise((r) => setTimeout(r, 0)); + + expect(await screen.findByText("from-fresh-hi")).toBeInTheDocument(); + expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument(); + }); + + it("aborts in-flight completion requests on unmount", async () => { + const user = userEvent.setup(); + // A request that never settles, so it is still in flight at unmount. + const onCompleteArgument = vi.fn(() => new Promise(() => {})); + + const { unmount } = renderWithMantine( + , + ); + + // Focus fires a completion immediately, leaving an in-flight request. + await user.click(screen.getByRole("textbox", { name: "userId" })); + await new Promise((r) => setTimeout(r, 0)); + expect(onCompleteArgument).toHaveBeenCalled(); + + // The unmount-cleanup effect iterates the in-flight controllers and + // aborts each — this must not throw. + expect(() => unmount()).not.toThrow(); + }); + + it("does not fire completions on focus when completions are unsupported", async () => { + const user = userEvent.setup(); + const onCompleteArgument = vi.fn(); + renderWithMantine( + , + ); + // Focusing the plain TextInput hits the early `!useAutocomplete` + // return in handleVariableFocus. + await user.click(screen.getByLabelText("userId")); + await new Promise((r) => setTimeout(r, 0)); + expect(onCompleteArgument).not.toHaveBeenCalled(); + }); + it("does not call onCompleteArgument when completions are unsupported", async () => { const user = userEvent.setup(); const onCompleteArgument = vi.fn(); diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index 68b2905e..5c60a3a5 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -143,6 +143,8 @@ export function ResourceTemplatePanel({ const runCompletion = useCallback( async (varName: string, value: string, context: Record) => { + /* v8 ignore next -- unreachable: runCompletion is only invoked when + useAutocomplete is true, which already requires onCompleteArgument. */ if (!onCompleteArgument) return; requestsRef.current.get(varName)?.abort(); const controller = new AbortController(); @@ -203,6 +205,8 @@ export function ResourceTemplatePanel({ } function handleVariableFocus(varName: string) { + /* v8 ignore next -- unreachable: the plain (non-autocomplete) TextInput + has no onFocus, so this handler only runs when useAutocomplete is true. */ if (!useAutocomplete) return; // Fire immediately so the dropdown isn't empty when the user first // clicks in. Cancel any pending debounce for this variable so a @@ -214,6 +218,8 @@ export function ResourceTemplatePanel({ clearTimeout(existing); timersRef.current.delete(varName); } + /* v8 ignore next -- the `?? ""` fallback is unreachable: `variables` (and + its ref) is seeded with every declared variable, so the key is present. */ const value = variablesRef.current[varName] ?? ""; void runCompletion(varName, value, buildContext(varName)); } @@ -237,13 +243,15 @@ export function ResourceTemplatePanel({ {description && {description}} - {variableNames.map((varName) => - useAutocomplete ? ( + {variableNames.map((varName) => { + /* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */ + const fieldValue = variables[varName] ?? ""; + return useAutocomplete ? ( handleVariableChange(varName, e.currentTarget.value) } @@ -271,8 +279,8 @@ export function ResourceTemplatePanel({ ) : null } /> - ), - )} + ); + })} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index ca50cddf..6437f8fa 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -109,6 +109,90 @@ describe("SchemaForm", () => { expect(onChange).toHaveBeenCalledWith({ format: "csv" }); }); + it("clears a string field via its Clear button", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { type: "string", title: "Name" }, + }, + }; + renderWithMantine( + , + ); + // The Clear button only renders while the value is truthy. + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onChange).toHaveBeenCalledWith({ name: "" }); + }); + + it("passes undefined to onChange when a number field is cleared", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + count: { type: "integer", title: "Count" }, + }, + }; + renderWithMantine( + , + ); + const input = screen.getByLabelText(/Count/) as HTMLInputElement; + // Clearing the input makes Mantine NumberInput emit "" (a string), + // which the handler maps to undefined. + await user.clear(input); + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastCall.count).toBeUndefined(); + }); + + it("falls back to empty/const labels for oneOf items missing const and title", () => { + const onChange = vi.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + choice: { + type: "string", + title: "Choice", + // One item has neither const nor title — exercises the + // `const ?? ""` and `title ?? String(const ?? "")` fallbacks. + oneOf: [{}, { const: "b" }], + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByText("Choice")).toBeInTheDocument(); + }); + + it("falls back to empty/const labels for anyOf items missing const and title", () => { + const onChange = vi.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + tags: { + type: "array", + title: "Tags", + items: { + // First item has neither const nor title — exercises the + // `const ?? ""` and `title ?? String(const ?? "")` fallbacks. + anyOf: [{}, { const: "b" }], + }, + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByText("Tags")).toBeInTheDocument(); + }); + it("renders an oneOf Select using titles for labels", () => { const onChange = vi.fn(); const schema: JsonSchemaType = { diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx index a9f8b34d..14fe826e 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx @@ -390,6 +390,57 @@ describe("ServerConfigModal", () => { expect(cwdInput).toHaveValue(""); }); + it("switches transport via the Transport select, revealing the URL field", async () => { + const user = userEvent.setup(); + renderWithMantine(); + + // Starts on stdio — Command is shown, URL is not. + expect(screen.getByLabelText(/^Command/)).toBeInTheDocument(); + + await user.click(screen.getByRole("textbox", { name: /Transport/i })); + const option = await screen.findByRole("option", { + name: /sse \(Server-Sent Events\)/, + hidden: true, + }); + await user.click(option); + + // The onChange handler set transport to sse, so the URL field renders + // and the stdio Command field is gone. + expect(await screen.findByLabelText(/^URL/)).toBeInTheDocument(); + expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument(); + }); + + it("requires a server id before submitting", async () => { + const user = userEvent.setup(); + const props = base(); + renderWithMantine(); + + // No id typed at all — handleSubmit short-circuits on the empty id + // before it ever validates the transport config. + await user.type(screen.getByLabelText(/Command/i), "node"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + expect(screen.getByRole("alert")).toHaveTextContent( + /Server id is required/i, + ); + expect(props.onSubmit).not.toHaveBeenCalled(); + }); + + it("rejects an env line whose '=' is at the start (no key)", async () => { + const user = userEvent.setup(); + const props = base(); + renderWithMantine(); + + await user.type(screen.getByLabelText(/Server ID/i), "alpha"); + await user.type(screen.getByLabelText(/Command/i), "node"); + // "=value" has the "=" at index 0 (eq === 0), the falsy-index guard. + await user.type(screen.getByLabelText(/Environment/i), "=value"); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + expect(screen.getByRole("alert")).toHaveTextContent(/Use KEY=VALUE/i); + expect(props.onSubmit).not.toHaveBeenCalled(); + }); + it("calls onClose when Cancel is clicked", async () => { const user = userEvent.setup(); const props = base(); diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index f2fff822..a76e5565 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -138,6 +138,9 @@ function parseEnv(raw: string): // env values preserve trailing whitespace — they're shell-style strings // where spaces / tabs can be load-bearing. const value = line.slice(eq + 1); + /* v8 ignore next 2 -- unreachable: the line is already trimmed and eq>0, + so the key slice always starts with a non-whitespace char and can never + trim to empty; kept as a defensive guard. */ if (!key) return { ok: false, error: `Invalid env line "${line}". Empty key.` }; out[key] = value; diff --git a/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.test.tsx b/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.test.tsx index 895189be..072f0edb 100644 --- a/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.test.tsx @@ -351,6 +351,69 @@ describe("ServerImportConfigModal", () => { ); }); + it("reflects the chosen client as the dropdown value", async () => { + const user = userEvent.setup(); + setup(); + // Selecting a client makes the dropdown's value non-empty (the truthy + // branch of `vm.selectedType ?? ""`). + await user.selectOptions(screen.getByLabelText("Client"), "Cursor"); + expect(screen.getByLabelText("Client")).toHaveValue("cursor"); + }); + + it("resets the dropdown to no selection when the placeholder is re-chosen", async () => { + const user = userEvent.setup(); + setup(); + const dropdown = screen.getByLabelText("Client"); + await user.selectOptions(dropdown, "Cursor"); + expect(dropdown).toHaveValue("cursor"); + // Re-selecting the leading placeholder emits "" → `value || null` resolves + // to null and the Import button disables again. + await user.selectOptions(dropdown, "Select a client…"); + expect(dropdown).toHaveValue(""); + expect(screen.getByRole("button", { name: "Import" })).toBeDisabled(); + }); + + it("renders a conflicts-only review without a new-servers section", async () => { + const user = userEvent.setup(); + // Every incoming server already exists → no additions, only conflicts (the + // false branch of `plan.additions.length > 0`). + setup( + { + type: "cursor", + found: true, + path: "/home/u/.cursor/mcp.json", + searched: ["/home/u/.cursor/mcp.json"], + config: { mcpServers: { existing: { type: "stdio", command: "e" } } }, + }, + ["existing"], + ); + await pickClientAndImport(user, "Cursor"); + await waitFor(() => + expect(screen.getByText("Already exists (1)")).toBeInTheDocument(), + ); + expect(screen.queryByText(/New servers/)).not.toBeInTheDocument(); + }); + + it("renders an additions-only review without a conflicts section", async () => { + const user = userEvent.setup(); + // No existing ids → every incoming server is a brand-new addition, so the + // "Already exists" conflicts section never renders (the false branch of + // `plan.conflicts.length > 0`). + setup(sourceWithConflict, []); + await pickClientAndImport(user, "Cursor"); + await waitFor(() => + expect(screen.getByText("New servers (2)")).toBeInTheDocument(), + ); + expect(screen.queryByText(/Already exists/)).not.toBeInTheDocument(); + // The default per-addition action renders as "import"; flipping one to Skip + // exercises the explicit-action branch of `additionActions[a.id] ?? "import"`. + const alphaRow = screen.getByText("alpha").parentElement as HTMLElement; + await user.click(within(alphaRow).getByText("Skip")); + expect( + screen.getByRole("button", { name: /Import 1 server/ }), + ).toBeInTheDocument(); + }); + it("goes back from review to the source picker", async () => { const user = userEvent.setup(); setup(); diff --git a/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx b/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx index c9d9232a..9e725874 100644 --- a/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx +++ b/clients/web/src/components/groups/ServerImportConfigModal/ServerImportConfigModal.tsx @@ -183,6 +183,10 @@ export function ServerImportConfigModal({ vm.setAdditionAction(a.id, value as AdditionAction) @@ -207,6 +211,11 @@ export function ServerImportConfigModal({ vm.setResolution( diff --git a/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.test.tsx b/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.test.tsx index 65e24007..e25b912b 100644 --- a/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.test.tsx +++ b/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.test.tsx @@ -23,6 +23,15 @@ const httpTarget: ServerEntry = { connection: { status: "disconnected" }, }; +const stdioNoTypeNoArgs: ServerEntry = { + id: "bare", + name: "bare", + // No `type` field and no `args` — exercises the `type ?? "stdio"` fallback + // and the `args ?? []` fallback in summarize(). + config: { command: "run-it" }, + connection: { status: "disconnected" }, +}; + describe("ServerRemoveConfirmModal", () => { it("does not render when opened is false", () => { renderWithMantine( @@ -114,6 +123,40 @@ describe("ServerRemoveConfirmModal", () => { expect(onCancel).not.toHaveBeenCalled(); }); + it("defaults the transport label to stdio and omits args when neither is set", () => { + renderWithMantine( + , + ); + // `type ?? "stdio"` falls back to "stdio"; summarize joins just the command + // since `args` is undefined (`args ?? []`). + expect(screen.getByText(/stdio · run-it/)).toBeInTheDocument(); + }); + + it("stringifies a non-Error rejection value in the alert", async () => { + const user = userEvent.setup(); + // Rejecting with a plain string takes the `String(err)` branch. + const onConfirm = vi.fn().mockRejectedValue("plain string failure"); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /^Remove$/ })); + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + /plain string failure/, + ); + }); + }); + it("is a no-op when Remove is clicked with no target", async () => { const user = userEvent.setup(); const onConfirm = vi.fn(); diff --git a/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.tsx b/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.tsx index cada0e1f..09944ca3 100644 --- a/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.tsx +++ b/clients/web/src/components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal.tsx @@ -19,6 +19,8 @@ const Summary = Paper.withProps({ }); function summarize(config: ServerEntry["config"] | undefined): string { + /* v8 ignore next -- defensive guard: every ServerEntry has a non-optional + config, so summarize is never called with undefined in practice. */ if (!config) return ""; // StdioServerConfig has `type?: "stdio"` (optional), which means // `config.type === "stdio"` doesn't narrow away the undefined-type stdio diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index a4d21f77..27c1f7ed 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -810,6 +810,78 @@ describe("ServerSettingsForm", () => { expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", ""); }); + it("omits the Clear buttons for an empty header key/value row", () => { + // A row whose key and value are both empty renders no Clear button in + // either field (the null branch of `item.key ?` / `item.value ?`). + renderWithMantine( + , + ); + const keyInput = screen.getByPlaceholderText("Key"); + const valueInput = screen.getByPlaceholderText("Value"); + expect( + within( + keyInput.closest('[class*="Input-wrapper"]') as HTMLElement, + ).queryByRole("button", { name: "Clear" }), + ).toBeNull(); + expect( + within( + valueInput.closest('[class*="Input-wrapper"]') as HTMLElement, + ).queryByRole("button", { name: "Clear" }), + ).toBeNull(); + }); + + it("handles an empty-uri, unnamed root row (no Clear buttons, fallbacks applied)", async () => { + const user = userEvent.setup(); + const onRootChange = vi.fn(); + // uri empty + name undefined → both fields render without a Clear button, + // and editing threads `root.name ?? ""` (empty) through onChange. + renderWithMantine( + , + ); + const uriInput = screen.getByPlaceholderText("URI (e.g. file:///path)"); + const nameInput = screen.getByPlaceholderText("Name (optional)"); + expect((nameInput as HTMLInputElement).value).toBe(""); + expect( + within( + uriInput.closest('[class*="Input-wrapper"]') as HTMLElement, + ).queryByRole("button", { name: "Clear" }), + ).toBeNull(); + expect( + within( + nameInput.closest('[class*="Input-wrapper"]') as HTMLElement, + ).queryByRole("button", { name: "Clear" }), + ).toBeNull(); + // Typing into the URI threads the empty name through (`root.name ?? ""`). + await user.type(uriInput, "f"); + expect(onRootChange).toHaveBeenLastCalledWith(0, "f", ""); + }); + + it("coerces a cleared (empty-string) timeout to 0", async () => { + const user = userEvent.setup(); + const onTimeoutChange = vi.fn(); + renderWithMantine( + , + ); + // Clearing the NumberInput emits "" which the handler coerces to 0 via the + // `parseInt(value, 10) || 0` fallback. + await user.clear(screen.getByLabelText(/Connection Timeout/)); + expect(onTimeoutChange).toHaveBeenLastCalledWith("connectionTimeout", 0); + }); + it("invokes onTimeoutChange with 0 when a non-numeric string is provided", () => { const onTimeoutChange = vi.fn(); // Render with a non-finite default in the NumberInput; then directly invoke diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 0b9817da..cea2bb34 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -91,6 +91,9 @@ function KeyValueRows({ onChange: (index: number, key: string, value: string) => void; onRemove: (index: number) => void; }) { + /* v8 ignore next 3 -- unreachable: every caller guards with `length === 0` + and renders an EmptyHint instead, so KeyValueRows is only mounted with + a non-empty list. */ if (items.length === 0) { return null; } @@ -137,6 +140,9 @@ function RootRows({ onChange: (index: number, uri: string, name: string) => void; onRemove: (index: number) => void; }) { + /* v8 ignore next 3 -- unreachable: the caller guards with `length === 0` + and renders an EmptyHint instead, so RootRows is only mounted with a + non-empty list. */ if (roots.length === 0) { return null; } diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 35cac4f2..5089e112 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -408,6 +408,130 @@ describe("ServerSettingsModal", () => { expect(headersControl.getAttribute("aria-expanded")).toBe("false"); }); + it("calls onSettingsChange when toggling Auto Refresh in the Options section", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + // The Options section is expanded on open, so the checkbox is visible. + await user.click( + screen.getByRole("checkbox", { + name: /Auto Refresh on List Changed Notifications/, + }), + ); + expect(onSettingsChange).toHaveBeenCalledWith({ + ...emptySettings, + autoRefreshOnListChanged: true, + }); + }); + + it("calls onSettingsChange when changing the Network Log Size", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByLabelText(/Network Log Size/), "2"); + expect(onSettingsChange).toHaveBeenCalled(); + const call = onSettingsChange.mock.calls.at(-1)?.[0]; + expect(typeof call.maxFetchRequests).toBe("number"); + }); + + it("preserves sibling rows when editing one of several headers", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const twoHeaders: InspectorServerSettings = { + ...emptySettings, + headers: [ + { key: "A", value: "1" }, + { key: "B", value: "2" }, + ], + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Custom Headers" })); + // Editing the first header's value must leave the second row untouched + // (the `i === index ? … : h` non-matching branch). + await user.type(screen.getByDisplayValue("1"), "0"); + const lastCall = onSettingsChange.mock.calls.at(-1)?.[0]; + expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" }); + }); + + it("preserves sibling rows when editing one of several metadata entries", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const twoMeta: InspectorServerSettings = { + ...emptySettings, + metadata: [ + { key: "m1", value: "v1" }, + { key: "m2", value: "v2" }, + ], + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Request Metadata" })); + await user.type(screen.getByDisplayValue("v1"), "x"); + const lastCall = onSettingsChange.mock.calls.at(-1)?.[0]; + expect(lastCall.metadata[1]).toEqual({ key: "m2", value: "v2" }); + }); + + it("preserves sibling rows when editing one of several roots", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const twoRoots: InspectorServerSettings = { + ...emptySettings, + roots: [ + { uri: "file:///a", name: "A" }, + { uri: "file:///b", name: "B" }, + ], + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Roots" })); + await user.type(screen.getByDisplayValue("file:///a"), "1"); + const lastCall = onSettingsChange.mock.calls.at(-1)?.[0]; + expect(lastCall.roots[1]).toEqual({ uri: "file:///b", name: "B" }); + }); + describe("stdio env / cwd handlers", () => { // The Options section is expanded on open, so the stdio fields are visible // immediately when isStdio is true. @@ -498,6 +622,35 @@ describe("ServerSettingsModal", () => { expect(lastCall.env[0]).toEqual({ key: "A", value: "12" }); }); + it("preserves sibling rows when editing one of several env vars", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { name: "Environment Variables" }), + ); + await user.type(screen.getByDisplayValue("1"), "0"); + const lastCall = onSettingsChange.mock.calls.at(-1)?.[0]; + // The untouched second env row survives the edit (the non-matching map + // branch). + expect(lastCall.env[1]).toEqual({ key: "B", value: "2" }); + }); + it("calls onSettingsChange when editing the working directory", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/components/groups/SortableServerCard/SortableServerCard.test.tsx b/clients/web/src/components/groups/SortableServerCard/SortableServerCard.test.tsx new file mode 100644 index 00000000..c3608bc3 --- /dev/null +++ b/clients/web/src/components/groups/SortableServerCard/SortableServerCard.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import type { + ConnectionState, + MCPServerConfig, +} from "@inspector/core/mcp/types.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { SortableServerCard } from "./SortableServerCard"; + +// Control the dnd-kit sortable state so both arms of the `isDragging` +// transform ternaries (zIndex / opacity) are exercised. The real +// `useSortable` only reports `isDragging: true` mid-drag, which can't be +// driven through happy-dom, so we mock it to flip the flag per test. +const sortableMock = vi.hoisted(() => ({ isDragging: false })); +vi.mock("@dnd-kit/sortable", () => ({ + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: () => {}, + setActivatorNodeRef: () => {}, + transform: null, + transition: undefined, + isDragging: sortableMock.isDragging, + }), +})); + +const stdioConfig: MCPServerConfig = { + command: "npx -y @modelcontextprotocol/server-everything", +}; + +const connected: ConnectionState = { + status: "connected", + protocolVersion: "2025-06-18", +}; + +const baseProps = { + id: "srv-1", + name: "My MCP Server", + config: stdioConfig, + info: { name: "My MCP Server", version: "1.2.0" }, + connection: connected, + onToggleConnection: vi.fn(), + onConnectionInfo: vi.fn(), + onSettings: vi.fn(), + onEdit: vi.fn(), + onClone: vi.fn(), + onRemove: vi.fn(), +}; + +describe("SortableServerCard", () => { + it("renders the wrapped ServerCard with a reorder grip", () => { + sortableMock.isDragging = false; + renderWithMantine(); + expect(screen.getByText("My MCP Server")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reorder My MCP Server" }), + ).toBeInTheDocument(); + }); + + it("lifts and fades the card while dragging", () => { + sortableMock.isDragging = true; + renderWithMantine(); + // The drag arm of the transform ternaries (zIndex: 2, opacity: 0.85) is + // applied to the positioned wrapper Box around the card. + const card = screen.getByText("My MCP Server"); + const wrapper = card.closest('[style*="opacity"]'); + expect(wrapper).not.toBeNull(); + expect(wrapper).toHaveStyle({ opacity: "0.85" }); + sortableMock.isDragging = false; + }); +}); diff --git a/clients/web/src/components/groups/TaskControls/TaskControls.test.tsx b/clients/web/src/components/groups/TaskControls/TaskControls.test.tsx index 52cdec71..2f8b86af 100644 --- a/clients/web/src/components/groups/TaskControls/TaskControls.test.tsx +++ b/clients/web/src/components/groups/TaskControls/TaskControls.test.tsx @@ -55,6 +55,22 @@ describe("TaskControls", () => { expect(inputs.length).toBeGreaterThan(0); }); + it("invokes onStatusFilterChange with the picked status", async () => { + const user = userEvent.setup(); + const onStatusFilterChange = vi.fn(); + renderWithMantine( + , + ); + // Open the Select and choose a real status — the truthy branch of + // `value && STATUS_OPTIONS.includes(value)` passes it straight through. + await user.click(screen.getByPlaceholderText("All statuses")); + await user.click(await screen.findByText("completed")); + expect(onStatusFilterChange).toHaveBeenCalledWith("completed"); + }); + it("invokes onStatusFilterChange with undefined when cleared", async () => { const user = userEvent.setup(); const onStatusFilterChange = vi.fn(); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index e0746c02..95870109 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -114,6 +114,20 @@ describe("ToolControls", () => { expect(onRefreshList).toHaveBeenCalledTimes(1); }); + it("clears the search via the Clear button", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onSearchChange).toHaveBeenCalledWith(""); + }); + it("renders empty list when no tools provided", () => { renderWithMantine(); expect(screen.getByText("Tools")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx index 795f485c..67369b1a 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx @@ -6,8 +6,23 @@ import { screen, waitFor, } from "../../../test/renderWithMantine"; +import { render } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { theme } from "../../../theme/theme"; import { ViewHeader } from "./ViewHeader"; +// Render under a forced-dark MantineProvider so `useComputedColorScheme` +// returns "dark" (the light/dark icon + logo branches). +function renderDark(ui: React.ReactElement) { + return render(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +} + // Mock @mantine/hooks so we can control useMediaQuery results per test. const mediaQueryMock = vi.hoisted(() => ({ value: false })); vi.mock("@mantine/hooks", async () => { @@ -275,6 +290,80 @@ describe("ViewHeader", () => { ).not.toBeInTheDocument(); }); + it("renders the dark-scheme icon/logo branch under a dark color scheme", () => { + // Rendering under a forced-dark provider exercises the + // `colorScheme === "dark"` branches for the theme icon and the logo src. + renderDark(); + expect( + screen.getByRole("button", { name: "Toggle color scheme" }), + ).toBeInTheDocument(); + expect(screen.getByAltText("MCP")).toBeInTheDocument(); + }); + + it("crossfades the title out and the connected header in on connect (#1450)", async () => { + mediaQueryMock.value = true; + // Start disconnected: the title cell is the entering one. + const { rerender } = renderWithMantine( + , + ); + expect( + screen + .getByText("MCP Inspector") + .closest("[data-anim]") + ?.getAttribute("data-anim"), + ).toBe("in"); + + // Connect: the title cell stays mounted while it exits ("out"), and the + // connected snapshot is adopted from a previously-null snapshot (the + // `snapshot ? … : ""` and `seenTabsKey ? … : []` empty-base branches). + rerender(); + expect( + screen + .getByText("MCP Inspector") + .closest("[data-anim]") + ?.getAttribute("data-anim"), + ).toBe("out"); + // The connected server name enters once the keep-alive Transition mounts. + expect(await screen.findByText("my-mcp-server")).toBeInTheDocument(); + }); + + it("disarms the tab glow when the connection drops after the grace window", () => { + vi.useFakeTimers(); + try { + mediaQueryMock.value = true; + const { rerender } = renderWithMantine( + , + ); + // Arm the glow by letting the grace window elapse. + act(() => { + vi.advanceTimersByTime(2000); + }); + // Disconnect resets glowArmed via adjust-state-during-render. + rerender( + , + ); + // Reconnect with a new tab: because glowArmed was reset, the freshly + // re-seen tab set is treated as initial and nothing glows. + rerender( + , + ); + expect(document.querySelector('[data-glow="on"]')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it("invokes onTabChange when a different tab is picked from the Select", async () => { const user = userEvent.setup(); const onTabChange = vi.fn(); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 522d1c21..05f12405 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -279,6 +279,9 @@ export function ViewHeader(props: ViewHeaderProps) { {headerData.serverName} ) : ( + /* v8 ignore next -- unreachable: this Transition only mounts + while connected, by which point headerData is set; the render + prop must still return an element, never null. */ <> ) } @@ -332,6 +335,7 @@ export function ViewHeader(props: ViewHeaderProps) { // Unreachable in practice — the Transition only mounts while // connected, by which point the snapshot is set — but the render // prop must return an element, never null. + /* v8 ignore next */ <> ) } @@ -378,6 +382,9 @@ export function ViewHeader(props: ViewHeaderProps) { )} ) : ( + /* v8 ignore next -- unreachable: this Transition only mounts + while connected, by which point headerData is set; the render + prop must still return an element, never null. */ <> ) } diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx index e9aa45ce..79877d53 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx @@ -309,6 +309,56 @@ describe("PromptsScreen", () => { ).toBeInTheDocument(); }); + it("re-clicking the active arg-bearing prompt is a no-op (early return)", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + const onGetPrompt = vi.fn(); + renderWithMantine( + , + ); + // First click selects "summarize" (arg-bearing → form, no auto-fetch). + await user.click(screen.getByText("summarize")); + const callsAfterSelect = onUiChange.mock.calls.length; + // Re-clicking the already-selected sidebar entry must early-return: + // no further onUiChange (selection unchanged) and no fetch. The form + // pane now also shows "summarize", so target the sidebar entry (first). + await user.click(screen.getAllByText("summarize")[0]); + expect(onUiChange.mock.calls.length).toBe(callsAfterSelect); + expect(onGetPrompt).not.toHaveBeenCalled(); + }); + + it("routes sidebar search text through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + renderWithMantine(); + await user.type(screen.getByPlaceholderText("Search prompts..."), "sum"); + expect(onUiChange).toHaveBeenCalled(); + const last = onUiChange.mock.calls.at(-1)?.[0] as PromptsUiState; + expect(last.search).toBe("sum"); + }); + + it("renders nothing in the preview when an ok state carries no result", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.click(screen.getByText("summarize")); + await user.type(screen.getByPlaceholderText("Enter topic..."), "x"); + await user.click(screen.getByRole("button", { name: "Get Prompt" })); + // No messages panel and no form (preview branch is active but empty). + expect(screen.queryByText("Messages")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Get Prompt" }), + ).not.toBeInTheDocument(); + }); + it("threads onCompleteArgument with a ref/prompt envelope", async () => { const user = userEvent.setup(); const onCompleteArgument = vi diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index aa0ff10b..9d00da36 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -131,7 +131,11 @@ export function PromptsScreen({ // Re-clicking the active prompt in the sidebar shouldn't wipe the // user's typed argument values or trigger a re-fetch — sidebar is // for navigation, ✕ is for dismiss. Closing-then-reselecting is - // its own thing (the close handler clears submittedFor). + // its own thing (the close handler clears submittedFor). PromptControls + // already swallows a re-click on the active item (it only fires + // onSelectPrompt when the name differs), so this is a redundant guard + // that never fires through the UI. + /* v8 ignore next -- unreachable: PromptControls never re-emits the active name */ if (name === selectedPromptName) return; // Auto-fetch no-argument prompts the moment they're selected — the // form pane would otherwise just render a bare Get Prompt button @@ -148,6 +152,10 @@ export function PromptsScreen({ } function handleSubmit() { + // Defensive guard: handleSubmit is only wired to the argument form's + // onGetPrompt, which renders solely when `selectedPrompt` is truthy, so + // this never fires through the UI. + /* v8 ignore next -- unreachable: form only renders with a selected prompt */ if (!selectedPrompt) return; onUiChange({ ...ui, submittedFor: selectedPrompt.name }); onGetPrompt(selectedPrompt.name, argumentValues); @@ -182,6 +190,10 @@ export function PromptsScreen({ getPromptState.promptName === selectedPrompt.name; function renderPreview() { + // Defensive guard: renderPreview is only invoked from the `previewActive` + // branch below, and `previewActive` already requires a truthy + // `getPromptState`, so neither arm of this condition is reachable here. + /* v8 ignore next -- unreachable: only called when previewActive && getPromptState */ if (!previewActive || !getPromptState) return null; if (getPromptState.status === "pending") { return ( diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx index dd88e908..59f0511d 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx @@ -318,6 +318,66 @@ describe("ResourcesScreen", () => { expect(onUnsubscribeResource).toHaveBeenCalledWith("file:///x"); }); + it("routes sidebar search text through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + renderWithMantine(); + await user.type(screen.getByPlaceholderText("Search..."), "y.txt"); + expect(onUiChange).toHaveBeenCalled(); + const last = onUiChange.mock.calls.at(-1)?.[0] as ResourcesUiState; + expect(last.search).toBe("y.txt"); + }); + + it("renders nothing in the preview when an ok state carries no result", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.click(screen.getByText("x.txt")); + // Preview branch is active (readResource present) but nothing renders: + // no error/loader and no preview content. + expect(screen.queryByText("Read Error")).not.toBeInTheDocument(); + expect(screen.queryByText("Reading resource...")).not.toBeInTheDocument(); + expect( + screen.queryByText("Select a resource to preview"), + ).not.toBeInTheDocument(); + }); + + it("threads onCompleteArgument with a ref/resource envelope from the template form", async () => { + const user = userEvent.setup(); + const onCompleteArgument = vi + .fn< + ( + ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + argName: string, + value: string, + context: Record, + ) => Promise + >() + .mockResolvedValue([]); + renderWithMantine( + , + ); + await user.click(screen.getByText("files")); + // Focusing the variable input fires an immediate completion request, + // which routes through the screen's ref/resource wrapper. + await user.click(screen.getByRole("textbox", { name: "path" })); + expect(onCompleteArgument).toHaveBeenCalled(); + expect(onCompleteArgument.mock.calls[0][0]).toEqual({ + type: "ref/resource", + uri: "file:///{path}", + }); + }); + it("hides the Subscriptions section and Subscribe button when subscriptionsSupported is false", async () => { const user = userEvent.setup(); renderWithMantine( diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index b0d4c2f3..56c4a59d 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -20,6 +20,11 @@ const tools: Tool[] = [ properties: { mode: { type: "string", default: "fast" } }, }, }, + { + name: "delta", + inputSchema: { type: "object" }, + execution: { taskSupport: "optional" }, + }, ]; const baseProps = { @@ -208,6 +213,23 @@ describe("ToolsScreen", () => { expect(onCancelCall).toHaveBeenCalledTimes(1); }); + it("threads the Run-as-task toggle through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + renderWithMantine( + , + ); + // delta advertises optional task support, so the switch renders. + await user.click(screen.getByText("delta")); + await user.click(screen.getByLabelText("Run as task")); + expect(onUiChange).toHaveBeenCalled(); + const last = onUiChange.mock.calls.at(-1)?.[0] as ToolsUiState; + expect(last.runAsTask).toBe(true); + }); + it("does not crash when onClearResult/onCancelCall are undefined", async () => { const user = userEvent.setup(); renderWithMantine( diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 698d4753..1ce4293c 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -144,10 +144,16 @@ export function ToolsScreen({ // form shows defaults via resolveValue, but onChange only writes // edited fields). const tool = tools.find((t) => t.name === name); + // `name` always comes from the rendered tools list (ToolControls + // only emits names it was given), so the lookup never misses; the + // empty-object fallback is an unreachable defensive default. + let formValues: Record = {}; + /* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */ + if (tool) formValues = collectSchemaDefaults(tool.inputSchema); onUiChange({ ...ui, selectedToolName: name, - formValues: tool ? collectSchemaDefaults(tool.inputSchema) : {}, + formValues, }); }} /> diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 102e3da9..940da801 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1083,6 +1083,86 @@ describe("InspectorView", () => { ).toBeNull(); }); + it("toggles the Servers list compact state from the list toggle", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + // Servers default to expanded (compact=false), so the toggle reads + // "Collapse all"; clicking it flips serversCompact via the inline callback. + const toggle = await screen.findByRole("button", { name: "Collapse all" }); + await user.click(toggle); + expect( + await screen.findByRole("button", { name: "Expand all" }), + ).toBeInTheDocument(); + }); + + it("toggles the Network list compact state from the list toggle", async () => { + const user = userEvent.setup(); + const httpServer: ServerEntry = { + id: "beta", + name: "Beta", + config: { type: "streamable-http", url: "http://localhost:3000/mcp" }, + connection: { status: "connected" }, + }; + renderWithMantine( + , + ); + const tabSelect = await screen.findByDisplayValue("Servers"); + await user.click(tabSelect); + await user.click(await screen.findByText("Network")); + // Network defaults to compact=true → "Expand all"; clicking flips it. + const toggle = await screen.findByRole("button", { name: "Expand all" }); + await user.click(toggle); + expect( + await screen.findByRole("button", { name: "Collapse all" }), + ).toBeInTheDocument(); + }); + + it("shows the Network tab when the active server id is not in the list (non-stdio fallback)", async () => { + // connectionStatus is connected but activeServer points at an id absent + // from the list — `active` is undefined, so isStdio falls back to false and + // the Network tab is not hidden. + const httpServer: ServerEntry = { + id: "beta", + name: "Beta", + config: { type: "streamable-http", url: "http://localhost:3000/mcp" }, + connection: { status: "connected" }, + }; + renderWithMantine( + , + ); + const radios = await screen.findAllByRole("radio"); + expect(radios.map((r) => r.getAttribute("value"))).toContain("Network"); + }); + describe("listChanged indicator wiring (#1402)", () => { // The indicator only mounts on the active screen, so each case connects, // navigates to the target tab, and asserts the "List updated" affordance. diff --git a/clients/web/src/test/core/auth/ema/emaFlow.test.ts b/clients/web/src/test/core/auth/ema/emaFlow.test.ts index ea45753c..37ef84a1 100644 --- a/clients/web/src/test/core/auth/ema/emaFlow.test.ts +++ b/clients/web/src/test/core/auth/ema/emaFlow.test.ts @@ -3,6 +3,9 @@ import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import { mintEmaResourceTokens, trySilentEmaAuth, + startEmaIdpAuthorization, + completeEmaIdpAuthorizationAndMint, + refreshEmaResourceTokens, type EmaFlowConfig, } from "@inspector/core/auth/ema/emaFlow.js"; import type { EmaResourceContext } from "@inspector/core/auth/ema/resourceContext.js"; @@ -36,13 +39,30 @@ function createMemoryStorage( idpSessions: Record = {}, savedTokens: Record = {}, ): OAuthStorage { + const metadataByKey: Record = {}; + const clientInfoByKey: Record = {}; + const codeVerifierByKey: Record = {}; return { getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), saveIdpSession: vi.fn(async (issuer: string, updates) => { idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; }), clearIdpSession: vi.fn(), - getServerMetadata: vi.fn(() => undefined), + getServerMetadata: vi.fn((key: string) => metadataByKey[key]), + saveServerMetadata: vi.fn((key: string, metadata: unknown) => { + metadataByKey[key] = metadata; + }), + getClientInformation: vi.fn((key: string) => clientInfoByKey[key]), + savePreregisteredClientInformation: vi.fn((key: string, info: unknown) => { + clientInfoByKey[key] = info; + }), + getCodeVerifier: vi.fn((key: string) => codeVerifierByKey[key]), + saveCodeVerifier: vi.fn((key: string, verifier: string) => { + codeVerifierByKey[key] = verifier; + }), + clearCodeVerifier: vi.fn((key: string) => { + delete codeVerifierByKey[key]; + }), getTokens: vi.fn(async (url: string) => savedTokens[url] as never), saveTokens: vi.fn(async (url: string, tokens) => { savedTokens[url] = tokens; @@ -107,6 +127,16 @@ function mockEmaFetch(idToken: string) { JSON.stringify({ id_token: refreshed, token_type: "Bearer" }), ); } + if (body.get("grant_type") === "authorization_code") { + return new Response( + JSON.stringify({ + access_token: "idp-access", + id_token: idToken, + refresh_token: "idp-refresh", + token_type: "Bearer", + }), + ); + } expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE); expect(body.get("subject_token")).toBe(idToken); return new Response( @@ -293,4 +323,163 @@ describe("emaFlow", () => { expect(tokens.access_token).toBe("resource-access-token"); expect(idpSessions[IDP_ISSUER]?.idToken).toBe(refreshed); }); + + it("mintEmaResourceTokens throws when resourceClientId is missing", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + await expect( + mintEmaResourceTokens( + { + ...baseConfig(storage), + resourceClientId: undefined, + fetchFn: mockEmaFetch(idToken), + }, + resourceContext(), + ), + ).rejects.toThrow(/resource authorization server clientId/); + }); + + it("mintEmaResourceTokens discovers resource context when none is passed", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + const tokens = await mintEmaResourceTokens({ + ...baseConfig(storage), + fetchFn: mockEmaFetch(idToken), + }); + + expect(tokens.access_token).toBe("resource-access-token"); + }); + + it("startEmaIdpAuthorization returns the IdP authorization URL", async () => { + const idToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + const url = await startEmaIdpAuthorization({ + ...baseConfig(storage), + fetchFn: mockEmaFetch(idToken), + }); + + expect(url).toBeInstanceOf(URL); + expect(url.href).toContain(`${IDP_ISSUER}/authorize`); + expect(storage.saveCodeVerifier).toHaveBeenCalled(); + }); + + it("completeEmaIdpAuthorizationAndMint exchanges code, mints, and saves tokens", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + const fetchFn = mockEmaFetch(idToken); + const config = { ...baseConfig(storage), fetchFn }; + + // Seed code verifier / client info / metadata for the callback exchange. + await startEmaIdpAuthorization(config); + + const tokens = await completeEmaIdpAuthorizationAndMint( + config, + "auth-code", + ); + + expect(tokens.access_token).toBe("resource-access-token"); + expect(storage.saveTokens).toHaveBeenCalledWith( + SERVER_URL, + expect.objectContaining({ access_token: "resource-access-token" }), + { enterpriseManaged: true }, + ); + }); + + it("completeEmaIdpAuthorizationAndMint wraps leg 1 errors", async () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + // No startEmaIdpAuthorization → no metadata seeded → leg 1 fails. + await expect( + completeEmaIdpAuthorizationAndMint(baseConfig(storage), "auth-code"), + ).rejects.toThrow(/EMA leg 1 \(IdP authorization code exchange\)/); + errorSpy.mockRestore(); + }); + + it("completeEmaIdpAuthorizationAndMint wraps mint failures from legs 2–3", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + const fetchFn = mockEmaFetch(idToken); + const config = { + ...baseConfig(storage), + resourceClientSecret: "", + fetchFn, + }; + + await startEmaIdpAuthorization(config); + + await expect( + completeEmaIdpAuthorizationAndMint(config, "auth-code"), + ).rejects.toThrow(/EMA legs 2–3 \(resource token mint\)/); + }); + + it("refreshEmaResourceTokens re-runs legs 2–3 and saves tagged tokens", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + const tokens = await refreshEmaResourceTokens({ + ...baseConfig(storage), + fetchFn: mockEmaFetch(idToken), + }); + + expect(tokens?.access_token).toBe("resource-access-token"); + expect(storage.saveTokens).toHaveBeenCalledWith( + SERVER_URL, + expect.objectContaining({ access_token: "resource-access-token" }), + { enterpriseManaged: true }, + ); + }); + + it("refreshEmaResourceTokens returns undefined without an IdP session", async () => { + expect(await refreshEmaResourceTokens(baseConfig(storage))).toBeUndefined(); + }); + + it("trySilentEmaAuth wraps non-Error mint failures (String(err) branch)", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + // Mint itself succeeds; saveTokens throws a non-Error string so the + // try/catch wraps it via wrapEmaMintError's String(err) branch. + vi.mocked(storage.saveTokens).mockImplementation(() => { + throw "boom-non-error"; + }); + + const result = await trySilentEmaAuth({ + ...baseConfig(storage), + fetchFn: mockEmaFetch(idToken), + }); + + expect(result.status).toBe("mint_failed"); + if (result.status === "mint_failed") { + expect(result.error.message).toBe( + "EMA legs 2–3 (resource token mint): boom-non-error", + ); + } + }); + + it("completeEmaIdpAuthorizationAndMint wraps non-Error leg-1 failures (String(err) branch)", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + const fetchFn = mockEmaFetch(idToken); + const config = { ...baseConfig(storage), fetchFn }; + + // Seed metadata/verifier so leg 1 would otherwise succeed, then make + // saveIdpSession (called at the end of leg 1) throw a non-Error so the + // leg-1 catch takes its String(err) branch. + await startEmaIdpAuthorization(config); + vi.mocked(storage.saveIdpSession).mockImplementation(() => { + throw "leg1-non-error"; + }); + + await expect( + completeEmaIdpAuthorizationAndMint(config, "auth-code"), + ).rejects.toThrow( + "EMA leg 1 (IdP authorization code exchange): leg1-non-error", + ); + }); }); diff --git a/clients/web/src/test/core/auth/ema/idpOidc.test.ts b/clients/web/src/test/core/auth/ema/idpOidc.test.ts index ae669faf..0831b4db 100644 --- a/clients/web/src/test/core/auth/ema/idpOidc.test.ts +++ b/clients/web/src/test/core/auth/ema/idpOidc.test.ts @@ -1,8 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import { + completeIdpOidcAuthorization, + discoverIdpMetadata, getValidIdToken, refreshIdpOidcSession, + startIdpOidcAuthorization, } from "@inspector/core/auth/ema/idpOidc.js"; import { minimalOAuthAsMetadata } from "../../../integration/mcp/ema-mock-servers.js"; @@ -117,4 +120,384 @@ describe("idpOidc refresh", () => { expect(await getValidIdToken({ idp, storage, fetchFn })).toBeUndefined(); }); + + it("getValidIdToken returns undefined when no session exists", async () => { + expect( + await getValidIdToken({ idp, storage, fetchFn: vi.fn() }), + ).toBeUndefined(); + }); + + it("getValidIdToken returns a still-valid ID Token without fetching", async () => { + const valid = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + idpSessions[IDP_ISSUER] = { idToken: valid, refreshToken: "rt-abc" }; + const fetchFn = vi.fn(); + expect(await getValidIdToken({ idp, storage, fetchFn })).toBe(valid); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("getValidIdToken returns undefined when expired and no refresh token", async () => { + const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + idpSessions[IDP_ISSUER] = { idToken: expired }; + const fetchFn = vi.fn(); + expect(await getValidIdToken({ idp, storage, fetchFn })).toBeUndefined(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("refreshIdpOidcSession throws when refresh token is absent", async () => { + idpSessions[IDP_ISSUER] = { + idToken: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + }; + await expect( + refreshIdpOidcSession({ idp, storage, fetchFn: vi.fn() }), + ).rejects.toThrow("IdP refresh token not available"); + }); + + it("refreshIdpOidcSession surfaces token-endpoint error responses", async () => { + idpSessions[IDP_ISSUER] = { + idToken: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshToken: "rt-bad", + }; + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "expired", + }), + { status: 400 }, + ), + ); + await expect( + refreshIdpOidcSession({ idp, storage, fetchFn }), + ).rejects.toThrow(/EMA IdP refresh.*invalid_grant/); + }); + + it("refreshIdpOidcSession throws when refresh response omits an ID Token", async () => { + idpSessions[IDP_ISSUER] = { + idToken: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshToken: "rt-abc", + }; + const fetchFn = vi.fn( + async () => new Response(JSON.stringify({ token_type: "Bearer" })), + ); + await expect( + refreshIdpOidcSession({ idp, storage, fetchFn }), + ).rejects.toThrow("IdP refresh did not return an ID Token"); + }); + + it("refreshIdpOidcSession discovers metadata when none is cached, and retains the old refresh_token", async () => { + idpSessions[IDP_ISSUER] = { + idToken: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshToken: "rt-keep", + }; + storage.getServerMetadata = + vi.fn() as unknown as OAuthStorage["getServerMetadata"]; + const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER))); + } + if (url === `${IDP_ISSUER}/token`) { + return new Response( + JSON.stringify({ id_token: refreshed, token_type: "Bearer" }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const idToken = await refreshIdpOidcSession({ idp, storage, fetchFn }); + expect(idToken).toBe(refreshed); + // No rotated refresh_token in the response -> the existing one is retained. + expect(idpSessions[IDP_ISSUER]?.refreshToken).toBe("rt-keep"); + }); + + // NOTE: idpOidc.ts:166-168 (`if (!metadata.token_endpoint) throw "IdP + // metadata missing token_endpoint"`) is unreachable defensive code. The only + // two metadata sources both guarantee token_endpoint: a cached entry is only + // returned by resolveIdpMetadata when `cached?.token_endpoint` is truthy, + // and discoverIdpMetadata runs the result through OAuthMetadataSchema.parse, + // which requires token_endpoint (throwing a ZodError otherwise). So no input + // reaches that guard with a missing token_endpoint — it is left uncovered. +}); + +describe("discoverIdpMetadata", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns parsed OAuth metadata from OIDC discovery", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER))); + } + throw new Error(`unexpected fetch: ${url}`); + }); + const metadata = await discoverIdpMetadata(IDP_ISSUER, fetchFn); + expect(metadata.token_endpoint).toBe(`${IDP_ISSUER}/token`); + expect(metadata.issuer).toBe(IDP_ISSUER); + }); + + it("throws when discovery yields no metadata (all probes 404)", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchFn = vi.fn( + async () => new Response("not found", { status: 404 }), + ); + await expect(discoverIdpMetadata(IDP_ISSUER, fetchFn)).rejects.toThrow( + /Failed to discover OIDC metadata/, + ); + errorSpy.mockRestore(); + }); + + it("rejects non-http(s) issuer URLs", async () => { + await expect( + discoverIdpMetadata("ftp://idp.test", vi.fn()), + ).rejects.toThrow(); + }); +}); + +describe("startIdpOidcAuthorization", () => { + let storage: OAuthStorage; + const saved: Record = {}; + + beforeEach(() => { + Object.keys(saved).forEach((k) => delete saved[k]); + storage = { + saveCodeVerifier: vi.fn(async (key: string, value: string) => { + saved[`cv:${key}`] = value; + }), + savePreregisteredClientInformation: vi.fn( + async (key: string, info: unknown) => { + saved[`client:${key}`] = info; + }, + ), + saveServerMetadata: vi.fn(async (key: string, meta: unknown) => { + saved[`meta:${key}`] = meta; + }), + } as unknown as OAuthStorage; + }); + + const idp = { + issuer: `${IDP_ISSUER}/`, + clientId: "idp-client", + clientSecret: "idp-secret", + }; + + it("discovers metadata, builds an authorization URL, and persists PKCE state", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER))); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const { authorizationUrl } = await startIdpOidcAuthorization({ + idp, + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn, + }); + + expect(authorizationUrl).toBeInstanceOf(URL); + expect(authorizationUrl.searchParams.get("client_id")).toBe("idp-client"); + expect(authorizationUrl.searchParams.get("scope")).toBe( + "openid offline_access", + ); + expect(authorizationUrl.searchParams.get("state")).toBeTruthy(); + + // Issuer was normalized (trailing slash dropped) for the storage key. + const key = `ema-idp:${IDP_ISSUER}`; + expect(storage.saveCodeVerifier).toHaveBeenCalledWith( + key, + expect.any(String), + ); + expect(storage.savePreregisteredClientInformation).toHaveBeenCalledWith( + key, + expect.objectContaining({ client_id: "idp-client" }), + ); + expect(storage.saveServerMetadata).toHaveBeenCalledWith( + key, + expect.objectContaining({ token_endpoint: `${IDP_ISSUER}/token` }), + ); + }); +}); + +describe("completeIdpOidcAuthorization", () => { + let storage: OAuthStorage; + const idpSessions: Record = {}; + const clearedVerifiers: string[] = []; + + function buildStorage(overrides: Partial = {}): OAuthStorage { + return { + getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + getClientInformation: vi.fn(async () => ({ + client_id: "idp-client", + client_secret: "idp-secret", + token_endpoint_auth_method: "client_secret_post", + })), + getCodeVerifier: vi.fn(() => "verifier-123"), + clearCodeVerifier: vi.fn((key: string) => { + clearedVerifiers.push(key); + }), + saveIdpSession: vi.fn(async (issuer: string, updates: unknown) => { + idpSessions[issuer] = updates; + }), + ...overrides, + } as unknown as OAuthStorage; + } + + const idp = { + issuer: IDP_ISSUER, + clientId: "idp-client", + clientSecret: "idp-secret", + }; + + beforeEach(() => { + Object.keys(idpSessions).forEach((k) => delete idpSessions[k]); + clearedVerifiers.length = 0; + }); + + it("exchanges the authorization code and stores the IdP session", async () => { + storage = buildStorage(); + const idToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === `${IDP_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("auth-code-xyz"); + expect(body.get("code_verifier")).toBe("verifier-123"); + return new Response( + JSON.stringify({ + id_token: idToken, + refresh_token: "rt-new", + access_token: "at-new", + token_type: "Bearer", + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + const result = await completeIdpOidcAuthorization({ + idp, + authorizationCode: "auth-code-xyz", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn, + }); + + expect(result.idToken).toBe(idToken); + expect(result.refreshToken).toBe("rt-new"); + expect(result.idTokenExpiresAt).toBeGreaterThan(Date.now()); + expect(clearedVerifiers).toContain(`ema-idp:${IDP_ISSUER}`); + expect(idpSessions[IDP_ISSUER]).toMatchObject({ + idToken, + refreshToken: "rt-new", + }); + }); + + it("throws when stored IdP metadata is missing", async () => { + storage = buildStorage({ + getServerMetadata: + vi.fn() as unknown as OAuthStorage["getServerMetadata"], + }); + await expect( + completeIdpOidcAuthorization({ + idp, + authorizationCode: "code", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn: vi.fn(), + }), + ).rejects.toThrow("IdP OAuth metadata not found"); + }); + + it("throws when client information is missing (both preregistered and dynamic)", async () => { + storage = buildStorage({ + getClientInformation: vi.fn(async () => undefined), + }); + await expect( + completeIdpOidcAuthorization({ + idp, + authorizationCode: "code", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn: vi.fn(), + }), + ).rejects.toThrow("IdP client information not found"); + }); + + it("falls back to dynamic client information when preregistered is absent", async () => { + let call = 0; + storage = buildStorage({ + getClientInformation: vi.fn(async () => { + call += 1; + // First call (preregistered=true) -> undefined; second -> the client. + return call === 1 + ? undefined + : ({ + client_id: "idp-client", + client_secret: "idp-secret", + token_endpoint_auth_method: "client_secret_post", + } as never); + }), + }); + const idToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ + id_token: idToken, + access_token: "at-new", + token_type: "Bearer", + }), + ), + ); + const result = await completeIdpOidcAuthorization({ + idp, + authorizationCode: "code", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn, + }); + expect(result.idToken).toBe(idToken); + expect(storage.getClientInformation).toHaveBeenCalledTimes(2); + }); + + it("throws when the PKCE verifier is missing", async () => { + storage = buildStorage({ getCodeVerifier: vi.fn(() => undefined) }); + await expect( + completeIdpOidcAuthorization({ + idp, + authorizationCode: "code", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn: vi.fn(), + }), + ).rejects.toThrow("IdP PKCE verifier not found"); + }); + + it("throws when the token response omits an ID Token", async () => { + storage = buildStorage(); + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ access_token: "at", token_type: "Bearer" }), + ), + ); + await expect( + completeIdpOidcAuthorization({ + idp, + authorizationCode: "code", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + fetchFn, + }), + ).rejects.toThrow("IdP token response did not include an ID Token"); + }); }); diff --git a/clients/web/src/test/core/auth/ema/idpSession.test.ts b/clients/web/src/test/core/auth/ema/idpSession.test.ts index cf187229..df7eb616 100644 --- a/clients/web/src/test/core/auth/ema/idpSession.test.ts +++ b/clients/web/src/test/core/auth/ema/idpSession.test.ts @@ -36,6 +36,11 @@ describe("idpSession", () => { expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe("none"); }); + it("getEmaIdpLoginState returns none when issuer normalizes to empty", async () => { + expect(await getEmaIdpLoginState(storage, "")).toBe("none"); + expect(storage.getIdpSession).not.toHaveBeenCalled(); + }); + it("getEmaIdpLoginState returns logged_in for valid id token", async () => { const exp = Math.floor(Date.now() / 1000) + 3600; vi.mocked(storage.getIdpSession).mockResolvedValue({ @@ -74,4 +79,13 @@ describe("idpSession", () => { expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); }); + + it("clearEmaIdpSession no-ops when issuer normalizes to empty", () => { + clearEmaIdpSession(storage, ""); + expect(storage.clearIdpSession).not.toHaveBeenCalled(); + expect(storage.clear).not.toHaveBeenCalled(); + expect( + storage.clearEnterpriseManagedResourceServers, + ).not.toHaveBeenCalled(); + }); }); diff --git a/clients/web/src/test/core/auth/ema/jwt.test.ts b/clients/web/src/test/core/auth/ema/jwt.test.ts new file mode 100644 index 00000000..d73aee17 --- /dev/null +++ b/clients/web/src/test/core/auth/ema/jwt.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { + jwtExpiresAtMs, + isJwtExpired, + isJwtFormat, + decodeJwtPayload, +} from "@inspector/core/auth/ema/jwt.js"; + +function base64Url(value: string): string { + return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function jwt(payload: unknown, header: unknown = { alg: "RS256" }): string { + return `${base64Url(JSON.stringify(header))}.${base64Url( + JSON.stringify(payload), + )}.sig`; +} + +describe("ema jwt", () => { + describe("jwtExpiresAtMs", () => { + it("returns exp in milliseconds for a valid JWT", () => { + expect(jwtExpiresAtMs(jwt({ exp: 1000 }))).toBe(1000 * 1000); + }); + + it("returns undefined when there are fewer than two segments", () => { + expect(jwtExpiresAtMs("onlyonesegment")).toBeUndefined(); + }); + + it("returns undefined when the payload is not valid JSON", () => { + // Second segment decodes to non-JSON text → JSON.parse throws (catch path). + const token = `${base64Url("header")}.${base64Url("not-json")}.sig`; + expect(jwtExpiresAtMs(token)).toBeUndefined(); + }); + + it("returns undefined when exp is missing", () => { + expect(jwtExpiresAtMs(jwt({ sub: "user" }))).toBeUndefined(); + }); + + it("returns undefined when exp is not a finite number", () => { + expect(jwtExpiresAtMs(jwt({ exp: "soon" }))).toBeUndefined(); + expect(jwtExpiresAtMs(jwt({ exp: Number.POSITIVE_INFINITY }))).toBe( + undefined, + ); + }); + }); + + describe("isJwtExpired", () => { + it("returns false when there is no exp claim", () => { + expect(isJwtExpired(jwt({ sub: "user" }))).toBe(false); + }); + + it("returns true when now is past exp minus skew", () => { + const exp = 1_000_000; + expect(isJwtExpired(jwt({ exp }), 60_000, exp * 1000 - 30_000)).toBe( + true, + ); + }); + + it("returns false when the token is still valid beyond the skew window", () => { + const exp = 1_000_000; + expect(isJwtExpired(jwt({ exp }), 60_000, exp * 1000 - 120_000)).toBe( + false, + ); + }); + }); + + describe("isJwtFormat", () => { + it("returns true for a three-segment token with non-empty header/payload", () => { + expect(isJwtFormat("a.b.")).toBe(true); + expect(isJwtFormat("a.b.c")).toBe(true); + }); + + it("returns false when there are not exactly three segments", () => { + expect(isJwtFormat("a.b")).toBe(false); + expect(isJwtFormat("a.b.c.d")).toBe(false); + }); + + it("returns false when header or payload segments are empty", () => { + expect(isJwtFormat(".b.c")).toBe(false); + expect(isJwtFormat("a..c")).toBe(false); + }); + }); + + describe("decodeJwtPayload", () => { + it("decodes header and payload of a well-formed JWT", () => { + const decoded = decodeJwtPayload( + jwt({ sub: "user", exp: 1000 }, { alg: "RS256", kid: "k1" }), + ); + expect(decoded?.header).toEqual({ alg: "RS256", kid: "k1" }); + expect(decoded?.payload).toEqual({ sub: "user", exp: 1000 }); + }); + + it("returns undefined when the token is not in JWT format", () => { + expect(decodeJwtPayload("not-a-jwt")).toBeUndefined(); + }); + + it("returns undefined when a segment is not valid JSON", () => { + // Three segments (passes isJwtFormat) but header is non-JSON → catch path. + const token = `${base64Url("not-json")}.${base64Url( + JSON.stringify({ sub: "user" }), + )}.sig`; + expect(decodeJwtPayload(token)).toBeUndefined(); + }); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/resourceContext.test.ts b/clients/web/src/test/core/auth/ema/resourceContext.test.ts new file mode 100644 index 00000000..c89c1345 --- /dev/null +++ b/clients/web/src/test/core/auth/ema/resourceContext.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from "vitest"; +import { + resolveEmaScopes, + discoverEmaResourceContext, + discoverResourceAsMetadata, +} from "@inspector/core/auth/ema/resourceContext.js"; +import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { minimalOAuthAsMetadata } from "../../../integration/mcp/ema-mock-servers.js"; + +const SERVER_URL = "http://127.0.0.1:9999/mcp"; +const MCP_RESOURCE = "http://127.0.0.1:9999/mcp"; +const AS_ISSUER = "https://as.resourcectx.test"; + +function resourceMetadata( + overrides: Partial = {}, +): OAuthProtectedResourceMetadata { + return { + resource: MCP_RESOURCE, + authorization_servers: [AS_ISSUER], + scopes_supported: ["mcp", "profile"], + ...overrides, + } as OAuthProtectedResourceMetadata; +} + +describe("ema resourceContext", () => { + describe("resolveEmaScopes", () => { + it("prefers a trimmed configured scope over metadata", () => { + expect(resolveEmaScopes(resourceMetadata(), " custom scope ")).toBe( + "custom scope", + ); + }); + + it("falls back to space-joined scopes_supported", () => { + expect(resolveEmaScopes(resourceMetadata())).toBe("mcp profile"); + }); + + it("treats a blank configured scope as unset", () => { + expect(resolveEmaScopes(resourceMetadata(), " ")).toBe("mcp profile"); + }); + + it("returns undefined when neither configured nor supported scopes exist", () => { + expect( + resolveEmaScopes(resourceMetadata({ scopes_supported: [] })), + ).toBeUndefined(); + expect(resolveEmaScopes(null)).toBeUndefined(); + expect(resolveEmaScopes(undefined)).toBeUndefined(); + }); + }); + + describe("discoverEmaResourceContext", () => { + it("discovers metadata, AS URL, resource URL, and scopes", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response(JSON.stringify(resourceMetadata())); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const ctx = await discoverEmaResourceContext( + SERVER_URL, + undefined, + fetchFn, + ); + + expect(ctx.resourceMetadata.resource).toBe(MCP_RESOURCE); + expect(ctx.resourceAsUrl.href).toBe(`${AS_ISSUER}/`); + expect(ctx.resourceUrl?.href).toBe(`${MCP_RESOURCE}`); + expect(ctx.scope).toBe("mcp profile"); + }); + + it("honors a configured scope override", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response(JSON.stringify(resourceMetadata())); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const ctx = await discoverEmaResourceContext(SERVER_URL, "only", fetchFn); + expect(ctx.scope).toBe("only"); + }); + + it("rejects when protected resource metadata has no resource identifier", async () => { + // NOTE: The SDK's discoverOAuthProtectedResourceMetadata validates the + // response against a Zod schema that *requires* `resource`, so a response + // lacking it is rejected at discovery time (Zod "invalid_type" on the + // `resource` path) before reaching the explicit guard in + // discoverEmaResourceContext. That guard is therefore a defensive, + // schema-unreachable branch; we assert the observable behavior (rejection) + // here rather than the guard's message. + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ authorization_servers: [AS_ISSUER] }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await expect( + discoverEmaResourceContext(SERVER_URL, undefined, fetchFn), + ).rejects.toThrow(); + errorSpy.mockRestore(); + }); + }); + + describe("discoverResourceAsMetadata", () => { + it("returns discovered authorization server metadata", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify(minimalOAuthAsMetadata(AS_ISSUER)), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const metadata = await discoverResourceAsMetadata( + new URL(AS_ISSUER), + fetchFn, + ); + expect(metadata.token_endpoint).toBe(`${AS_ISSUER}/token`); + }); + + it("throws when AS metadata discovery returns nothing", async () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + // All discovery endpoints 404 → SDK returns undefined. + const fetchFn = vi.fn( + async () => new Response("not found", { status: 404 }), + ); + + await expect( + discoverResourceAsMetadata(new URL(AS_ISSUER), fetchFn), + ).rejects.toThrow(/Failed to discover resource authorization server/); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/tokenEndpoint.test.ts b/clients/web/src/test/core/auth/ema/tokenEndpoint.test.ts new file mode 100644 index 00000000..d1be1fe6 --- /dev/null +++ b/clients/web/src/test/core/auth/ema/tokenEndpoint.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi } from "vitest"; +import type { + OAuthClientInformation, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + parseOAuthTokenErrorResponse, + postOAuthTokenRequest, +} from "@inspector/core/auth/ema/tokenEndpoint.js"; +import { minimalOAuthAsMetadata } from "../../../integration/mcp/ema-mock-servers.js"; + +const TOKEN_URL = new URL("https://as.test/token"); +const STEP = "EMA test step"; + +function metadataWithAuthMethods(methods: string[]): OAuthMetadata { + return { + ...minimalOAuthAsMetadata("https://as.test"), + token_endpoint_auth_methods_supported: methods, + } as OAuthMetadata; +} + +describe("parseOAuthTokenErrorResponse", () => { + it("returns a generic error when the body is not JSON", async () => { + const response = new Response("totally not json", { status: 503 }); + const error = await parseOAuthTokenErrorResponse(response, STEP); + expect(error.message).toBe(`${STEP}: token request failed (HTTP 503)`); + }); + + it("includes error and error_description from a structured body", async () => { + const response = new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "the grant expired", + }), + { status: 400 }, + ); + const error = await parseOAuthTokenErrorResponse(response, STEP); + expect(error.message).toBe( + `${STEP}: error=invalid_grant: the grant expired`, + ); + }); + + it("includes only the error code when no description is present", async () => { + const response = new Response(JSON.stringify({ error: "invalid_client" }), { + status: 401, + }); + const error = await parseOAuthTokenErrorResponse(response, STEP); + expect(error.message).toBe(`${STEP}: error=invalid_client`); + }); + + it("falls back to a generic error when the JSON body has no error fields", async () => { + const response = new Response(JSON.stringify({ unrelated: true }), { + status: 500, + }); + const error = await parseOAuthTokenErrorResponse(response, STEP); + expect(error.message).toBe(`${STEP}: token request failed (HTTP 500)`); + }); + + it("falls back to a generic error when the JSON body is null", async () => { + const response = new Response(JSON.stringify(null), { status: 502 }); + const error = await parseOAuthTokenErrorResponse(response, STEP); + expect(error.message).toBe(`${STEP}: token request failed (HTTP 502)`); + }); +}); + +describe("postOAuthTokenRequest", () => { + const clientInfo: OAuthClientInformation = { + client_id: "client-abc", + client_secret: "secret-xyz", + } as OAuthClientInformation; + + it("uses HTTP Basic auth when the server supports client_secret_basic", async () => { + let capturedHeaders: Headers | undefined; + let capturedBody: URLSearchParams | undefined; + const fetchFn = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedHeaders = new Headers(init?.headers); + capturedBody = init?.body as URLSearchParams; + return new Response(JSON.stringify({ ok: true })); + }, + ); + + const body = new URLSearchParams({ grant_type: "refresh_token" }); + await postOAuthTokenRequest( + TOKEN_URL, + body, + metadataWithAuthMethods(["client_secret_basic"]), + clientInfo, + fetchFn, + ); + + const expected = `Basic ${btoa("client-abc:secret-xyz")}`; + expect(capturedHeaders?.get("Authorization")).toBe(expected); + // Credentials must NOT be in the body for basic auth. + expect(capturedBody?.get("client_id")).toBeNull(); + expect(capturedBody?.get("client_secret")).toBeNull(); + }); + + it("posts client credentials in the body for client_secret_post", async () => { + let capturedBody: URLSearchParams | undefined; + let capturedHeaders: Headers | undefined; + const fetchFn = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = init?.body as URLSearchParams; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ ok: true })); + }, + ); + + const body = new URLSearchParams({ grant_type: "refresh_token" }); + await postOAuthTokenRequest( + TOKEN_URL, + body, + metadataWithAuthMethods(["client_secret_post"]), + clientInfo, + fetchFn, + ); + + expect(capturedBody?.get("client_id")).toBe("client-abc"); + expect(capturedBody?.get("client_secret")).toBe("secret-xyz"); + expect(capturedHeaders?.get("Authorization")).toBeNull(); + expect(capturedHeaders?.get("Content-Type")).toBe( + "application/x-www-form-urlencoded", + ); + }); + + it("falls back to public-client (client_id only) when no secret is present", async () => { + let capturedBody: URLSearchParams | undefined; + const fetchFn = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = init?.body as URLSearchParams; + return new Response(JSON.stringify({ ok: true })); + }, + ); + + const publicClient = { + client_id: "public-client", + } as OAuthClientInformation; + const body = new URLSearchParams({ grant_type: "refresh_token" }); + await postOAuthTokenRequest( + TOKEN_URL, + body, + metadataWithAuthMethods(["none"]), + publicClient, + fetchFn, + ); + + expect(capturedBody?.get("client_id")).toBe("public-client"); + expect(capturedBody?.get("client_secret")).toBeNull(); + }); + + it("falls back to the global fetch when no fetchFn is provided", async () => { + const globalSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify({ ok: true }))); + + const body = new URLSearchParams({ grant_type: "refresh_token" }); + await postOAuthTokenRequest( + TOKEN_URL, + body, + metadataWithAuthMethods(["client_secret_post"]), + clientInfo, + ); + + expect(globalSpy).toHaveBeenCalledTimes(1); + expect(globalSpy.mock.calls[0]?.[0]).toBe(TOKEN_URL); + globalSpy.mockRestore(); + }); + + it("defaults to an empty supported-methods list when metadata is undefined", async () => { + let capturedHeaders: Headers | undefined; + const fetchFn = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ ok: true })); + }, + ); + + const body = new URLSearchParams({ grant_type: "refresh_token" }); + await postOAuthTokenRequest( + TOKEN_URL, + body, + undefined, + clientInfo, + fetchFn, + ); + + // With no advertised methods and a secret present, the SDK defaults to + // client_secret_basic, so credentials land in the Authorization header. + expect(capturedHeaders?.get("Authorization")).toBe( + `Basic ${btoa("client-abc:secret-xyz")}`, + ); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts new file mode 100644 index 00000000..7f6b84e9 --- /dev/null +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js"; +import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js"; +import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js"; +import { + refreshEmaResourceTokens, + startEmaIdpAuthorization, +} from "@inspector/core/auth/ema/emaFlow.js"; + +vi.mock("@inspector/core/auth/ema/emaFlow.js", () => ({ + refreshEmaResourceTokens: vi.fn(), + startEmaIdpAuthorization: vi.fn(), +})); + +const refreshMock = vi.mocked(refreshEmaResourceTokens); +const startIdpMock = vi.mocked(startEmaIdpAuthorization); + +const SERVER_URL = "http://127.0.0.1:9999/mcp"; + +function jwtWithExp(expSec: number): string { + const payload = btoa(JSON.stringify({ exp: expSec })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `header.${payload}.sig`; +} + +const VALID_ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); +const EXPIRED_ACCESS_TOKEN = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + +interface FakeInner { + redirectUrl: string; + clientMetadataUrl: string | undefined; + clientMetadata: { redirect_uris: string[] }; + state: ReturnType; + clientInformation: ReturnType; + saveClientInformation: ReturnType; + tokens: ReturnType; + saveTokens: ReturnType; + redirectToAuthorization: ReturnType; + clearCapturedAuthUrl: ReturnType; + saveCodeVerifier: ReturnType; + codeVerifier: ReturnType; +} + +function createInner(): FakeInner { + return { + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + clientMetadataUrl: "http://127.0.0.1:3000/client.json", + clientMetadata: { redirect_uris: ["http://127.0.0.1:3000/oauth/callback"] }, + state: vi.fn(() => "state-123"), + clientInformation: vi.fn(() => ({ client_id: "abc" })), + saveClientInformation: vi.fn(), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + clearCapturedAuthUrl: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(() => "verifier-xyz"), + }; +} + +describe("EmaTransportOAuthProvider", () => { + let inner: FakeInner; + let emaConfig: EmaFlowConfig; + let saveTokensSpy: ReturnType; + let provider: EmaTransportOAuthProvider; + + beforeEach(() => { + vi.clearAllMocks(); + inner = createInner(); + saveTokensSpy = vi.fn(); + emaConfig = { + serverUrl: SERVER_URL, + idp: { issuer: "https://idp.test", clientId: "idp", clientSecret: "s" }, + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage: { + saveTokens: saveTokensSpy, + } as unknown as EmaFlowConfig["storage"], + } as EmaFlowConfig; + provider = new EmaTransportOAuthProvider( + inner as unknown as BaseOAuthClientProvider, + emaConfig, + ); + }); + + it("delegates pass-through getters and methods to the inner provider", async () => { + expect(provider.redirectUrl).toBe(inner.redirectUrl); + expect(provider.clientMetadataUrl).toBe(inner.clientMetadataUrl); + expect(provider.clientMetadata).toBe(inner.clientMetadata); + expect(await provider.state()).toBe("state-123"); + expect(await provider.clientInformation()).toEqual({ client_id: "abc" }); + expect(await provider.codeVerifier()).toBe("verifier-xyz"); + + provider.saveClientInformation({ client_id: "new" } as never); + expect(inner.saveClientInformation).toHaveBeenCalledWith({ + client_id: "new", + }); + + provider.saveCodeVerifier("cv"); + expect(inner.saveCodeVerifier).toHaveBeenCalledWith("cv"); + }); + + it("tokens() returns stored tokens when the access token is still usable", async () => { + const stored: OAuthTokens = { + access_token: VALID_ACCESS_TOKEN, + token_type: "Bearer", + }; + inner.tokens.mockResolvedValue(stored); + + const result = await provider.tokens(); + expect(result).toBe(stored); + expect(refreshMock).not.toHaveBeenCalled(); + }); + + it("tokens() refreshes EMA resource tokens when stored tokens are expired", async () => { + inner.tokens.mockResolvedValue({ + access_token: EXPIRED_ACCESS_TOKEN, + token_type: "Bearer", + }); + const refreshed: OAuthTokens = { + access_token: VALID_ACCESS_TOKEN, + token_type: "Bearer", + }; + refreshMock.mockResolvedValue(refreshed); + + const result = await provider.tokens(); + expect(result).toBe(refreshed); + expect(refreshMock).toHaveBeenCalledWith(emaConfig); + }); + + it("tokens() refreshes when there are no stored tokens", async () => { + inner.tokens.mockResolvedValue(undefined); + const refreshed: OAuthTokens = { + access_token: VALID_ACCESS_TOKEN, + token_type: "Bearer", + }; + refreshMock.mockResolvedValue(refreshed); + + expect(await provider.tokens()).toBe(refreshed); + expect(refreshMock).toHaveBeenCalledWith(emaConfig); + }); + + it("tokens() refreshes when stored tokens lack an access_token", async () => { + inner.tokens.mockResolvedValue({ token_type: "Bearer" } as OAuthTokens); + refreshMock.mockResolvedValue(undefined); + + expect(await provider.tokens()).toBeUndefined(); + expect(refreshMock).toHaveBeenCalledWith(emaConfig); + }); + + it("tokens() returns undefined when refresh yields nothing", async () => { + inner.tokens.mockResolvedValue(undefined); + refreshMock.mockResolvedValue(undefined); + + expect(await provider.tokens()).toBeUndefined(); + }); + + it("saveTokens() persists via storage tagged as enterprise-managed", async () => { + const tokens: OAuthTokens = { + access_token: VALID_ACCESS_TOKEN, + token_type: "Bearer", + }; + await provider.saveTokens(tokens); + expect(saveTokensSpy).toHaveBeenCalledWith(SERVER_URL, tokens, { + enterpriseManaged: true, + }); + }); + + it("redirectToAuthorization() redirects to the IdP, ignoring the resource AS URL", async () => { + const idpUrl = new URL("https://idp.test/authorize?state=abc"); + startIdpMock.mockResolvedValue(idpUrl); + + await provider.redirectToAuthorization( + new URL("https://resource-as.test/authorize"), + ); + + expect(startIdpMock).toHaveBeenCalledWith(emaConfig); + expect(inner.clearCapturedAuthUrl).toHaveBeenCalledTimes(1); + expect(inner.redirectToAuthorization).toHaveBeenCalledWith(idpUrl); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/wire.test.ts b/clients/web/src/test/core/auth/ema/wire.test.ts index b77226f6..d9c98117 100644 --- a/clients/web/src/test/core/auth/ema/wire.test.ts +++ b/clients/web/src/test/core/auth/ema/wire.test.ts @@ -77,6 +77,64 @@ describe("ema wire", () => { expect(idJag).toBe(ID_JAG); }); + it("throws when IdP metadata is missing a token_endpoint", async () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + // All discovery endpoints 404 → SDK returns undefined metadata. + const fetchFn = vi.fn( + async () => new Response("not found", { status: 404 }), + ); + + await expect( + exchangeIdJag({ + idp: { + issuer: IDP_ISSUER, + clientId: EMA_MOCK_IDP_CLIENT_ID, + clientSecret: EMA_MOCK_IDP_CLIENT_SECRET, + }, + idToken: ID_TOKEN, + audience: AS_ISSUER, + fetchFn, + }), + ).rejects.toThrow(/IdP metadata missing token_endpoint/); + errorSpy.mockRestore(); + }); + + it("sets resource and scope params and throws when no ID-JAG is returned", async () => { + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(idpMetadata())); + } + if (url === `${IDP_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("resource")).toBe("https://mcp.test/resource"); + expect(body.get("scope")).toBe("mcp profile"); + // 200 OK but empty access_token → ID-JAG missing branch. + return new Response(JSON.stringify({ issued_token_type: "x" })); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + await expect( + exchangeIdJag({ + idp: { + issuer: IDP_ISSUER, + clientId: EMA_MOCK_IDP_CLIENT_ID, + clientSecret: EMA_MOCK_IDP_CLIENT_SECRET, + }, + idToken: ID_TOKEN, + audience: AS_ISSUER, + resource: "https://mcp.test/resource", + scope: "mcp profile", + fetchFn, + }), + ).rejects.toThrow(/did not return an ID-JAG/); + }); + it("throws when IdP token exchange fails", async () => { const fetchFn = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); @@ -146,5 +204,70 @@ describe("ema wire", () => { expect(tokens.access_token).toBe(ACCESS_TOKEN); expect(tokens.token_type).toBe("Bearer"); }); + + it("sets resource and scope params and uses public-client auth when no secret", async () => { + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(asMetadata())); + } + if (url === `${AS_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("scope")).toBe("mcp"); + expect(body.get("resource")).toBe("https://mcp.test/resource"); + expect(body.get("client_id")).toBe(EMA_MOCK_RESOURCE_CLIENT_ID); + expect(body.get("client_secret")).toBeNull(); + return new Response( + JSON.stringify({ + access_token: ACCESS_TOKEN, + token_type: "Bearer", + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + const tokens = await redeemIdJagForAccessToken({ + resourceAsUrl: new URL(AS_ISSUER), + idJag: ID_JAG, + resourceClientId: EMA_MOCK_RESOURCE_CLIENT_ID, + resource: "https://mcp.test/resource", + scope: "mcp", + fetchFn, + }); + + expect(tokens.access_token).toBe(ACCESS_TOKEN); + }); + + it("throws when the resource AS JWT bearer grant fails", async () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(asMetadata())); + } + if (url === `${AS_ISSUER}/token`) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await expect( + redeemIdJagForAccessToken({ + resourceAsUrl: new URL(AS_ISSUER), + idJag: ID_JAG, + resourceClientId: EMA_MOCK_RESOURCE_CLIENT_ID, + resourceClientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET, + fetchFn, + }), + ).rejects.toThrow(/EMA leg 3/); + errorSpy.mockRestore(); + }); }); }); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 1aa53a86..c6440449 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { ConsoleNavigation, CallbackNavigation, + MutableRedirectUrlProvider, + BaseOAuthClientProvider, + type OAuthProviderConfig, } from "@inspector/core/auth/providers.js"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import { BrowserNavigation, BrowserOAuthClientProvider, @@ -39,6 +43,30 @@ describe("OAuthNavigation", () => { expect(callback).toHaveBeenCalledWith(authUrl); expect(navigation.getAuthorizationUrl()).toBe(authUrl); }); + + it("tolerates a callback that returns a Promise (fire-and-forget)", () => { + const callback = vi.fn(async () => undefined); + const navigation = new CallbackNavigation(callback); + const authUrl = new URL("http://example.com/authorize?async=1"); + + // Should not throw even though the returned Promise is not awaited; the + // `void result` branch handles the Promise case. + expect(() => navigation.navigateToAuthorization(authUrl)).not.toThrow(); + expect(callback).toHaveBeenCalledWith(authUrl); + expect(navigation.getAuthorizationUrl()).toBe(authUrl); + }); + }); + + describe("MutableRedirectUrlProvider", () => { + it("returns the mutable redirectUrl for any execution mode", () => { + const provider = new MutableRedirectUrlProvider(); + expect(provider.getRedirectUrl()).toBe(""); + + provider.redirectUrl = "http://127.0.0.1:9000/oauth/callback"; + expect(provider.getRedirectUrl()).toBe( + "http://127.0.0.1:9000/oauth/callback", + ); + }); }); describe("BrowserNavigation", () => { @@ -177,4 +205,159 @@ describe("OAuthNavigation", () => { ).toThrow(/requires browser environment/); }); }); + + describe("BaseOAuthClientProvider", () => { + const SERVER = "https://mcp.example.com"; + + function makeStorage(): OAuthStorage { + return { + getScope: vi.fn(() => undefined), + getClientInformation: vi.fn(async () => undefined), + saveClientInformation: vi.fn(async () => undefined), + savePreregisteredClientInformation: vi.fn(async () => undefined), + saveScope: vi.fn(async () => undefined), + getTokens: vi.fn(async () => undefined), + saveTokens: vi.fn(async () => undefined), + saveCodeVerifier: vi.fn(async () => undefined), + getCodeVerifier: vi.fn(() => undefined), + clear: vi.fn(), + getServerMetadata: vi.fn(() => null), + saveServerMetadata: vi.fn(async () => undefined), + } as unknown as OAuthStorage; + } + + function makeProvider( + storage: OAuthStorage, + navCallback = vi.fn(), + ): BaseOAuthClientProvider { + const config: OAuthProviderConfig = { + storage, + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(navCallback), + }; + return new BaseOAuthClientProvider(SERVER, config); + } + + it("clear() delegates to storage.clear with the server url", () => { + const storage = makeStorage(); + const provider = makeProvider(storage); + + provider.clear(); + + expect(storage.clear).toHaveBeenCalledWith(SERVER); + }); + + it("clientInformation() returns preregistered info when present", async () => { + const storage = makeStorage(); + vi.mocked(storage.getClientInformation).mockImplementation( + async (_url: string, preregistered?: boolean) => + preregistered ? { client_id: "pre" } : { client_id: "dyn" }, + ); + const provider = makeProvider(storage); + + expect(await provider.clientInformation()).toEqual({ client_id: "pre" }); + }); + + it("clientInformation() falls back to dynamic info when no preregistered info", async () => { + const storage = makeStorage(); + vi.mocked(storage.getClientInformation).mockImplementation( + async (_url: string, preregistered?: boolean) => + preregistered ? undefined : { client_id: "dyn" }, + ); + const provider = makeProvider(storage); + + expect(await provider.clientInformation()).toEqual({ client_id: "dyn" }); + }); + + it("codeVerifier() throws when none is saved", () => { + const storage = makeStorage(); + const provider = makeProvider(storage); + + expect(() => provider.codeVerifier()).toThrow( + /No code verifier saved for session/, + ); + }); + + it("codeVerifier() returns the saved verifier", () => { + const storage = makeStorage(); + vi.mocked(storage.getCodeVerifier).mockReturnValue("cv-1"); + const provider = makeProvider(storage); + + expect(provider.codeVerifier()).toBe("cv-1"); + }); + + it("clientMetadata reflects the stored scope when present", () => { + const storage = makeStorage(); + vi.mocked(storage.getScope).mockReturnValue("read write"); + const provider = makeProvider(storage); + + expect(provider.clientMetadata.scope).toBe("read write"); + }); + + it("redirectToAuthorization dispatches an event when an event target is set", () => { + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProvider(storage, navCallback); + const target = new EventTarget(); + const handler = vi.fn(); + target.addEventListener("oauthAuthorizationRequired", handler); + provider.setEventTarget(target); + + const url = new URL("https://mcp.example.com/authorize"); + provider.redirectToAuthorization(url); + + expect(handler).toHaveBeenCalledTimes(1); + expect(provider.getCapturedAuthUrl()).toBe(url); + expect(navCallback).toHaveBeenCalledWith(url); + + provider.clearCapturedAuthUrl(); + expect(provider.getCapturedAuthUrl()).toBeNull(); + }); + + it("redirectToAuthorization works without an event target", () => { + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProvider(storage, navCallback); + + const url = new URL("https://mcp.example.com/authorize"); + expect(() => provider.redirectToAuthorization(url)).not.toThrow(); + expect(provider.getCapturedAuthUrl()).toBe(url); + expect(navCallback).toHaveBeenCalledWith(url); + }); + + it("delegates token and scope persistence to storage", async () => { + const storage = makeStorage(); + const provider = makeProvider(storage); + + await provider.saveTokens({ access_token: "t", token_type: "Bearer" }); + await provider.saveScope("openid"); + await provider.saveClientInformation({ client_id: "c" }); + await provider.savePreregisteredClientInformation({ client_id: "p" }); + await provider.saveCodeVerifier("cv"); + await provider.saveServerMetadata({ + issuer: SERVER, + authorization_endpoint: `${SERVER}/a`, + token_endpoint: `${SERVER}/t`, + response_types_supported: ["code"], + }); + + expect(storage.saveTokens).toHaveBeenCalledWith(SERVER, { + access_token: "t", + token_type: "Bearer", + }); + expect(storage.saveScope).toHaveBeenCalledWith(SERVER, "openid"); + expect(storage.saveClientInformation).toHaveBeenCalledWith(SERVER, { + client_id: "c", + }); + expect(storage.savePreregisteredClientInformation).toHaveBeenCalledWith( + SERVER, + { client_id: "p" }, + ); + expect(storage.saveCodeVerifier).toHaveBeenCalledWith(SERVER, "cv"); + expect(storage.saveServerMetadata).toHaveBeenCalled(); + expect(await provider.tokens()).toBeUndefined(); + expect(provider.getServerMetadata()).toBeNull(); + expect(await provider.state()).toMatch(/quick/); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts index 41a14a7c..807a881b 100644 --- a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts +++ b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts @@ -9,6 +9,7 @@ class FakeTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; onclose?: () => void; onerror?: (error: Error) => void; + sessionId?: string; // Optional on the SDK Transport interface; stdio omits it, HTTP defines it. setProtocolVersion?: (version: string) => void; async start(): Promise {} @@ -86,6 +87,56 @@ describe("MessageTrackingTransport.send", () => { await tracked.send(request); expect(base.sent).toEqual([request]); }); + + it("fires no callback for an id-bearing message with neither method/result/error", async () => { + const { callbacks, base, tracked } = makeTracked(); + // An id present but no method, result, or error — none of the three + // tracking callbacks should fire, yet the message still forwards. + const malformed = { jsonrpc: "2.0", id: 5 } as unknown as JSONRPCMessage; + await tracked.send(malformed); + expect(callbacks.trackRequest).not.toHaveBeenCalled(); + expect(callbacks.trackResponse).not.toHaveBeenCalled(); + expect(callbacks.trackNotification).not.toHaveBeenCalled(); + expect(base.sent).toEqual([malformed]); + }); + + it("fires no callback for a message lacking both id and method", async () => { + // No id and no method — the outer notification branch's `method in message` + // check is false, so none of the callbacks fire. + const { callbacks, base, tracked } = makeTracked(); + const bare = { jsonrpc: "2.0" } as unknown as JSONRPCMessage; + await tracked.send(bare); + expect(callbacks.trackRequest).not.toHaveBeenCalled(); + expect(callbacks.trackResponse).not.toHaveBeenCalled(); + expect(callbacks.trackNotification).not.toHaveBeenCalled(); + expect(base.sent).toEqual([bare]); + }); + + it("treats a null-id message as a notification (no id) path", async () => { + const { callbacks, tracked } = makeTracked(); + const nullId = { + jsonrpc: "2.0", + id: null, + method: "notifications/ping", + } as unknown as JSONRPCMessage; + await tracked.send(nullId); + expect(callbacks.trackNotification).toHaveBeenCalledWith(nullId, "client"); + }); + + it("does not throw when tracking callbacks are all undefined", async () => { + const base = new FakeTransport(); + const tracked = new MessageTrackingTransport(base, {}); + const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const; + const response = { jsonrpc: "2.0", id: 1, result: {} } as const; + const notification = { + jsonrpc: "2.0", + method: "notifications/ping", + } as const; + await expect(tracked.send(request)).resolves.toBeUndefined(); + await expect(tracked.send(response)).resolves.toBeUndefined(); + await expect(tracked.send(notification)).resolves.toBeUndefined(); + expect(base.sent).toEqual([request, response, notification]); + }); }); describe("MessageTrackingTransport.onmessage", () => { @@ -121,6 +172,77 @@ describe("MessageTrackingTransport.onmessage", () => { // The wrapped handler still receives every message. expect(handler).toHaveBeenCalledTimes(3); }); + + it("fires no callback for an incoming id-bearing message with neither method/result/error", () => { + const { callbacks, base, tracked } = makeTracked(); + const handler = vi.fn(); + tracked.onmessage = handler; + const malformed = { jsonrpc: "2.0", id: 9 } as unknown as JSONRPCMessage; + base.onmessage?.(malformed); + expect(callbacks.trackRequest).not.toHaveBeenCalled(); + expect(callbacks.trackResponse).not.toHaveBeenCalled(); + expect(callbacks.trackNotification).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledWith(malformed, undefined); + }); + + it("fires no callback for an incoming message lacking both id and method", () => { + const { callbacks, base, tracked } = makeTracked(); + const handler = vi.fn(); + tracked.onmessage = handler; + const bare = { jsonrpc: "2.0" } as unknown as JSONRPCMessage; + base.onmessage?.(bare); + expect(callbacks.trackRequest).not.toHaveBeenCalled(); + expect(callbacks.trackResponse).not.toHaveBeenCalled(); + expect(callbacks.trackNotification).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledWith(bare, undefined); + }); + + it("exposes the wrapped handler via the onmessage getter", () => { + const { tracked } = makeTracked(); + expect(tracked.onmessage).toBeUndefined(); + tracked.onmessage = vi.fn(); + expect(typeof tracked.onmessage).toBe("function"); + }); + + it("clears the base transport onmessage when set to undefined", () => { + const { base, tracked } = makeTracked(); + tracked.onmessage = vi.fn(); + expect(base.onmessage).toBeTypeOf("function"); + tracked.onmessage = undefined; + expect(base.onmessage).toBeUndefined(); + }); +}); + +describe("MessageTrackingTransport lifecycle + delegation", () => { + it("delegates start() and close() to the base transport", async () => { + const base = new FakeTransport(); + const startSpy = vi.spyOn(base, "start"); + const closeSpy = vi.spyOn(base, "close"); + const tracked = new MessageTrackingTransport(base, {}); + await tracked.start(); + await tracked.close(); + expect(startSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("proxies onclose / onerror getters and setters to the base transport", () => { + const { base, tracked } = makeTracked(); + const onclose = vi.fn(); + const onerror = vi.fn(); + tracked.onclose = onclose; + tracked.onerror = onerror; + expect(base.onclose).toBe(onclose); + expect(base.onerror).toBe(onerror); + expect(tracked.onclose).toBe(onclose); + expect(tracked.onerror).toBe(onerror); + }); + + it("exposes the base transport sessionId via the getter", () => { + const { base, tracked } = makeTracked(); + expect(tracked.sessionId).toBeUndefined(); + base.sessionId = "session-123"; + expect(tracked.sessionId).toBe("session-123"); + }); }); describe("MessageTrackingTransport.setProtocolVersion", () => { diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 5e738966..23fd0f69 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -3,7 +3,7 @@ * dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens, * error propagation, and getOAuthFlowState/getOAuthFlowStep after beginGuidedAuth. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { OAuthManager, type OAuthManagerConfig, @@ -14,6 +14,21 @@ import { emaClientNotConfiguredMessage, } from "@inspector/core/auth/ema/clientConfigError.js"; import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js"; +import { OAuthStateMachine } from "@inspector/core/auth/state-machine.js"; +import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; + +// Mock the SDK auth() entry point so quick-flow paths don't hit the network. +// Other named exports of the module are left intact for code that uses them. +vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@modelcontextprotocol/sdk/client/auth.js") + >(); + return { ...actual, auth: vi.fn() }; +}); + +const mockedAuth = vi.mocked(auth); const SERVER_URL = "https://example.com/mcp"; @@ -78,7 +93,19 @@ function createMockParams( }; } +/** + * Storage typed accessor for casting the mock storage's methods in tests. + */ +type MockStorage = Record>; +function storageOf(params: OAuthManagerParams): MockStorage { + return params.initialConfig.storage as unknown as MockStorage; +} + describe("OAuthManager", () => { + beforeEach(() => { + mockedAuth.mockReset(); + }); + describe("setOAuthConfig", () => { it("merges config without throwing", () => { const params = createMockParams(); @@ -341,4 +368,717 @@ describe("OAuthManager", () => { startIdpSpy.mockRestore(); }); }); + + describe("createOAuthProvider validation", () => { + it("authenticate throws when storage component is missing", async () => { + const params = createMockParams({ + initialConfig: { + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + await expect(manager.authenticate()).rejects.toThrow( + "OAuth environment components (storage, navigation, redirectUrlProvider) are required.", + ); + }); + }); + + describe("getEmaFlowConfig validation", () => { + it("throws when storage/redirectUrlProvider are missing for an EMA flow", async () => { + const params = createMockParams({ + initialConfig: { + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + await expect(manager.trySilentEnterpriseManagedAuth()).rejects.toThrow( + "OAuth environment components (storage, redirectUrlProvider) are required.", + ); + }); + }); + + describe("authenticate (quick, standard)", () => { + it("captures the authorization URL, runs onBeforeOAuthRedirect, and stores flow state", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=abc", + ); + mockedAuth.mockResolvedValue("REDIRECT"); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue({ + execution: "quick", + authId: "auth-id-1", + } as ReturnType< + typeof import("@inspector/core/auth/utils.js").parseOAuthState + >); + + const onBeforeOAuthRedirect = vi.fn().mockResolvedValue(undefined); + const params = createMockParams({ onBeforeOAuthRedirect }); + // A configured scope exercises the saveScope branch in createOAuthProvider. + params.initialConfig.scope = "read write"; + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const result = await manager.authenticate(); + + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "read write", + ); + expect(result).toEqual(capturedUrl); + expect(onBeforeOAuthRedirect).toHaveBeenCalledWith("auth-id-1"); + expect(manager.getOAuthFlowStep()).toBe("authorization_code"); + expect(manager.getOAuthFlowState()?.oauthClientInfo).toEqual({ + client_id: "cid", + }); + + parseSpy.mockRestore(); + captureSpy.mockRestore(); + }); + + it("throws when auth() unexpectedly returns AUTHORIZED", async () => { + mockedAuth.mockResolvedValue("AUTHORIZED"); + const manager = new OAuthManager(createMockParams()); + await expect(manager.authenticate()).rejects.toThrow( + "Unexpected: auth() returned AUTHORIZED without authorization code", + ); + }); + + it("throws when no authorization URL is captured", async () => { + mockedAuth.mockResolvedValue("REDIRECT"); + const manager = new OAuthManager(createMockParams()); + // Default provider captures nothing (auth() is mocked and never redirects). + await expect(manager.authenticate()).rejects.toThrow( + "Failed to capture authorization URL", + ); + }); + + it("skips onBeforeOAuthRedirect when state param has no authId", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=zzz", + ); + mockedAuth.mockResolvedValue("REDIRECT"); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue(null); + const onBeforeOAuthRedirect = vi.fn(); + const params = createMockParams({ onBeforeOAuthRedirect }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.authenticate(); + expect(onBeforeOAuthRedirect).not.toHaveBeenCalled(); + + parseSpy.mockRestore(); + captureSpy.mockRestore(); + }); + }); + + describe("guided flow", () => { + function patchStateMachine( + onExecute: (state: OAuthFlowState) => void, + ): ReturnType { + return vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async (state: OAuthFlowState) => { + onExecute(state); + }); + } + + it("beginGuidedAuth seeds client info from config and executes the first step", async () => { + const execSpy = patchStateMachine(() => {}); + const params = createMockParams(); + const manager = new OAuthManager(params); + + await manager.beginGuidedAuth(); + + expect(execSpy).toHaveBeenCalledTimes(1); + expect(manager.getOAuthFlowState()?.oauthClientInfo).toEqual({ + client_id: "test-client", + client_secret: "test-secret", + }); + execSpy.mockRestore(); + }); + + it("runGuidedAuth dispatches authorizationRequired and returns the URL", async () => { + const authUrl = new URL("https://auth.example.com/authorize?state=guid"); + const execSpy = patchStateMachine((state) => { + state.oauthStep = "authorization_code"; + state.authorizationUrl = authUrl; + }); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue({ + execution: "guided", + authId: "guid-id", + } as ReturnType< + typeof import("@inspector/core/auth/utils.js").parseOAuthState + >); + const onBeforeOAuthRedirect = vi.fn().mockResolvedValue(undefined); + const params = createMockParams({ onBeforeOAuthRedirect }); + const manager = new OAuthManager(params); + + const result = await manager.runGuidedAuth(); + + expect(result).toEqual(authUrl); + expect(onBeforeOAuthRedirect).toHaveBeenCalledWith("guid-id"); + expect(params.dispatchOAuthAuthorizationRequired).toHaveBeenCalledWith({ + url: authUrl, + }); + execSpy.mockRestore(); + parseSpy.mockRestore(); + }); + + it("runGuidedAuth returns undefined when the flow already completed", async () => { + const execSpy = patchStateMachine((state) => { + state.oauthStep = "complete"; + }); + const manager = new OAuthManager(createMockParams()); + const result = await manager.runGuidedAuth(); + expect(result).toBeUndefined(); + execSpy.mockRestore(); + }); + + it("runGuidedAuth throws when no authorization URL is produced", async () => { + const execSpy = patchStateMachine((state) => { + state.oauthStep = "authorization_code"; + state.authorizationUrl = null; + }); + const manager = new OAuthManager(createMockParams()); + await expect(manager.runGuidedAuth()).rejects.toThrow( + "Failed to generate authorization URL", + ); + execSpy.mockRestore(); + }); + + it("runGuidedAuth loops through intermediate steps before stopping", async () => { + const authUrl = new URL("https://auth.example.com/authorize"); + const steps: OAuthStep[] = ["client_registration", "authorization_code"]; + let i = 0; + const execSpy = patchStateMachine((state) => { + state.oauthStep = steps[i++]; + if (state.oauthStep === "authorization_code") { + state.authorizationUrl = authUrl; + } + }); + const manager = new OAuthManager(createMockParams()); + const result = await manager.runGuidedAuth(); + expect(result).toEqual(authUrl); + // beginGuidedAuth runs the first step (client_registration); the loop then + // runs one more step that reaches authorization_code and stops. + expect(execSpy).toHaveBeenCalledTimes(2); + execSpy.mockRestore(); + }); + + it("the state-machine update callback merges updates and dispatches step changes", async () => { + // Drive the updateState closure (oauthManager lines 268-280) by reaching + // into the real OAuthStateMachine instance's private updateState field. + // The mocked executeStep is a `function` so `this` is the machine. + interface MachineWithUpdate { + updateState: (updates: Partial) => void; + } + const captured: { update?: (u: Partial) => void } = {}; + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async function ( + this: MachineWithUpdate, + ): Promise { + captured.update = this.updateState.bind(this); + }); + + const params = createMockParams(); + const manager = new OAuthManager(params); + await manager.beginGuidedAuth(); + + expect(captured.update).toBeDefined(); + const update = captured.update!; + + // Non-complete update: merges and dispatches with the new step. + update({ oauthStep: "client_registration" }); + expect(params.dispatchOAuthStepChange).toHaveBeenLastCalledWith({ + step: "client_registration", + previousStep: "metadata_discovery", + state: { oauthStep: "client_registration" }, + }); + + // Complete update: also stamps completedAt. + update({ oauthStep: "complete" }); + expect(manager.getOAuthFlowState()?.completedAt).toEqual( + expect.any(Number), + ); + + // Update with no oauthStep falls back to the current step. + update({ authorizationCode: "x" }); + expect(params.dispatchOAuthStepChange).toHaveBeenLastCalledWith({ + step: "complete", + previousStep: "complete", + state: { authorizationCode: "x" }, + }); + + execSpy.mockRestore(); + }); + }); + + describe("setGuidedAuthorizationCode", () => { + it("throws when current step is not authorization_code", async () => { + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async () => {}); + const manager = new OAuthManager(createMockParams()); + await manager.beginGuidedAuth(); + // After begin, step is still metadata_discovery (executeStep is a no-op). + await expect( + manager.setGuidedAuthorizationCode("code", false), + ).rejects.toThrow( + /Cannot set authorization code at step metadata_discovery/, + ); + execSpy.mockRestore(); + }); + + it("dispatches a step change without completing when completeFlow is false", async () => { + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async (state: OAuthFlowState) => { + state.oauthStep = "authorization_code"; + }); + const params = createMockParams(); + const manager = new OAuthManager(params); + await manager.beginGuidedAuth(); + + await manager.setGuidedAuthorizationCode("the-code", false); + + expect(params.dispatchOAuthStepChange).toHaveBeenLastCalledWith({ + step: "authorization_code", + previousStep: "authorization_code", + state: { authorizationCode: "the-code" }, + }); + execSpy.mockRestore(); + }); + + it("completes the flow and dispatches complete with tokens", async () => { + const tokens = { access_token: "T", token_type: "Bearer" }; + // Phases: begin -> authorization_code; complete-flow: first executeStep + // moves to token_request (loop body runs), second reaches complete. + const phases: OAuthStep[] = [ + "authorization_code", + "token_request", + "complete", + ]; + let phase = 0; + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async (state: OAuthFlowState) => { + state.oauthStep = phases[phase++]; + if (state.oauthStep === "complete") { + state.oauthTokens = tokens; + } + }); + const params = createMockParams(); + const manager = new OAuthManager(params); + await manager.beginGuidedAuth(); + + await manager.setGuidedAuthorizationCode("the-code", true); + + expect(params.dispatchOAuthComplete).toHaveBeenCalledWith({ tokens }); + execSpy.mockRestore(); + }); + + it("throws when completing yields no tokens", async () => { + let phase = 0; + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async (state: OAuthFlowState) => { + if (phase === 0) { + state.oauthStep = "authorization_code"; + } else { + state.oauthStep = "complete"; + state.oauthTokens = null; + } + phase++; + }); + const manager = new OAuthManager(createMockParams()); + await manager.beginGuidedAuth(); + await expect( + manager.setGuidedAuthorizationCode("code", true), + ).rejects.toThrow("Failed to exchange authorization code for tokens"); + execSpy.mockRestore(); + }); + }); + + describe("proceedOAuthStep", () => { + it("executes a step when in a guided flow", async () => { + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async () => {}); + const manager = new OAuthManager(createMockParams()); + await manager.beginGuidedAuth(); + execSpy.mockClear(); + await manager.proceedOAuthStep(); + expect(execSpy).toHaveBeenCalledTimes(1); + execSpy.mockRestore(); + }); + }); + + describe("completeOAuthFlow (quick, standard)", () => { + it("completes via the quick path and dispatches complete", async () => { + const tokens = { access_token: "QT", token_type: "Bearer" }; + mockedAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue(tokens); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + + await manager.completeOAuthFlow("code-xyz"); + + expect(params.dispatchOAuthComplete).toHaveBeenCalledWith({ tokens }); + expect(manager.getOAuthFlowStep()).toBe("complete"); + }); + + it("throws and dispatches error when auth() is not AUTHORIZED", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockedAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + const manager = new OAuthManager(params); + + await expect(manager.completeOAuthFlow("code")).rejects.toThrow( + /Expected AUTHORIZED after providing authorization code/, + ); + expect(params.dispatchOAuthError).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("throws when tokens cannot be retrieved after authorization", async () => { + mockedAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue(undefined); + const manager = new OAuthManager(params); + + await expect(manager.completeOAuthFlow("code")).rejects.toThrow( + "Failed to retrieve tokens after authorization", + ); + expect(params.dispatchOAuthError).toHaveBeenCalled(); + }); + + it("delegates to the guided path when a state machine exists", async () => { + const tokens = { access_token: "GT", token_type: "Bearer" }; + let phase = 0; + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async (state: OAuthFlowState) => { + if (phase === 0) { + state.oauthStep = "authorization_code"; + } else { + state.oauthStep = "complete"; + state.oauthTokens = tokens; + } + phase++; + }); + const params = createMockParams(); + const manager = new OAuthManager(params); + await manager.beginGuidedAuth(); + + await manager.completeOAuthFlow("code"); + + expect(params.dispatchOAuthComplete).toHaveBeenCalledWith({ tokens }); + // auth() (quick path) must not be used when a guided machine exists. + expect(mockedAuth).not.toHaveBeenCalled(); + execSpy.mockRestore(); + }); + }); + + describe("completeOAuthFlow (EMA)", () => { + it("mints resource tokens via the EMA path and dispatches complete", async () => { + const tokens = { access_token: "EMA", token_type: "Bearer" }; + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const mintSpy = vi + .spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint") + .mockResolvedValue(tokens); + + await manager.completeOAuthFlow("ema-code"); + + expect(mintSpy).toHaveBeenCalled(); + expect(params.dispatchOAuthComplete).toHaveBeenCalledWith({ tokens }); + expect(manager.getOAuthFlowStep()).toBe("complete"); + mintSpy.mockRestore(); + }); + }); + + describe("getOAuthTokens (in-memory)", () => { + it("returns tokens from in-memory flow state when present", async () => { + const tokens = { access_token: "MEM", token_type: "Bearer" }; + const execSpy = vi + .spyOn(OAuthStateMachine.prototype, "executeStep") + .mockImplementation(async (state: OAuthFlowState) => { + state.oauthTokens = tokens; + }); + const params = createMockParams(); + // Storage returns something different to prove the in-memory wins. + storageOf(params).getTokens.mockResolvedValue({ + access_token: "STORED", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + await manager.beginGuidedAuth(); + + expect(await manager.getOAuthTokens()).toEqual(tokens); + execSpy.mockRestore(); + }); + + it("returns undefined when provider.tokens() throws", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockRejectedValue(new Error("boom")); + const manager = new OAuthManager(params); + expect(await manager.getOAuthTokens()).toBeUndefined(); + }); + }); + + describe("trySilentEnterpriseManagedAuth", () => { + let errSpy: ReturnType; + beforeEach(() => { + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + errSpy.mockRestore(); + }); + + function emaManager(): OAuthManager { + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + return manager; + } + + it("returns false when not enterprise managed", async () => { + const manager = new OAuthManager(createMockParams()); + expect(await manager.trySilentEnterpriseManagedAuth()).toBe(false); + }); + + it("returns true on silent success", async () => { + const spy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + expect(await emaManager().trySilentEnterpriseManagedAuth()).toBe(true); + spy.mockRestore(); + }); + + it("returns false when there is no cached IdP session", async () => { + const spy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + expect(await emaManager().trySilentEnterpriseManagedAuth()).toBe(false); + spy.mockRestore(); + }); + + it("throws when the mint fails", async () => { + const error = new Error("mint failed"); + const spy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "mint_failed", error }); + await expect( + emaManager().trySilentEnterpriseManagedAuth(), + ).rejects.toThrow("mint failed"); + spy.mockRestore(); + }); + }); + + describe("authenticateEnterpriseManaged (interactive)", () => { + function emaParams(): OAuthManagerParams { + return createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + onBeforeOAuthRedirect: vi.fn().mockResolvedValue(undefined), + }); + } + + it("returns undefined when silent auth already succeeded", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = emaParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const result = await manager.authenticate(); + expect(result).toBeUndefined(); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + + it("redirects to the IdP and records flow state when not silent", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue({ + execution: "quick", + authId: "ema-id", + } as ReturnType< + typeof import("@inspector/core/auth/utils.js").parseOAuthState + >); + const params = emaParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const result = await manager.authenticate(); + + expect(result).toEqual(authUrl); + expect(params.onBeforeOAuthRedirect).toHaveBeenCalledWith("ema-id"); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authUrl); + expect(manager.getOAuthFlowStep()).toBe("authorization_code"); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + parseSpy.mockRestore(); + }); + }); + + describe("refreshEnterpriseManagedTokens", () => { + function emaManager(): OAuthManager { + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + return manager; + } + + it("returns false when not enterprise managed", async () => { + const manager = new OAuthManager(createMockParams()); + expect(await manager.refreshEnterpriseManagedTokens()).toBe(false); + }); + + it("returns true when refreshed tokens are returned", async () => { + const spy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue({ access_token: "R", token_type: "Bearer" }); + expect(await emaManager().refreshEnterpriseManagedTokens()).toBe(true); + spy.mockRestore(); + }); + + it("returns false when no tokens are returned", async () => { + const spy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + expect(await emaManager().refreshEnterpriseManagedTokens()).toBe(false); + spy.mockRestore(); + }); + }); + + describe("createOAuthProviderForTransport", () => { + it("returns a plain provider for standard OAuth", async () => { + const manager = new OAuthManager(createMockParams()); + const provider = await manager.createOAuthProviderForTransport(); + expect(provider).toBeDefined(); + expect(provider.constructor.name === "EmaTransportOAuthProvider").toBe( + false, + ); + }); + + it("wraps the provider in an EmaTransportOAuthProvider for EMA", async () => { + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + const provider = await manager.createOAuthProviderForTransport(); + expect(provider.constructor.name).toBe("EmaTransportOAuthProvider"); + }); + }); + + describe("getOAuthState (enterprise managed / scope)", () => { + it("returns ema connection state when enterprise managed is configured", async () => { + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + params.initialConfig.scope = "read"; + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const state = await manager.getOAuthState(); + expect(state?.protocol).toBe("ema"); + expect(state?.serverUrl).toBe(SERVER_URL); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts index 0437f904..a8fa25bb 100644 --- a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts +++ b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts @@ -80,7 +80,7 @@ describe("RemoteInspectorClientStorage", () => { }); it("loadSession converts ISO-string timestamps back into Date objects", async () => { - const fetchFn = vi.fn( + const fetchFn = vi.fn( async () => new Response( JSON.stringify({ @@ -111,7 +111,7 @@ describe("RemoteInspectorClientStorage", () => { }); it("loadSession preserves Date timestamps already provided as Date instances", async () => { - const fetchFn = vi.fn( + const fetchFn = vi.fn( async () => new Response( JSON.stringify({ @@ -136,6 +136,109 @@ describe("RemoteInspectorClientStorage", () => { ); }); + it("loadSession converts numeric timestamps into Date objects", async () => { + // A timestamp that is neither a string nor a Date hits the final + // `new Date(req.timestamp)` branch of the deserialization ternary. + const epoch = 1735689600000; + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ + fetchRequests: [ + { + id: "1", + timestamp: epoch, + method: "GET", + url: "http://example.com/", + requestHeaders: {}, + }, + ], + createdAt: 1, + updatedAt: 2, + }), + ), + ); + const storage = makeStorage(fetchFn as unknown as typeof fetch); + const state = await storage.loadSession("sid"); + expect(state?.fetchRequests[0]?.timestamp).toBeInstanceOf(Date); + expect((state?.fetchRequests[0]?.timestamp as Date).getTime()).toBe(epoch); + }); + + it("loadSession preserves an actual Date instance via the instanceof branch", async () => { + // JSON cannot carry a Date, so inject a parsed payload directly through a + // fetch stub whose json() yields a real Date instance — exercising the + // ternary's middle (instanceof Date) branch. + const realDate = new Date("2026-03-03T00:00:00Z"); + const fetchFn = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ + fetchRequests: [ + { + id: "1", + timestamp: realDate, + method: "GET", + url: "http://example.com/", + requestHeaders: {}, + }, + ], + createdAt: 1, + updatedAt: 2, + }), + }) as unknown as Response, + ); + const storage = makeStorage(fetchFn as unknown as typeof fetch); + const state = await storage.loadSession("sid"); + expect(state?.fetchRequests[0]?.timestamp).toBe(realDate); + }); + + it("loadSession omits the auth header when no token is configured", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ fetchRequests: [], createdAt: 1, updatedAt: 2 }), + ), + ); + const storage = makeStorage(fetchFn as unknown as typeof fetch); + await storage.loadSession("sid"); + expect( + (fetchFn.mock.calls[0]?.[1]?.headers as Record)[ + "x-mcp-remote-auth" + ], + ).toBeUndefined(); + }); + + it("loadSession sends the auth header when a token is configured", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ fetchRequests: [], createdAt: 1, updatedAt: 2 }), + ), + ); + const storage = makeStorage(fetchFn as unknown as typeof fetch, "tok"); + await storage.loadSession("sid"); + expect( + (fetchFn.mock.calls[0]?.[1]?.headers as Record)[ + "x-mcp-remote-auth" + ], + ).toBe("Bearer tok"); + }); + + it("deleteSession sends the auth header when a token is configured", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const storage = makeStorage(fetchFn as unknown as typeof fetch, "tok"); + await storage.deleteSession("sid"); + expect( + (fetchFn.mock.calls[0]?.[1]?.headers as Record)[ + "x-mcp-remote-auth" + ], + ).toBe("Bearer tok"); + }); + it("deleteSession tolerates 404 and rethrows on other errors", async () => { const fetchFn = vi .fn() diff --git a/clients/web/src/test/core/mcp/state/mergeTaskIntoList.test.ts b/clients/web/src/test/core/mcp/state/mergeTaskIntoList.test.ts new file mode 100644 index 00000000..b2acb3d6 --- /dev/null +++ b/clients/web/src/test/core/mcp/state/mergeTaskIntoList.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import type { TaskWithOptionalCreatedAt } from "@inspector/core/mcp/inspectorClientEventTarget"; +import { mergeTaskIntoList } from "@inspector/core/mcp/state/mergeTaskIntoList"; + +function makeTask(overrides: Partial = {}): Task { + return { + taskId: "t1", + status: "working", + createdAt: "2026-01-01T00:00:00.000Z", + lastUpdatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + } as Task; +} + +describe("mergeTaskIntoList", () => { + it("inserts a new task at the front when the id is not present", () => { + const existing: Task[] = [makeTask({ taskId: "existing" })]; + const result = mergeTaskIntoList( + existing, + "new", + makeTask({ taskId: "x" }), + ); + expect(result).toHaveLength(2); + expect(result[0]!.taskId).toBe("new"); + expect(result[1]!.taskId).toBe("existing"); + // Original list is not mutated. + expect(existing).toHaveLength(1); + }); + + it("replaces an existing task in place, preserving order", () => { + const existing: Task[] = [ + makeTask({ taskId: "a", status: "working" }), + makeTask({ taskId: "b", status: "working" }), + makeTask({ taskId: "c", status: "working" }), + ]; + const result = mergeTaskIntoList( + existing, + "b", + makeTask({ taskId: "ignored", status: "completed" }), + ); + expect(result).toHaveLength(3); + expect(result.map((t) => t.taskId)).toEqual(["a", "b", "c"]); + expect(result[1]!.status).toBe("completed"); + // taskId is overwritten with the keyed id, not the task's own taskId. + expect(result[1]!.taskId).toBe("b"); + // Original list is not mutated. + expect(existing[1]!.status).toBe("working"); + }); + + it("uses the task's createdAt when present", () => { + const result = mergeTaskIntoList( + [], + "t", + makeTask({ createdAt: "2026-05-05T00:00:00.000Z" }), + ); + expect(result[0]!.createdAt).toBe("2026-05-05T00:00:00.000Z"); + }); + + it("falls back to lastUpdatedAt when createdAt is absent", () => { + const task: TaskWithOptionalCreatedAt = { + taskId: "t", + status: "working", + lastUpdatedAt: "2026-06-06T00:00:00.000Z", + } as TaskWithOptionalCreatedAt; + const result = mergeTaskIntoList([], "t", task); + expect(result[0]!.createdAt).toBe("2026-06-06T00:00:00.000Z"); + }); + + it('falls back to "" when neither createdAt nor lastUpdatedAt is present', () => { + const task = { + taskId: "t", + status: "working", + } as unknown as TaskWithOptionalCreatedAt; + const result = mergeTaskIntoList([], "t", task); + expect(result[0]!.createdAt).toBe(""); + }); +}); diff --git a/clients/web/src/test/core/mcp/state/messageLogState.test.ts b/clients/web/src/test/core/mcp/state/messageLogState.test.ts index 8ef02a7e..b1088ddf 100644 --- a/clients/web/src/test/core/mcp/state/messageLogState.test.ts +++ b/clients/web/src/test/core/mcp/state/messageLogState.test.ts @@ -80,6 +80,34 @@ describe("MessageLogState", () => { expect(state.getMessages()[0]!.direction).toBe("notification"); }); + it("does not track a request whose message has no id", () => { + const noIdRequest: MessageEntry = { + id: "req-noid", + timestamp: new Date(2026, 4, 13, 0, 0, 0, 1), + direction: "request", + message: { jsonrpc: "2.0", method: "tools/list", params: {} }, + }; + client.dispatchTypedEvent("message", noIdRequest); + expect(state.getMessages()).toHaveLength(1); + + // A later response cannot match an untracked request -> appended. + client.dispatchTypedEvent("message", responseEntry(1)); + expect(state.getMessages()).toHaveLength(2); + expect(state.getMessages()[1]!.direction).toBe("response"); + }); + + it("appends a response whose message has no id", () => { + const noIdResponse: MessageEntry = { + id: "resp-noid", + timestamp: new Date(2026, 4, 13, 0, 0, 0, 2), + direction: "response", + message: { jsonrpc: "2.0", result: {} } as MessageEntry["message"], + }; + client.dispatchTypedEvent("message", noIdResponse); + expect(state.getMessages()).toHaveLength(1); + expect(state.getMessages()[0]!.direction).toBe("response"); + }); + it("trims the oldest entries when maxMessages is exceeded", () => { const small = new MessageLogState(client, { maxMessages: 2 }); client.dispatchTypedEvent("message", notificationEntry("a")); diff --git a/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx b/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx new file mode 100644 index 00000000..fc056dae --- /dev/null +++ b/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx @@ -0,0 +1,358 @@ +/** + * Tests for `useClientSettingsDraft` — the install-level (client.json) + * variant of the settings-draft hook. Mirrors `useSettingsDraft` but keys + * on the modal's `opened` boolean rather than a server id: client config + * is install-level, not per-server. Pins the seed-on-open, debounced + * persist, synchronous flush-on-close, and reset-on-close behaviors. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft"; + +interface SettingsShape { + text: string; + rows: string[]; +} + +const EMPTY: SettingsShape = { text: "", rows: [] }; + +describe("useClientSettingsDraft", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts with a null draft when the modal is closed", () => { + const resolveInitial = vi.fn(() => EMPTY); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: false, + resolveInitial, + onPersist: vi.fn(), + onError: vi.fn(), + }), + ); + expect(result.current.draft).toBe(null); + expect(resolveInitial).not.toHaveBeenCalled(); + }); + + it("seeds the draft via resolveInitial when the modal opens", () => { + const resolveInitial = vi.fn(() => ({ text: "seed", rows: [] })); + const { result, rerender } = renderHook( + ({ opened }: { opened: boolean }) => + useClientSettingsDraft({ + opened, + resolveInitial, + onPersist: vi.fn(), + onError: vi.fn(), + }), + { initialProps: { opened: false } }, + ); + expect(resolveInitial).not.toHaveBeenCalled(); + + rerender({ opened: true }); + expect(resolveInitial).toHaveBeenCalledTimes(1); + expect(result.current.draft).toEqual({ text: "seed", rows: [] }); + }); + + it("nulls the draft when the modal closes", () => { + const { result, rerender } = renderHook( + ({ opened }: { opened: boolean }) => + useClientSettingsDraft({ + opened, + resolveInitial: () => EMPTY, + onPersist: vi.fn(), + onError: vi.fn(), + }), + { initialProps: { opened: true } }, + ); + expect(result.current.draft).not.toBe(null); + + rerender({ opened: false }); + expect(result.current.draft).toBe(null); + }); + + it("onChange updates draft synchronously", () => { + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist: vi.fn(), + onError: vi.fn(), + }), + ); + expect(result.current.draft).toEqual(EMPTY); + + act(() => { + result.current.onChange({ text: "a", rows: [] }); + }); + expect(result.current.draft).toEqual({ text: "a", rows: [] }); + + act(() => { + result.current.onChange({ text: "ab", rows: [] }); + }); + expect(result.current.draft).toEqual({ text: "ab", rows: [] }); + }); + + it("debounces onPersist by the configured window", () => { + const onPersist = vi.fn(async () => {}); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + debounceMs: 300, + }), + ); + act(() => { + result.current.onChange({ text: "a", rows: [] }); + }); + expect(onPersist).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(299); + }); + expect(onPersist).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onPersist).toHaveBeenCalledExactlyOnceWith({ text: "a", rows: [] }); + }); + + it("uses the default 300ms debounce when none is configured", () => { + const onPersist = vi.fn(async () => {}); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + ); + act(() => { + result.current.onChange({ text: "x", rows: [] }); + }); + act(() => { + vi.advanceTimersByTime(299); + }); + expect(onPersist).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onPersist).toHaveBeenCalledExactlyOnceWith({ text: "x", rows: [] }); + }); + + it("collapses a burst of onChange calls into one persist with the final value", () => { + const onPersist = vi.fn(async () => {}); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + ); + act(() => { + result.current.onChange({ text: "a", rows: [] }); + vi.advanceTimersByTime(100); + result.current.onChange({ text: "ab", rows: [] }); + vi.advanceTimersByTime(100); + result.current.onChange({ text: "abc", rows: [] }); + }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(onPersist).toHaveBeenCalledExactlyOnceWith({ + text: "abc", + rows: [], + }); + }); + + it("ignores onChange when the modal is closed", () => { + const onPersist = vi.fn(async () => {}); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: false, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + ); + act(() => { + result.current.onChange({ text: "stray", rows: [] }); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onPersist).not.toHaveBeenCalled(); + expect(result.current.draft).toBe(null); + }); + + it("flush() fires the pending persist synchronously and clears the timer", () => { + const onPersist = vi.fn(async () => {}); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + ); + act(() => { + result.current.onChange({ text: "almost", rows: [] }); + }); + act(() => { + result.current.flush(); + }); + expect(onPersist).toHaveBeenCalledExactlyOnceWith({ + text: "almost", + rows: [], + }); + // The timer is cleared — advancing past the debounce window must not + // produce a second persist. + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onPersist).toHaveBeenCalledTimes(1); + }); + + it("flush() is a no-op when nothing is pending", () => { + const onPersist = vi.fn(async () => {}); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + ); + act(() => { + result.current.flush(); + }); + expect(onPersist).not.toHaveBeenCalled(); + }); + + it("flush() does not persist when the modal has since closed (opened ref false)", () => { + // A debounce is scheduled while open; the modal closes (which nulls + // the draft and flips the opened ref) and THEN flush is invoked. + // The guard `openedRef.current && value !== null` must suppress the + // persist on both counts. + const onPersist = vi.fn(async () => {}); + const { result, rerender } = renderHook( + ({ opened }: { opened: boolean }) => + useClientSettingsDraft({ + opened, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + { initialProps: { opened: true } }, + ); + act(() => { + result.current.onChange({ text: "pending", rows: [] }); + }); + // Capture flush before close — onChange/setDraft churn does not + // re-create it, and after close the hook returns the same identity. + const flush = result.current.flush; + rerender({ opened: false }); + act(() => { + flush(); + }); + expect(onPersist).not.toHaveBeenCalled(); + }); + + it("invokes onError when onPersist rejects (debounced persist failure path)", async () => { + const err = new Error("kaboom"); + const onPersist = vi.fn(async () => { + throw err; + }); + const onError = vi.fn(); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError, + }), + ); + act(() => { + result.current.onChange({ text: "x", rows: [] }); + vi.advanceTimersByTime(300); + }); + // Let the rejected promise propagate to the .catch handler. + await act(async () => { + await Promise.resolve(); + }); + expect(onError).toHaveBeenCalledExactlyOnceWith(err); + }); + + it("invokes onError when flush()'s persist rejects", async () => { + const err = new Error("flush-boom"); + const onPersist = vi.fn(async () => { + throw err; + }); + const onError = vi.fn(); + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError, + }), + ); + act(() => { + result.current.onChange({ text: "y", rows: [] }); + }); + act(() => { + result.current.flush(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(onError).toHaveBeenCalledExactlyOnceWith(err); + }); + + it("unmount clears the debounce timer — no persist after the component is gone", () => { + const onPersist = vi.fn(async () => {}); + const { result, unmount } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + ); + act(() => { + result.current.onChange({ text: "doomed", rows: [] }); + }); + unmount(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onPersist).not.toHaveBeenCalled(); + }); + + it("flush identity is stable across keystrokes (no churn on consumer's onClose)", () => { + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => EMPTY, + onPersist: vi.fn(), + onError: vi.fn(), + }), + ); + const flushAfterMount = result.current.flush; + act(() => { + result.current.onChange({ text: "a", rows: [] }); + }); + act(() => { + result.current.onChange({ text: "ab", rows: [] }); + }); + expect(result.current.flush).toBe(flushAfterMount); + }); +}); diff --git a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx index ffc5601d..f8b9c0e6 100644 --- a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx +++ b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx @@ -61,4 +61,75 @@ describe("useEmaIdpLoginState", () => { expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); expect(result.current.loginState).toBe("none"); }); + + it("reports 'expired' for an expired token with no refresh token", async () => { + const exp = Math.floor(Date.now() / 1000) - 3600; + const payload = btoa(JSON.stringify({ exp })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: `h.${payload}.s`, + }); + + const { result } = renderHook(() => + useEmaIdpLoginState(storage, "https://idp.test", true), + ); + + await waitFor(() => { + expect(result.current.loginState).toBe("expired"); + }); + }); + + it("does not refresh while inactive, then refreshes when activated", async () => { + vi.mocked(storage.getIdpSession).mockResolvedValue(undefined); + + const { result, rerender } = renderHook( + ({ active }: { active: boolean }) => + useEmaIdpLoginState(storage, "https://idp.test", active), + { initialProps: { active: false } }, + ); + + // Inactive: the open-driven refresh effect short-circuits. + expect(storage.getIdpSession).not.toHaveBeenCalled(); + expect(result.current.loginState).toBe("none"); + + rerender({ active: true }); + await waitFor(() => { + expect(storage.getIdpSession).toHaveBeenCalledWith("https://idp.test"); + }); + expect(result.current.loginState).toBe("none"); + }); + + it("refresh() resets to 'none' when there is no issuer (empty normalized)", async () => { + const { result } = renderHook(() => + useEmaIdpLoginState(storage, undefined, true), + ); + + // The active effect calls refresh(), which short-circuits to "none" + // without ever touching storage because the issuer is empty. + await act(async () => { + await result.current.refresh(); + }); + + expect(storage.getIdpSession).not.toHaveBeenCalled(); + expect(result.current.loginState).toBe("none"); + }); + + it("logout() is a no-op when there is no issuer", () => { + const { result } = renderHook(() => + useEmaIdpLoginState(storage, undefined, false), + ); + + act(() => { + result.current.logout(); + }); + + expect(storage.clearIdpSession).not.toHaveBeenCalled(); + expect(storage.clear).not.toHaveBeenCalled(); + expect( + storage.clearEnterpriseManagedResourceServers, + ).not.toHaveBeenCalled(); + expect(result.current.loginState).toBe("none"); + }); }); diff --git a/clients/web/src/test/core/react/useSandboxUrl.test.tsx b/clients/web/src/test/core/react/useSandboxUrl.test.tsx index 28a4f4b2..152a5f3e 100644 --- a/clients/web/src/test/core/react/useSandboxUrl.test.tsx +++ b/clients/web/src/test/core/react/useSandboxUrl.test.tsx @@ -111,4 +111,80 @@ describe("useSandboxUrl", () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.sandboxUrl).toBeUndefined(); }); + + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { + const globalFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ sandboxUrl: "http://global/sb" })); + const original = globalThis.fetch; + globalThis.fetch = globalFetch as unknown as typeof fetch; + try { + const { result } = renderHook(() => + useSandboxUrl({ baseUrl: "http://test.local" }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(globalFetch).toHaveBeenCalledWith( + "http://test.local/api/config", + expect.objectContaining({ method: "GET" }), + ); + expect(result.current.sandboxUrl).toBe("http://global/sb"); + } finally { + globalThis.fetch = original; + } + }); + + it("drops a response that resolves after unmount (no state update)", async () => { + // Gate the fetch so it is still in flight when we unmount; the + // isCancelled() guards (after fetch, after json, and in finally) must all + // short-circuit so no setState runs on the dead component. + let resolveFetch: ((r: Response) => void) | undefined; + const fetchFn = vi.fn().mockReturnValue( + new Promise((r) => { + resolveFetch = r; + }), + ); + + const { result, unmount } = renderHook(() => + useSandboxUrl({ baseUrl: "http://test.local", fetchFn }), + ); + expect(result.current.loading).toBe(true); + + unmount(); + // Resolve after unmount — the post-fetch isCancelled() guard returns early. + resolveFetch?.(jsonResponse({ sandboxUrl: "http://late/sb" })); + // Let the microtask queue drain so the continuation runs. + await Promise.resolve(); + await Promise.resolve(); + // No assertion error / React warning means the guards held; the last + // observed value stayed at its initial undefined. + expect(result.current.sandboxUrl).toBeUndefined(); + }); + + it("drops a response whose json resolves after unmount", async () => { + // Fetch resolves before unmount but the json() body resolves after, so the + // second isCancelled() guard (post-json) is the one that short-circuits. + let resolveJson: ((v: unknown) => void) | undefined; + const res = { + ok: true, + status: 200, + json: () => + new Promise((r) => { + resolveJson = r; + }), + } as unknown as Response; + const fetchFn = vi.fn().mockResolvedValue(res); + + const { result, unmount } = renderHook(() => + useSandboxUrl({ baseUrl: "http://test.local", fetchFn }), + ); + // Let the fetch resolve so we're parked awaiting json(). + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + await Promise.resolve(); + + unmount(); + resolveJson?.({ sandboxUrl: "http://late/sb" }); + await Promise.resolve(); + await Promise.resolve(); + expect(result.current.sandboxUrl).toBeUndefined(); + }); }); diff --git a/clients/web/src/test/core/react/useServerListWritable.test.tsx b/clients/web/src/test/core/react/useServerListWritable.test.tsx index 2cff7017..68eb5aeb 100644 --- a/clients/web/src/test/core/react/useServerListWritable.test.tsx +++ b/clients/web/src/test/core/react/useServerListWritable.test.tsx @@ -102,4 +102,76 @@ describe("useServerListWritable", () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.writable).toBe(true); }); + + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { + const globalFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ writable: false })); + const original = globalThis.fetch; + globalThis.fetch = globalFetch as unknown as typeof fetch; + try { + const { result } = renderHook(() => + useServerListWritable({ baseUrl: "http://test.local" }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(globalFetch).toHaveBeenCalledWith( + "http://test.local/api/config", + expect.objectContaining({ method: "GET" }), + ); + expect(result.current.writable).toBe(false); + } finally { + globalThis.fetch = original; + } + }); + + it("drops a response that resolves after unmount (no state update)", async () => { + // Gate the fetch so it is still in flight at unmount; the isCancelled() + // guards (after fetch and in finally) short-circuit so no setState runs on + // the dead component and `loading` is never flipped. + let resolveFetch: ((r: Response) => void) | undefined; + const fetchFn = vi.fn().mockReturnValue( + new Promise((r) => { + resolveFetch = r; + }), + ); + + const { result, unmount } = renderHook(() => + useServerListWritable({ baseUrl: "http://test.local", fetchFn }), + ); + expect(result.current.loading).toBe(true); + + unmount(); + resolveFetch?.(jsonResponse({ writable: false })); + await Promise.resolve(); + await Promise.resolve(); + // Guards held: the post-unmount value stayed at the writable default. + expect(result.current.writable).toBe(true); + }); + + it("drops a response whose json resolves after unmount", async () => { + // Fetch resolves before unmount but json() resolves after, exercising the + // second isCancelled() guard (post-json). + let resolveJson: ((v: unknown) => void) | undefined; + const res = { + ok: true, + status: 200, + json: () => + new Promise((r) => { + resolveJson = r; + }), + } as unknown as Response; + const fetchFn = vi.fn().mockResolvedValue(res); + + const { result, unmount } = renderHook(() => + useServerListWritable({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + await Promise.resolve(); + + unmount(); + resolveJson?.({ writable: false }); + await Promise.resolve(); + await Promise.resolve(); + expect(result.current.writable).toBe(true); + }); }); diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 2bd98d85..8e8a410d 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -659,6 +659,336 @@ describe("useServers", () => { ]); }); + it("updateServer throws the backend error message on a non-ok response", async () => { + // Drive the mutator through a fetchFn that serves the initial GET from the + // real app but fails the PUT, so the updateServer !res.ok throw path runs. + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (init?.method === "PUT") { + return new Response(JSON.stringify({ error: "put blew up" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await expect( + act(async () => { + await result.current.updateServer("alpha", "alpha", { + type: "stdio", + command: "node", + }); + }), + ).rejects.toThrow("put blew up"); + }); + + it("removeServer throws the backend error message on a non-ok response", async () => { + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (init?.method === "DELETE") { + return new Response(JSON.stringify({ error: "delete blew up" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await expect( + act(async () => { + await result.current.removeServer("filesystem-server-default"); + }), + ).rejects.toThrow("delete blew up"); + }); + + it("importSource throws the backend error message on a non-ok response", async () => { + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes("/api/import-source")) { + return new Response(JSON.stringify({ error: "import blew up" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await expect(result.current.importSource("cursor")).rejects.toThrow( + "import blew up", + ); + }); + + it("reorderServers rethrows a non-Error rejection wrapped as an Error", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + alpha: { type: "stdio", command: "a" }, + beta: { type: "stdio", command: "b" }, + }, + }), + ); + + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (init?.method === "PUT" && url.endsWith("/api/servers/order")) { + // Reject with a non-Error so the `err instanceof Error` false + // branch (wrap-as-Error) is taken. + return Promise.reject("string failure"); + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]), + ); + + await expect( + act(async () => { + await result.current.reorderServers(["beta", "alpha"]); + }), + ).rejects.toThrow("string failure"); + + // Reverted to disk truth after the failed round-trip. + await waitFor(() => { + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]); + }); + }); + + it("reorderServers keeps stray entries the requested order omitted", async () => { + // Defensive stray-keep branch: pass an order that drops `beta`. The + // optimistic setter appends the un-listed entry at the end so nothing + // vanishes before the failed-reorder refresh reconciles. The PUT 409s + // (incomplete set), so the list reverts — but the optimistic stray-keep + // ran first, which is the branch under test. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + alpha: { type: "stdio", command: "a" }, + beta: { type: "stdio", command: "b" }, + }, + }), + ); + + let resolvePut: (() => void) | undefined; + const observed: string[][] = []; + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (init?.method === "PUT" && url.endsWith("/api/servers/order")) { + // Hold the PUT so we can sample the optimistic stray-kept order. + await new Promise((r) => { + resolvePut = r; + }); + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]), + ); + + let pending: Promise; + act(() => { + // Only `alpha` in the order; `beta` is the stray that must be kept. + pending = result.current.reorderServers(["alpha"]); + }); + + await waitFor(() => { + observed.push(result.current.servers.map((s) => s.id)); + // alpha first (requested), beta appended as the stray. + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]); + }); + + await act(async () => { + resolvePut?.(); + await pending.catch(() => undefined); + }); + }); + + it("ignores an SSE channel that responds non-ok (no crash, list stays)", async () => { + // The events subscription returns !ok, hitting the `!res.ok || !res.body` + // early return so the reader loop is never entered. + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + return new Response("nope", { status: 503 }); + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + // The hook still loaded the list from the (real) GET handler. + expect(result.current.servers.length).toBeGreaterThan(0); + }); + + it("ignores an SSE channel with no body (no crash)", async () => { + // ok:true but a null body — exercises the `!res.body` half of the guard. + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + return { ok: true, body: null } as unknown as Response; + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.servers.length).toBeGreaterThan(0); + }); + + it("survives an SSE reader that throws mid-stream (catch swallows it)", async () => { + // A body whose reader.read() rejects — the reader-loop catch swallows the + // error and the hook stays in last-known-good state. + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: () => Promise.reject(new Error("stream broke")), + }), + }; + return { ok: true, body } as unknown as Response; + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeUndefined(); + expect(result.current.servers.length).toBeGreaterThan(0); + }); + + it("SSE reader loop fires a background refresh once per multi-frame chunk", async () => { + // A single decode chunk carrying two `\n\n`-separated frames must collapse + // to one refresh (sawFrame true once), then the stream ends (done:true). + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { seed: { type: "stdio", command: "s" } }, + }), + ); + + let reads = 0; + const encoder = new TextEncoder(); + const { result } = renderHook(() => + useServers({ + baseUrl: "http://test.local", + fetchFn: async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + if (reads === 1) { + // Two frames in one chunk → one background refresh. + return { + done: false, + value: encoder.encode("event: change\n\n\n\n"), + }; + } + return { done: true, value: undefined }; + }, + }), + }; + return { ok: true, body } as unknown as Response; + } + return h.fetchFn(url, init); + }, + }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Mutate disk, then the queued background refresh re-reads it. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { afterframe: { type: "stdio", command: "x" } }, + }), + ); + + // The two-frame chunk triggers exactly one refreshInternal(true); allow it + // to resolve and pick up the new disk state. + await waitFor( + () => { + expect(result.current.servers.map((s) => s.id)).toEqual(["afterframe"]); + }, + { timeout: 3000 }, + ); + }); + + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { + // No fetchFn → the `doFetch = fetchFn ?? globalThis.fetch` default branch. + const globalFetch = vi + .fn() + .mockImplementation((input, init) => { + const url = input instanceof Request ? input.url : String(input); + return h.fetchFn(url, init); + }); + const original = globalThis.fetch; + globalThis.fetch = globalFetch as unknown as typeof fetch; + try { + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local" }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(globalFetch).toHaveBeenCalled(); + expect(result.current.servers.length).toBeGreaterThan(0); + } finally { + globalThis.fetch = original; + } + }); + it("uses DEFAULT_SEED_CONFIG keys on the first load (seed-write contract)", async () => { const { result } = renderHook(() => useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), diff --git a/clients/web/src/test/integration/auth/discovery.test.ts b/clients/web/src/test/integration/auth/discovery.test.ts index e78652ce..c7104add 100644 --- a/clients/web/src/test/integration/auth/discovery.test.ts +++ b/clients/web/src/test/integration/auth/discovery.test.ts @@ -314,4 +314,24 @@ describe("getAuthorizationServerUrl", () => { new URL(authUrl), ); }); + + it("throws a descriptive error when the MCP server URL is invalid", () => { + // "not a url" cannot be parsed by `new URL("/", serverUrl)`, exercising the + // catch block that wraps the underlying parse failure (discovery.ts 21-22). + expect(() => getAuthorizationServerUrl("not a url")).toThrow( + /Invalid MCP server URL: "not a url"/, + ); + }); + + it("includes the underlying error detail in the thrown message", () => { + let captured: Error | undefined; + try { + getAuthorizationServerUrl(""); + } catch (err) { + captured = err instanceof Error ? err : undefined; + } + expect(captured).toBeInstanceOf(Error); + // The wrapped message carries the parenthesised detail from the parse failure. + expect(captured?.message).toMatch(/Invalid MCP server URL: "" \(.+\)/); + }); }); diff --git a/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts b/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts index 0e7459fa..de355ee1 100644 --- a/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts +++ b/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts @@ -1,9 +1,30 @@ import { describe, it, expect, afterEach } from "vitest"; +import { connect } from "node:net"; import { createOAuthCallbackServer, type OAuthCallbackServer, } from "@inspector/core/auth/node/oauth-callback-server.js"; +/** + * Send a raw HTTP request line over a socket so we can use request targets + * (like `//`) that `fetch` would normalize/reject before they reach the + * server. Returns the raw response text. + */ +function rawRequest(port: number, requestLine: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1", () => { + socket.write(`${requestLine}\r\nHost: localhost\r\n\r\n`); + }); + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + data += chunk; + }); + socket.on("end", () => resolve(data)); + socket.on("error", reject); + }); +} + describe("OAuthCallbackServer", () => { let server: OAuthCallbackServer; @@ -219,6 +240,90 @@ describe("OAuthCallbackServer", () => { expect(first.status).toBe(200); }); + it("returns 400 when the request target is an unparseable URL", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + // `GET //` makes req.url === "//", which `new URL("//", base)` rejects, + // exercising the try/catch 400 branch in handleRequest. + const raw = await rawRequest(result.port, "GET // HTTP/1.1"); + expect(raw).toContain("400 Bad Request"); + expect(raw).toContain("OAuth complete"); + }); + + it("returns 400 JSON for an unparseable URL when Accept: application/json", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const raw = await rawRequest( + result.port, + "GET // HTTP/1.1\r\nAccept: application/json", + ); + expect(raw).toContain("400 Bad Request"); + expect(raw).toContain('{"error":"Bad Request"}'); + }); + + it("start() rejects when the callback path is not absolute", async () => { + server = createOAuthCallbackServer(); + await expect( + server.start({ port: 0, path: "no-leading-slash" }), + ).rejects.toThrow(/Callback path must start with '\/'/); + }); + + it("POST returns 405 with a JSON body when Accept: application/json", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?code=x`, + { method: "POST", headers: { Accept: "application/json" } }, + ); + + expect(res.status).toBe(405); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.json()).toEqual({ error: "Method Not Allowed" }); + }); + + it("returns 404 with a JSON body when Accept: application/json", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const res = await fetch(`http://localhost:${result.port}/nope`, { + headers: { Accept: "application/json" }, + }); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Not Found" }); + }); + + it("returns 409 with a JSON body when Accept: application/json", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + + server = createOAuthCallbackServer(); + const result = await server.start({ + port: 0, + onCallback: async () => { + await gate; + }, + }); + + const firstP = fetch( + `http://localhost:${result.port}/oauth/callback?code=first`, + ); + await new Promise((r) => setTimeout(r, 50)); + const second = await fetch( + `http://localhost:${result.port}/oauth/callback?code=second`, + { headers: { Accept: "application/json" } }, + ); + expect(second.status).toBe(409); + expect(await second.json()).toEqual({ error: "Callback already handled" }); + release(); + await firstP; + }); + it("onCallback rejection returns 500 and error HTML", async () => { server = createOAuthCallbackServer(); const result = await server.start({ @@ -237,4 +342,51 @@ describe("OAuthCallbackServer", () => { expect(html).toContain("OAuth failed"); expect(html).toContain("exchange failed"); }); + + it("onCallback rejection with a non-Error stringifies the value (500)", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ + port: 0, + onCallback: async () => { + throw "string-failure"; + }, + }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?code=abc`, + ); + + expect(res.status).toBe(500); + expect(await res.text()).toContain("string-failure"); + }); + + it("GET with error but no error_description still returns 400 and calls onError", async () => { + server = createOAuthCallbackServer(); + const errors: Array<{ + error: string; + error_description?: string | null; + }> = []; + const result = await server.start({ + port: 0, + onError: (p) => errors.push(p), + }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?error=invalid_request`, + ); + + expect(res.status).toBe(400); + expect(errors).toHaveLength(1); + expect(errors[0]!.error).toBe("invalid_request"); + expect(errors[0]!.error_description).toBeUndefined(); + }); + + it("builds a bracketed redirect URL for an IPv6 hostname", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0, hostname: "::1" }); + + expect(result.redirectUrl).toBe( + `http://[::1]:${result.port}/oauth/callback`, + ); + }); }); diff --git a/clients/web/src/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index 345bd1a7..f118081c 100644 --- a/clients/web/src/test/integration/auth/node/secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store.test.ts @@ -311,6 +311,13 @@ describe("KeyringSecretStore (mocked native bindings)", () => { ).toBe("p"); }); + it("KeychainUnavailableError stringifies a non-Error cause", () => { + const err = new KeychainUnavailableError("plain string cause"); + expect(err).toBeInstanceOf(KeychainUnavailableError); + expect(err.message).toContain("plain string cause"); + expect(err.message).toMatch(/libsecret/); + }); + it("KeychainUnavailableError carries the underlying error message", async () => { keyringMocks.failures.setThrows = true; try { diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 38c2ff17..33993cb0 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { NodeOAuthStorage, getOAuthStore, @@ -590,6 +590,22 @@ describe("NodeOAuthStorage with custom storagePath", () => { expect(all.getServerState("http://server-b.test").tokens).toBeUndefined(); }); + it("clearAllOAuthClientState tolerates a store whose servers map is absent", () => { + // Exercises the `state.servers ?? {}` nullish fallback: when the persisted + // state lacks a `servers` map, the iteration must default to {} (no throw). + const defaultStore = getOAuthStore(); + const clearServerState = vi.fn(); + vi.spyOn(defaultStore, "getState").mockReturnValue({ + servers: undefined, + clearServerState, + } as unknown as ReturnType); + + expect(() => clearAllOAuthClientState()).not.toThrow(); + expect(clearServerState).not.toHaveBeenCalled(); + + vi.mocked(defaultStore.getState).mockRestore(); + }); + it("should isolate state from default store", async () => { const customPath = path.join( os.tmpdir(), diff --git a/clients/web/src/test/integration/auth/state-machine.test.ts b/clients/web/src/test/integration/auth/state-machine.test.ts index 35777132..371fdc96 100644 --- a/clients/web/src/test/integration/auth/state-machine.test.ts +++ b/clients/web/src/test/integration/auth/state-machine.test.ts @@ -401,6 +401,268 @@ describe("OAuthStateMachine", () => { ).rejects.toThrow("Client information not available for token exchange"); }); + it("wraps a non-Error thrown by resource metadata discovery into an Error", async () => { + const { discoverOAuthProtectedResourceMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + // Reject with a non-Error so the catch takes the `new Error(String(e))` + // branch (line 45). + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue( + "string-rejection", + ); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + ); + await stateMachine.executeStep(state); + + const call = vi + .mocked(updateState) + .mock.calls.find((c) => "resourceMetadataError" in c[0]); + expect(call?.[0].resourceMetadataError).toBeInstanceOf(Error); + expect(call?.[0].resourceMetadataError?.message).toBe("string-rejection"); + }); + + it("throws when authorization server metadata discovery returns nothing", async () => { + const { + discoverOAuthProtectedResourceMetadata, + discoverAuthorizationServerMetadata, + } = await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue( + new Error("no resource metadata"), + ); + // SDK returns undefined → execute throws "Failed to discover OAuth + // metadata" (line 69). + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue( + undefined as unknown as OAuthMetadata, + ); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + ); + await expect(stateMachine.executeStep(state)).rejects.toThrow( + "Failed to discover OAuth metadata", + ); + }); + + it("client_registration falls back to metadata.scopes_supported when provider scope is empty", async () => { + const { registerClient } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(registerClient).mockResolvedValue({ + redirect_uris: ["http://localhost/callback"], + client_id: "registered", + }); + + // provider.scope empty → falls into the scope-derivation block; the + // resourceMetadata has no scopes_supported so it uses metadata's. + const providerEmptyScope = { + ...mockProvider, + scope: "", + clientMetadata: { + redirect_uris: ["http://localhost:3000/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + client_name: "Test Client", + scope: "", + }, + clientInformation: vi.fn(async () => undefined), + } as unknown as BaseOAuthClientProvider; + + const regState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "client_registration", + oauthMetadata: { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["alpha", "beta"], + } as OAuthMetadata, + resourceMetadata: { + resource: "http://localhost:3000", + } as OAuthProtectedResourceMetadata, + }; + + await oauthTransitions.client_registration.execute({ + state: regState, + serverUrl, + provider: providerEmptyScope, + updateState, + }); + + expect(providerEmptyScope.clientMetadata.scope).toBe("alpha beta"); + expect(registerClient).toHaveBeenCalled(); + }); + + it("authorization_redirect discovers scopes when provider scope is empty", async () => { + const { startAuthorization } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(startAuthorization).mockResolvedValue({ + authorizationUrl: new URL("http://localhost:3000/authorize?x=1"), + codeVerifier: "verifier-xyz", + }); + + const providerEmptyScope = { + ...mockProvider, + scope: "", + saveCodeVerifier: vi.fn(), + state: vi.fn(() => "st"), + redirectUrl: "http://localhost:3000/callback", + } as unknown as BaseOAuthClientProvider; + + const redirectState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "authorization_redirect", + oauthMetadata: { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + } as OAuthMetadata, + oauthClientInfo: { client_id: "test-client" }, + // resourceMetadata with scopes_supported so discoverScopes resolves. + resourceMetadata: { + resource: "http://localhost:3000", + scopes_supported: ["s1", "s2"], + } as OAuthProtectedResourceMetadata, + }; + + await oauthTransitions.authorization_redirect.execute({ + state: redirectState, + serverUrl, + provider: providerEmptyScope, + updateState, + }); + + expect(startAuthorization).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ scope: "s1 s2" }), + ); + expect(providerEmptyScope.saveCodeVerifier).toHaveBeenCalledWith( + "verifier-xyz", + ); + }); + + it("authorization_code execute sets a validation error and throws when code is blank", async () => { + const codeState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "authorization_code", + authorizationCode: " ", + }; + + await expect( + oauthTransitions.authorization_code.execute({ + state: codeState, + serverUrl, + provider: mockProvider, + updateState, + }), + ).rejects.toThrow("Authorization code required"); + + expect(updateState).toHaveBeenCalledWith( + expect.objectContaining({ + validationError: "You need to provide an authorization code", + }), + ); + }); + + it("authorization_code execute advances to token_request when a code is present", async () => { + const codeState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "authorization_code", + authorizationCode: "good-code", + }; + + await oauthTransitions.authorization_code.execute({ + state: codeState, + serverUrl, + provider: mockProvider, + updateState, + }); + + expect(updateState).toHaveBeenCalledWith( + expect.objectContaining({ + validationError: null, + oauthStep: "token_request", + }), + ); + }); + + it("token_request execute throws when provider metadata is unavailable", async () => { + const providerNoMeta = { + ...mockProvider, + codeVerifier: vi.fn(() => "cv"), + getServerMetadata: vi.fn(() => null), + } as unknown as BaseOAuthClientProvider; + + const tokenState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "token_request", + authorizationCode: "code", + oauthClientInfo: { client_id: "c" }, + }; + + await expect( + oauthTransitions.token_request.execute({ + state: tokenState, + serverUrl, + provider: providerNoMeta, + updateState, + }), + ).rejects.toThrow("OAuth metadata not available"); + }); + + it("token_request execute coerces a string resource into a URL for exchange", async () => { + const { exchangeAuthorization } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + const metadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + }; + vi.mocked(exchangeAuthorization).mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + + const providerWithMeta = { + ...mockProvider, + codeVerifier: vi.fn(() => "cv"), + getServerMetadata: vi.fn(() => metadata), + clientInformation: vi.fn(async () => ({ client_id: "c" })), + saveTokens: vi.fn(), + redirectUrl: "http://localhost:3000/callback", + } as unknown as BaseOAuthClientProvider; + + const tokenState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "token_request", + authorizationCode: "code", + oauthClientInfo: { client_id: "c" }, + // resource as a *string* exercises the `new URL(resource)` branch. + resource: "http://localhost:3000/resource" as unknown as URL, + }; + + await oauthTransitions.token_request.execute({ + state: tokenState, + serverUrl, + provider: providerWithMeta, + updateState, + }); + + expect(exchangeAuthorization).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + resource: new URL("http://localhost:3000/resource"), + }), + ); + }); + it("complete.canTransition always returns false (terminal state)", async () => { const result = await oauthTransitions.complete.canTransition({ state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" }, diff --git a/clients/web/src/test/integration/mcp/import/resolveSource.test.ts b/clients/web/src/test/integration/mcp/import/resolveSource.test.ts index 7b3959ff..cf5d370c 100644 --- a/clients/web/src/test/integration/mcp/import/resolveSource.test.ts +++ b/clients/web/src/test/integration/mcp/import/resolveSource.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { resolveImportSource } from "@inspector/core/mcp/import/resolveSource.js"; +import * as strategies from "@inspector/core/mcp/import/strategies.js"; +import type { ImportStrategy } from "@inspector/core/mcp/import/types.js"; const HOME = "/home/alice"; const cursorPath = "/home/alice/.cursor/mcp.json"; @@ -61,4 +63,42 @@ describe("resolveImportSource", () => { expect(result?.found).toBe(true); expect(result?.error).toMatch(/Failed to read .*EACCES/); }); + + it("stringifies a non-Error thrown by the reader (String(err) branch)", () => { + const result = resolveImportSource("cursor", "linux", HOME, () => { + // Throwing a non-Error exercises the `String(err)` arm of the ternary. + throw "disk gone"; + }); + expect(result?.found).toBe(true); + expect(result?.path).toBe(cursorPath); + expect(result?.error).toBe(`Failed to read ${cursorPath}: disk gone`); + }); + + it("stringifies a non-Error thrown by the parser (String(err) branch)", () => { + // The built-in strategy parsers always throw Error, so the parse-error + // `String(err)` arm is only reachable via a strategy whose parse() throws a + // non-Error. Stub getImportStrategy to return one, then restore. + const fake: ImportStrategy = { + id: "fake", + label: "Fake", + defaultPaths: () => ["/some/path.json"], + parse: () => { + throw "kaboom"; // non-Error throw + }, + }; + const spy = vi.spyOn(strategies, "getImportStrategy").mockReturnValue(fake); + try { + const result = resolveImportSource( + "fake", + "linux", + HOME, + () => "{}", // exists + valid JSON, so parse() runs and throws + ); + expect(result?.found).toBe(true); + expect(result?.path).toBe("/some/path.json"); + expect(result?.error).toBe("kaboom"); + } finally { + spy.mockRestore(); + } + }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts new file mode 100644 index 00000000..1bc75c5b --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -0,0 +1,1031 @@ +/** + * Coverage backfill for InspectorClient: drives a set of small, individually + * simple methods and error paths that the broader e2e suite does not reach. + * Uses the real stdio test server (and HTTP test servers where a network + * transport is required) so these remain behavioral tests, not mocks. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import * as z from "zod/v4"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { SamplingCreateMessage } from "@inspector/core/mcp/samplingCreateMessage.js"; +import { ElicitationCreateMessage } from "@inspector/core/mcp/elicitationCreateMessage.js"; +import { + getTestMcpServerCommand, + createTestServerHttp, + type TestServerHttp, + waitForEvent, + createEchoTool, + createTestServerInfo, + getTaskServerConfig, + createTaskTool, + createNumberedResources, + createNumberedPrompts, +} from "@modelcontextprotocol/inspector-test-server"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; + +const serverCommand = getTestMcpServerCommand(); + +function stdioClient(): InspectorClient { + return new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { environment: { transport: createTransportNode } }, + ); +} + +async function getAllTools(client: InspectorClient): Promise { + const collected: Tool[] = []; + let cursor: string | undefined; + for (let i = 0; i < 100; i++) { + const r = await client.listTools(cursor); + collected.push(...r.tools); + cursor = r.nextCursor; + if (!cursor) break; + } + return collected; +} + +async function getTool(client: InspectorClient, name: string): Promise { + const tool = (await getAllTools(client)).find((t) => t.name === name); + if (!tool) throw new Error(`Tool ${name} not found`); + return tool; +} + +/** + * Attach a no-op catch to every outstanding receiver-task payload promise so a + * deliberate reject (failure-path test) doesn't bubble up as an unhandled + * rejection. The real consumer (the server polling tasks/result) handles the + * rejection, but it may not have a handler attached at the instant we reject. + */ +function suppressReceiverPayloadRejections(client: InspectorClient): void { + const records = ( + client as unknown as { + receiverTaskRecords: Map }>; + } + ).receiverTaskRecords; + for (const record of records.values()) { + record.payloadPromise.catch(() => {}); + } +} + +describe("InspectorClient coverage backfill", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + describe("simple accessors and ping", () => { + it("ping() resolves against a live server and getTransportConfig returns the config", async () => { + client = stdioClient(); + await client.connect(); + await expect(client.ping()).resolves.toBeUndefined(); + + const cfg = client.getTransportConfig(); + expect(cfg.type).toBe("stdio"); + }); + + it("ping() throws when the client is not initialized", async () => { + const c = stdioClient(); + // Force the private client field to null to hit the guard. + (c as unknown as { client: unknown }).client = null; + await expect(c.ping()).rejects.toThrow(/Client not initialized/); + }); + + it("getClientCapabilities reflects the advertised capabilities", async () => { + // sampling enabled (default), elicit form enabled (default), roots set. + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { environment: { transport: createTransportNode }, roots: [] }, + ); + const caps = client.getClientCapabilities(); + expect(caps.sampling).toBeDefined(); + expect(caps.elicitation).toBeDefined(); + expect(caps.roots).toBeDefined(); + }); + }); + + describe("getAppRendererClient proxy", () => { + it("returns null before connect and a memoized proxy after connect", async () => { + client = stdioClient(); + // Not connected yet → null. + expect(client.getAppRendererClient()).toBeNull(); + + await client.connect(); + const proxy = client.getAppRendererClient(); + expect(proxy).not.toBeNull(); + // Second call returns the same memoized proxy. + expect(client.getAppRendererClient()).toBe(proxy); + + // Accessing setNotificationHandler returns the wrapped function (covers + // the prop === "setNotificationHandler" branch), and accessing another + // prop returns the underlying value (covers the fall-through return). + const wrapped = (proxy as unknown as { setNotificationHandler: unknown }) + .setNotificationHandler; + expect(typeof wrapped).toBe("function"); + const other = (proxy as unknown as { request: unknown }).request; + expect(typeof other).toBe("function"); + }); + }); + + describe("getCompletions error rethrow", () => { + it("wraps a non-MethodNotFound error as 'Failed to get completions'", async () => { + client = stdioClient(); + await client.connect(); + // Force the underlying SDK complete() to throw a non-MethodNotFound error + // so the catch rethrows it wrapped. + const c = client as unknown as { + client: { complete: () => Promise }; + }; + const original = c.client.complete; + c.client.complete = async () => { + throw new Error("boom-completions"); + }; + try { + await expect( + client.getCompletions({ type: "ref/prompt", name: "x" }, "arg", ""), + ).rejects.toThrow(/Failed to get completions: boom-completions/); + } finally { + c.client.complete = original; + } + }); + + it("returns empty values gracefully when the server reports MethodNotFound", async () => { + client = stdioClient(); + await client.connect(); + const c = client as unknown as { + client: { complete: () => Promise }; + }; + const original = c.client.complete; + c.client.complete = async () => { + throw new McpError(ErrorCode.MethodNotFound, "Method not found"); + }; + try { + await expect( + client.getCompletions({ type: "ref/prompt", name: "x" }, "arg", ""), + ).resolves.toEqual({ values: [] }); + } finally { + c.client.complete = original; + } + }); + }); + + describe("setRoots", () => { + it("enables roots when previously undefined and dispatches rootsChange", async () => { + // No roots option → this.roots is undefined initially. + client = stdioClient(); + await client.connect(); + const rootsChange = waitForEvent(client, "rootsChange", { + timeout: 3000, + }); + await client.setRoots([{ uri: "file:///tmp", name: "tmp" }]); + await rootsChange; + expect(client.getRoots()).toEqual([{ uri: "file:///tmp", name: "tmp" }]); + }); + + it("logs (does not throw) when the list_changed notification fails", async () => { + client = stdioClient(); + await client.connect(); + const c = client as unknown as { + client: { notification: (...a: unknown[]) => Promise }; + }; + const original = c.client.notification; + c.client.notification = async () => { + throw new Error("notify-failed"); + }; + try { + // Should resolve (error is caught + logged), and roots still update. + await expect( + client.setRoots([{ uri: "file:///x", name: "x" }]), + ).resolves.toBeUndefined(); + expect(client.getRoots()).toEqual([{ uri: "file:///x", name: "x" }]); + } finally { + c.client.notification = original; + } + }); + + it("throws when client is not connected", async () => { + const c = stdioClient(); + (c as unknown as { client: unknown }).client = null; + await expect(c.setRoots([])).rejects.toThrow(/Client is not connected/); + }); + }); + + describe("subscribe / unsubscribe error wrapping", () => { + it("wraps subscribe failures from a server that advertises subscribe but rejects", async () => { + // Advertise subscriptions so supportsResourceSubscriptions() passes, then + // make the SDK subscribeResource reject to hit the catch wrapper. + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + resources: [], + subscriptions: true, + serverType: "sse", + }); + await server.start(); + client = new InspectorClient( + { type: "sse", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + expect(client.supportsResourceSubscriptions()).toBe(true); + + const c = client as unknown as { + client: { subscribeResource: () => Promise }; + }; + c.client.subscribeResource = async () => { + throw new Error("sub-rejected"); + }; + await expect(client.subscribeToResource("test://nope")).rejects.toThrow( + /Failed to subscribe to resource: sub-rejected/, + ); + }); + + it("wraps unsubscribe failures", async () => { + client = stdioClient(); + await client.connect(); + const c = client as unknown as { + client: { unsubscribeResource: () => Promise }; + }; + c.client.unsubscribeResource = async () => { + throw new Error("unsub-rejected"); + }; + await expect( + client.unsubscribeFromResource("test://nope"), + ).rejects.toThrow(/Failed to unsubscribe from resource: unsub-rejected/); + }); + }); + + describe("receiver-task failure callbacks (server-driven sampling/elicitation)", () => { + it("marks the receiver task failed when the sampling request is rejected", async () => { + const config = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createTaskTool({ + name: "receiverRejectSampling", + samplingText: "Reply please", + receiverTaskTtl: 5000, + }), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(config); + await server.start(); + client = new InspectorClient( + { type: "sse", url: server.url }, + { + environment: { transport: createTransportNode }, + sample: true, + receiverTasks: true, + receiverTaskTtlMs: 10_000, + }, + ); + await client.connect(); + + const samplingPromise = waitForEvent( + client, + "newPendingSample", + { timeout: 5000 }, + ); + const tool = await getTool(client, "receiverRejectSampling"); + const callPromise = client + .callToolStream(tool, { message: "x" }) + .catch((e: unknown) => e); + + const sample = await samplingPromise; + // Pre-attach a catch to the receiver task's payload promise so its + // rejection (driven below) doesn't surface as an unhandled rejection. + suppressReceiverPayloadRejections(client); + // Reject instead of respond — drives the receiver-task error callback, + // which sets status "failed" and calls upsertReceiverTask. + await sample.reject(new Error("user rejected sampling")); + + const outcome = await callPromise; + // The rejected payload surfaces as a failed/errored tool call. + expect(outcome).toBeDefined(); + }); + + it("marks the receiver task failed when the elicitation request is rejected", async () => { + const config = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createTaskTool({ + name: "receiverRejectElicit", + elicitationSchema: z.object({ + input: z.string().describe("User input"), + }), + receiverTaskTtl: 5000, + }), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(config); + await server.start(); + client = new InspectorClient( + { type: "sse", url: server.url }, + { + environment: { transport: createTransportNode }, + elicit: true, + receiverTasks: true, + receiverTaskTtlMs: 10_000, + }, + ); + await client.connect(); + + const elicitationPromise = waitForEvent( + client, + "newPendingElicitation", + { timeout: 5000 }, + ); + const tool = await getTool(client, "receiverRejectElicit"); + const callPromise = client + .callToolStream(tool, { message: "x" }) + .catch((e: unknown) => e); + + const elicitation = await elicitationPromise; + suppressReceiverPayloadRejections(client); + await elicitation.reject(new Error("user declined elicitation")); + + const outcome = await callPromise; + expect(outcome).toBeDefined(); + }); + }); + + describe("list/read/get with metadata + cursor (effectiveMeta + cursor branches)", () => { + it("listResources / listResourceTemplates / listPrompts / listTools accept metadata and cursor", async () => { + // Server with enough entries to produce a nextCursor under pagination so + // the cursor branch is exercised on a follow-up call. + const resources = createNumberedResources(2); + const prompts = createNumberedPrompts(1); + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + resources, + resourceTemplates: [], + prompts, + maxPageSize: { resources: 1 }, + serverType: "streamable-http", + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + // Non-empty default metadata so mergeMeta returns a non-empty map → + // the `effectiveMeta ? { _meta } : {}` truthy branch fires. + serverSettings: { + headers: [], + env: [], + metadata: [{ key: "x-test", value: "1" }], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 0, + maxFetchRequests: 1000, + roots: [], + }, + }, + ); + await client.connect(); + + const meta = { trace: "abc" }; + // First page (no cursor) with metadata. + const firstResources = await client.listResources(undefined, meta); + expect(firstResources.resources.length).toBeGreaterThan(0); + // Follow-up page WITH cursor (exercises the cursor-truthy branch). + if (firstResources.nextCursor) { + const secondResources = await client.listResources( + firstResources.nextCursor, + meta, + ); + expect(secondResources.resources).toBeDefined(); + } + + await client.listResourceTemplates(undefined, meta); + await client.listResourceTemplates("cursor-x", meta).catch(() => { + // A bogus cursor may error on some servers; we only need the param path. + }); + await client.listPrompts(undefined, meta); + await client.listPrompts("cursor-y", meta).catch(() => {}); + const tools = await client.listTools("cursor-z", meta).catch(() => ({ + tools: [], + })); + expect(tools).toBeDefined(); + + // readResource + getPrompt with metadata (effectiveMeta truthy + getPrompt + // stringArgs non-empty branch). + const read = await client.readResource("test://resource_1", meta); + expect(read.result).toBeDefined(); + const prompt = await client.getPrompt("prompt_1", { a: "b" }, meta); + expect(prompt.result).toBeDefined(); + + // getCompletions with context + metadata (context-truthy + _meta-truthy). + await client + .getCompletions( + { type: "ref/prompt", name: "prompt_1" }, + "a", + "", + { other: "v" }, + meta, + ) + .catch(() => { + // server may not support completions; the param-build branches ran. + }); + }); + + it("getPrompt without args uses an empty arg map and omits params", async () => { + const prompts = createNumberedPrompts(1); + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + prompts, + serverType: "sse", + }); + await server.start(); + client = new InspectorClient( + { type: "sse", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + // No args → `args ? convertPromptArguments(args) : {}` false branch and + // `Object.keys(stringArgs).length > 0 ? stringArgs : undefined` false branch. + const invocation = await client.getPrompt("prompt_1"); + expect(invocation.result).toBeDefined(); + expect(invocation.params).toBeUndefined(); + }); + + it("getCompletions without context omits the context param", async () => { + client = stdioClient(); + await client.connect(); + // No context arg → the `context ? { context } : {}` false branch. + await client + .getCompletions({ type: "ref/prompt", name: "x" }, "arg", "") + .catch(() => { + // server support varies; the param-build branch is what we exercise. + }); + }); + + it("getCompletions falls back to empty values when completion omits them", async () => { + client = stdioClient(); + await client.connect(); + const c = client as unknown as { + client: { complete: () => Promise }; + }; + // completion present but without a `values` field → `|| []` branch. + c.client.complete = async () => ({ completion: {} }); + const res = await client.getCompletions( + { type: "ref/prompt", name: "x" }, + "arg", + "", + undefined, + { progressToken: "tok" }, + ); + expect(res.values).toEqual([]); + }); + + it("readResourceFromTemplate wraps a URI-expansion failure", async () => { + client = stdioClient(); + await client.connect(); + // A malformed template triggers UriTemplate parse/expand to throw. + await expect( + client.readResourceFromTemplate("file:///{unclosed", { path: "x" }), + ).rejects.toThrow(/Failed to expand URI template/); + }); + }); + + describe("uninitialized-client guards", () => { + it("attemptToolCall path throws when client is null (via callTool guard)", async () => { + const c = stdioClient(); + (c as unknown as { client: unknown }).client = null; + await expect( + c.callTool({ name: "x", inputSchema: { type: "object" } }, {}), + ).rejects.toThrow(/Client is not connected/); + }); + + it("fetchServerInfo returns early when client is null", async () => { + const c = stdioClient(); + (c as unknown as { client: unknown }).client = null; + await expect( + ( + c as unknown as { fetchServerInfo: () => Promise } + ).fetchServerInfo(), + ).resolves.toBeUndefined(); + }); + + it("setOAuthConfig succeeds when an oauth manager is configured", async () => { + const c = new InspectorClient( + { type: "sse", url: "http://localhost:9/sse" }, + { + environment: { transport: createTransportNode }, + oauth: { clientId: "initial" }, + }, + ); + // Manager exists → delegates to it without throwing. + expect(() => c.setOAuthConfig({ clientId: "updated" })).not.toThrow(); + }); + }); + + describe("failed tool calls with metadata combinations", () => { + // A tool the server will reject (unknown name) so the call always errors, + // exercising the error-path metadata-merge branches in callTool + // (dispatchFailedToolCall) and callToolStream. + const badTool: Tool = { + name: "definitely-not-a-real-tool", + inputSchema: { type: "object" }, + }; + + it("callTool error path covers general-only, tool-specific-only, and both metadata", async () => { + client = stdioClient(); + await client.connect(); + // Force the underlying SDK callTool to throw so the dispatchFailedToolCall + // error path runs for each metadata combination. + const c = client as unknown as { + client: { callTool: () => Promise }; + }; + c.client.callTool = async () => { + throw new Error("forced-call-failure"); + }; + + await expect( + client.callTool(badTool, {}, { g: "1" }, undefined), + ).rejects.toThrow(/forced-call-failure/); + await expect( + client.callTool(badTool, {}, undefined, { t: "1" }), + ).rejects.toThrow(/forced-call-failure/); + await expect( + client.callTool(badTool, {}, { g: "1" }, { t: "2" }), + ).rejects.toThrow(/forced-call-failure/); + await expect( + client.callTool(badTool, {}, undefined, undefined), + ).rejects.toThrow(/forced-call-failure/); + }); + + it("callToolStream error path covers metadata combinations", async () => { + client = stdioClient(); + await client.connect(); + // Force the streaming API to throw synchronously so the error-path + // metadata-merge branches run for each combination. + const c = client as unknown as { + client: { experimental: { tasks: { callToolStream: () => never } } }; + }; + c.client.experimental.tasks.callToolStream = () => { + throw new Error("forced-stream-failure"); + }; + + await expect( + client.callToolStream(badTool, {}, { g: "1" }, undefined), + ).rejects.toThrow(/forced-stream-failure/); + await expect( + client.callToolStream(badTool, {}, undefined, { t: "1" }), + ).rejects.toThrow(/forced-stream-failure/); + await expect( + client.callToolStream(badTool, {}, { g: "1" }, { t: "2" }), + ).rejects.toThrow(/forced-stream-failure/); + await expect( + client.callToolStream(badTool, {}, undefined, undefined), + ).rejects.toThrow(/forced-stream-failure/); + }); + + it("callToolStream success path covers metadata combinations", async () => { + client = stdioClient(); + await client.connect(); + const echo = await getTool(client, "echo"); + + // general-only + const r1 = await client.callToolStream( + echo, + { message: "a" }, + { g: "1" }, + undefined, + ); + expect(r1.success).toBe(true); + // tool-specific-only + const r2 = await client.callToolStream( + echo, + { message: "b" }, + undefined, + { t: "1" }, + ); + expect(r2.success).toBe(true); + // both + const r3 = await client.callToolStream( + echo, + { message: "c" }, + { g: "1" }, + { t: "2" }, + ); + expect(r3.success).toBe(true); + }); + + it("callToolStream throws when client is not connected", async () => { + const c = stdioClient(); + (c as unknown as { client: unknown }).client = null; + await expect(c.callToolStream(badTool, {})).rejects.toThrow( + /Client is not connected/, + ); + }); + }); + + describe("connect-time timeout and safe-disconnect", () => { + it("times out the handshake when connectionTimeout is exceeded", async () => { + // Point at a non-routable address so the TCP connect HANGS (rather than + // refusing immediately). With a tiny connectionTimeout the Promise.race + // timeout wins, exercising the timeout branch (catch + disconnect + + // rethrow) and the connectPromise.catch(()=>{}) late-rejection absorber. + client = new InspectorClient( + { type: "streamable-http", url: "http://10.255.255.1:81/mcp" }, + { + environment: { transport: createTransportNode }, + serverSettings: { + headers: [], + env: [], + metadata: [], + connectionTimeout: 1, + requestTimeout: 0, + taskTtl: 0, + maxFetchRequests: 1000, + roots: [], + }, + }, + ); + await expect(client.connect()).rejects.toThrow(/timed out/i); + client = null; + }); + + it("disconnect(safeDisconnectTimeout) polls the response handlers before closing", async () => { + client = stdioClient(); + await client.connect(); + // A positive safeDisconnectTimeout drives the poll loop. With no pending + // handlers it returns promptly; the loop guard + close still run. + await expect(client.disconnect(50)).resolves.toBeUndefined(); + expect(client.getStatus()).toBe("disconnected"); + client = null; + }); + }); + + describe("non-Error rejections and empty-response fallbacks", () => { + it("getCompletions wraps a non-Error rejection via String(error)", async () => { + client = stdioClient(); + await client.connect(); + const c = client as unknown as { + client: { complete: () => Promise }; + }; + c.client.complete = async () => { + // Throw a non-Error so the `: String(error)` branch runs. + throw "string-completions-failure"; + }; + await expect( + client.getCompletions({ type: "ref/prompt", name: "x" }, "a", ""), + ).rejects.toThrow( + /Failed to get completions: string-completions-failure/, + ); + }); + + it("subscribe / unsubscribe wrap a non-Error rejection via String(error)", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + resources: [], + subscriptions: true, + serverType: "sse", + }); + await server.start(); + client = new InspectorClient( + { type: "sse", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + const c = client as unknown as { + client: { + subscribeResource: () => Promise; + unsubscribeResource: () => Promise; + }; + }; + c.client.subscribeResource = async () => { + throw "string-sub-failure"; + }; + c.client.unsubscribeResource = async () => { + throw "string-unsub-failure"; + }; + await expect(client.subscribeToResource("test://x")).rejects.toThrow( + /Failed to subscribe to resource: string-sub-failure/, + ); + await expect(client.unsubscribeFromResource("test://x")).rejects.toThrow( + /Failed to unsubscribe from resource: string-unsub-failure/, + ); + }); + + it("callToolStream error dispatch handles a non-Error rejection", async () => { + client = stdioClient(); + await client.connect(); + const echo = await getTool(client, "echo"); + const c = client as unknown as { + client: { experimental: { tasks: { callToolStream: () => never } } }; + }; + c.client.experimental.tasks.callToolStream = () => { + throw "string-stream-failure"; + }; + await expect( + client.callToolStream(echo, { message: "x" }, { g: "1" }), + ).rejects.toBe("string-stream-failure"); + }); + + it("list methods fall back to empty arrays when the response omits them", async () => { + client = stdioClient(); + await client.connect(); + const c = client as unknown as { + client: { + listResources: () => Promise; + listResourceTemplates: () => Promise; + listPrompts: () => Promise; + listTools: () => Promise; + }; + }; + // Return responses missing the array field → `|| []` fallback branches. + c.client.listResources = async () => ({}); + c.client.listResourceTemplates = async () => ({}); + c.client.listPrompts = async () => ({}); + c.client.listTools = async () => ({}); + + expect((await client.listResources()).resources).toEqual([]); + expect((await client.listResourceTemplates()).resourceTemplates).toEqual( + [], + ); + expect((await client.listPrompts()).prompts).toEqual([]); + expect((await client.listTools()).tools).toEqual([]); + }); + + it("readResourceFromTemplate expand-failure wraps a non-Error", async () => { + client = stdioClient(); + await client.connect(); + // Make the SDK UriTemplate throw a non-Error by passing a template that + // the UriTemplate constructor rejects. (If it throws an Error, the Error + // branch runs instead; either way the catch wrapper is covered.) + await expect( + client.readResourceFromTemplate("{a,b,c", { x: "1" }), + ).rejects.toThrow(/Failed to expand URI template/); + }); + }); + + describe("callToolStream task-result fallback and terminal branches", () => { + function patchStream( + c: InspectorClient, + gen: () => AsyncGenerator, + getTaskResult?: () => Promise, + ): void { + const internal = c as unknown as { + client: { + experimental: { + tasks: { + callToolStream: () => AsyncGenerator; + getTaskResult: (id: string) => Promise; + }; + }; + }; + }; + internal.client.experimental.tasks.callToolStream = gen; + if (getTaskResult) { + internal.client.experimental.tasks.getTaskResult = getTaskResult; + } + } + + const fakeTask = (taskId: string) => ({ + taskId, + status: "working" as const, + ttl: null, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + }); + + it("falls back to getTaskResult when the stream yields no result message", async () => { + client = stdioClient(); + await client.connect(); + const echo = await getTool(client, "echo"); + patchStream( + client, + async function* () { + // taskCreated then taskStatus, but NO result → triggers the fallback. + yield { type: "taskCreated", task: fakeTask("T1") }; + yield { type: "taskStatus", task: fakeTask("T1") }; + }, + async () => ({ content: [{ type: "text", text: "from-fallback" }] }), + ); + const result = await client.callToolStream(echo, { message: "x" }); + expect(result.success).toBe(true); + expect(result.result?.content?.[0]).toMatchObject({ + text: "from-fallback", + }); + }); + + it("throws when the getTaskResult fallback itself fails", async () => { + client = stdioClient(); + await client.connect(); + const echo = await getTool(client, "echo"); + patchStream( + client, + async function* () { + yield { type: "taskCreated", task: fakeTask("T2") }; + }, + async () => { + throw new Error("fallback-failed"); + }, + ); + await expect( + client.callToolStream(echo, { message: "x" }), + ).rejects.toThrow(/Tool call did not return a result: fallback-failed/); + }); + + it("surfaces a stream error message and marks the task failed", async () => { + client = stdioClient(); + await client.connect(); + const echo = await getTool(client, "echo"); + patchStream(client, async function* () { + yield { type: "taskCreated", task: fakeTask("T3") }; + // Empty error message → `message.error.message || "Task execution failed"` + // falsy branch. + yield { type: "error", error: { message: "" } }; + }); + await expect( + client.callToolStream(echo, { message: "x" }), + ).rejects.toThrow(/Task execution failed/); + }); + + it("treats a stream taskStatus before taskCreated as the task id", async () => { + client = stdioClient(); + await client.connect(); + const echo = await getTool(client, "echo"); + patchStream(client, async function* () { + // taskStatus arrives first (no prior taskCreated) → `if (!taskId)` true. + yield { type: "taskStatus", task: fakeTask("T4") }; + yield { + type: "result", + result: { content: [{ type: "text", text: "ok" }] }, + }; + }); + const result = await client.callToolStream(echo, { message: "x" }); + expect(result.success).toBe(true); + }); + }); + + describe("constructor capability branches and createReceiverTask options", () => { + it("constructs with no advertised capabilities (clientOptions undefined branch)", async () => { + // sample:false + elicit:false + no roots + no receiverTasks → the + // capabilities object is empty, so clientOptions is passed as undefined. + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + sample: false, + elicit: false, + }, + ); + const caps = client.getClientCapabilities(); + expect(Object.keys(caps)).toHaveLength(0); + await client.connect(); + expect(client.getStatus()).toBe("connected"); + }); + + it("createReceiverTask honors pollInterval and statusMessage options", async () => { + client = stdioClient(); + await client.connect(); + const internal = client as unknown as { + createReceiverTask: (opts: { + initialStatus: string; + ttl?: number; + pollInterval?: number; + statusMessage?: string; + }) => { + task: { + taskId: string; + pollInterval?: number; + statusMessage?: string; + }; + }; + }; + const record = internal.createReceiverTask({ + initialStatus: "working", + ttl: 5000, + pollInterval: 250, + statusMessage: "in progress", + }); + expect(record.task.pollInterval).toBe(250); + expect(record.task.statusMessage).toBe("in progress"); + + // Omitting ttl falls back to the configured numeric receiverTaskTtlMs + // (default 60_000) → the non-function branch of the ttl resolution. + const recordNoTtl = internal.createReceiverTask({ + initialStatus: "working", + }) as unknown as { task: { ttl: number } }; + expect(recordNoTtl.task.ttl).toBe(60_000); + }); + + it("createReceiverTask falls back to the configured TTL when ttl is omitted (function form)", async () => { + // receiverTaskTtlMs as a function → exercises the typeof === 'function' + // branch of the ttl resolution. + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + receiverTasks: true, + receiverTaskTtlMs: () => 1234, + }, + ); + await client.connect(); + const internal = client as unknown as { + createReceiverTask: (opts: { initialStatus: string }) => { + task: { ttl: number }; + }; + }; + const record = internal.createReceiverTask({ initialStatus: "working" }); + expect(record.task.ttl).toBe(1234); + }); + }); + + describe("emitReceiverTaskStatus guards", () => { + it("is a no-op when there is no connected client", () => { + const c = stdioClient(); + (c as unknown as { client: unknown }).client = null; + // Should not throw even though client is null. + expect(() => + ( + c as unknown as { emitReceiverTaskStatus: (t: unknown) => void } + ).emitReceiverTaskStatus({ taskId: "t" }), + ).not.toThrow(); + }); + + it("swallows notification-build errors via the catch path", async () => { + client = stdioClient(); + await client.connect(); + const internal = client as unknown as { + emitReceiverTaskStatus: (t: unknown) => void; + }; + // Passing a malformed task makes TaskStatusNotificationSchema.parse throw, + // which is caught and logged (no throw). + expect(() => + internal.emitReceiverTaskStatus({ not: "a task" }), + ).not.toThrow(); + }); + + it("upsertReceiverTask emits status for an existing record", async () => { + client = stdioClient(); + await client.connect(); + const internal = client as unknown as { + createReceiverTask: (opts: { initialStatus: string; ttl?: number }) => { + task: { taskId: string; status: string }; + }; + upsertReceiverTask: (t: { taskId: string; status: string }) => void; + getReceiverTask: ( + id: string, + ) => { task: { status: string } } | undefined; + }; + const record = internal.createReceiverTask({ + initialStatus: "working", + ttl: 5000, + }); + const updated = { ...record.task, status: "completed" }; + internal.upsertReceiverTask(updated); + expect(internal.getReceiverTask(record.task.taskId)?.task.status).toBe( + "completed", + ); + // upsert on an unknown id is a no-op (record undefined branch). + expect(() => + internal.upsertReceiverTask({ taskId: "missing", status: "completed" }), + ).not.toThrow(); + }); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/connect-crash.test.ts b/clients/web/src/test/integration/mcp/remote/connect-crash.test.ts index 211d7480..15e6589a 100644 --- a/clients/web/src/test/integration/mcp/remote/connect-crash.test.ts +++ b/clients/web/src/test/integration/mcp/remote/connect-crash.test.ts @@ -46,43 +46,62 @@ interface ParsedSseEvent { } /** - * Read a streaming Response body and parse Server-Sent Events. Stops when - * the server closes the stream (read returns done). + * Read a streaming Response body and parse Server-Sent Events. Stops as soon + * as the terminal `stopAtType` event arrives (default `transport_error`), + * cancelling the reader — or when the server closes the stream (`done`). + * + * We stop on the terminal event rather than waiting only for `done` because + * the server closes the stream immediately *after* writing `transport_error`, + * and under heavy parallel scheduler pressure (a full `coverage` run with v8 + * instrumentation across all workers) that close can lag far enough behind the + * event to trip the 30s test timeout — a hang, not a wrong result. Bailing on + * the terminal event keeps the test fast and deterministic regardless of load; + * `transport_error` is always the last event the queue emits, so nothing is + * lost. The `done` break still covers any stream that closes first. * * The SSE `data:` field carries the full SessionEvent JSON (both `type` and * `data`), per the writer in /api/mcp/events — we unwrap and surface just * the inner `.data` as the event payload, so callers can read e.g. * `event.data.message` for stdio_log without an extra hop. */ -async function readSseEvents(res: Response): Promise { +async function readSseEvents( + res: Response, + stopAtType = "transport_error", +): Promise { if (!res.body) return []; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const events: ParsedSseEvent[] = []; - while (true) { - const { value, done } = await reader.read(); - if (value) buffer += decoder.decode(value, { stream: true }); - // SSE events terminate with a blank line — split on it. - let split: number; - while ((split = buffer.indexOf("\n\n")) !== -1) { - const block = buffer.slice(0, split); - buffer = buffer.slice(split + 2); - let type: string | undefined; - let dataLine: string | undefined; - for (const line of block.split("\n")) { - if (line.startsWith("event:")) - type = line.slice("event:".length).trim(); - else if (line.startsWith("data:")) - dataLine = line.slice("data:".length).trim(); - } - if (type && dataLine) { - const parsed = JSON.parse(dataLine) as { data?: unknown }; - events.push({ type, data: parsed.data }); + try { + while (true) { + const { value, done } = await reader.read(); + if (value) buffer += decoder.decode(value, { stream: true }); + // SSE events terminate with a blank line — split on it. + let split: number; + let sawTerminal = false; + while ((split = buffer.indexOf("\n\n")) !== -1) { + const block = buffer.slice(0, split); + buffer = buffer.slice(split + 2); + let type: string | undefined; + let dataLine: string | undefined; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) + type = line.slice("event:".length).trim(); + else if (line.startsWith("data:")) + dataLine = line.slice("data:".length).trim(); + } + if (type && dataLine) { + const parsed = JSON.parse(dataLine) as { data?: unknown }; + events.push({ type, data: parsed.data }); + if (type === stopAtType) sawTerminal = true; + } } + if (sawTerminal || done) break; } - if (done) break; + } finally { + await reader.cancel().catch(() => {}); } return events; } diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index 174c3fd7..cf95564f 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -6,8 +6,23 @@ * no network) so the integration runner is comfortable for it. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js"; +import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js"; + +function makeFetchEntry( + overrides: Partial = {}, +): FetchRequestEntryBase { + return { + id: "req-1", + timestamp: new Date("2026-01-01T00:00:00Z"), + method: "GET", + url: "http://example.com/", + requestHeaders: {}, + ...overrides, + }; +} describe("RemoteSession", () => { it("queues events before a consumer attaches and drains them on attach", () => { @@ -75,4 +90,87 @@ describe("RemoteSession", () => { session.markTransportDead("boom"); expect(session.clearEventConsumer()).toBe(true); }); + + it("flushes a non-empty queue in order on consumer attach", () => { + const session = new RemoteSession("s6"); + session.onStderr({ timestamp: new Date(), message: "first" }); + session.onMessage({ jsonrpc: "2.0", id: 1, method: "ping" }); + session.onFetchResponseBody("req-1", "body"); + const received: string[] = []; + session.setEventConsumer((event) => received.push(event.type)); + expect(received).toEqual([ + "stdio_log", + "message", + "fetch_request_body_update", + ]); + }); + + it("setEventConsumer with an empty queue flushes nothing", () => { + const session = new RemoteSession("s7"); + const consumer = vi.fn(); + session.setEventConsumer(consumer); + expect(consumer).not.toHaveBeenCalled(); + }); + + it("setTransport stores the transport", () => { + const session = new RemoteSession("s8"); + const transport = { foo: "bar" } as unknown as Transport; + session.setTransport(transport); + expect(session.transport).toBe(transport); + }); + + it("hasEventConsumer reflects whether a consumer is attached", () => { + const session = new RemoteSession("s9"); + expect(session.hasEventConsumer()).toBe(false); + session.setEventConsumer(() => {}); + expect(session.hasEventConsumer()).toBe(true); + }); + + it("delivers events live to an attached consumer (pushEvent direct path)", () => { + const session = new RemoteSession("s10"); + const received: { type: string; data: unknown }[] = []; + session.setEventConsumer((event) => + received.push({ type: event.type, data: event.data }), + ); + session.onMessage({ jsonrpc: "2.0", id: 1, result: {} }); + session.onFetchResponseBody("req-1", "the-body"); + expect(received.map((e) => e.type)).toEqual([ + "message", + "fetch_request_body_update", + ]); + expect(received[1]?.data).toEqual({ + id: "req-1", + responseBody: "the-body", + }); + }); + + it("onFetchRequest serializes a Date timestamp to an ISO string", () => { + const session = new RemoteSession("s11"); + const received: { type: string; data: unknown }[] = []; + session.setEventConsumer((event) => + received.push({ type: event.type, data: event.data }), + ); + session.onFetchRequest( + makeFetchEntry({ timestamp: new Date("2026-01-01T00:00:00Z") }), + ); + expect(received[0]?.type).toBe("fetch_request"); + expect((received[0]?.data as { timestamp: string }).timestamp).toBe( + "2026-01-01T00:00:00.000Z", + ); + }); + + it("onFetchRequest leaves a non-Date timestamp untouched", () => { + const session = new RemoteSession("s12"); + const received: { data: unknown }[] = []; + session.setEventConsumer((event) => received.push({ data: event.data })); + // A pre-serialized (string) timestamp exercises the ternary's else branch. + session.onFetchRequest( + makeFetchEntry({ + timestamp: "2026-01-01T00:00:00.000Z" as unknown as Date, + }), + ); + expect((received[0]?.data as { timestamp: string }).timestamp).toBe( + "2026-01-01T00:00:00.000Z", + ); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts new file mode 100644 index 00000000..4e57718e --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -0,0 +1,611 @@ +/** + * Focused unit tests for RemoteClientTransport that drive the error and + * SSE-parsing branches that the full e2e remote-server harness does not + * reliably reach (connect/events/send non-OK responses, missing sessionId, + * missing event-stream body, transport_error events, malformed SSE JSON, + * stream read errors, and the SSE final-buffer flush path). + * + * These use a hand-built mock `fetchFn` plus a controllable SSE + * ReadableStream rather than a real Hono backend so each branch can be + * exercised deterministically. + */ + +import { describe, it, expect, vi } from "vitest"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; +import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteClientTransport.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +const CONFIG: MCPServerConfig = { + type: "sse", + url: "http://upstream.test/sse", +}; + +/** Build a JSON Response. */ +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +/** + * Build an SSE Response whose body streams the supplied chunks (raw strings). + * The returned `pushDone` flag is implicit: chunks are enqueued immediately + * and the stream then closes. + */ +function sseResponse(chunks: string[], init?: ResponseInit): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(encoder.encode(c)); + controller.close(); + }, + }); + return new Response(stream, { status: 200, ...init }); +} + +/** Encode a RemoteEvent as a single SSE `data:` frame. */ +function sseFrame(event: RemoteEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +interface MockFetchPlan { + connect?: () => Response | Promise; + events?: () => Response | Promise; + send?: () => Response | Promise; + disconnect?: () => Response | Promise; +} + +/** + * Returns a fetch implementation that dispatches by URL path to the plan's + * handlers, defaulting to sensible success responses when a handler is omitted. + */ +function mockFetch(plan: MockFetchPlan): typeof fetch { + const fn = vi.fn(async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/connect")) { + return plan.connect + ? plan.connect() + : jsonResponse({ sessionId: "sess-1" }); + } + if (url.includes("/api/mcp/events")) { + return plan.events ? plan.events() : sseResponse([]); + } + if (url.includes("/api/mcp/send")) { + return plan.send ? plan.send() : jsonResponse({ ok: true }); + } + if (url.includes("/api/mcp/disconnect")) { + return plan.disconnect ? plan.disconnect() : jsonResponse({ ok: true }); + } + throw new Error(`unexpected fetch to ${url}`); + }); + return fn as unknown as typeof fetch; +} + +function makeTransport( + plan: MockFetchPlan, + extra: Partial = {}, +): RemoteClientTransport { + return new RemoteClientTransport( + { + baseUrl: "http://remote.test/", // trailing slash exercises baseUrl trim + authToken: "tok", + fetchFn: mockFetch(plan), + ...extra, + }, + CONFIG, + ); +} + +/** Wait a tick so the detached consumeEventStream loop can advance. */ +const tick = () => new Promise((r) => setTimeout(r, 10)); + +describe("RemoteClientTransport (focused branch coverage)", () => { + describe("start()", () => { + it("throws Remote connect failed with status on non-OK connect", async () => { + const t = makeTransport({ + connect: () => + new Response("nope", { status: 401, statusText: "Unauthorized" }), + }); + await expect(t.start()).rejects.toThrow(/Remote connect failed \(401\)/); + try { + await t.start(); + } catch (e) { + expect((e as { status?: number }).status).toBe(401); + } + }); + + it("throws when the remote returns no sessionId", async () => { + const t = makeTransport({ + connect: () => jsonResponse({ sessionId: "" }), + }); + await expect(t.start()).rejects.toThrow(/did not return sessionId/); + }); + + it("throws Remote events stream failed on non-OK events response", async () => { + const t = makeTransport({ + events: () => new Response("bad", { status: 500 }), + }); + await expect(t.start()).rejects.toThrow( + /Remote events stream failed \(500\)/, + ); + }); + + it("throws when the events stream has no body", async () => { + const t = makeTransport({ + // A 204 No Content has a null body. + events: () => new Response(null, { status: 200 }), + }); + await expect(t.start()).rejects.toThrow(/no body/); + }); + + it("forwards OAuth tokens from authProvider into the connect body", async () => { + let connectBody: unknown; + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/connect")) { + connectBody = JSON.parse(init!.body as string); + return jsonResponse({ sessionId: "s" }); + } + return sseResponse([]); + }, + ); + const authProvider = { + tokens: async () => ({ + access_token: "AT", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "RT", + }), + } as unknown as NonNullable; + const t = new RemoteClientTransport( + { + baseUrl: "http://remote.test", + fetchFn: fetchFn as unknown as typeof fetch, + authProvider, + }, + CONFIG, + ); + await t.start(); + expect(connectBody).toMatchObject({ + oauthTokens: { access_token: "AT", refresh_token: "RT" }, + }); + await t.close(); + }); + + it("handles an authProvider that has no tokens", async () => { + const authProvider = { + tokens: async () => undefined, + } as unknown as NonNullable; + const t = makeTransport({}, { authProvider }); + await t.start(); + await t.close(); + }); + + it("start() is a no-op when already closed throws closed", async () => { + const t = makeTransport({}); + await t.close(); + await expect(t.start()).rejects.toThrow(/Transport is closed/); + }); + }); + + describe("consumeEventStream() event handling", () => { + it("delivers message events to onmessage", async () => { + const msg: JSONRPCMessage = { jsonrpc: "2.0", id: 1, result: {} }; + const t = makeTransport({ + events: () => sseResponse([sseFrame({ type: "message", data: msg })]), + }); + const received: JSONRPCMessage[] = []; + t.onmessage = (m) => received.push(m); + await t.start(); + await tick(); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ id: 1 }); + }); + + it("delivers fetch_request events (string timestamp coerced to Date)", async () => { + const ts = new Date().toISOString(); + const entry = { + id: "f1", + url: "http://x", + method: "GET", + timestamp: ts, + }; + const onFetchRequest = vi.fn(); + const t = makeTransport( + { + events: () => + sseResponse([ + sseFrame({ + type: "fetch_request", + data: entry as never, + }), + ]), + }, + { onFetchRequest }, + ); + await t.start(); + await tick(); + expect(onFetchRequest).toHaveBeenCalledTimes(1); + const arg = onFetchRequest.mock.calls[0]![0] as { timestamp: Date }; + expect(arg.timestamp).toBeInstanceOf(Date); + }); + + it("delivers fetch_request events keeping a Date timestamp as-is", async () => { + const onFetchRequest = vi.fn(); + // A numeric/Date timestamp triggers the non-string branch. + const entry = { id: "f2", url: "u", method: "POST", timestamp: 123 }; + const t = makeTransport( + { + events: () => + sseResponse([ + sseFrame({ type: "fetch_request", data: entry as never }), + ]), + }, + { onFetchRequest }, + ); + await t.start(); + await tick(); + const arg = onFetchRequest.mock.calls[0]![0] as { timestamp: unknown }; + expect(arg.timestamp).toBe(123); + }); + + it("delivers fetch_request_body_update events", async () => { + const onFetchResponseBody = vi.fn(); + const t = makeTransport( + { + events: () => + sseResponse([ + sseFrame({ + type: "fetch_request_body_update", + data: { id: "f1", responseBody: "BODY" }, + }), + ]), + }, + { onFetchResponseBody }, + ); + await t.start(); + await tick(); + expect(onFetchResponseBody).toHaveBeenCalledWith("f1", "BODY"); + }); + + it("delivers stdio_log events to onStderr", async () => { + const onStderr = vi.fn(); + const t = makeTransport( + { + events: () => + sseResponse([ + sseFrame({ + type: "stdio_log", + data: { + timestamp: new Date().toISOString(), + message: "stderr line", + }, + }), + ]), + }, + { onStderr }, + ); + await t.start(); + await tick(); + expect(onStderr).toHaveBeenCalledTimes(1); + expect(onStderr.mock.calls[0]![0].message).toBe("stderr line"); + }); + + it("handles transport_error events: onerror + onclose, with code", async () => { + const onerror = vi.fn(); + const onclose = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "transport_error", + data: { error: "boom", code: 1006 }, + }), + ]), + }); + t.onerror = onerror; + t.onclose = onclose; + await t.start(); + await tick(); + expect(onerror).toHaveBeenCalledTimes(1); + const err = onerror.mock.calls[0]![0] as { + message: string; + code?: number; + }; + expect(err.message).toBe("boom"); + expect(err.code).toBe(1006); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it("handles transport_error events without a code", async () => { + const onerror = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "transport_error", data: { error: "no-code" } }), + ]), + }); + t.onerror = onerror; + await t.start(); + await tick(); + const err = onerror.mock.calls[0]![0] as { code?: number }; + expect(err.code).toBeUndefined(); + }); + + it("reports malformed SSE JSON via onerror but keeps consuming", async () => { + const onerror = vi.fn(); + const msg: JSONRPCMessage = { jsonrpc: "2.0", id: 7, result: {} }; + const t = makeTransport({ + events: () => + // First frame is invalid JSON (parse error), second is a valid message. + sseResponse([ + `data: {not json}\n\n`, + sseFrame({ type: "message", data: msg }), + ]), + }); + const received: JSONRPCMessage[] = []; + t.onerror = onerror; + t.onmessage = (m) => received.push(m); + await t.start(); + await tick(); + expect(onerror).toHaveBeenCalled(); + // Consumption continued: the valid message after the bad frame arrived. + expect(received).toHaveLength(1); + }); + + it("flushes a trailing SSE frame with no terminating blank line", async () => { + const msg: JSONRPCMessage = { jsonrpc: "2.0", id: 9, result: {} }; + // No trailing \n\n — exercises the post-loop buffer-flush path in parseSSE. + const frame = `event: message\ndata: ${JSON.stringify({ + type: "message", + data: msg, + })}`; + const t = makeTransport({ + events: () => sseResponse([frame]), + }); + const received: JSONRPCMessage[] = []; + t.onmessage = (m) => received.push(m); + await t.start(); + await tick(); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ id: 9 }); + }); + + it("handles a trailing buffer whose final unterminated line is an event:", async () => { + // The stream ends mid-frame on an `event:` line (no terminating blank + // line and no data), exercising the event-line branch of parseSSE's + // post-loop buffer flush. Nothing is yielded (no data), but the branch + // runs without error. + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => sseResponse([`data: ignored\n\nevent: trailing`]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + // event-only trailing frame yields nothing + expect(onmessage).not.toHaveBeenCalled(); + }); + + it("stops delivering once the transport is closed mid-stream", async () => { + // Two frames arrive; closing the transport from the first onmessage + // handler makes the `if (this.closed) break` guard fire before the + // second frame is delivered. + const m1: JSONRPCMessage = { jsonrpc: "2.0", id: 1, result: {} }; + const m2: JSONRPCMessage = { jsonrpc: "2.0", id: 2, result: {} }; + const received: JSONRPCMessage[] = []; + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "message", data: m1 }), + sseFrame({ type: "message", data: m2 }), + ]), + }); + t.onmessage = (m) => { + received.push(m); + // Close after the first message; the loop's closed-check breaks before + // the second frame is processed. + void t.close(); + }; + await t.start(); + await tick(); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ id: 1 }); + }); + + it("coerces a non-Error thrown during event processing into an Error", async () => { + // onmessage throws a string (not an Error); the per-frame catch coerces + // it via `err instanceof Error ? err : new Error(String(err))`. + const onerror = vi.fn(); + const m: JSONRPCMessage = { jsonrpc: "2.0", id: 3, result: {} }; + const t = makeTransport({ + events: () => sseResponse([sseFrame({ type: "message", data: m })]), + }); + t.onmessage = () => { + throw "string failure"; + }; + t.onerror = onerror; + await t.start(); + await tick(); + expect(onerror).toHaveBeenCalled(); + const err = onerror.mock.calls[0]![0] as Error; + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("string failure"); + }); + + it("reports a stream read error via onerror (non-abort)", async () => { + const onerror = vi.fn(); + const onclose = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + controller.error(new Error("stream blew up")); + }, + }); + const t = makeTransport({ + events: () => new Response(stream, { status: 200 }), + }); + t.onerror = onerror; + t.onclose = onclose; + await t.start(); + await tick(); + expect(onerror).toHaveBeenCalled(); + expect(onerror.mock.calls[0]![0].message).toMatch(/stream blew up/); + // finally{} closes the transport. + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it("closes via finally when the stream ends without error", async () => { + const onclose = vi.fn(); + const t = makeTransport({ events: () => sseResponse([]) }); + t.onclose = onclose; + await t.start(); + await tick(); + expect(onclose).toHaveBeenCalledTimes(1); + }); + }); + + describe("send()", () => { + it("throws when not started", async () => { + const t = makeTransport({}); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "ping" }), + ).rejects.toThrow(/not started/); + }); + + it("throws closed when send() runs after a transport_error closed it", async () => { + // close() clears _sessionId so send() would hit the "not started" guard + // first. The transport_error event path sets closed=true while LEAVING + // _sessionId populated, which is the only way to reach send()'s + // "Transport is closed" guard. + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "transport_error", data: { error: "died" } }), + ]), + }); + await t.start(); + await tick(); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "ping" }), + ).rejects.toThrow(/Transport is closed/); + }); + + it("posts a message including relatedRequestId and succeeds", async () => { + let sentBody: { relatedRequestId?: unknown } | undefined; + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/connect")) return jsonResponse({ sessionId: "s" }); + if (url.includes("/events")) + return new Response( + new ReadableStream({ start() {} }), + { status: 200 }, + ); + if (url.includes("/send")) { + sentBody = JSON.parse(init!.body as string); + return jsonResponse({ ok: true }); + } + return jsonResponse({ ok: true }); + }, + ); + const t = new RemoteClientTransport( + { + baseUrl: "http://remote.test", + fetchFn: fetchFn as unknown as typeof fetch, + }, + CONFIG, + ); + await t.start(); + await t.send( + { jsonrpc: "2.0", id: 5, method: "ping" }, + { relatedRequestId: 42 }, + ); + expect(sentBody?.relatedRequestId).toBe(42); + await t.close(); + }); + + it("throws Remote send failed with status on non-OK send", async () => { + const t = makeTransport({ + events: () => + new Response(new ReadableStream({ start() {} }), { + status: 200, + }), + send: () => new Response("err", { status: 503 }), + }); + await t.start(); + try { + await t.send({ jsonrpc: "2.0", id: 1, method: "ping" }); + throw new Error("expected send to throw"); + } catch (e) { + expect((e as Error).message).toMatch(/Remote send failed \(503\)/); + expect((e as { status?: number }).status).toBe(503); + } + await t.close(); + }); + }); + + describe("close()", () => { + it("is idempotent and swallows disconnect errors", async () => { + const onclose = vi.fn(); + const t = makeTransport({ + events: () => + new Response(new ReadableStream({ start() {} }), { + status: 200, + }), + disconnect: () => { + throw new Error("disconnect failed"); + }, + }); + t.onclose = onclose; + await t.start(); + await t.close(); // disconnect throws but is swallowed; onclose fires + await t.close(); // second close is a no-op (early return) + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it("close() before start() returns early without calling disconnect", async () => { + const disconnect = vi.fn(() => jsonResponse({ ok: true })); + const t = makeTransport({ disconnect }); + await t.close(); + // No sessionId yet → disconnect branch is skipped, but onclose still fires. + expect(disconnect).not.toHaveBeenCalled(); + }); + }); + + describe("sessionId getter", () => { + it("always returns undefined (intentional)", () => { + const t = makeTransport({}); + expect(t.sessionId).toBeUndefined(); + }); + }); + + describe("headers/fetchFn defaults", () => { + it("omits auth header when no authToken is configured", async () => { + const seen: Array> = []; + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + seen.push((init?.headers as Record) ?? {}); + if (url.includes("/connect")) return jsonResponse({ sessionId: "s" }); + return sseResponse([]); + }, + ); + const t = new RemoteClientTransport( + { + baseUrl: "http://remote.test", + fetchFn: fetchFn as unknown as typeof fetch, + }, + CONFIG, + ); + await t.start(); + await t.close(); + // connect headers should not include the auth header + expect(seen[0]!["x-mcp-remote-auth"]).toBeUndefined(); + }); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts new file mode 100644 index 00000000..b523a913 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -0,0 +1,791 @@ +/** + * Supplemental coverage for createRemoteApp (core/mcp/remote/node/server.ts). + * + * Targets the remaining uncovered branches the broader suites don't exercise: + * - forwardLogEvent message/binding shape variants (/api/log) + * - validateSettings per-field rejection branches + * - /api/fetch headers / streaming content-type / network-error paths + * - normalizeMcpServers malformed-field drops, on both the logger and the + * console.warn fallback path + * - generic (non-keychain) 500 catch on POST / PUT / DELETE / order + * - keychain-unavailable migration with a logger (fileLogger.warn branch) + * - GET fast-path re-check when a concurrent write removed the plaintext + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import type pinoType from "pino"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { + InMemorySecretStore, + KeychainUnavailableError, + type SecretStore, +} from "@inspector/core/auth/node/secret-store.js"; + +interface Harness { + baseUrl: string; + server: ServerType; + configPath: string; + tempDir: string; +} + +interface StartOpts { + secretStore?: SecretStore; + logger?: pinoType.Logger; + seedConfig?: string; +} + +async function start(opts: StartOpts = {}): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "inspector-server-extra-")); + const configPath = join(tempDir, "mcp.json"); + if (opts.seedConfig !== undefined) { + writeFileSync(configPath, opts.seedConfig); + } + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + mcpConfigPath: configPath, + initialConfig: { defaultEnvironment: {} }, + secretStore: opts.secretStore ?? new InMemorySecretStore(), + logger: opts.logger, + }); + const { baseUrl, server } = await new Promise<{ + baseUrl: string; + server: ServerType; + }>((resolve, reject) => { + const s = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server: s }); + }, + ); + s.on("error", reject); + }); + return { baseUrl, server, configPath, tempDir }; +} + +async function stop(h: Harness): Promise { + await new Promise((r) => h.server.close(() => r())); + try { + rmSync(h.tempDir, { recursive: true }); + } catch { + /* ignore */ + } +} + +/** + * Minimal in-memory pino-shaped logger that captures forwarded records so a + * test can assert which level/object/message the server emitted. + */ +function makeCapturingLogger(): { + logger: pinoType.Logger; + records: Array<{ level: string; args: unknown[] }>; +} { + const records: Array<{ level: string; args: unknown[] }> = []; + const mk = + (level: string) => + (...args: unknown[]) => { + records.push({ level, args }); + }; + // Only the methods the server reaches are needed; cast through unknown to + // satisfy the pino.Logger surface without pulling in the full type. + const logger = { + info: mk("info"), + warn: mk("warn"), + error: mk("error"), + debug: mk("debug"), + trace: mk("trace"), + fatal: mk("fatal"), + } as unknown as pinoType.Logger; + return { logger, records }; +} + +describe("server.ts supplemental coverage", () => { + describe("/api/log forwardLogEvent shapes", () => { + let h: Harness; + let records: Array<{ level: string; args: unknown[] }>; + + beforeEach(async () => { + const cap = makeCapturingLogger(); + records = cap.records; + h = await start({ logger: cap.logger }); + }); + afterEach(async () => { + await stop(h); + }); + + async function postLog(body: unknown): Promise { + return fetch(`${h.baseUrl}/api/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("forwards an event whose first message is an object (obj + msg + args)", async () => { + const res = await postLog({ + level: { label: "warn", value: 40 }, + bindings: [{ component: "x" }, { category: "y" }], + messages: [{ extra: 1 }, "hello", "a", "b"], + }); + expect(res.status).toBe(200); + const rec = records.find((r) => r.level === "warn"); + expect(rec).toBeDefined(); + expect(rec!.args[0]).toMatchObject({ + component: "x", + category: "y", + extra: 1, + }); + expect(rec!.args[1]).toBe("hello"); + expect(rec!.args.slice(2)).toEqual(["a", "b"]); + }); + + it("forwards an event with no messages (bindings-only object)", async () => { + const res = await postLog({ + level: { label: "info", value: 30 }, + bindings: [{ a: 1 }], + messages: [], + }); + expect(res.status).toBe(200); + const rec = records.find((r) => r.level === "info"); + expect(rec).toBeDefined(); + expect(rec!.args).toEqual([{ a: 1 }]); + }); + + it("forwards a string-first message (bindings + msg + args)", async () => { + const res = await postLog({ + level: { label: "info", value: 30 }, + bindings: [{ b: 2 }], + messages: ["just a string", 1, 2], + }); + expect(res.status).toBe(200); + const rec = records.find( + (r) => r.level === "info" && r.args[1] === "just a string", + ); + expect(rec).toBeDefined(); + expect(rec!.args[0]).toMatchObject({ b: 2 }); + expect(rec!.args.slice(2)).toEqual([1, 2]); + }); + + it("defaults the level to info and tolerates non-array bindings/messages", async () => { + const res = await postLog({ bindings: "nope", messages: "nope" }); + expect(res.status).toBe(200); + const rec = records.find((r) => r.level === "info"); + expect(rec).toBeDefined(); + // bindings non-array → {} ; messages non-array → [] → bindings-only call + expect(rec!.args).toEqual([{}]); + }); + + it("drops an event whose level has no matching logger method", async () => { + const res = await postLog({ + level: { label: "nonsense", value: 1 }, + messages: ["x"], + }); + expect(res.status).toBe(200); + // No record captured for an unknown level. + expect(records.length).toBe(0); + }); + }); + + describe("/api/log without a logger is a no-op", () => { + it("returns 200 and swallows the event", async () => { + const h = await start(); + try { + const res = await fetch(`${h.baseUrl}/api/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: ["x"] }), + }); + expect(res.status).toBe(200); + // Invalid JSON also tolerated (catch → {}). + const res2 = await fetch(`${h.baseUrl}/api/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res2.status).toBe(200); + } finally { + await stop(h); + } + }); + }); + + describe("/api/fetch", () => { + let h: Harness; + let target: ServerType; + let targetUrl: string; + + beforeEach(async () => { + h = await start(); + // A tiny upstream HTTP server we can point /api/fetch at. + const { createServer } = await import("node:http"); + const srv = createServer((req, res) => { + if (req.url === "/stream") { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("data: hi\n\n"); + res.end(); + return; + } + if (req.url === "/echo-headers") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ got: req.headers["x-probe"] ?? null })); + return; + } + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("plain body"); + }); + await new Promise((resolve) => + srv.listen(0, "127.0.0.1", () => resolve()), + ); + target = srv as unknown as ServerType; + const addr = srv.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + targetUrl = `http://127.0.0.1:${port}`; + }); + + afterEach(async () => { + await new Promise((r) => + (target as unknown as { close: (cb: () => void) => void }).close(() => + r(), + ), + ); + await stop(h); + }); + + it("forwards method + headers and returns the response body", async () => { + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: `${targetUrl}/echo-headers`, + method: "GET", + headers: { "x-probe": "abc" }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok: boolean; + status: number; + headers: Record; + body?: string; + }; + expect(body.ok).toBe(true); + expect(body.status).toBe(200); + expect(body.headers["content-type"]).toContain("application/json"); + expect(JSON.parse(body.body!)).toEqual({ got: "abc" }); + }); + + it("omits the body for an event-stream content type", async () => { + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: `${targetUrl}/stream` }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { body?: string; status: number }; + expect(body.status).toBe(200); + expect(body.body).toBeUndefined(); + }); + + it("returns 400 when url is missing", async () => { + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "GET" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 on invalid JSON", async () => { + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{bad", + }); + expect(res.status).toBe(400); + }); + + it("returns 500 when the upstream fetch throws", async () => { + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://127.0.0.1:1/nope" }), + }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(typeof body.error).toBe("string"); + }); + }); + + describe("validateSettings rejection branches (POST /api/servers)", () => { + let h: Harness; + beforeEach(async () => { + h = await start(); + }); + afterEach(async () => { + await stop(h); + }); + + async function postSettings(settings: unknown): Promise { + return fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "srv", + config: { type: "stdio", command: "node" }, + settings, + }), + }); + } + + const base = { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + }; + + it("rejects a non-object settings value", async () => { + const res = await postSettings([1, 2, 3]); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/must be an object/); + }); + + it("rejects malformed headers", async () => { + const res = await postSettings({ ...base, headers: "oops" }); + expect((await res.json()).error).toMatch(/headers/); + }); + + it("rejects malformed metadata", async () => { + const res = await postSettings({ + ...base, + metadata: [{ key: 1, value: "x" }], + }); + expect((await res.json()).error).toMatch(/metadata/); + }); + + it("rejects malformed env", async () => { + const res = await postSettings({ ...base, env: "oops" }); + expect((await res.json()).error).toMatch(/env/); + }); + + it("rejects a non-string cwd", async () => { + const res = await postSettings({ ...base, cwd: 42 }); + expect((await res.json()).error).toMatch(/cwd/); + }); + + it("rejects a negative connectionTimeout", async () => { + const res = await postSettings({ ...base, connectionTimeout: -1 }); + expect((await res.json()).error).toMatch(/connectionTimeout/); + }); + + it("rejects a non-numeric requestTimeout", async () => { + const res = await postSettings({ ...base, requestTimeout: "x" }); + expect((await res.json()).error).toMatch(/requestTimeout/); + }); + + it("rejects a negative taskTtl", async () => { + const res = await postSettings({ ...base, taskTtl: -5 }); + expect((await res.json()).error).toMatch(/taskTtl/); + }); + + it("rejects a non-boolean autoRefreshOnListChanged", async () => { + const res = await postSettings({ + ...base, + autoRefreshOnListChanged: "yes", + }); + expect((await res.json()).error).toMatch(/autoRefreshOnListChanged/); + }); + + it("rejects a negative maxFetchRequests", async () => { + const res = await postSettings({ ...base, maxFetchRequests: -2 }); + expect((await res.json()).error).toMatch(/maxFetchRequests/); + }); + + it("rejects a non-string OAuth field", async () => { + const res = await postSettings({ ...base, oauthClientId: 7 }); + expect((await res.json()).error).toMatch(/oauthClientId/); + }); + + it("rejects a non-boolean enterpriseManaged", async () => { + const res = await postSettings({ ...base, enterpriseManaged: "yes" }); + expect((await res.json()).error).toMatch(/enterpriseManaged/); + }); + + it("rejects malformed roots", async () => { + const res = await postSettings({ ...base, roots: [{ uri: 1 }] }); + expect((await res.json()).error).toMatch(/roots/); + }); + + it("accepts a fully-populated valid settings payload", async () => { + const res = await postSettings({ + ...base, + env: [{ key: "K", value: "V" }], + cwd: "/tmp", + taskTtl: 1000, + autoRefreshOnListChanged: true, + maxFetchRequests: 5, + oauthClientId: "cid", + oauthScopes: "a b", + enterpriseManaged: true, + roots: [{ uri: "file:///x", name: "x" }], + }); + expect(res.status).toBe(200); + }); + }); + + describe("normalizeMcpServers malformed-field drops (console fallback, no logger)", () => { + let warnSpy: ReturnType; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("drops a legacy settings node and every malformed field, logging to console.warn", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + // non-object entry → skipped + bad: 5, + srv: { + type: "http", + url: "https://x.test/mcp", + settings: { legacy: true }, + // object with a non-string value → isStringRecord inner branch + headers: { Authorization: 123 }, + metadata: [{ key: 1 }], + connectionTimeout: Infinity, + requestTimeout: -1, + taskTtl: "x", + maxFetchRequests: -1, + // enterpriseManaged non-boolean → isOauthObject inner branch + oauth: { enterpriseManaged: "yes" }, + // a non-string `name` → isRootArray inner branch + roots: [{ uri: "file:///ok", name: 5 }], + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + mcpServers: Record>; + }; + const srv = body.mcpServers.srv; + expect(srv).toBeDefined(); + // type:"http" normalized to streamable-http + expect(srv.type).toBe("streamable-http"); + // Every malformed field dropped. + for (const k of [ + "settings", + "headers", + "metadata", + "connectionTimeout", + "requestTimeout", + "taskTtl", + "maxFetchRequests", + "oauth", + "roots", + ]) { + expect(srv).not.toHaveProperty(k); + } + expect(body.mcpServers.bad).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + } finally { + await stop(h); + } + }); + }); + + describe("normalizeMcpServers additional validator inner branches", () => { + let warnSpy: ReturnType; + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("drops oauth with a non-string clientId and roots with a null entry", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + // clientId non-string → isOauthObject clientId branch + oauth: { clientId: 5 }, + // a null entry → isRootArray `e === null` branch + roots: [null], + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + mcpServers: Record>; + }; + expect(body.mcpServers.srv).not.toHaveProperty("oauth"); + expect(body.mcpServers.srv).not.toHaveProperty("roots"); + } finally { + await stop(h); + } + }); + }); + + describe("plaintext-secret migration over a mixed config", () => { + it("migrates servers with secrets and passes through servers without", async () => { + // With an available keychain, the GET slow path migrates plaintext into + // the store. A mixed config (one server with a secret, one without) + // exercises both the migrate branch and the no-secret continue branch. + const store = new InMemorySecretStore(); + const h = await start({ + secretStore: store, + seedConfig: JSON.stringify({ + mcpServers: { + plain: { type: "stdio", command: "node" }, + withSecret: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "shh" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + mcpServers: Record>; + }; + // Both entries present; the secret value is rehydrated from keychain. + expect(body.mcpServers.plain).toBeDefined(); + expect(body.mcpServers.withSecret).toBeDefined(); + // Disk no longer holds the plaintext clientSecret (migrated out). + const onDisk = readFileSync(h.configPath, "utf-8"); + expect(onDisk).not.toContain("shh"); + } finally { + await stop(h); + } + }); + }); + + describe("normalizeMcpServers malformed-field drops (logger path)", () => { + it("routes the drop warnings through fileLogger.warn", async () => { + const cap = makeCapturingLogger(); + const h = await start({ + logger: cap.logger, + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "stdio", + command: "node", + headers: 123, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + const warned = cap.records.filter((r) => r.level === "warn"); + expect(warned.length).toBeGreaterThan(0); + } finally { + await stop(h); + } + }); + }); + + describe("generic 500 catch on mutating routes", () => { + // A secret store whose deleteAllForServer throws a plain (non-keychain) + // error drives the generic 500 catch on POST and DELETE; the writeKeychain + // path covers PUT. + class ThrowingSecretStore implements SecretStore { + async get(): Promise { + return null; + } + async set(): Promise { + throw new Error("boom-set"); + } + async delete(): Promise { + /* no-op */ + } + async deleteAllForServer(): Promise { + throw new Error("boom-sweep"); + } + } + + it("POST returns 500 when the keychain sweep throws a generic error", async () => { + const h = await start({ secretStore: new ThrowingSecretStore() }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "srv", + config: { type: "stdio", command: "node" }, + }), + }); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to add server/); + } finally { + await stop(h); + } + }); + + it("DELETE returns 500 when the keychain sweep throws a generic error", async () => { + const h = await start({ + secretStore: new ThrowingSecretStore(), + seedConfig: JSON.stringify({ + mcpServers: { srv: { type: "stdio", command: "node" } }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers/srv`, { + method: "DELETE", + }); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to delete server/); + } finally { + await stop(h); + } + }); + + it("PUT returns 500 when the keychain write throws a generic error", async () => { + const h = await start({ + secretStore: new ThrowingSecretStore(), + seedConfig: JSON.stringify({ + mcpServers: { + srv: { type: "streamable-http", url: "https://x.test/mcp" }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers/srv`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthClientSecret: "shh", + }, + }), + }); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to update server/); + } finally { + await stop(h); + } + }); + + it("order returns 500 when the disk write throws", async () => { + // Point the config at a path whose parent is a file, so the atomic + // write fails with ENOTDIR — a generic error surfaced as 500. + const tempDir = mkdtempSync(join(tmpdir(), "inspector-order-500-")); + const filePath = join(tempDir, "afile"); + writeFileSync(filePath, "x"); + const badConfigPath = join(filePath, "mcp.json"); + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + mcpConfigPath: badConfigPath, + initialConfig: { defaultEnvironment: {} }, + secretStore: new InMemorySecretStore(), + }); + const { baseUrl, server } = await new Promise<{ + baseUrl: string; + server: ServerType; + }>((resolve, reject) => { + const s = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server: s }); + }, + ); + s.on("error", reject); + }); + try { + // readMcpConfig sees ENOENT (parent is a file) → empty list, so an + // empty order set-matches and we proceed to the failing write. + const res = await fetch(`${baseUrl}/api/servers/order`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ order: [] }), + }); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to reorder servers/); + } finally { + await new Promise((r) => server.close(() => r())); + rmSync(tempDir, { recursive: true }); + } + }); + }); + + describe("keychain-unavailable migration with a logger", () => { + class UnavailableStore implements SecretStore { + async get(): Promise { + return null; + } + async set(): Promise { + throw new KeychainUnavailableError(new Error("libsecret missing")); + } + async delete(): Promise { + /* no-op */ + } + async deleteAllForServer(): Promise { + /* no-op */ + } + } + + it("warns via the logger and preserves on-disk plaintext", async () => { + const cap = makeCapturingLogger(); + const h = await start({ + secretStore: new UnavailableStore(), + logger: cap.logger, + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "plaintext" }, + }, + }, + }), + }); + try { + const before = readFileSync(h.configPath, "utf-8"); + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + // Disk plaintext preserved (migration abandoned). + expect(readFileSync(h.configPath, "utf-8")).toBe(before); + const warned = cap.records.filter( + (r) => + r.level === "warn" && + JSON.stringify(r.args).includes("Keychain unavailable"), + ); + expect(warned.length).toBeGreaterThan(0); + } finally { + await stop(h); + } + }); + }); +}); diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 33455b32..efd01fd6 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -159,6 +159,34 @@ describe("createSandboxController", () => { } }); + it("resolves with empty values + logs generically when listen fails with a non-EADDRINUSE error", async () => { + // An unresolvable bind host makes Node emit an `error` whose `code` is + // not EADDRINUSE (ENOTFOUND/EADDRNOTAVAIL depending on platform). This + // drives the non-EADDRINUSE branch of the error handler: a generic + // "Sandbox server error" log plus the same resolve-with-empty contract. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const controller = createSandboxController({ + port: 0, + host: "256.256.256.256", + }); + try { + const result = await controller.start(); + expect(result).toEqual({ port: 0, url: "" }); + expect(controller.getUrl()).toBeNull(); + await expect(controller.close()).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + "Sandbox server error:", + expect.objectContaining({ code: expect.any(String) }), + ); + // It must NOT have taken the EADDRINUSE branch. + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining("in use"), + ); + } finally { + errorSpy.mockRestore(); + } + }); + it("resolves with empty values when listen fails (EADDRINUSE)", async () => { // Bind a placeholder HTTP server to claim a port, then point a sandbox // controller at the same port to force EADDRINUSE. The Vite plugin awaits @@ -191,4 +219,38 @@ describe("createSandboxController", () => { await new Promise((resolve) => blocker.close(() => resolve())); } }); + + it("serves a fallback page when the sandbox HTML file can't be read", async () => { + // The static `sandbox_proxy.html` always ships, so the only way to reach + // the read-failure fallback is to make `readFileSync` throw. Mock + // `node:fs` for an isolated module instance so the rest of the suite keeps + // the real fs. + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + readFileSync: () => { + throw new Error("disk gone"); + }, + }; + }); + try { + const mod = await import("../../../../server/sandbox-controller.js"); + const controller = mod.createSandboxController({ port: 0 }); + try { + const { port } = await controller.start(); + const res = await fetch(`http://localhost:${port}/sandbox`); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("Sandbox not loaded"); + expect(body).toContain("disk gone"); + } finally { + await controller.close(); + } + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + } + }); }); diff --git a/clients/web/src/test/integration/storage/store-io.test.ts b/clients/web/src/test/integration/storage/store-io.test.ts new file mode 100644 index 00000000..d2c88596 --- /dev/null +++ b/clients/web/src/test/integration/storage/store-io.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for shared storage path resolution and atomic file I/O (store-io.ts). + * Node-only fs code -> lives in the integration project (node env). + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { + mkdtempSync, + rmSync, + existsSync, + readFileSync, + writeFileSync, + chmodSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getDefaultStorageDir, + getDefaultMcpConfigPath, + getStoreFilePath, + readStoreFile, + writeStoreFile, + flushStoreFileWrites, + deleteStoreFile, + serializeStore, + parseStore, +} from "@inspector/core/storage/store-io.js"; + +describe("store-io", () => { + describe("getDefaultStorageDir / getDefaultMcpConfigPath", () => { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + }); + + it("uses HOME when set", () => { + process.env.HOME = "/home/alice"; + delete process.env.USERPROFILE; + expect(getDefaultStorageDir()).toBe( + join("/home/alice", ".mcp-inspector", "storage"), + ); + expect(getDefaultMcpConfigPath()).toBe( + join("/home/alice", ".mcp-inspector", "mcp.json"), + ); + }); + + it("falls back to USERPROFILE when HOME is unset (Windows)", () => { + delete process.env.HOME; + process.env.USERPROFILE = "C:\\Users\\bob"; + expect(getDefaultStorageDir()).toBe( + join("C:\\Users\\bob", ".mcp-inspector", "storage"), + ); + expect(getDefaultMcpConfigPath()).toBe( + join("C:\\Users\\bob", ".mcp-inspector", "mcp.json"), + ); + }); + + it('falls back to "." when neither HOME nor USERPROFILE is set', () => { + delete process.env.HOME; + delete process.env.USERPROFILE; + expect(getDefaultStorageDir()).toBe( + join(".", ".mcp-inspector", "storage"), + ); + expect(getDefaultMcpConfigPath()).toBe( + join(".", ".mcp-inspector", "mcp.json"), + ); + }); + }); + + describe("getStoreFilePath", () => { + it("joins the storage dir with the store id and .json", () => { + expect(getStoreFilePath("/dir", "myStore")).toBe( + join("/dir", "myStore.json"), + ); + }); + }); + + describe("readStoreFile", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("returns the file contents when the file exists", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-readstore-")); + const filePath = join(tempDir, "store.json"); + writeFileSync(filePath, '{"a":1}', "utf-8"); + expect(await readStoreFile(filePath)).toBe('{"a":1}'); + }); + + it("returns null when the file does not exist (ENOENT)", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-readstore-")); + const filePath = join(tempDir, "missing.json"); + expect(await readStoreFile(filePath)).toBeNull(); + }); + + it("throws on a non-ENOENT read error (e.g. reading a directory)", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-readstore-")); + // Reading a directory as a file yields EISDIR, not ENOENT. + await expect(readStoreFile(tempDir)).rejects.toThrow(); + }); + }); + + describe("writeStoreFile / flushStoreFileWrites", () => { + let tempDir: string | null = null; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-writestore-")); + }); + + afterEach(() => { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("writes data atomically and creates parent directories", async () => { + const filePath = join(tempDir!, "nested", "deep", "store.json"); + await writeStoreFile(filePath, '{"hi":1}'); + expect(existsSync(filePath)).toBe(true); + expect(readFileSync(filePath, "utf-8")).toBe('{"hi":1}'); + }); + + it("chains concurrent writes to the same path (last write wins)", async () => { + const filePath = join(tempDir!, "store.json"); + const a = writeStoreFile(filePath, '{"v":1}'); + const b = writeStoreFile(filePath, '{"v":2}'); + await Promise.all([a, b]); + await flushStoreFileWrites(filePath); + expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({ v: 2 }); + }); + + it("flushStoreFileWrites(path) awaits a specific in-flight write", async () => { + const filePath = join(tempDir!, "store.json"); + const write = writeStoreFile(filePath, '{"flush":true}'); + await flushStoreFileWrites(filePath); + expect(existsSync(filePath)).toBe(true); + await write; + }); + + it("flushStoreFileWrites() awaits all in-flight writes", async () => { + const a = join(tempDir!, "a.json"); + const b = join(tempDir!, "b.json"); + const writes = Promise.all([ + writeStoreFile(a, '{"n":1}'), + writeStoreFile(b, '{"n":2}'), + ]); + await flushStoreFileWrites(); + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + await writes; + }); + + it("resolves immediately when nothing is in flight", async () => { + await expect(flushStoreFileWrites()).resolves.toBeUndefined(); + await expect( + flushStoreFileWrites(join(tempDir!, "none.json")), + ).resolves.toBeUndefined(); + }); + + it("swallows a prior write's rejection so a chained write still runs (.catch branch)", async () => { + // First write targets a path under a *file* (not a dir), so mkdir fails + // and the write rejects. The second write to the same resolved key is + // chained via `(prior ?? Promise.resolve()).catch(() => {})`, so the + // rejection is swallowed and the second write proceeds. + const blocker = join(tempDir!, "blocker"); + writeFileSync(blocker, "x", "utf-8"); + const badPath = join(blocker, "store.json"); // parent is a file -> ENOTDIR + + const first = writeStoreFile(badPath, '{"v":1}'); + await expect(first).rejects.toThrow(); + + // A second write to the same key after the first settled still works once + // the blocker is removed. + rmSync(blocker); + const second = writeStoreFile(badPath, '{"v":2}'); + await expect(second).resolves.toBeUndefined(); + expect(JSON.parse(readFileSync(badPath, "utf-8"))).toEqual({ v: 2 }); + }); + + it("a chained write swallows the prior in-flight write's rejection", async () => { + // Queue two writes to the same key while the first is still in flight and + // destined to reject (parent is a file). The second's `.catch(() => {})` + // on the chained prior promise must absorb the rejection. + const blocker = join(tempDir!, "blocker2"); + writeFileSync(blocker, "x", "utf-8"); + const badPath = join(blocker, "store.json"); + + const first = writeStoreFile(badPath, '{"v":1}'); + const second = writeStoreFile(badPath, '{"v":2}'); + await expect(first).rejects.toThrow(); + // Second also rejects (blocker still present) but does NOT reject with the + // first's error unhandled — the chain swallowed it. + await expect(second).rejects.toThrow(); + }); + + it("leaves a newer write's entry in place when an earlier write settles (finally branch)", async () => { + const filePath = join(tempDir!, "store.json"); + // Two overlapping writes share a key. When the first settles, its + // finally must NOT delete the map entry, because the second write + // replaced it. Awaiting the second then flushing exercises both the + // "replaced" finally branch (first) and the "delete" branch (second). + const first = writeStoreFile(filePath, '{"order":1}'); + const second = writeStoreFile(filePath, '{"order":2}'); + await first; + await second; + // After the last write settles, the pending entry is cleaned up so a + // subsequent flush is a no-op. + await expect(flushStoreFileWrites(filePath)).resolves.toBeUndefined(); + expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({ order: 2 }); + }); + }); + + describe("deleteStoreFile", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + try { + chmodSync(tempDir, 0o700); + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("deletes an existing file", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-delete-")); + const filePath = join(tempDir, "store.json"); + writeFileSync(filePath, "{}", "utf-8"); + expect(existsSync(filePath)).toBe(true); + await deleteStoreFile(filePath); + expect(existsSync(filePath)).toBe(false); + }); + + it("ignores ENOENT when the file is already gone", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-delete-")); + const filePath = join(tempDir, "missing.json"); + await expect(deleteStoreFile(filePath)).resolves.toBeUndefined(); + }); + + it("rethrows a non-ENOENT error (e.g. unlinking a directory)", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-delete-")); + // Unlinking a directory yields EPERM/EISDIR, not ENOENT. + const dirPath = join(tempDir, "subdir"); + writeFileSync(join(tempDir, "keep"), "x"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(dirPath); + await expect(deleteStoreFile(dirPath)).rejects.toThrow(); + }); + }); + + describe("serializeStore / parseStore", () => { + it("serializes to pretty-printed JSON", () => { + expect(serializeStore({ a: 1, b: [2, 3] })).toBe( + '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}', + ); + }); + + it("parses a JSON string round-trip", () => { + const obj = { x: "y", n: 42 }; + expect(parseStore(serializeStore(obj))).toEqual(obj); + }); + }); +}); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 29cd06f9..5fdf8f89 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -129,6 +129,8 @@ export default defineConfig({ path.join(repoRoot, 'core/mcp/sessionStorage.ts'), path.join(repoRoot, 'core/mcp/inspectorClientProtocol.ts'), path.join(repoRoot, 'core/mcp/remote/types.ts'), + path.join(repoRoot, 'core/mcp/import/types.ts'), + 'clients/web/server/types.ts', // .d.ts files are declaration-only. path.join(repoRoot, '**/*.d.ts'), // inspectorClientEventTarget.ts is types + a single empty-body class @@ -144,10 +146,18 @@ export default defineConfig({ ], thresholds: { perFile: true, + // Uniform 90 per-file gate across every dimension. The branch floor was + // lifted 50 → 70 (#1271) and then the whole gate raised to 90 after a + // codebase-wide coverage audit added real tests for every outlier. + // Genuinely-unreachable branches (Mantine portal / `useMediaQuery` / + // `typeof window` SSR guards, React StrictMode effect replay, and + // provably-dead defensive guards) are annotated with justified + // `/* v8 ignore … */` comments at the source rather than relaxing the + // gate. New code must clear 90 on all four dimensions. lines: 90, - statements: 85, - functions: 80, - branches: 50, + statements: 90, + functions: 90, + branches: 90, }, }, projects: [ diff --git a/core/auth/ema/resourceContext.ts b/core/auth/ema/resourceContext.ts index 25f13c77..d8345d99 100644 --- a/core/auth/ema/resourceContext.ts +++ b/core/auth/ema/resourceContext.ts @@ -37,11 +37,16 @@ export async function discoverEmaResourceContext( undefined, fetchFn, ); + /* v8 ignore start -- defensive: the SDK's discoverOAuthProtectedResourceMetadata + validates the response against a Zod schema that requires `resource` to be a + valid URL, so a metadata object reaching here always has a truthy `resource`. + This guard is unreachable through the public discovery path. */ if (!resourceMetadata.resource) { throw new Error( "EMA requires protected resource metadata with a resource identifier", ); } + /* v8 ignore stop */ const resourceAsUrl = getAuthorizationServerUrl(serverUrl, resourceMetadata); const resourceUrl = await selectResourceURL( serverUrl, @@ -70,7 +75,9 @@ export async function discoverResourceAsMetadata( fetchFn, }); if (!metadata) { - throw new Error("Failed to discover resource authorization server metadata"); + throw new Error( + "Failed to discover resource authorization server metadata", + ); } return metadata; } diff --git a/core/auth/node/oauth-callback-server.ts b/core/auth/node/oauth-callback-server.ts index 43cf8c07..285ca6ee 100644 --- a/core/auth/node/oauth-callback-server.ts +++ b/core/auth/node/oauth-callback-server.ts @@ -93,6 +93,10 @@ export class OAuthCallbackServer { this.server.on("error", reject); this.server.listen(port, hostname, () => { const a = this.server!.address(); + /* v8 ignore next 4 -- defensive: a TCP server that has fired its + `listen` callback always reports an AddressInfo object (never null + and never a string, which only happens for a pipe/unix socket), + so this guard is unreachable from a successful `listen(port, host)`. */ if (!a || typeof a === "string") { reject(new Error("Failed to get server address")); return; @@ -143,7 +147,12 @@ export class OAuthCallbackServer { let search: string; let state: string | undefined; try { - const u = new URL(req.url ?? "", "http://placeholder"); + const u = new URL( + /* v8 ignore next -- defensive: Node always populates req.url for an + HTTP request, so the `?? ""` fallback is never taken. */ + req.url ?? "", + "http://placeholder", + ); pathname = u.pathname; search = u.search; state = u.searchParams.get("state") ?? undefined; diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index d9647449..b145c7aa 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -65,6 +65,7 @@ async function* parseSSE( buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); + /* v8 ignore next -- String.split always returns a non-empty array, so pop() is never undefined; the ?? "" fallback is unreachable. */ buffer = lines.pop() ?? ""; let currentEvent = "message"; @@ -150,6 +151,7 @@ export class RemoteClientTransport implements Transport { } async start(): Promise { + /* v8 ignore next -- the sessionId getter is hardcoded to return undefined (see its doc comment), so this guard is never taken; it exists to satisfy the Transport contract. */ if (this.sessionId) return; if (this.closed) throw new Error("Transport is closed"); @@ -223,6 +225,7 @@ export class RemoteClientTransport implements Transport { } private async consumeEventStream(): Promise { + /* v8 ignore next -- consumeEventStream is only called at the end of start(), immediately after eventStreamReader is assigned from getReader(), so it is always set here. */ if (!this.eventStreamReader) return; try {