Files
felhom-controller/controller/internal/web/r353_unit_outcome_test.go
T
admin c0c8fe67bf
gates / gates (push) Failing after 13s
An unknown drawn as a zero: the defect v0.226.0's own fix introduced
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.
2026-08-30 19:51:16 +02:00

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)
}
}