9c5cf2975f
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
146 lines
5.4 KiB
Go
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"))
|
|
}
|
|
}
|