Files
felhom-controller/controller/internal/web/storage_handlers_test.go
T
admin 4913130514 controller v0.50.0: slice 10 P4 — dual-role drives + backup-aware wipe warning
4A: user-data drives are backup-target-eligible (not role-locked) — surfaced in
the drive purpose note. 4B: handleStorageImpact returns backup_copies (apps whose
cross-drive backups live on the drive, via backupCopiesOnPath); the wipe/eject
modal warns they'd be destroyed (stays customer-confirmable — copies redundant).
Cross-drive backup engine remains out of scope. Test: TestBackupCopiesOnPath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:00:27 +02:00

313 lines
12 KiB
Go

package web
import (
"context"
"io"
"log"
"os"
"path/filepath"
"sort"
"testing"
"text/template"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
// cleanups) to parse — they are otherwise only parsed at server startup (template.Must).
func TestTemplatesParse(t *testing.T) {
s := &Server{}
if _, err := template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"); err != nil {
t.Fatalf("templates parse: %v", err)
}
}
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct {
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
formatCalls []formatCall
guestAttachCalls []string
}
type assignCall struct{ uuid, where, fstype string }
type formatCall struct {
device, fstype, durableID string
confirmed bool
}
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
m.disksCalls++
return m.disks, nil
}
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) {
m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed})
return m.formatRes, m.formatErr
}
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype})
return m.assignErr
}
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
return agentapi.EjectResult{Ejected: where}, nil
}
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
m.guestAttachCalls = append(m.guestAttachCalls, where)
return nil
}
func testServer(t *testing.T) *Server {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
}
// SECURITY: a SYSTEM/BACKUP data-bearing refusal must surface the opsign command and perform NO
// assign/register (operator signature required — confirmation cannot help).
func TestRunStorageInit_SystemBackupRefusal(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatErr: agentapi.ErrFormatRefused,
formatRes: agentapi.FormatResult{
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", Role: "system",
PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"},
},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Refused {
t.Fatal("expected Refused=true on a protected data-bearing device")
}
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
t.Errorf("opsign command not surfaced: %q", res.Opsign)
}
if len(agent.assignCalls) != 0 {
t.Fatalf("REFUSAL MUST NOT mount: got %d assign call(s)", len(agent.assignCalls))
}
if len(s.settings.GetStoragePaths()) != 0 {
t.Fatal("REFUSAL MUST NOT register a StoragePath")
}
}
// A USER-DATA data-bearing device returns NeedsConfirmation (+ the durable id to confirm against) and
// performs NO assign/register — the customer must confirm the wipe first (NOT an operator signature).
func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatErr: agentapi.ErrNeedsConfirmation,
formatRes: agentapi.FormatResult{
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
Role: "user-data", DurableID: "byid:wwn-abc",
},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-abc" || res.Role != "user-data" {
t.Fatalf("expected NeedsConfirmation with the durable id + role: %+v", res)
}
if res.Refused || res.Opsign != "" {
t.Fatal("a user-data device must NOT surface an operator-signature path")
}
if len(agent.assignCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 {
t.Fatal("NeedsConfirmation MUST NOT mount or register")
}
}
// After the customer confirms, the wizard re-submits with confirmed=true + the durable id; the format
// then succeeds and the flow proceeds to assign + register. Assert the confirmation is forwarded.
func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: true, Role: "user-data"},
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"},
}},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered {
t.Fatalf("confirmed wipe should proceed to register: %+v", res)
}
if len(agent.formatCalls) != 1 || !agent.formatCalls[0].confirmed || agent.formatCalls[0].durableID != "byid:wwn-abc" {
t.Fatalf("the customer confirmation + durable id were not forwarded to the agent: %+v", agent.formatCalls)
}
}
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
func TestRunStorageInit_Success(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: false},
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"},
}},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered || res.Where != "/mnt/hdd1" {
t.Fatalf("expected registered at /mnt/hdd1, got %+v", res)
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "NEW-9999" || agent.assignCalls[0].where != "/mnt/hdd1" {
t.Fatalf("assign must use the resolved fs UUID + mount path: %+v", agent.assignCalls)
}
paths := s.settings.GetStoragePaths()
if len(paths) != 1 || paths[0].Path != "/mnt/hdd1" || paths[0].Label != "Külső HDD" || !paths[0].IsDefault || !paths[0].Schedulable {
t.Fatalf("StoragePath not registered as expected: %+v", paths)
}
// P2C: enroll must pass the drive into the guest.
if len(agent.guestAttachCalls) != 1 || agent.guestAttachCalls[0] != "/mnt/hdd1" {
t.Fatalf("enroll did not guest-attach the drive: %+v", agent.guestAttachCalls)
}
}
// Attach is non-destructive: resolve UUID → assign → register (no format).
func TestRunStorageAttach_Success(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:EXISTING-42"},
}},
}
res, err := s.runStorageAttach(context.Background(), agent, "/dev/sdb1", "", "/mnt/media", "Média", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered {
t.Fatal("expected registered")
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "EXISTING-42" {
t.Fatalf("attach must assign by the existing fs UUID: %+v", agent.assignCalls)
}
}
func TestFSUUIDForDevice(t *testing.T) {
disks := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{BackingDevice: "/dev/sda1", DurableID: "uuid:AAAA"},
{BackingDevice: "/dev/sdb1", DurableID: "store:lvm"}, // non-fs identity → no UUID
}}
if got := fsUUIDForDevice(disks, "/dev/sda1"); got != "AAAA" {
t.Errorf("fsUUIDForDevice(sda1) = %q, want AAAA", got)
}
if got := fsUUIDForDevice(disks, "/dev/sdb1"); got != "" {
t.Errorf("fsUUIDForDevice(non-fs) = %q, want empty", got)
}
if got := fsUUIDForDevice(disks, "/dev/sdc1"); got != "" {
t.Errorf("fsUUIDForDevice(absent) = %q, want empty", got)
}
}
// Dependency-impact: name the deployed apps whose data lives on a given mount (the type-to-confirm
// "which apps break" list). Pure helper so no live stacks.Manager is needed.
func TestAppsUsingPathIn(t *testing.T) {
all := []stacks.Stack{
{Name: "immich", Deployed: true, Meta: stacks.Metadata{DisplayName: "Immich"}},
{Name: "nextcloud", Deployed: true, Meta: stacks.Metadata{DisplayName: "Nextcloud"}},
{Name: "paperless", Deployed: true, Meta: stacks.Metadata{DisplayName: "Paperless"}},
{Name: "notdeployed", Deployed: false, Meta: stacks.Metadata{DisplayName: "Nem telepített"}},
}
env := map[string]map[string]string{
"immich": {"HDD_PATH": "/mnt/hdd_1"},
"nextcloud": {"HDD_PATH": "/mnt/hdd_1"},
"paperless": {"HDD_PATH": "/mnt/hdd_2"}, // different drive
"notdeployed": {"HDD_PATH": "/mnt/hdd_1"}, // on the drive but not deployed → excluded
}
load := func(name string) *stacks.AppConfig {
if e, ok := env[name]; ok {
return &stacks.AppConfig{Env: e}
}
return nil
}
got := appsUsingPathIn(all, load, "/mnt/hdd_1")
if len(got) != 2 || got[0] != "Immich" || got[1] != "Nextcloud" {
t.Fatalf("apps on /mnt/hdd_1: got %v, want [Immich Nextcloud]", got)
}
if other := appsUsingPathIn(all, load, "/mnt/hdd_2"); len(other) != 1 || other[0] != "Paperless" {
t.Fatalf("apps on /mnt/hdd_2: got %v, want [Paperless]", other)
}
if none := appsUsingPathIn(all, load, "/mnt/empty"); len(none) != 0 {
t.Fatalf("apps on an unused mount: got %v, want []", none)
}
}
// B1: the disk overview must render in a deterministic order — user-data first, then system, then
// backup (then anything unrecognized), alphabetical by name within each tier — so the list does not
// reorder on each reload (the agent's storage view iterates an unordered Go map).
func TestSortDisksForView(t *testing.T) {
disks := []agentapi.DiskInfo{
{Name: "felhom-pbs", Role: "backup"},
{Name: "local-lvm", Role: "system"},
{Name: "zdata", Role: "user-data"},
{Name: "local", Role: "system"},
{Name: "adata", Role: "user-data"},
{Name: "mystery", Role: ""},
}
sortDisksForView(disks)
var got []string
for _, d := range disks {
got = append(got, d.Name)
}
want := []string{"adata", "zdata", "local", "local-lvm", "felhom-pbs", "mystery"}
if len(got) != len(want) {
t.Fatalf("length mismatch: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order at %d: got %q want %q (full: %v)", i, got[i], want[i], got)
}
}
}
// P4 (4B): a drive's cross-drive backup copies (felhom-data/backups/secondary/<app>) are listed so the
// wipe confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped.
func TestBackupCopiesOnPath(t *testing.T) {
root := t.TempDir()
sec := filepath.Join(root, "felhom-data", "backups", "secondary")
for _, d := range []string{"immich", "nextcloud", "restic", "_infra"} {
if err := os.MkdirAll(filepath.Join(sec, d), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(sec, "stray-file"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
got := backupCopiesOnPath(root)
sort.Strings(got)
if len(got) != 2 || got[0] != "immich" || got[1] != "nextcloud" {
t.Fatalf("backup copies: got %v, want [immich nextcloud] (restic/_infra/files skipped)", got)
}
// A drive with no secondary backups → nil (no warning).
if c := backupCopiesOnPath(t.TempDir()); c != nil {
t.Fatalf("a drive with no cross-drive backups should report none, got %v", c)
}
}
func TestMountWhere(t *testing.T) {
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
}
for _, bad := range []string{"", "../etc", "a/b", "x y", "/abs"} {
if _, err := mountWhere(bad); err == nil {
t.Errorf("mountWhere(%q) should be rejected", bad)
}
}
}