diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/RestRouteInventoryTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/RestRouteInventoryTest.java new file mode 100644 index 0000000..2b2f552 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/RestRouteInventoryTest.java @@ -0,0 +1,121 @@ +package dev.ltms.fleet.rest; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #252: the REST surface has no supported operator entry point of its own — it is the + * documented fallback for when the MCP mount drops (see the operator wiki's REST page), and + * nothing has ever checked a hand-written route list against it. Proof it drifts: the ticket + * itself was filed with 14 routes, and {@link FleetApp} registers 15 — {@code GET + * /member-credentials} (fleetd #111) shipped hours before the ticket and was already missing from + * its list. + * + *

This test is modelled on {@code McpContractDocTest} (fleetd #114 / CB-609), which solved the + * same shape of problem for the MCP tool catalogue: read the source text with a regex instead of + * trusting a maintained copy. Here the "doc" is an explicit inventory written directly in this + * test rather than a separate Markdown file, because the operator wiki page lives in a submodule + * a worker cannot read reliably (see {@code CLAUDE.md} → Project addendum). Keeping the expected + * list in the test means it still fails loudly the moment {@link FleetApp} changes, which is the + * property that actually matters; a human keeps the wiki page in sync using the failure message + * below as the diff. + * + *

It checks source text, not behaviour. It reads {@link FleetApp}'s source for {@code + * app.("")} registrations and does not boot a server. It cannot catch a route that is + * registered through some other mechanism entirely (a filter, a redirect) — only ones shaped like + * the {@code app.get/post/delete/put/patch(...)} calls every route here actually uses. + */ +class RestRouteInventoryTest { + + /** Tests run with the module directory as cwd. */ + private static final Path REST_SOURCE = Path.of("src/main/java/dev/ltms/fleet/rest/FleetApp.java"); + + /** + * The REST surface as verified against {@link FleetApp} on 2026-09-03 (fleetd #252). Update + * this list AND the operator wiki's REST-surface entry together whenever a route is added, + * removed, or renamed — never one without the other. + * + *

{@code /mcp} is deliberately excluded: it is a raw Jetty {@code ServletHolder} mount + * (see {@code FleetApp.build()}, around the {@code modifyServletContextHandler} call), not an + * {@code app.(...)} route, so it is a different registration mechanism and this test's + * regex does not — and should not — see it. If {@code /mcp} ever moves to a Javalin route, + * add it here explicitly rather than relying on the regex to pick it up by accident. + */ + private static final Set EXPECTED_ROUTES = Set.of( + "GET /healthz", + "GET /metrics", + "GET /sessions", + "GET /agents", + "GET /members", + "GET /profiles", + "GET /member-credentials", + "POST /members", + "DELETE /members/{paneId}", + "POST /sessions/{id}/message", + "POST /sessions/{id}/reply", + "GET /sessions/{id}/replies", + "POST /sessions/{id}/ask", + "GET /sessions/{id}/status", + "GET /tasks/{ticket}" + ); + + /** + * Every {@code app.("")} call in {@link FleetApp}'s source, as {@code "VERB path"}. + * This matches inside an {@code if (...) { ... }} block just as well as a top-level statement — + * the regex only looks for the call shape, not its surrounding control flow — which is what + * catches {@code GET /metrics} (registered conditionally on {@code metrics != null}). + */ + private static Set routesTheServerRegisters() throws Exception { + String source = Files.readString(REST_SOURCE); + Matcher m = Pattern.compile("app\\.(get|post|delete|put|patch)\\(\\s*\"([^\"]+)\"").matcher(source); + Set found = new LinkedHashSet<>(); + while (m.find()) { + found.add(m.group(1).toUpperCase(Locale.ROOT) + " " + m.group(2)); + } + return found; + } + + @Test + @DisplayName("[SOURCE TEXT] FleetApp registers exactly the documented REST route inventory") + void theRegisteredRoutesMatchTheExpectedInventory() throws Exception { + Set actual = routesTheServerRegisters(); + + Set added = new LinkedHashSet<>(actual); + added.removeAll(EXPECTED_ROUTES); + Set removed = new LinkedHashSet<>(EXPECTED_ROUTES); + removed.removeAll(actual); + + assertTrue(added.isEmpty() && removed.isEmpty(), + "FleetApp's registered REST routes no longer match this test's expected inventory. " + + "Added (in FleetApp, not in this test): " + added + ". " + + "Removed (in this test, not in FleetApp): " + removed + ". " + + "Update EXPECTED_ROUTES in RestRouteInventoryTest AND the operator wiki's " + + "REST-surface entry together — this is the fleetd #252 defect: the route " + + "list drifted for a month with nothing checking it. Do NOT weaken this test."); + } + + /** + * The denominator guard (same shape as {@code McpContractDocTest}'s vacuity check). Pins that + * the regex really is still finding registrations, so a scrape that silently stops matching + * can't make the check above pass by finding nothing on both sides. + */ + @Test + @DisplayName("[SOURCE TEXT] the route scrape is not vacuous — it found the expected count") + void theScrapeActuallyFoundRoutes() throws Exception { + Set actual = routesTheServerRegisters(); + assertTrue(actual.size() >= EXPECTED_ROUTES.size(), + "scraped only " + actual.size() + " route registration(s) from FleetApp (" + actual + + "), but this test expects at least " + EXPECTED_ROUTES.size() + + "; the app.(\"...\") scrape has stopped matching and the check above " + + "is now vacuous"); + } +}