package web import ( "encoding/json" "html/template" "io" "log" "net/http" "net/http/httptest" "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/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // D62 (operator ruling 2026-10-29): restore copies in the kept-copies list with „Törlés most". Real settings, manager, // disk and a hub stand-in that records the event — the handler is the endpoint the page's form calls. type d62Fixture struct { s *Server sett *settings.Settings nsRoot string events chan map[string]interface{} } func newD62Fixture(t *testing.T) *d62Fixture { t.Helper() tmp := t.TempDir() lg := log.New(io.Discard, "", 0) sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = tmp cfg.Paths.SystemDataPath = filepath.Join(tmp, "sys") events := make(chan map[string]interface{}, 8) hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/event" { var ev map[string]interface{} _ = json.NewDecoder(r.Body).Decode(&ev) events <- ev } w.WriteHeader(http.StatusOK) })) t.Cleanup(hub.Close) n := notify.New(hub.URL, "test-key", "c1", sett, lg, false) s := &Server{cfg: cfg, settings: sett, backupMgr: backup.NewManager(cfg, sett, lg), notifier: n, logger: lg} return &d62Fixture{s: s, sett: sett, nsRoot: backup.NamespaceRootFor(cfg.Paths.SystemDataPath, cfg.Paths.SystemDataPath), events: events} } // plant writes one restore copy of app taken at `at`; returns its folder. func (f *d62Fixture) plant(t *testing.T, app string, at time.Time) string { t.Helper() dir := filepath.Join(backup.RestoreUndoDir(f.nsRoot, app), at.UTC().Format("20060102T150405Z")) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, app+"_data.tar"), []byte("LIVE-DATA"), 0o600); err != nil { t.Fatal(err) } return dir } func (f *d62Fixture) post(form string) (int, map[string]interface{}) { rr := httptest.NewRecorder() req := httptest.NewRequest("POST", "/backup/restore-copy/delete", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "192.168.0.5:5555" f.s.restoreCopyDeleteHandler(rr, req) var body map[string]interface{} _ = json.Unmarshal(rr.Body.Bytes(), &body) return rr.Code, body } func exists(p string) bool { _, err := os.Stat(p); return err == nil } // The list shows the restore's date, the size and „automatikusan törlődik: <30 days later>"; a held copy shows why and // offers no warning. RED-PROOF: drop the auto-delete part from restoreCopyRows → FAILS. func TestD62_TheListShowsTheDateAndTheAutoDeleteDay(t *testing.T) { f := newD62Fixture(t) at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC) f.plant(t, "vikunja", at) f.plant(t, "immich", at) if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: at.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil { t.Fatal(err) } rows := f.s.restoreCopyRows("hu") if len(rows) != 2 { t.Fatalf("rows %+v", rows) } var free, held restoreCopyRow for _, r := range rows { if r.App == "vikunja" { free = r } else { held = r } } for _, want := range []string{"visszaállítás: 2026. 10. 29. 09:00", "9 B", "automatikusan törlődik: 2026. 11. 28."} { if !strings.Contains(free.Line, want) { t.Errorf("free row line %q lacks %q", free.Line, want) } } if !strings.Contains(free.Warning, "Ez a(z) vikunja adatainak másolata a 2026. 10. 29-i visszaállítás előttről") || !strings.Contains(free.Warning, "a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza") { t.Errorf("warning %q", free.Warning) } if free.NeededText != "" || free.TypePrompt == "" { t.Errorf("free row: needed %q prompt %q", free.NeededText, free.TypePrompt) } if held.NeededText == "" || held.Warning != "" || strings.Contains(held.Line, "automatikusan") { t.Errorf("held row offers a delete or an auto-delete day: %+v", held) } en := f.s.restoreCopyRows("en") for _, r := range en { if r.App == "vikunja" && (!strings.Contains(r.Line, "automatically deleted on 2026-11-28") || !strings.Contains(r.Warning, "This is the copy of vikunja’s data from before the restore on 2026-10-29. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.")) { t.Errorf("English row %+v", r) } } } // The page: a deletable copy has the form (app, stamp, the typed name, CSRF); a held one has the button OFF and the // reason, and no form. RED-PROOF: render the form for a needed copy too → FAILS. func TestD62_ThePageOffersTheFormOnlyForADeletableCopy(t *testing.T) { d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") d["Backup"] = i18nBackupStatus(false, false) d["RestoreCopies"] = []restoreCopyRow{ {App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "L1", Warning: "W1", TypePrompt: "P1"}, {App: "immich", Stamp: "20261020T080000Z", Name: "Immich", Line: "L2", NeededText: "HELD-REASON"}, } d["CSRFField"] = template.HTML(``) html := renderBackupPage(t, "backups_restore", d) for _, want := range []string{"Visszaállítás előtti másolatok", `action="/backup/restore-copy/delete"`, `name="app" value="vikunja"`, `name="stamp" value="20261029T080000Z"`, `name="confirm"`, "W1", "P1", "Törlés most", `data-restore-copy-off="true"`, "HELD-REASON"} { if !strings.Contains(html, want) { t.Errorf("page lacks %q", want) } } if strings.Contains(html, `name="app" value="immich"`) || strings.Contains(html, `data-restore-copy="immich/`) { t.Error("a held copy got a delete form") } form := html[strings.Index(html, `action="/backup/restore-copy/delete"`):] if !strings.Contains(form[:strings.Index(form, "")], `value="TOKEN-D62"`) { t.Error("the delete form carries no CSRF field") } none := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") none["Backup"] = i18nBackupStatus(false, false) if html := renderBackupPage(t, "backups_restore", none); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "restore-copy") { t.Error("a page with no copy shows the section") } } // A wrong typed name is refused (400) and nothing is deleted. RED-PROOF: drop the name check → FAILS. func TestD62_AWrongNameIsRefused(t *testing.T) { f := newD62Fixture(t) c := f.plant(t, "vikunja", time.Now().Add(-time.Hour)) for _, typed := range []string{"", "vikunj", "immich", "VIKUNJA "} { code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=" + typed) if code != http.StatusBadRequest || body["ok"] != false || !exists(c) { t.Fatalf("typed %q: code %d body %v exists %v", typed, code, body, exists(c)) } } } // A held copy is refused by the handler too (409, the reason), and stays. RED-PROOF: drop the Needed check in the // handler AND in DeleteRestoreCopy → FAILS (either alone holds it). func TestD62_AHeldCopyIsRefusedByTheHandler(t *testing.T) { f := newD62Fixture(t) c := f.plant(t, "immich", time.Now().Add(-time.Hour)) if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: time.Now().Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil { t.Fatal(err) } code, body := f.post("app=immich&stamp=" + filepath.Base(c) + "&confirm=immich") if code != http.StatusConflict || !exists(c) || !strings.Contains(body["error"].(string), "félbeszakadt visszaállítás") { t.Fatalf("code %d body %v exists %v", code, body, exists(c)) } } // While a restore runs, a delete is 409 with a sentence, and nothing moves. RED-PROOF: drop the busy check → FAILS. func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) { f := newD62Fixture(t) c := f.plant(t, "vikunja", time.Now().Add(-time.Hour)) f.s.backupMgr.BeginRestoreOp("restore", "other") code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja") if code != http.StatusConflict || !exists(c) || body["error"] == "" { t.Fatalf("code %d body %v exists %v", code, body, exists(c)) } f.s.backupMgr.EndRestoreOp(true, "ok") } // The right name: the copy is gone, ONLY it (the app's other copy and another app's copy stay), the answer leads back // to the page with the flash, and the operator event carries who, app, the restore's date and size. // RED-PROOF: drop the NotifyRestoreCopyDeleted call → no event → FAILS. func TestD62_ADeleteRemovesOnlyThatCopyAndStoresTheEvent(t *testing.T) { f := newD62Fixture(t) at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC) c := f.plant(t, "vikunja", at) sibling := f.plant(t, "vikunja", at.Add(time.Hour)) other := f.plant(t, "immich", at) code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja") if code != http.StatusOK || body["ok"] != true || !strings.Contains(body["redirect"].(string), "flash.restore_copy.deleted") { t.Fatalf("code %d body %v", code, body) } if exists(c) || !exists(sibling) || !exists(other) { t.Fatalf("gone=%v sibling=%v other=%v", !exists(c), exists(sibling), exists(other)) } select { case ev := <-f.events: if ev["event_type"] != "restore_copy_deleted" || ev["severity"] != "info" { t.Fatalf("event %v", ev) } det, _ := json.Marshal(ev["details"]) for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":9`, `"who":"the household (dashboard sign-in) from 192.168.0.5"`} { if !strings.Contains(string(det), want) { t.Errorf("details %s lack %s", det, want) } } case <-time.After(5 * time.Second): t.Fatal("no restore_copy_deleted event reached the hub") } // a second press: the copy is gone → 404, no second event if code, _ := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja"); code != http.StatusNotFound { t.Fatalf("second press code %d", code) } }