Files
felhom.eu/hub/internal/web/wrapper_drift_test.go
T
admin 107f74ea3c hub v0.68.0 — auth_failed self-heal, consumed_at honesty gauge, wrapper drift (R-39 + R-50b(a))
Completes the hub half of R-39's fleet fix on top of the generation core (c484aa2).

pbsdrheal gains an auth_failed TRIGGER — a new trigger in the existing machine, not a
new machine. A box whose credential PBS rejects escalates to a fresh mint, never a
re-stage (which would re-feed the secret PBS just rejected), through the EXISTING damper:
a 401 flap must not become a secret-minting chain. With the generation stamp this closes
the loop end to end — agent proves the 401, hub re-keys, generation advances, descriptor
hash moves, agent re-consumes.

consumed_at honesty gauge: a staged secret still unconsumed past a 15-minute grace while
the box reports `applied` is surfaced with its own event. That is the exact 2026-07-18
fingerprint and a disagreement no single tier can see alone. Deliberately a SURFACE, not
a heal — auto-re-issuing on it would mint a second secret on top of an unconsumed one,
which is the mint/consume race R-39(a) already recorded. One event per distinct report,
and an honestly-stuck box does not double-report (its unconsumed secret is the symptom
being healed, not a contradiction).

R-50b(a): ArtifactManifest.WrapperSHA256 + operator field + host-page drift surface. The
PBS wrapper is root-owned 0755 and the pinned sudoers vector, yet installed unversioned
from raw/branch/main and absent from every manifest. Agents >=0.91.0 report the installed
hash; a mismatch is surfaced. An unknown on EITHER side reads as quiet, never as drift —
lighting every host amber on rollout day is how a warning becomes background noise. The
delivery channel itself stays R-50b(b)/(c).

Compatibility unchanged: safe for 0.90.0 agents (unknown JSON key dropped); the re-arm
and auth-honesty guarantees need agent >=0.91.0, so MinAgent moves only after the fleet
has self-updated.

Tests: auth_failed escalate/debounce/recovery-forgets-streak; honesty gauge incl. grace
window, the restage edge (consumed_at deliberately NULLed), consumed-never-alarms, and
honest-stuck-no-double-report; wrapper drift incl. both unknown directions. Red-proof run
at the assertion level: removing the auth_failed arm fails the escalation tests with
reissues=0.
2026-07-21 10:01:35 +02:00

55 lines
2.0 KiB
Go

package web
import "testing"
// R-50b(a) — wrapper drift must be VISIBLE, and "unknown" must never read as "mismatch".
//
// The wrapper is root-owned, 0755, and installed from raw/branch/main: unversioned, unpinned, and
// absent from every manifest until v0.68.0. This is the surface that makes drift answerable.
func TestParseReportedWrapperSHA(t *testing.T) {
cases := []struct {
name, report, want string
}{
{"present", `{"host":{"wrapper_sha256":"AABBCC"}}`, "aabbcc"},
{"lowercased and trimmed", `{"host":{"wrapper_sha256":" AaBb "}}`, "aabb"},
{"absent key", `{"host":{"agent_version":"0.91.0"}}`, ""},
{"no host stanza", `{"guests":[]}`, ""},
{"empty report", ``, ""},
{"malformed json", `{nope`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseReportedWrapperSHA(tc.report); got != tc.want {
t.Errorf("parseReportedWrapperSHA(%q) = %q, want %q", tc.report, got, tc.want)
}
})
}
}
// The comparison itself. The load-bearing case is the pair of UNKNOWNS: an un-vouched hub, or an
// agent below 0.91.0 that reports nothing, must be quiet — lighting every host amber on rollout day
// is how a warning gets trained into background noise.
func TestWrapperDriftComparison(t *testing.T) {
const vouched = "1111111111111111111111111111111111111111111111111111111111111111"
const other = "2222222222222222222222222222222222222222222222222222222222222222"
cases := []struct {
name, reported, vouched, want string
}{
{"match", vouched, vouched, "ok"},
{"match is case-insensitive", "AAAA", "aaaa", "ok"},
{"mismatch", other, vouched, "mismatch"},
{"agent reports nothing (pre-0.91.0) → quiet", "", vouched, ""},
{"hub has vouched nothing → quiet", vouched, "", ""},
{"neither side known → quiet", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := compareWrapperSHA(tc.reported, tc.vouched)
if got != tc.want {
t.Errorf("compareWrapperSHA(%q, %q) = %q, want %q", tc.reported, tc.vouched, got, tc.want)
}
})
}
}