package localapi import ( "encoding/json" "net/http" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/storage" ) // decodeDisks pulls the disks array out of a GET /disks response. func decodeDisks(t *testing.T, body []byte) []DiskInfo { t.Helper() var resp struct { Data struct { Disks []DiskInfo `json:"disks"` } `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode /disks: %v (body=%s)", err, string(body)) } return resp.Data.Disks } // TestDisks_WipeDurableID_GateScheme asserts F20-BUG2: /disks surfaces a wipe_durable_id in the gate's // scheme (byid:/byuuid:, via the SAME s.deviceDurableID seam the format gate uses), DISTINCT from the // uuid: durable_id (which feeds /disks/assign). Pre-fix the only id was uuid:, which the gate rejected // as a binding_mismatch. func TestDisks_WipeDurableID_GateScheme(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} sv := fakeStorage{targets: []hub.StorageTarget{ {Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:abc-123"}, }} 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()) } disks := decodeDisks(t, w.Body.Bytes()) if len(disks) != 1 { t.Fatalf("want 1 disk, got %d", len(disks)) } got := disks[0] // The stub in newDiskServer maps /dev/sdb1 → byid:wwn-sdb1 (the gate scheme). if got.WipeDurableID != "byid:wwn-sdb1" { t.Fatalf("WipeDurableID = %q, want byid:wwn-sdb1 (gate scheme)", got.WipeDurableID) } if got.DurableID != "uuid:abc-123" { t.Fatalf("DurableID = %q, want uuid:abc-123 (assign scheme, unchanged)", got.DurableID) } if got.WipeDurableID == got.DurableID { t.Fatal("wipe id must differ from the assign (uuid:) id") } } // TestDisks_WipeID_MatchesGateBinding asserts the BUG2 end-to-end property: when the customer confirms a // wipe with the wipe_durable_id surfaced by /disks, the handler derives the device's gate id through the // SAME seam — so the value the gate compares as DeviceDurableID equals the customer's ConfirmDurableID // (no binding_mismatch). Asserted via the request the handler forwards to the gate. func TestDisks_WipeID_MatchesGateBinding(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} sv := fakeStorage{targets: []hub.StorageTarget{ {Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:abc-123"}, }} g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}} h := newDiskServer(t, d, g, sv, nil) wipeID := decodeDisks(t, do(t, h, "GET", "/disks", "A", "").Body.Bytes())[0].WipeDurableID body := `{"device":"/dev/sdb1","fstype":"ext4","confirmed":true,"durable_id":"` + wipeID + `"}` w := do(t, h, "POST", "/disks/format", "A", body) if w.Code != http.StatusOK { t.Fatalf("confirmed wipe with the list's wipe id: %d (%s)", w.Code, w.Body.String()) } reqs := g.requests() if len(reqs) != 1 { t.Fatalf("gate consulted %d times, want 1", len(reqs)) } // The crux: the customer's confirm id (from the list) equals the device id the gate resolves. if reqs[0].ConfirmDurableID != wipeID { t.Fatalf("ConfirmDurableID forwarded = %q, want the list's wipe id %q", reqs[0].ConfirmDurableID, wipeID) } if reqs[0].DeviceDurableID != reqs[0].ConfirmDurableID { t.Fatalf("gate binding mismatch: device id %q != confirm id %q (BUG2 not fixed)", reqs[0].DeviceDurableID, reqs[0].ConfirmDurableID) } } // TestDisks_GuestAttached asserts F9 reporting: a drive bound into THIS guest's config (mp=) // reports guest_attached=true; a host-present-but-unbound drive reports false — the signal that was // missing when the HDD looked available but wasn't attached. func TestDisks_GuestAttached(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} sv := fakeStorage{targets: []hub.StorageTarget{ {Name: "usb", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/felhom-usb"}, {Name: "extra", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdc1", MountPath: "/mnt/extra"}, }} // Guest 8200 (token "A") has /mnt/felhom-usb bound (Model-A bind: source .../felhom-data, mp=where), // but NOT /mnt/extra. h := newDiskServerWithGuestConfigs(t, d, sv, nil, map[int]map[string]string{ 8200: {"mp3": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"}, }) disks := decodeDisks(t, do(t, h, "GET", "/disks", "A", "").Body.Bytes()) got := map[string]bool{} for _, di := range disks { got[di.MountPath] = di.GuestAttached } if !got["/mnt/felhom-usb"] { t.Errorf("/mnt/felhom-usb should be guest_attached=true (bound into the guest)") } if got["/mnt/extra"] { t.Errorf("/mnt/extra should be guest_attached=false (host-present but not bound)") } }