Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions clients/web/src/lib/downloadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
111 changes: 111 additions & 0 deletions clients/web/src/lib/environmentFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
98 changes: 98 additions & 0 deletions clients/web/src/lib/types/customHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
5 changes: 1 addition & 4 deletions clients/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'),
Expand Down
Loading