diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java index 01a88a6..7ebb602 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java @@ -31,6 +31,20 @@ public final class ConnectionIdentity { this.cwds = cwds; } + /** + * The {@link PaneLocator} this identity resolves callers against — fleetd #612 CB-185: lets a + * test drive the exact {@link PaneLocator} a real assembly wired up (e.g. {@code + * FleetdAssembly}'s {@code new ConnectionIdentity(new PaneLocator(herdr, memberHerdr), ...)}) + * directly with a chosen pid, bypassing the OS-dependent {@link PeerPidLookup} that {@link + * #resolve} otherwise goes through. A full HTTP round trip cannot exercise this: {@code + * LsofPeerPidLookup} excludes its own pid, and an in-process test client and server share one + * JVM pid, so {@code pidForLocalPort} always returns {@code -1} and {@link PaneLocator} never + * gets called at all. + */ + public PaneLocator panes() { + return panes; + } + /** * The caller resolved from the connection: its worker {@code terminal} (or {@code null} for the * primary / an off-host client), its {@code pid} (or {@code -1} if not resolvable), and whether diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 556d6b2..2231e70 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -107,6 +107,13 @@ public final class FleetMcp { * untested identity heuristic. */ private final boolean authorizationEnforced; + /** + * fleetd #612 CB-185: kept as a field (rather than only captured by the {@code + * contextExtractor} closure built in the constructor) so a test can reach the exact {@link + * ConnectionIdentity} — and, through {@link ConnectionIdentity#panes()}, the exact {@link + * dev.ltms.fleet.herdr.PaneLocator} — that a real assembly wired up. See {@link #identity()}. + */ + private final ConnectionIdentity identity; private final Metrics metrics; // CB-502: null → auth failures not counted private final CapacitySource capacity; private final HealthCoverageSource healthCoverage; @@ -396,6 +403,7 @@ public final class FleetMcp { Objects.requireNonNull(callers, "callers"); this.authorizationEnforced = Objects.requireNonNull(authorizationMode, "authorizationMode") == AuthorizationMode.ENFORCED; + this.identity = identity; this.leadChannel = leadChannel; this.peers = peers == null ? List.of() : List.copyOf(peers); this.capacity = capacity; @@ -755,6 +763,15 @@ public final class FleetMcp { return transport; } + /** + * The {@link ConnectionIdentity} this server resolves every caller against — fleetd #612 + * CB-185: lets a test reach the exact {@link dev.ltms.fleet.herdr.PaneLocator} a real assembly + * wired up (via {@link ConnectionIdentity#panes()}), rather than a copy built for the test. + */ + public ConnectionIdentity identity() { + return identity; + } + /** Mark a connected spawned member available for the injector readiness gate. */ static void markSpawnedMemberPresent(Principal caller, MemberPresence presence) { if (caller.isSpawnedMember()) { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java new file mode 100644 index 0000000..df5afdf --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyConnectionIdentityTest.java @@ -0,0 +1,222 @@ +package dev.ltms.fleet; + +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.herdr.PaneLocator; +import io.javalin.Javalin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.LongSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * fleetd #612 step 2, unit B2 (CB-185, identity half). Replaces the deleted + * {@code FleetdConnectionIdentityConstructionTest}, which pinned this claim by reading {@code + * Fleetd.java}'s source text for {@code "new PaneLocator(herdr, memberHerdr)"}. That claim moved + * to {@code FleetdAssembly.java} (fleetd #612 Unit A) and is pinned here instead, by driving the + * real {@link ConnectionIdentity} — via {@code runtime.mcp().identity()}, not a copy — that {@link + * FleetdAssembly#assembleAndStart} built. + * + *

