Files
felhom-agent/internal/localapi/disks_bug2_f9_test.go
T
admin a2a76e7624 F20-BUG2 + F9-reporting: /disks surfaces wipe_durable_id (gate scheme) + guest_attached
F20-BUG2: the /disks list only carried DurableID in the uuid: scheme (for /disks/assign),
but the wipe gate resolves devices in the byid:/byuuid: scheme — so a customer confirming a
wipe with the advertised id was refused (binding_mismatch). Added a shared s.deviceDurableID
seam used by BOTH handleDisks (new DiskInfo.WipeDurableID) and the format gate, so the id the
customer copies from the list is exactly the id the gate accepts. DurableID (uuid:) is unchanged
(still feeds assign).

F9 (reporting half): added DiskInfo.GuestAttached — whether the drive's namespace is actually
bound into THIS guest's config (guestBoundPaths), distinct from mere host presence (State). This
is the signal whose absence made the HDD look available when it wasn't attached, and resolves the
F2 hdd_configured-vs-/disks disagreement.

Tests: wipe_durable_id is the gate scheme + distinct from uuid:; the list's wipe id matches the
gate's device-id binding (no mismatch); guest_attached true iff bound into the guest.
2026-06-14 15:00:56 +02:00

117 lines
5.0 KiB
Go

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=<mount_path>)
// 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)")
}
}