test: raise per-file coverage gate to 90 across all clients (closes #1271) (#1532)

* test(web): audit branch-coverage outliers, raise per-file gate to 70 (#1271)

The web per-file coverage gate had drifted red: the recently-landed EMA
auth feature (core/auth/ema/*) and ClientSettings components shipped
undertested, and the prior 50% branch floor let business-logic branches
go untested. CI does not run the coverage gate, so this accumulated.

Add tests to bring every failing file above the gate and push branch
coverage well past 70 across the board, then raise the per-file branches
threshold 50 -> 70 in clients/web/vite.config.ts.

- EMA core (emaFlow, resourceContext, jwt, wire): 50-88% -> 86-100% branch
- EMA OIDC/transport (idpOidc, tokenEndpoint, transportProvider): -> 97-100%
- oauthManager / discovery: 73-83% -> 88-92%
- ClientSettingsModal/Form, ConnectionInfoContent: 0-88% -> 96-100%
- useClientSettingsDraft / useEmaIdpLoginState hooks: 20% -> 100%
- TaskStatusBadge / SortableServerCard outliers: 50% -> 100%

Coverable branches reached by mocking useComputedColorScheme (dark theme),
dnd-kit useSortable (isDragging), and fetch/OIDC paths. Remaining sub-100
branches are genuinely-unreachable defensive guards or happy-dom-inherent
(portal / useMediaQuery / typeof window) and documented in test comments.

No source files changed. Global web branch coverage 88.73% -> 90.63%.
AGENTS.md updated; cli/tui/launcher stay at 50 (out of scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: raise per-file coverage gate to 90/90/90/90 across all clients

Follows the #1271 web branch-coverage audit (50 -> 70) by raising the
whole per-file gate to >=90 on all four dimensions — lines, statements,
functions, branches — for web, cli, tui, and launcher (core/ is measured
under the web suite). A codebase-wide audit added real tests for every
outlier and brought global coverage up across the board.

Config: all four vitest/vite coverage configs set to 90/90/90/90.
Two pure-type modules (core/mcp/import/types.ts, clients/web/server/
types.ts) added to the web coverage exclude list, matching the existing
type-only exclusions.

Tests: ~50 test files extended/created across core (auth, ema, mcp,
remote, state, storage, react), web components/screens/server, and the
cli/tui clients. Highlights: full EMA OAuth flow + transport coverage,
inspectorClient/remote server backfill, every Mantine control's handler
branches, and the cli unsupported-method / argv-fallback paths.

Genuinely-unreachable branches are annotated at the source with justified
`/* v8 ignore -- <reason> */` comments rather than lowering the gate:
happy-dom-inherent paths (Mantine portal, useMediaQuery, typeof window
SSR guards), React StrictMode effect replay, and provably-dead defensive
guards (?? fallbacks the types make impossible, Select.onChange values
outside the allowed list). No runtime logic changed; the only structural
edits are two behavior-preserving refactors (ResourceTemplatePanel hoists
the field value to a local so the ignore applies in JSX; ToolsScreen
swaps a ternary for an if) and prettier line-wrapping.

Also fixed a flaky real-timer debounce assertion in PromptArgumentsForm
(asserted an exact sibling-typing interleaving that raced the 300ms
debounce; now asserts the load-bearing fact — a non-empty fire-time
sibling capture).

AGENTS.md updated to describe the uniform 90 gate and the v8-ignore
policy. All four client coverage gates, root validate, storybook (370),
and smoke pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): make connect-crash SSE reader stop on the terminal event

`readSseEvents` looped until the server closed the stream (`done`). The
server closes the events stream immediately after writing the terminal
`transport_error`, but under the heavy parallel scheduler pressure of a
full `coverage` run (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. This made the test intermittently fail under
`npm run coverage` (it passed in isolation and under CI's lighter
`test:integration`).

Stop reading as soon as the terminal `transport_error` event arrives
(cancelling the reader) instead of waiting only for `done`. That event is
always the last one the session queue emits, so nothing is lost, and the
test no longer depends on stream-close timing. The `done` break is kept
for any stream that closes first.

Verified green across two consecutive full `npm run test:coverage` runs
(3094 tests) where it previously hung, plus isolation (~0.5s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-27 15:26:03 -04:00 • committed by GitHub
commit c96db0402e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 8793 additions and 63 deletions

View file

@ -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<string, { idToken?: string; refreshToken?: string }> = {},
savedTokens: Record<string, unknown> = {},
): OAuthStorage {
const metadataByKey: Record<string, unknown> = {};
const clientInfoByKey: Record<string, unknown> = {};
const codeVerifierByKey: Record<string, string> = {};
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",
);
});
});

View file

@ -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<string, unknown> = {};
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<string, unknown> = {};
const clearedVerifiers: string[] = [];
function buildStorage(overrides: Partial<OAuthStorage> = {}): 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");
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});
});

View file

@ -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> = {},
): 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();
});
});
});

View file

@ -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")}`,
);
});
});

View file

@ -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<typeof vi.fn>;
clientInformation: ReturnType<typeof vi.fn>;
saveClientInformation: ReturnType<typeof vi.fn>;
tokens: ReturnType<typeof vi.fn>;
saveTokens: ReturnType<typeof vi.fn>;
redirectToAuthorization: ReturnType<typeof vi.fn>;
clearCapturedAuthUrl: ReturnType<typeof vi.fn>;
saveCodeVerifier: ReturnType<typeof vi.fn>;
codeVerifier: ReturnType<typeof vi.fn>;
}
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<typeof vi.fn>;
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);
});
});

View file

@ -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();
});
});
});

View file

@ -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/);
});
});
});

View file

@ -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<void> {}
@ -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", () => {

View file

@ -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<string, ReturnType<typeof vi.fn>>;
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<typeof vi.spyOn> {
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<OAuthFlowState>) => void;
}
const captured: { update?: (u: Partial<OAuthFlowState>) => void } = {};
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async function (
this: MachineWithUpdate,
): Promise<void> {
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<typeof vi.spyOn>;
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);
});
});
});

View file

@ -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<typeof fetch>(
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<typeof fetch>(
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<typeof fetch>(
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<typeof fetch>(
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<typeof fetch>(
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<string, string>)[
"x-mcp-remote-auth"
],
).toBeUndefined();
});
it("loadSession sends the auth header when a token is configured", async () => {
const fetchFn = vi.fn<typeof fetch>(
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<string, string>)[
"x-mcp-remote-auth"
],
).toBe("Bearer tok");
});
it("deleteSession sends the auth header when a token is configured", async () => {
const fetchFn = vi
.fn<typeof fetch>()
.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<string, string>)[
"x-mcp-remote-auth"
],
).toBe("Bearer tok");
});
it("deleteSession tolerates 404 and rethrows on other errors", async () => {
const fetchFn = vi
.fn<typeof fetch>()

View file

@ -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> = {}): 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("");
});
});

View file

@ -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"));

View file

@ -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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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<SettingsShape>({
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);
});
});

View file

@ -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");
});
});

View file

@ -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<Response>((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();
});
});

View file

@ -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<Response>((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);
});
});

View file

@ -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<void>((r) => {
resolvePut = r;
});
}
return h.fetchFn(url, init);
},
}),
);
await waitFor(() =>
expect(result.current.servers.map((s) => s.id)).toEqual([
"alpha",
"beta",
]),
);
let pending: Promise<void>;
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<typeof fetch>()
.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 }),

View file

@ -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: "" \(.+\)/);
});
});

Some files were not shown because too many files have changed in this diff Show more