package web import ( "net/http" "net/http/httptest" "path/filepath" "strings" "sync/atomic" "testing" "time" "io" "log" "os" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── R-353 — a local restore that gave back nothing still said it worked ────────────────────────── // // Observed on demo-hp 2026-08-21: an `opengist` restore reported „opengist visszaállítva ()." // over a recovery unit holding manifest.json and compose/ and nothing else. The customer reads that as // "my data is back". It was not, and no screen in the product could have said so — the count of what // came back was discarded one line below the function that produced it. // // The three cases below are three DIFFERENT facts and collapsing any two is the whole defect. The // wording of the middle one is constrained by R-355 and by 07-backup-architecture §6.3: it is a claim // about THE BACKUP, never about the app, because an absent dump has causes that say nothing about // whether the app has data (R-361 destroyed apps' canonical .sql files for four months). func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) { msg := unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{ VolumesReplayed: 2, DBsReplayed: 1, ManifestVolumes: 2, ManifestDBs: 1, }) for _, want := range []string{"2 adatkötet", "az adatbázis"} { if !strings.Contains(msg, want) { t.Errorf("a restore that returned data must NAME it; missing %q in %q", want, msg) } } if strings.Contains(msg, "FIGYELEM") { t.Errorf("a fully successful restore must not carry a warning; got %q", msg) } if strings.Contains(msg, "visszaállítva (") { t.Errorf("the snapshot-id sentence is the pre-fix shape and says nothing about what came back; got %q", msg) } } func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) { msg := unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{}) for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} { if !strings.Contains(msg, want) { t.Errorf("a restore that returned no data must say so plainly; missing %q in %q", want, msg) } } // R-355: the forbidden inference. The manifest cannot support a claim about the APP, and on the // off-site path the equivalent sentence was printed over a live 72-table PostgreSQL. for _, forbidden := range []string{"nincs adata", "nincs adatbázisa", "alkalmazásnak nincs"} { if strings.Contains(msg, forbidden) { t.Fatalf("FALSE CLAIM about the app inferred from a counter (%q) in %q", forbidden, msg) } } } func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) { msg := unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{ VolumesReplayed: 0, DBsReplayed: 0, ManifestVolumes: 2, ManifestDBs: 1, }) for _, want := range []string{"2 adatkötetet", "1 adatbázis-mentést", "változatlanok maradtak"} { if !strings.Contains(msg, want) { t.Errorf("the unit listed data that did not come back — the message must say so; missing %q in %q", want, msg) } } // The Scenario B sentence would say the backup held only settings, which the manifest contradicts. if strings.Contains(msg, "csak a beállításokat tartalmazta") { t.Fatalf("wrong case: said the backup held only settings while its manifest lists 3 dumps; got %q", msg) } } func TestUnitRestoreOutcome_DatabaseOnly(t *testing.T) { msg := unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{ VolumesReplayed: 0, DBsReplayed: 1, ManifestVolumes: 0, ManifestDBs: 1, }) if !strings.Contains(msg, "az adatbázis visszaállítva") { t.Errorf("a database-only restore must read naturally; got %q", msg) } if strings.Contains(msg, "adatkötet") { t.Fatalf("named a volume count for a restore that replayed none; got %q", msg) } if strings.Contains(msg, "FIGYELEM") { t.Errorf("data came back — this is not a warning case; got %q", msg) } } // --- A5: THE SEAM TEST (§10) -------------------------------------------------------------------- // // The one that matters. It drives the REAL backupRestoreHandler and reads the sentence off the // op-status surface the customer's banner polls — not unitRestoreOutcomeMsg directly. Three shipped // defects in this project came from testing a component whose caller never invoked it, and R-353 is // itself an instance: restoreDockerVolumesFrom returned the count correctly the whole time. type r353Provider struct { hdd string starts int32 } func (p *r353Provider) GetStackComposePath(string) (string, bool) { return "", false } func (p *r353Provider) ListDeployedStacks() []backup.StackSummary { return nil } func (p *r353Provider) GetStackHDDMounts(string) []string { return nil } func (p *r353Provider) GetStackHDDPath(string) string { return p.hdd } func (p *r353Provider) GetImportRoot() string { return "" } func (p *r353Provider) GetDockerVolumes(string) []string { return nil } func (p *r353Provider) StopStack(string) error { return nil } func (p *r353Provider) StartStack(string) error { atomic.AddInt32(&p.starts, 1); return nil } func (p *r353Provider) RefreshAndIsRunning(string) bool { return true } func (p *r353Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { return backup.RecoveryInfo{}, false } func (p *r353Provider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *r353Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error { return nil } func (p *r353Provider) StartStackServices(string, []string) error { return nil } func (p *r353Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) { return nil, false } func TestR353_HandlerPublishesTheOutcome(t *testing.T) { tmp := t.TempDir() lg := log.New(io.Discard, "", 0) live := filepath.Join(tmp, "live") if err := os.MkdirAll(live, 0o755); err != nil { t.Fatal(err) } sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: live, Label: "live"}); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = tmp m := backup.NewManager(cfg, sett, lg) prov := &r353Provider{hdd: live} m.SetStackProvider(prov) s := &Server{cfg: cfg, backupMgr: m, logger: lg} req := httptest.NewRequest(http.MethodPost, "/backup/restore", strings.NewReader("stack_name=opengist&snapshot_id=snap-123")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.backupRestoreHandler(w, req) if w.Code != http.StatusFound { t.Fatalf("want 302, got %d", w.Code) } // The restore runs in a background goroutine; poll the surface the banner polls. var last string for i := 0; i < 900; i++ { st := m.RestoreStatus() if !st.Running && st.Last.Message != "" { last = st.Last.Message break } time.Sleep(10 * time.Millisecond) } if last == "" { t.Fatal("the restore never reached a terminal status") } // THE ASSERTION THAT CARRIES THE SEAM: whatever branch fires, the sentence the customer is shown // must be `unitRestoreOutcomeMsg`'s output and not the pre-fix string. if strings.Contains(last, "visszaállítva (snap-123)") { t.Fatalf("THE PRE-FIX SENTENCE REACHED THE CUSTOMER: %q — it is true of a restore that "+ "returned an entire dataset and of one that returned nothing", last) } // This fixture has no recovery unit, so the production path takes the RestoreApp fallback — where // the counts are genuinely unknown. The honest sentence for that is the unknown one, and asserting // it here is what pins the fallback to it: the zero-value shape would otherwise print // „csak a beállításokat tartalmazta" over a restore that may have returned everything. if strings.Contains(last, "csak a beállításokat tartalmazta") { t.Fatalf("the no-unit fallback claimed the backup held no data, from counts it never "+ "established: %q", last) } if !strings.Contains(last, "nem tudjuk megmondani") { t.Fatalf("the published outcome does not state the unknown as unknown; got %q", last) } } // TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty — the defect the first draft of this fix // introduced, caught by the observations gate forcing a re-read of my own code. // // `RestoreFromRecoveryUnit` falls back to `RestoreApp` when there is no recovery unit, and `RestoreApp` // returns only an error — its signature is deliberately out of scope. So the result is a ZERO value, // and a zero `UnitRestoreResult` is Scenario B's shape: „ez a mentés csak a beállításokat tartalmazta, // adatot nem". That sentence would be printed over a fallback restore that had just replayed the app's // entire dataset. **An unknown drawn as a zero is the R-88 failure direction**, and it is exactly what // this whole change exists to remove — so it must not be re-introduced by the fix itself. func TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty(t *testing.T) { msg := unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true}) if strings.Contains(msg, "csak a beállításokat tartalmazta") { t.Fatal("FALSE CLAIM: told the customer the backup held no data when the counts were never " + "established — a fallback restore may have returned everything they own") } if strings.Contains(msg, "adatkötet") { t.Fatal("claimed a volume count that was never measured") } if !strings.Contains(msg, "nem tudjuk megmondani") { t.Errorf("an unknown must be STATED as unknown, not left silent; got %q", msg) } // And it must still tell them the restore ran, or the sentence reads as a failure. if !strings.Contains(msg, "lefutott") { t.Errorf("the message must say the restore completed; got %q", msg) } }