package backup import ( "context" "errors" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // R-659 (v0.268.0; operator ruling 2026-09-24, `09` §3 decision 25, option A) — a held app's sentence // names only a copy that brings the app back WHOLE; with none, it says so and names nothing. // // Measured 2026-09-24 00:00 on 9202, chaos round 11: nextcloud (drive files declared), own unit the only // copy; the hold named „saját meghajtó"; the restore of exactly that copy REFUSED (R-538). var r659At = time.Date(2026, 9, 23, 21, 42, 39, 0, time.UTC) // r659Manager: one app; `files` declares a mandatory drive leg (nextcloud's class). `tiers` are the // copies present, each at its own time. func r659Manager(t *testing.T, files bool, tiers map[int]time.Time) *Manager { t.Helper() drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["nextcloud"] = drive if files { prov.binds["nextcloud"] = []ClassifiedBind{mandatoryHDD("appdata/nextcloud")} prov.has["nextcloud"] = true } m.updateTier2PointFn = func(string) (Tier2RestorePoint, error) { at, ok := tiers[UpdateTierSecondDrive] if !ok { return Tier2RestorePoint{}, errors.New("no Tier-2 copy") } return Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: at.Format(time.RFC3339), CopyDate: at.Format(time.RFC3339)}, nil } m.updateTier1PointsFn = func(string) ([]RestorePoint, bool) { at, ok := tiers[UpdateTierLocal] if !ok { return nil, false } return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1}}, true } m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) { at, ok := tiers[UpdateTierOffsite] if !ok { return map[string]time.Time{}, nil } return map[string]time.Time{"nextcloud": at}, nil } return m } // TestR659_TheHoldNamesOnlyAWholeCopy — app class × copies present. // // COMPANION RED-PROOF (REPORT.md): make WholeOnTier answer true for every tier (the v0.267.0 reading: // any copy is a route back). The round-11 case then fails at "names „saját meghajtó"". func TestR659_TheHoldNamesOnlyAWholeCopy(t *testing.T) { unit := r659At.Add(-2 * time.Hour) second := r659At.Add(-20 * time.Hour) off := r659At.Add(-5 * time.Hour) noWhole := util.Text("hu", "hold.update.no_whole_copy", "nextcloud") cases := []struct { name string files bool tiers map[int]time.Time wantNone bool wantLabel string // the tier label the sentence must name (hu) }{ {"files / unit only (round 11)", true, map[int]time.Time{UpdateTierLocal: unit}, true, ""}, {"files / second drive only", true, map[int]time.Time{UpdateTierSecondDrive: second}, true, ""}, {"files / unit + second drive", true, map[int]time.Time{UpdateTierLocal: unit, UpdateTierSecondDrive: second}, true, ""}, {"files / off-site + unit", true, map[int]time.Time{UpdateTierLocal: unit, UpdateTierOffsite: off}, false, "távoli mentés"}, {"files / none", true, map[int]time.Time{}, true, ""}, {"volumes / unit only", false, map[int]time.Time{UpdateTierLocal: unit}, false, "saját meghajtó"}, {"volumes / second drive older than unit", false, map[int]time.Time{UpdateTierLocal: unit, UpdateTierSecondDrive: second}, false, "saját meghajtó"}, {"volumes / off-site newest", false, map[int]time.Time{UpdateTierSecondDrive: second, UpdateTierOffsite: off}, false, "távoli mentés"}, {"volumes / none", false, map[int]time.Time{}, true, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { m := r659Manager(t, c.files, c.tiers) none, err := m.HoldAfterFailedUpdateWhole(context.Background(), "nextcloud", r659At, "untouched") if err != nil { t.Fatal(err) } held, why := m.RestoreHoldForLang("nextcloud", "hu") if !held { t.Fatal("not held") } if c.wantNone { if !none || why != noWhole { t.Fatalf("no whole copy exists, yet the hold names %q (none=%v)", why, none) } if !m.HoldNoWholeCopy("nextcloud") { t.Fatal("the page flag is not set — the restore button would stay") } h, _ := m.UpdateHold("nextcloud") if len(h.CopiesSeen) != len(c.tiers) { t.Fatalf("copies seen %v, want %d recorded for support", h.CopiesSeen, len(c.tiers)) } return } if none || strings.Contains(why, "nincs olyan másolat") { t.Fatalf("a whole copy exists, yet the hold says none: %q", why) } if !strings.Contains(why, c.wantLabel) { t.Fatalf("the hold names %q, want the copy %q", why, c.wantLabel) } if m.HoldNoWholeCopy("nextcloud") { t.Fatal("the page flag is set over a whole copy") } }) } } // The English sentence is the operator's copy, verbatim, and the Hungarian likewise. func TestR659_SentenceBothLanguages(t *testing.T) { m := r659Manager(t, true, map[int]time.Time{UpdateTierLocal: r659At.Add(-time.Hour)}) if _, err := m.HoldAfterFailedUpdateWhole(context.Background(), "nextcloud", r659At, "untouched"); err != nil { t.Fatal(err) } _, en := m.RestoreHoldForLang("nextcloud", "en") if en != "The update of nextcloud did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart or remove the app." { t.Fatalf("en = %q", en) } _, hu := m.RestoreHoldForLang("nextcloud", "hu") if hu != "A(z) nextcloud frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — kérjük, addig ne indítsa újra és ne törölje az alkalmazást." { t.Fatalf("hu = %q", hu) } } // TestR659_TruthTableAgreesWithTheRestoresRefusal — WholeOnTier for the unit tiers must be exactly // "the unit restore does not refuse for missing files". The two predicates cannot drift. func TestR659_TruthTableAgreesWithTheRestoresRefusal(t *testing.T) { for _, files := range []bool{true, false} { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["nextcloud"] = drive if files { prov.binds["nextcloud"] = []ClassifiedBind{mandatoryHDD("appdata/nextcloud")} prov.has["nextcloud"] = true } mkUnit(t, drive, "nextcloud") _, err := m.RestoreFromRecoveryUnitAt("nextcloud", RecoveryUnitPath(drive, "nextcloud")) var refusal *ErrUnitLacksFileLegs refused := errors.As(err, &refusal) for _, tier := range []int{UpdateTierLocal, UpdateTierSecondDrive} { if m.WholeOnTier("nextcloud", tier) == refused { t.Fatalf("files=%v tier %d: WholeOnTier=%v but the unit restore refused=%v — the page would send a household to a refusal", files, tier, m.WholeOnTier("nextcloud", tier), refused) } } if !m.WholeOnTier("nextcloud", UpdateTierOffsite) { t.Fatal("the off-site full restore brings files and database back — it is whole") } } }