Files
felhom-controller/controller/internal/web/r353_unit_outcome_test.go
T
admin b8af72764d
gates / gates (push) Successful in 11s
R-353/R-357/R-358/R-360: the restore tells the truth (v0.226.0)
Four defects on the restore surface, all proven on demo-hp during the 2026-08-21
backup-truth drill, all still in shipped code. They share one acceptance idea: a
restore surface must state what it actually did, and must refuse what it cannot
do.

VERSION NOTE. The task specifying this targeted v0.224.0 against baseline
f8c9390. Both were consumed earlier the same day by R-330 (0.224.0) and R-331
(0.225.0). Drift re-confirmed against live Gitea before the first edit, operator
authorised proceeding, every symbol the spec named re-verified present at the
real baseline e5eee50.

R-353 -- a restore that gave back nothing still said it worked.
RestoreFromRecoveryUnit returned only error, so the surface printed
"<app> visszaallitva (<snapshot>)." -- equally true of a run that returned an
entire dataset and one that returned nothing. The count already existed and was
discarded one line deep: restoreDockerVolumesFrom always returned it, the
wrapper threw it away. Now (UnitRestoreResult, error), carrying replayed counts
AND what the manifest LISTED, because zero-replayed has two causes that are
opposite news. Three cases, three sentences, and EVERY one is a claim about the
BACKUP, never about the app -- this path has no SafetyDump discriminator, and
07-backup-architecture 6.3 records that an absent dump says nothing about the
app (R-361 destroyed canonical .sql files for four months).

R-357 -- the destructive restore had no free-space gate. offbox_reconstitute.go
contained ZERO references to offboxFree; all three existing gates guard
non-destructive paths. The gate now sits before mapOffsiteRestorePaths,
writeSafetyDump and StopStack, so a refusal costs nothing. Position IS the fix,
which is why the test asserts StopStack was never called. No headroom multiplier
(matches PlaceOffsiteRestore; the x1.1 elsewhere predicts a download). Fail
closed on either probe <= 0 -- otherwise `free < need` with need==0 is FALSE and
an unmeasurable scratch sails through: a gate present and inert.

R-358 -- a failed download was offered as a good one. The gate answered "the
directory exists and is non-empty", which is exactly what a part-way restic run
leaves. Now a completion marker written 0600 atomically AFTER restic returns
nil, with any stale one cleared BEFORE it starts; both orders pinned by an AST
test because resticStep is not a seam. Both handlers refuse server-side: the
wizard flags control a button, and a hidden button is not a guard.

SCENARIO F ANSWERED, and worse than the question assumed: a unit-only scratch IS
reachable through the real flow, by the most ordinary route. "Ellenorzo
visszaallitas" (mode=unit, advertised non-destructive) writes the SAME directory
-- offboxRestoreScratchDir ignores `full` and --include limits what restic
extracts, never where -- so a customer who ran the SAFE restore was then offered
the destructive one over a unit-only copy. Filed R-396; the marker closes it.

R-360 -- the delete refused only while a BACKUP ran. IsRunning() is FALSE for the
whole of a verification restore; the five sibling handlers all use
restoreOpBlocked(). Its doc comment claimed it already did this, which is why
nobody looked -- corrected in place.

Red-proofs, each printing the pre-fix behaviour, in CHANGELOG and REPORT. The
first R-357 red-proof exposed a hollow test OF MY OWN and it is recorded rather
than quietly fixed: the fixture refused earlier at the placement stat pre-pass,
so `stops == 0` passed against the pre-fix code. Fixture corrected, assertions
reordered so a removed gate reports the outage rather than "no error returned".

Green gate clean: 28 packages, rc 0. All 12 controller gates OK.
2026-08-30 19:31:31 +02:00

184 lines
7.7 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")
}
// There is no recovery unit and no data on this drive, so nothing came back: Scenario B.
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)
}
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
if !strings.Contains(last, want) {
t.Fatalf("the published outcome does not state that no data came back; missing %q in %q", want, last)
}
}
}