Files
felhom-controller/controller/internal/web/netstorage_handlers_test.go
T
admin 364dc50794 controller v0.92.0: NAS network storage Part A2 (registry + UI + per-share health)
Controller-side of NAS network storage, proxying to agent A1 /netstorage/*. Distinct
'network' storage kind (no drive lifecycle); add/list/remove + per-share health UI;
unreachable NAS is a recoverable warning, never the drive missing/stop cascade; SMB
creds pass through to the agent, never persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
2026-06-30 14:40:47 +02:00

153 lines
6.5 KiB
Go

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")
}
}