Files
fleetd/bridged/src/test/java/dev/ltms/bridged/auth/MemberRegistryTest.java
T
Dai Ha 4b48d2d921 CB-557: fleet role pools — role is the config key, and the tab label says it
Four top-level keys (leaders:, members:, leadScan:, defaultProfile:) become one
`fleet:` block, and a member's role becomes the map key that contains it rather
than a `role:` field inside it.

Why the key and not a field: a misspelled `role: architct` used to produce a
member with no contract, which nothing rejected. A misspelled pool name declares
nothing, which is a shape the loader can see.

`fleet.architects/developers/reviewers` are pools of profiles a role MAY run on.
That replaces the single global `defaultProfile:`, so an unqualified spawn now
resolves its profile from the pool of the role it asked for. Role and profile
stay orthogonal: a reviewer may run on the same profile as the dev it reviews,
and one profile may appear in several pools.

Tab labels are role-first — `dev: sonnet #4`. The template lives on `fleet:`
because a profile cannot know the role of the member launched on it; a profile
may still override it. The `{n}` counter is scoped per role+profile, so a dev
and a reviewer on one profile each start at #1. Making {role} the first field
also turns the lead/member namespace check into a structural guarantee: roles
are a closed enum, so only hand-written templates can still collide with a lead
tabPrefix.

Removed keys are hard errors that name their successor. `defaultProfile:` has no
single successor key, so its message explains the new model instead of pointing
at a key that does not exist.

Map order is kept with LinkedHashMap, deliberately not Map.copyOf — the latter
salts iteration order per JVM run, which would destroy the YAML definition order
that `placement: fixed` selects on.

Not yet wired: SessionManager still hands the launchers one effectiveDefault-
Profile, so pools are not enforced at spawn time yet, and placement still ranges
over all profiles.

595 tests pass.
2026-08-14 16:32:54 +02:00

270 lines
12 KiB
Java

