diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java index 44057474..adfeb8ae 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java @@ -1,5 +1,8 @@ package dev.ltms.fleet.rest; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.ltms.fleet.auth.CallerResolver; import dev.ltms.fleet.auth.Authz; import dev.ltms.fleet.auth.MemberRegistry; @@ -18,6 +21,7 @@ import dev.ltms.fleet.msg.Rendezvous; import dev.ltms.fleet.session.FakeWorktrees; import dev.ltms.fleet.session.SessionManager; import dev.ltms.fleet.member.ClaudeCodeLauncher; +import dev.ltms.fleet.testing.CapturedLog; import io.javalin.Javalin; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -292,6 +296,58 @@ class FleetAppAuthTest { assertTrue(granted.body().contains("stale_turn"), "a granted caller's body IS read and acted on"); } + /** + * fleetd #689 (ticket comment 18353): the only place {@code sendMessage}'s call to {@code + * answerGatePasses} is observable is the audit trail — {@code allow()} logs an {@code + * "allowed"} entry for every granted action except {@code READ}/{@code METRICS}/{@code + * TASK_READ}, and {@code ANSWER} is none of those. A granted {@code turnId} request must + * therefore log both a {@code SEND} and an {@code ANSWER} entry; a granted plain request must + * log {@code SEND} alone. A unit test of the extracted helper pins the helper; this pins the + * call site — deleting the {@code answerGatePasses} call from {@code sendMessage} leaves the + * helper's own test green but turns this one red. + */ + @Test + void aGrantedTurnIdRequestAuditsBothSendAndAnswerButAPlainRequestAuditsSendAlone() throws Exception { + int port = start(999_999, false, null); // primary: granted both SEND and ANSWER + ObjectMapper mapper = new ObjectMapper(); + + try (CapturedLog audit = CapturedLog.at("audit", Level.INFO)) { + send(port, "POST", "/sessions/term_b/message", + "{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null); + + List allowed = allowedActions(audit, mapper); + assertTrue(allowed.contains("SEND"), + "a turnId request must still clear the coarse SEND grant first"); + assertTrue(allowed.contains("ANSWER"), + "a turnId request must ALSO clear the ANSWER grant — this is the call site itself"); + } + + try (CapturedLog audit = CapturedLog.at("audit", Level.INFO)) { + send(port, "POST", "/sessions/term_b/message", + "{\"content\":\"hi\",\"timeoutMs\":50}", null); + + List allowed = allowedActions(audit, mapper); + assertEquals(List.of("SEND"), allowed, + "a plain request must log SEND and nothing else — ANSWER is conditional on " + + "turnId, not something every request happens to log"); + } + } + + private static List allowedActions(CapturedLog audit, ObjectMapper mapper) { + return audit.events().stream() + .map(ILoggingEvent::getFormattedMessage) + .map(line -> { + try { + return mapper.readTree(line); + } catch (Exception e) { + throw new AssertionError("audit line is not valid JSON: " + line, e); + } + }) + .filter(n -> "allowed".equals(n.path("outcome").asText())) + .map(n -> n.path("action").asText()) + .toList(); + } + // --- token mode --------------------------------------------------------------------------- @Test