Files
computer-playbook/roles/test-e2e-playwright/files/mcp-endpoint.js
kevinveenbirkenbachandClaude Opus 5 a24fcb3950 fix(test-e2e-playwright): assert the MCP probe URL is absolute, not merely truthy
registerMcpDisabledState and registerMcpGuestRejection opened with
expect(endpointUrl).toBeTruthy(). normalizeBaseUrl turns an unset base
into "", so mcpEndpointUrl() still returns "/mcp", which is truthy, and
Playwright resolves the relative path against baseURL (APP_BASE_URL or
http://127.0.0.1). A role whose own base URL variable was missing
therefore probed some other host and could pass.

Both probes now require /^https?:\/\/[^/]+/. Every caller builds its URL
from a base that lookup('tls', ..., 'url.base') renders as
"<scheme>://<domain>/", so the check holds in tor mode
(http://<app>.<onion>.onion/) and in clearnet mode (https:// or http://
with TLS off) alike, and fails only when the base is unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:10:22 +02:00

120 lines
4.6 KiB
JavaScript

/**
* Shared disabled-state assertion for MCP endpoints.
*
* Every role-local MCP spec gates itself on `MCP_SERVICE_ENABLED` and skips
* when the surface is off, so nothing ever checked that switching it off
* actually removes it. A surface still serving after the flag went false is
* indistinguishable from one that was never deployed, which is the failure
* this registers a test for.
*
* The check is deliberately transport-agnostic. Roles serve over classic SSE,
* Streamable HTTP or plain JSON-RPC and their enabled-state specs differ
* accordingly, but an endpoint that is gone answers none of them, so both a
* GET and a POST have to come back unserved.
*
* Contract:
* mcpEndpointUrl(path?, baseUrl?) -> the endpoint under baseUrl, defaulting
* to `/mcp` under APP_BASE_URL. Roles that export their own base variable
* pass it rather than declaring APP_BASE_URL: the shared guest persona
* collapses itself on `!APP_BASE_URL`, so adding one to a role that has
* none turns its never-executed public journey live.
* registerMcpDisabledState(() => endpointUrl) -> registers one Playwright test
* registerMcpGuestRejection(() => endpointUrl) -> registers one Playwright test
*
* The URL is passed as a thunk because the roles build it differently and some
* of them resolve it from fixtures that only exist once a test is running.
*/
const { test, expect } = require("@playwright/test");
const { normalizeBaseUrl } = require("./personas/utils/dotenv");
const {
skipUnlessServiceDisabled,
skipUnlessServiceEnabled,
} = require("./service-gating");
const SERVED = new Set([200, 201, 202, 401, 403, 405, 406, 415, 429]);
const ABSOLUTE_URL = /^https?:\/\/[^/]+/;
function mcpEndpointUrl(path = "/mcp", baseUrl = process.env.APP_BASE_URL) {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
function registerMcpDisabledState(resolveEndpointUrl) {
test("guest: the MCP endpoint is gone while the service is switched off", async ({
page,
}) => {
skipUnlessServiceDisabled("mcp");
const endpointUrl = resolveEndpointUrl();
expect(endpointUrl, "the MCP endpoint URL must resolve to an absolute URL even while disabled").toMatch(ABSOLUTE_URL);
const attempts = [
await page.request.get(endpointUrl, {
failOnStatusCode: false,
maxRedirects: 0,
headers: { accept: "text/event-stream" },
}),
await page.request.post(endpointUrl, {
failOnStatusCode: false,
maxRedirects: 0,
headers: { Accept: "application/json", "Content-Type": "application/json" },
data: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} },
}),
];
for (const response of attempts) {
const status = response.status();
const excerpt = (await response.text()).slice(0, 200);
expect(
SERVED.has(status),
`${endpointUrl} answered ${status} with ${excerpt} while MCP is disabled; ` +
`a refused credential still means the surface is listening`,
).toBe(false);
expect(
response.headers()["content-type"] || "",
"a disabled endpoint must not open the SSE channel",
).not.toContain("text/event-stream");
}
});
}
function registerMcpGuestRejection(resolveEndpointUrl, label = "") {
const title = label
? `mcp: an unauthenticated probe of ${label} is rejected`
: "mcp: an unauthenticated probe of the MCP endpoint is rejected";
test(title, async ({ page }) => {
skipUnlessServiceEnabled("mcp");
const endpointUrl = resolveEndpointUrl();
expect(endpointUrl, "the MCP endpoint URL must resolve to an absolute URL").toMatch(ABSOLUTE_URL);
// maxRedirects: 0 because a vhost may answer the bearerless probe with an
// SSO redirect; following it would land on a 200 login page and hide that
// no MCP response was ever served.
const response = await page.request.post(endpointUrl, {
failOnStatusCode: false,
maxRedirects: 0,
headers: { "Content-Type": "application/json" },
data: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
});
expect(
response.status(),
`POST ${endpointUrl} answered ${response.status()} — the MCP server is ` +
"internal-only, so the public vhost must redirect or refuse a bearerless " +
"initialize, never answer it.",
).toBeGreaterThanOrEqual(300);
expect(
await response.text(),
"the public vhost must not return an MCP protocol response to a bearerless probe",
).not.toContain('"jsonrpc"');
});
}
module.exports = {
mcpEndpointUrl,
registerMcpDisabledState,
registerMcpGuestRejection,
};