* feat(test-servers): stall an OAuth endpoint so #2319's timeouts can be driven #2319 put an `AbortSignal.timeout` on five OAuth-path calls that previously went out with no signal at all. Nothing in the repo could make any of them fire: the composable server's `oauth` block had no stall, delay or never-answer option, so all five were covered only by tests injecting a `fetch` stub. A stub settles on the client side. The state #2319 is actually about is an established, idle socket in the Inspector's own Node process — invisible in browser devtools, and held behind a connection status that deliberately stays at `connecting`. Only a server that accepts and withholds reproduces it. Adds `oauth.stallEndpoints` (any of `protected-resource-metadata`, `as-metadata`, `authorize`, `token`, `revoke`, `register`) plus an optional `stallMs` to answer late rather than never. Endpoints are named by the CALL, not the path: the exchange and the refresh share `/oauth/token`, and two of the documents sit at configurable paths, so a path-keyed option would be both ambiguous and wrong when `resourceMetadataPath` or `asMetadataPath` moves one. Per endpoint rather than global, so a test can tell "discovery timed out" from "token exchange timed out" — the distinction the five separate timeouts exist to make. An unrecognized name throws at start rather than being ignored, since a typo would otherwise yield a fixture that answers normally and a timeout test that fails pointing at the timeout instead of at the config. Two showcase configs and a `docs/test-servers.md` section covering both, what to do with them, and what the broken build did. Teardown needed no new machinery: `TestServerHttp.stop()` already calls `httpServer.closeAllConnections?.()`, which destroys an established socket whether or not a response was written. Measured at 0s with a withheld response in flight, and pinned by a test, because it is the property most likely to be broken by a future change to the stop path — and it would break as a hang in `afterEach`, pointing at the wrong test. Each guard was mutation-checked against the new suite: middleware never registered -> 7 of 8 failed match req.url instead of req.path -> 1 of 8 failed unknown-entry validation removed -> 1 of 8 failed stall calls next() anyway -> 6 of 8 failed (unmutated) -> 8 passed The `req.path` mutation initially escaped: every stallable endpoint but `authorize` is requested at a bare path, so matching on `req.url` passed the whole file. The query-string case exists to close that, and its comment says so. Closes #2382 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): key the OAuth stall by method, and cover every endpoint (#2383 review) Five findings from review, all defects in what this PR added. **Method is part of the endpoint identity.** The middleware collapsed each endpoint to a path, but two of the paths are caller-supplied: a valid `asMetadataPath: "/oauth/token"` made stalling `token` also stall the metadata GET, and vice versa — breaking the per-call contract the option exists to provide. Keyed on `${METHOD} ${path}` now, with `authorize` carrying both GET (consent page) and POST (submission). **Every advertised endpoint is driven, not three of six.** `as-metadata`, `revoke` and `register` were reachable in config but never requested, so a typo in any of their hardcoded entries would yield a fixture that quietly answers normally — the exact failure this option exists to prevent. The suite is now table-driven over `STALLABLE_OAUTH_ENDPOINTS`, so adding a name without covering it is impossible. **`stallMs` is tested.** It was advertised and every fixture used 0, leaving the delay branch and its timer cleanup able to regress unseen. Three cases now: answers late rather than never, times out when the caller's budget is shorter than the delay, and releases the timer when the client aborts first. **The teardown test waits for a real signal, not a fixed sleep.** A 250ms sleep that lost the race would have stopped the server before the request arrived; the fetch would reject because the server closed and the test would pass without ever exercising `closeAllConnections()` on an established request. It now waits until the request is actually parked. ⚠️ That signal started as a module-level counter and failed under Vitest, which can load the fixture module more than once — the middleware incremented one copy while the test polled another, and the wait timed out with the fixture working perfectly (verified standalone: parked after 100ms). It is per-server state read off the instance the test already holds, which makes module identity irrelevant and scopes the count to one fixture. **The docs section is top-level.** It had been inserted as `###` directly under `## Revoking tokens on clear`, so that section's prose became part of the stalled-fixture subsection. Re-mutated against the expanded suite; every guard is independently detected: middleware never registered -> 19 of 20 failed match req.url instead of req.path -> 2 of 20 failed ignore method (path-only key) -> 1 of 20 failed stallMs answers immediately -> 3 of 20 failed registry never decremented -> 1 of 20 failed as-metadata path typo -> 2 of 20 failed (unmutated) -> 20 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): validate stallMs, cover both configurable paths and the timer (#2383 review round 2) Six more findings, all defects in what this PR added. **The timer-cleanup test did not test timer cleanup.** It observed `registry.parked`, which a separate `close` listener decrements, so it passed with the 10s timer still armed — a test whose name and comment claimed something it could not see. Split in two: the end-to-end case now says only what it proves (the fixture lets go), and a focused middleware test with fake timers and a `next` spy checks the actual claim. Removing `clearTimeout` now fails; before, it did not. **Both configurable metadata paths are now driven at non-default values.** `stallTargetsFor` reads `resourceMetadataPath` and `asMetadataPath`, and every test requested the defaults — so replacing either lookup with its default literal left the suite green while `stallEndpoints` silently stopped working for any config that moves the document. That is the exact typo class this option exists to prevent, and it was the one my own doc comment claimed to guard. **`authorize` is driven as a POST as well as a GET.** It is the only endpoint mapped to two methods, and only one was exercised, so dropping `"POST"` passed the suite while a consent submission stopped stalling. **`stallMs` is validated at setup**, like its sibling `stallEndpoints`. A JSON/YAML config is only *cast* to its interface, so anything can arrive: a negative or non-numeric value made `stallMs > 0` false and silently became a PERMANENT stall, and a value past the 32-bit timer range overflows to ~1ms and answers almost immediately. Both read as "the timeout behaved strangely" rather than "the config is wrong". `MAX_STALL_MS` mirrors `MAX_TIMER_DELAY_MS` in `core/auth/requestTimeout.ts`, which exists for the same reason. **The docs no longer claim a configurable budget.** They said the failure arrives "after roughly 30s unless a shorter one is configured". There is no such setting: `InspectorClient` passes `DEFAULT_OAUTH_REQUEST_TIMEOUT_MS` straight into the transport chain and passes no budget at all on the auth chain, so both take the default. Verified in `core/mcp/inspectorClient.ts:866-889` before changing the text — it now states the fixed 30 seconds. **`stop()`'s JSDoc is back above `stop()`.** The new accessor had been inserted between them, orphaning it. Each fix mutation-checked against the 26-test suite; every one is detected, and each by exactly one test: drop POST from authorize's methods -> 1 of 26 failed resourceMetadataPath -> default literal -> 1 of 26 failed asMetadataPath -> default literal -> 1 of 26 failed clearTimeout on close removed -> 1 of 26 failed stallMs validation removed -> 1 of 26 failed (unmutated) -> 26 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): validate the stallEndpoints container, reject stallMs null, drop `any` (#2383 review round 3) Four findings — two inline, two suppressed. All valid. **`stallEndpoints` is validated as a container, not just by length.** A config file is cast rather than parsed, so the value can arrive as a string, `null`, or an object with a `length`. The old `.length === 0` check accepted `""` and `{ length: 0 }` and returned "no stalling configured" — silently disabling the capability this startup validation exists to protect — while a non-empty string failed later with an incidental `.filter is not a function`. **An explicit `stallMs: null` is rejected rather than defaulted.** `?? 0` turned it into a valid 0, which skipped every check below and silently produced a permanent stall. Only an omitted value takes the default now; `null` falls through to the finite-number check. **Both `as any` suppressions are gone.** This repo forbids `any` outright and forbids disabling the rule to satisfy the linter, and I did both — twice. The doubles are now bridged to `Request`/`Response` with a single justified `as unknown as` and a comment saying why no narrower option exists: the literal is missing ~50 properties so a single `as Request` is rejected, and the middleware reads only `method`, `path` and `res.on`. If it ever reads more, the double is wrong at runtime instead of being silently absorbed — which is what the `any` was doing. Mutation-checked, both new guards detected: stallEndpoints container check removed -> 1 of 28 failed stallMs back to `?? 0` (null coerced) -> 1 of 28 failed (unmutated) -> 28 passed ⚠️ The container mutation initially read as ESCAPED at 28 passed. It had not: `npm run format` had rewrapped the condition across lines, so the perl pattern matched nothing and the "mutation" was a no-op. Re-applied by line range it fails as expected. Worth recording — a mutation that silently fails to apply reads exactly like a test gap, and I nearly reported it as one. `npm run check` passes in both clients/cli and clients/web, so the `any` rule and the type-aware lint are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): refuse to stall a route the config does not serve (#2383 review round 4) Three findings, all suppressed comments (no inline threads this round). **A stall is refused for an endpoint this config does not actually expose.** The middleware runs *before* Express routing, so it would happily hold a request for a route that would otherwise 404 — `supportDCR: false` with `stallEndpoints: ["register"]` produced a hanging registration endpoint instead of a configuration error, and a timeout test written against it would have passed for entirely the wrong reason. `servesEndpoint` mirrors the registration rules in `setupOAuthRoutes`/`setupMetadataEndpoints`: the protected-resource document is always served, the rest of the local AS exists only in `combined` mode, `revoke` carries `supportRevocation` and `register` carries `supportDCR`. It lives next to `stallTargetsFor` so the two stay in step — a new OAuth route needs an entry in both. This is the same class as the validation already here, and the reason is the one this PR keeps restating: a misconfiguration must fail loudly rather than produce a hang that reads as a timeout. **The error no longer misreports NaN and Infinity.** `JSON.stringify` returns the string `"null"` for both — the two values most likely to reach the `stallMs` check — so the message named a value the author never wrote. `describeValue` renders non-finite numbers with `String` and keeps quotes on strings, which is what makes `"600"` distinguishable from `600`. **The PR description's test count is corrected again** (26 → 30) along with the mutation table. Mutation-checked, both new guards detected: availability check removed -> 1 of 30 failed describeValue -> JSON.stringify -> 1 of 30 failed (unmutated) -> 30 passed The availability test asserts both directions: five contradictory configs throw, and the protected-resource document (served in every mode) plus the enabled forms are accepted, so the check cannot pass by over-rejecting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> --------- Signed-off-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
20e679bb1f
commit
de2cbaeeaf
1 changed files with 1168 additions and 3 deletions
|
|
@ -0,0 +1,745 @@
|
|||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type { Request, Response } from "express";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
withOAuthRequestTimeout,
|
||||
OAuthRequestTimeoutError,
|
||||
} from "@inspector/core/auth/requestTimeout.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
createEchoTool,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
STALLABLE_OAUTH_ENDPOINTS,
|
||||
type StallableOAuthEndpoint,
|
||||
createOAuthStallMiddleware,
|
||||
createStallRegistry,
|
||||
MAX_STALL_MS,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
/**
|
||||
* The OAuth-path request timeouts (#2319), driven against a **real, established,
|
||||
* idle socket** rather than a `fetch` stub (#2382).
|
||||
*
|
||||
* #2319 put an `AbortSignal.timeout` on five OAuth calls that previously went
|
||||
* out with no signal at all. Every test of that work injected a stubbed
|
||||
* `fetchFn`, which settles on the *client* side — so the state the issue
|
||||
* actually describes was never reproduced:
|
||||
*
|
||||
* > a stall is invisible in browser devtools […] while a Node-side socket sat
|
||||
* > established and idle.
|
||||
*
|
||||
* These tests use the `oauth.stallEndpoints` fixture capability to accept the
|
||||
* request and withhold the response, so the deadline is the only thing that can
|
||||
* end the call. A regression that dropped the wrapper would hang here until the
|
||||
* suite's own budget killed it, rather than passing against an obliging stub.
|
||||
*/
|
||||
const configsDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../../test-servers/configs",
|
||||
);
|
||||
|
||||
/** Comfortably under the suite budget, comfortably over a LAN round trip. */
|
||||
const BUDGET_MS = 1_500;
|
||||
|
||||
/** How each endpoint is requested, so every advertised name is really driven. */
|
||||
const ENDPOINTS: Record<
|
||||
StallableOAuthEndpoint,
|
||||
{ pathname: string; init: RequestInit; query?: Record<string, string> }
|
||||
> = {
|
||||
"protected-resource-metadata": {
|
||||
pathname: "/.well-known/oauth-protected-resource",
|
||||
init: { method: "GET" },
|
||||
},
|
||||
"as-metadata": {
|
||||
pathname: "/.well-known/oauth-authorization-server",
|
||||
init: { method: "GET" },
|
||||
},
|
||||
authorize: {
|
||||
pathname: "/oauth/authorize",
|
||||
init: { method: "GET" },
|
||||
// The one endpoint always called WITH a query string — which is what makes
|
||||
// the middleware's `req.path` match load-bearing. Matching `req.url`
|
||||
// instead passes every other case in this file and fails only here.
|
||||
query: {
|
||||
client_id: "test-client",
|
||||
response_type: "code",
|
||||
redirect_uri: "http://127.0.0.1:6274/oauth/callback",
|
||||
},
|
||||
},
|
||||
token: {
|
||||
pathname: "/oauth/token",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: "grant_type=authorization_code&code=irrelevant",
|
||||
},
|
||||
},
|
||||
revoke: {
|
||||
pathname: "/oauth/revoke",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: "token=irrelevant",
|
||||
},
|
||||
},
|
||||
register: {
|
||||
pathname: "/oauth/register",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: ["http://127.0.0.1:6274/cb"] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Wait for `predicate`, rather than sleeping and hoping. */
|
||||
async function until(
|
||||
predicate: () => boolean,
|
||||
what: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
describe("OAuth request timeouts against a stalled endpoint (#2382)", () => {
|
||||
let server: TestServerHttp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
function urlFor(base: string, endpoint: StallableOAuthEndpoint): string {
|
||||
const { pathname, query } = ENDPOINTS[endpoint];
|
||||
const url = new URL(pathname, base);
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function startStalling(
|
||||
endpoints: StallableOAuthEndpoint[],
|
||||
extra: { stallMs?: number } = {},
|
||||
): Promise<TestServerHttp> {
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("oauth-stall", "1.0.0"),
|
||||
tools: [createEchoTool()],
|
||||
oauth: {
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
requireAuth: true,
|
||||
scopesSupported: ["mcp"],
|
||||
supportDCR: true,
|
||||
stallEndpoints: endpoints,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
return started;
|
||||
}
|
||||
|
||||
// ⚠️ Every advertised endpoint, not a sample. `stallTargetsFor` hardcodes a
|
||||
// path and a method set per endpoint, so a typo in any entry produces a
|
||||
// fixture that quietly answers normally — the precise failure this option
|
||||
// exists to prevent, and one no other test would catch (Copilot).
|
||||
for (const endpoint of STALLABLE_OAUTH_ENDPOINTS) {
|
||||
it(`stalls the ${endpoint} endpoint until the deadline fires`, async () => {
|
||||
const started = await startStalling([endpoint]);
|
||||
const url = urlFor(started.url, endpoint);
|
||||
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||
const startedAt = Date.now();
|
||||
|
||||
await expect(timedFetch(url, ENDPOINTS[endpoint].init)).rejects.toThrow(
|
||||
OAuthRequestTimeoutError,
|
||||
);
|
||||
|
||||
// The deadline ended it, not an instant connection failure: a refused
|
||||
// port rejects in single-digit ms and would satisfy a bare
|
||||
// `rejects.toThrow` while proving nothing about the timer.
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(BUDGET_MS - 100);
|
||||
});
|
||||
|
||||
it(`names the ${endpoint} endpoint and the budget it gave up on`, async () => {
|
||||
const started = await startStalling([endpoint]);
|
||||
const url = urlFor(started.url, endpoint);
|
||||
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||
|
||||
await expect(
|
||||
timedFetch(url, ENDPOINTS[endpoint].init),
|
||||
).rejects.toMatchObject({
|
||||
timeoutMs: BUDGET_MS,
|
||||
url: expect.stringContaining(ENDPOINTS[endpoint].pathname),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("stalls only the selected endpoint, leaving the others answering", async () => {
|
||||
// The control. A fixture that stalled everything would make every test
|
||||
// above pass while telling us nothing about which call was bounded.
|
||||
const started = await startStalling(["token"]);
|
||||
const metadata = await fetch(
|
||||
urlFor(started.url, "protected-resource-metadata"),
|
||||
);
|
||||
|
||||
expect(metadata.ok).toBe(true);
|
||||
await expect(metadata.json()).resolves.toMatchObject({
|
||||
authorization_servers: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes two calls that share a path by their method", async () => {
|
||||
// ⚠️ Both configurable document paths are caller-supplied, so a config may
|
||||
// point one at a path another endpoint already serves. Keyed on path alone,
|
||||
// stalling `token` would also stall this GET (Copilot).
|
||||
const started = await startStalling(["token"]);
|
||||
const collided = new URL("/oauth/token", started.url).href;
|
||||
|
||||
// The token POST is stalled…
|
||||
await expect(
|
||||
withOAuthRequestTimeout(fetch, BUDGET_MS)(collided, ENDPOINTS.token.init),
|
||||
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||
|
||||
// …while a GET to the very same path is not held by the stall middleware.
|
||||
// Which status it gets does not matter; that a response arrives at all does.
|
||||
const samePathGet = await fetch(collided, { method: "GET" });
|
||||
expect(typeof samePathGet.status).toBe("number");
|
||||
});
|
||||
|
||||
it("answers late rather than never when stallMs is positive", async () => {
|
||||
// `stallMs` is advertised and was previously untested: every fixture used
|
||||
// 0, so the `setTimeout(() => next())` branch could regress unseen
|
||||
// (Copilot).
|
||||
const delayMs = 600;
|
||||
const started = await startStalling(["protected-resource-metadata"], {
|
||||
stallMs: delayMs,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
|
||||
const res = await fetch(urlFor(started.url, "protected-resource-metadata"));
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
// Late, but it did arrive — both halves matter.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(delayMs - 50);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
authorization_servers: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it("times out when the caller's budget is shorter than stallMs", async () => {
|
||||
const started = await startStalling(["token"], { stallMs: 5_000 });
|
||||
const timedFetch = withOAuthRequestTimeout(fetch, 400);
|
||||
|
||||
await expect(
|
||||
timedFetch(urlFor(started.url, "token"), ENDPOINTS.token.init),
|
||||
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||
});
|
||||
|
||||
it("stops holding the request when the client aborts a delayed stall", async () => {
|
||||
// End-to-end: the fixture lets go. This observes the REGISTRY, which is
|
||||
// decremented by the `close` listener — so it does NOT prove the timer was
|
||||
// cleared. That claim belongs to the focused test below, which can actually
|
||||
// see the timer (Copilot).
|
||||
const started = await startStalling(["token"], { stallMs: 10_000 });
|
||||
const controller = new AbortController();
|
||||
|
||||
const pending = fetch(urlFor(started.url, "token"), {
|
||||
...ENDPOINTS.token.init,
|
||||
signal: controller.signal,
|
||||
}).catch(() => "aborted");
|
||||
|
||||
await until(
|
||||
() => started.stalledRequestCount() >= 1,
|
||||
"the request to be parked",
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toBe("aborted");
|
||||
await until(
|
||||
() => started.stalledRequestCount() === 0,
|
||||
"the stall to be released",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown stallEndpoints entry instead of answering normally", async () => {
|
||||
// A typo must fail loudly. Ignored, it would produce a fixture that answers
|
||||
// promptly, and a timeout test written against it would fail pointing at
|
||||
// the timeout rather than at the config.
|
||||
//
|
||||
// The throw lands on `start()`, not on the constructor: routes — and so the
|
||||
// middleware — are built when the server is started. Asserting on the
|
||||
// constructor would pass for the wrong reason, since it validates nothing.
|
||||
const typo = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("oauth-stall-typo", "1.0.0"),
|
||||
tools: [createEchoTool()],
|
||||
oauth: {
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
// @ts-expect-error - deliberately not a StallableOAuthEndpoint
|
||||
stallEndpoints: ["tokens"],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(typo.start()).rejects.toThrow(
|
||||
/Unknown oauth\.stallEndpoints entry.*"tokens"/s,
|
||||
);
|
||||
// It must also name what WAS valid, or the fixture author is left guessing.
|
||||
await expect(typo.start()).rejects.toThrow(/Expected one of:.*token/s);
|
||||
|
||||
try {
|
||||
await typo.stop();
|
||||
} catch {
|
||||
// never started; nothing to stop
|
||||
}
|
||||
});
|
||||
|
||||
it("stops cleanly with a withheld response still in flight", async () => {
|
||||
// ⚠️ The property that makes this fixture usable at all. A withheld
|
||||
// response holds an established socket, and `stop()` relies on
|
||||
// `httpServer.closeAllConnections?.()` to destroy it. Without that, every
|
||||
// suite touching this fixture would hang at teardown instead of failing —
|
||||
// and it would hang in `afterEach`, pointing at the wrong test.
|
||||
const started = await startStalling(["token"]);
|
||||
|
||||
// Deliberately unawaited and unbounded: the point is that a request with no
|
||||
// deadline of its own is in flight when the server goes down.
|
||||
const pending = fetch(urlFor(started.url, "token"), {
|
||||
...ENDPOINTS.token.init,
|
||||
}).catch(() => "socket destroyed");
|
||||
|
||||
// ⚠️ Wait for the request to be ACCEPTED AND PARKED, never a fixed sleep.
|
||||
// A sleep that lost the race would stop the server before the request
|
||||
// arrived; the fetch would then reject because the server closed, and this
|
||||
// test would pass without ever exercising `closeAllConnections()` on an
|
||||
// established request (Copilot).
|
||||
await until(
|
||||
() => started.stalledRequestCount() >= 1,
|
||||
"the request to be parked",
|
||||
);
|
||||
|
||||
await expect(started.stop()).resolves.toBeUndefined();
|
||||
server = null;
|
||||
|
||||
await expect(pending).resolves.toBe("socket destroyed");
|
||||
});
|
||||
|
||||
it("stalls the authorize POST, not only its GET", async () => {
|
||||
// ⚠️ `authorize` is the only endpoint mapped to two methods, and the table
|
||||
// above drives it as a GET. Without this, deleting `"POST"` from its method
|
||||
// list passes the whole suite while a consent submission stops stalling
|
||||
// (Copilot).
|
||||
const started = await startStalling(["authorize"]);
|
||||
const url = new URL("/oauth/authorize", started.url).href;
|
||||
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||
|
||||
await expect(
|
||||
timedFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: "client_id=test-client&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A6274%2Foauth%2Fcallback",
|
||||
}),
|
||||
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||
});
|
||||
|
||||
it("follows resourceMetadataPath when the document is moved", async () => {
|
||||
// ⚠️ Both metadata paths are configurable, and the table above only ever
|
||||
// requests the DEFAULTS. A regression replacing either lookup in
|
||||
// `stallTargetsFor` with its default literal would leave every other test
|
||||
// green while `stallEndpoints` silently stopped working for any config that
|
||||
// moves the document (Copilot).
|
||||
const moved = "/custom/protected-resource.json";
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("oauth-stall-moved-prm", "1.0.0"),
|
||||
tools: [createEchoTool()],
|
||||
oauth: {
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
requireAuth: true,
|
||||
scopesSupported: ["mcp"],
|
||||
resourceMetadataPath: moved,
|
||||
stallEndpoints: ["protected-resource-metadata"],
|
||||
},
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
|
||||
await expect(
|
||||
withOAuthRequestTimeout(
|
||||
fetch,
|
||||
BUDGET_MS,
|
||||
)(new URL(moved, started.url).href),
|
||||
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||
|
||||
// And the DEFAULT path is no longer the stalled one — it is not served at
|
||||
// all once the document moves, so it answers (404) instead of hanging.
|
||||
const atDefault = await fetch(
|
||||
new URL("/.well-known/oauth-protected-resource", started.url).href,
|
||||
);
|
||||
expect(typeof atDefault.status).toBe("number");
|
||||
});
|
||||
|
||||
it("follows asMetadataPath when the AS document is moved", async () => {
|
||||
const moved = "/custom/as-metadata.json";
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("oauth-stall-moved-as", "1.0.0"),
|
||||
tools: [createEchoTool()],
|
||||
oauth: {
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
requireAuth: true,
|
||||
scopesSupported: ["mcp"],
|
||||
asMetadataPath: moved,
|
||||
stallEndpoints: ["as-metadata"],
|
||||
},
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
|
||||
await expect(
|
||||
withOAuthRequestTimeout(
|
||||
fetch,
|
||||
BUDGET_MS,
|
||||
)(new URL(moved, started.url).href),
|
||||
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||
});
|
||||
|
||||
it("drives the two checked-in showcase configs, not just inline ones", async () => {
|
||||
// The JSON-to-ServerConfig mapping is its own failure surface: a misspelled
|
||||
// key in either file, or a `resolveConfig` that stopped threading
|
||||
// `stallEndpoints`, would leave the documented manual fixtures quietly
|
||||
// permissive while every inline test above stayed green.
|
||||
for (const file of [
|
||||
"oauth-stalled-token-http.json",
|
||||
"oauth-stalled-discovery-http.json",
|
||||
]) {
|
||||
const resolved = resolveConfig(loadConfig(path.join(configsDir, file)));
|
||||
const started = createTestServerHttp(resolved);
|
||||
await started.start();
|
||||
server = started;
|
||||
|
||||
const endpoint: StallableOAuthEndpoint = file.includes("token")
|
||||
? "token"
|
||||
: "protected-resource-metadata";
|
||||
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||
|
||||
await expect(
|
||||
timedFetch(urlFor(started.url, endpoint), ENDPOINTS[endpoint].init),
|
||||
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||
|
||||
await started.stop();
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The middleware on its own, with no server, so the timer itself is observable.
|
||||
*
|
||||
* The end-to-end abort test can only watch the registry, which a separate
|
||||
* `close` listener decrements — so it passes with the timer still armed and
|
||||
* cannot support a claim about `clearTimeout` (Copilot). Here `next` is a spy
|
||||
* and time is fake, so "the delayed answer never fires after a close" is
|
||||
* directly checkable.
|
||||
*/
|
||||
describe("createOAuthStallMiddleware timer cleanup (#2382)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal Express doubles, bridged to the real types.
|
||||
*
|
||||
* The middleware touches exactly three things — `req.method`, `req.path` and
|
||||
* `res.on("close", …)` — so a full `Request`/`Response` is unnecessary and a
|
||||
* real server would defeat the purpose (the whole point here is to control
|
||||
* time and observe `next`).
|
||||
*
|
||||
* ⚠️ The bridge is a justified double cast, NOT an `any`: this repo forbids
|
||||
* `any` outright and forbids disabling the rule to satisfy the linter
|
||||
* (`AGENTS.md`, Typescript instructions). A single `as Request` cannot work —
|
||||
* the literal is missing ~50 properties, so TS rejects the conversion — and
|
||||
* these doubles are structurally correct for every field the code under test
|
||||
* reads. If the middleware ever reads more, the double will be wrong at
|
||||
* runtime rather than silently absorbing it, which is what an `any` would do
|
||||
* (Copilot).
|
||||
*/
|
||||
function fakeRes(): { res: Response; close: () => void } {
|
||||
const listeners: Array<() => void> = [];
|
||||
const double = {
|
||||
on(event: string, fn: () => void) {
|
||||
if (event === "close") listeners.push(fn);
|
||||
return double;
|
||||
},
|
||||
};
|
||||
return {
|
||||
res: double as unknown as Response,
|
||||
close: () => listeners.forEach((fn) => fn()),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeReq(method: string, path: string): Request {
|
||||
return { method, path } as unknown as Request;
|
||||
}
|
||||
|
||||
const tokenReq = fakeReq("POST", "/oauth/token");
|
||||
|
||||
it("does not answer a delayed stall after the client closed", () => {
|
||||
const middleware = createOAuthStallMiddleware(
|
||||
{
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
stallMs: 10_000,
|
||||
},
|
||||
createStallRegistry(),
|
||||
);
|
||||
expect(middleware).not.toBeNull();
|
||||
|
||||
const next = vi.fn();
|
||||
const { res, close } = fakeRes();
|
||||
middleware!(tokenReq, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
close();
|
||||
|
||||
// Past the delay, with the timer cleared: nothing should fire. Without
|
||||
// `clearTimeout` this advances into `next()` and the spy is called.
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still answers a delayed stall that is not closed", () => {
|
||||
// The complement — otherwise a middleware that never calls `next` at all
|
||||
// would satisfy the test above.
|
||||
const middleware = createOAuthStallMiddleware(
|
||||
{
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
stallMs: 10_000,
|
||||
},
|
||||
createStallRegistry(),
|
||||
);
|
||||
const next = vi.fn();
|
||||
const { res } = fakeRes();
|
||||
middleware!(tokenReq, res, next);
|
||||
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a malformed stallEndpoints container instead of disabling silently", () => {
|
||||
// ⚠️ The container, not just its entries. A config file is cast, so
|
||||
// `stallEndpoints` can arrive as a string or an object with a `length`. A
|
||||
// bare `.length === 0` check accepts `""` and `{ length: 0 }` and returns
|
||||
// "no stalling configured" — silently disabling the very capability this
|
||||
// validation exists to protect (Copilot).
|
||||
for (const stallEndpoints of ["", "token", null, { length: 0 }, 3]) {
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||
stallEndpoints,
|
||||
}),
|
||||
).toThrow(/oauth\.stallEndpoints must be an array/);
|
||||
}
|
||||
|
||||
// An omitted value is not malformed — it means "no stalling".
|
||||
expect(
|
||||
createOAuthStallMiddleware({ enabled: true, mode: "combined" }),
|
||||
).toBeNull();
|
||||
// Nor is an explicitly empty array.
|
||||
expect(
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to stall a route this config does not serve", () => {
|
||||
// ⚠️ The middleware runs BEFORE Express routing, so without this it will
|
||||
// hold a request for an endpoint that would otherwise 404 — turning a
|
||||
// contradictory fixture into a hang that reads as a timeout, and inviting a
|
||||
// test that passes for entirely the wrong reason (Copilot).
|
||||
const cases: Array<[Record<string, unknown>, StallableOAuthEndpoint]> = [
|
||||
// DCR off, but asked to stall the registration endpoint.
|
||||
[{ mode: "combined", supportDCR: false }, "register"],
|
||||
// Revocation explicitly disabled.
|
||||
[{ mode: "combined", supportRevocation: false }, "revoke"],
|
||||
// protected-resource mode serves no local AS routes at all.
|
||||
[
|
||||
{
|
||||
mode: "protected-resource",
|
||||
authorizationServers: ["https://as.example"],
|
||||
},
|
||||
"token",
|
||||
],
|
||||
[
|
||||
{
|
||||
mode: "protected-resource",
|
||||
authorizationServers: ["https://as.example"],
|
||||
},
|
||||
"authorize",
|
||||
],
|
||||
[
|
||||
{
|
||||
mode: "protected-resource",
|
||||
authorizationServers: ["https://as.example"],
|
||||
},
|
||||
"as-metadata",
|
||||
],
|
||||
];
|
||||
|
||||
for (const [oauth, endpoint] of cases) {
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
...oauth,
|
||||
stallEndpoints: [endpoint],
|
||||
}),
|
||||
).toThrow(/does not serve/);
|
||||
}
|
||||
|
||||
// The protected-resource document is served in every mode, so stalling it
|
||||
// is always legitimate — the check must not over-reject.
|
||||
expect(
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "protected-resource",
|
||||
authorizationServers: ["https://as.example"],
|
||||
stallEndpoints: ["protected-resource-metadata"],
|
||||
}),
|
||||
).not.toBeNull();
|
||||
// And the enabled forms are accepted.
|
||||
expect(
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
supportDCR: true,
|
||||
stallEndpoints: ["register", "revoke", "token"],
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("names the offending value honestly, including NaN and Infinity", () => {
|
||||
// ⚠️ `JSON.stringify(NaN)` is the string "null", so an error built with it
|
||||
// reports a value the author never wrote (Copilot).
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
stallMs: Number.NaN,
|
||||
}),
|
||||
).toThrow(/got NaN/);
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
stallMs: Infinity,
|
||||
}),
|
||||
).toThrow(/got Infinity/);
|
||||
// A string keeps its quotes, so "600" stays distinguishable from 600.
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||
stallMs: "600",
|
||||
}),
|
||||
).toThrow(/got "600"/);
|
||||
});
|
||||
|
||||
it("rejects an explicit stallMs: null rather than defaulting it to 0", () => {
|
||||
// ⚠️ `?? 0` would turn `null` into a valid 0 and skip every check, silently
|
||||
// producing a permanent stall. Only an OMITTED value gets the default
|
||||
// (Copilot).
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||
stallMs: null,
|
||||
}),
|
||||
).toThrow(/oauth\.stallMs must be a finite number/);
|
||||
|
||||
// Omitted still means 0 (stall forever), which is the documented default.
|
||||
expect(
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a stallMs that is negative, non-finite or past the timer range", () => {
|
||||
// ⚠️ A JSON/YAML config is only CAST to its interface, so anything can
|
||||
// arrive here. Unvalidated, a negative value makes `stallMs > 0` false and
|
||||
// silently becomes a PERMANENT stall, and a value past the 32-bit timer
|
||||
// range overflows to ~1ms and answers almost immediately — both read as
|
||||
// "the timeout behaved strangely" rather than "the config is wrong"
|
||||
// (Copilot).
|
||||
for (const stallMs of [-1, Number.NaN, Infinity, MAX_STALL_MS + 1]) {
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
stallMs,
|
||||
}),
|
||||
).toThrow(/oauth\.stallMs must be a finite number/);
|
||||
}
|
||||
// A non-numeric value from an unvalidated config file.
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||
stallMs: "600",
|
||||
}),
|
||||
).toThrow(/oauth\.stallMs must be a finite number/);
|
||||
|
||||
// The boundaries themselves are valid.
|
||||
for (const stallMs of [0, MAX_STALL_MS]) {
|
||||
expect(() =>
|
||||
createOAuthStallMiddleware({
|
||||
enabled: true,
|
||||
mode: "combined",
|
||||
stallEndpoints: ["token"],
|
||||
stallMs,
|
||||
}),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue