package appbackup import ( "bytes" "log" "path/filepath" "strings" "testing" ) // The deployed set on demo-hp on 2026-08-22, used by every case below so the tests argue about the // real fleet rather than a convenient fiction. func fleetKnown() map[string]bool { return map[string]bool{ "calibre-web": true, "kimai": true, "opengist": true, "paperless-ngx": true, "privatebin": true, "romm": true, } } func quietLogger() (*log.Logger, *bytes.Buffer) { var buf bytes.Buffer return log.New(&buf, "", 0), &buf } // TestResolveStackName_R355_ComposeProjectResolvesWhatTheNameCannot is the headline case. // // `paperless-ngx` runs its database in a container called `paperless-postgres`. The container name // carries no route to the stack name: suffix-stripping gives `paperless`, which is not a stack, and // no known stack is a prefix of it. deriveStackName then returns that unresolved candidate anyway. // The compose project label is the answer the name cannot give. func TestResolveStackName_R355_ComposeProjectResolvesWhatTheNameCannot(t *testing.T) { known := fleetKnown() lg, _ := quietLogger() // First, pin the shape of the defect, so this test still means something if the fallback changes. if got := deriveStackName("paperless-postgres", known); got == "paperless-ngx" { t.Fatalf("precondition lost: deriveStackName now resolves paperless-postgres correctly (%q) — "+ "this test exists because it cannot", got) } else if got != "paperless" { t.Fatalf("precondition changed: deriveStackName(paperless-postgres) = %q, expected the "+ "unresolved candidate %q", got, "paperless") } got := resolveStackName("paperless-postgres", "paperless-ngx", known, lg, false) if got != "paperless-ngx" { t.Errorf("resolveStackName(paperless-postgres, project=paperless-ngx) = %q, want %q", got, "paperless-ngx") } } // TestR355_DumpLandsWhereTheRecoveryUnitReads asserts the CONSEQUENCE rather than the mechanism. // // It is not enough that the name resolves: the dump must land in the directory the unit assembler // enumerates. Before the fix those were two different directories on two different drives — // `…/backups/primary/paperless/db-dumps/` was written and `…/backups/primary/paperless-ngx/db-dumps/` // was read — which is exactly why the manifest recorded `db_dumps: null` while a valid 72-table dump // existed on disk. func TestR355_DumpLandsWhereTheRecoveryUnitReads(t *testing.T) { const nsRoot = "/mnt/felhom-drives/hdd_1/felhom-data" const stack = "paperless-ngx" known := fleetKnown() lg, _ := quietLogger() // Where the unit assembler looks (CaptureRecoveryUnit enumerates exactly this). readBy := AppDBDumpPath(nsRoot, stack) // Where the dump writer would put it, for the container this app actually runs. writtenTo := AppDBDumpPath(nsRoot, resolveStackName("paperless-postgres", "paperless-ngx", known, lg, false)) if writtenTo != readBy { t.Errorf("the dump would be written to %q but the recovery unit reads %q — the unit will record no database", writtenTo, readBy) } // And demonstrate the pre-fix divergence explicitly, so the failure mode is documented by the suite // rather than only by the report. preFix := AppDBDumpPath(nsRoot, deriveStackName("paperless-postgres", known)) if preFix == readBy { t.Fatalf("precondition lost: the pre-fix path %q no longer diverges from %q", preFix, readBy) } if !strings.Contains(preFix, filepath.Join("primary", "paperless")+string(filepath.Separator)) { t.Errorf("expected the pre-fix path to point at the phantom `paperless` stack, got %q", preFix) } } // TestResolveStackName_UnaffectedAppsAreUnchanged is scenario D: every container whose name already // resolved must keep its exact previous attribution. A naming change that moved another app's dumps // would be a far worse defect than the one being fixed. func TestResolveStackName_UnaffectedAppsAreUnchanged(t *testing.T) { known := fleetKnown() lg, _ := quietLogger() // container name → compose project, as read from `docker ps` on demo-hp 2026-08-22. fleet := map[string]string{ "kimai-db": "kimai", "romm-db": "romm", "romm-redis": "romm", "calibre-web": "calibre-web", "privatebin": "privatebin", "opengist": "opengist", "paperless-redis": "paperless-ngx", } for container, project := range fleet { old := deriveStackName(container, known) got := resolveStackName(container, project, known, lg, false) if container == "paperless-redis" { // The one app the fix moves — asserted by the headline test, not here. continue } if got != old { t.Errorf("resolveStackName(%q, project=%q) = %q but deriveStackName gave %q — the fix moved an app it should not have", container, project, got, old) } } } // TestResolveStackName_NoLabelFallsBack covers a container not started by compose: the label is empty // and the legacy path is all there is. func TestResolveStackName_NoLabelFallsBack(t *testing.T) { known := fleetKnown() lg, _ := quietLogger() if got := resolveStackName("romm-db", "", known, lg, false); got != "romm" { t.Errorf("with no compose label, resolveStackName(romm-db) = %q, want %q", got, "romm") } } // TestResolveStackName_UnknownProjectIsNotTrusted covers the fail-safe direction. A project label // naming a stack we do not know must NOT be used to choose a directory — writing into an unknown // app's tree is the very fault being fixed, and doing it on the strength of an unverified label would // simply move the bug. func TestResolveStackName_UnknownProjectIsNotTrusted(t *testing.T) { known := fleetKnown() lg, buf := quietLogger() got := resolveStackName("romm-db", "some-other-project", known, lg, false) if got != "romm" { t.Errorf("an unknown compose project must not be trusted: got %q, want the derived %q", got, "romm") } if buf.Len() != 0 { t.Errorf("a container that still resolves via the fallback must not warn; logged: %q", buf.String()) } } // TestResolveStackName_UnresolvableIsLoud pins the residual behaviour. When neither route reaches a // known stack we keep the legacy answer — refusing would change behaviour for containers legitimately // on the old path — but the line must be LOUD, because the silent version of it is what hid R-355 for // the life of the feature. func TestResolveStackName_UnresolvableIsLoud(t *testing.T) { known := fleetKnown() lg, buf := quietLogger() got := resolveStackName("mystery-postgres", "", known, lg, false) if got != "mystery" { t.Errorf("unresolvable container: got %q, want the legacy candidate %q", got, "mystery") } out := buf.String() if !strings.Contains(out, "[WARN]") { t.Errorf("an unattributable DB container must WARN; logged: %q", out) } for _, want := range []string{"mystery-postgres", "R-355"} { if !strings.Contains(out, want) { t.Errorf("the warning must name %q so it is greppable; logged: %q", want, out) } } } // TestResolveStackName_LegacyCallersUnaffected: appexport passes no known set. The label must still be // honoured there (len(known)==0 → trust it), because that caller has no other route to the truth. func TestResolveStackName_LegacyCallersUnaffected(t *testing.T) { lg, _ := quietLogger() if got := resolveStackName("paperless-postgres", "paperless-ngx", nil, lg, false); got != "paperless-ngx" { t.Errorf("with no known set the compose label is the only truth available: got %q, want %q", got, "paperless-ngx") } if got := resolveStackName("romm-postgres", "", nil, lg, false); got != "romm" { t.Errorf("no label and no known set → legacy strip: got %q, want %q", got, "romm") } }