2e936f43bf
mode=unit restores the recovery unit — the app's definition, configuration and database dumps — and NOT the customer's own files: RestoreOffboxScratch passes --include <unit path> and the userdata in the same snapshot is excluded by it. The outcome was one sentence for both modes and named neither scope, so on the last step of a disaster recovery the customer was told the app had been restored after the thing they were looking for had not been. restoreScratchOutcomeMsg states what came back, what did not, and the next step that gets it. The wizard's intent card states its scope before the choice. The full-restore size gate is untouched and pinned as unchanged; the default stays unit, since all three wizard forms set mode explicitly.
476 lines
22 KiB
Go
476 lines
22 KiB
Go
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) <strong>nem</strong> 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, "<form") {
|
|
t.Error("the execution step must render NO mutation form — the single-flight would refuse it")
|
|
}
|
|
if !strings.Contains(html, "bookstack") {
|
|
t.Error("the execution card must name what is actually running")
|
|
}
|
|
}
|
|
|
|
// --- Group D (Scenario D): real flows only — no new mutation surface ------------------------------
|
|
|
|
var formActionRe = regexp.MustCompile(`action="([^"]+)"`)
|
|
|
|
// Every form in every wizard state posts to a PRE-EXISTING endpoint. If this test has to be updated
|
|
// to add a path, a new mutation endpoint was introduced — which R-48 explicitly does not do.
|
|
func TestRestoreWizard_NoNewMutationEndpoints(t *testing.T) {
|
|
preExisting := map[string]bool{
|
|
"/backup/offbox/restore": true,
|
|
"/backup/offbox/place": true,
|
|
"/backup/offbox/reconstitute": true,
|
|
}
|
|
states := []restoreWizardInput{
|
|
{App: "immich"},
|
|
{App: "immich", ScratchReady: true},
|
|
{App: "immich", FullPrepApp: "immich"},
|
|
{App: "immich", OpRunning: true},
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, in := range states {
|
|
html := renderWizard(t, wizardData(in.App, deriveWizardStep(in), backup.OffsitePairInfo{Ready: true, HasDump: true}))
|
|
for _, m := range formActionRe.FindAllStringSubmatch(html, -1) {
|
|
seen[m[1]] = true
|
|
if !preExisting[m[1]] {
|
|
t.Errorf("wizard posts to a NON-pre-existing endpoint %q — R-48 adds no mutation surface", m[1])
|
|
}
|
|
}
|
|
}
|
|
var got []string
|
|
for p := range seen {
|
|
got = append(got, p)
|
|
}
|
|
sort.Strings(got)
|
|
if len(got) != 3 {
|
|
t.Errorf("expected all three existing endpoints to be reachable across the states, got %v", got)
|
|
}
|
|
}
|
|
|
|
// The field NAMES the wizard sends must match what the handlers read — a renamed field would make
|
|
// every action a silent no-op that still redirects with a success-shaped flash.
|
|
func TestRestoreWizard_FieldContract(t *testing.T) {
|
|
// intent step, scratch ready: unit-verify + place + reconstitute(confirm=1)
|
|
html := renderWizard(t, wizardData("immich",
|
|
deriveWizardStep(restoreWizardInput{App: "immich", ScratchReady: true}),
|
|
backup.OffsitePairInfo{Ready: true, HasDump: true}))
|
|
for _, want := range []string{
|
|
`<input type="hidden" name="app" value="immich">`,
|
|
`<input type="hidden" name="mode" value="unit">`,
|
|
`<input type="hidden" name="confirm" value="1">`,
|
|
} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("field contract broken, missing: %s", want)
|
|
}
|
|
}
|
|
// prepare-confirm step: mode=full AND confirm=1 together (the size-gated commit)
|
|
confirmHTML := renderWizard(t, wizardData("immich",
|
|
deriveWizardStep(restoreWizardInput{App: "immich", FullPrepApp: "immich"}),
|
|
backup.OffsitePairInfo{}))
|
|
if !strings.Contains(confirmHTML, `name="mode" value="full"`) || !strings.Contains(confirmHTML, `name="confirm" value="1"`) {
|
|
t.Error("the size-gated commit must post mode=full together with confirm=1")
|
|
}
|
|
if !strings.Contains(confirmHTML, "1,2 GB") {
|
|
t.Error("the confirm step must show the measured size before the customer commits")
|
|
}
|
|
}
|
|
|
|
// --- The v0.154.0 escape: the handler read the WRONG "is something running" flag -----------------
|
|
//
|
|
// The Scenario-E table test above proves deriveWizardStep behaves correctly GIVEN OpRunning=true.
|
|
// Nothing proved the handler ever COMPUTES OpRunning=true — and it didn't, for the wizard's most-used
|
|
// action. `Manager` carries two booleans: `running` (concurrency, acquired inside the goroutine, and
|
|
// `RestoreOffboxScratch` never acquires it at all) and `opRunning` (display, set synchronously by
|
|
// `BeginRestoreOp`). v0.154.0 read the first via `IsRunning()`, so during a verification restore the
|
|
// page offered all three intents with live buttons while the progress banner on the same screen said
|
|
// the restore was in progress. Found by the operator on the first live click-through.
|
|
//
|
|
// COMPANION RED-PROOF (run + recorded in REPORT.md): point restoreOpInFlight at m.IsRunning() —
|
|
// this test FAILS with inFlight=false while a restore op is live.
|
|
func TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag(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)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = tmp
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
|
|
if restoreOpInFlight(m.RestoreStatus()) {
|
|
t.Fatal("idle manager must not report an op in flight")
|
|
}
|
|
|
|
// EXACTLY what offboxRestoreHandler does for a verification restore: mark the op, then launch.
|
|
// RestoreOffboxScratch never acquires the concurrency flag, so IsRunning() stays false here —
|
|
// which is precisely why reading it was wrong.
|
|
m.BeginRestoreOp("offbox-restore", "immich")
|
|
|
|
if m.IsRunning() {
|
|
t.Fatal("precondition changed: BeginRestoreOp now sets the concurrency flag too — revisit this test")
|
|
}
|
|
if !restoreOpInFlight(m.RestoreStatus()) {
|
|
t.Fatal("a started restore op MUST read as in-flight for display (this is the v0.154.0 bug)")
|
|
}
|
|
// …and the wizard must therefore suppress every mutation form.
|
|
view := deriveWizardStep(restoreWizardInput{App: "immich", OpRunning: restoreOpInFlight(m.RestoreStatus()), ScratchReady: true})
|
|
if view.Step != wizStepExecution {
|
|
t.Fatalf("wizard must render the execution step during a restore, got %q", view.Step)
|
|
}
|
|
html := renderWizard(t, wizardData("immich", view, backup.OffsitePairInfo{Ready: true, HasDump: true}))
|
|
if strings.Contains(html, "<form") {
|
|
t.Error("no mutation form may render while a restore op is in flight")
|
|
}
|
|
|
|
m.EndRestoreOp(true, "kész")
|
|
if restoreOpInFlight(m.RestoreStatus()) {
|
|
t.Error("a finished op must clear the in-flight display state")
|
|
}
|
|
}
|
|
|
|
// hasRecentRestoreResult decides whether „Eredmény" lights up. Two ways it could lie: showing a
|
|
// stale result forever (no bound), and showing ANOTHER app's result on this app's page (the op
|
|
// status is process-wide). Both are asserted here.
|
|
func TestHasRecentRestoreResult(t *testing.T) {
|
|
now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
|
|
res := func(stack string, ago time.Duration) *backup.RestoreOpResult {
|
|
return &backup.RestoreOpResult{Op: "offbox-restore", Stack: stack, OK: true,
|
|
Message: "kész", FinishedAt: now.Add(-ago)}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
st backup.RestoreOpStatus
|
|
app string
|
|
want bool
|
|
}{
|
|
{"just finished, this app", backup.RestoreOpStatus{Last: res("immich", time.Minute)}, "immich", true},
|
|
{"finished long ago — the strip must not claim a fresh result",
|
|
backup.RestoreOpStatus{Last: res("immich", 2*time.Hour)}, "immich", false},
|
|
{"ANOTHER app's result must not light this app's page",
|
|
backup.RestoreOpStatus{Last: res("bookstack", time.Minute)}, "immich", false},
|
|
{"still running — Végrehajtás owns the strip, not Eredmény",
|
|
backup.RestoreOpStatus{Running: true, Last: res("immich", time.Minute)}, "immich", false},
|
|
{"no result at all", backup.RestoreOpStatus{}, "immich", false},
|
|
{"zero FinishedAt is not a result", backup.RestoreOpStatus{
|
|
Last: &backup.RestoreOpResult{Stack: "immich", OK: true}}, "immich", false},
|
|
{"exactly at the window boundary is stale (half-open)",
|
|
backup.RestoreOpStatus{Last: res("immich", restoreResultWindow)}, "immich", false},
|
|
{"one tick inside the window is fresh",
|
|
backup.RestoreOpStatus{Last: res("immich", restoreResultWindow-time.Second)}, "immich", true},
|
|
{"a clock skew into the future must not count as recent",
|
|
backup.RestoreOpStatus{Last: res("immich", -time.Minute)}, "immich", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := hasRecentRestoreResult(tc.st, tc.app, now); got != tc.want {
|
|
t.Errorf("hasRecentRestoreResult = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The result card renders the outcome and is bound to the real result — and a FAILED restore must
|
|
// not borrow the success styling.
|
|
func TestRestoreWizard_ResultCard(t *testing.T) {
|
|
view := deriveWizardStep(restoreWizardInput{App: "immich", ScratchReady: true, HasRecentResult: true})
|
|
data := wizardData("immich", view, backup.OffsitePairInfo{Ready: true, HasDump: true})
|
|
data["LastResult"] = &backup.RestoreOpResult{
|
|
Op: "offbox-restore", Stack: "immich", OK: true,
|
|
Message: "A(z) immich visszaállítva ellenőrző mappába: /mnt/felhom-drives/hdd_1/backups/offsite-restore/immich",
|
|
FinishedAt: time.Date(2026, 7, 21, 9, 12, 0, 0, time.UTC),
|
|
}
|
|
html := renderWizard(t, data)
|
|
if !strings.Contains(html, "offsite-restore/immich") {
|
|
t.Error("the result card must show the real outcome message, naming where the copy landed")
|
|
}
|
|
if !strings.Contains(html, "alert alert-info") {
|
|
t.Error("a successful result must render in the neutral/info tone")
|
|
}
|
|
|
|
data["LastResult"] = &backup.RestoreOpResult{Op: "offbox-restore", Stack: "immich", OK: false,
|
|
Message: "A visszaállítás sikertelen: nincs elég hely", FinishedAt: time.Date(2026, 7, 21, 9, 12, 0, 0, time.UTC)}
|
|
fail := renderWizard(t, data)
|
|
if !strings.Contains(fail, "alert alert-error") {
|
|
t.Error("a FAILED restore must render in the error tone, not the success one")
|
|
}
|
|
if strings.Contains(fail, `alert alert-info">A visszaállítás sikertelen`) {
|
|
t.Error("failure message rendered with success styling")
|
|
}
|
|
|
|
// No recent result -> no card at all.
|
|
plain := renderWizard(t, wizardData("immich",
|
|
deriveWizardStep(restoreWizardInput{App: "immich", ScratchReady: true}),
|
|
backup.OffsitePairInfo{Ready: true, HasDump: true}))
|
|
if strings.Contains(plain, "<h3>Eredmény</h3>") {
|
|
t.Error("no result card may render without a recent result")
|
|
}
|
|
}
|