R-270: the local_api drift detector also names a TOKEN-only divergence

A rotation that reached bootstrap.json but not controller.yaml left the
agent channel at 401 across restarts while the endpoint-only detector
stayed silent. The tokens are now compared (constant time, boolean only);
the token case gets its own banner key and operator message. Still
detection only - nothing is reconciled (R-78).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:36:34 +02:00
parent 33c397380e
commit 6fac245d24
6 changed files with 89 additions and 4 deletions
@@ -221,3 +221,41 @@ func TestScenarioC_AbsentBlockStillMerges(t *testing.T) {
t.Errorf("after a successful merge the two files agree — no drift: %+v", d)
}
}
// R-270 — a token rotation that reached bootstrap.json but not controller.yaml left the channel at
// HTTP 401 across restarts, and the endpoint-only detector stayed silent (proved live 2026-08-09).
// The consequence pinned: the token-only divergence is DETECTED, NAMED as a token (not an address)
// on the banner key, nothing is written, and the token value appears in no output.
func TestR270_TokenOnlyDriftIsNamedNotLeaked(t *testing.T) {
cfgPath, cfg := writeDriftFixture(t, "169.254.253.1:8443", "169.254.253.1:8443", "aaaa1111", "tok-ROTATED")
before := sha(t, cfgPath)
var buf bytes.Buffer
d := DetectEndpointDrift(cfgPath, cfg, log.New(&buf, "", 0))
if d == nil {
t.Fatal("a token-only divergence must be DETECTED — this is the R-270 live shape (401 across restarts)")
}
if !d.EndpointAgrees || d.TokenAgrees {
t.Errorf("want EndpointAgrees=true TokenAgrees=false, got %+v", *d)
}
if d.AlertKey() != "alert.local_api_token_drift" {
t.Errorf("the banner must name the TOKEN, not the address: key %q", d.AlertKey())
}
if !strings.Contains(d.EnglishMessage(), "token drift") || !strings.Contains(buf.String(), "TOKEN drift") {
t.Errorf("operator message/log must name the token drift:\nmsg=%q\nlog=%q", d.EnglishMessage(), buf.String())
}
for _, secret := range []string{"tok-secret", "tok-ROTATED", "aaaa1111"} {
if strings.Contains(buf.String(), secret) || strings.Contains(d.EnglishMessage(), secret) || strings.Contains(d.HungarianMessage(), secret) {
t.Errorf("a secret value leaked: %q", secret)
}
}
if after := sha(t, cfgPath); !bytes.Equal(before, after) {
t.Error("controller.yaml was MODIFIED — detection must never write (R-78)")
}
// The address case keeps its own key (no regression of R-77's banner).
cfgPath2, cfg2 := writeDriftFixture(t, "192.168.0.87:8443", "169.254.253.1:8443", "aaaa1111", "tok-secret")
d2 := DetectEndpointDrift(cfgPath2, cfg2, log.New(io.Discard, "", 0))
if d2 == nil || d2.EndpointAgrees || !d2.TokenAgrees || d2.AlertKey() != "alert.endpoint_drift" {
t.Errorf("address drift must keep alert.endpoint_drift: %+v", d2)
}
}