3c6b49b31c
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
234 lines
9.1 KiB
Go
234 lines
9.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// v0.269.0 — the WHOLE restore from the second drive (`09` §3 decision 26, R-661). The file rules are
|
|
// asserted as CONSEQUENCES over the whole tree: every live file is fingerprinted before and after, and
|
|
// nothing the household had may disappear or be silently overwritten.
|
|
|
|
func writeAt(t *testing.T, p, body string, mt time.Time) {
|
|
t.Helper()
|
|
mustWrite(t, p, body)
|
|
if err := os.Chtimes(p, mt, mt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func readFile(t *testing.T, p string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", p, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// fingerprint maps every regular file under root to its content.
|
|
func fingerprint(t *testing.T, root string) map[string]string {
|
|
t.Helper()
|
|
out := map[string]string{}
|
|
_ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
|
|
if err == nil && fi.Mode().IsRegular() {
|
|
rel, _ := filepath.Rel(root, p)
|
|
out[rel] = readFile(t, p)
|
|
}
|
|
return nil
|
|
})
|
|
return out
|
|
}
|
|
|
|
// TestWhole_TheFourFileRules — one mirror, one live tree, every rule exercised at once.
|
|
//
|
|
// COMPANION RED-PROOFS (REPORT):
|
|
// - rule 2 removed (no newer-check): "b.txt, the household's NEWER copy, was overwritten";
|
|
// - rule 4 without keeping the live copy: "the older live copy of c.txt is gone";
|
|
// - rule 3 removed: "a.txt, missing live, was not brought back".
|
|
func TestWhole_TheFourFileRules(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
mirror, live := filepath.Join(tmp, "mirror"), filepath.Join(tmp, "live")
|
|
old := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
|
mid := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC)
|
|
newer := time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC)
|
|
|
|
writeAt(t, filepath.Join(mirror, "photos", "a.txt"), "A from the copy", mid) // live: missing
|
|
writeAt(t, filepath.Join(mirror, "photos", "b.txt"), "B from the copy", mid) // live: newer
|
|
writeAt(t, filepath.Join(mirror, "photos", "c.txt"), "C from the copy", mid) // live: older, different
|
|
writeAt(t, filepath.Join(mirror, "photos", "d.txt"), "D same", mid) // live: same
|
|
writeAt(t, filepath.Join(mirror, "docs", "deep", "e.txt"), "E from the copy", mid) // live: whole dir missing
|
|
writeAt(t, filepath.Join(live, "photos", "b.txt"), "B edited by the household", newer) // newer live
|
|
writeAt(t, filepath.Join(live, "photos", "c.txt"), "C broken by the update", old) // older live
|
|
writeAt(t, filepath.Join(live, "photos", "d.txt"), "D same", old) // same content
|
|
writeAt(t, filepath.Join(live, "photos", "only-live.txt"), "not in the copy", old) // must survive
|
|
before := fingerprint(t, live)
|
|
|
|
ts := time.Date(2026, 9, 24, 21, 0, 0, 0, time.UTC)
|
|
c, err := mergeRestoreFiles(mirror, live, ts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after := fingerprint(t, live)
|
|
|
|
// Rule 1 — nothing the household had is gone: every file present before still exists, or (c.txt)
|
|
// its content survives beside it.
|
|
kept := "photos/c.txt" + wholeRestoreSuffix(ts)
|
|
for rel, body := range before {
|
|
if got, ok := after[rel]; ok && (got == body || rel == "photos/c.txt") {
|
|
continue
|
|
}
|
|
t.Fatalf("live file %s (%q) is gone or changed — rule 1", rel, body)
|
|
}
|
|
if after[kept] != "C broken by the update" {
|
|
t.Fatalf("the older live copy of c.txt is gone — rule 4 must keep it beside as %s; files: %v", kept, keys(after))
|
|
}
|
|
// Rule 2
|
|
if after["photos/b.txt"] != "B edited by the household" {
|
|
t.Fatalf("b.txt, the household's NEWER copy, was overwritten: %q", after["photos/b.txt"])
|
|
}
|
|
// Rule 3
|
|
if after["photos/a.txt"] != "A from the copy" || after["docs/deep/e.txt"] != "E from the copy" {
|
|
t.Fatalf("a.txt / e.txt, missing live, were not brought back: %q %q", after["photos/a.txt"], after["docs/deep/e.txt"])
|
|
}
|
|
// Rule 4
|
|
if after["photos/c.txt"] != "C from the copy" {
|
|
t.Fatalf("c.txt (older, different) was not replaced from the copy: %q", after["photos/c.txt"])
|
|
}
|
|
if after["photos/only-live.txt"] != "not in the copy" {
|
|
t.Fatal("a live file absent from the copy was touched")
|
|
}
|
|
if c.Restored != 2 || c.Replaced != 1 || c.KeptNewer != 1 || c.Unchanged != 1 {
|
|
t.Fatalf("counts = %+v, want restored 2, replaced 1, kept-newer 1, unchanged 1", c)
|
|
}
|
|
// mtime of a restored file is the copy's, so a later restore's rule 2 compares like with like.
|
|
if fi, _ := os.Stat(filepath.Join(live, "photos", "a.txt")); !fi.ModTime().Equal(mid) {
|
|
t.Fatalf("a.txt mtime %v, want the copy's %v", fi.ModTime(), mid)
|
|
}
|
|
// Idempotent: a second run changes nothing and deletes nothing.
|
|
c2, err := mergeRestoreFiles(mirror, live, ts.Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c2.Restored != 0 || c2.Replaced != 0 {
|
|
t.Fatalf("a second run moved files again: %+v", c2)
|
|
}
|
|
}
|
|
|
|
func keys(m map[string]string) []string {
|
|
var out []string
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// fileAppProvider makes the R-102 fixture's app a FILE app: a mandatory drive leg.
|
|
type fileAppProvider struct{ *fakeRecoveryProvider }
|
|
|
|
func (f fileAppProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) {
|
|
return []ClassifiedBind{mandatoryHDD("appdata/app")}, true
|
|
}
|
|
|
|
// wholeFixture: the R-102 fixture (a real RunTier2 mirror + unit restore seams), turned into a file app
|
|
// with photos in the mirror's hdd leg and a damaged live tree.
|
|
func wholeFixture(t *testing.T) (*r102T2, string, string) {
|
|
f := r102Tier2Fixture(t, []string{"app_data.tar"}, pgDump(1))
|
|
f.m.stackProvider = fileAppProvider{f.fake}
|
|
mid := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC)
|
|
mirrorLeg := filepath.Join(f.destBase, "hdd", "appdata", "app")
|
|
liveLeg := filepath.Join(f.liveDrive, "appdata", "app")
|
|
writeAt(t, filepath.Join(mirrorLeg, "p1.jpg"), "P1", mid)
|
|
writeAt(t, filepath.Join(mirrorLeg, "p2.jpg"), "P2", mid)
|
|
writeAt(t, filepath.Join(liveLeg, "p2.jpg"), "P2 newer", mid.Add(48*time.Hour))
|
|
return f, mirrorLeg, liveLeg
|
|
}
|
|
|
|
// TestWhole_BringsTheFilesAndTheUnitBack — the whole action: files merged by the rules, then the unit
|
|
// restore FROM THE MIRROR (not the primary), with the app stopped throughout.
|
|
func TestWhole_BringsTheFilesAndTheUnitBack(t *testing.T) {
|
|
f, _, liveLeg := wholeFixture(t)
|
|
res, err := f.m.RestoreTier2Whole("app")
|
|
if err != nil {
|
|
t.Fatalf("whole restore: %v", err)
|
|
}
|
|
if readFile(t, filepath.Join(liveLeg, "p1.jpg")) != "P1" || readFile(t, filepath.Join(liveLeg, "p2.jpg")) != "P2 newer" {
|
|
t.Fatal("the file half did not follow the rules")
|
|
}
|
|
if res.Files.Restored != 1 || res.Files.KeptNewer != 1 {
|
|
t.Fatalf("file counts %+v", res.Files)
|
|
}
|
|
if len(*f.dbPaths) == 0 || !strings.HasPrefix((*f.dbPaths)[0], f.destBase) {
|
|
t.Fatalf("the database was not replayed from the MIRROR: %v", *f.dbPaths)
|
|
}
|
|
if !f.fake.stopped {
|
|
t.Fatal("the app was not stopped")
|
|
}
|
|
}
|
|
|
|
// TestWhole_RefusesBeforeAnythingMoves — no proven copy, no room, not a file app: refused, the app never
|
|
// stopped, not one file written.
|
|
func TestWhole_RefusesBeforeAnythingMoves(t *testing.T) {
|
|
t.Run("no room", func(t *testing.T) {
|
|
f, _, liveLeg := wholeFixture(t)
|
|
f.m.freeBytesFn = func(string) int64 { return 1 << 20 }
|
|
_, err := f.m.RestoreTier2Whole("app")
|
|
var sp *wholeRestoreSpace
|
|
if !errors.As(err, &sp) || f.fake.stopped {
|
|
t.Fatalf("err=%v stopped=%v — want the space refusal before the stop", err, f.fake.stopped)
|
|
}
|
|
if _, e := os.Stat(filepath.Join(liveLeg, "p1.jpg")); e == nil {
|
|
t.Fatal("a file was written by a refused restore")
|
|
}
|
|
})
|
|
t.Run("unit not openable", func(t *testing.T) {
|
|
f, _, _ := wholeFixture(t)
|
|
if err := os.Remove(UnitManifestFile(tier2UnitDir(f.destBase))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.m.RestoreTier2Whole("app"); !errors.Is(err, ErrWholeRestoreNoProvenCopy) || f.fake.stopped {
|
|
t.Fatalf("err=%v stopped=%v", err, f.fake.stopped)
|
|
}
|
|
})
|
|
t.Run("not a file app", func(t *testing.T) {
|
|
f := r102Tier2Fixture(t, []string{"app_data.tar"}, pgDump(1))
|
|
if _, err := f.m.RestoreTier2Whole("app"); !errors.Is(err, ErrWholeRestoreNotFileApp) || f.fake.stopped {
|
|
t.Fatalf("err=%v stopped=%v", err, f.fake.stopped)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestWhole_R538StillRefusesTheUnitOnlyRestore — decision 26 is a NEW action beside R-538, not its
|
|
// removal: the unit-only restore of a file app (own unit AND the second drive's unit) still refuses.
|
|
func TestWhole_R538StillRefusesTheUnitOnlyRestore(t *testing.T) {
|
|
f, _, _ := wholeFixture(t)
|
|
_, err := f.m.RestoreTier2Unit("app")
|
|
var refusal *ErrUnitLacksFileLegs
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("the unit-only restore of a file app must still refuse (R-538): %v", err)
|
|
}
|
|
}
|
|
|
|
// TestWhole_SecondDriveIsWholeForAFileAppWithAMirror — decision 25's table gains the second drive.
|
|
func TestWhole_SecondDriveIsWholeForAFileAppWithAMirror(t *testing.T) {
|
|
f, _, _ := wholeFixture(t)
|
|
if !f.m.WholeOnTier("app", UpdateTierSecondDrive) {
|
|
t.Fatal("a file app with a mirrored unit AND file legs must be whole on the second drive")
|
|
}
|
|
if f.m.WholeOnTier("app", UpdateTierLocal) {
|
|
t.Fatal("the own unit is still NOT whole for a file app")
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(f.destBase, "hdd")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.m.WholeOnTier("app", UpdateTierSecondDrive) {
|
|
t.Fatal("a mirror without the file leg is not whole")
|
|
}
|
|
}
|