package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-103 Group D — the surface: the refusal becomes an action. // // Before v0.229.0 an app whose Tier-2 copy held only a recovery unit got a message telling it to // press a button on a DIFFERENT page. The data it was asking for is in the copy it is looking at, and // since R-102 it is restorable from there. The action now lives where the refusal was. // // Every string assertion here compares against the NAMED CONSTANT rather than a Hungarian literal // retyped in the test. That is R-364 discipline in its strongest form: a grep for accented text // returned zero for strings that were present, and a re-typed literal can differ from the shipped one // by a character nobody sees. // --- the surface (template) ----------------------------------------------------------------- // r103Row is a Tier-2-configured row with a successful copy — the shape the template needs before it // will render the actions block at all. func r103Row(t *testing.T, unitRestorable bool, date string, proven bool) AppBackupRow { row := AppBackupRow{ StackName: "docmost", DisplayName: "Docmost", Tier2Configured: true, Tier2Dest: "flash", Tier2Schedule: "Naponta", Tier2LastRun: date, Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres", Tier2LastSuccess: date, Tier2SuccessTracked: true, Tier2UnitRestorable: unitRestorable, } if unitRestorable { row.Tier2CopyDate, row.Tier2CopyDateProven = date, proven row.Tier2UnitConfirm = noteServer(t).tier2UnitConfirmMsg(date, proven) } return row } // D1 — TestR103_UnitActionOfferedWhenTheMirrorExists. func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) { html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ r103Row(t, true, "2026-08-25T03:30:00Z", true), })) if !strings.Contains(html, `action="/backup/tier2/unit-restore"`) { t.Error("the unit-restore form is not on the page — the refusal is still a dead end") } if !strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) { t.Errorf("the action is not labelled %q", noteHU(t, tier2UnitActionLabelKey)) } // The additive action must still be there, separately. Merging them is forbidden: they are // different promises (one adds, one overwrites). if !strings.Contains(html, `action="/backup/tier2/restore"`) { t.Error("the additive file restore disappeared from the row") } // The destructive one is visually distinct from the additive one beside it. if !strings.Contains(html, "btn-danger-outline") { t.Error("the destructive action does not carry a danger style") } } // D2 — TestR103_UnitActionAbsentWhenItDoesNot. Scenario G: no legs and no openable unit → an honest // refusal, and NO unit action. A button offered over a copy the restore would refuse is worse than no // button, because it is a promise withdrawn at the moment of use. func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) { html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ r103Row(t, false, "2026-08-25T03:30:00Z", true), })) if strings.Contains(html, "/backup/tier2/unit-restore") { t.Error("the unit action was offered for a copy with no openable unit") } if strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) { t.Error("the unit action's label leaked onto a row that must not offer it") } // NEGATIVE CONTROL for D1's assertions: the row itself did render, so D1's positives were not // an artefact of the whole block being absent. if !strings.Contains(html, `action="/backup/tier2/restore"`) { t.Fatal("the row did not render at all — D1's positive assertions prove nothing") } } // D3 — TestR103_ConfirmStatesTheOverwrite. The confirm must carry the DIFFERENCE the register // requires: a destructive operation reached from a non-destructive surface says so, and says how it // differs from the action beside it. func TestR103_ConfirmStatesTheOverwrite(t *testing.T) { confirm := noteServer(t).tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true) if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmBaseKey)) { t.Error("the confirm does not state that the operation OVERWRITES live data") } if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmContrastKey)) { t.Error("the confirm does not state how it differs from the additive restore beside it") } // NEGATIVE CONTROL: the additive restore's own confirm must NOT have acquired this language. // Without it, a test that passed because both buttons warn about overwriting would look green. html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ r103Row(t, true, "2026-08-25T03:30:00Z", true), })) fileConfirmStart := strings.Index(html, "Visszaállítja a hiányzó fájlokat") if fileConfirmStart < 0 { t.Fatal("the additive confirm is gone — the negative control has nothing to check") } fileConfirm := html[fileConfirmStart:] if end := strings.Index(fileConfirm, `">`); end > 0 { fileConfirm = fileConfirm[:end] } if strings.Contains(fileConfirm, "FEL") { t.Errorf("the ADDITIVE confirm gained overwrite language: %q", fileConfirm) } // And the confirm reaches the markup as the rendered attribute, not only as a Go constant. if !strings.Contains(html, `data-confirm=`) { t.Error("no data-confirm attribute rendered") } if !strings.Contains(html, "FEL") { t.Error("the destructive wording never reached the page") } } // D4 — TestR103_ConfirmNamesTheCopyDate. Scenario E. The action overwrites live data with a copy of a // certain age, and „nobody restores last week over today by accident" is only true if the date is on // the screen. R-101 governs the wording when the date is an ATTEMPT rather than a proven copy. func TestR103_ConfirmNamesTheCopyDate(t *testing.T) { const stamp = "2026-08-25T03:30:00Z" rendered := fmtRFC3339Local(stamp) if rendered == stamp { t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous") } proven := noteServer(t).tier2UnitConfirmMsg(stamp, true) if !strings.Contains(proven, rendered) { t.Errorf("the confirm does not name the copy's date: %q", proven) } unproven := noteServer(t).tier2UnitConfirmMsg(stamp, false) if !strings.Contains(unproven, rendered) { t.Errorf("the unproven confirm does not name the date: %q", unproven) } if proven == unproven { t.Error("a proven copy and a bare attempt got the SAME sentence — R-101 exactly") } // A copy with no recorded date still gets the warning; it just cannot name one. none := noteServer(t).tier2UnitConfirmMsg("", false) if !strings.Contains(none, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(none, noteHU(t, tier2UnitConfirmContrastKey)) { t.Errorf("a dateless copy lost its confirm entirely: %q", none) } // And it is on the page, not merely constructible. html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(t, true, stamp, true)})) if !strings.Contains(html, rendered) { t.Errorf("the copy's date %q is not in the rendered row", rendered) } } // TestR103_AvailableMsgNamesTheButtonByItsLabel — the refusal that now points AT the new action must // name it by the label the button actually carries, or it points at nothing. func TestR103_AvailableMsgNamesTheButtonByItsLabel(t *testing.T) { if !strings.Contains(noteHU(t, tier2UnitAvailableKey), noteHU(t, tier2UnitActionLabelKey)) { t.Errorf("noteHU(t, tier2UnitAvailableKey) does not name %q", noteHU(t, tier2UnitActionLabelKey)) } // It must NOT send anyone to another page any more; that is the R-103 defect it replaces. if strings.Contains(noteHU(t, tier2UnitAvailableKey), "oldalon") { t.Error("the message still routes the customer to another page for a copy restorable here") } // The surviving no-coverage message still names the route that works. if !strings.Contains(noteHU(t, tier2NoCoverageKey), "oldalon") { t.Error("noteHU(t, tier2NoCoverageKey) no longer names any route — Scenario G requires one") } // C3 — the not-covered sentence is NOT deleted: it is still appended where the FILE restore ran. // Since v0.254.0 it is a SAVED note written in the box's language, so the key is what the source // names and the bundle is what carries the sentence; both halves are checked. if noteHU(t, tier2UnitNotCoveredKey) == "" { t.Fatal("the not-covered sentence was deleted from the bundle") } if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + s.note(tier2UnitNotCoveredKey)`) { t.Error("the not-covered sentence is no longer appended by the file-restore handler") } } func readSourceFile(t *testing.T, name string) string { t.Helper() b, err := os.ReadFile(name) if err != nil { t.Fatal(err) } return string(b) } // --- the handler ---------------------------------------------------------------------------- // r103Provider is a StackDataProvider that completes every lifecycle call, so the restore goroutine // runs to its outcome instead of parking. type r103Provider struct{ hdd string } func (p *r103Provider) GetStackComposePath(string) (string, bool) { return "", false } func (p *r103Provider) ListDeployedStacks() []backup.StackSummary { return nil } func (p *r103Provider) GetStackHDDMounts(string) []string { return nil } func (p *r103Provider) GetStackHDDPath(string) string { return p.hdd } func (p *r103Provider) GetImportRoot() string { return "" } func (p *r103Provider) GetDockerVolumes(string) []string { return nil } func (p *r103Provider) StopStack(string) error { return nil } func (p *r103Provider) StartStack(string) error { return nil } func (p *r103Provider) RefreshAndIsRunning(string) bool { return true } func (p *r103Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { return backup.RecoveryInfo{}, false } func (p *r103Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) { return nil, false } func (p *r103Provider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *r103Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error { return nil } func (p *r103Provider) StartStackServices(string, []string) error { return nil } const r103CopyStamp = "2026-08-25T03:30:00Z" // newR103Server wires a Server over a real backup.Manager whose recorded Tier-2 copy for "app" holds // an OPENABLE recovery unit mirror. Nothing is stubbed between the handler and the manager: the whole // point of D6 is that the wiring is what is under test. func newR103Server(t *testing.T, withUnit bool) (*Server, *backup.Manager) { t.Helper() tmp := t.TempDir() live := filepath.Join(tmp, "usb") dest := filepath.Join(tmp, "flash") lg := log.New(io.Discard, "", 0) sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) if err != nil { t.Fatal(err) } for _, p := range []string{live, dest} { if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil { t.Fatal(err) } } if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", DestinationPath: dest, LastRun: r103CopyStamp, LastSuccess: r103CopyStamp, SuccessTracked: true, LastStatus: "ok", }); err != nil { t.Fatal(err) } destBase := filepath.Join(dest, "backups", "secondary", "app") write := func(rel, body string) { p := filepath.Join(destBase, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } } write(".felhom-tier2-layout", "2") if withUnit { write("recovery-unit/manifest.json", `{"schema_version":1,"app_name":"app"}`) write("recovery-unit/compose/app.yaml", "deployed: true\nenv:\n SUBDOMAIN: app\n") write("recovery-unit/compose/docker-compose.yml", "services:\n app:\n image: example/app:1\n") } else { // A directory that exists and is not a package — the fail-closed case. if err := os.MkdirAll(filepath.Join(destBase, "recovery-unit"), 0o755); err != nil { t.Fatal(err) } } cfg := &config.Config{} cfg.Paths.DataDir = tmp m := backup.NewManager(cfg, sett, lg) m.SetStackProvider(&r103Provider{hdd: live}) return &Server{cfg: cfg, backupMgr: m, logger: lg}, m } func postTier2UnitRestore(t *testing.T, s *Server, stack string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"stack_name": {stack}} req := httptest.NewRequest(http.MethodPost, "/backup/tier2/unit-restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() s.backupTier2UnitRestoreHandler(rec, req) return rec } func waitRestoreDone(t *testing.T, m *backup.Manager) backup.RestoreOpStatus { t.Helper() deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { st := m.RestoreStatus() if !st.Running && st.Last != nil { return st } time.Sleep(50 * time.Millisecond) } t.Fatal("the restore never published a result") return backup.RestoreOpStatus{} } // D5 — TestR103_SecondPressIsRefused. Scenario H, R-351b. The concurrency flag is only taken inside // the goroutine, AFTER the handler returns, so a guard reading IsRunning() alone leaves a window in // which a second press starts a second run and is told „elindult". func TestR103_SecondPressIsRefused(t *testing.T) { s, m := newR103Server(t, true) m.BeginRestoreOp("restore", "other-app") // a restore is already in flight, display-flag set rec := postTier2UnitRestore(t, s, "app") loc := rec.Header().Get("Location") if !strings.Contains(loc, "flash_error") { t.Fatalf("a second press was accepted: %q", loc) } // The identity of the FIRST operation survives — the consequence, not which flag was read. if st := m.RestoreStatus(); st.Stack != "other-app" { t.Errorf("the in-flight op was replaced by the refused one: %+v", st) } } // D6 — TestR103_HandlerPublishesTheOutcome. THE SEAM TEST. // // It reads the outcome off RestoreStatus().Last.Message — the only place a customer sees it — rather // than calling the manager and inspecting a return value. Three shipped defects in this project came // from testing a component whose caller never invoked it (R-106's fakeObserver being the clearest), // and the mechanism here is exactly that shape: a correct RestoreTier2Unit wired to nothing would // leave the banner silent and every manager-level test green. // // Red-proof (recorded in REPORT.md): drop the `s.backupMgr.EndRestoreOp(true, msg)` call at the end // of the handler's goroutine → this test fails on "the restore never published a result". func TestR103_HandlerPublishesTheOutcome(t *testing.T) { s, m := newR103Server(t, true) rec := postTier2UnitRestore(t, s, "app") if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302", rec.Code) } if loc := rec.Header().Get("Location"); strings.Contains(loc, "flash_error") { t.Fatalf("the restore was refused: %q", loc) } st := waitRestoreDone(t, m) if !st.Last.OK { t.Fatalf("outcome reported failure: %q", st.Last.Message) } if st.Last.Stack != "app" || st.Last.Op != "tier2-unit-restore" { t.Errorf("the published result names the wrong operation: op=%q stack=%q", st.Last.Op, st.Last.Stack) } // The sentence is yesterday's honest outcome PLUS the clause naming which copy overwrote the live // data (Scenario E). Asserted as an exact composition so neither half can silently vanish. wantOutcome := noteServer(t).unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{}) wantSource := noteServer(t).tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp}) if wantSource == "" { t.Fatal("the fixture recorded no copy date — the assertion below would be vacuous") } if want := wantOutcome + " " + wantSource; st.Last.Message != want { t.Errorf("published message =\n %q\nwant\n %q", st.Last.Message, want) } if !strings.Contains(st.Last.Message, fmtRFC3339Local(r103CopyStamp)) { t.Error("the outcome does not name the date of the copy it restored from") } } // TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping — the handler's fail-closed pre-flight. // A directory is not a package, and refusing before BeginRestoreOp means no banner, no outage and no // rewritten definition. func TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping(t *testing.T) { s, m := newR103Server(t, false) rec := postTier2UnitRestore(t, s, "app") loc := rec.Header().Get("Location") if !strings.Contains(loc, "flash_error") { t.Fatalf("the restore was accepted over an unopenable mirror: %q", loc) } // The flash carries a KEY since v0.252.0; the sentence is what this test is about, so it is // resolved the way the page resolves it. if got := flashSentence(t, loc); got != noteHU(t, tier2NoCoverageKey) { t.Errorf("refusal flash = %q, want the no-coverage sentence", got) } if st := m.RestoreStatus(); st.Running || st.Last != nil { t.Errorf("an operation was begun despite the refusal: %+v", st) } } // TestR103_UnitRestoreHandlerGuards — the same three guards the two restores beside it carry, proven // to run BEFORE any work (backupMgr is nil, so reaching it would panic). func TestR103_UnitRestoreHandlerGuards(t *testing.T) { s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose for name, want := range map[string]string{ "../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", "a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", "": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek", } { rec := postTier2UnitRestore(t, s, name) if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) { t.Errorf("%q: redirect = %q, want flash %q", name, loc, want) } } rec := postTier2UnitRestore(t, s, "docmost") if loc := rec.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "Mentés nincs beállítva") { t.Errorf("nil backupMgr: redirect = %q", loc) } } // TestR103_FileRestoreRefusalPointsAtTheActionThatWorks — the R-103 split. An app with no file legs // but a restorable unit gets the message that names the button beside it, NOT the one that sends it // to another page. func TestR103_FileRestoreRefusalPointsAtTheActionThatWorks(t *testing.T) { s, _ := newR103Server(t, true) rec := postTier2Restore(t, s, "app") loc := rec.Header().Get("Location") if !strings.Contains(loc, url.QueryEscape(noteHU(t, tier2UnitAvailableKey))) { t.Errorf("refusal flash = %q, want noteHU(t, tier2UnitAvailableKey)", loc) } if strings.Contains(loc, url.QueryEscape(noteHU(t, tier2NoCoverageKey))) { t.Error("the old dead-end message is still shown for a copy restorable here") } }