package web import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // addNetworkPath registers a Kind=network StoragePath in the test server's settings. func addNetworkPath(t *testing.T, s *Server, name string) { t.Helper() if err := s.settings.AddStoragePath(settings.StoragePath{ Path: settings.NetworkMountRoot + "/" + name, Label: "NAS " + name, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), Kind: settings.StorageKindNetwork, Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name, MappedUID: 1000, MappedGID: 1000, }); err != nil { t.Fatal(err) } } func decodeEnvelope(t *testing.T, w *httptest.ResponseRecorder) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { t.Fatalf("decode: %v (%s)", err, w.Body.String()) } return m } // TestNetStorage_RegisteredAndListed: a Kind=network path is registered and surfaced by the list handler // (with health "unknown" when the agent is not reachable in the test). func TestNetStorage_RegisteredAndListed(t *testing.T) { s := testServer(t) addNetworkPath(t, s, "media") r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage", nil) w := httptest.NewRecorder() s.handleNetStorageList(w, r) if w.Code != http.StatusOK { t.Fatalf("list: got %d want 200", w.Code) } body := w.Body.String() if !strings.Contains(body, `"name":"media"`) || !strings.Contains(body, `"protocol":"nfs"`) { t.Fatalf("network share not listed: %s", body) } if !strings.Contains(body, `"health":"unknown"`) { t.Fatalf("expected unknown health with no agent, got: %s", body) } } // TestNetStorage_KindGate is the §10 companion: the drive-lifecycle handlers REFUSE a Kind=network path. // A network path → 400 refusal (the Kind-gate short-circuits BEFORE any agent call). The companion // red-proof: a DRIVE path (or an unknown path) is NOT refused by the gate — it proceeds and fails later // with a 503 (no agent configured in the test). A Kind-blind impl would treat both alike and FAIL here. func TestNetStorage_KindGate(t *testing.T) { s := testServer(t) addNetworkPath(t, s, "media") lifecycle := []struct { name string path string handler func(http.ResponseWriter, *http.Request) driveCompan bool // a drive path fails cleanly at the agent step (no stackMgr needed) → safe to assert }{ {"eject", "/api/storage/eject", s.handleStorageEject, true}, {"wipe", "/api/storage/wipe", s.handleStorageWipe, true}, {"decommission", "/api/storage/decommission", s.handleStorageDecommission, false}, } for _, lc := range lifecycle { // A NETWORK path is refused by the Kind-gate (400, before any agent/stackMgr use). netBody := `{"where":"/mnt/felhom-drives/media","device":"/dev/sdz","mount_name":"media","mode":"anyway"}` rn := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(netBody)) wn := httptest.NewRecorder() lc.handler(wn, rn) if wn.Code != http.StatusBadRequest { t.Fatalf("%s on a network path: got %d want 400 (Kind-gate must refuse)", lc.name, wn.Code) } if env := decodeEnvelope(t, wn); env["ok"] != false { t.Fatalf("%s on a network path must be ok:false, got %v", lc.name, env["ok"]) } // COMPANION: a DRIVE path is NOT refused by the Kind-gate — it falls through to the agent step and // fails 503 (no agent), proving the gate keys on Kind, not the path. (Only for handlers that reach // the agent before any nil-dependency; decommission-anyway dives into stackMgr, covered by the // direct refuseNetworkLifecycle unit test instead.) if !lc.driveCompan { continue } drvBody := `{"where":"/mnt/hdd_1","device":"/dev/sdz","mount_name":"hdd_1","mode":"anyway"}` rd := httptest.NewRequest(http.MethodPost, lc.path, strings.NewReader(drvBody)) wd := httptest.NewRecorder() lc.handler(wd, rd) if wd.Code == http.StatusBadRequest { t.Fatalf("%s on a DRIVE path must NOT hit the Kind-gate 400 (got 400) — the gate would be Kind-blind", lc.name) } } } // TestNetStorage_UnreachableNotMissing is the §10 health-surface companion: an `unreachable` NAS must // surface as a recoverable WARNING, never the drive "missing → stopped" cascade. Here: (1) a network path // is NEVER reported "missing" by missingStorageLabel, and (2) the drive-absent gate produces NO Stop // action for a network path even though it is absent from the agent's /disks list. The companion shows a // real DRIVE path under the same parent, absent from /disks, DOES get a Stop action — proving the network // exemption (not luck) is what prevents the cascade. An impl that mapped unreachable→missing FAILS (1). func TestNetStorage_UnreachableNotMissing(t *testing.T) { s := testServer(t) addNetworkPath(t, s, "media") // registered network path /mnt/felhom-drives/media // (1) a network path is never "missing" (no stop-cascade label), regardless of agent reachability. if lbl, missing := s.missingStorageLabel("/mnt/felhom-drives/media"); missing { t.Fatalf("a network path must NOT be 'missing' (got missing=true, label=%q)", lbl) } // (2) the drive-absent gate skips the network path: with the agent reporting NO disks (the NAS is not // a drive — Scenario D), the network path is absent from /disks but must produce NO Stop action. netPaths := s.settings.GetStoragePaths() if acts := planDriveGates(netPaths, nil); len(acts) != 0 { t.Fatalf("the drive gate must produce NO action for a network path, got %+v", acts) } // COMPANION: a real DRIVE path under the same parent, absent from /disks, DOES get a Stop action — // proving the gate WOULD have stopped the network path's apps if it weren't exempted. drivePaths := []settings.StoragePath{{Path: StableParentDir + "/realhdd", Schedulable: true}} acts := planDriveGates(drivePaths, nil) if len(acts) != 1 || !acts[0].Stop { t.Fatalf("control: an absent DRIVE path under the parent must get a Stop action, got %+v", acts) } } // TestRefuseNetworkLifecycle is the unit-level discriminator: network → refused, drive/unknown → not. func TestRefuseNetworkLifecycle(t *testing.T) { s := testServer(t) addNetworkPath(t, s, "media") w1 := httptest.NewRecorder() if !s.refuseNetworkLifecycle(w1, "/mnt/felhom-drives/media") { t.Fatal("a network path must be refused") } w2 := httptest.NewRecorder() if s.refuseNetworkLifecycle(w2, "/mnt/hdd_1") { t.Fatal("a drive/unknown path must NOT be refused by the network gate") } }