package localapi import ( "context" "io" "log/slog" "net/http" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/storage" ) func smartIP(v int) *int { return &v } // TestDisks_SmartSerialized (v0.94.0) — the already-computed SMART summary is copied into the /disks // payload for a target that has it (Health set), including the SATA counters + temperature; a target // whose SMART was never read (zero-value summary, Health "") omits the field entirely. // // Red-proof: drop the `di.Smart = &sm` copy in handleDisks → the "data" disk's Smart is nil → this fails. func TestDisks_SmartSerialized(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} sv := fakeStorage{targets: []hub.StorageTarget{ { Name: "data", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/data", Smart: hub.SmartSummary{ Health: hub.SmartPassed, TemperatureC: smartIP(34), ReallocatedSectors: smartIP(3), PendingSectors: smartIP(0), }, }, // Zero-value SMART (never read) — Health "" → must be omitted from the payload. {Name: "nosmart", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdc1", MountPath: "/mnt/nosmart"}, }} h := newDiskServer(t, d, &fakeGate{}, sv, nil) w := do(t, h, "GET", "/disks", "A", "") if w.Code != http.StatusOK { t.Fatalf("GET /disks: %d (%s)", w.Code, w.Body.String()) } byName := map[string]DiskInfo{} for _, di := range decodeDisks(t, w.Body.Bytes()) { byName[di.Name] = di } ds := byName["data"].Smart if ds == nil { t.Fatal("data disk: smart summary was not serialized") } if ds.Health != hub.SmartPassed { t.Errorf("data smart Health = %q, want PASSED", ds.Health) } if ds.ReallocatedSectors == nil || *ds.ReallocatedSectors != 3 { t.Errorf("data ReallocatedSectors = %v, want 3", ds.ReallocatedSectors) } if ds.PendingSectors == nil || *ds.PendingSectors != 0 { t.Errorf("data PendingSectors = %v, want 0 (a real zero, not null)", ds.PendingSectors) } if ds.TemperatureC == nil || *ds.TemperatureC != 34 { t.Errorf("data TemperatureC = %v, want 34", ds.TemperatureC) } if byName["nosmart"].Smart != nil { t.Errorf("nosmart disk: smart must be omitted when Health is empty, got %+v", byName["nosmart"].Smart) } } // ---- Fix B (v0.95.0): the /disks union path reads SMART for registry/USB drives ---- type fakeKnownTargets struct{ drives []storage.KnownTarget } func (f fakeKnownTargets) Known(context.Context) ([]storage.KnownTarget, error) { return f.drives, nil } type fakeSmartReader struct { byDev map[string]hub.SmartSummary calls []string } func (f *fakeSmartReader) SMARTForBacking(_ context.Context, dev string) hub.SmartSummary { f.calls = append(f.calls, dev) if s, ok := f.byDev[dev]; ok { return s } return hub.SmartSummary{} } func sp(s string) *string { return &s } // A union-path (registry/USB) drive now gets a real SMART read + model, via the Smart seam — it used // to ride the enrich-free union path and show "Nincs adat". // Red-proof: delete the Fix-B block in handleDisks (the s.smart.SMARTForBacking call) → the union // drive carries no smart and this fails. func TestDisks_UnionPathReadsSMART(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} sm := &fakeSmartReader{byDev: map[string]hub.SmartSummary{ "/dev/sdb1": {Health: hub.SmartPassed, ModelName: sp("TOSHIBA MQ04ABF100")}, }} srv, err := NewServer(Options{ ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{}, Storage: fakeStorage{}, // no Observe targets → the union drive is not deduped away DriveTargets: fakeKnownTargets{drives: []storage.KnownTarget{ {Name: "data-usb", Type: hub.StorageTypeUSB, MountPath: "/mnt/hdd_1", DurableID: "uuid:47a3", UUID: "47a3"}, }}, Smart: sm, Tokens: staticTokens{"A": 8200}, Disks: d, HostReader: sysOnSDA(), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("new server: %v", err) } srv.baseCtx = context.Background() srv.resolveStorageDevice = func(string) (string, error) { return "/dev/sdb1", nil } disks := decodeDisks(t, do(t, srv.Handler(), "GET", "/disks", "A", "").Body.Bytes()) var usb *DiskInfo for i := range disks { if disks[i].Name == "data-usb" { usb = &disks[i] } } if usb == nil { t.Fatalf("union drive not in /disks: %+v", disks) } if usb.Smart == nil || usb.Smart.Health != hub.SmartPassed { t.Fatalf("union drive SMART not read: %+v", usb.Smart) } if usb.Smart.ModelName == nil || *usb.Smart.ModelName != "TOSHIBA MQ04ABF100" { t.Errorf("union drive model not carried: %v", usb.Smart) } if len(sm.calls) != 1 || sm.calls[0] != "/dev/sdb1" { t.Errorf("SMART should be read once on /dev/sdb1, got %v", sm.calls) } }