mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
test(mcp): prove a switched-off surface is actually gone
Every role-local MCP spec gates itself on the service flag and skips when the surface is off, so nothing ever checked that switching it off removes it. A surface still serving after the flag went false was indistinguishable from one that was never deployed: both read as a green skip. The gating helper had no inverse at all, so there was no way to assert what a disabled service must not do. A shared assertion now probes the endpoint with both a GET and a POST while the flag is false and fails on any answer that shows something listening. It counts 401 and 403 as listening, which is the point: a refused credential still means the surface is there. The check is transport-agnostic because the roles differ in their enabled-state probes, over SSE, Streamable HTTP or plain JSON-RPC, while an endpoint that is gone answers none of them. Each role passes its endpoint URL as a thunk, since some resolve it only once a test is running. The coverage lint requires the registration, and derives the role set from meta/mcp.yml rather than from a filename convention, which is how the tenth role surfaced: its spec is not named like the other nine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ec736bd4d7
commit
7d8e7e87ff
13 files changed
+300
No files matched your search
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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:
|
||||
* registerMcpDisabledState(() => 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 { skipUnlessServiceDisabled } = require("./service-gating");
|
||||
|
||||
const SERVED = new Set([200, 201, 202, 401, 403, 405, 406, 415, 429]);
|
||||
|
||||
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 even while disabled").toBeTruthy();
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerMcpDisabledState };
|
||||
@@ -121,6 +121,25 @@ function skipUnlessServiceEnabled(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the current test unless the named service is disabled, so a spec can
|
||||
* assert what a switched-off service must NOT do:
|
||||
*
|
||||
* test("the endpoint is gone while mcp is off", async ({ page }) => {
|
||||
* skipUnlessServiceDisabled("mcp");
|
||||
* // assert absence below
|
||||
* });
|
||||
*
|
||||
* Without this the disabled state is never asserted at all: every gated spec
|
||||
* skips itself when the flag is false, so a surface that kept serving after
|
||||
* being switched off looks exactly like one that was never deployed.
|
||||
*/
|
||||
function skipUnlessServiceDisabled(name) {
|
||||
if (isServiceEnabled(name)) {
|
||||
test.skip(true, `${envKey(name)}=true`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy wrapper kept for compatibility with callers that were written
|
||||
* against the wrap-style API. New code SHOULD use
|
||||
@@ -142,5 +161,6 @@ module.exports = {
|
||||
isServiceEnabled,
|
||||
isServiceDisabledReason,
|
||||
requireService,
|
||||
skipUnlessServiceDisabled,
|
||||
skipUnlessServiceEnabled,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { normalizeBaseUrl } = require("./personas");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const UNKNOWN_ENDPOINT_KEY = "0".repeat(32);
|
||||
|
||||
@@ -25,3 +26,8 @@ test("mcp: an unauthenticated probe of the MCP endpoint is rejected", async ({ p
|
||||
"a probe carrying no valid endpoint key must never receive an MCP session endpoint",
|
||||
).not.toContain("event: endpoint");
|
||||
});
|
||||
|
||||
registerMcpDisabledState(
|
||||
() =>
|
||||
`${normalizeBaseUrl(process.env.BASEROW_BASE_URL || "")}/mcp/${UNKNOWN_ENDPOINT_KEY}/sse`,
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
exports.register = function (shared) {
|
||||
test("mcp: an unauthenticated probe of the MCP endpoint is rejected", async ({ page }) => {
|
||||
@@ -27,4 +28,6 @@ exports.register = function (shared) {
|
||||
"the public vhost must not return an MCP protocol response to a bearerless probe",
|
||||
).not.toContain('"jsonrpc"');
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() => shared.mcpEndpointUrl());
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { decodeDotenvQuotedValue, normalizeBaseUrl } = require("./personas");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
|
||||
const mcpEndpointPath = decodeDotenvQuotedValue(process.env.MCP_ENDPOINT_PATH || "");
|
||||
@@ -28,3 +29,5 @@ test("guest: the MCP endpoint rejects unauthenticated access", async ({ page })
|
||||
"a refused probe must not return an MCP protocol response",
|
||||
).not.toContain('"jsonrpc"');
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() => `${appBaseUrl}${mcpEndpointPath}`);
|
||||
@@ -2,6 +2,7 @@ const { test, expect } = require("@playwright/test");
|
||||
const { resolveTimeout } = require("./timeouts");
|
||||
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
exports.register = function (shared) {
|
||||
test("mcp: an unauthenticated probe of the MCP endpoint is rejected", async ({ request }) => {
|
||||
@@ -25,4 +26,8 @@ exports.register = function (shared) {
|
||||
"a refused probe must not return an MCP protocol response",
|
||||
).not.toContain('"jsonrpc"');
|
||||
});
|
||||
|
||||
registerMcpDisabledState(
|
||||
() => `${shared.env.baseUrl.replace(/\/+$/, "")}/api/mcp`,
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { decodeDotenvQuotedValue, normalizeBaseUrl } = require("./personas");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const baseUrl = normalizeBaseUrl(process.env.JENKINS_BASE_URL || "");
|
||||
const mcpEndpointPath = decodeDotenvQuotedValue(process.env.MCP_ENDPOINT_PATH || "");
|
||||
@@ -27,3 +28,7 @@ test("MCP: the plugin endpoint serves no tool without credentials", async ({ pag
|
||||
"a refused tools/list must not leak a tool inventory",
|
||||
).not.toContain('"tools"');
|
||||
});
|
||||
|
||||
registerMcpDisabledState(
|
||||
() => `${baseUrl.replace(/\/$/, "")}${mcpEndpointPath}`,
|
||||
);
|
||||
@@ -2,6 +2,7 @@ const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { decodeDotenvQuotedValue } = require("./personas");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const MCP_ENDPOINT_PATH = decodeDotenvQuotedValue(process.env.MCP_ENDPOINT_PATH);
|
||||
|
||||
@@ -33,4 +34,8 @@ exports.register = function (shared) {
|
||||
"an unauthenticated MCP initialize must not be served a 2xx; the endpoint is bearer-guarded",
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() =>
|
||||
new URL(MCP_ENDPOINT_PATH, shared.expectedMattermostBaseUrl()).toString(),
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { decodeDotenvQuotedValue } = require("./personas");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const mcpEndpointPath = decodeDotenvQuotedValue(process.env.MCP_ENDPOINT_PATH || "");
|
||||
|
||||
@@ -34,4 +35,8 @@ exports.register = function (shared) {
|
||||
`an unauthenticated MCP probe must be rejected, got ${status} with ${excerpt}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() =>
|
||||
new URL(mcpEndpointPath, shared.env.moodleBaseUrl).toString(),
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
const { decodeDotenvQuotedValue, normalizeBaseUrl } = require("./personas");
|
||||
|
||||
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
|
||||
@@ -27,3 +28,5 @@ test("guest: the managed MCP trigger answers nobody without its bearer", async (
|
||||
"a refused probe must not open the SSE channel",
|
||||
).not.toContain("text/event-stream");
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() => `${appBaseUrl}${mcpEndpointPath}`);
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { decodeDotenvQuotedValue } = require("./personas");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const mcpEndpointPath = decodeDotenvQuotedValue(process.env.NEXTCLOUD_MCP_ENDPOINT_PATH);
|
||||
|
||||
@@ -30,4 +31,8 @@ exports.register = function (shared) {
|
||||
"an unauthenticated MCP probe must not be served a 2xx; the endpoint is app-password guarded",
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() =>
|
||||
new URL(mcpEndpointPath, shared.env.nextcloudBaseUrl).toString(),
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const shared = require("./_shared");
|
||||
const { registerMcpDisabledState } = require("./mcp-endpoint");
|
||||
|
||||
const MCP_ENDPOINT = "/wp-json/mcp/mcp-adapter-default-server";
|
||||
|
||||
@@ -26,3 +27,5 @@ test("guest: the MCP endpoint rejects unauthenticated access", async ({ page })
|
||||
"a refused probe must not return an MCP protocol response",
|
||||
).not.toContain('"jsonrpc"');
|
||||
});
|
||||
|
||||
registerMcpDisabledState(() => `${shared.env.wpBaseUrl}${MCP_ENDPOINT}`);
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Lint: an enabled MCP surface is covered by a Playwright spec of its own.
|
||||
|
||||
The deploy-time provider probe proves the endpoint answers *its* credential. It
|
||||
says nothing about what the endpoint answers a stranger, because it never asks
|
||||
without one. That question belongs to a browser-side spec, and a role that
|
||||
turns its MCP surface on without one ships an endpoint nobody ever asked the
|
||||
hostile question of.
|
||||
|
||||
Two relations per role whose ``meta/mcp.yml`` is enabled:
|
||||
|
||||
* it ships at least one file under ``files/playwright/`` whose name carries
|
||||
``mcp``, so the surface has a spec rather than being covered incidentally by
|
||||
a persona test;
|
||||
* a role serving MCP additionally asserts the unauthenticated case somewhere in
|
||||
those files. The anchor is vocabulary — ``unauthenticated``, ``bearerless``,
|
||||
``without a bearer``, ``refus…`` or ``reject…`` — so name the assertion in
|
||||
those words rather than leaving a reader to infer it.
|
||||
|
||||
Disabled surfaces are exempt: nothing is served until an operator turns them on.
|
||||
|
||||
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
|
||||
|
||||
* ``# nocheck: mcp-playwright-coverage`` in the head of the role's
|
||||
``meta/mcp.yml``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_in_head
|
||||
from utils.cache.files import read_text
|
||||
from utils.cache.yaml import load_yaml_any
|
||||
from utils.roles.mapping import ROLE_FILE_META_MCP
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_RULE = "mcp-playwright-coverage"
|
||||
_SERVER_DIRECTIONS = frozenset({"server", "both"})
|
||||
_CLIENT_DIRECTIONS = frozenset({"client", "both"})
|
||||
_UNAUTHENTICATED = re.compile(
|
||||
r"unauthenticated|bearerless|without a bearer|refus|reject", re.IGNORECASE
|
||||
)
|
||||
_CLIENT_SURFACE = re.compile(
|
||||
r"unauthenticated|refus|reject|server list|servers\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _enabled_mcp_roles() -> dict[str, str]:
|
||||
"""Return ``{role: direction}`` for every role serving or consuming MCP."""
|
||||
roles: dict[str, str] = {}
|
||||
for mcp_path in sorted(Path(PROJECT_ROOT, "roles").glob(f"*/{ROLE_FILE_META_MCP}")):
|
||||
mcp = load_yaml_any(str(mcp_path), default_if_missing={})
|
||||
if not isinstance(mcp, Mapping) or not mcp.get("enabled"):
|
||||
continue
|
||||
if is_suppressed_in_head(read_text(str(mcp_path)).splitlines(), _RULE):
|
||||
continue
|
||||
roles[mcp_path.parent.parent.name] = (
|
||||
str(mcp.get("direction") or "").strip().lower()
|
||||
)
|
||||
return roles
|
||||
|
||||
|
||||
def _mcp_specs(role: str) -> list[Path]:
|
||||
directory = PROJECT_ROOT / "roles" / role / "files" / "playwright"
|
||||
return sorted(p for p in directory.glob("*.js") if "mcp" in p.name)
|
||||
|
||||
|
||||
class TestMcpPlaywrightCoverage(unittest.TestCase):
|
||||
def test_every_enabled_surface_has_a_spec(self) -> None:
|
||||
missing = [
|
||||
f"{role}: its MCP surface is enabled but files/playwright/ holds no "
|
||||
f"mcp spec, so nothing exercises it from outside the deploy"
|
||||
for role in sorted(_enabled_mcp_roles())
|
||||
if not _mcp_specs(role)
|
||||
]
|
||||
self.assertEqual(
|
||||
[],
|
||||
missing,
|
||||
f"enabled MCP surface(s) without a spec ({len(missing)}):\n"
|
||||
+ "\n".join(f" - {m}" for m in missing),
|
||||
)
|
||||
|
||||
def test_every_served_surface_asserts_the_unauthenticated_case(self) -> None:
|
||||
silent = []
|
||||
for role, direction in sorted(_enabled_mcp_roles().items()):
|
||||
if direction not in _SERVER_DIRECTIONS:
|
||||
continue
|
||||
specs = _mcp_specs(role)
|
||||
if specs and not any(
|
||||
_UNAUTHENTICATED.search(read_text(str(spec))) for spec in specs
|
||||
):
|
||||
silent.append(
|
||||
f"{role}: serves MCP but no spec names the unauthenticated "
|
||||
f"case, so the endpoint is only ever asked with a valid "
|
||||
f"credential"
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
silent,
|
||||
f"served MCP surface(s) never probed anonymously ({len(silent)}):\n"
|
||||
+ "\n".join(f" - {s}" for s in silent),
|
||||
)
|
||||
|
||||
def test_every_served_surface_asserts_the_disabled_case(self) -> None:
|
||||
silent = []
|
||||
for role, direction in sorted(_enabled_mcp_roles().items()):
|
||||
if direction not in _SERVER_DIRECTIONS:
|
||||
continue
|
||||
specs = _mcp_specs(role)
|
||||
if specs and not any(
|
||||
"registerMcpDisabledState" in read_text(str(spec)) for spec in specs
|
||||
):
|
||||
silent.append(
|
||||
f"{role}: every MCP spec here skips itself when the surface "
|
||||
f"is switched off, so one that kept serving afterwards looks "
|
||||
f"exactly like one that was never deployed"
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
silent,
|
||||
f"served MCP surface(s) never probed while disabled ({len(silent)}):\n"
|
||||
+ "\n".join(f" - {s}" for s in silent),
|
||||
)
|
||||
|
||||
def test_every_consumed_surface_asserts_what_the_client_exposes(self) -> None:
|
||||
silent = []
|
||||
for role, direction in sorted(_enabled_mcp_roles().items()):
|
||||
if direction not in _CLIENT_DIRECTIONS:
|
||||
continue
|
||||
specs = _mcp_specs(role)
|
||||
if specs and not any(
|
||||
_CLIENT_SURFACE.search(read_text(str(spec))) for spec in specs
|
||||
):
|
||||
silent.append(
|
||||
f"{role}: consumes MCP but no spec asserts either the "
|
||||
f"configured server list or that its own API refuses an "
|
||||
f"unauthenticated caller, so the credential it holds is "
|
||||
f"never shown to be out of reach"
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
silent,
|
||||
f"MCP client(s) with an unexercised surface ({len(silent)}):\n"
|
||||
+ "\n".join(f" - {s}" for s in silent),
|
||||
)
|
||||
|
||||
def test_the_scan_finds_enabled_surfaces(self) -> None:
|
||||
roles = _enabled_mcp_roles()
|
||||
self.assertTrue(
|
||||
roles,
|
||||
"no role enables an MCP surface, so both rules would pass "
|
||||
"vacuously; check that the scan still reads the right topic",
|
||||
)
|
||||
self.assertTrue(
|
||||
any(d in _SERVER_DIRECTIONS for d in roles.values()),
|
||||
"no enabled role serves MCP, so the anonymous-probe rule would "
|
||||
"pass vacuously",
|
||||
)
|
||||
self.assertTrue(
|
||||
any(d in _CLIENT_DIRECTIONS for d in roles.values()),
|
||||
"no enabled role consumes MCP, so the client-surface rule would "
|
||||
"pass vacuously",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user