package web import ( "io" "log" "net/http/httptest" "os" "path/filepath" "strings" "syscall" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // D25 (operator ruling 2026-10-21): the restore page lists kept second-drive copies with „Eltávolítás" — and shows no // section at all when there are none. Render test per branch of the {{if .KeptCopies}} gate (the seam-wiring rule). func TestD25_RestorePageListsKeptCopiesWithRemove(t *testing.T) { base := func() map[string]interface{} { d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") d["Backup"] = i18nBackupStatus(false, false) d["OffboxConfigured"] = true d["OffsiteStoreState"] = "known" return d } with := base() with["KeptCopies"] = []keptCopyRow{{ID: "0123456789abcdef", Source: "csaladi_fotok", Line: "a(z) hdd_3 meghajtóról megőrzött másolat · a meghajtó nincs csatlakoztatva", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}} html := renderBackupPage(t, "backups_restore", with) for _, want := range []string{"Megőrzött másolatok", "csaladi_fotok", "a(z) hdd_3 meghajtóról megőrzött másolat", `action="/backup/kept/remove"`, `name="id" value="0123456789abcdef"`, `name="confirm" value="1"`, "Eltávolítás", "confirmRemoveKeptCopy(this)"} { if !strings.Contains(html, want) { t.Errorf("restore page with a kept copy lacks %q", want) } } if html := renderBackupPage(t, "backups_restore", base()); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "/backup/kept/remove") { t.Error("restore page with NO kept copy shows the kept-copies section") } } // „Eltávolítás" through the handler: no confirm → refused, nothing removed; an unknown id → its own refusal; the // recorded copy → removed, and ONLY it. Real settings + manager + disk (the record is the only selector). func TestD25_RemoveHandlerRemovesOnlyTheRecordedCopy(t *testing.T) { 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) } destBase := filepath.Join(tmp, "hdd_1", "backups", "secondary", "_shares") kept := filepath.Join(destBase, "hdd_3", "csaladi_fotok") other := filepath.Join(destBase, "hdd_2", "b_share") for _, d := range []string{kept, other} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(d, "f.txt"), []byte("x"), 0o644); err != nil { t.Fatal(err) } } st, _ := os.Stat(kept) sys := st.Sys().(*syscall.Stat_t) if err := sett.UpsertKeptCopy(settings.KeptCopy{Kind: "share", Source: "csaladi_fotok", Drive: "hdd_3", Reason: "drive_away", DestBase: destBase, Path: kept, Dev: uint64(sys.Dev), Ino: sys.Ino}); err != nil { t.Fatal(err) } id := settings.KeptCopyID(kept) cfg := &config.Config{} cfg.Paths.DataDir = tmp s := &Server{cfg: cfg, backupMgr: backup.NewManager(cfg, sett, lg), logger: lg} post := func(form string) string { rr := httptest.NewRecorder() req := httptest.NewRequest("POST", "/backup/kept/remove", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.keptCopyRemoveHandler(rr, req) return rr.Header().Get("Location") } if loc := post("id=" + id); !strings.Contains(loc, "flash.kept.needs_confirmation") { t.Fatalf("no confirm → %q", loc) } if _, err := os.Stat(kept); err != nil { t.Fatal("removed without a confirm") } if loc := post("id=ffffffffffffffff&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") { t.Fatalf("unknown id → %q", loc) } if loc := post("id=" + id + "&confirm=1"); !strings.Contains(loc, "flash.kept.removed") { t.Fatalf("recorded copy → %q", loc) } if _, err := os.Stat(kept); !os.IsNotExist(err) { t.Fatalf("the kept copy is still there: %v", err) } if _, err := os.Stat(filepath.Join(other, "f.txt")); err != nil { t.Fatalf("another copy was touched: %v", err) } } // D47 (2026-10-25): a set-aside unit of an earlier install is a kept copy with „Visszaállítás" beside „Eltávolítás"; // a second-drive copy is not restorable from this list (render test per branch of {{if .Restorable}}). func TestD47_RestorePageOffersRestoreOnlyForASetAsideUnit(t *testing.T) { d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből") d["Backup"] = i18nBackupStatus(false, false) d["OffboxConfigured"] = true d["OffsiteStoreState"] = "known" d["KeptCopies"] = []keptCopyRow{ {ID: "aaaaaaaaaaaaaaaa", Source: "tandoor", Line: "a(z) belso meghajtóról megőrzött másolat · egy korábbi telepítés mentése (újratelepítés előtt készült)", SizeHuman: "150 MB", Since: "2026-10-25T08:00:00Z", Restorable: true}, {ID: "bbbbbbbbbbbbbbbb", Source: "csaladi_fotok", Line: "a(z) hdd_3 meghajtóról megőrzött másolat · a meghajtó nincs csatlakoztatva", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}, } d["KeptUnitRestorable"] = true html := renderBackupPage(t, "backups_restore", d) if n := strings.Count(html, `action="/backup/kept/restore"`); n != 1 { t.Fatalf("exactly the set-aside unit's row offers the restore, found %d", n) } i := strings.Index(html, `action="/backup/kept/restore"`) if !strings.Contains(html[i:i+300], `value="aaaaaaaaaaaaaaaa"`) || !strings.Contains(html, "confirmRestoreKeptUnit(this)") { t.Fatal("the restore form must name the set-aside unit's record and ask first") } if strings.Count(html, `action="/backup/kept/remove"`) != 2 { t.Fatal("both rows keep the remove button") } } // The restore handler names a set-aside unit ONLY by its record: no confirm → refused; an unknown id → not found; a // second-drive copy's id is not a unit → not found (nothing starts). func TestD47_KeptUnitRestoreHandlerRefusals(t *testing.T) { 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) } share := filepath.Join(tmp, "hdd_1", "backups", "secondary", "_shares", "hdd_3", "csaladi_fotok") if err := os.MkdirAll(share, 0o755); err != nil { t.Fatal(err) } st, _ := os.Stat(share) sys := st.Sys().(*syscall.Stat_t) if err := sett.UpsertKeptCopy(settings.KeptCopy{Kind: "share", Source: "csaladi_fotok", Drive: "hdd_3", Reason: "drive_away", DestBase: filepath.Dir(filepath.Dir(share)), Path: share, Dev: uint64(sys.Dev), Ino: sys.Ino}); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = tmp s := &Server{cfg: cfg, backupMgr: backup.NewManager(cfg, sett, lg), logger: lg} post := func(form string) string { rr := httptest.NewRecorder() req := httptest.NewRequest("POST", "/backup/kept/restore", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.keptUnitRestoreHandler(rr, req) return rr.Header().Get("Location") } if loc := post("id=x"); !strings.Contains(loc, "flash.kept.restore_needs_confirmation") { t.Fatalf("no confirm → %q", loc) } if loc := post("id=ffffffffffffffff&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") { t.Fatalf("unknown id → %q", loc) } if loc := post("id=" + settings.KeptCopyID(share) + "&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") { t.Fatalf("a second-drive copy is not a restorable unit → %q", loc) } if s.backupMgr.RestoreStatus().Running { t.Fatal("a refusal must start nothing") } }