Files
felhom-controller/controller/internal/web/r355_outcome_msg_test.go
T
admin 5ce3a44645
gates / gates (push) Successful in 11s
v0.218.0: attribute a DB container by its compose project, and replay volumes on the off-site restore
R-355 (first, because it is the only one where data can be lost for good). paperless-ngx's
PostgreSQL was dumped into backups/primary/paperless/db-dumps/ — a directory for a stack that
does not exist, on the system drive — while the app's own unit recorded db_dumps: null. The
same misattribution reached writeSafetyDump, so a destructive restore of that app took NO undo
copy and the fail-closed refusal was never reached. Fixed by reading the compose project label,
which is the stack name by construction (compose runs with cmd.Dir set to the stack dir and no
-p). The old derivation stays as the fallback and an unresolvable attribution is now loud.
Catalogue sweep, proven able to convict: one affected app of 53. The fix is in the controller,
not the catalogue.

R-354. ReconstituteFromOffsite skipped every unit placement and the volume archives live inside
the unit, so the off-site restore had no volume leg at all — proven live with planted files:
calibre-web's 1,422,848-byte config archive was in the unit, the snapshot and the checking
folder, and the restore reported success without it. For the 40 of 53 apps that declare no data
drive that archive is the whole dataset. restoreDockerVolumesFrom is the local path's own replay
with an explicit directory: ONE implementation, two callers. Volumes replay before the database
and inside the stopped window. VolumesReplayed reaches the message.

The comment beside the skip was half false and is corrected; the half that still holds — the
live unit is the local path's source — is named, and scenario D fingerprints the whole live unit
across the operation.

Seven red-proofs, each asserted applied and reverted. Two found defects in the tests, not the
code: scenario D passed with the unit guard removed because the fingerprint had been narrowed
and was blind to the unit root.
2026-08-22 09:43:22 +02:00

119 lines
5.4 KiB
Go

package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-355, the sentence. „Ennek az alkalmazásnak nincs adatbázisa" is a claim ABOUT THE APP, and it was
// being inferred from a counter that has two different causes. On 2026-08-21 it was printed over a live
// 72-table PostgreSQL that the controller had dumped five minutes earlier.
//
// `SafetyDump` is the honest discriminator: writeSafetyDump returns a path only when a live database
// for this app was found AND successfully dumped.
func TestReconstituteOutcome_NoDatabaseOnlyWhenThereIsNone(t *testing.T) {
msg := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
FilesPlaced: 3, DBsReplayed: 0, SafetyDump: "",
})
if !strings.Contains(msg, "nincs adatbázisa") {
t.Errorf("an app with genuinely no database should still say so; got %q", msg)
}
}
// The one that matters: a database exists, none was replayed. Saying "this app has no database" here
// is false, and saying nothing at all would leave a silent loss under a success.
func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) {
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
FilesPlaced: 0, DBsReplayed: 0,
SafetyDump: "/mnt/x/db-dumps/pre-restore-20260822T060000Z-paperless-ngx-postgres.sql",
})
if strings.Contains(msg, "nincs adatbázisa") {
t.Fatalf("FALSE CLAIM: told the customer the app has no database while its undo copy proves it does; got %q", msg)
}
for _, want := range []string{"VAN adatbázisa", "NEM állt vissza"} {
if !strings.Contains(msg, want) {
t.Errorf("the message must state that a database exists and did not come back; missing %q in %q", want, msg)
}
}
// The undo copy is the customer's way back, so it has to be named.
if !strings.Contains(msg, "pre-restore-20260822T060000Z-paperless-ngx-postgres.sql") {
t.Errorf("the message must name the undo copy; got %q", msg)
}
// It must never leak the directory — only the file name.
if strings.Contains(msg, "/mnt/x/") {
t.Errorf("the message leaked a filesystem path; got %q", msg)
}
}
func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) {
msg := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
FilesPlaced: 4, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
})
if !strings.Contains(msg, "és az adatbázis visszaállítva") {
t.Errorf("the full case must keep its wording; got %q", msg)
}
if strings.Contains(msg, "FIGYELEM") {
t.Errorf("a complete restore must not carry a warning; got %q", msg)
}
}
// ── R-354: the volume leg has to reach the sentence ─────────────────────────────────────────────
// The 2026-08-21 case, as the customer saw it and as they must see it now.
func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) {
msg := reconstituteOutcomeMsg("calibre-web", backup.OffsiteReconstituteResult{
FilesPlaced: 5, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
})
if !strings.Contains(msg, "5 fájl és 1 adatkötet visszaállítva") {
t.Errorf("the message must name the volume that came back; got %q", msg)
}
// The old sentence — five files and nothing else — must be gone.
if strings.Contains(msg, "5 fájl visszaállítva") {
t.Errorf("the pre-fix wording is still being produced; got %q", msg)
}
}
// A volume-only app: the whole dataset is the volume, and "0 fájl" alone said nothing about it.
func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) {
msg := reconstituteOutcomeMsg("privatebin", backup.OffsiteReconstituteResult{
FilesPlaced: 0, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
})
if !strings.Contains(msg, "0 fájl és 1 adatkötet visszaállítva") {
t.Errorf("a volume-only restore must state the volume; got %q", msg)
}
}
// Scenario C: a snapshot with no volume archives must produce the EXACT sentence it produced before,
// so the change cannot be read as "a volume was expected and did not arrive".
func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) {
noDB := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
FilesPlaced: 3, VolumesReplayed: 0, DBsReplayed: 0, SafetyDump: "",
})
if noDB != "A(z) opengist: 3 fájl visszaállítva — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa." {
t.Errorf("the no-volume, no-database wording changed; got %q", noDB)
}
withDB := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
FilesPlaced: 4, VolumesReplayed: 0, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
})
if withDB != "A(z) romm: 4 fájl és az adatbázis visszaállítva — az alkalmazás újraindult." {
t.Errorf("the no-volume, with-database wording changed; got %q", withDB)
}
if strings.Contains(noDB, "adatkötet") || strings.Contains(withDB, "adatkötet") {
t.Error("a restore that replayed no volume must not mention volumes at all")
}
}
// All three legs at once.
func TestReconstituteOutcome_AllThreeLegs(t *testing.T) {
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
FilesPlaced: 12, VolumesReplayed: 3, DBsReplayed: 1, SafetyDump: "/x/pre-restore-p.sql",
})
want := "A(z) paperless-ngx: 12 fájl és 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."
if msg != want {
t.Errorf("got %q\nwant %q", msg, want)
}
}