Files
felhom-controller/controller/internal/web/disk_health_test.go
T
admin 90f2545679
gates / gates (push) Successful in 9s
fix(disk-health): one physical disk must be evaluated once per run (R-335)
Found on live hardware two hours after the v0.215.0 deploy, by noticing the
release's own positive observable disagreed with its own persisted artefact:
the check logged '3 disk(s) evaluated' while disk-health-state.json held two
records. demo-hp's c11-scratch and felhom-backup are the same NVMe and share
a durable id, so one disk was walked twice per run.

Not cosmetic. The loop writes a disk's record before the next entry reads it,
so the second copy of an aliased disk consumed the FIRST copy's write as its
prior: the disk sustained against ITSELF and reached Hiba on a first sighting,
defeating truth-table row 6 — the rule that separates a one-hour benign
excursion from a false critical. It would also have emitted two identical
events for one drive. Latent on demo-hp only because all counters are zero.

Each diskKey is now evaluated once per run. Both entries stay marked seen so
neither looks like a disappeared disk, and the card still renders both rows —
the dedup is about state and alerts, not display.

Red-proof run and reverted: deleting the guard makes the first sighting emit
Kind:2 (Hiba-from-sectors) at 8 sectors.
2026-08-14 10:30:22 +02:00

742 lines
31 KiB
Go

