8fb2f9ef9d
gates / gates (push) Successful in 23s
Two defects in v0.254.0's globe, both plain on a browser and neither catchable by anything that existed — every test read the MARKUP, and the fault was in which CSS file the browser fetched. The shells requested /static/style.css with NO ?v=, while layout.html has carried one since v0.166.0. A browser holding a copy from before v0.254.0 kept serving CSS with no .lang-globe rules, so the globe came out as a bare unstyled <details> — a stray triangle and two plain words at the edge of the window. It was FIVE shells, not the three named: both guest share pages have the same fault for any CSS change, and their visitor is the likeliest of all to be holding an old copy. And .Version was missing from three of those five data maps, which is exactly how the next one would be forgotten — it is now filled at the one choke point every shell renders through. The globe also floated outside the card, pinned to the corner of the VIEWPORT, reading as part of the browser rather than the page. It now sits inside the card, centred under the footer, with the menu opening upward via the shared rule — so the dashboard and the shells cannot drift. AND A THIRD, caught by a test that already existed: putting the version on the guest share pages would have printed the controller build onto a page a stranger with a capability URL can open. TestShareGuest_HeadersTilesNoAdminChrome refused it. Those two now take an opaque per-build tag — same cache-busting, no disclosure. The fill is ONE function shared with the parity harness, because a fixture rendered through a different data path is a picture of a page nobody serves, which the previous release got wrong twice. 15 shell fixtures re-captured; 91 identical, every dashboard page among them. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
594 lines
24 KiB
Go
594 lines
24 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
|
"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{}
|
|
// v0.247.0: templates carry {{T}} markers and are parsed per language after i18n expansion — the
|
|
// production path is parseTemplateSet, so parse exactly that, for every supported language.
|
|
b, err := i18n.Shared()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, lang := range i18n.Supported {
|
|
funcs := s.templateFuncMap()
|
|
for n, fn := range s.localeFuncs(lang) {
|
|
funcs[n] = fn
|
|
}
|
|
if _, _, err := parseTemplateSet(b, lang, funcs); err != nil {
|
|
t.Fatalf("templates (%s) parse: %v", lang, 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)
|
|
}
|
|
// A version, because v0.255.0 renders the stylesheet cache-buster from it and an empty `?v=`
|
|
// would bake into a fixture as a URL that busts a cache exactly once and never again. Production
|
|
// passes the real version to NewServer; this is the fixture's stand-in for it.
|
|
return &Server{settings: sett, logger: lg, cfg: &config.Config{}, version: "test"}
|
|
}
|
|
|
|
// 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 := huTemplateSource(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)
|
|
}
|
|
}
|
|
}
|