Files
felhom-controller/controller/internal/web/d62_restore_copies_test.go
T

249 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(`<input type="hidden" name="_csrf" value="TOKEN-D62">`)
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, "</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)
}
}
// R-1342: every reason a copy can be needed has its one-line sentence in both languages (the row's key is built from the
// reason, so a missing key would show the raw key). RED-PROOF: remove restore_copy.needed.checking from BOTH bundles →
// FAILS (seen 2026-10-29; a key missing only in en.json falls back to the Hungarian line — i18n_missing_gate counts that).
func TestR1342_EveryNeededReasonHasItsSentence(t *testing.T) {
f := newD62Fixture(t)
for _, why := range []string{backup.RestoreCopyNeededHold, backup.RestoreCopyNeededPutBack, backup.RestoreCopyNeededUnknown, backup.RestoreCopyNeededChecking} {
for _, lang := range []string{"hu", "en"} {
key := "restore_copy.needed." + why
if got := f.s.msgLang(lang, key); got == "" || got == key || strings.Contains(got, "restore_copy.") {
t.Errorf("%s/%s renders %q", lang, key, got)
}
}
}
}