package web import ( "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "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" ) // ── R-358 / R-360 at the HANDLERS ──────────────────────────────────────────────────────────────── // // Both defects are server-side. R-358's part-copy was hidden by a template flag, and a hidden button // is not a guard — these POST directly, which is what a curious customer, a stale tab or a double // submit does anyway. R-360's delete refused only while a BACKUP ran, so it went through during a // restore; that one asserts the CONSEQUENCE (the directory still exists), never the branch. func newR358Server(t *testing.T) (*Server, *backup.Manager, string) { t.Helper() tmp := t.TempDir() lg := log.New(io.Discard, "", 0) drive := filepath.Join(tmp, "drive") if err := os.MkdirAll(drive, 0o755); err != nil { t.Fatal(err) } sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "drive", Schedulable: true}); err != nil { t.Fatal(err) } if err := sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", Port: 22, User: "u", RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed", }); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = tmp m := backup.NewManager(cfg, sett, lg) if err := m.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil { t.Fatal(err) } m.SetStackProvider(&r353Provider{hdd: drive}) s := &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg} return s, m, drive } // plantIncompleteScratch writes the exact shape a failed restic download leaves: files, no marker. func plantIncompleteScratch(t *testing.T, m *backup.Manager, app string) string { t.Helper() scratch := m.OffsiteRestoreScratchPath(app) if scratch == "" { t.Fatal("could not resolve the scratch path") } if err := os.MkdirAll(filepath.Join(scratch, "backups", "primary", app), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(scratch, "backups", "primary", app, "half.tar"), []byte("partial"), 0o644); err != nil { t.Fatal(err) } return scratch } func TestR358_PlaceHandlerRefusesIncompleteScratch(t *testing.T) { s, m, _ := newR358Server(t) plantIncompleteScratch(t, m, "kimai") req := httptest.NewRequest(http.MethodPost, "/backup/offbox/place", strings.NewReader("app=kimai")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.offboxPlaceHandler(w, req) loc := w.Header().Get("Location") if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") { t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q", loc) } if m.RestoreStatus().Running { t.Fatal("the place operation actually STARTED over an incomplete scratch") } } func TestR358_ReconstituteHandlerRefusesIncompleteScratch(t *testing.T) { // The destructive one. On 2026-08-21 „Teljes visszaállítás indítása" was offered over exactly this // state and reported ok=true. s, m, _ := newR358Server(t) plantIncompleteScratch(t, m, "kimai") req := httptest.NewRequest(http.MethodPost, "/backup/offbox/reconstitute", strings.NewReader("app=kimai&confirm=1")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.offboxReconstituteHandler(w, req) loc := w.Header().Get("Location") if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") { t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q", loc) } if m.RestoreStatus().Running { t.Fatal("the destructive restore actually STARTED over an incomplete scratch") } } // TestR360_VerifyCopyDeleteRefusedDuringRestore — Scenario G, asserting the CONSEQUENCE. // // The state is the one observed live on 2026-08-21 22:35 and it is the whole reason the bug existed: // RestoreStatus().Running is TRUE while IsRunning() is FALSE. The old guard read only the second. func TestR360_VerifyCopyDeleteRefusedDuringRestore(t *testing.T) { s, m, drive := newR358Server(t) // A real verification copy on disk, at the path DeleteOffsiteRestoreCopy resolves. copyDir := filepath.Join(drive, "backups", "offsite-restore", "kimai") if err := os.MkdirAll(copyDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(copyDir, "payload.txt"), []byte("the copy a restore is writing into"), 0o644); err != nil { t.Fatal(err) } // The live state: a restore op in flight, no BACKUP running. m.BeginRestoreOp("offbox-restore", "kimai") if !m.RestoreStatus().Running { t.Fatal("fixture wrong: no restore op is in flight") } if m.IsRunning() { t.Fatal("fixture wrong: IsRunning() must be FALSE — that divergence IS the defect") } req := httptest.NewRequest(http.MethodPost, "/backup/offbox/verify-copy/delete", strings.NewReader("stack=kimai&confirm=1")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.offboxVerifyCopyDeleteHandler(w, req) // THE ASSERTION THAT MATTERS: the copy the restore is writing into is still there. if _, err := os.Stat(copyDir); os.IsNotExist(err) { t.Fatal("THE VERIFICATION COPY WAS DELETED while a restore was writing into it — this is " + "the 2026-08-21 behaviour, and the customer can do it from the UI") } if _, err := os.Stat(filepath.Join(copyDir, "payload.txt")); err != nil { t.Fatalf("the copy's contents did not survive the delete attempt: %v", err) } if loc := w.Header().Get("Location"); !strings.Contains(loc, "flash_error") { t.Errorf("the refusal must reach the customer as an error flash; redirect was %q", loc) } } func TestR360_VerifyCopyDeleteStillWorksWhenIdle(t *testing.T) { // Scenario H for this handler: with nothing in flight the delete must still work. A guard that // refuses always is not a fix, it is a removed feature. s, _, drive := newR358Server(t) copyDir := filepath.Join(drive, "backups", "offsite-restore", "kimai") if err := os.MkdirAll(copyDir, 0o755); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/backup/offbox/verify-copy/delete", strings.NewReader("stack=kimai&confirm=1")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.offboxVerifyCopyDeleteHandler(w, req) if _, err := os.Stat(copyDir); !os.IsNotExist(err) { t.Fatalf("an idle delete no longer removes the copy (redirect %q)", w.Header().Get("Location")) } } // TestR358_UnitOnlyScratchClosesTheFullRestoreCard — Scenario F at the FLOW level. // // THE ANSWER TO THE SPEC'S OPEN QUESTION, and it is worse than the question assumed. The task asked // whether the real UI flow can reach a state where a unit-only scratch makes the full-restore action // appear. It can, and by the MOST ORDINARY route available: // // - „Ellenőrző visszaállítás" (`mode=unit`, the default, advertised as non-destructive) calls // RestoreOffboxScratch(ctx, app, full=false); // - both modes write the SAME directory — offboxRestoreScratchDir ignores `full`, and `--include` // limits WHAT restic extracts, never WHERE; // - the wizard sets ScratchReady from OffboxFullScratchReady, which pre-fix answered // "directory exists and is non-empty"; // - deriveWizardStep then sets PlaceEnabled AND RestoreEnabled from that one flag. // // So a customer who ran the SAFE verification restore was then offered „Teljes visszaállítás // indítása" over a unit-only copy. Filed as a register row; the fix closes it because the marker // records full=false. func TestR358_UnitOnlyScratchClosesTheFullRestoreCard(t *testing.T) { s, m, _ := newR358Server(t) scratch := m.OffsiteRestoreScratchPath("kimai") if err := os.MkdirAll(scratch, 0o755); err != nil { t.Fatal(err) } // What a completed `mode=unit` verification restore leaves behind. if err := os.MkdirAll(filepath.Join(scratch, "mnt", "old", "backups", "primary", "kimai"), 0o755); err != nil { t.Fatal(err) } if err := m.WriteScratchMarkerForTest(scratch, "snap-1", false); err != nil { t.Fatal(err) } ready := s.backupMgr.OffboxFullScratchReady("kimai") if ready { t.Fatal("a unit-only verification restore still unlocks the full-restore card — the customer " + "is offered a destructive restore over a copy that holds only the recovery unit") } view := deriveWizardStep(restoreWizardInput{App: "kimai", ScratchReady: ready}) if view.RestoreEnabled || view.PlaceEnabled { t.Fatalf("the wizard still offers place/restore over a unit-only scratch: %+v", view) } if !view.PrepareEnabled { t.Fatal("the customer is left with no way forward — PrepareEnabled must be true so they can " + "run the real full download") } if !view.VerifyEnabled { t.Fatal("the verification restore must stay available") } }