package backup import ( "context" "io" "log" "os" "path/filepath" "strings" "testing" ) // R-355's second half. The naming defect does not stop at the backup: writeSafetyDump filters the // discovered databases with `db.StackName == stackName`, so an app whose database is attributed to the // wrong stack has NO database as far as the destructive restore is concerned. It therefore takes no // undo copy, and the fail-closed refusal that protects every other app cannot fire — the guard is not // bypassed, it is never reached. // // Measured live on 2026-08-21: a destructive restore of `paperless-ngx` ran to completion over a live // 72-table PostgreSQL with `find /mnt -name "pre-restore-*"` empty both before and after. func newSafetyTestManager() *Manager { return &Manager{logger: log.New(io.Discard, "", 0)} } // TestR355_SafetyDumpIsTakenForTheCorrectlyAttributedApp is scenario B: the undo copy exists. func TestR355_SafetyDumpIsTakenForTheCorrectlyAttributedApp(t *testing.T) { nsRoot := t.TempDir() m := newSafetyTestManager() // Post-fix attribution: the compose project label resolves this container to `paperless-ngx`. m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{ {StackName: "paperless-ngx", DBType: DBTypePostgres, ContainerName: "paperless-postgres", ContainerID: "cid"}, }, nil } m.safetyDumpFn = func(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult { p := filepath.Join(dumpDir, string(db.StackName)+"-"+string(db.DBType)+".sql") if err := os.MkdirAll(dumpDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("-- 72 tables\n"), 0o644); err != nil { t.Fatal(err) } return DumpResult{DB: db, FilePath: p, Size: 13} } // v0.220.0 (R-379): writeSafetyDump returns the SET it wrote, so a rollback can re-apply EVERY // database's undo. `.First()` is the value this signature returned before; these assertions are // unchanged in meaning. set, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot) safety := set.First() if err != nil { t.Fatalf("writeSafetyDump: %v", err) } if safety == "" { t.Fatal("no safety dump was taken for an app that HAS a database — the restore would proceed with no undo") } if _, err := os.Stat(safety); err != nil { t.Fatalf("the safety dump path %q is not on disk: %v", safety, err) } if !strings.Contains(filepath.Base(safety), "pre-restore-") { t.Errorf("the undo copy must carry the pre-restore prefix so it can never be replayed as a source; got %q", filepath.Base(safety)) } // The consequence that matters: it lives inside THIS app's unit, not a phantom's. if got, want := filepath.Dir(safety), AppDBDumpPath(nsRoot, "paperless-ngx"); got != want { t.Errorf("undo copy written to %q, want %q", got, want) } } // TestR355_MisattributedAppGetsNoUndoCopy demonstrates the WRONG OUTCOME — the state the fix removes. // It models the pre-fix attribution (`paperless`) against a restore of `paperless-ngx` and asserts the // undo silently does not happen. This is the shape that made a destructive restore unrecoverable. func TestR355_MisattributedAppGetsNoUndoCopy(t *testing.T) { nsRoot := t.TempDir() m := newSafetyTestManager() // PRE-FIX attribution: deriveStackName gave `paperless` for container `paperless-postgres`. m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{ {StackName: "paperless", DBType: DBTypePostgres, ContainerName: "paperless-postgres", ContainerID: "cid"}, }, nil } called := false m.safetyDumpFn = func(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult { called = true return DumpResult{DB: db} } // v0.220.0 (R-379): writeSafetyDump returns the SET it wrote, so a rollback can re-apply EVERY // database's undo. `.First()` is the value this signature returned before; these assertions are // unchanged in meaning. set, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot) safety := set.First() if err != nil { t.Fatalf("writeSafetyDump: %v", err) } if safety != "" || called { t.Fatalf("precondition lost: the misattributed shape now takes an undo copy (safety=%q called=%v) — "+ "this test documents the defect and must keep failing to find one", safety, called) } // And this is precisely why it was invisible: no error, no dump, and the caller reads // `hasDB == false` — indistinguishable from an app that genuinely has no database. } // TestR355_RestoreRefusesWhenTheUndoCannotBeTaken is scenario C, the fail-closed direction. The // invariant already existed and was proven working live on 2026-08-21 for `romm`; this pins it for the // app that could not reach it before, so the two cannot drift apart. func TestR355_RestoreRefusesWhenTheUndoCannotBeTaken(t *testing.T) { nsRoot := t.TempDir() m := newSafetyTestManager() m.discoverDBs = func(ctx context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{ {StackName: "paperless-ngx", DBType: DBTypePostgres, ContainerName: "paperless-postgres", ContainerID: "cid"}, }, nil } m.safetyDumpFn = func(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult { return DumpResult{DB: db, Error: os.ErrPermission} } // v0.220.0 (R-379): writeSafetyDump returns the SET it wrote, so a rollback can re-apply EVERY // database's undo. `.First()` is the value this signature returned before; these assertions are // unchanged in meaning. set, err := m.writeSafetyDump(context.Background(), "paperless-ngx", nsRoot) safety := set.First() if err == nil { t.Fatal("a database that cannot be dumped must be a hard error — the undo would not exist") } if safety != "" { t.Errorf("a failed undo must return no path, got %q", safety) } // The message must say the restore did not start, because that is the customer's only signal. if !strings.Contains(err.Error(), "nem indult el") { t.Errorf("the refusal must state that the restore did not start; got %q", err.Error()) } }