diff --git a/clients/web/src/lib/downloadFile.ts b/clients/web/src/lib/downloadFile.ts index 4ce5057f3..50b8c62d5 100644 --- a/clients/web/src/lib/downloadFile.ts +++ b/clients/web/src/lib/downloadFile.ts @@ -44,6 +44,7 @@ export function downloadJsonFile(filename: string, json: string): void { * nothing usable remains. */ export function fileNameFromUri(uri: string): string { + /* v8 ignore next -- String.prototype.split always returns a non-empty array, so .pop() is never undefined; the `?? ""` fallback is unreachable. */ const tail = uri.split(/[\\/]/).pop() ?? ""; const safe = tail .replace(/[\p{Cc}\p{Cf}]+/gu, "") diff --git a/clients/web/src/lib/environmentFactory.test.ts b/clients/web/src/lib/environmentFactory.test.ts new file mode 100644 index 000000000..742b78d46 --- /dev/null +++ b/clients/web/src/lib/environmentFactory.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the browser `InspectorClientEnvironment` assembly. + * + * The three remote factories are mocked so we can (a) assert the `baseUrl` / + * `authToken` derived from `window.location` are threaded into each, and + * (b) capture the internal `fetchFn` wrapper and invoke it to prove it + * delegates to `globalThis.fetch` (exercising the arrow body that exists to + * preserve the global receiver). `BrowserNavigation` and the OAuth storage + * accessor are the real implementations. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; +import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; + +interface CapturedOptions { + baseUrl: string; + authToken: string | undefined; + fetchFn: typeof fetch; +} + +const captured: { + transport?: CapturedOptions; + fetch?: CapturedOptions; + logger?: CapturedOptions; +} = {}; + +vi.mock("@inspector/core/mcp/remote/index.js", () => ({ + createRemoteTransport: (opts: CapturedOptions) => { + captured.transport = opts; + return { transport: true }; + }, + createRemoteFetch: (opts: CapturedOptions) => { + captured.fetch = opts; + return (async () => new Response("ok")) as unknown as typeof fetch; + }, + createRemoteLogger: (opts: CapturedOptions) => { + captured.logger = opts; + return { info: vi.fn() }; + }, +})); + +import { createWebEnvironment } from "./environmentFactory"; + +const REDIRECT: RedirectUrlProvider = { + getRedirectUrl: () => "http://localhost/callback", +}; + +describe("createWebEnvironment", () => { + beforeEach(() => { + captured.transport = undefined; + captured.fetch = undefined; + captured.logger = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("threads the window.location origin and auth token into every remote factory", () => { + const expectedBaseUrl = `${window.location.protocol}//${window.location.host}`; + const { environment, logger } = createWebEnvironment("tok-123", REDIRECT); + + for (const opts of [captured.transport, captured.fetch, captured.logger]) { + expect(opts?.baseUrl).toBe(expectedBaseUrl); + expect(opts?.authToken).toBe("tok-123"); + } + + // The returned logger is the same instance the factory produced. + expect(logger).toBe(environment.logger); + expect(environment.transport).toEqual({ transport: true }); + expect(typeof environment.fetch).toBe("function"); + }); + + it("passes an undefined auth token straight through", () => { + createWebEnvironment(undefined, REDIRECT); + expect(captured.logger?.authToken).toBeUndefined(); + }); + + it("wraps fetch so the call preserves the global receiver", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("delegated", { status: 200 })); + createWebEnvironment("tok", REDIRECT); + + // Every factory receives the identical wrapper; invoking it must delegate. + const wrapper = captured.transport!.fetchFn; + const res = await wrapper("http://example.test/x"); + expect(spy).toHaveBeenCalledWith("http://example.test/x"); + expect(res.status).toBe(200); + }); + + it("builds a BrowserNavigation and wires the OAuth storage + redirect provider", () => { + const onBeforeRedirect = vi.fn(); + const { environment } = createWebEnvironment( + "tok", + REDIRECT, + onBeforeRedirect, + ); + + const oauth = environment.oauth; + expect(oauth?.navigation).toBeInstanceOf(BrowserNavigation); + expect(oauth?.redirectUrlProvider).toBe(REDIRECT); + expect(oauth?.storage).toBeDefined(); + }); + + it("works without an onBeforeOAuthRedirect callback", () => { + const { environment } = createWebEnvironment(undefined, REDIRECT); + expect(environment.oauth?.navigation).toBeInstanceOf(BrowserNavigation); + }); +}); diff --git a/clients/web/src/lib/types/customHeaders.test.ts b/clients/web/src/lib/types/customHeaders.test.ts new file mode 100644 index 000000000..a12929efe --- /dev/null +++ b/clients/web/src/lib/types/customHeaders.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for the custom-headers value helpers: constructing headers, filtering + * enabled/non-blank ones, converting to/from a plain record, and the legacy + * bearer-token migration. + */ + +import { describe, it, expect } from "vitest"; +import { + createEmptyHeader, + createHeaderFromBearerToken, + getEnabledHeaders, + headersToRecord, + recordToHeaders, + migrateFromLegacyAuth, + type CustomHeaders, +} from "./customHeaders"; + +describe("createEmptyHeader", () => { + it("returns an enabled header with blank name and value", () => { + expect(createEmptyHeader()).toEqual({ + name: "", + value: "", + enabled: true, + }); + }); +}); + +describe("createHeaderFromBearerToken", () => { + it("defaults to an Authorization: Bearer header when no name is given", () => { + expect(createHeaderFromBearerToken("abc")).toEqual({ + name: "Authorization", + value: "Bearer abc", + enabled: true, + }); + }); + + it("prefixes Bearer when the header name is Authorization (any case)", () => { + expect(createHeaderFromBearerToken("abc", "authorization")).toEqual({ + name: "authorization", + value: "Bearer abc", + enabled: true, + }); + }); + + it("uses the raw token for non-Authorization header names", () => { + expect(createHeaderFromBearerToken("abc", "X-Api-Key")).toEqual({ + name: "X-Api-Key", + value: "abc", + enabled: true, + }); + }); +}); + +describe("getEnabledHeaders", () => { + it("keeps only enabled headers with non-blank name and value", () => { + const headers: CustomHeaders = [ + { name: "A", value: "1", enabled: true }, + { name: "B", value: "2", enabled: false }, + { name: "", value: "3", enabled: true }, + { name: "C", value: " ", enabled: true }, + ]; + expect(getEnabledHeaders(headers)).toEqual([ + { name: "A", value: "1", enabled: true }, + ]); + }); +}); + +describe("headersToRecord", () => { + it("trims names/values and drops disabled or blank entries", () => { + const headers: CustomHeaders = [ + { name: " X-One ", value: " a ", enabled: true }, + { name: "X-Two", value: "b", enabled: false }, + ]; + expect(headersToRecord(headers)).toEqual({ "X-One": "a" }); + }); +}); + +describe("recordToHeaders", () => { + it("maps each record entry to an enabled header", () => { + expect(recordToHeaders({ "X-A": "1", "X-B": "2" })).toEqual([ + { name: "X-A", value: "1", enabled: true }, + { name: "X-B", value: "2", enabled: true }, + ]); + }); +}); + +describe("migrateFromLegacyAuth", () => { + it("returns a single header for a bearer token", () => { + expect(migrateFromLegacyAuth("abc", "X-Api-Key")).toEqual([ + { name: "X-Api-Key", value: "abc", enabled: true }, + ]); + }); + + it("returns an empty list when there is no bearer token", () => { + expect(migrateFromLegacyAuth()).toEqual([]); + expect(migrateFromLegacyAuth("")).toEqual([]); + }); +}); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index e251fa751..b69338fe0 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -101,10 +101,7 @@ export default defineConfig(({ command }) => { include: [ 'src/components/**/*.{ts,tsx}', 'src/utils/**/*.{ts,tsx}', - // Pure, fully-tested sandbox CSP builder library. Listed by file (not a - // `src/lib/**` glob) so the untested legacy siblings in src/lib are not - // pulled under the gate before they have tests of their own. - 'src/lib/sandbox-csp.{ts,tsx}', + 'src/lib/**/*.{ts,tsx}', 'clients/web/server/**/*.{ts,tsx}', path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'), path.join(repoRoot, 'core/react/**/*.{ts,tsx}'),