190 lines
8.0 KiB
Go
190 lines
8.0 KiB
Go
package backup
|
|
|
|
// R-1252 (review round 2, HUNT-2 H1, H2, H9): a rejected backup block never deletes second-drive copies.
|
|
//
|
|
// The first test is the review's 9202 reproduction (audits/review-round-2-2026-10-24/proofs/H2-bad-backup-block/) as a
|
|
// unit test: an app with its household files in a userdata leg; two good runs keep the copy (the control); one bogus
|
|
// path in the block rejects it → the legacy legs → before the fix „Tier 2 reconcile: removed stale dest dir
|
|
// userdata/media". RED-PROOF (2026-10-24, on the parent of this commit): TestR1252_ARejectedBlockKeepsEveryCopy failed
|
|
// on „the household's file copy was deleted"; TestR1252_AKeptCopyStaysWhenItsLegLeaves on „the kept copy was
|
|
// reconciled away"; TestR1252_RemoveDropsTheKeptRecordsItDeleted on „the record outlived its copy".
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// rejectingProvider is the v2 fixture provider that can also say a block was rejected (the stack adapter's
|
|
// BackupBlockRejected in production).
|
|
type rejectingProvider struct {
|
|
*t2v2Provider
|
|
rejected map[string]string
|
|
}
|
|
|
|
func (p *rejectingProvider) BackupBlockRejected(n string) string { return p.rejected[n] }
|
|
|
|
func mUD(rel string) ClassifiedBind {
|
|
b := oUD(rel)
|
|
b.ReadOnly = false
|
|
b.Class = ClassMandatory
|
|
return b
|
|
}
|
|
|
|
func TestR1252_ARejectedBlockKeepsEveryCopy(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "audiobookshelf")
|
|
rp := &rejectingProvider{t2v2Provider: prov, rejected: map[string]string{}}
|
|
m.stackProvider = rp
|
|
var sent []string
|
|
m.SetTier2SettingsUnreadableNotify(func(app, why string) { sent = append(sent, app+": "+why) })
|
|
prov.has["audiobookshelf"] = true
|
|
prov.binds["audiobookshelf"] = []ClassifiedBind{mHDD("appdata/audiobookshelf/config"), mUD("media/audiobooks")}
|
|
prov.mounts["audiobookshelf"] = []string{filepath.Join(src, "appdata", "audiobookshelf", "config")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "audiobookshelf", "config", "db.sqlite"), "DB")
|
|
mustWrite(t, filepath.Join(src, "userdata", "media", "audiobooks", "r1252-marker.mp3"), "the household's book")
|
|
destBase := filepath.Join(target, "backups", "secondary", "audiobookshelf")
|
|
copyOfBook := filepath.Join(destBase, "userdata", "media", "audiobooks", "r1252-marker.mp3")
|
|
|
|
for run := 1; run <= 2; run++ { // the positive control: an unchanged block keeps the copy
|
|
if err := m.RunTier2("audiobookshelf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists(copyOfBook) {
|
|
t.Fatalf("control run %d: the household's file was not copied", run)
|
|
}
|
|
}
|
|
// one bogus path: LoadMetadata rejects the WHOLE block → no classification, the legacy legs
|
|
prov.has["audiobookshelf"] = false
|
|
rp.rejected["audiobookshelf"] = `userdata path "media/nope" matches no compose bind`
|
|
if err := m.RunTier2("audiobookshelf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists(copyOfBook) {
|
|
t.Fatal("the household's file copy was deleted by the night copy after a rejected block (R-1252)")
|
|
}
|
|
if cd := m.settings.GetCrossDriveConfig("audiobookshelf"); cd == nil || !strings.Contains(cd.LastWarning, "nem olvashat") {
|
|
t.Fatalf("the app's status must say the settings are unreadable and the copies kept: %+v", cd)
|
|
}
|
|
if len(sent) != 1 || !strings.Contains(sent[0], "media/nope") {
|
|
t.Fatalf("the operator must be told once, with the reason: %v", sent)
|
|
}
|
|
if err := m.RunTier2("audiobookshelf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sent) != 1 {
|
|
t.Fatalf("a second run the same day must not tell the operator again: %v", sent)
|
|
}
|
|
m.tier2Unreadable.now = func() time.Time { return time.Now().Add(25 * time.Hour) }
|
|
if err := m.RunTier2("audiobookshelf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sent) != 2 {
|
|
t.Fatalf("the next day tells the operator again: %v", sent)
|
|
}
|
|
}
|
|
|
|
// An app with no block at all (absent) — the legacy legs — reconciles nothing either, and says nothing.
|
|
func TestR1252_AnAbsentBlockReconcilesNothing(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "legacyapp")
|
|
prov.has["legacyapp"] = false
|
|
prov.mounts["legacyapp"] = []string{filepath.Join(src, "appdata", "legacyapp", "x")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "legacyapp", "a.txt"), "A")
|
|
destBase := filepath.Join(target, "backups", "secondary", "legacyapp")
|
|
stray := filepath.Join(destBase, "userdata", "photos", "p.jpg")
|
|
mustWrite(t, stray, "a copy from an earlier, classified night")
|
|
if err := m.RunTier2("legacyapp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists(stray) {
|
|
t.Fatal("an app without a valid classification must not reconcile away a copy")
|
|
}
|
|
if cd := m.settings.GetCrossDriveConfig("legacyapp"); cd != nil && strings.Contains(cd.LastWarning, "nem olvashat") {
|
|
t.Fatalf("an absent block is not unreadable settings: %q", cd.LastWarning)
|
|
}
|
|
}
|
|
|
|
// The control the other way: a VALID classification still reconciles a leg that was re-classed excluded.
|
|
func TestR1252_AValidClassificationStillReconciles(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "app")
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/keep"), mHDD("appdata/app/drop")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "keep", "k"), "K")
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "drop", "d"), "D")
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dropped := filepath.Join(target, "backups", "secondary", "app", "hdd", "appdata", "app", "drop")
|
|
if !exists(dropped) {
|
|
t.Fatal("precondition: run 1 copied both legs")
|
|
}
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/keep"), xHDD("appdata/app/drop")}
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists(dropped) {
|
|
t.Fatal("a leg re-classed excluded under a VALID block must still be reconciled away (§8.2)")
|
|
}
|
|
}
|
|
|
|
// HUNT-2 H2: a copy D25 keeps (its folder went missing) stays kept when its leg later leaves the declared set.
|
|
func TestR1252_AKeptCopyStaysWhenItsLegLeaves(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "app")
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos"), mHDD("appdata/app/docs")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "docs", "contract.pdf"), "PDF")
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
photo := filepath.Join(target, "backups", "secondary", "app", "hdd", "appdata", "app", "photos", "family.jpg")
|
|
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.RunTier2("app"); err != nil { // the folder is missing → kept + recorded (D25)
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.KeptCopies()) != 1 {
|
|
t.Fatalf("precondition: the photos copy is kept, got %+v", m.KeptCopies())
|
|
}
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/docs")} // the photos leg leaves the declared set
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists(photo) {
|
|
t.Fatal("the kept copy was reconciled away while its record stays (HUNT-2 H2)")
|
|
}
|
|
if len(m.KeptCopies()) != 1 {
|
|
t.Fatalf("the kept record must stay: %+v", m.KeptCopies())
|
|
}
|
|
}
|
|
|
|
// HUNT-2 H9: Remove with „remove backups" deletes the mirror — and the kept-copy records inside it go with it.
|
|
func TestR1252_RemoveDropsTheKeptRecordsItDeleted(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "app")
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.KeptCopies()) != 1 {
|
|
t.Fatalf("precondition: one kept copy, got %+v", m.KeptCopies())
|
|
}
|
|
mirror := filepath.Join(target, "backups", "secondary", "app")
|
|
removed := m.RemoveTier2Mirrors("app", []string{mirror})
|
|
if len(removed) != 1 || exists(mirror) {
|
|
t.Fatalf("precondition: the mirror is deleted, got %v", removed)
|
|
}
|
|
if len(m.KeptCopies()) != 0 {
|
|
t.Fatalf("the record outlived its copy: %+v", m.KeptCopies())
|
|
}
|
|
}
|