mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): walk the MCP consent screen back to a loopback callback
A connector signing in through the MCP OAuth consent screen gets stuck after Allow in Chromium: the grant is issued, but the browser never reaches the client's callback. Only a real browser enforces the page's CSP on that redirect, so this is a browser journey. It registers a client with a local loopback callback, clicks Allow and requires the callback to receive a code, state and issuer. It runs against the second server, which now carries APP_URL because the MCP surface fails closed without a configured origin. Refs #983
This commit is contained in:
1 parent
970c89a5e7
commit
7b7a77b292
2 files changed
+121
-4
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server";
|
||||
|
||||
/**
|
||||
* The MCP consent screen hands the browser back to the connecting client.
|
||||
*
|
||||
* After "Allow", the consent form posts to its own origin and the server
|
||||
* answers with a redirect to the client's `redirect_uri`. Chromium applies the
|
||||
* consent page's CSP `form-action` to that redirect as well, so a page that
|
||||
* only allows `'self'` strands the user on the consent screen while the grant
|
||||
* has already been issued server-side. Only a real browser enforces CSP, which
|
||||
* is why this is an e2e journey and not a route test.
|
||||
*
|
||||
* It runs against the second server, the one started with `APP_URL`: the MCP
|
||||
* surface fails closed without a configured origin. The callback is a local
|
||||
* loopback listener, which is also what desktop MCP clients register.
|
||||
*/
|
||||
test.describe("MCP OAuth consent", () => {
|
||||
test.use({
|
||||
storageState: STORAGE_STATE_PATH,
|
||||
baseURL: SSR_PREFETCH_BASE_URL,
|
||||
});
|
||||
|
||||
let callback: Server;
|
||||
let callbackUrl = "";
|
||||
const hits: URL[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
callback = createServer((req, res) => {
|
||||
hits.push(new URL(req.url ?? "/", callbackUrl));
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end("<!doctype html><title>Callback</title><p>Connected.</p>");
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
callback.listen(0, "127.0.0.1", () => resolve()),
|
||||
);
|
||||
const { port } = callback.address() as AddressInfo;
|
||||
callbackUrl = `http://127.0.0.1:${port}/callback`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await new Promise<void>((resolve) => callback.close(() => resolve()));
|
||||
});
|
||||
|
||||
test("Allow lands on the client's callback with a code", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"The redirect is enforced by the browser engine, not the layout; one Chromium run is the proof.",
|
||||
);
|
||||
|
||||
// Register over the page's own connection rather than the shared API
|
||||
// request pool, which can hand out a socket the server already closed.
|
||||
await page.goto("/api/version");
|
||||
const registration = await page.evaluate(async (redirectUri) => {
|
||||
const res = await fetch("/api/mcp/oauth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "E2E consent client",
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
}),
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}, callbackUrl);
|
||||
expect(registration.status, JSON.stringify(registration.body)).toBe(201);
|
||||
const clientId = (registration.body as { client_id: string }).client_id;
|
||||
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
const state = randomBytes(8).toString("hex");
|
||||
const origin = new URL(SSR_PREFETCH_BASE_URL).origin;
|
||||
const authorize = new URL("/api/mcp/oauth/authorize", origin);
|
||||
for (const [key, value] of Object.entries({
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: callbackUrl,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
state,
|
||||
resource: `${origin}/mcp`,
|
||||
})) {
|
||||
authorize.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
await page.goto(authorize.toString());
|
||||
const allow = page.getByRole("button", { name: "Allow" });
|
||||
await expect(allow).toBeVisible();
|
||||
await allow.click();
|
||||
|
||||
await page.waitForURL((url) => url.toString().startsWith(callbackUrl), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
const hit = hits.at(-1);
|
||||
expect(hit, "the callback listener was never reached").toBeDefined();
|
||||
expect(hit!.searchParams.get("code")).toMatch(/^hlac_/);
|
||||
expect(hit!.searchParams.get("state")).toBe(state);
|
||||
expect(hit!.searchParams.get("iss")).toBe(origin);
|
||||
});
|
||||
});
|
||||
+10
-4
@@ -298,10 +298,15 @@ export default defineConfig({
|
||||
// rebuilt it. The suite could not see it, because with the prefetch
|
||||
// off both sides paint the same skeleton.
|
||||
//
|
||||
// One extra server on 3100 closes that. Only
|
||||
// `dashboard-ssr-prefetch-hydration.spec.ts` talks to it, and it
|
||||
// asserts the one thing the mocked specs cannot: that the page the
|
||||
// server streamed is the page the browser keeps.
|
||||
// One extra server on 3100 closes that. It asserts the one thing the
|
||||
// mocked specs cannot: that the page the server streamed is the page
|
||||
// the browser keeps (`dashboard-ssr-prefetch-hydration.spec.ts`).
|
||||
//
|
||||
// It also carries `APP_URL`, as a real deployment does. The MCP OAuth
|
||||
// surface fails closed without a configured origin, so the consent
|
||||
// journey (`mcp-oauth-consent-redirect.spec.ts`) runs here. The shared
|
||||
// server stays without it, because `APP_URL` also narrows the accepted
|
||||
// passkey origins and shapes invite links for every other spec.
|
||||
{
|
||||
command: `PORT=${SSR_PREFETCH_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
|
||||
url: `${SSR_PREFETCH_BASE_URL}/api/version`,
|
||||
@@ -313,6 +318,7 @@ export default defineConfig({
|
||||
...process.env,
|
||||
NATIVE_CANVAS: "off",
|
||||
DASHBOARD_SSR_PREFETCH: "true",
|
||||
APP_URL: SSR_PREFETCH_BASE_URL,
|
||||
},
|
||||
},
|
||||
// The mail-configured, scheduler-free server, on its own port.
|
||||
|
||||
Reference in new issue
Block a user