package web import ( "io" "log" "net/http/httptest" "path/filepath" "regexp" "sort" "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/settings" ) // R-48 — the offsite restore wizard. // // What these tests actually guard is the finding's RULE, not the page: two adjacent controls whose // difference is "your data comes back" vs "your data cannot come back" must not be distinguishable // only by layout. Scenario A is therefore asserted STRUCTURALLY (the mutation forms are absent from // the list page and present only in the wizard), not by eyeballing copy. // wizardData builds the wizard template's data map. Mirrors what backupsRestoreWizardHandler puts // there — the handler's own construction is exercised separately by the redirect tests. func wizardData(app string, view restoreWizardView, pair backup.OffsitePairInfo) map[string]interface{} { return map[string]interface{}{ "Page": "backups-restore", "Title": "Visszaállítás", "Backup": &backup.FullBackupStatus{}, "App": app, "AppDisplayName": strings.ToUpper(app[:1]) + app[1:], "AppSlug": app, "Wizard": view, "Pair": pair, "FullPrepSize": "1,2 GB", "RunningStack": "", } } func renderWizard(t *testing.T, data map[string]interface{}) string { t.Helper() return renderBackupPage(t, "backups_restore_wizard", data) } // --- Group B (Scenario B): the step derivation is server truth, and PURE --------------------------- // // COMPANION RED-PROOF (run + recorded in REPORT.md): replace the body of deriveWizardStep with the // trivial `return restoreWizardView{Step: wizStepIntent, VerifyEnabled: true}` — the op-running, // prepare-confirm and scratch-ready rows all FAIL. Restore → green. func TestDeriveWizardStep_Table(t *testing.T) { cases := []struct { name string in restoreWizardInput want restoreWizardView }{ { name: "no scratch, no op → intent; only verification is offered, full restore must be prepared first", in: restoreWizardInput{App: "immich"}, want: restoreWizardView{Step: wizStepIntent, Phase: wizPhasePrepare, VerifyEnabled: true, PrepareEnabled: true}, }, { name: "scratch ready → intent, and BOTH data-touching intents unlock; preparation is done", in: restoreWizardInput{App: "immich", ScratchReady: true}, want: restoreWizardView{Step: wizStepIntent, Phase: wizPhasePrepare, VerifyEnabled: true, PlaceEnabled: true, RestoreEnabled: true}, }, { name: "full_prep flash for THIS app → prepare-confirm; only the commit is offered", in: restoreWizardInput{App: "immich", FullPrepApp: "immich"}, want: restoreWizardView{Step: wizStepPrepareConfirm, Phase: wizPhaseConfirm, CommitPrepareEnabled: true}, }, { name: "full_prep flash for ANOTHER app → this app keeps its own intent step", in: restoreWizardInput{App: "immich", FullPrepApp: "bookstack"}, want: restoreWizardView{Step: wizStepIntent, Phase: wizPhasePrepare, VerifyEnabled: true, PrepareEnabled: true}, }, { name: "op running (this app) → execution; nothing offered", in: restoreWizardInput{App: "immich", OpRunning: true}, want: restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}, }, { name: "op running for ANOTHER app still suppresses THIS app (the single-flight is process-wide)", in: restoreWizardInput{App: "immich", OpRunning: true, ScratchReady: true}, want: restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}, }, { name: "a just-finished restore returns to intent, but the strip says Eredmény", in: restoreWizardInput{App: "immich", ScratchReady: true, HasRecentResult: true}, want: restoreWizardView{Step: wizStepIntent, Phase: wizPhaseResult, VerifyEnabled: true, PlaceEnabled: true, RestoreEnabled: true}, }, { name: "a running op outranks a recent result — Végrehajtás, not Eredmény", in: restoreWizardInput{App: "immich", OpRunning: true, HasRecentResult: true}, want: restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}, }, { name: "op running OUTRANKS a stale full_prep flash — no commit button mid-restore", in: restoreWizardInput{App: "immich", OpRunning: true, FullPrepApp: "immich"}, want: restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := deriveWizardStep(tc.in) if got != tc.want { t.Errorf("deriveWizardStep(%+v)\n got %+v\n want %+v", tc.in, got, tc.want) } }) } } // --- Group C (Scenario B error rows): refusals resolve, never 500 ---------------------------------- func TestResolveWizardApp_Refusals(t *testing.T) { rows := []OffboxAppRow{ {Name: "immich", DisplayName: "Immich", Enabled: true}, {Name: "radarr", DisplayName: "Radarr", Enabled: false}, } if got := resolveWizardApp(rows, "immich"); got == nil || got.DisplayName != "Immich" { t.Fatalf("toggled app must resolve, got %+v", got) } if got := resolveWizardApp(rows, "radarr"); got != nil { t.Errorf("an app that is NOT toggled for offsite has no snapshot to restore from — want nil, got %+v", got) } if got := resolveWizardApp(rows, "does-not-exist"); got != nil { t.Errorf("unknown app must not resolve, got %+v", got) } if got := resolveWizardApp(nil, "immich"); got != nil { t.Errorf("empty set must not resolve, got %+v", got) } } // The customer-visible URL must redirect, not 500, when the offsite target is not configured at all. func TestRestoreWizardHandler_UnconfiguredRedirects(t *testing.T) { s := testServer(t) rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/backups/restore/app?name=immich", nil) s.backupsRestoreWizardHandler(rec, req) if rec.Code != 302 { t.Fatalf("want 302 redirect, got %d", rec.Code) } loc := rec.Header().Get("Location") if !strings.HasPrefix(loc, "/backups/restore?flash_error=") { t.Errorf("must redirect to the list with an error flash, got %q", loc) } } // --- Group A + C (Scenarios A and C): one entry on the list, three described cards in the wizard ---- // Scenario A — the list page carries EXACTLY ONE restore control per app and ZERO offsite mutation // forms. This is the finding itself: the five inline forms are gone from the row. func TestRestoreList_SingleEntryPerApp(t *testing.T) { data := splitTestData() data["OffboxScratchReady"] = map[string]bool{"calibre-web": true} data["OffboxPairInfo"] = map[string]backup.OffsitePairInfo{ "calibre-web": {Ready: true, HasDump: true, DumpsAt: time.Now().Add(-2 * time.Hour)}, } html := renderBackupPage(t, "backups_restore", data) // The scratch-ready fixture is precisely the state in which the OLD page rendered place and // reconstitute as adjacent siblings. None of them may appear here now. for _, banned := range []string{ `action="/backup/offbox/restore"`, `action="/backup/offbox/place"`, `action="/backup/offbox/reconstitute"`, "Helyreállítás az élő adatok közé", "Teljes visszaállítás (fájlok + adatbázis)", "Teljes visszaállítás előkészítése", } { if strings.Contains(html, banned) { t.Errorf("R-48 violated: the list page still renders %q", banned) } } if n := strings.Count(html, `href="/backups/restore/app?name=calibre-web"`); n != 1 { t.Errorf("want exactly ONE wizard entry for the app, got %d", n) } } // Scenario C — the three intents are CARDS with their own consequence sentence, the dangerous one is // styled as such, and the honesty panel is bound to the real pair info. func TestRestoreWizard_ThreeIntentCards(t *testing.T) { pair := backup.OffsitePairInfo{ Ready: true, HasDump: true, DumpsAt: time.Date(2026, 7, 19, 3, 15, 0, 0, time.UTC), Skewed: true, LooksEmpty: true, } view := deriveWizardStep(restoreWizardInput{App: "immich", ScratchReady: true}) html := renderWizard(t, wizardData("immich", view, pair)) // Each intent must state its CONSEQUENCE, not just its name. for _, want := range []string{ "Ellenőrzés külön mappába", "Az élő adataid nem változnak", // R-204 item 3: intent 1's SCOPE is stated before the choice, not only in the outcome. A // disaster-recovery customer picked this card expecting their documents; it does not return // them. If this substring goes, the card is back to promising „a mentés tartalma". "A saját fájljaidat (dokumentumok, képek, feltöltések) nem hozza vissza", "Hiányzó fájlok visszahozása", "törölt tartalom ettől nem jelenik meg újra", "Teljes visszaállítás (fájlok + adatbázis)", "az adatbázist is visszatölti", } { if !strings.Contains(html, want) { t.Errorf("intent card copy missing: %q", want) } } // The dangerous intent is marked structurally, not only by wording/position. if !strings.Contains(html, "restore-danger-card") { t.Error("the full-restore card must carry the danger styling hook") } // Pair honesty is BOUND to the fixture, not hardcoded prose. if !strings.Contains(html, "eltérő időpontból származnak") { t.Error("Skewed pair must surface the skew warning") } if !strings.Contains(html, "üresnek tűnik") { t.Error("LooksEmpty pair must surface the empty-dump warning") } if !strings.Contains(html, `data-restore-skewed="1"`) || !strings.Contains(html, `data-restore-empty="1"`) { t.Error("the double-confirm must receive the pair facts it repeats back") } // The confirm copy moved VERBATIM — it is the good part of the old surface. if !strings.Contains(html, "UTOLSÓ MEGERŐSÍTÉS") || !strings.Contains(html, "confirmFullRestore") { t.Error("the double-confirm did not move with the action") } // A pair WITHOUT the warnings must not inherit them (guards a hardcoded-prose regression). // NOTE: the confirm helper's JS repeats both sentences as string literals, so the assertion has // to be on the RENDERED markup — the warning banners and the data-attributes that drive them — // not on a bare substring, which is always present via the script block. clean := renderWizard(t, wizardData("immich", view, backup.OffsitePairInfo{Ready: true, HasDump: true})) // The two banners are identified by their opening markup, not by a bare substring: the layout's // shared confirm helper also contains alert-warning literals. const skewBanner = `alert alert-warning" style="margin-bottom:.75rem">Az adatbázis-mentés régebbi` const emptyBanner = `alert alert-warning" style="margin-bottom:.75rem">A mentett adatbázis üresnek tűnik` if !strings.Contains(html, skewBanner) || !strings.Contains(html, emptyBanner) { t.Error("a skewed + empty-looking pair must render BOTH warning banners") } if strings.Contains(clean, skewBanner) || strings.Contains(clean, emptyBanner) { t.Error("a clean pair must render neither warning banner") } if !strings.Contains(clean, `data-restore-skewed=""`) || !strings.Contains(clean, `data-restore-empty=""`) { t.Error("a clean pair must pass empty skew/empty flags to the confirm helper") } } // Without a prepared scratch the two data-touching intents are NOT offered — the card explains what // has to happen first instead of showing a button that would fail. func TestRestoreWizard_NoScratchLocksDataIntents(t *testing.T) { view := deriveWizardStep(restoreWizardInput{App: "immich"}) html := renderWizard(t, wizardData("immich", view, backup.OffsitePairInfo{})) if strings.Contains(html, `action="/backup/offbox/place"`) { t.Error("missing-only merge must not be offered without a prepared scratch") } if strings.Contains(html, `action="/backup/offbox/reconstitute"`) { t.Error("full restore must not be offered without a prepared scratch") } if !strings.Contains(html, "Teljes visszaállítás előkészítése") { t.Error("the full-restore card must offer preparation instead") } } // --- Group E (Scenario B, execution row): every mutation form suppressed while an op runs ---------- func TestRestoreWizard_OpRunningSuppressesAllMutations(t *testing.T) { view := deriveWizardStep(restoreWizardInput{App: "immich", OpRunning: true, ScratchReady: true, FullPrepApp: "immich"}) data := wizardData("immich", view, backup.OffsitePairInfo{Ready: true, HasDump: true}) data["RunningStack"] = "bookstack" html := renderWizard(t, data) if strings.Contains(html, "