package storage import ( "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // fixtureSysfs builds a /sys/block-shaped tree and points sysBlockRoot at it. `slaves` maps a dm // name to its slave entries; `dmNames` maps a dm name to its /dm/name content (for /dev/mapper/*). func fixtureSysfs(t *testing.T, slaves map[string][]string, dmNames map[string]string) { t.Helper() root := t.TempDir() for dm, sl := range slaves { for _, s := range sl { if err := os.MkdirAll(filepath.Join(root, dm, "slaves", s), 0o755); err != nil { t.Fatal(err) } } } for dm, name := range dmNames { dir := filepath.Join(root, dm, "dm") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "name"), []byte(name+"\n"), 0o644); err != nil { t.Fatal(err) } } old := sysBlockRoot sysBlockRoot = root t.Cleanup(func() { sysBlockRoot = old }) } // Fix A dm/LVM resolution. Red-proof: remove the `len(disks) != 1` all-same-disk guard in // dmWholeDisk → the "mirror over two disks" case resolves to one of them instead of skipping. func TestDMWholeDisk(t *testing.T) { cases := []struct { name string slaves map[string][]string dmNames map[string]string in string want string ok bool }{ {"single SATA slave", map[string][]string{"dm-1": {"sda3"}}, nil, "/dev/dm-1", "/dev/sda", true}, {"single NVMe slave", map[string][]string{"dm-0": {"nvme0n1p3"}}, nil, "/dev/dm-0", "/dev/nvme0n1", true}, {"stacked dm → one disk", map[string][]string{"dm-2": {"dm-1"}, "dm-1": {"sda3"}}, nil, "/dev/dm-2", "/dev/sda", true}, {"mapper name → dm-1", map[string][]string{"dm-1": {"sda3"}}, map[string]string{"dm-1": "pve-root"}, "/dev/mapper/pve-root", "/dev/sda", true}, {"mirror over two disks → skip", map[string][]string{"dm-1": {"sda3", "sdb3"}}, nil, "/dev/dm-1", "", false}, {"no slaves → skip", map[string][]string{"dm-1": {}}, nil, "/dev/dm-1", "", false}, } for _, c := range cases { fixtureSysfs(t, c.slaves, c.dmNames) got, ok := dmWholeDisk(c.in) if ok != c.ok || got != c.want { t.Errorf("%s: dmWholeDisk(%q) = (%q,%v), want (%q,%v)", c.name, c.in, got, ok, c.want, c.ok) } } } // smartDeviceFor routes dm/mapper devices through the resolver, and whole-disk/partition through the // regex path unchanged. func TestSmartDeviceFor_DMBranch(t *testing.T) { fixtureSysfs(t, map[string][]string{"dm-1": {"sda3"}}, map[string]string{"dm-1": "pve-root"}) if dev, ok := smartDeviceFor("/dev/mapper/pve-root"); !ok || dev != "/dev/sda" { t.Errorf("smartDeviceFor(/dev/mapper/pve-root) = (%q,%v), want (/dev/sda,true)", dev, ok) } // missing sysfs → skip, not a guess fixtureSysfs(t, map[string][]string{}, nil) if _, ok := smartDeviceFor("/dev/dm-9"); ok { t.Error("smartDeviceFor should skip an unresolvable dm device") } } func TestContainingMountDevice(t *testing.T) { mounts := []Mount{ {Device: "/dev/mapper/pve-root", MountPoint: "/"}, {Device: "/dev/sda2", MountPoint: "/boot/efi"}, {Device: "/dev/sdb1", MountPoint: "/mnt/usb"}, } // A dir inside root resolves to root's device (longest prefix wins over "/"). if dev, ok := containingMountDevice(mounts, "/var/lib/vz"); !ok || dev != "/dev/mapper/pve-root" { t.Errorf("containing(/var/lib/vz) = (%q,%v), want /dev/mapper/pve-root", dev, ok) } // A path under a more-specific mount picks that mount, not root. if dev, ok := containingMountDevice(mounts, "/mnt/usb/data"); !ok || dev != "/dev/sdb1" { t.Errorf("containing(/mnt/usb/data) = (%q,%v), want /dev/sdb1", dev, ok) } } // Model capture (v0.95.0) — smartctl's model_name flows into SmartSummary; absent → nil. func TestParseSMART_ModelName(t *testing.T) { withModel := parseSMART([]byte(`{"model_name":"TOSHIBA MQ04ABF100","smart_status":{"passed":true}}`)) if withModel.ModelName == nil || *withModel.ModelName != "TOSHIBA MQ04ABF100" { t.Errorf("ModelName = %v, want TOSHIBA MQ04ABF100", withModel.ModelName) } if withModel.Health != hub.SmartPassed { t.Errorf("health = %q, want PASSED", withModel.Health) } noModel := parseSMART([]byte(`{"smart_status":{"passed":true}}`)) if noModel.ModelName != nil { t.Errorf("absent model_name should be nil, got %v", noModel.ModelName) } }