Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)
* refactor(web): update OAuth E2E tests to use new authentication method - Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration. - Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs. - Ensured that the tests continue to validate the expected authorization URL after the change. Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process. * Guided-auth removal, OAuth smoke testing, and TUI/CLI support * Added CLI OAuth runner flags tests * Added clarifying comment on port 0 binding * README notes on TUI oauth callback port, prettier fixes for CLI tests * CIMD validation uses inline like EMA IdP * Fixed TUI test failures from merge * test: bring 5 OAuth files over the 90 per-file coverage gate The guided-auth-removal work added/touched several files that fell below the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run the gate (it's local-only), so validate + test:integration passed while these gaps slipped through. Bring them all back over the line: - core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the provably-dead guards (empty host, out-of-range port, String(err) fallback, pathname "/" fallback) -- all rejected by new URL() upstream -- with justified v8 ignore comments instead of lowering the gate. - core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return. - core/react/useClientSettingsDraft.ts: test the functional-updater path, the stale-onChange-after-close prev===null guard, and the timer-fires-after-close value===null guard. - ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear. - clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration kinds, the expired IdP session, and the configured-scope/empty-scope branches of formatScopes. npm run coverage now passes (exit 0) across web, cli, tui, launcher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a927d3978
commit
13dafe457b
13 changed files with 5702 additions and 4444 deletions
114
clients/web/src/test/core/auth/cimd.test.ts
Normal file
114
clients/web/src/test/core/auth/cimd.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
|
||||
import { ensureCimdClientRegistration } from "@inspector/core/auth/cimd.js";
|
||||
|
||||
const SERVER_URL = "http://127.0.0.1:9999/mcp";
|
||||
const METADATA_URL = "http://127.0.0.1:8888/client-metadata.json";
|
||||
|
||||
function createProvider(storage: OAuthStorage): BaseOAuthClientProvider {
|
||||
return new BaseOAuthClientProvider(SERVER_URL, {
|
||||
storage,
|
||||
redirectUrlProvider: {
|
||||
getRedirectUrl: () => "http://127.0.0.1:3000/oauth/callback",
|
||||
},
|
||||
navigation: { navigateToAuthorization: vi.fn() },
|
||||
clientMetadataUrl: METADATA_URL,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ensureCimdClientRegistration", () => {
|
||||
let storage: OAuthStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {
|
||||
getClientInformation: vi.fn(async () => undefined),
|
||||
saveClientInformation: vi.fn(async () => {}),
|
||||
getScope: vi.fn(() => undefined),
|
||||
getTokens: vi.fn(async () => undefined),
|
||||
saveTokens: vi.fn(async () => {}),
|
||||
clear: vi.fn(),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
it("pre-registers URL-based client id when AS supports CIMD", 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({ resource: SERVER_URL }));
|
||||
}
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: "http://127.0.0.1:9999",
|
||||
authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize",
|
||||
token_endpoint: "http://127.0.0.1:9999/oauth/token",
|
||||
response_types_supported: ["code"],
|
||||
client_id_metadata_document_supported: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const provider = createProvider(storage);
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER_URL,
|
||||
provider,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
{
|
||||
client_id: METADATA_URL,
|
||||
},
|
||||
{ registrationKind: "cimd" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not register when the AS metadata omits CIMD support", 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({ resource: SERVER_URL }));
|
||||
}
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: "http://127.0.0.1:9999",
|
||||
authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize",
|
||||
token_endpoint: "http://127.0.0.1:9999/oauth/token",
|
||||
response_types_supported: ["code"],
|
||||
// client_id_metadata_document_supported intentionally absent.
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const provider = createProvider(storage);
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER_URL,
|
||||
provider,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(storage.saveClientInformation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when client information is already stored", async () => {
|
||||
storage.getClientInformation = vi.fn(async () => ({
|
||||
client_id: "existing-client",
|
||||
}));
|
||||
|
||||
const provider = createProvider(storage);
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER_URL,
|
||||
provider,
|
||||
fetchFn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(storage.saveClientInformation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
buildOAuthConnectionState,
|
||||
hasPersistedOAuthServerState,
|
||||
isServerOAuthConfigured,
|
||||
protocolFromOAuthConfig,
|
||||
} from "@inspector/core/auth/connection-state.js";
|
||||
|
|
@ -13,6 +14,7 @@ function createStorage(
|
|||
tokens: Awaited<ReturnType<OAuthStorage["getTokens"]>>;
|
||||
preregistered: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
|
||||
dynamic: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
|
||||
registrationKind: ReturnType<OAuthStorage["getClientRegistrationKind"]>;
|
||||
scope: string | undefined;
|
||||
serverMetadata: ReturnType<OAuthStorage["getServerMetadata"]>;
|
||||
idpSession: Awaited<ReturnType<OAuthStorage["getIdpSession"]>>;
|
||||
|
|
@ -32,6 +34,7 @@ function createStorage(
|
|||
return overrides.serverMetadata ?? null;
|
||||
}),
|
||||
getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession),
|
||||
getClientRegistrationKind: vi.fn(() => overrides.registrationKind),
|
||||
saveClientInformation: vi.fn(),
|
||||
savePreregisteredClientInformation: vi.fn(),
|
||||
saveTokens: vi.fn(),
|
||||
|
|
@ -59,6 +62,14 @@ describe("isServerOAuthConfigured", () => {
|
|||
it("returns false when all oauth fields are empty", () => {
|
||||
expect(isServerOAuthConfigured({})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when clientMetadataUrl is set", () => {
|
||||
expect(
|
||||
isServerOAuthConfigured({
|
||||
clientMetadataUrl: "https://example.com/oauth/client.json",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocolFromOAuthConfig", () => {
|
||||
|
|
@ -67,6 +78,24 @@ describe("protocolFromOAuthConfig", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("hasPersistedOAuthServerState", () => {
|
||||
it("returns true when dynamic client information is stored", async () => {
|
||||
const storage = createStorage({
|
||||
dynamic: { client_id: "https://example.com/cimd.json" },
|
||||
});
|
||||
await expect(
|
||||
hasPersistedOAuthServerState(storage, SERVER_URL),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when storage is empty", async () => {
|
||||
const storage = createStorage();
|
||||
await expect(
|
||||
hasPersistedOAuthServerState(storage, SERVER_URL),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOAuthConnectionState", () => {
|
||||
it("returns authorized standard state from storage tokens", async () => {
|
||||
const storage = createStorage({
|
||||
|
|
@ -91,7 +120,7 @@ describe("buildOAuthConnectionState", () => {
|
|||
expect(state.authorized).toBe(true);
|
||||
expect(state.protocol).toBe("standard");
|
||||
expect(state.client).toEqual({
|
||||
source: "preregistered",
|
||||
registrationKind: "static",
|
||||
clientId: "cfg-client",
|
||||
hasClientSecret: false,
|
||||
});
|
||||
|
|
@ -101,6 +130,55 @@ describe("buildOAuthConnectionState", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("returns dcr registration kind for dynamic client slot", async () => {
|
||||
const storage = createStorage({
|
||||
dynamic: { client_id: "dcr-uuid" },
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "standard",
|
||||
storage,
|
||||
});
|
||||
expect(state.client).toEqual({
|
||||
registrationKind: "dcr",
|
||||
clientId: "dcr-uuid",
|
||||
hasClientSecret: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cimd registration kind for dynamic client slot", async () => {
|
||||
const storage = createStorage({
|
||||
dynamic: { client_id: "https://example.com/cimd.json" },
|
||||
registrationKind: "cimd",
|
||||
});
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "standard",
|
||||
storage,
|
||||
});
|
||||
expect(state.client).toEqual({
|
||||
registrationKind: "cimd",
|
||||
clientId: "https://example.com/cimd.json",
|
||||
hasClientSecret: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers static registration when preregistered and dynamic slots coexist", async () => {
|
||||
const storage = createStorage({
|
||||
preregistered: { client_id: "static-id" },
|
||||
dynamic: { client_id: "https://example.com/cimd.json" },
|
||||
registrationKind: "cimd",
|
||||
});
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "standard",
|
||||
storage,
|
||||
});
|
||||
expect(state.client?.registrationKind).toBe("static");
|
||||
expect(state.client?.clientId).toBe("static-id");
|
||||
});
|
||||
|
||||
it("returns unauthorized when tokens are missing", async () => {
|
||||
const storage = createStorage();
|
||||
const state = await buildOAuthConnectionState({
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ describe("OAuthNavigation", () => {
|
|||
// observe the still-current document (location.href not yet reassigned)
|
||||
// so a keepalive request it fires outlives the navigation.
|
||||
const order: string[] = [];
|
||||
const authUrl = new URL("http://example.com/authorize?state=normal:abc");
|
||||
const authUrl = new URL(
|
||||
`http://example.com/authorize?state=${"a".repeat(64)}`,
|
||||
);
|
||||
const navigation = new BrowserNavigation(undefined, (url) => {
|
||||
order.push("before");
|
||||
// At hook time the redirect has not happened yet.
|
||||
|
|
@ -346,9 +348,13 @@ describe("OAuthNavigation", () => {
|
|||
token_type: "Bearer",
|
||||
});
|
||||
expect(storage.saveScope).toHaveBeenCalledWith(SERVER, "openid");
|
||||
expect(storage.saveClientInformation).toHaveBeenCalledWith(SERVER, {
|
||||
client_id: "c",
|
||||
});
|
||||
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||
SERVER,
|
||||
{
|
||||
client_id: "c",
|
||||
},
|
||||
{ registrationKind: "dcr" },
|
||||
);
|
||||
expect(storage.savePreregisteredClientInformation).toHaveBeenCalledWith(
|
||||
SERVER,
|
||||
{ client_id: "p" },
|
||||
|
|
@ -357,7 +363,9 @@ describe("OAuthNavigation", () => {
|
|||
expect(storage.saveServerMetadata).toHaveBeenCalled();
|
||||
expect(await provider.tokens()).toBeUndefined();
|
||||
expect(provider.getServerMetadata()).toBeNull();
|
||||
expect(await provider.state()).toMatch(/quick/);
|
||||
const state = await provider.state();
|
||||
expect(typeof state).toBe("string");
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
105
clients/web/src/test/core/auth/runner-oauth-callback.test.ts
Normal file
105
clients/web/src/test/core/auth/runner-oauth-callback.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
|
||||
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
|
||||
formatRunnerOAuthRedirectUrl,
|
||||
parseRunnerOAuthCallbackUrl,
|
||||
} from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||
|
||||
describe("runner OAuth callback URL", () => {
|
||||
const envKey = "MCP_OAUTH_CALLBACK_URL";
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env[envKey];
|
||||
});
|
||||
|
||||
it("defaults to 127.0.0.1:6276", () => {
|
||||
expect(DEFAULT_RUNNER_OAUTH_CALLBACK_URL).toBe(
|
||||
"http://127.0.0.1:6276/oauth/callback",
|
||||
);
|
||||
expect(parseRunnerOAuthCallbackUrl()).toEqual({
|
||||
hostname: "127.0.0.1",
|
||||
port: RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
|
||||
pathname: "/oauth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers CLI flag over env", () => {
|
||||
process.env[envKey] = "http://127.0.0.1:9999/oauth/callback";
|
||||
expect(
|
||||
parseRunnerOAuthCallbackUrl("http://127.0.0.1:3000/oauth/callback"),
|
||||
).toEqual({
|
||||
hostname: "127.0.0.1",
|
||||
port: 3000,
|
||||
pathname: "/oauth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses MCP_OAUTH_CALLBACK_URL when CLI flag absent", () => {
|
||||
process.env[envKey] = "http://127.0.0.1:8888/custom/callback";
|
||||
expect(parseRunnerOAuthCallbackUrl()).toEqual({
|
||||
hostname: "127.0.0.1",
|
||||
port: 8888,
|
||||
pathname: "/custom/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows port 0 for ephemeral listener", () => {
|
||||
expect(
|
||||
parseRunnerOAuthCallbackUrl("http://127.0.0.1:0/oauth/callback"),
|
||||
).toEqual({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/oauth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to port 80 when the URL omits a port", () => {
|
||||
expect(
|
||||
parseRunnerOAuthCallbackUrl("http://127.0.0.1/oauth/callback"),
|
||||
).toEqual({
|
||||
hostname: "127.0.0.1",
|
||||
port: 80,
|
||||
pathname: "/oauth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on an unparseable callback URL", () => {
|
||||
expect(() => parseRunnerOAuthCallbackUrl("not a url")).toThrow(
|
||||
/Invalid OAuth callback URL/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-http scheme", () => {
|
||||
expect(() =>
|
||||
parseRunnerOAuthCallbackUrl("https://127.0.0.1:6276/oauth/callback"),
|
||||
).toThrow(/must use http scheme/);
|
||||
});
|
||||
|
||||
it("formatRunnerOAuthRedirectUrl round-trips default config", () => {
|
||||
const config = parseRunnerOAuthCallbackUrl();
|
||||
expect(formatRunnerOAuthRedirectUrl(config)).toBe(
|
||||
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
|
||||
);
|
||||
});
|
||||
|
||||
it("formatRunnerOAuthRedirectUrl brackets a bare IPv6 hostname", () => {
|
||||
expect(
|
||||
formatRunnerOAuthRedirectUrl({
|
||||
hostname: "::1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
}),
|
||||
).toBe("http://[::1]:6276/oauth/callback");
|
||||
});
|
||||
|
||||
it("formatRunnerOAuthRedirectUrl leaves an already-bracketed IPv6 host alone", () => {
|
||||
expect(
|
||||
formatRunnerOAuthRedirectUrl({
|
||||
hostname: "[::1]",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
}),
|
||||
).toBe("http://[::1]:6276/oauth/callback");
|
||||
});
|
||||
});
|
||||
|
|
@ -66,7 +66,9 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_secret: "test-secret",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toEqual(clientInfo);
|
||||
|
|
@ -100,7 +102,9 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toEqual(clientInfo);
|
||||
|
|
@ -115,11 +119,23 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "second-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, firstInfo);
|
||||
storage.saveClientInformation(testServerUrl, secondInfo);
|
||||
storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
registrationKind: "cimd",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toEqual(secondInfo);
|
||||
expect(storage.getClientRegistrationKind(testServerUrl)).toBe("cimd");
|
||||
});
|
||||
|
||||
it("savePreregisteredClientInformation sets static registration kind", async () => {
|
||||
await storage.savePreregisteredClientInformation(testServerUrl, {
|
||||
client_id: "static-id",
|
||||
});
|
||||
expect(storage.getClientRegistrationKind(testServerUrl)).toBe("static");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -267,7 +283,11 @@ describe("BrowserOAuthStorage", () => {
|
|||
|
||||
describe("clearClientInformation", () => {
|
||||
it("removes the dynamically-registered client info by default", async () => {
|
||||
storage.saveClientInformation(testServerUrl, { client_id: "dyn" });
|
||||
storage.saveClientInformation(
|
||||
testServerUrl,
|
||||
{ client_id: "dyn" },
|
||||
{ registrationKind: "dcr" },
|
||||
);
|
||||
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
||||
client_id: "dyn",
|
||||
});
|
||||
|
|
@ -338,7 +358,9 @@ describe("BrowserOAuthStorage", () => {
|
|||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
|
|
@ -353,8 +375,12 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
storage.saveClientInformation(otherServerUrl, clientInfo);
|
||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(otherServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
|
||||
|
|
@ -378,8 +404,12 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "client-2",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(server1Url, clientInfo1);
|
||||
storage.saveClientInformation(server2Url, clientInfo2);
|
||||
storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
expect(await storage.getClientInformation(server1Url)).toEqual(
|
||||
clientInfo1,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
});
|
||||
|
||||
it("saveClientInformation + getClientInformation round-trip", async () => {
|
||||
await storage.saveClientInformation(serverUrl, { client_id: "dyn" });
|
||||
await storage.saveClientInformation(
|
||||
serverUrl,
|
||||
{ client_id: "dyn" },
|
||||
{ registrationKind: "dcr" },
|
||||
);
|
||||
expect(await storage.getClientInformation(serverUrl)).toEqual({
|
||||
client_id: "dyn",
|
||||
});
|
||||
|
|
@ -41,7 +45,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
});
|
||||
|
||||
it("clearClientInformation default branch removes dynamic info", async () => {
|
||||
await storage.saveClientInformation(serverUrl, { client_id: "dyn" });
|
||||
await storage.saveClientInformation(
|
||||
serverUrl,
|
||||
{ client_id: "dyn" },
|
||||
{ registrationKind: "dcr" },
|
||||
);
|
||||
storage.clearClientInformation(serverUrl);
|
||||
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
||||
});
|
||||
|
|
@ -90,7 +98,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
});
|
||||
|
||||
it("clear() wipes all state for a server", async () => {
|
||||
await storage.saveClientInformation(serverUrl, { client_id: "x" });
|
||||
await storage.saveClientInformation(
|
||||
serverUrl,
|
||||
{ client_id: "x" },
|
||||
{ registrationKind: "dcr" },
|
||||
);
|
||||
await storage.saveTokens(serverUrl, {
|
||||
access_token: "t",
|
||||
token_type: "Bearer",
|
||||
|
|
|
|||
|
|
@ -3,207 +3,93 @@ import {
|
|||
parseHttpUrl,
|
||||
parseOAuthCallbackParams,
|
||||
generateOAuthState,
|
||||
generateOAuthStateWithExecution,
|
||||
generateOAuthStateWithMode,
|
||||
parseOAuthState,
|
||||
generateOAuthErrorDescription,
|
||||
} from "@inspector/core/auth/utils.js";
|
||||
|
||||
describe("auth utils", () => {
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
it("should parse successful callback with code", () => {
|
||||
const params = parseOAuthCallbackParams("?code=abc123");
|
||||
expect(params).toEqual({ successful: true, code: "abc123" });
|
||||
});
|
||||
describe("parseHttpUrl", () => {
|
||||
it("parses valid URLs", () => {
|
||||
expect(parseHttpUrl("https://example.com/path", "test").href).toBe(
|
||||
"https://example.com/path",
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse error callback", () => {
|
||||
const params = parseOAuthCallbackParams(
|
||||
it("throws on invalid URLs", () => {
|
||||
expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
it("parses successful callback", () => {
|
||||
expect(parseOAuthCallbackParams("?code=abc123")).toEqual({
|
||||
successful: true,
|
||||
code: "abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses error callback", () => {
|
||||
expect(
|
||||
parseOAuthCallbackParams(
|
||||
"?error=access_denied&error_description=User%20denied",
|
||||
);
|
||||
expect(params).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: "User denied",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return invalid_request when code and error are missing", () => {
|
||||
const params = parseOAuthCallbackParams("?foo=bar");
|
||||
expect(params).toEqual({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
});
|
||||
),
|
||||
).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: "User denied",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthState", () => {
|
||||
it("should generate 64-char hex string", () => {
|
||||
const state = generateOAuthState();
|
||||
expect(state).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate unique states", () => {
|
||||
const s1 = generateOAuthState();
|
||||
const s2 = generateOAuthState();
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthStateWithExecution", () => {
|
||||
it("should generate state with quick prefix", () => {
|
||||
const state = generateOAuthStateWithExecution("quick");
|
||||
expect(state.startsWith("quick:")).toBe(true);
|
||||
expect(state.slice(6)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate state with guided prefix", () => {
|
||||
const state = generateOAuthStateWithExecution("guided");
|
||||
expect(state.startsWith("guided:")).toBe(true);
|
||||
expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate unique states", () => {
|
||||
const s1 = generateOAuthStateWithExecution("quick");
|
||||
const s2 = generateOAuthStateWithExecution("quick");
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
|
||||
it("generateOAuthStateWithMode alias matches quick execution", () => {
|
||||
const state = generateOAuthStateWithMode("quick");
|
||||
expect(state.startsWith("quick:")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthState", () => {
|
||||
it("should parse quick prefix", () => {
|
||||
const parsed = parseOAuthState("quick:abc123def456");
|
||||
expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should parse guided prefix", () => {
|
||||
const parsed = parseOAuthState("guided:a1b2c3d4e5f6");
|
||||
expect(parsed).toEqual({ execution: "guided", authId: "a1b2c3d4e5f6" });
|
||||
});
|
||||
|
||||
it("should map legacy normal prefix to quick", () => {
|
||||
const parsed = parseOAuthState("normal:abc123def456");
|
||||
expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should map legacy ema-idp prefix to quick", () => {
|
||||
const parsed = parseOAuthState("ema-idp:abc123def456");
|
||||
expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should parse legacy 64-char hex as quick", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const parsed = parseOAuthState(hex);
|
||||
expect(parsed).toEqual({ execution: "quick", authId: hex });
|
||||
});
|
||||
|
||||
it("should return null for invalid state", () => {
|
||||
expect(parseOAuthState("")).toBeNull();
|
||||
expect(parseOAuthState("invalid")).toBeNull();
|
||||
expect(parseOAuthState("other:xyz")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription", () => {
|
||||
it("should generate error description with error code only", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toBe("Error: access_denied.");
|
||||
});
|
||||
|
||||
it("should generate error description with error code and description", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "invalid_request",
|
||||
error_description: "The request is missing a required parameter",
|
||||
error_uri: null,
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: invalid_request.");
|
||||
expect(description).toContain(
|
||||
"Details: The request is missing a required parameter.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate error description with all fields", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "server_error",
|
||||
error_description: "An internal server error occurred",
|
||||
error_uri: "https://example.com/errors/server_error",
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: server_error.");
|
||||
expect(description).toContain(
|
||||
"Details: An internal server error occurred.",
|
||||
);
|
||||
expect(description).toContain(
|
||||
"More info: https://example.com/errors/server_error.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle null error_description", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: "https://example.com/error",
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: access_denied.");
|
||||
expect(description).not.toContain("Details:");
|
||||
expect(description).toContain("More info: https://example.com/error.");
|
||||
});
|
||||
|
||||
it("should handle null error_uri", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "invalid_client",
|
||||
error_description: "Invalid client credentials",
|
||||
error_uri: null,
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: invalid_client.");
|
||||
expect(description).toContain("Details: Invalid client credentials.");
|
||||
expect(description).not.toContain("More info:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHttpUrl", () => {
|
||||
it("parses a valid absolute URL", () => {
|
||||
expect(parseHttpUrl("https://idp.example.com", "test").href).toBe(
|
||||
"https://idp.example.com/",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with label and value when URL is invalid", () => {
|
||||
expect(() =>
|
||||
parseHttpUrl("https;//idp.xaa.dev", "EMA IdP issuer (Client Settings)"),
|
||||
).toThrow(
|
||||
'Invalid EMA IdP issuer (Client Settings): "https;//idp.xaa.dev"',
|
||||
);
|
||||
it("returns invalid_request when code and error are missing", () => {
|
||||
expect(parseOAuthCallbackParams("?foo=bar")).toEqual({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthState", () => {
|
||||
it("should generate 64-char hex state", () => {
|
||||
const state = generateOAuthState();
|
||||
expect(state).toMatch(/^[a-f0-9]{64}$/i);
|
||||
});
|
||||
|
||||
it("should generate unique states", () => {
|
||||
const s1 = generateOAuthState();
|
||||
const s2 = generateOAuthState();
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthState", () => {
|
||||
it("should parse 64-char hex authId", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const parsed = parseOAuthState(hex);
|
||||
expect(parsed).toEqual({ authId: hex });
|
||||
});
|
||||
|
||||
it("should return null for invalid state", () => {
|
||||
expect(parseOAuthState("")).toBeNull();
|
||||
expect(parseOAuthState("invalid")).toBeNull();
|
||||
expect(parseOAuthState("abc123")).toBeNull();
|
||||
expect(parseOAuthState("other:xyz")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription", () => {
|
||||
it("formats error with description and uri", () => {
|
||||
const message = generateOAuthErrorDescription({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: "User denied access",
|
||||
error_uri: "https://example.com/errors/access_denied",
|
||||
});
|
||||
expect(message).toContain("Error: access_denied.");
|
||||
expect(message).toContain("Details: User denied access.");
|
||||
expect(message).toContain(
|
||||
"More info: https://example.com/errors/access_denied.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ import {
|
|||
isAbsoluteHttpUrl,
|
||||
} from "@inspector/core/client/config-parse.js";
|
||||
import {
|
||||
getActiveCimdClientMetadataUrl,
|
||||
getActiveEnterpriseManagedAuthIdp,
|
||||
isCimdEnabled,
|
||||
isEnterpriseManagedAuthEnabled,
|
||||
} from "@inspector/core/client/types.js";
|
||||
|
||||
|
|
@ -134,6 +136,80 @@ describe("client config", () => {
|
|||
).toThrow(/Invalid URL/);
|
||||
});
|
||||
|
||||
it("parseClientConfig accepts cimd clientMetadataUrl", () => {
|
||||
const config = parseClientConfig({
|
||||
cimd: {
|
||||
enabled: true,
|
||||
clientMetadataUrl: "https://example.com/oauth/client.json",
|
||||
},
|
||||
});
|
||||
expect(config.cimd?.clientMetadataUrl).toBe(
|
||||
"https://example.com/oauth/client.json",
|
||||
);
|
||||
expect(isCimdEnabled(config)).toBe(true);
|
||||
expect(getActiveCimdClientMetadataUrl(config)).toBe(
|
||||
"https://example.com/oauth/client.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("parseClientConfig accepts enabled: false with stored CIMD URL", () => {
|
||||
const config = parseClientConfig({
|
||||
cimd: {
|
||||
enabled: false,
|
||||
clientMetadataUrl: "https://example.com/oauth/client.json",
|
||||
},
|
||||
});
|
||||
expect(config.cimd?.enabled).toBe(false);
|
||||
expect(isCimdEnabled(config)).toBe(false);
|
||||
expect(getActiveCimdClientMetadataUrl(config)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseClientConfig accepts disabled CIMD with empty URL", () => {
|
||||
const config = parseClientConfig({
|
||||
cimd: {
|
||||
enabled: false,
|
||||
clientMetadataUrl: "",
|
||||
},
|
||||
});
|
||||
expect(config.cimd).toEqual({
|
||||
enabled: false,
|
||||
clientMetadataUrl: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("parseClientConfig rejects enabled CIMD with empty URL", () => {
|
||||
expect(() =>
|
||||
parseClientConfig({
|
||||
cimd: {
|
||||
enabled: true,
|
||||
clientMetadataUrl: "",
|
||||
},
|
||||
}),
|
||||
).toThrow(/required when CIMD is enabled/);
|
||||
});
|
||||
|
||||
it("parseClientConfig rejects non-HTTPS CIMD URL", () => {
|
||||
expect(() =>
|
||||
parseClientConfig({
|
||||
cimd: {
|
||||
enabled: true,
|
||||
clientMetadataUrl: "http://example.com/oauth/client.json",
|
||||
},
|
||||
}),
|
||||
).toThrow(/HTTPS/);
|
||||
});
|
||||
|
||||
it("parseClientConfig rejects CIMD URL without path", () => {
|
||||
expect(() =>
|
||||
parseClientConfig({
|
||||
cimd: {
|
||||
enabled: true,
|
||||
clientMetadataUrl: "https://example.com/",
|
||||
},
|
||||
}),
|
||||
).toThrow(/path/);
|
||||
});
|
||||
|
||||
it("loadClientConfig returns {} when file is absent", async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
|
||||
const filePath = path.join(tmpDir, "client.json");
|
||||
|
|
|
|||
83
clients/web/src/test/core/client/runner.test.ts
Normal file
83
clients/web/src/test/core/client/runner.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildRunnerClientAuthOptions,
|
||||
isOAuthCapableServerConfig,
|
||||
} from "@inspector/core/client/runner.js";
|
||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
|
||||
describe("runner client auth options", () => {
|
||||
it("isOAuthCapableServerConfig accepts sse and streamable-http only", () => {
|
||||
expect(isOAuthCapableServerConfig({ type: "sse" })).toBe(true);
|
||||
expect(isOAuthCapableServerConfig({ type: "streamable-http" })).toBe(true);
|
||||
expect(isOAuthCapableServerConfig({ type: "stdio" })).toBe(false);
|
||||
expect(isOAuthCapableServerConfig(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions wires EMA IdP from client.json", () => {
|
||||
const clientConfig: ClientConfig = {
|
||||
enterpriseManagedAuth: {
|
||||
enabled: true,
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = buildRunnerClientAuthOptions(clientConfig);
|
||||
expect(opts.enterpriseManagedAuth?.idp.issuer).toBe(
|
||||
"https://idp.example.com",
|
||||
);
|
||||
expect(opts.installEnterpriseManagedAuth).toEqual(
|
||||
clientConfig.enterpriseManagedAuth,
|
||||
);
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions prefers CLI CIMD over client.json", () => {
|
||||
const clientConfig: ClientConfig = {
|
||||
cimd: {
|
||||
enabled: true,
|
||||
clientMetadataUrl: "https://example.com/from-config.json",
|
||||
},
|
||||
};
|
||||
const opts = buildRunnerClientAuthOptions(clientConfig, undefined, {
|
||||
clientMetadataUrl: "https://example.com/from-cli.json",
|
||||
});
|
||||
expect(opts.oauth?.clientMetadataUrl).toBe(
|
||||
"https://example.com/from-cli.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions uses client.json CIMD when CLI flag absent", () => {
|
||||
const clientConfig: ClientConfig = {
|
||||
cimd: {
|
||||
enabled: true,
|
||||
clientMetadataUrl: "https://example.com/from-config.json",
|
||||
},
|
||||
};
|
||||
const opts = buildRunnerClientAuthOptions(clientConfig);
|
||||
expect(opts.oauth?.clientMetadataUrl).toBe(
|
||||
"https://example.com/from-config.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions includes enterpriseManaged from server settings", () => {
|
||||
const settings: InspectorServerSettings = {
|
||||
enterpriseManaged: true,
|
||||
oauthClientId: "resource-client",
|
||||
requestTimeout: 0,
|
||||
connectionTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 10,
|
||||
autoRefreshOnListChanged: false,
|
||||
metadata: [],
|
||||
headers: [],
|
||||
env: [],
|
||||
roots: [],
|
||||
};
|
||||
const opts = buildRunnerClientAuthOptions({}, settings);
|
||||
expect(opts.oauth?.enterpriseManaged).toBe(true);
|
||||
expect(opts.oauth?.clientId).toBe("resource-client");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getOAuthServerUrl,
|
||||
getServerType,
|
||||
isOAuthCapableServerType,
|
||||
} from "@inspector/core/mcp/config.js";
|
||||
|
|
@ -19,3 +20,23 @@ describe("isOAuthCapableServerType", () => {
|
|||
expect(isOAuthCapableServerType(type)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOAuthServerUrl", () => {
|
||||
it("returns the MCP URL for HTTP transports", () => {
|
||||
expect(
|
||||
getOAuthServerUrl({
|
||||
type: "streamable-http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
}),
|
||||
).toBe("https://mcp.example.com/mcp");
|
||||
expect(
|
||||
getOAuthServerUrl({ type: "sse", url: "https://mcp.example.com/sse" }),
|
||||
).toBe("https://mcp.example.com/sse");
|
||||
});
|
||||
|
||||
it("returns undefined for stdio", () => {
|
||||
expect(
|
||||
getOAuthServerUrl({ type: "stdio", command: "node", args: [] }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
} from "@inspector/core/auth/node/secret-store";
|
||||
import {
|
||||
headersToServerSettings,
|
||||
loadServerEntries,
|
||||
|
|
@ -42,7 +46,7 @@ describe("loadServerEntries", () => {
|
|||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lifts disk headers, timeouts, and OAuth into per-server settings", () => {
|
||||
it("lifts disk headers, timeouts, and OAuth into per-server settings", async () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
|
|
@ -64,7 +68,7 @@ describe("loadServerEntries", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({ configPath });
|
||||
const servers = await loadServerEntries({ configPath });
|
||||
const settings = servers.web?.settings;
|
||||
expect(settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer disk" },
|
||||
|
|
@ -76,7 +80,43 @@ describe("loadServerEntries", () => {
|
|||
expect(settings?.oauthScopes).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("merges --header over disk headers while preserving disk timeouts", () => {
|
||||
it("rehydrates OAuth client secrets from the keychain when stripped on disk", async () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
Todo0: {
|
||||
type: "streamable-http",
|
||||
url: "https://mcp.xaa.dev/mcp",
|
||||
oauth: {
|
||||
clientId: "client_febcc047cef20866-at-todo0-mcp",
|
||||
scopes: "todos.read mcp.access",
|
||||
enterpriseManaged: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const secretStore = new InMemorySecretStore();
|
||||
await secretStore.set(
|
||||
"Todo0",
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
"resource-as-secret-from-keychain",
|
||||
);
|
||||
|
||||
const servers = await loadServerEntries({ configPath, secretStore });
|
||||
expect(servers.Todo0?.settings?.oauthClientId).toBe(
|
||||
"client_febcc047cef20866-at-todo0-mcp",
|
||||
);
|
||||
expect(servers.Todo0?.settings?.oauthClientSecret).toBe(
|
||||
"resource-as-secret-from-keychain",
|
||||
);
|
||||
expect(servers.Todo0?.settings?.enterpriseManaged).toBe(true);
|
||||
});
|
||||
|
||||
it("merges --header over disk headers while preserving disk timeouts", async () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
|
|
@ -92,7 +132,7 @@ describe("loadServerEntries", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({
|
||||
const servers = await loadServerEntries({
|
||||
configPath,
|
||||
headers: { Authorization: "Bearer cli" },
|
||||
});
|
||||
|
|
@ -103,7 +143,7 @@ describe("loadServerEntries", () => {
|
|||
expect(servers.web?.settings?.requestTimeout).toBe(9000);
|
||||
});
|
||||
|
||||
it("merges --header into a server that has no disk settings", () => {
|
||||
it("merges --header into a server that has no disk settings", async () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
|
|
@ -114,7 +154,7 @@ describe("loadServerEntries", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({
|
||||
const servers = await loadServerEntries({
|
||||
configPath,
|
||||
headers: { Authorization: "Bearer cli" },
|
||||
});
|
||||
|
|
@ -123,7 +163,7 @@ describe("loadServerEntries", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("applies env and cwd overrides to stdio configs only", () => {
|
||||
it("applies env and cwd overrides to stdio configs only", async () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
|
|
@ -135,7 +175,7 @@ describe("loadServerEntries", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({
|
||||
const servers = await loadServerEntries({
|
||||
configPath,
|
||||
env: { B: "2" },
|
||||
cwd: "/tmp/work",
|
||||
|
|
@ -153,9 +193,9 @@ describe("loadServerEntries", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("seeds an empty writable catalog when --catalog is missing", () => {
|
||||
it("seeds an empty writable catalog when --catalog is missing", async () => {
|
||||
const catalogPath = join(tempDir, "catalog.json");
|
||||
const servers = loadServerEntries({ catalogPath });
|
||||
const servers = await loadServerEntries({ catalogPath });
|
||||
expect(servers).toEqual({});
|
||||
expect(existsSync(catalogPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
|
||||
|
|
@ -163,34 +203,34 @@ describe("loadServerEntries", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("throws when a read-only --config file is missing (never seeds)", () => {
|
||||
it("throws when a read-only --config file is missing (never seeds)", async () => {
|
||||
const configPath = join(tempDir, "absent.json");
|
||||
expect(() => loadServerEntries({ configPath })).toThrow(
|
||||
await expect(loadServerEntries({ configPath })).rejects.toThrow(
|
||||
/Config file not found/,
|
||||
);
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects --catalog and --config together", () => {
|
||||
it("rejects --catalog and --config together", async () => {
|
||||
const catalogPath = join(tempDir, "catalog.json");
|
||||
const configPath = join(tempDir, "config.json");
|
||||
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
||||
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
||||
expect(() => loadServerEntries({ catalogPath, configPath })).toThrow(
|
||||
/mutually exclusive/,
|
||||
);
|
||||
await expect(
|
||||
loadServerEntries({ catalogPath, configPath }),
|
||||
).rejects.toThrow(/mutually exclusive/);
|
||||
});
|
||||
|
||||
it("rejects --catalog combined with an ad-hoc target", () => {
|
||||
it("rejects --catalog combined with an ad-hoc target", async () => {
|
||||
const catalogPath = join(tempDir, "catalog.json");
|
||||
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
||||
expect(() =>
|
||||
await expect(
|
||||
loadServerEntries({ catalogPath, target: ["my-server"] }),
|
||||
).toThrow(/--catalog cannot be combined/);
|
||||
).rejects.toThrow(/--catalog cannot be combined/);
|
||||
});
|
||||
|
||||
it("builds a single ad-hoc server from a positional target", () => {
|
||||
const servers = loadServerEntries({
|
||||
it("builds a single ad-hoc server from a positional target", async () => {
|
||||
const servers = await loadServerEntries({
|
||||
target: ["my-server", "--flag"],
|
||||
headers: { Authorization: "Bearer t" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* OAuthManager unit tests. Uses mocked getServerUrl, fetch, storage, and
|
||||
* dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens,
|
||||
* error propagation, and getOAuthFlowState/getOAuthFlowStep after beginGuidedAuth.
|
||||
* error propagation, and getOAuthFlowState/getOAuthFlowStep.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
|
|
@ -14,8 +14,6 @@ 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.
|
||||
|
|
@ -35,7 +33,6 @@ const SERVER_URL = "https://example.com/mcp";
|
|||
function createMockParams(
|
||||
overrides?: Partial<OAuthManagerParams>,
|
||||
): OAuthManagerParams {
|
||||
const dispatchOAuthStepChange = vi.fn();
|
||||
const dispatchOAuthComplete = vi.fn();
|
||||
const dispatchOAuthAuthorizationRequired = vi.fn();
|
||||
const dispatchOAuthError = vi.fn();
|
||||
|
|
@ -43,6 +40,7 @@ function createMockParams(
|
|||
const storage = {
|
||||
getScope: vi.fn().mockReturnValue(undefined),
|
||||
getClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
|
||||
saveClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
saveScope: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -85,7 +83,6 @@ function createMockParams(
|
|||
effectiveAuthFetch: vi.fn().mockResolvedValue(new Response("{}")),
|
||||
getEventTarget: vi.fn().mockReturnValue(new EventTarget()),
|
||||
initialConfig,
|
||||
dispatchOAuthStepChange,
|
||||
dispatchOAuthComplete,
|
||||
dispatchOAuthAuthorizationRequired,
|
||||
dispatchOAuthError,
|
||||
|
|
@ -203,10 +200,9 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
describe("dispatch callbacks", () => {
|
||||
it("completeOAuthFlow calls dispatchOAuthError when normal path throws", async () => {
|
||||
it("completeOAuthFlow calls dispatchOAuthError when auth() throws", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
// Normal path (no guided state): auth() will run and fail (no real server), so catch calls dispatchOAuthError
|
||||
await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow();
|
||||
expect(params.dispatchOAuthError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -244,6 +240,13 @@ describe("OAuthManager", () => {
|
|||
const tokens = await manager.getOAuthTokens();
|
||||
expect(tokens).toEqual(storedTokens);
|
||||
});
|
||||
|
||||
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("isOAuthAuthorized", () => {
|
||||
|
|
@ -273,26 +276,6 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("setGuidedAuthorizationCode", () => {
|
||||
it("throws when not in guided flow", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
await expect(
|
||||
manager.setGuidedAuthorizationCode("code", true),
|
||||
).rejects.toThrow("Not in guided OAuth flow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("proceedOAuthStep", () => {
|
||||
it("throws when not in guided flow", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
await expect(manager.proceedOAuthStep()).rejects.toThrow(
|
||||
"Not in guided OAuth flow",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enterprise-managed auth", () => {
|
||||
function createEmaManager(
|
||||
overrides?: Partial<OAuthManagerParams>,
|
||||
|
|
@ -503,251 +486,6 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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" };
|
||||
|
|
@ -789,32 +527,6 @@ describe("OAuthManager", () => {
|
|||
);
|
||||
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)", () => {
|
||||
|
|
@ -845,35 +557,6 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,73 @@ describe("useClientSettingsDraft", () => {
|
|||
expect(result.current.draft).toEqual({ text: "ab", rows: [] });
|
||||
});
|
||||
|
||||
it("applies a functional updater against the latest draft", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientSettingsDraft<SettingsShape>({
|
||||
opened: true,
|
||||
resolveInitial: () => ({ text: "seed", rows: ["a"] }),
|
||||
onPersist: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
result.current.onChange((prev) => ({ ...prev, text: prev.text + "!" }));
|
||||
});
|
||||
expect(result.current.draft).toEqual({ text: "seed!", rows: ["a"] });
|
||||
});
|
||||
|
||||
it("ignores a stale onChange captured before the modal closed", () => {
|
||||
// Capture the onChange created while open (its closure still sees
|
||||
// opened === true), then close the modal — which nulls latestValuesRef.
|
||||
// Invoking the stale handler must short-circuit on the `prev === null`
|
||||
// guard rather than scheduling a persist of nothing.
|
||||
const onPersist = vi.fn(async () => {});
|
||||
const { result, rerender } = renderHook(
|
||||
({ opened }: { opened: boolean }) =>
|
||||
useClientSettingsDraft<SettingsShape>({
|
||||
opened,
|
||||
resolveInitial: () => EMPTY,
|
||||
onPersist,
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { opened: true } },
|
||||
);
|
||||
const staleOnChange = result.current.onChange;
|
||||
rerender({ opened: false });
|
||||
act(() => {
|
||||
staleOnChange({ text: "stale", rows: [] });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(onPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the debounced persist when the modal closed before the timer fired", () => {
|
||||
// A persist is scheduled while open; the modal then closes (nulling
|
||||
// latestValuesRef) without unmounting, so the pending timer still fires.
|
||||
// The `value !== null` guard inside the timer must suppress the persist.
|
||||
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: [] });
|
||||
});
|
||||
rerender({ opened: false });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(onPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("debounces onPersist by the configured window", () => {
|
||||
const onPersist = vi.fn(async () => {});
|
||||
const { result } = renderHook(() =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue