c0c8fe67bf
gates / gates (push) Failing after 13s
Writing the REPORT's observation "the no-unit fallback already reports a zero result, which is honest" exposed that the sentence was FALSE. A zero UnitRestoreResult is Scenario B's shape. So RestoreFromRecoveryUnit's fallback to RestoreApp -- which returns only an error, and whose signature is deliberately out of scope -- would have printed "ez a mentes csak a beallitasokat tartalmazta, adatot nem" over a restore that may have replayed the app's entire dataset. That is an unknown drawn as a zero: the exact R-88 failure direction this whole change exists to remove, re-introduced by the change. UnitRestoreResult now carries CountsUnknown, the fallback sets it, and there is a fourth sentence claiming only what is known -- the restore ran, the app is back, and we cannot say what came back. RestoreApp's signature is untouched. Pinned by TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty. The A5 seam test was corrected too: its fixture has no recovery unit, so it exercises exactly this path and had been asserting the wrong sentence -- it now asserts the unknown, which is what pins the fallback to it. IT WAS THE observations GATE REFUSING THE PUSH THAT FORCED THE RE-READ. A gate written to stop findings dying in an overwritten REPORT.md caught a live defect instead. Also files R-397 (NotifyIntegrityOK/Failed are dead code AND the monitoring page advertises a weekly integrity check that does not exist) and R-398 (resticStep is not a seam, which is why R-358's ordering needed an AST test) rather than leaving them in a file that is overwritten every session. REPORT.md is the full run record: baselines re-confirmed, per-test results, the five red-proofs with their observed output, the live validation with verbatim Hungarian messages, what was NOT validated and why, teardown across three layers, and the register 165 -> 167 -> 161. Green gate clean: 28 packages, rc 0. All 12 controller gates OK.
219 lines
9.8 KiB
Go
219 lines
9.8 KiB
Go
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 (<snapshot>)."
|
|
// 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)
|
|
}
|
|
}
|