48f3336956
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
274 lines
12 KiB
Go
274 lines
12 KiB
Go
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)
|
|
}
|
|
// v0.254.0 (R-557 release C): the notice is SAVED in the box's language, so the comparison is
|
|
// against the bundle text for its key rather than against a Go constant. Same claim, measured one
|
|
// step further out — a reworded sentence still fails, and so does a note written from a different key.
|
|
if want := m.note(tier2UnitPreservedKey); cd.LastWarning != want &&
|
|
!strings.Contains(cd.LastWarning, want) {
|
|
t.Errorf("LastWarning is not the named notice: %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
|
|
}
|