Files
felhom-controller/controller/internal/channelhealth/checker_test.go
T
admin b2ad871720 channelhealth: F2 — alert on born/persistent-down (alerted flag), not only transitions v0.91.0
A channel broken at startup/reseed (e.g. controller boots into pin_mismatch) was dashboard-only,
no operator email ever. New 'alerted' flag drives alerting instead of prev=='': born-down
non-transient alerts cycle 1; transient still N>=2; healthy first-obs silent; recovery re-arms.
Red-proof + companion included.

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

275 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelhealth
import (
"context"
"errors"
"io"
"log"
"testing"
)
type downCall struct {
reason Reason
eventType string
severity string
}
type fakeSink struct {
downs []downCall
recovered int
dashDown bool
dashReason Reason
}
func (f *fakeSink) SetDashboard(down bool, reason Reason, _ string) {
f.dashDown = down
f.dashReason = reason
}
func (f *fakeSink) NotifyDown(reason Reason, eventType, severity, _ string) {
f.downs = append(f.downs, downCall{reason, eventType, severity})
}
func (f *fakeSink) NotifyRecovered() { f.recovered++ }
// scriptedProbe returns the next (constructionErr, err) on each call; a fresh agentapi.New is NEVER
// built here — the checker only calls this seam (proving it reuses the production client).
type scriptedProbe struct {
steps []struct {
cons bool
err error
}
i int
calls int
}
func (p *scriptedProbe) fn(_ context.Context) (bool, error) {
p.calls++
s := p.steps[p.i]
if p.i < len(p.steps)-1 {
p.i++
}
return s.cons, s.err
}
func newChecker(t *testing.T, sink Sink) *Checker {
t.Helper()
return New(nil, sink, log.New(io.Discard, "", 0))
}
func step(cons bool, err error) struct {
cons bool
err error
} {
return struct {
cons bool
err error
}{cons, err}
}
// run feeds the checker a sequence of probe results.
func run(c *Checker, p *scriptedProbe) {
c.probe = p.fn
for range p.steps {
_ = c.Check(context.Background())
}
}
// §8 classification: each error → correct reason/event/severity. Seed up first so the down is a
// transition (non-debounce reasons fire on the first down observation).
func TestClassify_PerReason(t *testing.T) {
cases := []struct {
name string
cons bool
err error
reason Reason
eventType string
severity string
}{
{"pin", false, errors.New(`agentapi: GET /storage: Get "https://x": agentapi: TLS pin mismatch: ...`), ReasonPinMismatch, "agent_channel_pin_mismatch", "error"},
{"401", false, errors.New("agentapi: GET /storage: HTTP 401"), ReasonUnauthorized, "agent_channel_unauthorized", "error"},
{"dns", false, errors.New(`agentapi: GET /storage: Get "https://x": dial tcp: lookup x: no such host`), ReasonMisconfigured, "agent_channel_misconfigured", "error"},
{"construction", true, errors.New("agentapi: fingerprint must be a SHA-256 (64 hex chars), got 10"), ReasonConstruction, "agent_channel_construction_error", "error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sink := &fakeSink{}
c := newChecker(t, sink)
p := &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil), step(tc.cons, tc.err)}} // seed up → down
run(c, p)
if len(sink.downs) != 1 {
t.Fatalf("want 1 down alert, got %d", len(sink.downs))
}
d := sink.downs[0]
if d.reason != tc.reason || d.eventType != tc.eventType || d.severity != tc.severity {
t.Fatalf("got %+v, want reason=%s event=%s sev=%s", d, tc.reason, tc.eventType, tc.severity)
}
if !sink.dashDown {
t.Errorf("dashboard should be down")
}
})
}
}
// §7-C debounce RED-PROOF: a single connection-refused (the ~1s agent-restart blip) must NOT alert;
// TWO consecutive must alert exactly once. A no-debounce impl fires on the single blip → fails this.
func TestDebounce_TransientBlipSuppressed(t *testing.T) {
refused := errors.New(`agentapi: GET /storage: Get "https://x": dial tcp: connect: connection refused`)
// One refused sandwiched by up: NO alert.
sink := &fakeSink{}
c := newChecker(t, sink)
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil), step(false, refused), step(false, nil)}})
if len(sink.downs) != 0 {
t.Fatalf("single transient blip must NOT alert, got %d", len(sink.downs))
}
if sink.dashDown {
t.Errorf("single blip must not flip the dashboard down")
}
// Two consecutive refused: exactly ONE alert.
sink2 := &fakeSink{}
c2 := newChecker(t, sink2)
run(c2, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil), step(false, refused), step(false, refused)}})
if len(sink2.downs) != 1 || sink2.downs[0].reason != ReasonUnreachable {
t.Fatalf("two consecutive refused → want 1 unreachable alert, got %+v", sink2.downs)
}
}
// §7-A/B: up→down alerts once (not per cycle); §7: down→up recovers.
func TestTransitions_NoDuplicate_AndRecovery(t *testing.T) {
pin := errors.New("agentapi: GET /storage: ...: agentapi: TLS pin mismatch: ...")
sink := &fakeSink{}
c := newChecker(t, sink)
// seed up, then 3× down (same reason), then up.
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil), step(false, pin), step(false, pin), step(false, pin), step(false, nil)}})
if len(sink.downs) != 1 {
t.Fatalf("steady down must alert once, got %d", len(sink.downs))
}
if sink.recovered != 1 {
t.Fatalf("want 1 recovered, got %d", sink.recovered)
}
if sink.dashDown {
t.Errorf("dashboard should be cleared after recovery")
}
}
// §7-E: a reason change (unreachable→pin_mismatch) re-alerts.
func TestReasonChange_ReAlerts(t *testing.T) {
refused := errors.New("...: connect: connection refused")
pin := errors.New("...: agentapi: TLS pin mismatch: ...")
sink := &fakeSink{}
c := newChecker(t, sink)
// seed up; 2× refused (confirm unreachable → alert 1); then pin (reason change → alert 2).
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil), step(false, refused), step(false, refused), step(false, pin)}})
if len(sink.downs) != 2 {
t.Fatalf("reason change should re-alert (want 2), got %d: %+v", len(sink.downs), sink.downs)
}
if sink.downs[0].reason != ReasonUnreachable || sink.downs[1].reason != ReasonPinMismatch {
t.Fatalf("want unreachable then pin_mismatch, got %+v", sink.downs)
}
}
// A HEALTHY first observation seeds silently (no alert).
func TestFirstObservation_HealthySeedsNoAlert(t *testing.T) {
sink := &fakeSink{}
c := newChecker(t, sink)
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil)}})
if len(sink.downs) != 0 || sink.recovered != 0 || sink.dashDown {
t.Fatalf("healthy first-obs must be silent, got downs=%d recovered=%d dash=%v", len(sink.downs), sink.recovered, sink.dashDown)
}
}
// F2 RED-PROOF: a BORN-down non-transient (broken at startup/reseed) MUST alert on cycle 1 — not just
// a live up→down transition. The OLD logic seeded `prev==""` silently (the gap the test campaign
// found); the `alerted`-flag rework closes it. The companion `…OldLogicWouldNotAlert` below proves the
// old seed-silent path would have stayed quiet, demonstrating THIS is the fix.
func TestF2_BornDownNonTransient_AlertsOnce(t *testing.T) {
pin := errors.New(`agentapi: GET /storage: ...: agentapi: TLS pin mismatch: ...`)
sink := &fakeSink{}
c := newChecker(t, sink)
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, pin), step(false, pin)}}) // first ever = down (born-down), then steady
if len(sink.downs) != 1 || sink.downs[0].reason != ReasonPinMismatch {
t.Fatalf("born-down pin_mismatch must alert exactly once, got %+v", sink.downs)
}
if !sink.dashDown || c.State() != "down:pin_mismatch" {
t.Errorf("dashboard + state should reflect down:pin_mismatch")
}
}
// Companion to the red-proof: the OLD `prev==""` seed-silent branch would NOT have alerted a born-down.
// (Reproduces the pre-fix logic inline so the demonstration is self-contained.)
func TestF2_OldSeedSilentLogicWouldNotAlert(t *testing.T) {
// Pre-fix decision: confirmed down with prev=="" → seed, return (no NotifyDown).
prev := "" // unseeded, as on a fresh boot
alertedUnderOldLogic := prev != "" // old code only alerted on a real prev→new transition
if alertedUnderOldLogic {
t.Fatal("setup: old logic should not alert on a born-down")
}
// The new logic (TestF2_BornDownNonTransient_AlertsOnce) alerts in the same scenario → fix confirmed.
}
// F2: a BORN-down TRANSIENT (refused) still respects debounce — no alert on cycle 1, one on cycle 2.
func TestF2_BornDownTransient_Debounced(t *testing.T) {
refused := errors.New("...: connect: connection refused")
sink := &fakeSink{}
c := newChecker(t, sink)
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, refused), step(false, refused)}}) // born-down transient
if len(sink.downs) != 1 || sink.downs[0].reason != ReasonUnreachable {
t.Fatalf("born-down transient → one alert after N>=2, got %+v", sink.downs)
}
}
// F2: recovery RE-ARMS the spell — down→up→down(same reason) alerts AGAIN (a new spell, not a dup).
func TestF2_RecoveryReArmsSpell(t *testing.T) {
pin := errors.New("...: agentapi: TLS pin mismatch: ...")
sink := &fakeSink{}
c := newChecker(t, sink)
run(c, &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil), step(false, pin), step(false, nil), step(false, pin)}}) // up,down,up,down
if len(sink.downs) != 2 {
t.Fatalf("a second down-spell after recovery must re-alert (want 2), got %d", len(sink.downs))
}
if sink.recovered != 1 {
t.Fatalf("one recovery between the spells, got %d", sink.recovered)
}
}
// The checker only uses the injected probe seam (never a fresh agentapi.New).
func TestProbe_SeamOnly(t *testing.T) {
sink := &fakeSink{}
c := newChecker(t, sink)
p := &scriptedProbe{steps: []struct {
cons bool
err error
}{step(false, nil)}}
run(c, p)
if p.calls != 1 {
t.Fatalf("checker should call the probe seam exactly once, got %d", p.calls)
}
}