Files
felhom-controller/controller/internal/backup/tier2_whole_test.go
T

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