Files
felhom-controller/controller/internal/agentapi/diskverdict_test.go
T
admin bb50e1293c fix(disk-health): the alert that never sent — severity, a real Hiba level, and a memory that survives a restart
Three defects made the disk-health feature silent in exactly the case it
exists for. Evidence: felhom.eu documentation/audits/DIAG-smart-passed-trap-2026-08-14.md

1. SEVERITY (the one that changes whether anything arrives at all).
   NotifyDiskHealthDegraded emitted severity "warn", which is NOT in the
   hub's accepted set {info,warning,error,critical}. The hub coerced it to
   "info" (hub/internal/api/handler.go) and severityNotifies dropped it
   (hub/internal/notify/dispatcher.go), so every Figyelmeztetes-level disk
   alert was filed as an informational notice and emailed to NOBODY, on the
   customer and the operator leg alike. Now "warning". DiskAlertKind.Severity()
   is exported so the contract is checkable from any package.

2. NO LEVEL ABOVE "worth an eye". smart_status.passed CANNOT fail on
   unreadable sectors (attrs 187/197/198 all carry thresh 0 and a normalized
   value floors at 1), so Hiba was unreachable for this whole fault class.
   DiskVerdictFor now takes a DiskPrior and implements a 14-row top-down
   ladder: sustained unreadable sectors, a count too large to be a blip (64),
   unreadable+remapping together, overheating, NVMe critical flag or spent
   endurance all reach Hiba. No fourth label — predicted failure is "Hiba".

3. IT SPOKE ONCE, AND FORGOT ON RESTART. The baseline was in-memory, so a box
   that rebooted while a disk was failing never alerted again; and between 8
   and 352 sectors nothing was emitted at all. State is now persisted
   (disk-health-state.json, atomic tmp+rename), the decision compares against
   the last ALERTED verdict (collapsing flaps to one alert while letting a
   genuine escalation fire immediately), and a disk already at Hiba re-alerts
   once it has BOTH doubled its count and waited out a 24h cooldown.

The card replays the same prior the check used (diskRecord.PriorSawUncorrectable)
so the chip and the email cannot disagree — the property the shared verdict
function exists to guarantee, now pinned rather than asserted.

Tests: 12 scenario groups A-L. Group L builds the Server through web.NewServer,
the same call main.go makes, over a real file.
2026-08-14 08:10:59 +02:00

72 lines
3.6 KiB
Go

package agentapi
import "testing"
func ip(v int) *int { return &v }
// Verdict table (Part 2, extended v0.215.0). Red-proof: change the PercentageUsed boundary from
// `>= 90` to `> 90` in DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case
// fails.
//
// v0.215.0 moved ONE pre-existing case deliberately: critical_warning>0 was Figyelmeztetés and is
// now Hiba (truth-table row 4). It is NVMe's own critical flag — a declaration by the device, not a
// counter that might drift back — so it belongs with the self-reported failures, not below them.
func TestDiskVerdictFor(t *testing.T) {
noPrior := DiskPrior{}
cases := []struct {
name string
in *SmartSummary
prior DiskPrior
want DiskVerdict
}{
{"nil → unknown", nil, noPrior, DiskVerdictUnknown},
{"empty health → unknown", &SmartSummary{Health: ""}, noPrior, DiskVerdictUnknown},
{"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, noPrior, DiskVerdictUnknown},
{"FAILING → fail", &SmartSummary{Health: SmartFailing}, noPrior, DiskVerdictFail},
{"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, noPrior, DiskVerdictFail},
{"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, noPrior, DiskVerdictOK},
{"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, noPrior, DiskVerdictOK},
{"reallocated>0 alone → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, noPrior, DiskVerdictWarn},
{"pending>0 first sighting → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, noPrior, DiskVerdictWarn},
{"offline_unc>0 first sighting → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, noPrior, DiskVerdictWarn},
{"critical_warning>0 → fail (row 4)", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, noPrior, DiskVerdictFail},
{"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, noPrior, DiskVerdictWarn},
{"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, noPrior, DiskVerdictOK},
{"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, noPrior, DiskVerdictWarn},
{"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, noPrior, DiskVerdictWarn},
{"percentage_used exactly 100 → fail (row 5)", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(100)}, noPrior, DiskVerdictFail},
}
for _, c := range cases {
if got := DiskVerdictFor(c.in, c.prior); got != c.want {
t.Errorf("%s: DiskVerdictFor = %d, want %d", c.name, got, c.want)
}
}
}
func TestDiskVerdict_Label(t *testing.T) {
want := map[DiskVerdict]string{
DiskVerdictUnknown: "Nincs adat",
DiskVerdictOK: "Rendben",
DiskVerdictWarn: "Figyelmeztetés",
DiskVerdictFail: "Hiba",
}
for v, w := range want {
if got := v.Label(); got != w {
t.Errorf("verdict %d Label = %q, want %q", v, got, w)
}
}
}
// A warn lists every triggering attribute at once (Scenario "multiple attributes degrade" → ONE event).
func TestDegradedAttributes_ListsAll(t *testing.T) {
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(5), ReallocatedSectors: ip(2), PercentageUsed: ip(91)}
got := DegradedAttributes(s)
if len(got) != 3 {
t.Fatalf("want 3 attributes, got %d: %v", len(got), got)
}
// clean disk → none
if a := DegradedAttributes(&SmartSummary{Health: SmartPassed}); len(a) != 0 {
t.Errorf("clean disk should list no attributes, got %v", a)
}
}