package web
import (
"context"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Disk-health severity ladder, event half (v0.215.0). Scenario letters refer to the task spec.
// The verdict half is pinned in internal/agentapi/diskverdict_ladder_test.go.
func smartPtr(v int) *int { return &v }
func physDisk(name string, sm *agentapi.SmartSummary) agentapi.DiskInfo {
return agentapi.DiskInfo{Name: name, BackingDevice: "/dev/" + name, DurableID: "uuid:" + name, Smart: sm}
}
// realDriveSmart is the failing drive as captured on 2026-08-14 — PASSED, 352 unreadable sectors.
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json
func realDriveSmart() *agentapi.SmartSummary {
return &agentapi.SmartSummary{
Health: agentapi.SmartPassed,
PendingSectors: smartPtr(352),
OfflineUncorrectable: smartPtr(352),
ReallocatedSectors: smartPtr(0),
TemperatureC: smartPtr(40),
}
}
// diskHarness wires a Server with the disks source + notify sink seams, a REAL data dir (so the
// persisted state is exercised, not bypassed) and a controllable clock.
type diskHarness struct {
s *Server
fired *[]notify.DiskAlert
payload *[]agentapi.DiskInfo
dir string
clock *time.Time
}
func newDiskHarness(t *testing.T) *diskHarness {
t.Helper()
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
s := &Server{settings: sett, logger: lg, cfg: cfg}
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
h := &diskHarness{s: s, dir: dir, clock: &now, fired: &[]notify.DiskAlert{}, payload: &[]agentapi.DiskInfo{}}
s.diskHealth.clock = func() time.Time { return *h.clock }
s.diskNotifyFn = func(a notify.DiskAlert) { *h.fired = append(*h.fired, a) }
s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{Disks: *h.payload}, nil
}
return h
}
func (h *diskHarness) set(d ...agentapi.DiskInfo) { *h.payload = d }
func (h *diskHarness) run(t *testing.T) {
t.Helper()
if err := h.s.RunDiskHealthCheck(context.Background()); err != nil {
t.Fatalf("RunDiskHealthCheck: %v", err)
}
}
func (h *diskHarness) advance(d time.Duration) { *h.clock = h.clock.Add(d) }
func (h *diskHarness) events() []notify.DiskAlert {
return *h.fired
}
func (h *diskHarness) reset() { *h.fired = nil }
// record reads the persisted state file straight off disk — asserting the FILE, not the in-memory
// map, because Scenario L depends on what actually landed there.
func (h *diskHarness) record(t *testing.T, key string) *diskRecord {
t.Helper()
data, err := os.ReadFile(filepath.Join(h.dir, diskStateFileName))
if err != nil {
t.Fatalf("reading state file: %v", err)
}
var f diskStateFile
if err := json.Unmarshal(data, &f); err != nil {
t.Fatalf("parsing state file: %v", err)
}
return f.Disks[key]
}
// ── Group A — Scenario A: the real drive, second observation ────────────────────────────────────
// The captured failing drive reaches Hiba, renders the crit chip, and emits exactly ONE event at
// severity "critical".
//
// Red-proof: remove truth-table row 6 (prior.SawUncorrectable) → 352 still trips row 8, so use
// TestDiskLadder_SustainDrivesTheEscalation (Group C) to isolate row 6.
func TestDiskCheck_RealDrive_HibaAndOneCriticalEvent(t *testing.T) {
h := newDiskHarness(t)
h.set(physDisk("sdg", realDriveSmart()))
h.run(t) // first ever observation: baselines silently
if n := len(h.events()); n != 0 {
t.Fatalf("first observation must be silent, got %d event(s)", n)
}
h.run(t) // second observation, now with prior
ev := h.events()
if len(ev) != 1 {
t.Fatalf("want exactly ONE event, got %d: %+v", len(ev), ev)
}
if ev[0].Kind.Severity() != "critical" {
t.Errorf("severity = %q, want %q", ev[0].Kind.Severity(), "critical")
}
if ev[0].Kind != notify.DiskAlertFailSectors {
t.Errorf("kind = %d, want DiskAlertFailSectors (the drive says PASSED — we must not claim it self-reported)", ev[0].Kind)
}
if ev[0].Sectors != 352 {
t.Errorf("sectors = %d, want 352", ev[0].Sectors)
}
// The card must agree with the alert — same verdict function, same prior.
rows := h.s.diskHealthRows(context.Background())
if len(rows) != 1 {
t.Fatalf("want 1 card row, got %d", len(rows))
}
if rows[0].ChipLabel != "Hiba" {
t.Errorf("chip label = %q, want %q", rows[0].ChipLabel, "Hiba")
}
if rows[0].ChipClass != "state-text-crit" {
t.Errorf("chip class = %q, want %q", rows[0].ChipClass, "state-text-crit")
}
}
// ── Group B — Scenario B: the transient that cleared (11 August) ────────────────────────────────
// A first-ever sighting of 8 sectors is Figyelmeztetés, not Hiba. The real drive did exactly this on
// 11 Aug 12:28 and had cleared completely by 13:28.
//
// Red-proof: make truth-table row 9 return Fail → the chip reads Hiba and the kind assertion fails.
func TestDiskCheck_FirstSightingIsWarnOnly(t *testing.T) {
h := newDiskHarness(t)
excursion := &agentapi.SmartSummary{Health: agentapi.SmartPassed,
PendingSectors: smartPtr(8), OfflineUncorrectable: smartPtr(8), ReallocatedSectors: smartPtr(0)}
h.set(physDisk("sdg", excursion))
h.run(t)
rows := h.s.diskHealthRows(context.Background())
if len(rows) != 1 || rows[0].ChipLabel != "Figyelmeztetés" {
t.Fatalf("first sighting chip = %+v, want Figyelmeztetés", rows)
}
rec := h.record(t, "uuid:sdg")
if rec == nil {
t.Fatal("no persisted record for the disk")
}
if !rec.SawUncorrectable {
t.Error("persisted state must record SawUncorrectable=true so the NEXT check can sustain")
}
if rec.Sectors != 8 {
t.Errorf("persisted sectors = %d, want 8", rec.Sectors)
}
}
// ── Group C — Scenario C: the same disk one check later, still bad ──────────────────────────────
// The count does NOT grow — only the fact that it persisted. This isolates truth-table row 6: at 8
// sectors the count backstop (64) cannot be what fires.
//
// Red-proof: drop the prior argument's effect (always pass a zero DiskPrior in RunDiskHealthCheck)
// → the disk stays at Figyelmeztetés forever and no event is emitted.
func TestDiskLadder_SustainDrivesTheEscalation(t *testing.T) {
h := newDiskHarness(t)
excursion := func() agentapi.DiskInfo {
return physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed,
PendingSectors: smartPtr(8), OfflineUncorrectable: smartPtr(8), ReallocatedSectors: smartPtr(0)})
}
h.set(excursion())
h.run(t) // first sighting → silent baseline at Figyelmeztetés
if n := len(h.events()); n != 0 {
t.Fatalf("first sighting must be silent, got %d", n)
}
h.set(excursion())
h.run(t) // SAME counters, now sustained → Hiba
ev := h.events()
if len(ev) != 1 {
t.Fatalf("sustained excursion must emit exactly ONE event, got %d: %+v", len(ev), ev)
}
if ev[0].Kind.Severity() != "critical" {
t.Errorf("severity = %q, want critical", ev[0].Kind.Severity())
}
if ev[0].Sectors != 8 {
t.Errorf("sectors = %d, want 8 — the count did not grow; SUSTAIN is what fired", ev[0].Sectors)
}
if rows := h.s.diskHealthRows(context.Background()); rows[0].ChipLabel != "Hiba" {
t.Errorf("chip = %q, want Hiba", rows[0].ChipLabel)
}
}
// ── Group D — Scenario D: recovered ─────────────────────────────────────────────────────────────
// Red-proof: make recovery emit → an event appears where zero are expected.
func TestDiskCheck_RecoveryIsSilentAndClearsState(t *testing.T) {
h := newDiskHarness(t)
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(8)}))
h.run(t)
h.reset()
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed,
PendingSectors: smartPtr(0), OfflineUncorrectable: smartPtr(0), ReallocatedSectors: smartPtr(0)}))
h.run(t)
if n := len(h.events()); n != 0 {
t.Errorf("recovery must be silent, got %d event(s): %+v", n, h.events())
}
if rows := h.s.diskHealthRows(context.Background()); rows[0].ChipLabel != "Rendben" {
t.Errorf("chip = %q, want Rendben", rows[0].ChipLabel)
}
rec := h.record(t, "uuid:sdg")
if rec.SawUncorrectable {
t.Error("recovery must clear SawUncorrectable, else the disk re-escalates on the next blip")
}
}
// ── Group E — Scenario E: the flap must not spam ────────────────────────────────────────────────
// Warn (alerted) → OK → Warn again, all inside 24h: exactly ONE event across all three checks.
// Today's transition-only logic emits twice on that zero-crossing, which is how a customer learns to
// ignore the message.
//
// Red-proof: compare against the last OBSERVED verdict instead of the last ALERTED one → two events.
func TestDiskCheck_FlapDamping(t *testing.T) {
h := newDiskHarness(t)
warn := func() agentapi.DiskInfo {
return physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed, ReallocatedSectors: smartPtr(1)})
}
clean := func() agentapi.DiskInfo {
return physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed, ReallocatedSectors: smartPtr(0)})
}
// Baseline clean first so the Warn below is a real transition rather than a first sighting.
h.set(clean())
h.run(t)
h.set(warn())
h.run(t) // OK→Warn: the one legitimate alert
if n := len(h.events()); n != 1 {
t.Fatalf("first degradation must emit once, got %d", n)
}
h.advance(2 * time.Hour)
h.set(clean())
h.run(t) // recovery: silent
h.advance(2 * time.Hour)
h.set(warn())
h.run(t) // re-degradation to the SAME level within 24h: damped
if n := len(h.events()); n != 1 {
t.Errorf("a flap inside 24h must produce exactly ONE event, got %d: %+v", n, h.events())
}
}
// ── Group F — Scenario F: escalation always beats damping ───────────────────────────────────────
// Red-proof: let damping cover escalations (return false whenever a verdict was ever alerted) →
// zero events, and a drive that has started actually failing says nothing.
func TestDiskCheck_EscalationBeatsDamping(t *testing.T) {
h := newDiskHarness(t)
h.set(physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed, ReallocatedSectors: smartPtr(0)}))
h.run(t)
h.set(physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed, ReallocatedSectors: smartPtr(1)}))
h.run(t) // OK→Warn, alerted
if n := len(h.events()); n != 1 {
t.Fatalf("setup: want 1 event, got %d", n)
}
h.reset()
// Two hours later — well inside the damping window — it reaches Hiba.
h.advance(2 * time.Hour)
h.set(physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartFailing, ReallocatedSectors: smartPtr(1)}))
h.run(t)
ev := h.events()
if len(ev) != 1 {
t.Fatalf("an escalation inside the damping window MUST emit, got %d", len(ev))
}
if ev[0].Kind.Severity() != "critical" {
t.Errorf("severity = %q, want critical", ev[0].Kind.Severity())
}
if ev[0].Kind != notify.DiskAlertFailSelfReported {
t.Errorf("kind = %d, want DiskAlertFailSelfReported", ev[0].Kind)
}
}
// ── Group G — Scenario G: still getting worse ───────────────────────────────────────────────────
// A disk already at Hiba, last alerted at 64 sectors, now at 352, 24h later: a SECOND event naming
// the current count. Today's code emits nothing at all between 8 and 352 sectors.
//
// Red-proof: remove the re-alert branch from diskAlertDecision → one event only.
func TestDiskCheck_RealertWhenStillWorsening(t *testing.T) {
h := newDiskHarness(t)
at := func(n int) agentapi.DiskInfo {
return physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed,
PendingSectors: smartPtr(n), OfflineUncorrectable: smartPtr(n), ReallocatedSectors: smartPtr(0)})
}
h.set(at(64))
h.run(t) // first sighting → silent (already Hiba via row 8, but baselined)
h.set(at(64))
h.run(t) // sustained → Hiba, alerted at 64
if n := len(h.events()); n != 1 {
t.Fatalf("setup: want 1 event, got %d", n)
}
h.reset()
h.advance(25 * time.Hour)
h.set(at(352))
h.run(t)
ev := h.events()
if len(ev) != 1 {
t.Fatalf("a doubling after the cooldown must re-alert, got %d event(s)", len(ev))
}
if ev[0].Kind != notify.DiskAlertFailWorsened {
t.Errorf("kind = %d, want DiskAlertFailWorsened", ev[0].Kind)
}
if ev[0].Sectors != 352 {
t.Errorf("re-alert must name the CURRENT count, got %d", ev[0].Sectors)
}
if ev[0].Kind.Severity() != "critical" {
t.Errorf("severity = %q, want critical", ev[0].Kind.Severity())
}
}
// ── Group H — Scenario H: growth below the bar, and inside the cooldown ─────────────────────────
// Red-proof: make the cooldown and the doubling bar an OR instead of an AND → an event appears.
func TestDiskCheck_NoRealertBelowBothBars(t *testing.T) {
at := func(n int) agentapi.DiskInfo {
return physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed,
PendingSectors: smartPtr(n), OfflineUncorrectable: smartPtr(n), ReallocatedSectors: smartPtr(0)})
}
// Neither bar cleared: 352 → 360 after 2h.
h := newDiskHarness(t)
h.set(at(352))
h.run(t)
h.set(at(352))
h.run(t) // alerted at 352
h.reset()
h.advance(2 * time.Hour)
h.set(at(360))
h.run(t)
if n := len(h.events()); n != 0 {
t.Errorf("neither bar cleared → no event, got %d: %+v", n, h.events())
}
// ONLY the cooldown cleared (25h) but growth is tiny → still silent. Pins the AND.
h.advance(25 * time.Hour)
h.set(at(400))
h.run(t)
if n := len(h.events()); n != 0 {
t.Errorf("cooldown alone must not re-alert (AND, not OR), got %d: %+v", n, h.events())
}
// ONLY the doubling cleared (2h later) → still silent. Pins the other half of the AND.
h2 := newDiskHarness(t)
h2.set(at(100))
h2.run(t)
h2.set(at(100))
h2.run(t) // alerted at 100
h2.reset()
h2.advance(2 * time.Hour)
h2.set(at(400))
h2.run(t)
if n := len(h2.events()); n != 0 {
t.Errorf("doubling alone inside the cooldown must not re-alert, got %d: %+v", n, h2.events())
}
}
// ── Group I — Scenario I: heat ──────────────────────────────────────────────────────────────────
// The event half of the temperature ladder: a hot disk alerts with the temperature SHAPE, so the
// customer is told to check ventilation rather than to arrange a replacement.
//
// Red-proof: remove truth-table rows 3 and 13 → the disk reads Rendben and nothing is emitted.
func TestDiskCheck_TemperatureShape(t *testing.T) {
h := newDiskHarness(t)
cool := physDisk("sdd", &agentapi.SmartSummary{Health: agentapi.SmartPassed, TemperatureC: smartPtr(40)})
hot := physDisk("sdd", &agentapi.SmartSummary{Health: agentapi.SmartPassed, TemperatureC: smartPtr(61)})
h.set(cool)
h.run(t)
h.set(hot)
h.run(t)
ev := h.events()
if len(ev) != 1 {
t.Fatalf("overheating must emit once, got %d", len(ev))
}
if ev[0].Kind != notify.DiskAlertFailTemperature {
t.Errorf("kind = %d, want DiskAlertFailTemperature", ev[0].Kind)
}
if ev[0].TemperatureC != 61 {
t.Errorf("temperature = %d, want 61", ev[0].TemperatureC)
}
if rows := h.s.diskHealthRows(context.Background()); rows[0].ChipLabel != "Hiba" {
t.Errorf("chip = %q, want Hiba", rows[0].ChipLabel)
}
}
// ── Group J — Scenario J: no data never alarms, and never erases history ────────────────────────
// Red-proof: let UNKNOWN write or delete the record → the disk re-baselines and its history is lost,
// so the sustained escalation afterwards never fires.
func TestDiskCheck_UnknownNeverAlarmsNorErasesPrior(t *testing.T) {
h := newDiskHarness(t)
// Establish a real record with SawUncorrectable set.
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(8)}))
h.run(t)
before := h.record(t, "uuid:sdg")
if !before.SawUncorrectable {
t.Fatal("setup: expected SawUncorrectable")
}
h.reset()
// A transient unreadable SMART, and separately a nil one.
for _, sm := range []*agentapi.SmartSummary{{Health: agentapi.SmartUnknown}, nil} {
h.set(physDisk("sdg", sm))
h.run(t)
if n := len(h.events()); n != 0 {
t.Fatalf("no-data disk must never alarm, got %d", n)
}
after := h.record(t, "uuid:sdg")
if after == nil {
t.Fatal("UNKNOWN deleted the prior record — a transient blip must not erase history")
}
if after.SawUncorrectable != before.SawUncorrectable || after.Verdict != before.Verdict {
t.Errorf("UNKNOWN overwrote the prior: before=%+v after=%+v", before, after)
}
if rows := h.s.diskHealthRows(context.Background()); len(rows) != 1 || rows[0].ChipLabel != "Nincs adat" {
t.Errorf("chip = %+v, want Nincs adat", rows)
}
}
// History survived, so the real sustained escalation still fires.
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(8)}))
h.run(t)
if n := len(h.events()); n != 1 {
t.Errorf("the sustained escalation must still fire after a no-data blip, got %d", n)
}
}
// ── Group L — Scenario L: state survives a restart (SEAM TEST) ──────────────────────────────────
// Constructed through the PRODUCTION path — web.NewServer, exactly as cmd/controller/main.go builds
// it — over the same data dir, reading and writing a real file. An injected-seam test proves the
// component; it never proves the caller. This project has shipped seven built-but-never-wired
// defects, and the severity bug being fixed here is arguably the seventh.
//
// Red-proof: skip loadDiskStateLocked in RunDiskHealthCheck → the restarted controller silently
// re-baselines a disk it has already reported, and never alerts on it again. That is today's bug.
func TestDiskCheck_StateSurvivesRestart_ProductionPath(t *testing.T) {
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
failing := []agentapi.DiskInfo{physDisk("sdg", realDriveSmart())}
newProdServer := func() (*Server, *[]notify.DiskAlert) {
// The same call cmd/controller/main.go makes.
s := NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
fired := &[]notify.DiskAlert{}
s.diskNotifyFn = func(a notify.DiskAlert) { *fired = append(*fired, a) }
s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{Disks: failing}, nil
}
return s, fired
}
s1, fired1 := newProdServer()
_ = s1.RunDiskHealthCheck(context.Background()) // baseline
_ = s1.RunDiskHealthCheck(context.Background()) // sustained → Hiba, one alert
if len(*fired1) != 1 {
t.Fatalf("setup: want 1 alert before the restart, got %d", len(*fired1))
}
if _, err := os.Stat(filepath.Join(dir, diskStateFileName)); err != nil {
t.Fatalf("the production path must have WRITTEN the state file: %v", err)
}
// Restart: a brand-new Server over the same data dir, disk still failing.
//
// TWO checks, deliberately. One is not enough to tell a loaded state from a silent re-baseline:
// a forgetful controller is also silent on its first check. It betrays itself on the SECOND,
// when the rebuilt prior makes the disk look newly sustained and it alerts all over again.
s2, fired2 := newProdServer()
_ = s2.RunDiskHealthCheck(context.Background())
_ = s2.RunDiskHealthCheck(context.Background())
if n := len(*fired2); n != 0 {
t.Errorf("a restarted controller must NOT re-alert an already-reported disk, got %d: %+v", n, *fired2)
}
// And it must still know the disk is bad — the chip cannot silently drop to Figyelmeztetés.
rows := s2.diskHealthRows(context.Background())
if len(rows) != 1 || rows[0].ChipLabel != "Hiba" {
t.Errorf("after restart the card = %+v, want Hiba", rows)
}
}
// A corrupt state file must LOG and fall back to "no prior" — never crash the check and never take
// the box down. The fail-safe direction is a duplicate alert, not a missing one.
func TestDiskState_CorruptFileFallsBackToNoPrior(t *testing.T) {
h := newDiskHarness(t)
if err := os.WriteFile(filepath.Join(h.dir, diskStateFileName), []byte("{not json"), 0644); err != nil {
t.Fatal(err)
}
h.set(physDisk("sdg", realDriveSmart()))
h.run(t) // must not panic; treated as first-ever → silent
if n := len(h.events()); n != 0 {
t.Errorf("corrupt state → treated as no prior → silent baseline, got %d", n)
}
// And the next check works normally, proving the check was not left wedged.
h.run(t)
if n := len(h.events()); n != 1 {
t.Errorf("check must keep working after a corrupt state file, got %d events", n)
}
}
// A disk that disappears from the report is forgotten, so a reappearance re-baselines silently
// (behaviour preserved from v0.169.0 — persisting the state must not change it).
func TestDiskCheck_DisappearedDiskIsForgotten(t *testing.T) {
h := newDiskHarness(t)
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(8)}))
h.run(t)
h.set() // drive removed
h.run(t)
if r := h.record(t, "uuid:sdg"); r != nil {
t.Errorf("a disappeared disk must be dropped, got %+v", r)
}
// Reappears, still bad: first sighting again → silent, NOT an immediate Hiba.
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(8)}))
h.run(t)
if n := len(h.events()); n != 0 {
t.Errorf("a reappearing disk must re-baseline silently, got %d", n)
}
}
// ── One physical disk, several storage entries ──────────────────────────────────────────────────
// FOUND ON LIVE HARDWARE (demo-hp, 2026-08-14): the check reported "3 disk(s) evaluated" while the
// persisted state held only TWO records, because `c11-scratch` and `felhom-backup` are the same NVMe
// and resolve to the same durable id.
//
// That aliasing was a real defect, not a cosmetic one. The loop writes this run's record before the
// next entry reads it, so the second copy of a disk consumed the FIRST copy's write as its prior —
// the disk sustained against ITSELF and reached Hiba on a first sighting, defeating truth-table row 6
// entirely, and emitted two identical events while doing it.
//
// Red-proof: delete the `if seen[key] { continue }` guard in RunDiskHealthCheck → the first run
// reaches Fail and emits, and both assertions below fail.
func TestDiskCheck_SameDiskTwiceIsEvaluatedOnce(t *testing.T) {
h := newDiskHarness(t)
// Two storage entries, ONE physical device — identical durable id, as the agent really reports it.
twice := func() []agentapi.DiskInfo {
sm := &agentapi.SmartSummary{Health: agentapi.SmartPassed,
PendingSectors: smartPtr(8), OfflineUncorrectable: smartPtr(8), ReallocatedSectors: smartPtr(0)}
return []agentapi.DiskInfo{
{Name: "c11-scratch", Type: "local-dir", BackingDevice: "/dev/nvme0n1", DurableID: "uuid:nvme-1", Smart: sm},
{Name: "felhom-backup", Type: "local-dir", BackingDevice: "/dev/nvme0n1", DurableID: "uuid:nvme-1", Smart: sm},
}
}
h.set(twice()...)
h.run(t)
// FIRST sighting: silent, and Figyelmeztetés — a disk must not sustain against its own alias.
if n := len(h.events()); n != 0 {
t.Fatalf("first sighting of an aliased disk must be silent, got %d: %+v", n, h.events())
}
rec := h.record(t, "uuid:nvme-1")
if rec == nil {
t.Fatal("no record for the aliased disk")
}
if agentapi.DiskVerdict(rec.Verdict) != agentapi.DiskVerdictWarn {
t.Errorf("first sighting verdict = %d, want Warn — the disk sustained against its own alias",
rec.Verdict)
}
if rec.PriorSawUncorrectable {
t.Error("the prior used on a first sighting must be empty, not this run's own write")
}
// SECOND run: now genuinely sustained → exactly ONE event, not one per storage entry.
h.set(twice()...)
h.run(t)
if n := len(h.events()); n != 1 {
t.Errorf("one physical disk must produce ONE event, got %d: %+v", n, h.events())
}
// Both entries still render on the card — dedup is about state and alerts, not display.
if rows := h.s.diskHealthRows(context.Background()); len(rows) != 2 {
t.Errorf("card must still show both storage entries, got %d rows", len(rows))
}
}
// An unreachable agent changes nothing at all — no state churn, no alarm, no error.
func TestDiskCheck_UnreachableAgentIsInert(t *testing.T) {
h := newDiskHarness(t)
h.set(physDisk("sdg", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(8)}))
h.run(t)
before := h.record(t, "uuid:sdg")
h.s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{}, context.DeadlineExceeded
}
if err := h.s.RunDiskHealthCheck(context.Background()); err != nil {
t.Errorf("an unreachable agent must return nil, got %v", err)
}
after := h.record(t, "uuid:sdg")
if after == nil || after.SawUncorrectable != before.SawUncorrectable {
t.Errorf("an unreachable agent must not churn state: before=%+v after=%+v", before, after)
}
if n := len(h.events()); n != 0 {
t.Errorf("an unreachable agent must not alarm, got %d", n)
}
}
// diskAlertDecision is pure — pin its table directly, including the boundaries the scenarios above
// only reach indirectly.
func TestDiskAlertDecision_Table(t *testing.T) {
base := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
alertedFail := func(sectors int, ago time.Duration) *diskRecord {
return &diskRecord{Verdict: int(agentapi.DiskVerdictFail), Alerted: true,
AlertedVerdict: int(agentapi.DiskVerdictFail), AlertedSectors: sectors, AlertedAt: base.Add(-ago)}
}
cases := []struct {
name string
prev *diskRecord
v agentapi.DiskVerdict
sectors int
wantEmit bool
wantWorsened bool
}{
{"first ever, Warn", nil, agentapi.DiskVerdictWarn, 8, false, false},
{"first ever, Fail", nil, agentapi.DiskVerdictFail, 352, false, false},
{"OK is always silent", &diskRecord{Verdict: int(agentapi.DiskVerdictWarn)}, agentapi.DiskVerdictOK, 0, false, false},
{"never alerted, now Warn", &diskRecord{Verdict: int(agentapi.DiskVerdictOK)}, agentapi.DiskVerdictWarn, 8, true, false},
{"escalate Warn→Fail", &diskRecord{Verdict: int(agentapi.DiskVerdictWarn), Alerted: true,
AlertedVerdict: int(agentapi.DiskVerdictWarn), AlertedAt: base}, agentapi.DiskVerdictFail, 8, true, false},
{"steady Fail, no growth, no time", alertedFail(352, time.Hour), agentapi.DiskVerdictFail, 352, false, false},
{"exactly 2x but only 23h", alertedFail(64, 23 * time.Hour), agentapi.DiskVerdictFail, 128, false, false},
{"exactly 2x at exactly 24h", alertedFail(64, 24 * time.Hour), agentapi.DiskVerdictFail, 128, true, true},
{"25h but only 1.9x", alertedFail(64, 25 * time.Hour), agentapi.DiskVerdictFail, 121, false, false},
{"improved Fail→Warn is silent", alertedFail(352, 48 * time.Hour), agentapi.DiskVerdictWarn, 8, false, false},
{"Fail from heat (0 sectors) never re-alerts on the doubling rule",
alertedFail(0, 48 * time.Hour), agentapi.DiskVerdictFail, 0, false, false},
}
for _, c := range cases {
emit, worsened := diskAlertDecision(c.prev, c.v, c.sectors, base)
if emit != c.wantEmit || worsened != c.wantWorsened {
t.Errorf("%s: got (emit=%v worsened=%v), want (emit=%v worsened=%v)",
c.name, emit, worsened, c.wantEmit, c.wantWorsened)
}
}
}
// ── Preserved v0.169.0/v0.171.0 coverage ────────────────────────────────────────────────────────
// The card renders gracefully with nil SMART (old agent / no data): the row shows "Nincs adat" and
// never errors; a physical disk with no smart field is still listed.
func TestDiskHealthRows_NilSmart(t *testing.T) {
h := newDiskHarness(t)
h.set(
agentapi.DiskInfo{Name: "sdb", BackingDevice: "/dev/sdb", Smart: nil}, // physical, no smart → Nincs adat
// PBS/LVM carry a default UNKNOWN SMART from the agent — must STILL be excluded (not disks).
agentapi.DiskInfo{Name: "felhom-pbs", Type: "pbs", Smart: &agentapi.SmartSummary{Health: agentapi.SmartUnknown}},
agentapi.DiskInfo{Name: "local-lvm", Type: "lvmthin", Smart: &agentapi.SmartSummary{Health: agentapi.SmartUnknown}},
agentapi.DiskInfo{Name: "sdc", BackingDevice: "/dev/sdc", Smart: &agentapi.SmartSummary{Health: agentapi.SmartPassed, TemperatureC: smartPtr(31)}},
)
rows := h.s.diskHealthRows(context.Background())
if len(rows) != 2 {
t.Fatalf("want 2 physical-disk rows (pbs+lvm excluded), got %d: %+v", len(rows), rows)
}
byLabel := map[string]DiskHealthRow{}
for _, r := range rows {
byLabel[r.Label] = r
}
if byLabel["sdb"].ChipLabel != "Nincs adat" {
t.Errorf("nil-smart disk chip = %q, want Nincs adat", byLabel["sdb"].ChipLabel)
}
if byLabel["sdc"].ChipLabel != "Rendben" || byLabel["sdc"].Temp != "31" {
t.Errorf("sdc row = %+v, want Rendben / 31", byLabel["sdc"])
}
}
// TTL cache: two card fetches inside 60s hit the agent once.
func TestCachedDisks_TTL(t *testing.T) {
s := testServer(t)
calls := 0
s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
calls++
return agentapi.DisksResponse{}, nil
}
ctx := context.Background()
_, _ = s.cachedDisks(ctx)
_, _ = s.cachedDisks(ctx)
if calls != 1 {
t.Errorf("two fetches within the TTL should call the agent once, got %d", calls)
}
}
func sptr(s string) *string { return &s }
// v0.171.0: the card label prefers the device model (agent v0.95.0) over the raw name/UUID; it falls
// back to Name (+ speed hint) on an older agent or a modelless disk.
// Red-proof: drop the fallback (always return ModelName) → the nil-model/old-agent cases return "" and
// TestDiskDisplayLabel_PrefersModel fails (A4 — old-payload tolerance).
func TestDiskDisplayLabel_PrefersModel(t *testing.T) {
withModel := agentapi.DiskInfo{Name: "47a3361a-uuid", Class: "slow",
Smart: &agentapi.SmartSummary{Health: agentapi.SmartPassed, ModelName: sptr("TOSHIBA MQ04ABF100")}}
if got := diskDisplayLabel(withModel); got != "TOSHIBA MQ04ABF100" {
t.Errorf("label = %q, want the model name", got)
}
// A4: an old (v0.94.0) agent carries no model → Name (+ speed hint), byte-identical to before.
oldAgent := agentapi.DiskInfo{Name: "local", Class: "fast",
Smart: &agentapi.SmartSummary{Health: agentapi.SmartUnknown}}
if got := diskDisplayLabel(oldAgent); got != "local (gyors)" {
t.Errorf("old-agent label = %q, want 'local (gyors)'", got)
}
// No SMART at all → bare Name.
if got := diskDisplayLabel(agentapi.DiskInfo{Name: "sdb"}); got != "sdb" {
t.Errorf("nil-smart label = %q, want 'sdb'", got)
}
}