diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAuthorizationModeTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAuthorizationModeTest.java new file mode 100644 index 0000000..3c3f569 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyAuthorizationModeTest.java @@ -0,0 +1,185 @@ +package dev.ltms.fleet; + +import dev.ltms.fleet.auth.Authz; +import dev.ltms.fleet.auth.Principal; +import dev.ltms.fleet.config.ConfigRef; +import dev.ltms.fleet.config.FleetConfig; +import dev.ltms.fleet.guard.SubscriptionGuard; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.HerdrClient; +import dev.ltms.fleet.mcp.FleetMcp; +import dev.ltms.fleet.msg.ReplyInbox; +import io.javalin.Javalin; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.LongSupplier; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #672 — pins the {@link FleetMcp.AuthorizationMode} that {@link FleetdAssembly}'s + * production boot path passes to {@link FleetMcp} at {@code FleetdAssembly.java:481} + * ({@code AuthorizationMode.ENFORCED}). + * + *

{@code FleetMcpAuthzTest} already exercises {@code FleetMcp#denyFor} against the CB-505 + * table, but it constructs its own {@link FleetMcp} and chooses its own {@code AuthorizationMode} + * — it tests the seam, not the producer. This test instead reaches the exact {@link FleetMcp} + * {@link FleetdAssembly#assembleAndStart} builds (via {@code FleetdRuntime#mcp()}, the same + * accessor {@code FleetdAssemblyConnectionIdentityTest} uses for {@code identity()}) and asserts + * the consequence rather than reading the enum back: an unauthorized caller must actually be + * refused through it, and the primary must still be allowed, so the test cannot pass with the + * gate wired backwards. + * + *

{@code FleetMcp#denyFor} is package-private to {@code dev.ltms.fleet.mcp}; this test lives in + * {@code dev.ltms.fleet}, where {@link FleetdAssembly} and {@code FleetdRuntime#mcp()} live, so it + * cannot call {@code denyFor} directly. Reflection bridges that package boundary the same way + * {@code getDeclaredField} does in {@link FleetdAssemblyLeadTabScannerExclusionTest} — the + * assertion itself still exercises the real policy decision ({@code denyFor} calling + * {@code Authz.permits}), not a field read. + */ +class FleetdAssemblyAuthorizationModeTest { + + private static final class TestResourcePorts implements ResourcePorts { + final FakeHerdr herdr = new FakeHerdr(); + Runnable shutdownHook; + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + return herdr; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> new ReplyInbox() { + @Override public void own(String target) { } + @Override public void release(String target) { } + @Override public void publish(String target, String msgId, String content) { } + @Override public List peek(String target) { return List.of(); } + @Override public boolean ack(String target, String msgId) { return false; } + }; + } + + @Override + public Fleetd.LeadMailboxOpener leadMailboxOpener() { + return (uri, selfCoordId, prefetch) -> { + throw new UnsupportedOperationException( + "leadMailboxOpener must not be called — no coordinator: block is configured"); + }; + } + + @Override + public LongSupplier nanoClock() { + return System::nanoTime; + } + + @Override + public LongSupplier wallClockNanos() { + return System::nanoTime; + } + + @Override + public ScheduledExecutorService newScheduler(String purpose) { + return Executors.newSingleThreadScheduledExecutor(); + } + + @Override + public void addShutdownHook(Runnable hook) { + shutdownHook = hook; + } + + @Override + public void startHttp(Javalin app, String host, int port) { + // Do not bind a real port in this assembly test — see FleetdAssemblyLeadTabScannerExclusionTest. + } + + @Override + public Runnable herdrPollWait() { + return () -> { + throw new UnsupportedOperationException("FakeHerdr is healthy; no poll wait is expected"); + }; + } + } + + private TestResourcePorts ports; + + @AfterEach + void tearDown() { + if (ports != null && ports.shutdownHook != null) { + ports.shutdownHook.run(); + } + } + + private static FleetConfig writeConfig(Path dir) throws Exception { + Path file = dir.resolve("fleetd.yaml"); + Files.writeString(file, """ + bind: + host: 127.0.0.1 + port: 8765 + idleSleepGuard: + enabled: false + health: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + """); + return FleetConfig.load(file); + } + + private FleetMcp assemble(Path dir) throws Exception { + FleetConfig cfg = writeConfig(dir); + ports = new TestResourcePorts(); + FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, + new ConfigRef(dir.resolve("fleetd.yaml"), cfg), new SubscriptionGuard(cfg.guard().hostSet())), ports); + return runtime.mcp(); + } + + /** + * Invokes the real production {@code FleetMcp#denyFor} by reflection. The method is + * package-private to {@code dev.ltms.fleet.mcp}; this is the only seam available to this test + * without a full HTTP/servlet round trip (see this class's javadoc). No {@code catch} here can + * turn a missing method into a pass — a {@code NoSuchMethodException} propagates out of the + * test and fails it loudly if {@code denyFor} is ever renamed or removed. + */ + private static McpSchema.CallToolResult denyFor(FleetMcp mcp, Principal caller, Authz.Action action, + String target) throws Exception { + Method m = FleetMcp.class.getDeclaredMethod("denyFor", Principal.class, Authz.Action.class, String.class); + m.setAccessible(true); + return (McpSchema.CallToolResult) m.invoke(mcp, caller, action, target); + } + + @Test + void productionBootPathRefusesAnUnauthorizedCallerThroughTheAssembledFleetMcp(@TempDir Path dir) + throws Exception { + FleetMcp mcp = assemble(dir); + + McpSchema.CallToolResult deniedForWorker = denyFor(mcp, Principal.worker("term_a", 200), + Authz.Action.SPAWN, "term_a"); + assertNotNull(deniedForWorker, + "FleetdAssembly.java:481 must pass AuthorizationMode.ENFORCED to FleetMcp — a worker " + + "must not be able to fleet_spawn through the assembled production object"); + assertTrue(deniedForWorker.isError(), "a refusal is returned as an MCP tool error"); + + McpSchema.CallToolResult allowedForPrimary = denyFor(mcp, Principal.primary(100), + Authz.Action.SPAWN, "term_a"); + assertNull(allowedForPrimary, + "control: the primary must still be allowed to fleet_spawn — otherwise the worker " + + "refusal above would pass even with the gate wired backwards"); + } +}