package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.peer.MemberRole;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.jupiter.api.Assertions.*;
/**
* CB-548 — the architect-slot registry: the config snapshot of slot → profile, and the live
* terminal → slot bindings it owns. The role a binding produces is asserted in
* {@link CallerResolverTest}; this pins the registry object itself — its invariants and their
* thread-safety.
*/
class MemberRegistryTest {
/**
* Slot keys are qualified by role (CB-557): a bare name is unique only within its pool, so
* {@code sonnet} can be both a developer and a reviewer, while a terminal binds to exactly one.
*/
private static final String DESIGNER = "architect:lead-designer";
private static final String REVIEWER = "architect:code-reviewer";
private static BridgedConfig.Fleet fleetWith(Map<String, BridgedConfig.Slot> architects) {
return new BridgedConfig.Fleet(Map.of(), architects, Map.of(), Map.of(), null);
}
private static Map<String, BridgedConfig.Slot> architects() {
Map<String, BridgedConfig.Slot> pool = new LinkedHashMap<>();
pool.put("lead-designer", new BridgedConfig.Slot("sonnet"));
pool.put("code-reviewer", new BridgedConfig.Slot("gx10"));
return pool;
}
private final MemberRegistry registry = new MemberRegistry(fleetWith(architects()));
@Test
void exposesTheConfiguredSlotsQualifiedByRole() {
assertEquals(Set.of(DESIGNER, REVIEWER), registry.slots().keySet());
assertTrue(registry.isSlot(REVIEWER));
assertFalse(registry.isSlot("nope"));
assertFalse(registry.isSlot("lead-designer"),
"the bare name is not the key — it is unique only inside its pool");
}
/** The case the pool shape exists for: one profile serving two roles is not a duplicate. */
@Test
void oneProfileMayServeTwoRolesUnderTheSameSlotName() {
Map<String, BridgedConfig.Slot> devs = Map.of("sonnet", new BridgedConfig.Slot("sonnet"));
Map<String, BridgedConfig.Slot> revs = Map.of("sonnet", new BridgedConfig.Slot("sonnet"));
MemberRegistry r = new MemberRegistry(
new BridgedConfig.Fleet(Map.of(), Map.of(), devs, revs, null));
assertEquals(Set.of("dev:sonnet", "reviewer:sonnet"), r.slots().keySet());
assertEquals(MemberRole.DEV, r.roleForSlot("dev:sonnet"));
assertEquals(MemberRole.REVIEWER, r.roleForSlot("reviewer:sonnet"));
}
@Test
void theSpawnLifecycleReadsTheProfileBackFromASlot() {
assertEquals("sonnet", registry.profileForSlot(DESIGNER));
assertEquals("gx10", registry.profileForSlot(REVIEWER));
assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile");
}
@Test
void startsEmptySoNoTerminalResolvesToAnArchitect() {
assertTrue(registry.snapshot().isEmpty());
assertNull(registry.slotForTerminal("term_design"),
"config declares no architect terminal — nothing is recognised until a bind");
assertNull(registry.slotForTerminal(null), "no terminal ⇒ no slot");
}
// ── bind ──────────────────────────────────────────────────────────────────────────────────
@Test
void bindResolvesTheTerminalToTheSlot() {
assertTrue(registry.bind(DESIGNER, "term_design"));
assertEquals(DESIGNER, registry.slotForTerminal("term_design"));
assertEquals(Map.of("term_design", DESIGNER), registry.snapshot());
}
@Test
void bindRefusesAnUnknownSlot() {
assertFalse(registry.bind("nope", "term_x"),
"a slot that is not configured must be refused — bind is not a way to invent one");
assertNull(registry.slotForTerminal("term_x"));
}
@Test
void bindRefusesATerminalInTwoSlots() {
assertTrue(registry.bind(DESIGNER, "term_design"));
assertFalse(registry.bind(REVIEWER, "term_design"),
"a terminal may occupy at most one slot");
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
"the first binding survives the refused second");
}
@Test
void bindRefusesASlotWithTwoTerminals() {
assertTrue(registry.bind(DESIGNER, "term_design"));
assertFalse(registry.bind(DESIGNER, "term_other"),
"a slot may host at most one terminal");
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
"the first binding survives the refused second");
assertNull(registry.slotForTerminal("term_other"));
}
@Test
void rebindingTheSamePairIsAnIdempotentNoOp() {
assertTrue(registry.bind(DESIGNER, "term_design"));
assertTrue(registry.bind(DESIGNER, "term_design"),
"the same terminal → slot is harmless to repeat");
assertEquals(1, registry.snapshot().size());
}
// ── unbind ────────────────────────────────────────────────────────────────────────────────
@Test
void unbindRemovesTheExactBinding() {
assertTrue(registry.bind(DESIGNER, "term_design"));
assertTrue(registry.unbind(DESIGNER, "term_design"));
assertNull(registry.slotForTerminal("term_design"));
assertTrue(registry.snapshot().isEmpty());
}
@Test
void aStaleUnbindDoesNotRemoveAReplacement() {
// Bind, tear down, and stand the slot back up with a NEW terminal.
assertTrue(registry.bind(DESIGNER, "term_design"));
registry.unbind(DESIGNER, "term_design");
assertTrue(registry.bind(DESIGNER, "term_new"));
// A late unbind naming the OLD terminal must not remove the replacement binding.
assertFalse(registry.unbind(DESIGNER, "term_design"));
assertEquals(DESIGNER, registry.slotForTerminal("term_new"),
"the replacement terminal stays bound");
}
@Test
void aStaleUnbindForATerminalThatMovedSlotsDoesNothing() {
// term_design starts in lead-designer, is torn down, and stands back up in a FREE slot.
assertTrue(registry.bind(DESIGNER, "term_design"));
registry.unbind(DESIGNER, "term_design");
assertTrue(registry.bind(REVIEWER, "term_design"));
// Unbinding against the slot it no longer occupies is refused; the new binding is intact.
assertFalse(registry.unbind(DESIGNER, "term_design"),
"the old slot must not unbind a terminal that moved elsewhere");
assertEquals(REVIEWER, registry.slotForTerminal("term_design"));
}
@Test
void unbindOfNothingIsAFalseNoOp() {
assertFalse(registry.unbind(DESIGNER, "term_design"),
"nothing was bound, so nothing is removed");
}
// ── snapshot ─────────────────────────────────────────────────────────────────────────────
@Test
void theSnapshotIsAnImmutableCopyNotAliveState() {
assertTrue(registry.bind(DESIGNER, "term_design"));
Map<String, String> snap = registry.snapshot();
assertThrows(UnsupportedOperationException.class, () -> snap.put("x", "y"),
"a handed-out snapshot cannot be mutated in place");
// Later binds must not leak into an earlier snapshot.
assertTrue(registry.bind(REVIEWER, "term_review"));
assertFalse(snap.containsKey("term_review"),
"a snapshot is a point-in-time copy, not a live view");
}
// ── concurrency (CB-548 invariants hold under contention) ─────────────────────────────────
@Test
void concurrentBindsNeverGiveASlotTwoTerminals() throws Exception {
int n = 16;
ExecutorService pool = Executors.newFixedThreadPool(n);
try {
CountDownLatch go = new CountDownLatch(1);
List<Future<Boolean>> results = new ArrayList<>();
for (int i = 0; i < n; i++) {
final String term = "term_" + i; // every thread races for the SAME slot
results.add(pool.submit(() -> {
go.await();
return registry.bind(DESIGNER, term);
}));
}
go.countDown();
int won = 0;
for (Future<Boolean> r : results) {
if (r.get()) {
won++;
}
}
assertEquals(1, won, "exactly one terminal may win the sole slot, got " + won);
assertEquals(1, registry.snapshot().size(),
"the slot hosts at most one terminal after the race");
} finally {
pool.shutdownNow();
}
}
@Test
void concurrentBindsNeverPutOneTerminalInTwoSlots() throws Exception {
int n = 16;
ExecutorService pool = Executors.newFixedThreadPool(n);
try {
CountDownLatch go = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < n; i++) {
final String slot = (i % 2 == 0) ? DESIGNER : REVIEWER; // all race for ONE terminal
results.add(pool.submit(() -> {
go.await();
return registry.bind(slot, "shared_term")
? registry.slotForTerminal("shared_term") : null;
}));
}
go.countDown();
// Rebinding the same terminal to the same slot is a harmless idempotent true, so count
// winners is not the assertion — agreement is: every thread that reported success must
// have seen the terminal in the SAME slot, never in two at once.
String bound = null;
boolean conflict = false;
for (Future<String> r : results) {
String s = r.get();
if (s != null) {
if (bound == null) {
bound = s;
} else if (!bound.equals(s)) {
conflict = true;
}
}
}
assertFalse(conflict, "a terminal was observed in two slots at once");
assertNotNull(bound, "at least one thread bound the terminal");
assertEquals(1, registry.snapshot().size(),
"the terminal occupies exactly one slot in the final snapshot");
assertEquals(bound, registry.slotForTerminal("shared_term"));
} finally {
pool.shutdownNow();
}
}
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
@Test
void theSlotSnapshotIsFixedByConstruction() {
Map<String, BridgedConfig.Slot> mutable = architects();
MemberRegistry r = new MemberRegistry(fleetWith(mutable));
mutable.put("hijack", new BridgedConfig.Slot("gx10"));
assertFalse(r.isSlot("architect:hijack"), "a handed-over map is not offered as live state");
}
}