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)
}
}