package web import ( "bytes" "context" "encoding/json" "io" "log" "net/http" "net/http/httptest" "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 decommissionCalls []string guestRebootCalls int guestRebootErr error candidates agentapi.CandidatesResult } 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) ListCandidates(context.Context) (agentapi.CandidatesResult, error) { return m.candidates, 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) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) { m.decommissionCalls = append(m.decommissionCalls, where) return agentapi.DecommissionResult{Decommissioned: where}, nil } func (m *mockAgent) GuestAttach(_ context.Context, where string) error { m.guestAttachCalls = append(m.guestAttachCalls, where) return nil } func (m *mockAgent) GuestReboot(context.Context) error { m.guestRebootCalls++ return m.guestRebootErr } 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) } // Intermediary model: the AGENT operates on the RAW /mnt/hdd1 (assign + guest-attach), but the // REGISTERED path (+ HDD_PATH/FileBrowser) is the STABLE /mnt/felhom-drives/hdd1. // // COMPANION GUARD: a pre-fix impl that registered the raw /mnt/hdd1, or that passed the stable path to // the agent, would fail one of these assertions. if !res.Registered || res.Where != "/mnt/felhom-drives/hdd1" { t.Fatalf("expected registered at the STABLE /mnt/felhom-drives/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 + RAW mount path: %+v", agent.assignCalls) } paths := s.settings.GetStoragePaths() if len(paths) != 1 || paths[0].Path != "/mnt/felhom-drives/hdd1" || paths[0].Label != "Külső HDD" || !paths[0].IsDefault || !paths[0].Schedulable { t.Fatalf("StoragePath not registered at the stable path as expected: %+v", paths) } // Enroll must pass the drive into the guest via the RAW path (the agent maps it under the parent). if len(agent.guestAttachCalls) != 1 || agent.guestAttachCalls[0] != "/mnt/hdd1" { t.Fatalf("enroll did not guest-attach the raw drive path: %+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) } } // Impl-2b: a RAW candidate (not in /disks — no PVE storage) attaches by resolving its fs UUID from the // raw-device scan's durable_id. RED-PROOF: with the old fsUUIDForDevice(Disks)-only resolution this // fails ("no fs identifier"), since the raw device is absent from /disks. func TestRunStorageAttach_RawCandidate(t *testing.T) { s := testServer(t) agent := &mockAgent{ disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{}}, // raw device NOT in /disks candidates: agentapi.CandidatesResult{ Attach: []agentapi.DiskCandidate{ {Device: "/dev/sdd", FSType: "ext4", Mountable: true, MountSource: "/dev/sdd", DurableID: "uuid:RAW-99"}, }, }, } res, err := s.runStorageAttach(context.Background(), agent, "/dev/sdd", "ext4", "/mnt/teszt", "Teszt", 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 != "RAW-99" { t.Fatalf("raw candidate must assign by the scan-derived fs UUID: %+v", agent.assignCalls) } } // handleStorageRegister (the "Regisztrálás" action for an already-mounted, unregistered drive) must // register the STABLE intermediary path /mnt/felhom-drives/ — the same path runStorageInit/ // runStorageAttach register — NOT the raw /mnt/ it receives. Registering the raw path made the // controller watch an empty rootfs placeholder ("Rendszermeghajtón" + stuck activation banner; // DIAGNOSE-drive-bind-after-reprovision-2026-06-23.md). RED-PROOF: reverting the handler to // registerStoragePath(req.Where, …) makes this fail (registry holds the raw /mnt/felhom-flash). // (The agent still operates on the RAW path via attachIntoGuest — that mapping is unchanged and is // asserted by TestRunStorageInit_Success's guestAttachCalls check; agentClient is unconfigured here so // the attach is skipped, which does not affect the registration under test.) func TestHandleStorageRegister_RegistersStablePath(t *testing.T) { s := testServer(t) body, _ := json.Marshal(map[string]any{"where": "/mnt/felhom-flash"}) req := httptest.NewRequest(http.MethodPost, "/api/storage/register", bytes.NewReader(body)) rr := httptest.NewRecorder() s.handleStorageRegister(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body %s)", rr.Code, rr.Body.String()) } paths := s.settings.GetStoragePaths() if len(paths) != 1 { t.Fatalf("expected exactly one registered path, got %+v", paths) } if paths[0].Path != "/mnt/felhom-drives/felhom-flash" { t.Fatalf("must register the STABLE path /mnt/felhom-drives/felhom-flash (raw-path bug if /mnt/felhom-flash), got %q", paths[0].Path) } var resp struct { OK bool `json:"ok"` Data struct { Where string `json:"where"` Raw string `json:"raw"` } `json:"data"` } if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if !resp.OK || resp.Data.Where != "/mnt/felhom-drives/felhom-flash" || resp.Data.Raw != "/mnt/felhom-flash" { t.Fatalf("response must report the stable where + raw, got %+v", resp.Data) } } // TestHandleServerReboot_CallsGuestReboot exercises the standalone "Kiszolgáló újraindítása" core: // it must invoke the agent's GuestReboot exactly once and return the 202 envelope. (The HTTP handler // HandleServerReboot delegates to this core after building the live agent client — same split-out // pattern as runStorageInit, so the fake diskAgent can be injected here.) func TestHandleServerReboot_CallsGuestReboot(t *testing.T) { s := testServer(t) agent := &mockAgent{} req := httptest.NewRequest(http.MethodPost, "/api/server/reboot", nil) rr := httptest.NewRecorder() s.serverReboot(rr, req, agent) if agent.guestRebootCalls != 1 { t.Fatalf("expected GuestReboot invoked exactly once, got %d", agent.guestRebootCalls) } if rr.Code != http.StatusAccepted { t.Fatalf("expected 202 Accepted, got %d (body %s)", rr.Code, rr.Body.String()) } var resp struct { OK bool `json:"ok"` Data struct { Rebooting bool `json:"rebooting"` } `json:"data"` } if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if !resp.OK || !resp.Data.Rebooting { t.Fatalf("expected {ok:true, data.rebooting:true}, got %+v", resp) } } 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 (backups/secondary/) are listed so the wipe // confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped. // Layout is Model-A in-guest: the drive mount IS the felhom-data namespace root (no felhom-data // subdir), matching NamespaceRoot(where, true) and where Tier 2 (Phase 3) writes its copies. func TestBackupCopiesOnPath(t *testing.T) { root := t.TempDir() sec := filepath.Join(root, "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) } } }