package stacks import ( "context" "log" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // --- pure helpers: siblingName / lowest-free-N / merge walk --- func TestSiblingName(t *testing.T) { cases := map[string]string{ "/d/foo.txt": "/d/foo(1).txt", "/d/backup.tar.gz": "/d/backup.tar(1).gz", // single (last) extension "/d/README": "/d/README(1)", } for in, want := range cases { if got := siblingName(filepath.FromSlash(in), 1); got != filepath.FromSlash(want) { t.Errorf("siblingName(%q) = %q, want %q", in, got, want) } } } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } return string(b) } // TestMergeWalk_Conflicts covers differ→(1), identical→dedup, and re-run idempotency (no (1)(1)). func TestMergeWalk_Conflicts(t *testing.T) { lg := log.New(os.Stderr, "", 0) src := t.TempDir() dst := t.TempDir() // identical file at same rel path → should dedup (no sibling) writeFile(t, filepath.Join(src, "same.txt"), "IDENTICAL") writeFile(t, filepath.Join(dst, "same.txt"), "IDENTICAL") // differing file at same rel path → should land as differ(1).txt, original untouched writeFile(t, filepath.Join(src, "differ.txt"), "SRC") writeFile(t, filepath.Join(dst, "differ.txt"), "DST") // fresh file absent at dst → copied as-is writeFile(t, filepath.Join(src, "sub", "new.txt"), "NEW") if err := walkMerge(lg, src, dst, nil, false, nil); err != nil { t.Fatalf("walkMerge: %v", err) } if got := readFile(t, filepath.Join(dst, "same.txt")); got != "IDENTICAL" { t.Errorf("identical file mutated: %q", got) } if pathExists(filepath.Join(dst, "same(1).txt")) { t.Errorf("identical file should NOT create a (1) sibling") } if got := readFile(t, filepath.Join(dst, "differ.txt")); got != "DST" { t.Errorf("existing target overwritten: %q (must never overwrite)", got) } if got := readFile(t, filepath.Join(dst, "differ(1).txt")); got != "SRC" { t.Errorf("differing file should land as differ(1).txt, got %q", got) } if got := readFile(t, filepath.Join(dst, "sub", "new.txt")); got != "NEW" { t.Errorf("fresh file = %q", got) } // Re-run must be idempotent: SRC now equals differ(1).txt, so no differ(1)(1).txt accrues. if err := walkMerge(lg, src, dst, nil, false, nil); err != nil { t.Fatalf("walkMerge re-run: %v", err) } if pathExists(filepath.Join(dst, "differ(1)(1).txt")) { t.Errorf("re-run proliferated a (1)(1) sibling — NOT idempotent") } // A THIRD distinct content for the same rel path → lowest-free N = (2), not (1)(1). writeFile(t, filepath.Join(src, "differ.txt"), "THIRD") if err := walkMerge(lg, src, dst, nil, false, nil); err != nil { t.Fatalf("walkMerge third: %v", err) } if got := readFile(t, filepath.Join(dst, "differ(2).txt")); got != "THIRD" { t.Errorf("third distinct content should be differ(2).txt, got %q", got) } if pathExists(filepath.Join(dst, "differ(1)(1).txt")) { t.Errorf("must not create differ(1)(1).txt") } } // TestMergeWalk_AssertOnly: assert passes when every source file has an identical counterpart, and // fails when one does not. func TestMergeWalk_AssertOnly(t *testing.T) { lg := log.New(os.Stderr, "", 0) src := t.TempDir() dst := t.TempDir() writeFile(t, filepath.Join(src, "a.txt"), "A") writeFile(t, filepath.Join(dst, "a.txt"), "A") if err := walkMerge(lg, src, dst, nil, true, nil); err != nil { t.Errorf("assert should pass when all counterparts identical: %v", err) } // add a source file with no counterpart at dst writeFile(t, filepath.Join(src, "missing.txt"), "M") if err := walkMerge(lg, src, dst, nil, true, nil); err == nil { t.Errorf("assert must FAIL when a source file has no identical counterpart") } // a (N)-sibling counterpart also satisfies assert writeFile(t, filepath.Join(dst, "missing(1).txt"), "M") if err := walkMerge(lg, src, dst, nil, true, nil); err != nil { t.Errorf("assert should accept a (N)-sibling counterpart: %v", err) } } // TestMergeWalk_SkipDirs confirms app appdata dirs are pruned (handled by rsync). func TestMergeWalk_SkipDirs(t *testing.T) { lg := log.New(os.Stderr, "", 0) src := t.TempDir() dst := t.TempDir() appdata := filepath.Join(src, "appdata", "romm") writeFile(t, filepath.Join(appdata, "rom.bin"), "ROM") writeFile(t, filepath.Join(src, "media", "movie.mkv"), "VID") skip := map[string]bool{filepath.Clean(appdata): true} if err := walkMerge(lg, src, dst, skip, false, nil); err != nil { t.Fatalf("walkMerge: %v", err) } if pathExists(filepath.Join(dst, "appdata", "romm", "rom.bin")) { t.Errorf("skipped appdata dir should NOT be merged") } if !pathExists(filepath.Join(dst, "media", "movie.mkv")) { t.Errorf("non-app content should be merged") } } // --- orchestration: phase machine via injected seams + real temp-dir FS --- func newMigManager(t *testing.T, target string) *Manager { t.Helper() lg := log.New(os.Stderr, "", 0) cfg := &config.Config{} cfg.Paths.DataDir = t.TempDir() cfg.Paths.SystemDataPath = "/mnt/sys_drive" m := &Manager{cfg: cfg, logger: lg, stacks: map[string]*Stack{}, sysDataPath: cfg.Paths.SystemDataPath} sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg) if err != nil { t.Fatal(err) } if target != "" { _ = sett.AddStoragePath(settings.StoragePath{Path: target, Schedulable: true}) } m.settings = sett return m } // runJob runs a manually-built job synchronously to a terminal phase. func (m *Manager) runJobSync(j *MigrationJob) { _ = m.acquireMigrating() m.setJob(j) m.runMigration(context.Background(), j) } func newAllJob(srcNS, dstNS, source, target string, apps ...string) *MigrationJob { j := &MigrationJob{ Scope: "all", Phase: PhaseStop, Source: source, Target: target, SourceNS: srcNS, TargetNS: dstNS, Apps: apps, Units: map[string]*MigUnit{}, } for _, a := range apps { j.Units[a] = &MigUnit{App: a, State: UnitPending} } j.Units[nonAppUnit] = &MigUnit{App: nonAppUnit, State: UnitPending} return j } // TestMigration_CleanupOnlyAfterRedeploy: a redeploy failure aborts BEFORE cleanup; source intact. func TestMigration_CleanupOnlyAfterRedeploy(t *testing.T) { m := newMigManager(t, "/mnt/target") srcNS := t.TempDir() dstNS := t.TempDir() // real source data so we can assert it survives an abort writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM") // seams: copy/verify succeed (no-op), redeploy FAILS for romm. m.testSeams = &migSeams{ stop: func(string) error { return nil }, copy: func(_ context.Context, _, _ string, _ func(int64)) error { return nil }, verify: func(_ context.Context, _, _ string) error { return nil }, flipRedeploy: func(name, _ string) error { return os.ErrPermission // simulate app failing to come up }, } j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm") m.runJobSync(j) if j.Phase != PhaseAborted { t.Fatalf("phase = %s, want aborted", j.Phase) } if !pathExists(appbackup.AppDataDir(srcNS, "romm")) { t.Errorf("SOURCE was deleted despite redeploy failure — cleanup must not run before redeploy") } } // TestMigration_HappyPathCleansSource: full success removes the source only after verify+redeploy. func TestMigration_HappyPathCleansSource(t *testing.T) { m := newMigManager(t, "/mnt/target") srcNS := t.TempDir() dstNS := t.TempDir() writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM") writeFile(t, filepath.Join(srcNS, "media", "movie.mkv"), "VID") // non-app content flips := []string{} m.testSeams = &migSeams{ stop: func(string) error { return nil }, copy: func(_ context.Context, _, _ string, _ func(int64)) error { return nil }, verify: func(_ context.Context, _, _ string) error { return nil }, flipRedeploy: func(name, target string) error { flips = append(flips, name+"→"+target) return nil }, } j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm") m.runJobSync(j) if j.Phase != PhaseDone { t.Fatalf("phase = %s (err=%s), want done", j.Phase, j.Error) } if pathExists(appbackup.AppDataDir(srcNS, "romm")) { t.Errorf("source appdata should be removed after success") } if pathExists(filepath.Join(srcNS, "media")) { t.Errorf("source non-app content should be removed after success") } if len(flips) != 1 || flips[0] != "romm→/mnt/target" { t.Errorf("flipRedeploy calls = %v", flips) } } // TestMigration_VerifyCatchesCorruption: a verify failure aborts; source intact, no cleanup. func TestMigration_VerifyCatchesCorruption(t *testing.T) { m := newMigManager(t, "/mnt/target") srcNS := t.TempDir() dstNS := t.TempDir() writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM") cleanupReached := false m.testSeams = &migSeams{ stop: func(string) error { return nil }, copy: func(_ context.Context, _, _ string, _ func(int64)) error { return nil }, verify: func(_ context.Context, _, _ string) error { return os.ErrInvalid }, // corruption flipRedeploy: func(string, string) error { cleanupReached = true // would only be reachable past verify return nil }, } j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm") m.runJobSync(j) if j.Phase != PhaseAborted { t.Fatalf("phase = %s, want aborted", j.Phase) } if cleanupReached { t.Errorf("flip/redeploy ran despite a verify failure") } if !pathExists(appbackup.AppDataDir(srcNS, "romm")) { t.Errorf("source deleted despite verify failure") } } // TestMigration_Resume: a journal at Phase=verify with units already copied must NOT re-copy. func TestMigration_Resume(t *testing.T) { m := newMigManager(t, "/mnt/target") srcNS := t.TempDir() dstNS := t.TempDir() writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "romm"), "rom.bin"), "ROM") copied := 0 m.testSeams = &migSeams{ stop: func(string) error { return nil }, copy: func(_ context.Context, _, _ string, _ func(int64)) error { copied++ // must NOT be called on resume from verify return nil }, verify: func(_ context.Context, _, _ string) error { return nil }, flipRedeploy: func(string, string) error { return nil }, } j := newAllJob(srcNS, dstNS, "/mnt/source", "/mnt/target", "romm") j.Phase = PhaseVerify j.Units["romm"].State = UnitCopied j.Units[nonAppUnit].State = UnitCopied m.runJobSync(j) if j.Phase != PhaseDone { t.Fatalf("phase = %s (err=%s), want done", j.Phase, j.Error) } if copied != 0 { t.Errorf("resume re-copied %d subtree(s); should re-copy none", copied) } } // TestMigValidate_CollisionRefused: an existing app dir at target refuses, naming the app. (Companion: // without the collision guard this returns nil, so the test would fail — non-hollow.) func TestMigValidate_CollisionRefused(t *testing.T) { m := newMigManager(t, "/mnt/target") dstNS := t.TempDir() m.testSeams = &migSeams{} // skip the rsync-binary check // pre-existing app dir at the target namespace writeFile(t, filepath.Join(appbackup.AppDataDir(dstNS, "romm"), "x"), "x") j := &MigrationJob{Scope: "all", Source: "/mnt/source", Target: "/mnt/target", SourceNS: t.TempDir(), TargetNS: dstNS, Apps: []string{"romm"}, Units: map[string]*MigUnit{}} if err := m.migValidate(j); err == nil { t.Fatalf("expected collision refusal") } else if !contains(err.Error(), "romm") { t.Errorf("collision error must name the app, got %q", err) } // No collision (fresh target) → validate passes. j.TargetNS = t.TempDir() if err := m.migValidate(j); err != nil { t.Errorf("validate should pass without collision: %v", err) } } // TestMigValidate_BackupExclusion: refuse to start while a backup is running (Change 3). func TestMigValidate_BackupExclusion(t *testing.T) { m := newMigManager(t, "/mnt/target") m.testSeams = &migSeams{} m.backupRunning = func() bool { return true } j := &MigrationJob{Scope: "all", Source: "/mnt/source", Target: "/mnt/target", SourceNS: t.TempDir(), TargetNS: t.TempDir(), Apps: nil, Units: map[string]*MigUnit{}} if err := m.migValidate(j); err == nil || !contains(err.Error(), "mentés") { t.Errorf("expected backup-in-progress refusal, got %v", err) } } // TestMigrate_SingleFlight: a second Start while one is active is refused. func TestMigrate_SingleFlight(t *testing.T) { m := newMigManager(t, "/mnt/target") _ = m.acquireMigrating() // simulate an active migration if _, err := m.MigrateApp(context.Background(), "romm", "/mnt/target"); err == nil || !contains(err.Error(), "folyamatban") { t.Errorf("second migration should be refused, got %v", err) } } // TestAppSourceNS_SSDToDrive: an app with no HDD_PATH resolves to the system/SSD namespace. func TestAppSourceNS_SSDToDrive(t *testing.T) { m := newMigManager(t, "") // Use filepath.Clean on expectations so the test holds on both Linux (the deploy target) and // Windows (dev), where Clean uses backslashes. src, ns := m.appSourceNS(&AppConfig{Env: map[string]string{}}) if src != filepath.Clean("/mnt/sys_drive") { t.Errorf("SSD app source = %q, want %q", src, filepath.Clean("/mnt/sys_drive")) } wantNS := appbackup.NamespaceRoot("/mnt/sys_drive", false) // SSD → felhom-data subdir if ns != wantNS { t.Errorf("SSD app ns = %q, want %q", ns, wantNS) } // a drive-resident app uses its mount root as the namespace src2, ns2 := m.appSourceNS(&AppConfig{Env: map[string]string{"HDD_PATH": "/mnt/felhom-usb"}}) if src2 != filepath.Clean("/mnt/felhom-usb") || ns2 != filepath.Clean("/mnt/felhom-usb") { t.Errorf("drive app resolved to src=%q ns=%q", src2, ns2) } } // TestCleanupGate: the cleanup gate refuses until all units verified AND all apps redeployed. func TestCleanupGate(t *testing.T) { m := newMigManager(t, "") j := &MigrationJob{Scope: "all", Apps: []string{"romm"}, Units: map[string]*MigUnit{ "romm": {App: "romm", State: UnitVerified}, // verified but NOT redeployed nonAppUnit: {App: nonAppUnit, State: UnitVerified}, }} if err := m.migCleanupAllowed(j); err == nil { t.Errorf("gate must refuse cleanup when an app is not redeployed") } j.Units["romm"].State = UnitRedeployed if err := m.migCleanupAllowed(j); err != nil { t.Errorf("gate should allow cleanup once all verified+redeployed: %v", err) } // non-app unit only verified is required; if it regressed below verified, refuse j.Units[nonAppUnit].State = UnitCopied if err := m.migCleanupAllowed(j); err == nil { t.Errorf("gate must refuse when non-app content is not verified") } } func contains(s, sub string) bool { return strings.Contains(s, sub) }