* 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:
parent
d04a7fb9c8
commit
c96db0402e
5 changed files with 8793 additions and 63 deletions
|
|
@ -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: "" \(.+\)/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { connect } from "node:net";
|
||||
import {
|
||||
createOAuthCallbackServer,
|
||||
type OAuthCallbackServer,
|
||||
} from "@inspector/core/auth/node/oauth-callback-server.js";
|
||||
|
||||
/**
|
||||
* Send a raw HTTP request line over a socket so we can use request targets
|
||||
* (like `//`) that `fetch` would normalize/reject before they reach the
|
||||
* server. Returns the raw response text.
|
||||
*/
|
||||
function rawRequest(port: number, requestLine: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(port, "127.0.0.1", () => {
|
||||
socket.write(`${requestLine}\r\nHost: localhost\r\n\r\n`);
|
||||
});
|
||||
let data = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
socket.on("end", () => resolve(data));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("OAuthCallbackServer", () => {
|
||||
let server: OAuthCallbackServer;
|
||||
|
||||
|
|
@ -219,6 +240,90 @@ describe("OAuthCallbackServer", () => {
|
|||
expect(first.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 when the request target is an unparseable URL", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
// `GET //` makes req.url === "//", which `new URL("//", base)` rejects,
|
||||
// exercising the try/catch 400 branch in handleRequest.
|
||||
const raw = await rawRequest(result.port, "GET // HTTP/1.1");
|
||||
expect(raw).toContain("400 Bad Request");
|
||||
expect(raw).toContain("OAuth complete");
|
||||
});
|
||||
|
||||
it("returns 400 JSON for an unparseable URL when Accept: application/json", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const raw = await rawRequest(
|
||||
result.port,
|
||||
"GET // HTTP/1.1\r\nAccept: application/json",
|
||||
);
|
||||
expect(raw).toContain("400 Bad Request");
|
||||
expect(raw).toContain('{"error":"Bad Request"}');
|
||||
});
|
||||
|
||||
it("start() rejects when the callback path is not absolute", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
await expect(
|
||||
server.start({ port: 0, path: "no-leading-slash" }),
|
||||
).rejects.toThrow(/Callback path must start with '\/'/);
|
||||
});
|
||||
|
||||
it("POST returns 405 with a JSON body when Accept: application/json", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=x`,
|
||||
{ method: "POST", headers: { Accept: "application/json" } },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(405);
|
||||
expect(res.headers.get("content-type")).toContain("text/html");
|
||||
expect(await res.json()).toEqual({ error: "Method Not Allowed" });
|
||||
});
|
||||
|
||||
it("returns 404 with a JSON body when Accept: application/json", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const res = await fetch(`http://localhost:${result.port}/nope`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toEqual({ error: "Not Found" });
|
||||
});
|
||||
|
||||
it("returns 409 with a JSON body when Accept: application/json", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onCallback: async () => {
|
||||
await gate;
|
||||
},
|
||||
});
|
||||
|
||||
const firstP = fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=first`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const second = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=second`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
expect(second.status).toBe(409);
|
||||
expect(await second.json()).toEqual({ error: "Callback already handled" });
|
||||
release();
|
||||
await firstP;
|
||||
});
|
||||
|
||||
it("onCallback rejection returns 500 and error HTML", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({
|
||||
|
|
@ -237,4 +342,51 @@ describe("OAuthCallbackServer", () => {
|
|||
expect(html).toContain("OAuth failed");
|
||||
expect(html).toContain("exchange failed");
|
||||
});
|
||||
|
||||
it("onCallback rejection with a non-Error stringifies the value (500)", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onCallback: async () => {
|
||||
throw "string-failure";
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=abc`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(await res.text()).toContain("string-failure");
|
||||
});
|
||||
|
||||
it("GET with error but no error_description still returns 400 and calls onError", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const errors: Array<{
|
||||
error: string;
|
||||
error_description?: string | null;
|
||||
}> = [];
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onError: (p) => errors.push(p),
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?error=invalid_request`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.error).toBe("invalid_request");
|
||||
expect(errors[0]!.error_description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a bracketed redirect URL for an IPv6 hostname", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0, hostname: "::1" });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
`http://[::1]:${result.port}/oauth/callback`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -311,6 +311,13 @@ describe("KeyringSecretStore (mocked native bindings)", () => {
|
|||
).toBe("p");
|
||||
});
|
||||
|
||||
it("KeychainUnavailableError stringifies a non-Error cause", () => {
|
||||
const err = new KeychainUnavailableError("plain string cause");
|
||||
expect(err).toBeInstanceOf(KeychainUnavailableError);
|
||||
expect(err.message).toContain("plain string cause");
|
||||
expect(err.message).toMatch(/libsecret/);
|
||||
});
|
||||
|
||||
it("KeychainUnavailableError carries the underlying error message", async () => {
|
||||
keyringMocks.failures.setThrows = true;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
NodeOAuthStorage,
|
||||
getOAuthStore,
|
||||
|
|
@ -590,6 +590,22 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
expect(all.getServerState("http://server-b.test").tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearAllOAuthClientState tolerates a store whose servers map is absent", () => {
|
||||
// Exercises the `state.servers ?? {}` nullish fallback: when the persisted
|
||||
// state lacks a `servers` map, the iteration must default to {} (no throw).
|
||||
const defaultStore = getOAuthStore();
|
||||
const clearServerState = vi.fn();
|
||||
vi.spyOn(defaultStore, "getState").mockReturnValue({
|
||||
servers: undefined,
|
||||
clearServerState,
|
||||
} as unknown as ReturnType<typeof defaultStore.getState>);
|
||||
|
||||
expect(() => clearAllOAuthClientState()).not.toThrow();
|
||||
expect(clearServerState).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(defaultStore.getState).mockRestore();
|
||||
});
|
||||
|
||||
it("should isolate state from default store", async () => {
|
||||
const customPath = path.join(
|
||||
os.tmpdir(),
|
||||
|
|
|
|||
|
|
@ -401,6 +401,268 @@ describe("OAuthStateMachine", () => {
|
|||
).rejects.toThrow("Client information not available for token exchange");
|
||||
});
|
||||
|
||||
it("wraps a non-Error thrown by resource metadata discovery into an Error", async () => {
|
||||
const { discoverOAuthProtectedResourceMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
// Reject with a non-Error so the catch takes the `new Error(String(e))`
|
||||
// branch (line 45).
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
|
||||
"string-rejection",
|
||||
);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
const call = vi
|
||||
.mocked(updateState)
|
||||
.mock.calls.find((c) => "resourceMetadataError" in c[0]);
|
||||
expect(call?.[0].resourceMetadataError).toBeInstanceOf(Error);
|
||||
expect(call?.[0].resourceMetadataError?.message).toBe("string-rejection");
|
||||
});
|
||||
|
||||
it("throws when authorization server metadata discovery returns nothing", async () => {
|
||||
const {
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
discoverAuthorizationServerMetadata,
|
||||
} = await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
|
||||
new Error("no resource metadata"),
|
||||
);
|
||||
// SDK returns undefined → execute throws "Failed to discover OAuth
|
||||
// metadata" (line 69).
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(
|
||||
undefined as unknown as OAuthMetadata,
|
||||
);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await expect(stateMachine.executeStep(state)).rejects.toThrow(
|
||||
"Failed to discover OAuth metadata",
|
||||
);
|
||||
});
|
||||
|
||||
it("client_registration falls back to metadata.scopes_supported when provider scope is empty", async () => {
|
||||
const { registerClient } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(registerClient).mockResolvedValue({
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
client_id: "registered",
|
||||
});
|
||||
|
||||
// provider.scope empty → falls into the scope-derivation block; the
|
||||
// resourceMetadata has no scopes_supported so it uses metadata's.
|
||||
const providerEmptyScope = {
|
||||
...mockProvider,
|
||||
scope: "",
|
||||
clientMetadata: {
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
client_name: "Test Client",
|
||||
scope: "",
|
||||
},
|
||||
clientInformation: vi.fn(async () => undefined),
|
||||
} as unknown as BaseOAuthClientProvider;
|
||||
|
||||
const regState: OAuthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "client_registration",
|
||||
oauthMetadata: {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["alpha", "beta"],
|
||||
} as OAuthMetadata,
|
||||
resourceMetadata: {
|
||||
resource: "http://localhost:3000",
|
||||
} as OAuthProtectedResourceMetadata,
|
||||
};
|
||||
|
||||
await oauthTransitions.client_registration.execute({
|
||||
state: regState,
|
||||
serverUrl,
|
||||
provider: providerEmptyScope,
|
||||
updateState,
|
||||
});
|
||||
|
||||
expect(providerEmptyScope.clientMetadata.scope).toBe("alpha beta");
|
||||
expect(registerClient).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("authorization_redirect discovers scopes when provider scope is empty", async () => {
|
||||
const { startAuthorization } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(startAuthorization).mockResolvedValue({
|
||||
authorizationUrl: new URL("http://localhost:3000/authorize?x=1"),
|
||||
codeVerifier: "verifier-xyz",
|
||||
});
|
||||
|
||||
const providerEmptyScope = {
|
||||
...mockProvider,
|
||||
scope: "",
|
||||
saveCodeVerifier: vi.fn(),
|
||||
state: vi.fn(() => "st"),
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
} as unknown as BaseOAuthClientProvider;
|
||||
|
||||
const redirectState: OAuthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "authorization_redirect",
|
||||
oauthMetadata: {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
} as OAuthMetadata,
|
||||
oauthClientInfo: { client_id: "test-client" },
|
||||
// resourceMetadata with scopes_supported so discoverScopes resolves.
|
||||
resourceMetadata: {
|
||||
resource: "http://localhost:3000",
|
||||
scopes_supported: ["s1", "s2"],
|
||||
} as OAuthProtectedResourceMetadata,
|
||||
};
|
||||
|
||||
await oauthTransitions.authorization_redirect.execute({
|
||||
state: redirectState,
|
||||
serverUrl,
|
||||
provider: providerEmptyScope,
|
||||
updateState,
|
||||
});
|
||||
|
||||
expect(startAuthorization).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
expect.objectContaining({ scope: "s1 s2" }),
|
||||
);
|
||||
expect(providerEmptyScope.saveCodeVerifier).toHaveBeenCalledWith(
|
||||
"verifier-xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("authorization_code execute sets a validation error and throws when code is blank", async () => {
|
||||
const codeState: OAuthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "authorization_code",
|
||||
authorizationCode: " ",
|
||||
};
|
||||
|
||||
await expect(
|
||||
oauthTransitions.authorization_code.execute({
|
||||
state: codeState,
|
||||
serverUrl,
|
||||
provider: mockProvider,
|
||||
updateState,
|
||||
}),
|
||||
).rejects.toThrow("Authorization code required");
|
||||
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
validationError: "You need to provide an authorization code",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("authorization_code execute advances to token_request when a code is present", async () => {
|
||||
const codeState: OAuthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "authorization_code",
|
||||
authorizationCode: "good-code",
|
||||
};
|
||||
|
||||
await oauthTransitions.authorization_code.execute({
|
||||
state: codeState,
|
||||
serverUrl,
|
||||
provider: mockProvider,
|
||||
updateState,
|
||||
});
|
||||
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
validationError: null,
|
||||
oauthStep: "token_request",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("token_request execute throws when provider metadata is unavailable", async () => {
|
||||
const providerNoMeta = {
|
||||
...mockProvider,
|
||||
codeVerifier: vi.fn(() => "cv"),
|
||||
getServerMetadata: vi.fn(() => null),
|
||||
} as unknown as BaseOAuthClientProvider;
|
||||
|
||||
const tokenState: OAuthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "token_request",
|
||||
authorizationCode: "code",
|
||||
oauthClientInfo: { client_id: "c" },
|
||||
};
|
||||
|
||||
await expect(
|
||||
oauthTransitions.token_request.execute({
|
||||
state: tokenState,
|
||||
serverUrl,
|
||||
provider: providerNoMeta,
|
||||
updateState,
|
||||
}),
|
||||
).rejects.toThrow("OAuth metadata not available");
|
||||
});
|
||||
|
||||
it("token_request execute coerces a string resource into a URL for exchange", async () => {
|
||||
const { exchangeAuthorization } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
const metadata = {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
vi.mocked(exchangeAuthorization).mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const providerWithMeta = {
|
||||
...mockProvider,
|
||||
codeVerifier: vi.fn(() => "cv"),
|
||||
getServerMetadata: vi.fn(() => metadata),
|
||||
clientInformation: vi.fn(async () => ({ client_id: "c" })),
|
||||
saveTokens: vi.fn(),
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
} as unknown as BaseOAuthClientProvider;
|
||||
|
||||
const tokenState: OAuthFlowState = {
|
||||
...EMPTY_OAUTH_FLOW_STATE,
|
||||
oauthStep: "token_request",
|
||||
authorizationCode: "code",
|
||||
oauthClientInfo: { client_id: "c" },
|
||||
// resource as a *string* exercises the `new URL(resource)` branch.
|
||||
resource: "http://localhost:3000/resource" as unknown as URL,
|
||||
};
|
||||
|
||||
await oauthTransitions.token_request.execute({
|
||||
state: tokenState,
|
||||
serverUrl,
|
||||
provider: providerWithMeta,
|
||||
updateState,
|
||||
});
|
||||
|
||||
expect(exchangeAuthorization).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
expect.objectContaining({
|
||||
resource: new URL("http://localhost:3000/resource"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("complete.canTransition always returns false (terminal state)", async () => {
|
||||
const result = await oauthTransitions.complete.canTransition({
|
||||
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue