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")) } }