Files
felhom.eu/hub/internal/monitor/r435_pinned_drop_test.go
T

191 lines
7.2 KiB
Go

package monitor
import (
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-435 (D7, `09` §3 decision 191). On a PINNED tier (the hub holds a confirmed append-only key) the
// only legitimate fall of the off-site snapshot count is a clean-up window the hub opened, so any fall
// the windows do not explain alarms — even one snapshot. Non-pinned tiers keep the half-rule.
//
// RED-PROOF (2026-10-08): on the pre-R-435 checker TestR435_PinnedUnexplainedFallAlarms fails —
// 69 → 60 is 9 < 34.5, so no event.
type r435Setup struct {
pinned, confirmed bool
window func(st *store.Store) // runs between the baseline and the next report
next string
}
func r435Run(t *testing.T, cust string, s r435Setup) []string {
t.Helper()
st := newDiskStore(t)
if s.pinned {
if err := st.RecordOffsiteKeyInstalled(cust, "fp-1"); err != nil {
t.Fatal(err)
}
if s.confirmed {
if ok, err := st.RecordOffsiteKeyConfirmed(cust, "fp-1"); err != nil || !ok {
t.Fatalf("confirm: %v %v", ok, err)
}
}
}
var msgs []string
saveOffsiteReport(t, st, cust, dropJSON(69, true, "", "ok"))
oc := NewOffsiteChecker(st, 48*time.Hour, func(_, et, sev, msg, _, _ string) {
if et == "offsite_snapshots_dropped" {
if sev != "error" {
t.Errorf("severity %q, want error", sev)
}
msgs = append(msgs, msg)
}
}, quietLog())
if s.window != nil {
s.window(st)
}
saveOffsiteReport(t, st, cust, s.next)
oc.Check()
return msgs
}
func closedWindow(t *testing.T, cust string, before, after int) func(*store.Store) {
return func(st *store.Store) {
id, err := st.OpenOffsiteWindowRow(cust, time.Now().Add(30*time.Minute), before, 10)
if err != nil {
t.Fatal(err)
}
if _, err := st.CloseOffsiteWindowRow(id, after, "ok", "box"); err != nil {
t.Fatal(err)
}
}
}
func TestR435_PinnedUnexplainedFallAlarms(t *testing.T) {
msgs := r435Run(t, "p1", r435Setup{pinned: true, confirmed: true, next: dropJSON(60, true, "", "ok")})
if len(msgs) != 1 {
t.Fatalf("pinned 69 -> 60 with no window: want exactly 1 offsite_snapshots_dropped, got %d", len(msgs))
}
if !strings.Contains(msgs[0], "outside any clean-up window") || !strings.Contains(msgs[0], "69") || !strings.Contains(msgs[0], "60") {
t.Fatalf("message must name both counts and say „outside any clean-up window\": %s", msgs[0])
}
}
func TestR435_PinnedOneSnapshotAlarms(t *testing.T) {
if msgs := r435Run(t, "p2", r435Setup{pinned: true, confirmed: true, next: dropJSON(68, true, "", "ok")}); len(msgs) != 1 {
t.Fatalf("pinned 69 -> 68: even one snapshot must alarm, got %d", len(msgs))
}
}
func TestR435_WindowExplainsFall(t *testing.T) {
if msgs := r435Run(t, "p3", r435Setup{pinned: true, confirmed: true,
window: closedWindow(t, "p3", 69, 60), next: dropJSON(60, true, "", "ok")}); len(msgs) != 0 {
t.Fatalf("a closed window that removed 9 explains 69 -> 60; got %d alarm(s): %v", len(msgs), msgs)
}
}
func TestR435_WindowExplainsOnlyPart(t *testing.T) {
if msgs := r435Run(t, "p4", r435Setup{pinned: true, confirmed: true,
window: closedWindow(t, "p4", 69, 64), next: dropJSON(60, true, "", "ok")}); len(msgs) != 1 {
t.Fatalf("a window that removed 5 does not explain 69 -> 60; got %d alarm(s)", len(msgs))
}
}
// A timeout-closed window explains up to its hub-set cap (10 here): 69 → 60 is explained.
func TestR435_TimeoutWindowIsUnknownNoAlarm(t *testing.T) {
timeout := func(st *store.Store) {
id, err := st.OpenOffsiteWindowRow("p5", time.Now().Add(30*time.Minute), 69, 10)
if err != nil {
t.Fatal(err)
}
_, _ = st.CloseOffsiteWindowRow(id, -1, "", "timeout")
}
if msgs := r435Run(t, "p5", r435Setup{pinned: true, confirmed: true, window: timeout, next: dropJSON(60, true, "", "ok")}); len(msgs) != 0 {
t.Fatalf("a timeout-closed window cannot say what it removed → no alarm; got %d", len(msgs))
}
}
// Security review 2026-10-08: a box that claims its window removed everything explains only the cap the
// hub granted (10): 69 → 40 is 29 > 10 → one alarm. RED-PROOF: drop the cap in RemovedByWindowsBetween → 0 alarms.
func TestR435_LyingWindowExplainsOnlyItsCap(t *testing.T) {
if msgs := r435Run(t, "p9", r435Setup{pinned: true, confirmed: true,
window: closedWindow(t, "p9", 69, 0), next: dropJSON(40, true, "", "ok")}); len(msgs) != 1 {
t.Fatalf("a window capped at 10 cannot explain 69 -> 40; got %d alarm(s)", len(msgs))
}
}
// Security review 2026-10-08: one window explains ONE fall. A window that removed 9 explains 69 → 60; a
// second fall 60 → 55 in the next report (inside the 2-h slack, no new window) alarms.
// RED-PROOF: stop marking windows as spent (usedWindows) → the second fall is explained again → 0 alarms → FAILS.
func TestR435_OneWindowExplainsOneFall(t *testing.T) {
st := newDiskStore(t)
cust := "p10"
if err := st.RecordOffsiteKeyInstalled(cust, "fp-1"); err != nil {
t.Fatal(err)
}
if ok, err := st.RecordOffsiteKeyConfirmed(cust, "fp-1"); err != nil || !ok {
t.Fatalf("confirm: %v %v", ok, err)
}
var msgs []string
saveOffsiteReport(t, st, cust, dropJSON(69, true, "", "ok"))
oc := NewOffsiteChecker(st, 48*time.Hour, func(_, et, _, msg, _, _ string) {
if et == "offsite_snapshots_dropped" {
msgs = append(msgs, msg)
}
}, quietLog())
closedWindow(t, cust, 69, 60)(st)
saveOffsiteReport(t, st, cust, dropJSON(60, true, "", "ok"))
oc.Check()
if len(msgs) != 0 {
t.Fatalf("the window explains 69 -> 60; got %d alarm(s)", len(msgs))
}
saveOffsiteReport(t, st, cust, dropJSON(55, true, "", "ok"))
oc.Check()
if len(msgs) != 1 {
t.Fatalf("a spent window must not explain the next fall 60 -> 55; got %d alarm(s)", len(msgs))
}
}
// Controls: the half-rule still governs a non-pinned tier, and an installed-but-unconfirmed key.
func TestR435_NotPinnedKeepsHalfRule(t *testing.T) {
if msgs := r435Run(t, "n1", r435Setup{next: dropJSON(60, true, "", "ok")}); len(msgs) != 0 {
t.Fatalf("NAS tier 69 -> 60 must stay silent (half-rule); got %d", len(msgs))
}
if msgs := r435Run(t, "n2", r435Setup{pinned: true, next: dropJSON(60, true, "", "ok")}); len(msgs) != 0 {
t.Fatalf("an unconfirmed key is not pinned; 69 -> 60 must stay silent; got %d", len(msgs))
}
if msgs := r435Run(t, "n3", r435Setup{next: dropJSON(4, true, "", "ok")}); len(msgs) != 1 {
t.Fatalf("NAS tier 69 -> 4 still alarms by the half-rule; got %d", len(msgs))
} else if strings.Contains(msgs[0], "outside any clean-up window") {
t.Fatalf("the half-rule message must not claim a window rule: %s", msgs[0])
}
}
// An untrustworthy report on a pinned tier: no alarm, and the baseline does not move.
func TestR435_UntrustworthyReportNoAlarmBaselineKept(t *testing.T) {
st := newDiskStore(t)
_ = st.RecordOffsiteKeyInstalled("u1", "fp")
_, _ = st.RecordOffsiteKeyConfirmed("u1", "fp")
n := 0
saveOffsiteReport(t, st, "u1", dropJSON(69, true, "", "ok"))
oc := NewOffsiteChecker(st, 48*time.Hour, func(_, et, _, _, _, _ string) {
if et == "offsite_snapshots_dropped" {
n++
}
}, quietLog())
saveOffsiteReport(t, st, "u1", dropJSON(0, false, "", "ok")) // stats unknown
oc.Check()
if n != 0 {
t.Fatalf("untrustworthy report must not alarm; got %d", n)
}
oc.mu.Lock()
base := oc.lastCounts["u1"]
oc.mu.Unlock()
if base != 69 {
t.Fatalf("baseline must stay 69 after an untrustworthy report, got %d", base)
}
}