package backup import ( "context" "os" "strings" "sync" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-411 / R-408 — the single-writer flag on the customer restore paths. // // These drive the real functions through the restic exec seam (`SetOffboxRunner`), which REUSE.md // names as the way to see the argv — replacing `resticStep` would hide the `unlock --remove-all` // escalation from exactly the assertion that must see it. // lockHarness records every restic argv and lets a test hold the flag. type lockHarness struct { m *Manager mu sync.Mutex argv [][]string } func newLockHarness(t *testing.T, stacks ...string) *lockHarness { t.Helper() m, sett := newOffboxManager(t) drive := t.TempDir() if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "data", Schedulable: true}); err != nil { t.Fatal(err) } deployed := map[string]bool{} for _, s := range stacks { deployed[s] = true } m.SetStackProvider(&offbox3aProvider{ hdd: map[string]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}, deployed: deployed, }) h := &lockHarness{m: m} m.SetOffboxLatestSnapshotFn(func(_ context.Context, stack string) (string, []string, error) { return "snap-" + stack, []string{"/mnt/sys_drive/felhom-data/backups/primary/" + stack}, nil }) m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { h.mu.Lock() h.argv = append(h.argv, append([]string{}, args...)) h.mu.Unlock() return []byte("{}"), nil }) m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 }) return h } func (h *lockHarness) allArgs() [][]string { h.mu.Lock() defer h.mu.Unlock() return h.argv } // TestR411_ScratchRestoreTakesTheSingleWriterFlag — A1. func TestR411_ScratchRestoreTakesTheSingleWriterFlag(t *testing.T) { h := newLockHarness(t, "kimai") // Hold the flag as a sibling operation would. if err := h.m.AcquireRunningForTest(); err != nil { t.Fatalf("could not take the flag: %v", err) } err := h.m.RestoreOffboxScratch(context.Background(), "kimai", false) if err == nil { t.Fatal("a scratch restore must REFUSE while the single-writer flag is held — before this fix it ran anyway, which is R-411") } if len(h.allArgs()) != 0 { t.Fatalf("a refused restore must invoke restic ZERO times; got %v", h.allArgs()) } } // TestR411_PrepareFullAlsoTakesTheFlag — the SECOND entry point, found while fixing the first. // The customer's real UI flow reaches this one first, and it is the one that shells `restic stats`. func TestR411_PrepareFullAlsoTakesTheFlag(t *testing.T) { h := newLockHarness(t, "kimai") if err := h.m.AcquireRunningForTest(); err != nil { t.Fatal(err) } if _, err := h.m.OffboxRestorePrepareFull(context.Background(), "kimai"); err == nil { t.Fatal("the full-restore PREPARE must refuse while the flag is held — it shells `restic stats`, which takes a repository lock") } if len(h.allArgs()) != 0 { t.Fatalf("a refused prepare must invoke restic ZERO times; got %v", h.allArgs()) } } // TestR411_SharesScratchRestoreAlsoTakesTheFlag — the third, found by the R-408 walk. func TestR411_SharesScratchRestoreAlsoTakesTheFlag(t *testing.T) { h := newLockHarness(t) if err := h.m.AcquireRunningForTest(); err != nil { t.Fatal(err) } if err := h.m.RestoreSharesScratch(context.Background()); err == nil { t.Fatal("the shares scratch restore must refuse while the flag is held — it runs unlockStale + resticStep, R-411's exact shape") } if len(h.allArgs()) != 0 { t.Fatalf("a refused shares restore must invoke restic ZERO times; got %v", h.allArgs()) } } // TestR411_NoUnlockRemoveAllInAnyArgv — A3, the non-effect that matters. // // RED-PROOF (run 2026-09-01): removing the acquire from `RestoreOffboxScratch` lets the restore run // while the flag is held, so an integrity check could collide with it — the state this asserts is // impossible. The direct form of the red-proof is `TestR408_…`, which names the function. func TestR411_NoUnlockRemoveAllInAnyArgv(t *testing.T) { h := newLockHarness(t, "kimai") if err := h.m.RestoreOffboxScratch(context.Background(), "kimai", false); err != nil { t.Fatalf("an idle-box restore must succeed: %v", err) } for _, args := range h.allArgs() { joined := strings.Join(args, " ") if strings.Contains(joined, "--remove-all") { t.Fatalf("the escalation fired during an ordinary restore: %s", joined) } } if len(h.allArgs()) == 0 { t.Fatal("no restic invocation at all — the assertion above ran over nothing") } } // TestR411_RestoreStillWorksWhenIdle — Scenario C. The flag must not make the common case refuse. func TestR411_RestoreStillWorksWhenIdle(t *testing.T) { h := newLockHarness(t, "kimai") if err := h.m.RestoreOffboxScratch(context.Background(), "kimai", false); err != nil { t.Fatalf("a restore on an idle box must still work: %v", err) } var sawRestore bool for _, args := range h.allArgs() { if containsArg(args, "restore") { sawRestore = true } } if !sawRestore { t.Fatal("no restore was issued") } } // TestR411_FlagIsReleasedOnEveryPath — A5. A flag that leaks would wedge every nightly job. func TestR411_FlagIsReleasedOnEveryPath(t *testing.T) { t.Run("success", func(t *testing.T) { h := newLockHarness(t, "kimai") if err := h.m.RestoreOffboxScratch(context.Background(), "kimai", false); err != nil { t.Fatal(err) } if err := h.m.AcquireRunningForTest(); err != nil { t.Fatal("the flag was NOT released after a successful restore") } }) t.Run("restic error", func(t *testing.T) { h := newLockHarness(t, "kimai") h.m.SetOffboxRunner(func(_ context.Context, _ []string, _ ...string) ([]byte, error) { return []byte("boom"), os.ErrPermission }) _ = h.m.RestoreOffboxScratch(context.Background(), "kimai", false) if err := h.m.AcquireRunningForTest(); err != nil { t.Fatal("the flag was NOT released after a failed restore") } }) t.Run("early refusal", func(t *testing.T) { h := newLockHarness(t, "kimai") _ = h.m.RestoreOffboxScratch(context.Background(), "no such stack!!", false) if err := h.m.AcquireRunningForTest(); err != nil { t.Fatal("the flag was NOT released after an early refusal") } }) t.Run("prepare", func(t *testing.T) { h := newLockHarness(t, "kimai") _, _ = h.m.OffboxRestorePrepareFull(context.Background(), "kimai") if err := h.m.AcquireRunningForTest(); err != nil { t.Fatal("the flag was NOT released after the prepare step") } }) }