What this guards against (from the deleted test's own javadoc): pinning + * {@code PaneLocator} to {@code memberHerdr} alone leaves every LEAD's own MCP connection + * unresolvable ({@code callerTerminal == null}) the moment {@code memberHerdrSocket} names a + * second daemon, which breaks {@code fleet_reply}/{@code fleet_ask}/{@code fleet_whoami} for a + * lead. {@code PaneLocatorTest} already proves {@link PaneLocator} itself can search two clients + * given two — the gap this pins is that the assembly actually passes it two, and in the right + * order (lead first). + * + *

Why this cannot be driven through a real MCP/HTTP round trip. The natural + * way to observe {@code ConnectionIdentity} would be a real {@code fleet_whoami} call over the + * built {@code FleetMcp}, the way {@code FleetMcpContextExtractorTest} drives its own + * hand-built one. That does not work for the REAL assembly, because {@code FleetdAssembly} wires + * {@code ConnectionIdentity} with a hardcoded {@code new LsofPeerPidLookup()} (see {@code + * FleetdAssembly.java:444}), and {@code LsofPeerPidLookup} explicitly excludes its own PID — see + * its javadoc: "we exclude our own PID and take the other end". In a JUnit test the HTTP client + * and the daemon under test run in the very same JVM, so the "client" and "server" ends of the + * loopback connection ARE the same PID, and {@code pidForLocalPort} always returns {@code -1} + * before {@link PaneLocator} is ever reached — proving nothing about which daemon(s) got searched. + * This test instead reaches the real {@link PaneLocator} the assembly built (through {@link + * ConnectionIdentity#panes()}, added for exactly this) and drives it with a chosen pid directly, + * bypassing the OS-dependent PID lookup entirely — a legitimate substitute, since the pid lookup + * is not what CB-185 is about. + */ +class FleetdAssemblyConnectionIdentityTest { + + /** Same shape as {@code FleetdAssemblyLifecycleTest}'s fake, but keys {@code connectHerdr} by + * socket path so the lead and member daemons can be two DIFFERENT {@link FakeHerdr}s. */ + private static final class TwoHerdrResourcePorts implements ResourcePorts { + + final Map herdrsBySocket = new LinkedHashMap<>(); + final CopyOnWriteArrayList schedulers = new CopyOnWriteArrayList<>(); + Runnable shutdownHook; + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + HerdrClient client = herdrsBySocket.get(socketPath); + if (client == null) { + throw new IllegalStateException("no fake herdr registered for socket " + socketPath); + } + return client; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> new dev.ltms.fleet.msg.InMemoryReplyInbox(); + } + + @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) { + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + schedulers.add(scheduler); + return scheduler; + } + + @Override + public void addShutdownHook(Runnable hook) { + this.shutdownHook = hook; + } + + @Override + public void startHttp(Javalin app, String host, int port) { + // Deliberately never bind — this test never issues a real HTTP request. + } + } + + private FleetdRuntime runtime; + private TwoHerdrResourcePorts ports; + + @AfterEach + void tearDown() { + if (ports != null && ports.shutdownHook != null) { + ports.shutdownHook.run(); + } + } + + private static final Path LEAD_SOCKET = Path.of("/fake/lead-herdr.sock"); + private static final Path MEMBER_SOCKET = Path.of("/fake/member-herdr.sock"); + + private static FleetConfig writeConfig(Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: "%s" + memberHerdrSocket: "%s" + lifecycle: + idleTtlSeconds: 600 + health: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + """.formatted(LEAD_SOCKET, MEMBER_SOCKET)); + return FleetConfig.load(f); + } + + private FleetdRuntime assemble(Path dir, FakeHerdr lead, FakeHerdr member) throws Exception { + FleetConfig cfg = writeConfig(dir); + ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg); + SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); + ports = new TwoHerdrResourcePorts(); + ports.herdrsBySocket.put(LEAD_SOCKET, lead); + ports.herdrsBySocket.put(MEMBER_SOCKET, member); + runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); + return runtime; + } + + /** + * The pin. {@code lead} carries the one pane {@link FakeHerdr}'s canned {@code + * pane.process_info} ties to {@link FakeHerdr#WORKER_PID} (pane {@code w2:p7}); {@code member} + * reports NO panes at all ({@link FakeHerdr#withNoPanes()}) — modelling a second daemon that + * simply does not host the caller's pane, exactly the CB-185 javadoc's scenario for a lead's + * own connection. If {@code PaneLocator} only ever searches the member daemon (the bug), this + * pid resolves to nothing, because the pane that owns it lives on the LEAD daemon the bug + * skips. + */ + @Test + void connectionIdentitySearchesTheLeadDaemonNotJustTheMemberOne(@TempDir Path dir) throws Exception { + FakeHerdr lead = new FakeHerdr(); + FakeHerdr member = new FakeHerdr().withNoPanes(); + + assemble(dir, lead, member); + + PaneLocator panes = runtime.mcp().identity().panes(); + PaneLocator.Lookup lookup = panes.terminalForPid(FakeHerdr.WORKER_PID); + + assertEquals("term_a", lookup.terminal(), + "the pane owning WORKER_PID lives on the LEAD daemon only (the member fake reports " + + "no panes) — PaneLocator must still find it, which is only possible if it " + + "searches the lead client and not just the member one"); + } + + /** + * The mirror control: when the pane instead lives ONLY on the member daemon (the lead reports + * no panes), the lookup must still find it — proving the member client is genuinely searched + * too, not merely tolerated as a second, always-losing argument. + */ + @Test + void connectionIdentityAlsoSearchesTheMemberDaemon(@TempDir Path dir) throws Exception { + FakeHerdr lead = new FakeHerdr().withNoPanes(); + FakeHerdr member = new FakeHerdr(); + + assemble(dir, lead, member); + + PaneLocator panes = runtime.mcp().identity().panes(); + PaneLocator.Lookup lookup = panes.terminalForPid(FakeHerdr.WORKER_PID); + + assertEquals("term_a", lookup.terminal(), + "the pane owning WORKER_PID lives on the MEMBER daemon only — PaneLocator must " + + "find it there too"); + } + + /** Sanity control: a pid nobody owns resolves to nothing on either daemon. */ + @Test + void aPidNoPaneOwnsResolvesToNoTerminalOnEitherDaemon(@TempDir Path dir) throws Exception { + FakeHerdr lead = new FakeHerdr(); + FakeHerdr member = new FakeHerdr(); + + assemble(dir, lead, member); + + PaneLocator panes = runtime.mcp().identity().panes(); + PaneLocator.Lookup lookup = panes.terminalForPid(999_999L); + + assertNull(lookup.terminal()); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java new file mode 100644 index 0000000..1372c22 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyFleetAppTest.java @@ -0,0 +1,209 @@ +package dev.ltms.fleet; + +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 io.javalin.Javalin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.LongSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * fleetd #612 step 2, unit B2 (CB-185, {@code FleetApp} half). Replaces the deleted {@code + * FleetdFleetAppConstructionTest}, which pinned this claim by reading {@code Fleetd.java}'s + * source text for {@code "new FleetApp(herdr, memberHerdr, workers,"}. That claim moved to {@code + * FleetdAssembly.java} (fleetd #612 Unit A) and is pinned here instead, by driving the real {@code + * Javalin} app — via {@code runtime.app()}, not a copy — that {@link + * FleetdAssembly#assembleAndStart} built and handed to {@link FleetdRuntime}. + * + *

What this guards against (from the deleted test's own javadoc): constructing + * {@code FleetApp} with the lead-only {@code herdr} client (dropping {@code memberHerdr}) makes + * {@code GET /healthz} report green while the MEMBER daemon is down — so every spawn fails + * invisibly — and silently drops every member workspace from {@code GET /sessions}. {@code + * FleetAppTwoDaemonTest} already proves {@code FleetApp} itself merges/gates correctly given two + * clients; the gap this pins is that the assembly actually passes it two. + * + *

Unlike the {@code ConnectionIdentity} half of CB-185 ({@code + * FleetdAssemblyConnectionIdentityTest}), {@code /healthz} needs no caller identity at all, so + * this test can bind {@link FleetdRuntime#app()} to a REAL ephemeral port (exactly {@code + * FleetAppTwoDaemonTest} does for its own hand-built {@code FleetApp}) and drive it with a real + * {@code HttpClient} — no accessor needed for this half. + * + *

{@code GET /sessions} could not be driven the same way, so this class does + * not pin the merge half of the deleted test's javadoc. {@code /sessions} requires + * {@code Authz.Action.READ}, which — through the REAL assembly's real {@code + * CallerResolver}/{@code ConnectionIdentity} (built with a hardcoded {@code + * new LsofPeerPidLookup()}) — needs {@code Caller.resolved()}, i.e. a real positive pid from + * {@code lsof}. {@code LsofPeerPidLookup} excludes its own pid (see its javadoc), and a JUnit + * test's HTTP client and the daemon under test share one JVM pid, so the resolved pid is always + * {@code -1} and every such request is refused as {@code ANONYMOUS} (fleetd #317's fail-closed + * rule) before the route handler — and its {@code memberHerdr} merge — is ever reached. Verified + * directly: driving {@code GET /sessions} here returns {@code 401 unauthenticated}, not the + * merged body. {@code FleetAppTwoDaemonTest} avoids this because it builds {@code FleetApp} with + * {@code callers: null}, which is not what the real assembly passes. The {@code /healthz} pin + * below is what this class relies on for CB-185's {@code FleetApp} half; {@code + * FleetAppTwoDaemonTest} remains the full behavioural proof that {@code FleetApp} itself merges + * {@code /sessions} correctly once handed two clients. + */ +class FleetdAssemblyFleetAppTest { + + private static final class TwoHerdrResourcePorts implements ResourcePorts { + + final Map herdrsBySocket = new LinkedHashMap<>(); + final CopyOnWriteArrayList schedulers = new CopyOnWriteArrayList<>(); + Runnable shutdownHook; + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + HerdrClient client = herdrsBySocket.get(socketPath); + if (client == null) { + throw new IllegalStateException("no fake herdr registered for socket " + socketPath); + } + return client; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> new dev.ltms.fleet.msg.InMemoryReplyInbox(); + } + + @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) { + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + schedulers.add(scheduler); + return scheduler; + } + + @Override + public void addShutdownHook(Runnable hook) { + this.shutdownHook = hook; + } + + @Override + public void startHttp(Javalin app, String host, int port) { + // Deliberately never bind here — this test binds runtime.app() itself, for real, below. + } + } + + private static final Path LEAD_SOCKET = Path.of("/fake/lead-herdr.sock"); + private static final Path MEMBER_SOCKET = Path.of("/fake/member-herdr.sock"); + + private final HttpClient http = HttpClient.newHttpClient(); + private FleetdRuntime runtime; + private TwoHerdrResourcePorts ports; + private Javalin boundApp; + + @AfterEach + void tearDown() { + if (boundApp != null) { + boundApp.stop(); + } + if (ports != null && ports.shutdownHook != null) { + ports.shutdownHook.run(); + } + } + + private static FleetConfig writeConfig(Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: "%s" + memberHerdrSocket: "%s" + lifecycle: + idleTtlSeconds: 600 + health: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + """.formatted(LEAD_SOCKET, MEMBER_SOCKET)); + return FleetConfig.load(f); + } + + /** Assembles the real graph, then binds the real {@code Javalin app} to an ephemeral port. */ + private int assembleAndBind(Path dir, FakeHerdr lead, FakeHerdr member) throws Exception { + FleetConfig cfg = writeConfig(dir); + ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg); + SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); + ports = new TwoHerdrResourcePorts(); + ports.herdrsBySocket.put(LEAD_SOCKET, lead); + ports.herdrsBySocket.put(MEMBER_SOCKET, member); + runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); + boundApp = runtime.app().start("127.0.0.1", 0); + return boundApp.port(); + } + + private HttpResponse get(int port, String path) throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).GET().build(); + return http.send(req, HttpResponse.BodyHandlers.ofString()); + } + + /** + * The pin. The MEMBER daemon is down; the LEAD daemon is healthy. If the assembly built + * {@code FleetApp} with only the lead client (the bug: passing {@code herdr} where {@code + * memberHerdr} is expected), the down member is invisible and {@code /healthz} stays 200. + */ + @Test + void healthzGoesRedWhenTheMemberDaemonIsDownEvenThoughTheLeadIsUp(@TempDir Path dir) throws Exception { + FakeHerdr lead = new FakeHerdr(); + FakeHerdr member = new FakeHerdr().healthy(false); + + int port = assembleAndBind(dir, lead, member); + + HttpResponse res = get(port, "/healthz"); + assertEquals(503, res.statusCode(), + "a down MEMBER daemon must not be masked by a healthy lead: " + res.body()); + } + + /** Sanity control: both daemons healthy must still be green through the real assembly. */ + @Test + void healthzIsGreenWhenBothDaemonsAreUp(@TempDir Path dir) throws Exception { + FakeHerdr lead = new FakeHerdr(); + FakeHerdr member = new FakeHerdr(); + + int port = assembleAndBind(dir, lead, member); + + assertEquals(200, get(port, "/healthz").statusCode()); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdConnectionIdentityConstructionTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdConnectionIdentityConstructionTest.java deleted file mode 100644 index 7274264..0000000 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdConnectionIdentityConstructionTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.ltms.fleet; - -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * CB-185: {@code ConnectionIdentity} must resolve a caller's pane on EITHER herdr daemon (a - * lead's MCP connection resolves against the lead daemon; a member's against the member daemon). - * Pinning {@code PaneLocator} to {@code memberHerdr} alone — the bug this guards against — leaves - * every lead's own connection unresolvable ({@code callerTerminal == null}) the moment - * {@code memberHerdrSocket} names a second daemon, which breaks {@code fleet_reply}/{@code - * fleet_ask} and {@code fleet_whoami} for a lead. A unit test on {@link - * dev.ltms.fleet.herdr.PaneLocator} alone (see {@code PaneLocatorTest}) proves the class CAN - * search two clients, but not that {@code Fleetd.main} actually wires it that way — hence this - * source-level assertion, the same technique {@code FleetdHerdrControlConstructionTest} uses. - */ -class FleetdConnectionIdentityConstructionTest { - @Test - void connectionIdentitySearchesBothDaemonsNotJustTheMemberOne() throws Exception { - String source = Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java")); - assertFalse(source.contains("new PaneLocator(memberHerdr)"), - "PaneLocator must not be pinned to the member daemon alone — a lead's own " - + "connection resolves against the LEAD daemon and would never be found"); - assertTrue(source.contains("new PaneLocator(herdr, memberHerdr)"), - "PaneLocator must search the lead daemon first, then the member daemon"); - } -} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdFleetAppConstructionTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdFleetAppConstructionTest.java deleted file mode 100644 index a7e8482..0000000 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdFleetAppConstructionTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.ltms.fleet; - -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * CB-185: {@code FleetApp} must be constructed with BOTH herdr clients (the lead's and the - * member's), never the raw lead-only {@code herdr}. Passing only {@code herdr} — the bug this - * guards against — makes {@code GET /healthz} green while the member daemon is down (so every - * spawn fails invisibly) and silently drops every member workspace from {@code GET /sessions}. - * A behavioural test on {@code FleetApp} alone (see {@code FleetAppTwoDaemonTest}) proves the - * class merges/gates correctly when given two clients, but not that {@code Fleetd.main} actually - * passes it two — hence this source-level assertion, mirroring - * {@code FleetdHerdrControlConstructionTest}. - */ -class FleetdFleetAppConstructionTest { - @Test - void fleetAppIsConstructedWithBothHerdrDaemons() throws Exception { - String source = Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java")); - assertFalse(source.contains("new FleetApp(herdr, workers,"), - "FleetApp must not be constructed with the lead-only herdr client"); - assertTrue(source.contains("new FleetApp(herdr, memberHerdr, workers,"), - "FleetApp must be constructed with both the lead and the member herdr client"); - } -}