578 lines
24 KiB
Go
578 lines
24 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"text/template"
|
|
"time"
|
|
|
|
"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
|
|
formatStatus agentapi.FormatStatusResult
|
|
formatStatusErr error
|
|
formatStatusCalls int
|
|
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) FormatStatus(context.Context) (agentapi.FormatStatusResult, error) {
|
|
m.formatStatusCalls++
|
|
return m.formatStatus, m.formatStatusErr
|
|
}
|
|
func (m *mockAgent) AssignDisk(ctx context.Context, uuid, where, fstype, _ string) error {
|
|
// Respect cancellation — a mount over a dead client/request context fails (this is the F6 bug's
|
|
// mechanism: a disconnect after format aborts the mount+register leg). Background ctx never
|
|
// cancels, so existing happy-path tests are unaffected.
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
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, "", nil)
|
|
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, "", nil)
|
|
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", nil)
|
|
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, "", nil)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// F6 (VALIDATION-n100) — initialize must end in a USABLE (mounted+registered) drive even when the
|
|
// client disconnects mid-format. Two halves:
|
|
// RED-PROOF: runStorageInit on a CANCELLED context (the disconnect) aborts at the mount step →
|
|
// the device is formatted but NOT registered (the N100-observed state). This is exactly what
|
|
// the pre-fix handler did (it ran the chain on r.Context()).
|
|
// FIX: startStorageInit runs the chain on a DETACHED context → it registers regardless of the
|
|
// client, and leaves EXACTLY ONE registry entry (marker-last, Scenario B).
|
|
func TestStorageInit_DetachedSurvivesClientDisconnect(t *testing.T) {
|
|
newMock := func() *mockAgent {
|
|
return &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"},
|
|
}},
|
|
}
|
|
}
|
|
|
|
t.Run("cancelled_ctx_does_not_register", func(t *testing.T) {
|
|
s := testServer(t)
|
|
agent := newMock()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // the client disconnected right after confirm
|
|
_, err := s.runStorageInit(ctx, agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil)
|
|
if err == nil {
|
|
t.Fatal("expected the disconnected (cancelled-ctx) chain to fail at mount")
|
|
}
|
|
if got := s.settings.GetStoragePaths(); len(got) != 0 {
|
|
t.Fatalf("a disconnected init must NOT register (F6 bug): got %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("detached_job_registers_exactly_once", func(t *testing.T) {
|
|
s := testServer(t)
|
|
agent := newMock()
|
|
if !s.startStorageInit(agent, storageInitParams{
|
|
device: "/dev/sdb1", fstype: "ext4", where: "/mnt/hdd1", label: "HDD", setDefault: true,
|
|
}) {
|
|
t.Fatal("startStorageInit refused (slot unexpectedly busy)")
|
|
}
|
|
var job *storageInitJob
|
|
for i := 0; i < 300; i++ {
|
|
if job = s.storageInit.snapshot(); job != nil && (job.Phase == storageInitPhaseDone || job.Phase == storageInitPhaseFailed) {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if job == nil || job.Phase != storageInitPhaseDone {
|
|
t.Fatalf("detached job did not reach done: %+v", job)
|
|
}
|
|
if job.Where != "/mnt/felhom-drives/hdd1" {
|
|
t.Fatalf("done job registered path = %q, want the stable /mnt/felhom-drives/hdd1", job.Where)
|
|
}
|
|
paths := s.settings.GetStoragePaths()
|
|
if len(paths) != 1 || paths[0].Path != "/mnt/felhom-drives/hdd1" {
|
|
t.Fatalf("detached init must register EXACTLY ONE stable path (marker-last): %+v", paths)
|
|
}
|
|
if len(agent.assignCalls) != 1 {
|
|
t.Fatalf("expected exactly one mount (assign): %+v", agent.assignCalls)
|
|
}
|
|
})
|
|
}
|
|
|
|
// F6 deeper half — a slow mkfs outruns the agent-client's 15 s timeout; the agent runs it detached,
|
|
// so the controller must POLL GET /disks/format/status and continue to mount+register on `done`
|
|
// (rather than reporting the timeout as a failure). Observed live on a 64 GB USB.
|
|
func TestStorageInit_PollsAgentFormatStatusOnTimeout(t *testing.T) {
|
|
t.Run("timeout_then_done_registers", func(t *testing.T) {
|
|
s := testServer(t)
|
|
agent := &mockAgent{
|
|
formatErr: context.DeadlineExceeded, // the 15 s client timeout — mkfs continues detached
|
|
formatStatus: agentapi.FormatStatusResult{Phase: "done", Device: "/dev/sdb1"},
|
|
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", "HDD", true, false, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("expected success via the format-status poll, got %v", err)
|
|
}
|
|
if !res.Registered {
|
|
t.Fatal("expected registered after the format-status poll (F6)")
|
|
}
|
|
if agent.formatStatusCalls == 0 {
|
|
t.Fatal("the agent format-status was never polled")
|
|
}
|
|
if len(agent.assignCalls) != 1 {
|
|
t.Fatalf("expected exactly one mount after poll-done: %+v", agent.assignCalls)
|
|
}
|
|
})
|
|
|
|
t.Run("timeout_then_failed_surfaces_error", func(t *testing.T) {
|
|
s := testServer(t)
|
|
agent := &mockAgent{
|
|
formatErr: context.DeadlineExceeded,
|
|
formatStatus: agentapi.FormatStatusResult{Phase: "failed", Device: "/dev/sdb1", Error: "mkfs exploded"},
|
|
}
|
|
_, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil)
|
|
if err == nil {
|
|
t.Fatal("a failed detached format must surface an error, not register")
|
|
}
|
|
if len(s.settings.GetStoragePaths()) != 0 {
|
|
t.Fatalf("a failed format must NOT register: %+v", s.settings.GetStoragePaths())
|
|
}
|
|
})
|
|
}
|
|
|
|
// F7 (VALIDATION-n100) — the "Vissza" (Back) anchor on the init/attach wizards must route to
|
|
// /storage, not /settings. One assertion per template.
|
|
func TestStorageWizardBackAnchors_PointToStorage(t *testing.T) {
|
|
for _, file := range []string{"templates/storage_init.html", "templates/storage_attach.html"} {
|
|
b, err := templateFS.ReadFile(file)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", file, err)
|
|
}
|
|
src := string(b)
|
|
if !strings.Contains(src, `<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>`) {
|
|
t.Errorf("%s: the Vissza Back anchor must point to /storage (F7)", file)
|
|
}
|
|
if strings.Contains(src, `<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>`) {
|
|
t.Errorf("%s: the Vissza Back anchor still points to /settings (F7 regression)", file)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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/<name> — the same path runStorageInit/
|
|
// runStorageAttach register — NOT the raw /mnt/<name> 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/<app>) 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)
|
|
}
|
|
}
|
|
}
|