Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion
This commit is contained in:
commit
63250a6c52
12 changed files with 2391 additions and 126 deletions
|
|
@ -69,25 +69,27 @@ type Story = StoryObj<typeof Button>;
|
|||
export const ProjectAnnotationsApplied: Story = {
|
||||
play: async ({ canvasElement, parameters }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("button", { name: "Themed" })).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Themed" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
|
||||
// `MantineProvider` (the `./preview` decorator) injects the theme's CSS
|
||||
// variables onto `:root`. No provider, no variables.
|
||||
expect(root.getPropertyValue("--mantine-primary-color-filled").trim()).toBe(
|
||||
expectedPrimaryColor(),
|
||||
);
|
||||
await expect(
|
||||
root.getPropertyValue("--mantine-primary-color-filled").trim(),
|
||||
).toBe(expectedPrimaryColor());
|
||||
|
||||
// `--inspector-brand-primary` is defined in `App.css`, which only reaches
|
||||
// the story through `./preview`'s stylesheet import — and it resolves
|
||||
// *through* the Mantine variable above, so this covers both layers.
|
||||
expect(root.getPropertyValue("--inspector-brand-primary").trim()).toBe(
|
||||
expectedPrimaryColor(),
|
||||
);
|
||||
await expect(
|
||||
root.getPropertyValue("--inspector-brand-primary").trim(),
|
||||
).toBe(expectedPrimaryColor());
|
||||
|
||||
// The preview `parameters` merged into the story context — the same channel
|
||||
// that carries `a11y: { test: "error" }` to the a11y addon.
|
||||
expect(parameters.a11y).toMatchObject({ test: "error" });
|
||||
await expect(parameters.a11y).toMatchObject({ test: "error" });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -117,12 +117,12 @@ describe("EmaTransportOAuthProvider", () => {
|
|||
expect(await provider.clientInformation()).toEqual({ client_id: "abc" });
|
||||
expect(await provider.codeVerifier()).toBe("verifier-xyz");
|
||||
|
||||
provider.saveClientInformation({ client_id: "new" } as never);
|
||||
await provider.saveClientInformation({ client_id: "new" } as never);
|
||||
expect(inner.saveClientInformation).toHaveBeenCalledWith({
|
||||
client_id: "new",
|
||||
});
|
||||
|
||||
provider.saveCodeVerifier("cv");
|
||||
await provider.saveCodeVerifier("cv");
|
||||
expect(inner.saveCodeVerifier).toHaveBeenCalledWith("cv");
|
||||
});
|
||||
|
||||
|
|
|
|||
394
clients/web/src/test/core/auth/endpointOverrides.test.ts
Normal file
394
clients/web/src/test/core/auth/endpointOverrides.test.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
applyOAuthEndpointOverrides,
|
||||
isAuthorizationServerMetadata,
|
||||
normalizeOAuthEndpointOverrides,
|
||||
oauthEndpointUrlError,
|
||||
withOAuthEndpointOverrides,
|
||||
} from "@inspector/core/auth/endpointOverrides.js";
|
||||
|
||||
const METADATA = {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
const AS_METADATA_URL =
|
||||
"https://as.example.com/.well-known/oauth-authorization-server";
|
||||
const STAGING_AUTHORIZE = "https://staging.example.com/authorize";
|
||||
const STAGING_TOKEN = "https://staging.example.com/token";
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
/** A `fetch` that always resolves to the same prepared response. */
|
||||
function passThrough(response: Response): typeof fetch {
|
||||
return async () => response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("oauthEndpointUrlError", () => {
|
||||
it("accepts a blank value as 'no override'", () => {
|
||||
expect(oauthEndpointUrlError("")).toBeUndefined();
|
||||
expect(oauthEndpointUrlError(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts absolute http and https URLs", () => {
|
||||
expect(oauthEndpointUrlError(STAGING_AUTHORIZE)).toBeUndefined();
|
||||
expect(
|
||||
oauthEndpointUrlError("http://localhost:9000/token"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a relative path", () => {
|
||||
expect(oauthEndpointUrlError("/authorize")).toMatch(/not an absolute URL/);
|
||||
});
|
||||
|
||||
it("rejects a non-http(s) scheme", () => {
|
||||
expect(oauthEndpointUrlError("ftp://example.com/authorize")).toMatch(
|
||||
/not an http\(s\) URL/,
|
||||
);
|
||||
});
|
||||
|
||||
// `new URL` accepts these, but Fetch rejects a request URL carrying them —
|
||||
// so without this check the value passes validation and fails mid-flow.
|
||||
it("rejects embedded credentials", () => {
|
||||
expect(
|
||||
oauthEndpointUrlError("https://user:pass@as.example.com/token"),
|
||||
).toMatch(/username or password/);
|
||||
expect(oauthEndpointUrlError("https://user@as.example.com/token")).toMatch(
|
||||
/username or password/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOAuthEndpointOverrides", () => {
|
||||
it("returns undefined when nothing is configured", () => {
|
||||
expect(normalizeOAuthEndpointOverrides(undefined)).toBeUndefined();
|
||||
expect(normalizeOAuthEndpointOverrides({})).toBeUndefined();
|
||||
expect(
|
||||
normalizeOAuthEndpointOverrides({ authorizationUrl: " " }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims and keeps each configured endpoint independently", () => {
|
||||
expect(
|
||||
normalizeOAuthEndpointOverrides({
|
||||
authorizationUrl: ` ${STAGING_AUTHORIZE} `,
|
||||
}),
|
||||
).toEqual({ authorizationUrl: STAGING_AUTHORIZE });
|
||||
expect(
|
||||
normalizeOAuthEndpointOverrides({ tokenUrl: STAGING_TOKEN }),
|
||||
).toEqual({ tokenUrl: STAGING_TOKEN });
|
||||
});
|
||||
|
||||
it("drops a malformed value with a warning, keeping the valid sibling", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(
|
||||
normalizeOAuthEndpointOverrides({
|
||||
authorizationUrl: "not a url",
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}),
|
||||
).toEqual({ tokenUrl: STAGING_TOKEN });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Ignoring `authorizationUrl`"),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a URL with embedded credentials", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(
|
||||
normalizeOAuthEndpointOverrides({
|
||||
tokenUrl: "https://user:pass@as.example.com/token",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("username or password"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when every configured value is malformed", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(
|
||||
normalizeOAuthEndpointOverrides({
|
||||
authorizationUrl: "ftp://example.com/a",
|
||||
tokenUrl: "also not a url",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizationServerMetadata", () => {
|
||||
it("accepts a document with an issuer and at least one endpoint", () => {
|
||||
expect(isAuthorizationServerMetadata(METADATA)).toBe(true);
|
||||
expect(
|
||||
isAuthorizationServerMetadata({
|
||||
issuer: "https://as.example.com",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-object body", () => {
|
||||
expect(isAuthorizationServerMetadata(null)).toBe(false);
|
||||
expect(isAuthorizationServerMetadata("nope")).toBe(false);
|
||||
expect(isAuthorizationServerMetadata([METADATA])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects protected-resource metadata, which has no issuer", () => {
|
||||
expect(
|
||||
isAuthorizationServerMetadata({
|
||||
resource: "https://mcp.example.com",
|
||||
authorization_servers: ["https://as.example.com"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a document with an issuer but neither endpoint", () => {
|
||||
expect(
|
||||
isAuthorizationServerMetadata({ issuer: "https://as.example.com" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOAuthEndpointOverrides", () => {
|
||||
it("replaces only the configured endpoints and copies the rest", () => {
|
||||
const patched = applyOAuthEndpointOverrides(METADATA, {
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
});
|
||||
expect(patched).toEqual({
|
||||
...METADATA,
|
||||
token_endpoint: STAGING_TOKEN,
|
||||
});
|
||||
expect(METADATA.token_endpoint).toBe("https://as.example.com/token");
|
||||
});
|
||||
|
||||
it("supplies an endpoint the document never advertised", () => {
|
||||
const patched = applyOAuthEndpointOverrides(
|
||||
{ issuer: "https://as.example.com" },
|
||||
{ authorizationUrl: STAGING_AUTHORIZE, tokenUrl: STAGING_TOKEN },
|
||||
);
|
||||
expect(patched).toEqual({
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: STAGING_AUTHORIZE,
|
||||
token_endpoint: STAGING_TOKEN,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withOAuthEndpointOverrides", () => {
|
||||
it("rewrites the endpoints of a metadata response", async () => {
|
||||
const base: typeof fetch = async () => jsonResponse(METADATA);
|
||||
const wrapped = withOAuthEndpointOverrides(base, () => ({
|
||||
authorizationUrl: STAGING_AUTHORIZE,
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
|
||||
const response = await wrapped(AS_METADATA_URL);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
...METADATA,
|
||||
authorization_endpoint: STAGING_AUTHORIZE,
|
||||
token_endpoint: STAGING_TOKEN,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("re-reads the overrides on every call, so a settings edit takes effect", async () => {
|
||||
const base: typeof fetch = async () => jsonResponse(METADATA);
|
||||
// Held in a box rather than a `let`: the resolver closes over it before the
|
||||
// first assignment, which is what the lazy read exists to support.
|
||||
const config: { overrides?: { tokenUrl?: string } } = {};
|
||||
const wrapped = withOAuthEndpointOverrides(base, () => config.overrides);
|
||||
|
||||
const before = await wrapped(AS_METADATA_URL);
|
||||
await expect(before.json()).resolves.toMatchObject({
|
||||
token_endpoint: METADATA.token_endpoint,
|
||||
});
|
||||
|
||||
config.overrides = { tokenUrl: STAGING_TOKEN };
|
||||
const after = await wrapped(AS_METADATA_URL);
|
||||
await expect(after.json()).resolves.toMatchObject({
|
||||
token_endpoint: STAGING_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
// The gate that keeps this wrapper off the hot path: an ordinary JSON-RPC
|
||||
// response is not even cloned, let alone parsed. Regression test for PR #2037.
|
||||
it("ignores a response to a request that is not metadata discovery", async () => {
|
||||
const original = jsonResponse(METADATA);
|
||||
const clone = vi.spyOn(original, "clone");
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
|
||||
await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe(
|
||||
original,
|
||||
);
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recognizes discovery through every request-input form", async () => {
|
||||
const wrapped = withOAuthEndpointOverrides(
|
||||
// A fresh response per call: each patched one consumes its own clone.
|
||||
async () => jsonResponse(METADATA),
|
||||
() => ({ tokenUrl: STAGING_TOKEN }),
|
||||
);
|
||||
const oidcUrl = "https://as.example.com/.well-known/openid-configuration";
|
||||
for (const input of [
|
||||
AS_METADATA_URL,
|
||||
new URL(oidcUrl),
|
||||
new Request(`${AS_METADATA_URL}/tenant-a`),
|
||||
]) {
|
||||
const response = await wrapped(input);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
token_endpoint: STAGING_TOKEN,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("warns once about a malformed override, not on every request", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base: typeof fetch = async () => jsonResponse(METADATA);
|
||||
const wrapped = withOAuthEndpointOverrides(base, () => ({
|
||||
tokenUrl: "not a url",
|
||||
}));
|
||||
|
||||
await wrapped(AS_METADATA_URL);
|
||||
await wrapped(AS_METADATA_URL);
|
||||
await wrapped(AS_METADATA_URL);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the response through untouched when nothing is configured", async () => {
|
||||
const original = jsonResponse(METADATA);
|
||||
const wrapped = withOAuthEndpointOverrides(
|
||||
passThrough(original),
|
||||
() => undefined,
|
||||
);
|
||||
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
|
||||
});
|
||||
|
||||
it("leaves an error response alone", async () => {
|
||||
const original = jsonResponse(METADATA, { status: 404 });
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
|
||||
});
|
||||
|
||||
it("leaves a non-JSON response alone", async () => {
|
||||
const original = new Response("<html></html>", {
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
|
||||
});
|
||||
|
||||
// Everything that is not a metadata document comes back as the caller's own
|
||||
// Response — identity, not an equivalent copy — so native properties a
|
||||
// synthesized Response cannot carry (`url`, `redirected`, `type`) survive.
|
||||
it("returns the original response for a JSON body that is not metadata", async () => {
|
||||
const original = jsonResponse({ access_token: "abc" });
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
const response = await wrapped(AS_METADATA_URL);
|
||||
expect(response).toBe(original);
|
||||
await expect(response.json()).resolves.toEqual({ access_token: "abc" });
|
||||
});
|
||||
|
||||
it("returns the original response when the body is not valid JSON", async () => {
|
||||
const original = jsonResponse("not json at all");
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
const response = await wrapped(AS_METADATA_URL);
|
||||
expect(response).toBe(original);
|
||||
await expect(response.text()).resolves.toBe("not json at all");
|
||||
});
|
||||
|
||||
// `application/x-ndjson` satisfies a naive `includes("json")` test, and the
|
||||
// streamable-HTTP transport serves its long-lived server-push channel that way
|
||||
// (see `isLongLivedStreamResponse`). Awaiting `.json()` on a clone of an
|
||||
// unbounded stream never resolves, so the MCP connection would hang for as
|
||||
// long as an override was configured. Regression test for PR #2037 review.
|
||||
it("does not read an unbounded NDJSON stream", async () => {
|
||||
// A body that never closes: if the wrapper reads it, this test times out.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{"jsonrpc":"2.0"}\n'));
|
||||
// Deliberately never closed.
|
||||
},
|
||||
});
|
||||
const original = new Response(stream, {
|
||||
headers: { "content-type": "application/x-ndjson" },
|
||||
});
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe(
|
||||
original,
|
||||
);
|
||||
await original.body?.cancel();
|
||||
});
|
||||
|
||||
it("ignores content-type parameters when deciding a body is JSON", async () => {
|
||||
const wrapped = withOAuthEndpointOverrides(
|
||||
passThrough(
|
||||
new Response(JSON.stringify(METADATA), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
}),
|
||||
),
|
||||
() => ({ tokenUrl: STAGING_TOKEN }),
|
||||
);
|
||||
const response = await wrapped(AS_METADATA_URL);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
token_endpoint: STAGING_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch forbids a body on 204/205, so rebuilding one would throw. A JSON
|
||||
// content-type on an empty response is unusual but legal, and it must not
|
||||
// take down an unrelated transport request.
|
||||
it("passes a body-less 204 through untouched", async () => {
|
||||
const original = new Response(null, {
|
||||
status: 204,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
|
||||
tokenUrl: STAGING_TOKEN,
|
||||
}));
|
||||
await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original);
|
||||
});
|
||||
|
||||
it("drops the stale content-length and content-encoding of a rewritten body", async () => {
|
||||
const body = JSON.stringify(METADATA);
|
||||
const wrapped = withOAuthEndpointOverrides(
|
||||
passThrough(
|
||||
new Response(body, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(body.length),
|
||||
"content-encoding": "gzip",
|
||||
},
|
||||
}),
|
||||
),
|
||||
() => ({ tokenUrl: STAGING_TOKEN }),
|
||||
);
|
||||
const response = await wrapped(AS_METADATA_URL);
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
});
|
||||
});
|
||||
|
|
@ -124,6 +124,46 @@ describe("runner client auth options", () => {
|
|||
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
|
||||
});
|
||||
|
||||
// #1906 — the CLI/TUI leg reads the endpoint overrides from the same
|
||||
// per-server settings the web leg does.
|
||||
it("buildRunnerClientAuthOptions forwards the endpoint overrides", () => {
|
||||
const settings: InspectorServerSettings = {
|
||||
oauthAuthorizationUrl: "https://staging.example.com/authorize",
|
||||
oauthTokenUrl: "https://staging.example.com/token",
|
||||
requestTimeout: 0,
|
||||
connectionTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 10,
|
||||
autoRefreshOnListChanged: false,
|
||||
metadata: [],
|
||||
headers: [],
|
||||
env: [],
|
||||
roots: [],
|
||||
};
|
||||
const opts = buildRunnerClientAuthOptions({}, settings);
|
||||
expect(opts.oauth?.authorizationUrl).toBe(
|
||||
"https://staging.example.com/authorize",
|
||||
);
|
||||
expect(opts.oauth?.tokenUrl).toBe("https://staging.example.com/token");
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions ignores blank endpoint overrides", () => {
|
||||
const settings: InspectorServerSettings = {
|
||||
oauthAuthorizationUrl: " ",
|
||||
oauthTokenUrl: "",
|
||||
requestTimeout: 0,
|
||||
connectionTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 10,
|
||||
autoRefreshOnListChanged: false,
|
||||
metadata: [],
|
||||
headers: [],
|
||||
env: [],
|
||||
roots: [],
|
||||
};
|
||||
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
|
||||
expect(buildRunnerClientAuthOptions({})).toEqual({});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import type { CreateTransportOptions } from "@inspector/core/mcp/types.js";
|
||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";
|
||||
|
||||
/**
|
||||
* #1906 — the per-server authorization/token endpoint overrides are applied by
|
||||
* wrapping the client's base fetch, so the authorization-server metadata
|
||||
* document carries the overridden endpoints wherever the SDK discovers it —
|
||||
* including the discovery the transport runs on its own (the 401/refresh path),
|
||||
* which is handed this same fetch.
|
||||
*
|
||||
* The assertion goes through the fetch the client actually hands the transport,
|
||||
* rather than through a private field, so it fails if the wiring is removed.
|
||||
*/
|
||||
|
||||
class InertTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
async start(): Promise<void> {}
|
||||
|
||||
async send(): Promise<void> {
|
||||
throw new Error("not connected");
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real browser storage plus inert navigation/redirect stubs — enough for the
|
||||
* OAuth manager to exist (which is what installs the override wrapper) without
|
||||
* any stored tokens, so `connect()` still builds a transport with no auth
|
||||
* provider attached.
|
||||
*/
|
||||
function oauthEnvironment() {
|
||||
return {
|
||||
storage: new BrowserOAuthStorage(),
|
||||
navigation: { navigateToAuthorization: () => {} },
|
||||
redirectUrlProvider: { getRedirectUrl: () => "http://localhost/callback" },
|
||||
};
|
||||
}
|
||||
|
||||
const METADATA = {
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://as.example.com/authorize",
|
||||
token_endpoint: "https://as.example.com/token",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a client, drive `connect()` far enough to create the transport, and
|
||||
* return the `fetchFn` the transport was given.
|
||||
*/
|
||||
async function transportFetchFor(
|
||||
oauth: Record<string, string> | undefined,
|
||||
): Promise<typeof fetch | undefined> {
|
||||
let captured: CreateTransportOptions | undefined;
|
||||
const baseFetch: typeof fetch = async () =>
|
||||
new Response(JSON.stringify(METADATA), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const client = new InspectorClient(
|
||||
{ type: "streamable-http", url: "https://mcp.example/mcp" },
|
||||
{
|
||||
environment: {
|
||||
fetch: baseFetch,
|
||||
oauth: oauthEnvironment(),
|
||||
transport: (_config, options) => {
|
||||
captured = options;
|
||||
return { transport: new InertTransport() };
|
||||
},
|
||||
},
|
||||
...(oauth && { oauth }),
|
||||
},
|
||||
);
|
||||
|
||||
await client.connect().catch(() => undefined);
|
||||
return captured?.fetchFn;
|
||||
}
|
||||
|
||||
describe("InspectorClient OAuth endpoint overrides (#1906)", () => {
|
||||
it("rewrites the discovered metadata endpoints for the transport's fetch", async () => {
|
||||
const fetchFn = await transportFetchFor({
|
||||
authorizationUrl: "https://staging.example.com/authorize",
|
||||
tokenUrl: "https://staging.example.com/token",
|
||||
});
|
||||
expect(fetchFn).toBeDefined();
|
||||
|
||||
const response = await fetchFn!(
|
||||
"https://as.example.com/.well-known/oauth-authorization-server",
|
||||
);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
issuer: "https://as.example.com",
|
||||
authorization_endpoint: "https://staging.example.com/authorize",
|
||||
token_endpoint: "https://staging.example.com/token",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the metadata untouched when no override is configured", async () => {
|
||||
const fetchFn = await transportFetchFor(undefined);
|
||||
expect(fetchFn).toBeDefined();
|
||||
|
||||
const response = await fetchFn!(
|
||||
"https://as.example.com/.well-known/oauth-authorization-server",
|
||||
);
|
||||
await expect(response.json()).resolves.toEqual(METADATA);
|
||||
});
|
||||
|
||||
it("picks up an override applied after construction via setOAuthConfig", async () => {
|
||||
let captured: CreateTransportOptions | undefined;
|
||||
const client = new InspectorClient(
|
||||
{ type: "streamable-http", url: "https://mcp.example/mcp" },
|
||||
{
|
||||
environment: {
|
||||
fetch: async () =>
|
||||
new Response(JSON.stringify(METADATA), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
oauth: oauthEnvironment(),
|
||||
transport: (_config, options) => {
|
||||
captured = options;
|
||||
return { transport: new InertTransport() };
|
||||
},
|
||||
},
|
||||
oauth: { clientId: "cid" },
|
||||
},
|
||||
);
|
||||
await client.connect().catch(() => undefined);
|
||||
|
||||
client.setOAuthConfig({ tokenUrl: "https://staging.example.com/token" });
|
||||
const response = await captured!.fetchFn!(
|
||||
"https://as.example.com/.well-known/oauth-authorization-server",
|
||||
);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
token_endpoint: "https://staging.example.com/token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -115,6 +115,53 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #1906 — the endpoint overrides are read live from the config so a settings
|
||||
// edit reaches the already-built fetch wrapper.
|
||||
describe("getEndpointOverrides", () => {
|
||||
it("returns undefined when neither endpoint is configured", () => {
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
expect(manager.getEndpointOverrides()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reflects each endpoint set through setOAuthConfig", () => {
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
manager.setOAuthConfig({
|
||||
authorizationUrl: "https://staging.test/authorize",
|
||||
});
|
||||
expect(manager.getEndpointOverrides()).toEqual({
|
||||
authorizationUrl: "https://staging.test/authorize",
|
||||
});
|
||||
manager.setOAuthConfig({ tokenUrl: "https://staging.test/token" });
|
||||
expect(manager.getEndpointOverrides()).toEqual({
|
||||
authorizationUrl: "https://staging.test/authorize",
|
||||
tokenUrl: "https://staging.test/token",
|
||||
});
|
||||
});
|
||||
|
||||
// The EMA leg authorizes against the enterprise IdP — a different
|
||||
// authorization server, whose OIDC discovery runs through the same fetch the
|
||||
// override wrapper is installed on. Applying a resource-AS override there
|
||||
// would redirect the IdP login (or the IdP code exchange) to the resource
|
||||
// AS. Regression test for the review finding on PR #2037.
|
||||
it("suppresses the overrides under enterprise-managed authorization", () => {
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
manager.setOAuthConfig({
|
||||
authorizationUrl: "https://staging.test/authorize",
|
||||
tokenUrl: "https://staging.test/token",
|
||||
});
|
||||
expect(manager.getEndpointOverrides()).toBeDefined();
|
||||
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
expect(manager.getEndpointOverrides()).toBeUndefined();
|
||||
|
||||
manager.setOAuthConfig({ enterpriseManaged: false });
|
||||
expect(manager.getEndpointOverrides()).toEqual({
|
||||
authorizationUrl: "https://staging.test/authorize",
|
||||
tokenUrl: "https://staging.test/token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerUrl propagation", () => {
|
||||
it("createOAuthProviderForTransport throws when getServerUrl throws", async () => {
|
||||
const params = createMockParams({
|
||||
|
|
@ -1588,7 +1635,7 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
const provider = await manager.createOAuthProviderForTransport();
|
||||
provider.redirectToAuthorization(
|
||||
await provider.redirectToAuthorization(
|
||||
new URL("https://as.example.com/authorize?client_id=abc&state=xyz"),
|
||||
);
|
||||
|
||||
|
|
@ -1610,7 +1657,7 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
const provider = await manager.createOAuthProviderForTransport();
|
||||
provider.redirectToAuthorization(
|
||||
await provider.redirectToAuthorization(
|
||||
new URL("https://as.example.com/authorize?client_id=abc&state=xyz"),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
mergeSecretsIntoStored,
|
||||
normalizeServerType,
|
||||
oauthAuthorizationParamsFromSettings,
|
||||
oauthEndpointOverridesFromSettings,
|
||||
serverEntriesToMcpConfig,
|
||||
serializeMcpConfig,
|
||||
storedFieldsToInspectorSettings,
|
||||
|
|
@ -1288,6 +1289,89 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => {
|
|||
).toBeUndefined();
|
||||
expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined();
|
||||
});
|
||||
|
||||
// #1906 — authorization/token endpoint overrides.
|
||||
it("lifts oauth endpoint overrides into settings fields", () => {
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
oauth: {
|
||||
authorizationUrl: "https://staging.example.com/authorize",
|
||||
tokenUrl: "https://staging.example.com/token",
|
||||
},
|
||||
});
|
||||
expect(settings?.oauthAuthorizationUrl).toBe(
|
||||
"https://staging.example.com/authorize",
|
||||
);
|
||||
expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token");
|
||||
});
|
||||
|
||||
// Hand-edited `mcp.json` reaches the CLI/TUI unvalidated, and
|
||||
// `oauthEndpointOverridesFromSettings` calls `.trim()` on these — so a
|
||||
// non-string would crash the server load rather than being ignored.
|
||||
it("drops a non-string endpoint override from disk", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
oauth: {
|
||||
clientId: "cid",
|
||||
// Types the field as a string; disk does not honor that.
|
||||
authorizationUrl: 42 as unknown as string,
|
||||
tokenUrl: "https://staging.example.com/token",
|
||||
},
|
||||
});
|
||||
expect(settings?.oauthAuthorizationUrl).toBeUndefined();
|
||||
expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token");
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("oauth.authorizationUrl"),
|
||||
);
|
||||
// Restored here rather than left to a global teardown: a later describe
|
||||
// spies on `console.warn` again and would inherit this spy's recorded call.
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("reads a blank endpoint override back as unset", () => {
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
oauth: { clientId: "cid", authorizationUrl: "", tokenUrl: "" },
|
||||
});
|
||||
expect(settings?.oauthAuthorizationUrl).toBeUndefined();
|
||||
expect(settings?.oauthTokenUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists endpoint overrides under oauth, trimming whitespace", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthAuthorizationUrl: " https://staging.example.com/authorize ",
|
||||
oauthTokenUrl: "https://staging.example.com/token",
|
||||
});
|
||||
expect(stored.oauth).toEqual({
|
||||
authorizationUrl: "https://staging.example.com/authorize",
|
||||
tokenUrl: "https://staging.example.com/token",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the oauth block when the endpoint overrides are blank", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthAuthorizationUrl: " ",
|
||||
oauthTokenUrl: "",
|
||||
});
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("derives the endpoint-override client option, or undefined when blank", () => {
|
||||
expect(
|
||||
oauthEndpointOverridesFromSettings({
|
||||
oauthAuthorizationUrl: " https://staging.example.com/authorize ",
|
||||
}),
|
||||
).toEqual({ authorizationUrl: "https://staging.example.com/authorize" });
|
||||
expect(
|
||||
oauthEndpointOverridesFromSettings({
|
||||
oauthTokenUrl: "https://staging.example.com/token",
|
||||
}),
|
||||
).toEqual({ tokenUrl: "https://staging.example.com/token" });
|
||||
expect(
|
||||
oauthEndpointOverridesFromSettings({ oauthAuthorizationUrl: " " }),
|
||||
).toBeUndefined();
|
||||
expect(oauthEndpointOverridesFromSettings({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("envRecordToPairs / envPairsToRecord", () => {
|
||||
|
|
|
|||
|
|
@ -969,6 +969,146 @@ describe("useServers", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("parses CRLF-delimited SSE frames as change notifications (#2006)", async () => {
|
||||
// SSE permits CRLF line endings, and this hook is a hand-rolled parser
|
||||
// (EventSource can't send `x-mcp-remote-auth`). Matching only `\n\n` left
|
||||
// a CRLF frame buffered forever, so an external mcp.json edit never
|
||||
// reached the list.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { seed: { type: "stdio", command: "s" } },
|
||||
}),
|
||||
);
|
||||
|
||||
// The frame is held back until the test has mutated the file, so the
|
||||
// resulting list can only come from a refresh this frame triggered.
|
||||
let releaseFrame: (() => void) | undefined;
|
||||
const framed = new Promise<void>((r) => {
|
||||
releaseFrame = r;
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
// Referentially stable: an inline arrow re-subscribes on every render, and
|
||||
// the re-subscription's own mount refresh would pick the edit up on its
|
||||
// own — passing whether or not the frame was ever parsed.
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/api/servers/events")) {
|
||||
// A real ReadableStream in a real Response — `pull` is only invoked
|
||||
// when the hook's reader asks for a chunk, so awaiting inside it
|
||||
// gates delivery exactly the way a hand-rolled reader double would,
|
||||
// while keeping the mock type-checked against the Response API.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
await framed;
|
||||
controller.enqueue(encoder.encode("event: change\r\n\r\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
return h.fetchFn(url, init);
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]);
|
||||
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { aftercrlf: { type: "stdio", command: "x" } },
|
||||
}),
|
||||
);
|
||||
releaseFrame?.();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["aftercrlf"]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not split a CRLF frame separator straddling two chunks (#2006)", async () => {
|
||||
// The bare-CR terminator is deliberately not accepted: a chunk ending in
|
||||
// `…\r\n\r` would otherwise read as a completed frame, and the `\n`
|
||||
// arriving next would start a bogus one. Deliver exactly that split and
|
||||
// assert the frame is recognized once — only after both halves land.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { seed: { type: "stdio", command: "s" } },
|
||||
}),
|
||||
);
|
||||
|
||||
let releaseFirstHalf: (() => void) | undefined;
|
||||
const firstHalf = new Promise<void>((r) => {
|
||||
releaseFirstHalf = r;
|
||||
});
|
||||
let releaseSecondHalf: (() => void) | undefined;
|
||||
const secondHalf = new Promise<void>((r) => {
|
||||
releaseSecondHalf = r;
|
||||
});
|
||||
let listGets = 0;
|
||||
let chunks = 0;
|
||||
const encoder = new TextEncoder();
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/api/servers/events")) {
|
||||
// `pull` runs once per chunk the hook's reader consumes, so `chunks`
|
||||
// reaching 2 proves the half-separator chunk was read and parsed.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
chunks += 1;
|
||||
if (chunks === 1) {
|
||||
await firstHalf;
|
||||
controller.enqueue(encoder.encode("event: change\r\n\r"));
|
||||
return;
|
||||
}
|
||||
if (chunks === 2) {
|
||||
await secondHalf;
|
||||
controller.enqueue(encoder.encode("\n"));
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/api/servers")) listGets += 1;
|
||||
return h.fetchFn(url, init);
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(listGets).toBe(1);
|
||||
|
||||
// Half a separator is not a frame: no refresh yet.
|
||||
releaseFirstHalf?.();
|
||||
await waitFor(() => expect(chunks).toBeGreaterThanOrEqual(2));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(listGets).toBe(1);
|
||||
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { aftersplit: { type: "stdio", command: "x" } },
|
||||
}),
|
||||
);
|
||||
releaseSecondHalf?.();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["aftersplit"]);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(listGets).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores the backend's inert priming comment frame (#1858)", async () => {
|
||||
// The backend opens every SSE stream with a `:` comment so a streaming
|
||||
// fetch() resolves on Firefox at all. That frame carries no event/data
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { serve } from "@hono/node-server";
|
|||
import type { ServerType } from "@hono/node-server";
|
||||
import type pinoType from "pino";
|
||||
import {
|
||||
closeAbortedStream,
|
||||
createRemoteApp,
|
||||
requestIdForSendWait,
|
||||
mcpParamHeadersOnly,
|
||||
|
|
@ -1107,4 +1108,37 @@ describe("server.ts supplemental coverage", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
describe("closeAbortedStream", () => {
|
||||
it("owns a rejected close instead of letting it go unhandled", async () => {
|
||||
// The whole reason the helper exists. Both SSE `onAbort` listeners run
|
||||
// after the peer is already gone, so `close()` can lose the race and
|
||||
// reject — and Hono invokes abort subscribers with a bare
|
||||
// `subscriber()`, so nothing upstream would catch it. A regression here
|
||||
// does not fail at the call site; it fails the whole vitest run from
|
||||
// wherever the rejection happens to surface.
|
||||
const rejected = Promise.reject(new Error("peer already gone"));
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (err: unknown) => unhandled.push(err);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
expect(closeAbortedStream({ close: () => rejected })).toBeUndefined();
|
||||
// An unhandledRejection fires on a later macrotask, not this one.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
expect(unhandled).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns without waiting on a close that resolves", async () => {
|
||||
let closed = false;
|
||||
closeAbortedStream({
|
||||
close: async () => {
|
||||
closed = true;
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -496,6 +496,75 @@ describe("/api/servers routes", () => {
|
|||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// #1906 — the endpoint overrides travel as plain strings and land under
|
||||
// `oauth` on disk beside the credentials.
|
||||
it("persists the oauth endpoint overrides on POST", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "endpointoverrides",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthAuthorizationUrl: "https://staging.test/authorize",
|
||||
oauthTokenUrl: "https://staging.test/token",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.endpointoverrides;
|
||||
expect(stored?.oauth).toEqual({
|
||||
authorizationUrl: "https://staging.test/authorize",
|
||||
tokenUrl: "https://staging.test/token",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an empty oauth endpoint override rather than writing it", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "blankoverrides",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthAuthorizationUrl: "",
|
||||
oauthTokenUrl: "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
readConfig(h.configPath).mcpServers.blankoverrides,
|
||||
).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("rejects a non-string oauthTokenUrl payload", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "badtokenurl",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthTokenUrl: 42,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("updates Inspector-extension fields at the top level on PUT", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
|
|
|
|||
149
clients/web/src/test/integration/server/server-auto-open.test.ts
Normal file
149
clients/web/src/test/integration/server/server-auto-open.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* `autoOpen` browser launch (#1959).
|
||||
*
|
||||
* Both servers call `openBrowser` from a synchronous callback — the prod
|
||||
* server from `serve()`'s listen callback, the dev plugin from `logBanner` —
|
||||
* so a rejection there has nowhere to go. On a headless or otherwise
|
||||
* browser-less host that rejection is the *ordinary* outcome, not an edge
|
||||
* case, and before #1959 it escaped as an unhandled rejection from a server
|
||||
* that had already started listening and printed a perfectly usable URL.
|
||||
*
|
||||
* The helper is shared precisely so this behavior has one implementation to
|
||||
* pin. The unit tests below cover it directly; the end-to-end test then
|
||||
* proves the prod server routes through it and survives the failure. The dev
|
||||
* plugin is not booted here — it returns early under `VITEST` by design (see
|
||||
* `vite-hono-plugin.ts`) — but it calls the same tested helper.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { createServer } from "node:net";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { WebServerConfig } from "../../../../server/web-server-config.js";
|
||||
import type { WebServerHandle } from "../../../../server/types.js";
|
||||
|
||||
const openMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("open", () => ({ default: openMock }));
|
||||
|
||||
// Imported after the mock is registered so both bind to the double.
|
||||
const { openBrowser } = await import("../../../../server/web-server-config.js");
|
||||
const { startHonoServer } = await import("../../../../server/server.js");
|
||||
|
||||
const WARNING = "Could not open a browser automatically:";
|
||||
|
||||
async function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = createServer();
|
||||
srv.unref();
|
||||
srv.on("error", reject);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (addr && typeof addr === "object") {
|
||||
const { port } = addr;
|
||||
srv.close(() => resolve(port));
|
||||
} else {
|
||||
srv.close(() => reject(new Error("Could not resolve a free port")));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** An unhandledRejection fires on a later macrotask than the rejection. */
|
||||
async function settle() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
const INDEX_HTML =
|
||||
"<!doctype html><html><body><div id=root></div></body></html>";
|
||||
|
||||
let handle: WebServerHandle | undefined;
|
||||
let staticRoot: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.close();
|
||||
handle = undefined;
|
||||
if (staticRoot) await rm(staticRoot, { recursive: true, force: true });
|
||||
staticRoot = undefined;
|
||||
openMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("openBrowser", () => {
|
||||
it("owns a rejection and warns instead of letting it go unhandled", async () => {
|
||||
// Suppress the expected warning rather than letting it pollute the run,
|
||||
// but assert on it — the warning IS the user-visible half of the fix.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (err: unknown) => unhandled.push(err);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
openMock.mockRejectedValue(new Error("no browser on this host"));
|
||||
expect(openBrowser("http://127.0.0.1:6274")).toBeUndefined();
|
||||
await settle();
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
expect(unhandled).toEqual([]);
|
||||
expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error));
|
||||
});
|
||||
|
||||
it("stays quiet when the browser opens", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
openMock.mockResolvedValue(undefined);
|
||||
openBrowser("http://127.0.0.1:6274");
|
||||
await settle();
|
||||
expect(openMock).toHaveBeenCalledWith("http://127.0.0.1:6274");
|
||||
expect(warn).not.toHaveBeenCalledWith(WARNING, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("startHonoServer autoOpen", () => {
|
||||
it("keeps serving when the browser cannot be opened", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (err: unknown) => unhandled.push(err);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
try {
|
||||
openMock.mockRejectedValue(new Error("no browser on this host"));
|
||||
|
||||
staticRoot = await mkdtemp(join(tmpdir(), "inspector-autoopen-"));
|
||||
await writeFile(join(staticRoot, "index.html"), INDEX_HTML, "utf-8");
|
||||
const port = await findFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const config: WebServerConfig = {
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
authToken: "auto-open-token",
|
||||
dangerouslyOmitAuth: false,
|
||||
initialMcpConfig: null,
|
||||
mcpConfigPath: undefined,
|
||||
writable: true,
|
||||
initialServers: null,
|
||||
storageDir: undefined,
|
||||
allowedOrigins: [baseUrl],
|
||||
sandboxPort: 0,
|
||||
sandboxHost: "127.0.0.1",
|
||||
logger: undefined,
|
||||
autoOpen: true,
|
||||
staticRoot,
|
||||
};
|
||||
handle = await startHonoServer(config);
|
||||
|
||||
// `serve()`'s listen callback fires after startHonoServer resolves.
|
||||
for (let i = 0; i < 100 && openMock.mock.calls.length === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
await settle();
|
||||
|
||||
expect(openMock).toHaveBeenCalledWith(expect.stringContaining(baseUrl));
|
||||
expect(unhandled).toEqual([]);
|
||||
expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error));
|
||||
// The point of warning rather than throwing: the server is still up.
|
||||
const res = await fetch(`${baseUrl}/`);
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -6,22 +6,24 @@ import { expect } from "storybook/test";
|
|||
* hidden when idle (`type="scroll"`, so it shows only while scrolling rather
|
||||
* than on hover). Shared by the Protocol/Network/Logging panel stories (#1474).
|
||||
*/
|
||||
export function expectScrollbarGutterIdleHidden(canvasElement: HTMLElement) {
|
||||
export async function expectScrollbarGutterIdleHidden(
|
||||
canvasElement: HTMLElement,
|
||||
) {
|
||||
const viewport = canvasElement.querySelector(".mantine-ScrollArea-viewport");
|
||||
if (!(viewport instanceof HTMLElement)) {
|
||||
throw new Error("scroll viewport not found");
|
||||
}
|
||||
// offsetScrollbars reserves a non-zero inline-end gutter for the scrollbar.
|
||||
const pad = parseFloat(getComputedStyle(viewport).paddingInlineEnd);
|
||||
expect(pad).toBeGreaterThan(0);
|
||||
await expect(pad).toBeGreaterThan(0);
|
||||
// type="scroll" → scrollbars are mounted but hidden until the user scrolls.
|
||||
// Assert at least one exists first so the loop isn't trivially-true if a
|
||||
// future change stops rendering the scrollbar element.
|
||||
const scrollbars = canvasElement.querySelectorAll(
|
||||
".mantine-ScrollArea-scrollbar",
|
||||
);
|
||||
expect(scrollbars.length).toBeGreaterThan(0);
|
||||
await expect(scrollbars.length).toBeGreaterThan(0);
|
||||
for (const bar of scrollbars) {
|
||||
expect(bar.getAttribute("data-state")).toBe("hidden");
|
||||
await expect(bar.getAttribute("data-state")).toBe("hidden");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue