package backup import ( "os" "os/exec" "path/filepath" "strings" "testing" ) // R-1175 + D25 (operator ruling 2026-10-21), scenario B. The first two tests are the data-loss hunt's C3 cases // (felhom.eu audits/dataloss-hunt-2026-10-20/C3-D1-shares-tier2/), now asserting instead of logging. func sharesCopy(env *sharesEnv, target, srcDrive, share string) string { return filepath.Join(NamespaceRoot(env.drives[target], true), "backups", "secondary", SharesPseudoStack, sharesDriveKey(env.drives[srcDrive]), share) } func mustExist(t *testing.T, p, why string) { t.Helper() if _, err := os.Stat(p); err != nil { t.Fatalf("%s: %s is gone (%v)", why, p, err) } } // Shares on hdd_2 and hdd_3 both copy to hdd_1: after EVERY run both groups' copies exist. Before the fix each group's // reconcile walked every drive key and removed the other group's copy. func TestR1175_TwoDrivesSharingOneTargetKeepBothCopies(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") env.addShare(t, "hdd_2", "b_share", true) env.addShare(t, "hdd_3", "c_share", true) for run := 1; run <= 2; run++ { if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "run "+string(rune('0'+run))+": hdd_2's share copy") mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_3", "c_share"), "c_share.txt"), "run "+string(rune('0'+run))+": hdd_3's share copy") } } // The night a drive is away its copy is NOT deleted: it stays, and it is listed as kept (drive_away). func TestR1175_AnAwayDrivesShareCopyIsKeptAndListed(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") env.addShare(t, "hdd_2", "b_share", true) env.addShare(t, "hdd_3", "csaladi_fotok", true) if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil { t.Fatal(err) } if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } cp := sharesCopy(env, "hdd_1", "hdd_3", "csaladi_fotok") mustExist(t, filepath.Join(cp, "csaladi_fotok.txt"), "the away drive's only second copy") kept := env.m.KeptCopies() if len(kept) != 1 || kept[0].Source != "csaladi_fotok" || kept[0].Reason != "drive_away" || kept[0].Path != filepath.Clean(cp) { t.Fatalf("kept copies = %+v, want csaladi_fotok (drive_away) at %s", kept, cp) } // The drive comes back: the copy is live again and its record goes. if err := env.sett.SetDisconnected(env.drives["hdd_3"], false, nil); err != nil { t.Fatal(err) } if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } if k := env.m.KeptCopies(); len(k) != 0 { t.Fatalf("after the drive came back, kept copies = %+v, want none", k) } } // A registered share whose FOLDER is missing (drive present) keeps its copy; a share the household DELETED is pruned. func TestR1175_MissingFolderKeptDeletedSharePruned(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2") gone := env.addShare(t, "hdd_1", "eltunt", true) env.addShare(t, "hdd_1", "torolt", true) env.addShare(t, "hdd_1", "marad", true) if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } if err := os.RemoveAll(gone); err != nil { t.Fatal(err) } if err := env.sett.RemoveSMBShare("torolt"); err != nil { t.Fatal(err) } if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "eltunt"), "eltunt.txt"), "a missing folder's copy") if _, err := os.Stat(sharesCopy(env, "hdd_2", "hdd_1", "torolt")); !os.IsNotExist(err) { t.Fatalf("a deleted share's copy was not pruned (stat err %v)", err) } if k := env.m.KeptCopies(); len(k) != 1 || k[0].Source != "eltunt" || k[0].Reason != "folder_missing" { t.Fatalf("kept copies = %+v, want eltunt (folder_missing)", k) } } // An EMPTIED present folder is not mirrored over a copy that holds files: the copy stays and the run warns. func TestR1181_AnEmptiedShareDoesNotEmptyItsCopy(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2") if _, err := exec.LookPath("rsync"); err == nil { env.m.tier2Mirror = rsyncMirror // the production mirror: `rsync -a --delete` is what empties a copy } p := env.addShare(t, "hdd_1", "fotok", true) if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } if err := os.Remove(filepath.Join(p, "fotok.txt")); err != nil { t.Fatal(err) } if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "fotok"), "fotok.txt"), "an emptied folder's copy") st := env.m.SharesTier2Status() if st == nil || !strings.Contains(st.LastWarning, "fotok") { t.Fatalf("the run did not warn about the emptied folder: %+v", st) } } // „Eltávolítás" removes ONE kept copy by its record, and refuses a directory that is not the recorded one. func TestD25_RemovingAKeptCopyIsByRecordAndIdentity(t *testing.T) { env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3") env.addShare(t, "hdd_2", "b_share", true) env.addShare(t, "hdd_3", "c_share", true) if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } if err := env.sett.SetDecommissioned(env.drives["hdd_3"], ""); err != nil { t.Fatal(err) } if err := env.m.RunSharesTier2(); err != nil { t.Fatal(err) } k := env.m.KeptCopies() if len(k) != 1 || k[0].Reason == "" { t.Fatalf("kept = %+v", k) } // Swap the directory for another one at the same path: same name, different identity → refused, nothing removed. cp := k[0].Path if err := os.Rename(cp, cp+".old"); err != nil { t.Fatal(err) } if err := os.MkdirAll(cp, 0o755); err != nil { t.Fatal(err) } if err := env.m.RemoveKeptCopy(k[0].ID); err == nil { t.Fatal("a directory that is not the recorded one was removed") } mustExist(t, cp, "the refused removal") // Put the recorded one back → removed, and only it. _ = os.Remove(cp) if err := os.Rename(cp+".old", cp); err != nil { t.Fatal(err) } if err := env.m.RemoveKeptCopy(k[0].ID); err != nil { t.Fatal(err) } if _, err := os.Stat(cp); !os.IsNotExist(err) { t.Fatalf("the kept copy is still there: %v", err) } mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "the other drive's copy") if len(env.m.KeptCopies()) != 0 { t.Fatal("the record outlived the removal") } }