package backup import ( "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // R-403 Group B — the mirror guard, driven through the REAL RunTier2. // // The `tier2Mirror` seam records every (src,dst) the run asks for, so a skip is provable as an // ABSENCE OF A CALL rather than inferred from a log line — and the destination's own bytes are // compared before and after, which is the consequence the customer actually cares about. // r403Recorder is a mirror seam that records its calls and then performs a REAL MIRROR — the // destination is emptied first, so `rsync -a --delete`'s defining behaviour is present in the test. // // This matters more than it looks: `copyTree` (the additive helper next door) would let every // assertion here pass while proving nothing, because the deletion IS the defect. A seam that is // gentler than the thing it stands in for is a test that cannot see the bug. type r403Recorder struct{ calls []string } func (r *r403Recorder) mirror(src, dst string) error { r.calls = append(r.calls, dst) if err := os.RemoveAll(dst); err != nil { return err } return copyTree(src, dst) } func (r *r403Recorder) calledFor(sub string) bool { for _, c := range r.calls { if strings.Contains(c, sub) { return true } } return false } // r403Fixture builds a Manager with a source drive and a registered off-drive target, seeds the // SOURCE unit and (optionally) a pre-existing DESTINATION unit, and returns the recorder. func r403Fixture(t *testing.T, srcDB, srcVols []string, destDB, destVols []string, destExists bool) ( m *Manager, rec *r403Recorder, src, destBase string) { t.Helper() m, src, target, prov := newTier2V2(t, "app") rec = &r403Recorder{} m.tier2Mirror = rec.mirror prov.has["app"] = false // legacy path, no classified binds → the unit leg is the only leg destBase = filepath.Join(target, "backups", "secondary", "app") // SOURCE unit — newTier2V2 already wrote a `{}` manifest; replace it with a real one. srcUnit := RecoveryUnitPath(src, "app") man := &RecoveryManifest{SchemaVersion: 2, AppName: "app", DBDumps: srcDB, VolumeDumps: srcVols} if err := writeManifest(UnitManifestFile(srcUnit), man); err != nil { t.Fatal(err) } for _, d := range srcDB { mustWrite(t, filepath.Join(UnitDBDumpDir(srcUnit), d), "SOURCE-"+d) } for _, v := range srcVols { mustWrite(t, filepath.Join(UnitVolumeDumpDir(srcUnit), v), "SOURCE-"+v) } // DESTINATION unit — the copy that must survive. if destExists { destUnit := filepath.Join(destBase, "recovery-unit") // atomicWrite needs the parent to exist — the real capture creates it before writing. if err := os.MkdirAll(destUnit, 0o755); err != nil { t.Fatal(err) } dman := &RecoveryManifest{SchemaVersion: 2, AppName: "app", DBDumps: destDB, VolumeDumps: destVols} if err := writeManifest(UnitManifestFile(destUnit), dman); err != nil { t.Fatal(err) } for _, d := range destDB { mustWrite(t, filepath.Join(UnitDBDumpDir(destUnit), d), "DEST-"+d) } for _, v := range destVols { mustWrite(t, filepath.Join(UnitVolumeDumpDir(destUnit), v), "DEST-"+v) } mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), tier2LayoutVersion) } return m, rec, src, destBase } // B1 — TestR403_HollowSourceOverCompleteDestIsSkipped. THE ACCEPTANCE TEST. // // This is the state measured live on demo-hp 2026-08-31: a hollow primary unit and a complete // secondary copy. On the shipped v0.229.0 the run deleted 4 database dumps and 3 volume tars — // 120 082 104 B → 7 036 B — and reported success. // // Red-proof (recorded in REPORT.md): remove the guard and this fails on BOTH assertions — the seam // is called for the unit leg, and the destination's fingerprint changes. func TestR403_HollowSourceOverCompleteDestIsSkipped(t *testing.T) { m, rec, _, destBase := r403Fixture(t, nil, nil, []string{"app-postgres.sql"}, []string{"app_data.tar", "app_cache.tar"}, true) destUnit := filepath.Join(destBase, "recovery-unit") before := fingerprintTree(t, destUnit) if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2 must still succeed — the other legs are not held hostage: %v", err) } // THE CONSEQUENCE, first: the customer's package is byte-identical. if after := fingerprintTree(t, destUnit); after != before { t.Error("the destination unit CHANGED — a hollow source was mirrored over a complete copy (R-403)") } for _, f := range []string{"db-dumps/app-postgres.sql", "volume-dumps/app_data.tar", "volume-dumps/app_cache.tar"} { if _, err := os.Stat(filepath.Join(destUnit, f)); err != nil { t.Errorf("%s was DELETED from the copy: %v", f, err) } } // And the mechanism: the mirror was never asked to touch the unit leg. if rec.calledFor("recovery-unit") { t.Errorf("the mirror seam WAS called for the unit leg: %v", rec.calls) } } // B2 — a complete source still mirrors. The guard must not become a general refusal. func TestR403_CompleteSourceStillMirrors(t *testing.T) { m, rec, _, destBase := r403Fixture(t, []string{"app-postgres.sql"}, []string{"app_data.tar"}, []string{"old.sql"}, []string{"old.tar"}, true) if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if !rec.calledFor("recovery-unit") { t.Fatalf("the unit leg did not run for a complete source: %v", rec.calls) } destUnit := filepath.Join(destBase, "recovery-unit") if _, err := os.Stat(filepath.Join(destUnit, "volume-dumps/app_data.tar")); err != nil { t.Errorf("the source's tar did not reach the copy: %v", err) } // --delete still works: the stale destination file is gone. The mirror is still a MIRROR. if _, err := os.Stat(filepath.Join(destUnit, "volume-dumps/old.tar")); err == nil { t.Error("the stale destination tar survived — --delete was neutered, which is NOT the fix") } } // B3 — hollow over hollow still mirrors. An app that genuinely has no database and no volumes is not // a defect, and both sides agree, so nothing is at risk. func TestR403_HollowOverHollowStillMirrors(t *testing.T) { m, rec, _, _ := r403Fixture(t, nil, nil, nil, nil, true) if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if !rec.calledFor("recovery-unit") { t.Errorf("hollow→hollow was skipped; it must mirror: %v", rec.calls) } } // B3b — a first copy (no destination unit at all) still mirrors, hollow source or not. func TestR403_FirstCopyStillMirrors(t *testing.T) { m, rec, _, _ := r403Fixture(t, nil, nil, nil, nil, false) if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if !rec.calledFor("recovery-unit") { t.Errorf("the first copy was skipped: %v", rec.calls) } } // B4 — the other legs still run when the unit leg is skipped. A preserved package must not cost the // customer their file legs. func TestR403_OtherLegsStillRunWhenTheUnitLegIsSkipped(t *testing.T) { m, rec, src, _ := r403Fixture(t, nil, nil, []string{"a.sql"}, []string{"a.tar"}, true) // Give the app a classified file leg with real content. prov := m.stackProvider.(*t2v2Provider) prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app")} mustWrite(t, filepath.Join(src, "appdata", "app", "user.txt"), "USERDATA") if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if rec.calledFor("recovery-unit") { t.Error("the unit leg ran despite a hollow source over a complete destination") } if !rec.calledFor(filepath.Join("hdd", "appdata", "app")) { t.Errorf("the FILE leg was held hostage by the skipped unit leg: %v", rec.calls) } } // B5 — TestR403_SkipIsRecordedForTheSurface. Not only logged. // // A refusal that lives only in a container log is a refusal the customer cannot see, and the whole // point of preserving the copy is lost if the page then calls it fresh. func TestR403_SkipIsRecordedForTheSurface(t *testing.T) { m, _, _, destBase := r403Fixture(t, nil, nil, []string{"a.sql"}, []string{"a.tar"}, true) // Stamp the destination manifest with a date so the surface has a package date to name. destUnit := filepath.Join(destBase, "recovery-unit") dman := &RecoveryManifest{SchemaVersion: 2, AppName: "app", CreatedAt: "2026-08-25T03:30:00Z", DBDumps: []string{"a.sql"}, VolumeDumps: []string{"a.tar"}} if err := os.MkdirAll(destUnit, 0o755); err != nil { t.Fatal(err) } if err := writeManifest(UnitManifestFile(destUnit), dman); err != nil { t.Fatal(err) } if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } cd := m.settings.GetCrossDriveConfig("app") if cd == nil { t.Fatal("no cross-drive record was written at all") } if !cd.UnitLegSkipped { t.Error("UnitLegSkipped is false — the surface cannot tell the package was preserved") } if cd.UnitPackageDate != "2026-08-25T03:30:00Z" { t.Errorf("UnitPackageDate = %q, want the DESTINATION manifest's own created_at", cd.UnitPackageDate) } if !strings.Contains(cd.LastWarning, "hi") { // ASCII fragment of "hiányos" (R-364) t.Errorf("LastWarning does not carry the preserved-copy notice: %q", cd.LastWarning) } if cd.LastWarning != tier2UnitPreservedWarning && !strings.Contains(cd.LastWarning, tier2UnitPreservedWarning) { t.Errorf("LastWarning is not the named constant: %q", cd.LastWarning) } // And the coverage the restore surface reads agrees, from the ARTIFACT rather than the record. cov, err := m.Tier2RestoreCoverage("app") if err != nil { t.Fatalf("coverage: %v", err) } date, older := cov.UnitRestoreDate() if date != "2026-08-25T03:30:00Z" || !older { t.Errorf("UnitRestoreDate() = (%q,%v), want the package date and older-than-the-run", date, older) } } // B6 — TestR403_DataLegShrinkIsUnaffected. The guard is on the UNIT leg only. // // `07-backup-architecture.md` §8 row 5 records that the secondary is a derived copy, rebuilt on the // next run, and tier2.go's header records that a classified app's copy legitimately shrinks as // `export` drops out of its class set. Fencing that would be calling a decision a defect. // // Red-proof (recorded in REPORT.md): widen the guard to the data legs and this fails — the stale file // survives in the copy. func TestR403_DataLegShrinkIsUnaffected(t *testing.T) { m, _, src, destBase := r403Fixture(t, []string{"a.sql"}, []string{"a.tar"}, nil, nil, true) prov := m.stackProvider.(*t2v2Provider) prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app")} mustWrite(t, filepath.Join(src, "appdata", "app", "kept.txt"), "KEPT") // A file that exists ONLY in the destination — the shrink case. mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "dropped.txt"), "SHOULD-GO") if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if _, err := os.Stat(filepath.Join(destBase, "hdd", "appdata", "app", "kept.txt")); err != nil { t.Errorf("the live file did not reach the copy: %v", err) } if _, err := os.Stat(filepath.Join(destBase, "hdd", "appdata", "app", "dropped.txt")); err == nil { t.Error("a data leg stopped shrinking — the guard is TOO WIDE and is fencing a design decision") } } // A guard that quietly widened would also be caught by the class constant staying where it belongs. func TestR403_GuardUsesTheSharedPredicate(t *testing.T) { // The predicate answers the same for the same directory whichever caller asks — one definition. dir := r403Unit(t, nil, []string{"v.tar"}, nil) if unitIsHollow(dir) != !unitCarriesData(dir) { t.Error("unitIsHollow is not the negation of unitCarriesData") } _ = appbackup.UnitManifestFile // the unit-dir-relative helper is what both callers resolve through }