968c968559
gates / gates (push) Successful in 11s
writeSafetyDump called DumpOne into the app's OWN unit dir and renamed the result to pre-restore-* afterwards. DumpOne writes <stack>-<dbtype>.sql - the app's canonical dump - so every safety dump overwrote the app's real backup and then moved it away, leaving the app with no database backup until the next nightly run. A local restore-from-unit in that window tells the customer the app never had a database. The comment beside it asserted the rename meant it 'can never overwrite the app's real dump'. False as written, and believed for four months. Measured live before the fix: docmost and bookstack each held only pre-restore-* files and no canonical dump. DumpOneTo takes the final path and derives its own .tmp from it. DumpOne keeps its signature and calls it with the canonical name. writeSafetyDump asks for its own name directly; the rename is gone; the comment now states the invariant and how it is enforced. db_dumps no longer lists the undo copies. All three consumers of Manifest.DBDumps were grepped and named - all inside recovery_unit.go, none reads it for recovery. The files are neither deleted nor hidden. Tests 1485 -> 1493. FIVE red-proofs, TWO PASSED first time and both are reported: the behavioural tests inject the dump seam so a mutation inside DumpOneTo was invisible, and 1.3 had no test at all. Guards added at the layer each defect lives in; both mutations then convicted.
138 lines
5.9 KiB
Go
138 lines
5.9 KiB
Go
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, finalPath string) DumpResult {
|
|
// R-361: the seam takes the FINAL PATH now.
|
|
if err := os.MkdirAll(filepath.Dir(finalPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(finalPath, []byte("-- 72 tables\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return DumpResult{DB: db, FilePath: finalPath, 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())
|
|
}
|
|
}
|