Files
felhom.eu/hub/internal/monitor/host_leaf_test.go
T
admin 9c5cf2975f hub: HostLeafChecker — proactive agent re-key detection (host_leaf_changed) v0.22.0
Watches each host's reported local-API leaf fp; alerts on change (trust-on-first-report). Sibling of
HostCapabilityChecker; store.GetHostLeafFingerprints reads report_json (no migration); hub-generated
event (no allowlist change). Change red-proof + first-obs-seed + empty-skip + blocked-drop tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
2026-06-29 23:15:22 +02:00

146 lines
5.4 KiB
Go

package monitor
import (
"io"
"log"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
_ "modernc.org/sqlite"
)
const (
fpA = "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"
fpB = "911d703c9cb4cf54d7aba9d9a768e587e9d0757ea638b1db595939623c675738"
)
func reportWithLeaf(fp string) []byte {
return []byte(`{"host_id":"h1","leaf_fingerprint":"` + fp + `"}`)
}
func newLeafStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ck", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"})
return st
}
// B.2 change RED-PROOF: seed fp A, then fp B → exactly one host_leaf_changed (A→B), baseline advanced
// to B. Companion (TestHostLeafChecker_NoChangeNoEvent): fp stays A → no event — proving the CHANGE,
// not the cycle, fires it.
func TestHostLeafChecker_ChangeAlertsOnce(t *testing.T) {
st := newLeafStore(t)
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
var events []string
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
if lc.GetState("h1") != fpA {
t.Fatalf("seed baseline = %q, want %q", lc.GetState("h1"), fpA)
}
if len(events) != 0 {
t.Fatalf("construction must not emit, got %v", events)
}
st.SaveHostReport("h1", "c1", reportWithLeaf(fpB), store.HostReportDenorm{})
lc.Check()
if len(events) != 1 || events[0] != "host_leaf_changed" {
t.Fatalf("fp change → one host_leaf_changed, got %v", events)
}
if lc.GetState("h1") != fpB {
t.Fatalf("baseline not advanced, got %q want %q", lc.GetState("h1"), fpB)
}
lc.Check() // steady at B → no duplicate
if len(events) != 1 {
t.Fatalf("steady fp must not re-emit, got %v", events)
}
}
func TestHostLeafChecker_NoChangeNoEvent(t *testing.T) {
st := newLeafStore(t)
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
var events []string
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{}) // same fp
lc.Check()
if len(events) != 0 {
t.Fatalf("unchanged fp must not alert, got %v", events)
}
}
// First observation seeds the baseline silently.
func TestHostLeafChecker_FirstObsSeedsNoEvent(t *testing.T) {
st := newLeafStore(t)
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
var events []string
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
// construction already seeded; a Check with no change stays silent.
lc.Check()
if len(events) != 0 {
t.Fatalf("first-obs/seed must be silent, got %v", events)
}
if lc.GetState("h1") != fpA {
t.Fatalf("baseline = %q, want %q", lc.GetState("h1"), fpA)
}
}
// A change-BACK (B→A) is also a change → another event (informative).
func TestHostLeafChecker_ChangeBackReAlerts(t *testing.T) {
st := newLeafStore(t)
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
var events []string
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
st.SaveHostReport("h1", "c1", reportWithLeaf(fpB), store.HostReportDenorm{})
lc.Check() // A→B
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
lc.Check() // B→A
if len(events) != 2 {
t.Fatalf("change-back should re-alert (want 2), got %d", len(events))
}
}
// An empty reported fp (pre-v0.48.0 / local-API disabled) is unknown — never seeded, never an alert,
// and never overwrites an existing baseline.
func TestHostLeafChecker_EmptyFpSkipped(t *testing.T) {
st := newLeafStore(t)
st.SaveHostReport("h1", "c1", []byte(`{"host_id":"h1"}`), store.HostReportDenorm{}) // no leaf_fingerprint
var events []string
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
if lc.GetState("h1") != "" {
t.Fatalf("empty fp must not seed a baseline, got %q", lc.GetState("h1"))
}
lc.Check()
if len(events) != 0 {
t.Fatalf("empty fp must not alert, got %v", events)
}
// A real fp later → seeds (no alert, it's the first real baseline).
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
lc.Check()
if len(events) != 0 || lc.GetState("h1") != fpA {
t.Fatalf("first real fp should seed silently, events=%v state=%q", events, lc.GetState("h1"))
}
}
// A blocked customer's host is dropped — no event.
func TestHostLeafChecker_CustomerBlockedDropped(t *testing.T) {
st := newLeafStore(t)
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
var events []string
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
if err := st.SetCustomerConfigStatus("c1", "blocked"); err != nil {
t.Fatalf("block customer: %v", err)
}
st.SaveHostReport("h1", "c1", reportWithLeaf(fpB), store.HostReportDenorm{}) // would-be change
lc.Check()
if len(events) != 0 {
t.Fatalf("blocked customer must not alert, got %v", events)
}
if lc.GetState("h1") != "" {
t.Fatalf("blocked host should be dropped, got %q", lc.GetState("h1"))
}
}