234 lines
10 KiB
Go
234 lines
10 KiB
Go
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)
|
||
}
|
||
}
|