3f048e042b
Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction.
175 lines
7.4 KiB
Go
175 lines
7.4 KiB
Go
package backup
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// R-101 Part 2 — THE COPY-SITE HAZARD, exercised through the REAL record* helpers.
|
|
//
|
|
// The three record* helpers used to build a WHOLE CrossDriveBackup literal, with a helper re-applying
|
|
// exactly two fields. Anything not named in the literal was zeroed on every status write. Adding
|
|
// LastSuccess to that shape would have had `recordTier2Failure` CLEAR the anchor — the mirror image of
|
|
// the defect being fixed, and firing on the FIRST failure rather than lying dormant.
|
|
//
|
|
// The R-100 lesson applies: these call the production functions. A test that modelled the copy in a
|
|
// closure would stay green through any mutation of the real code.
|
|
|
|
func anchorMgr(t *testing.T) *Manager {
|
|
t.Helper()
|
|
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatalf("settings.Load: %v", err)
|
|
}
|
|
return &Manager{settings: sett, logger: log.New(io.Discard, "", 0)}
|
|
}
|
|
|
|
func tgt() *Tier2Target { return &Tier2Target{NamespaceRoot: "/mnt/hdd_1/felhom-data/calibre-web"} }
|
|
|
|
// SCENARIO F — a FAILURE must not wipe the anchor. This is the Part 2 proof and it runs the real
|
|
// recordTier2Success → recordTier2Failure sequence.
|
|
//
|
|
// RED-PROOF: make recordTier2Failure build a whole literal again (or add `c.LastSuccess = ""` to its
|
|
// mutation) → this fails with "a FAILED run wiped the success anchor".
|
|
func TestTier2_FailureDoesNotWipeTheAnchor(t *testing.T) {
|
|
m := anchorMgr(t)
|
|
|
|
// Seed a row with a DISTINCT, older anchor (a real success on the 25th) already tracked, so the
|
|
// anchor and the attempt clock are distinguishable — a success recorded in this same test would
|
|
// land in the same second as the failures and prove nothing about which field moved.
|
|
const anchor = "2026-07-25T01:30:00Z"
|
|
if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", Schedule: "daily",
|
|
LastRun: anchor, LastStatus: "ok", LastSuccess: anchor, SuccessTracked: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Five consecutive failing nights, through the real helper.
|
|
for i := 0; i < 5; i++ {
|
|
m.recordTier2Failure("calibre-web", tgt(), errors.New("rsync: connection reset"))
|
|
got := m.settings.GetCrossDriveConfig("calibre-web")
|
|
if got.LastSuccess == "" {
|
|
t.Fatalf("a FAILED run wiped the success anchor (round %d) — one bad night would read as 'no copy has ever succeeded'", i+1)
|
|
}
|
|
if got.LastSuccess != anchor {
|
|
t.Fatalf("a FAILED run MOVED the anchor to %q (was %q) — that is the R-101 defect in mirror image", got.LastSuccess, anchor)
|
|
}
|
|
if got.LastStatus != "error" {
|
|
t.Errorf("the failure was not recorded (status=%q)", got.LastStatus)
|
|
}
|
|
// the ATTEMPT clock DOES move — that is what made the old rendering wrong
|
|
if got.LastRun == anchor {
|
|
t.Errorf("LastRun did not advance on the attempt — it still reads the old success time %q", got.LastRun)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A successful run advances the anchor, or a tier would look permanently stale after one good night.
|
|
func TestTier2_SuccessAdvancesTheAnchor(t *testing.T) {
|
|
m := anchorMgr(t)
|
|
if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", Schedule: "daily",
|
|
LastRun: "2026-07-25T01:30:00Z", LastStatus: "error",
|
|
LastSuccess: "2026-07-25T01:30:00Z", SuccessTracked: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.recordTier2Success("calibre-web", tgt(), 1<<30, "", 0)
|
|
got := m.settings.GetCrossDriveConfig("calibre-web")
|
|
if got.LastSuccess == "2026-07-25T01:30:00Z" {
|
|
t.Error("a successful run did not advance the anchor")
|
|
}
|
|
if got.LastSuccess != got.LastRun {
|
|
t.Errorf("the anchor and the attempt clock disagree after a success (%q vs %q)", got.LastSuccess, got.LastRun)
|
|
}
|
|
if got.LastError != "" {
|
|
t.Errorf("a success left the previous error in place (%q)", got.LastError)
|
|
}
|
|
}
|
|
|
|
// A customer preference must still survive a status write — the behaviour the old helper existed to
|
|
// provide. The new copy-the-row form should give this for free, and this pins that it does.
|
|
//
|
|
// RED-PROOF: make tier2Update start from a zero-value struct instead of copying the existing row →
|
|
// this fails with "a status write wiped the customer's Tier-2 preference".
|
|
func TestTier2_StatusWritePreservesCustomerPreference(t *testing.T) {
|
|
m := anchorMgr(t)
|
|
if err := m.settings.SetTier2Preference("calibre-web", true, "/mnt/hdd_2"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.recordTier2Failure("calibre-web", tgt(), errors.New("boom"))
|
|
|
|
got := m.settings.GetCrossDriveConfig("calibre-web")
|
|
if !got.UserDisabled || got.PreferredTarget != "/mnt/hdd_2" {
|
|
t.Errorf("a status write wiped the customer's Tier-2 preference (UserDisabled=%v PreferredTarget=%q)",
|
|
got.UserDisabled, got.PreferredTarget)
|
|
}
|
|
}
|
|
|
|
// A no-target write must not destroy the anchor either: "there is no destination drive right now" is
|
|
// not evidence that the last successful copy never happened.
|
|
func TestTier2_NoTargetKeepsTheAnchor(t *testing.T) {
|
|
m := anchorMgr(t)
|
|
m.recordTier2Success("calibre-web", tgt(), 1<<30, "", 0)
|
|
anchor := m.settings.GetCrossDriveConfig("calibre-web").LastSuccess
|
|
|
|
m.recordTier2NoTarget("calibre-web", "nincs elérhető második meghajtó")
|
|
got := m.settings.GetCrossDriveConfig("calibre-web")
|
|
if got.LastSuccess != anchor {
|
|
t.Errorf("a no_target write lost the anchor (%q, was %q)", got.LastSuccess, anchor)
|
|
}
|
|
if got.LastRun != "" {
|
|
t.Errorf("no_target should clear the attempt clock as before, got %q", got.LastRun)
|
|
}
|
|
}
|
|
|
|
// SCENARIO E (producer half) — a LEGACY row is migrated truthfully on first touch: a row whose last
|
|
// known state was a SUCCESS adopts that time as its anchor, so the deploy does not flip every existing
|
|
// customer to "never succeeded".
|
|
//
|
|
// RED-PROOF: delete the `if !cfg.SuccessTracked` seeding block in tier2Update → this fails with
|
|
// "a legacy OK row was not migrated".
|
|
func TestTier2_LegacyOkRowSeedsItsAnchor(t *testing.T) {
|
|
m := anchorMgr(t)
|
|
// A pre-R-101 row: status ok, a last_run, no anchor, not tracked.
|
|
if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", Schedule: "daily",
|
|
LastRun: "2026-07-28T01:30:00Z", LastStatus: "ok",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The next run fails — the first new-code touch of this row.
|
|
m.recordTier2Failure("calibre-web", tgt(), errors.New("boom"))
|
|
|
|
got := m.settings.GetCrossDriveConfig("calibre-web")
|
|
if !got.SuccessTracked {
|
|
t.Fatal("the row was not marked as tracked")
|
|
}
|
|
if got.LastSuccess != "2026-07-28T01:30:00Z" {
|
|
t.Errorf("a legacy OK row was not migrated — its known-good run should have become the anchor (got %q)", got.LastSuccess)
|
|
}
|
|
}
|
|
|
|
// ...but a legacy row whose last known state was an ERROR seeds NOTHING: the old data contains no
|
|
// evidence of a success, and inventing one would be the original defect.
|
|
func TestTier2_LegacyErrorRowSeedsNothing(t *testing.T) {
|
|
m := anchorMgr(t)
|
|
if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", Schedule: "daily",
|
|
LastRun: "2026-07-28T01:30:00Z", LastStatus: "error",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.recordTier2Failure("calibre-web", tgt(), errors.New("boom"))
|
|
|
|
if got := m.settings.GetCrossDriveConfig("calibre-web"); got.LastSuccess != "" {
|
|
t.Errorf("a legacy ERROR row invented an anchor (%q) — that is the defect, not the fix", got.LastSuccess)
|
|
}
|
|
}
|