package backup import ( "fmt" "io" "log" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // Update arc slice 4 — the backup side of the guarded update. // The measured case (demo-hp 2026-09-13, bookstack): the mirror's manifest said 2026-09-12T02:15:29Z // while its database dump was written 2026-09-13T00:30Z and the Tier-2 run succeeded at 01:30Z. The // update's age must be the proven COPY time; the manifest date would call a fresh copy stale forever. func TestSlice4_ProvenCopyTime_IsTheLastSuccessNotTheManifestDate(t *testing.T) { rp := restorePointFromCoverage(Tier2Coverage{ UnitRestorable: true, UnitPackageDate: "2026-09-12T02:15:29Z", CopyLastRun: "2026-09-13T01:30:00Z", CopyLastSuccess: "2026-09-13T01:30:00Z", }) at, ok := rp.ProvenCopyTime() if !ok || !at.Equal(time.Date(2026, 9, 13, 1, 30, 0, 0, time.UTC)) { t.Fatalf("proven copy time = %v ok=%v, want the last successful copy", at, ok) } // The page still names the PACKAGE date (R-403) — the extraction changes nothing it shows. if rp.CopyDate != "2026-09-12T02:15:29Z" || !rp.CopyDateProven || rp.PackagePreserved { t.Errorf("restore point = %+v", rp) } } func TestSlice4_ProvenCopyTime_PreservedPackageUsesThePackageDate(t *testing.T) { rp := restorePointFromCoverage(Tier2Coverage{ UnitRestorable: true, UnitPackageDate: "2026-09-01T02:00:00Z", UnitLegPreserved: true, CopyLastSuccess: "2026-09-13T01:30:00Z", }) if at, ok := rp.ProvenCopyTime(); !ok || !at.Equal(time.Date(2026, 9, 1, 2, 0, 0, 0, time.UTC)) { t.Errorf("a PRESERVED package is as old as the package, got %v ok=%v", at, ok) } } func TestSlice4_ProvenCopyTime_NoProvenOrNoUnitIsNoRestorePoint(t *testing.T) { for _, cov := range []Tier2Coverage{ {UnitRestorable: true, CopyLastRun: "2026-09-13T01:30:00Z"}, // attempt, never a success (R-101) {UnitRestorable: false, CopyLastSuccess: "2026-09-13T01:30:00Z"}, // a copy with no openable unit {UnitRestorable: true, CopyLastSuccess: "not-a-date"}, // unparseable is unknown, never "now" } { if _, ok := restorePointFromCoverage(cov).ProvenCopyTime(); ok { t.Errorf("%+v must not yield a proven copy time", cov) } } } func slice4Settings(t *testing.T) *settings.Settings { t.Helper() s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } return s } func TestSlice4_UpdateHoldTextNamesTheTimeAndTheCopy(t *testing.T) { sett := slice4Settings(t) m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett} at := time.Date(2026, 9, 13, 8, 0, 0, 0, time.UTC) copyAt := time.Date(2026, 9, 13, 1, 30, 0, 0, time.UTC) if err := m.HoldAfterFailedUpdate("bookstack", at, copyAt, UpdateTierSecondDrive); err != nil { t.Fatal(err) } h, ok := sett.GetRestoreHold("bookstack") if !ok || h.Reason != settings.HoldReasonUpdateFailed || h.CopyDate != "2026-09-13T01:30:00Z" { t.Fatalf("hold = %+v ok=%v", h, ok) } held, why := m.RestoreHoldFor("bookstack") // Budapest is UTC+2 in September: 08:00Z → 10:00, 01:30Z → 03:30. want := fmt.Sprintf(UpdateHoldFmt, "bookstack", "2026-09-13 10:00", "második meghajtó", "2026-09-13 03:30") if !held || why != want { t.Errorf("hold text =\n%q\nwant\n%q", why, want) } } func TestSlice4_RestoreHoldTextIsUnchanged(t *testing.T) { sett := slice4Settings(t) m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett} if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "docmost", At: "2026-08-22T14:00:00Z"}); err != nil { t.Fatal(err) } _, why := m.RestoreHoldFor("docmost") if !strings.Contains(why, "visszaállítása") || !strings.Contains(why, "Vedd fel velünk a kapcsolatot") || strings.Contains(why, "frissítése") { t.Errorf("an R-379 restore hold must keep its own sentence, got %q", why) } } func TestSlice4_ASuccessfulRestoreClearsOnlyAnUpdateHold(t *testing.T) { sett := slice4Settings(t) m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett} _ = m.HoldAfterFailedUpdate("upd", time.Now(), time.Now(), UpdateTierLocal) _ = sett.SetRestoreHold(settings.RestoreHold{Stack: "rst", At: "2026-08-22T14:00:00Z"}) m.clearUpdateHoldAfterRestore("upd") m.clearUpdateHoldAfterRestore("rst") if _, ok := sett.GetRestoreHold("upd"); ok { t.Error("a restore is the route back from a failed update — its hold must be lifted") } if _, ok := sett.GetRestoreHold("rst"); !ok { t.Error("an R-379 restore hold stays operator-cleared") } } func TestSlice4_UpdateBusy(t *testing.T) { m := &Manager{logger: log.New(io.Discard, "", 0)} if busy, _ := m.UpdateBusy("app"); busy { t.Fatal("an idle manager is not busy") } if err := m.acquireRunning(); err != nil { t.Fatal(err) } if busy, _ := m.UpdateBusy("app"); !busy { t.Error("a running backup/restore must make an update wait") } m.releaseRunning() m.BeginRestoreOp("tier2-unit-restore", "other") if busy, _ := m.UpdateBusy("app"); !busy { t.Error("a restore op in flight must make an update wait") } } // "A hold that only one path honours is not a hold." The nightly legs are unattended start paths // (DumpAppVolumesSafe ends in StartStack) and writers of the restore point the hold text names. // // COMPANION RED-PROOF (REPORT.md): delete the isHeld skip from runVolumeDumps — the held app is then // stopped (and restarted) by the nightly backup, and this test fails. func TestSlice4_NightlyLegsLeaveAHeldAppAlone(t *testing.T) { h := newAdmissionHarness(t, "held", "free") h.m.settings = slice4Settings(t) if err := h.m.HoldAfterFailedUpdate("held", time.Now(), time.Now(), UpdateTierSecondDrive); err != nil { t.Fatal(err) } h.m.runVolumeDumps() for _, n := range append(append([]string{}, h.volDumped...), h.prov.stopped...) { if n == "held" { t.Fatalf("the nightly volume dump touched a HELD app (dumped=%v stopped=%v)", h.volDumped, h.prov.stopped) } } if len(h.volDumped) != 1 || h.volDumped[0] != "free" { t.Errorf("positive control: the unheld app must still be dumped, got %v", h.volDumped) } h.m.captureAllRecoveryUnits() for _, n := range h.prov.infoHits { if n == "held" { t.Error("the capture must not rewrite a HELD app's restore point") } } var mirrored []string h.m.perAppTier2 = func(name string) error { mirrored = append(mirrored, name); return nil } h.m.RunAllTier2() for _, n := range mirrored { if n == "held" { t.Error("Tier 2 must not mirror over a HELD app's copy") } } if len(mirrored) != 1 { t.Errorf("positive control: the unheld app must still be mirrored, got %v", mirrored) } } // v0.238.1 — the gap Scenario F found live: during the update's health wait the app is not yet held, // and the periodic capture wrote the never-started new definition into its primary unit. An app a // guarded update is moving must be left alone by all three nightly legs, exactly like a held one. // // COMPANION RED-PROOF (REPORT.md): delete the updatingCheck clause from isHeld — the updating app is // then dumped, captured and mirrored, and this test fails. func TestSlice4_NightlyLegsLeaveAnAppMidUpdateAlone(t *testing.T) { h := newAdmissionHarness(t, "updating", "free") h.m.settings = slice4Settings(t) h.m.SetUpdatingCheck(func(name string) bool { return name == "updating" }) h.m.runVolumeDumps() h.m.captureAllRecoveryUnits() var mirrored []string h.m.perAppTier2 = func(name string) error { mirrored = append(mirrored, name); return nil } h.m.RunAllTier2() for _, list := range [][]string{h.volDumped, h.prov.stopped, h.prov.infoHits, mirrored} { for _, n := range list { if n == "updating" { t.Fatalf("a nightly leg touched an app MID-UPDATE (dumped=%v stopped=%v captured=%v mirrored=%v)", h.volDumped, h.prov.stopped, h.prov.infoHits, mirrored) } } } if len(mirrored) != 1 || mirrored[0] != "free" { t.Errorf("positive control: the app not being updated must still be mirrored, got %v", mirrored) } }