v0.155.0 — the restore wizard read the wrong "is something running" flag
Fixes a defect shipped in v0.154.0, found by the operator on the first live click-through of the new wizard. backup.Manager carries TWO running booleans. `running` (read by IsRunning) is the concurrency single-flight, acquired inside the background goroutine — and RestoreOffboxScratch never acquires it at all. `opRunning` (read by RestoreStatus) is the display flag, set synchronously by BeginRestoreOp in the handler. The wizard sourced OpRunning from IsRunning(), so for „Ellenőrzés" and the full-restore preparation — its two most-used and longest actions, both streaming from restic — the execution step was unreachable: the page offered all three intents with live buttons while a restore was running, and the progress banner contradicted the phase strip on the same screen. Pressing anything there would have been refused by the handler, which is the exact "offering a control guaranteed to fail" dishonesty R-48 exists to remove. Fix: restoreOpInFlight(st) behind a documented seam, fed by a SINGLE RestoreStatus() read per render so the strip, the suppression decision and the running-op name cannot diverge. Why the tests missed it: the Scenario-E table proved deriveWizardStep behaves correctly GIVEN OpRunning=true, but nothing proved the handler ever computes true — hollow at exactly that seam. TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag now drives a real Manager through BeginRestoreOp and asserts the render suppresses every form. Red-proofed against the v0.154.0 shape. Also: „Eredmény" was a dead label. The strip's highlight is now its own derived Phase, separate from Step — a finished restore returns to the intent step (everything available again) while the strip reads „Eredmény" and an outcome card shows the result. Bounded by restoreResultWindow (10 min) so a stale result cannot look fresh, and bound to the app so a finished bookstack restore does not light immich's page with bookstack's message. The card survives a reload; the redirect flash does not. No new agent coupling — MinAgent stays 0.90.0.
This commit is contained in:
@@ -1,5 +1,43 @@
|
||||
## Changelog
|
||||
|
||||
### v0.155.0 — the restore wizard read the wrong "is something running" flag (2026-07-21)
|
||||
|
||||
**No new agent coupling — MinAgent stays 0.90.0.** Fixes a defect shipped in v0.154.0 and found by
|
||||
the operator on the first live click-through, plus the dead phase-strip label from the same release.
|
||||
|
||||
**The bug.** `backup.Manager` carries two different booleans and v0.154.0 read the wrong one:
|
||||
|
||||
| flag | read by | set by | covers the verification restore? |
|
||||
|---|---|---|---|
|
||||
| `running` | `IsRunning()` | `acquireRunning()`, **inside** the goroutine | **no — `RestoreOffboxScratch` never acquires it at all** |
|
||||
| `opRunning` | `RestoreStatus()` | `BeginRestoreOp()`, in the handler, synchronously | yes, all four offsite actions |
|
||||
|
||||
The wizard sourced `OpRunning` from `IsRunning()`. For „Ellenőrzés" and the full-restore preparation
|
||||
— the wizard's two most-used actions, and the long ones, since they stream from restic — that flag is
|
||||
false for the *entire* operation. So the execution step was unreachable: the page kept offering all
|
||||
three intents with live buttons while a restore was downloading, and the progress banner (which polls
|
||||
the op status) contradicted the phase strip on the same screen. Any button pressed there would have
|
||||
been refused by the handler — which is exactly the "offering a control guaranteed to fail" dishonesty
|
||||
R-48 exists to remove.
|
||||
|
||||
**The fix** is one line of behaviour behind a named seam: `restoreOpInFlight(st)` takes the
|
||||
`RestoreOpStatus` the handler already reads once, and its doc comment states which flag is which and
|
||||
why. The handler now takes a single `RestoreStatus()` read, so the strip, the suppression decision
|
||||
and the running-op name can no longer disagree with each other.
|
||||
|
||||
**Why the v0.154.0 tests missed it.** The Scenario-E table proved `deriveWizardStep` behaves
|
||||
correctly *given* `OpRunning=true`; nothing proved the handler ever computes `true`. Hollow at exactly
|
||||
that seam. `TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag` now drives a real `Manager`
|
||||
through `BeginRestoreOp` and asserts the wizard suppresses every form — red-proofed against the
|
||||
v0.154.0 shape.
|
||||
|
||||
**„Eredmény" is now reachable.** The fourth phase label never lit up in v0.154.0. The strip's
|
||||
highlight is now its own derived value (`Phase`), separate from `Step`: a finished restore is back on
|
||||
the intent step — everything is available again — while the strip rightly reads „Eredmény" and an
|
||||
outcome card shows the result. Bounded by `restoreResultWindow` (10 min) so a stale result cannot
|
||||
claim to be fresh, and bound to the app, so a finished bookstack restore does not light up immich's
|
||||
page with bookstack's message. The card survives a reload, which the redirect flash does not.
|
||||
|
||||
### v0.154.0 — one restore entry per app, and the intent is a described choice (2026-07-21)
|
||||
|
||||
Closes **R-48**. **No new agent coupling — MinAgent stays 0.90.0.** This is a UI-layer change:
|
||||
|
||||
+21
-1
@@ -7,7 +7,27 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-07-21 (v0.154.0 — R-48: one restore entry per app, intent as a described choice)
|
||||
Last updated: 2026-07-21 (v0.155.0 — R-48 follow-up: the wizard read the wrong running-flag)
|
||||
|
||||
> **2026-07-21 — v0.155.0.** v0.154.0's wizard sourced "is an op running" from `Manager.IsRunning()`
|
||||
> — the CONCURRENCY single-flight, acquired inside the goroutine, and **`RestoreOffboxScratch` never
|
||||
> acquires it**. So the execution step was unreachable for „Ellenőrzés" and the full-restore
|
||||
> preparation: live buttons while a restore downloaded, with the progress banner contradicting the
|
||||
> phase strip on the same screen. Found by the operator on the first live click-through.
|
||||
>
|
||||
> **DECISION: display reads `RestoreStatus()` (the `opRunning` flag), never `IsRunning()`**, through
|
||||
> the named `restoreOpInFlight` seam, and the handler reads the status ONCE per render so the strip,
|
||||
> the suppression and the running-op name cannot diverge. The lesson generalises: `opstatus.go` is the
|
||||
> DISPLAY surface and says so in its own header — the concurrency flag is not a substitute.
|
||||
>
|
||||
> **The test lesson:** a table test over a pure function proves the function, not the caller. Scenario
|
||||
> E passed throughout because it injected `OpRunning=true` directly. The new test drives a real
|
||||
> `Manager` through `BeginRestoreOp` and asserts the render.
|
||||
>
|
||||
> **DECISION: „Eredmény" earns its place.** The strip's highlight is now `Phase`, derived separately
|
||||
> from `Step`: a finished restore is back on the intent step while the strip reads „Eredmény" and an
|
||||
> outcome card shows the result — window-bounded (10 min) and app-bound.
|
||||
|
||||
|
||||
> **2026-07-21 — v0.154.0 (R-48).** Collapses the offsite restore controls to a single
|
||||
> „Visszaállítás…" entry per app row plus a per-app wizard at `GET /backups/restore/app?name=<app>`.
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
| `limitBody` | controller/internal/api/router.go | `(w, req)` | Bound request bodies (1MB) | Apply before decode on any new POST |
|
||||
| `offboxRedirect` | controller/internal/web/offbox_handlers.go | `(w, r, msg string, isErr bool)` | Flash-message redirects | Flash = `?flash=` / `?flash_error=` query params, read by page handlers |
|
||||
| `offboxRedirectTo` | controller/internal/web/offbox_handlers.go | `(w, r, page, msg string, isErr bool)` | Same, to an EXPLICIT page | **TRAP (fixed v0.154.0): the separator is chosen, not `"?"`.** Targets may already carry a query — the R-48 wizard is `/backups/restore/app?name=<app>` — and a hardcoded `"?"` buries the flash inside the previous parameter's value |
|
||||
| `restoreOpInFlight` + `hasRecentRestoreResult` | controller/internal/web/restore_wizard.go | `(backup.RestoreOpStatus) bool` / `(st, app, now) bool` | THE "is a restore running / did one just finish" display reads | **TRAP (v0.154.0 shipped this bug): `Manager` has TWO running flags.** `IsRunning()` reads the CONCURRENCY flag, acquired inside the goroutine — and `RestoreOffboxScratch` never acquires it, so it is false for the whole verification restore. Display must read `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`). Read the status ONCE per render or the strip and the suppression can disagree. `hasRecentRestoreResult` is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény" |
|
||||
| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface |
|
||||
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
|
||||
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
|
||||
|
||||
@@ -326,6 +326,12 @@ Each app can define rich metadata in `.felhom.yml`:
|
||||
`?full_prep=`, so no commit button survives into a restore. While ANY op runs, every mutation
|
||||
form is suppressed server-side rather than offered and refused. No new endpoint, no job registry
|
||||
(that stays R-45), and the page works with JavaScript disabled.
|
||||
**v0.155.0 fix:** the "is an op running" read must come from `RestoreStatus()` (the `opRunning`
|
||||
display flag, set synchronously by `BeginRestoreOp`), NOT `Manager.IsRunning()` (the concurrency
|
||||
single-flight, which `RestoreOffboxScratch` never acquires — so v0.154.0's execution step was
|
||||
unreachable for the verification restore). The strip's highlight is its own derived `Phase`, so a
|
||||
finished restore reads „Eredmény" while the intent step is available again; the outcome card is
|
||||
window-bounded and app-bound.
|
||||
- **The DB-only replay window (v0.153.0, R-47).** Until v0.153.0 the whole stack was started before
|
||||
the replay, so the application's own schema management raced the dump: measured live on
|
||||
2026-07-19 (H4), immich-server rebuilt `clip_index` two seconds before the dump's `CREATE INDEX`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
@@ -53,8 +54,12 @@ const (
|
||||
type restoreWizardInput struct {
|
||||
// App is the app this wizard page is for.
|
||||
App string
|
||||
// OpRunning is true when ANY backup/restore op is in flight — not just this app's. The
|
||||
// single-flight is process-wide, so a restore running for app X must suppress app Y's controls.
|
||||
// OpRunning is true when ANY backup/restore op is in flight — not just this app's. The op status
|
||||
// is process-wide, so a restore running for app X must suppress app Y's controls.
|
||||
//
|
||||
// Source it via restoreOpInFlight (the DISPLAY flag), never Manager.IsRunning() — see the note
|
||||
// on that helper. Reading the wrong flag makes this field silently always-false for the
|
||||
// verification restore, which is the wizard's most-used path.
|
||||
OpRunning bool
|
||||
// ScratchReady is true when a completed full-restore scratch exists for App. Both the
|
||||
// missing-only merge and the true reconstitution require one.
|
||||
@@ -62,6 +67,10 @@ type restoreWizardInput struct {
|
||||
// FullPrepApp is the app the size-gate flash binds to (from ?full_prep=). It binds to ONE app:
|
||||
// a prepare for X must not reveal a confirm on Y's page.
|
||||
FullPrepApp string
|
||||
// HasRecentResult is true when THIS app's restore finished a short while ago — see
|
||||
// hasRecentRestoreResult. It moves the phase strip to „Eredmény"; it never changes what the
|
||||
// customer may do (a finished restore leaves every intent available again).
|
||||
HasRecentResult bool
|
||||
}
|
||||
|
||||
// restoreWizardView is what the template renders. The enabled-flags are part of the derivation (not
|
||||
@@ -69,6 +78,11 @@ type restoreWizardInput struct {
|
||||
// one pure function with one test table.
|
||||
type restoreWizardView struct {
|
||||
Step restoreWizardStep
|
||||
// Phase is which label the phase strip highlights. It is NOT the same as Step: a finished restore
|
||||
// is back on the intent step (everything is offered again) while the strip rightly says
|
||||
// „Eredmény". Keeping them separate is what stopped the strip from having to lie in one direction
|
||||
// or the other.
|
||||
Phase restoreWizardPhase
|
||||
// VerifyEnabled — intent 1: restore into a separate verification folder (mode=unit). Live data
|
||||
// is untouched, so this is the only intent available without a prepared scratch.
|
||||
VerifyEnabled bool
|
||||
@@ -85,6 +99,35 @@ type restoreWizardView struct {
|
||||
CommitPrepareEnabled bool
|
||||
}
|
||||
|
||||
// restoreWizardPhase is the phase-strip highlight. Four labels, all reachable.
|
||||
type restoreWizardPhase string
|
||||
|
||||
const (
|
||||
wizPhasePrepare restoreWizardPhase = "elokeszites"
|
||||
wizPhaseConfirm restoreWizardPhase = "megerosites"
|
||||
wizPhaseExecute restoreWizardPhase = "vegrehajtas"
|
||||
wizPhaseResult restoreWizardPhase = "eredmeny"
|
||||
)
|
||||
|
||||
// restoreResultWindow bounds how long after a finished restore the strip still says „Eredmény".
|
||||
// Without a bound the last result would light that phase forever — landing on the page a week later
|
||||
// would claim you had just finished a restore. Same reasoning as escrowCeremonyGraceWindow; shorter,
|
||||
// because this answers "what just happened", not "are we still waiting".
|
||||
const restoreResultWindow = 10 * time.Minute
|
||||
|
||||
// hasRecentRestoreResult reports whether THIS app has a just-finished restore to show. Pure (the
|
||||
// clock is a parameter) so the boundary and the wrong-app case are table-testable.
|
||||
//
|
||||
// Bound to the app on purpose: the op status is process-wide, so a finished bookstack restore must
|
||||
// not light „Eredmény" on immich's wizard and show bookstack's message there.
|
||||
func hasRecentRestoreResult(st backup.RestoreOpStatus, app string, now time.Time) bool {
|
||||
if st.Running || st.Last == nil || st.Last.Stack != app || st.Last.FinishedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
d := now.Sub(st.Last.FinishedAt)
|
||||
return d >= 0 && d < restoreResultWindow
|
||||
}
|
||||
|
||||
// deriveWizardStep is the Scenario-B truth table: step and available intents are a PURE function of
|
||||
// the state, in strict precedence order.
|
||||
//
|
||||
@@ -98,13 +141,18 @@ type restoreWizardView struct {
|
||||
// never resurrect a commit button while a restore is mid-flight.
|
||||
func deriveWizardStep(in restoreWizardInput) restoreWizardView {
|
||||
if in.OpRunning {
|
||||
return restoreWizardView{Step: wizStepExecution}
|
||||
return restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}
|
||||
}
|
||||
if in.FullPrepApp != "" && in.FullPrepApp == in.App {
|
||||
return restoreWizardView{Step: wizStepPrepareConfirm, CommitPrepareEnabled: true}
|
||||
return restoreWizardView{Step: wizStepPrepareConfirm, Phase: wizPhaseConfirm, CommitPrepareEnabled: true}
|
||||
}
|
||||
phase := wizPhasePrepare
|
||||
if in.HasRecentResult {
|
||||
phase = wizPhaseResult
|
||||
}
|
||||
return restoreWizardView{
|
||||
Step: wizStepIntent,
|
||||
Phase: phase,
|
||||
VerifyEnabled: true,
|
||||
PrepareEnabled: !in.ScratchReady,
|
||||
PlaceEnabled: in.ScratchReady,
|
||||
@@ -129,6 +177,28 @@ func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreOpInFlight reports whether a restore op is in flight, FOR DISPLAY.
|
||||
//
|
||||
// **Use this, not `Manager.IsRunning()`.** The Manager carries two different booleans and they are
|
||||
// not interchangeable:
|
||||
//
|
||||
// - `m.running` (read by `IsRunning`) is the CONCURRENCY single-flight. It is acquired *inside*
|
||||
// the restore function, on the background goroutine — and `RestoreOffboxScratch` never acquires
|
||||
// it at all. So for the verification restore and the full-restore preparation — the wizard's two
|
||||
// most-used actions, and the long ones, since they stream from restic — `IsRunning()` is false
|
||||
// for the entire operation.
|
||||
// - `m.opRunning` (read by `RestoreStatus`) is the DISPLAY flag, set synchronously by
|
||||
// `BeginRestoreOp` in the handler *before* the goroutine launches and cleared by `EndRestoreOp`.
|
||||
// It covers all four offsite actions with no start-up window.
|
||||
//
|
||||
// v0.154.0 shipped with `IsRunning()` here, which made the execution step unreachable for
|
||||
// `RestoreOffboxScratch`: the page offered all three intents, with live buttons, while a restore was
|
||||
// downloading — and the progress banner (which polls the op status) contradicted it on the same
|
||||
// screen. Caught by the operator on the first live click-through.
|
||||
func restoreOpInFlight(st backup.RestoreOpStatus) bool {
|
||||
return st.Running
|
||||
}
|
||||
|
||||
// backupsRestoreWizardHandler renders GET /backups/restore/app?name=<app> — the single entry the
|
||||
// list page now offers per app.
|
||||
//
|
||||
@@ -154,11 +224,13 @@ func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
data := s.backupsCommonData("backups-restore", "Visszaállítás — "+row.DisplayName, r)
|
||||
|
||||
st := s.backupMgr.RestoreStatus()
|
||||
in := restoreWizardInput{
|
||||
App: app,
|
||||
OpRunning: s.backupMgr.IsRunning(),
|
||||
ScratchReady: s.backupMgr.OffboxFullScratchReady(app),
|
||||
FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")),
|
||||
App: app,
|
||||
OpRunning: restoreOpInFlight(st),
|
||||
ScratchReady: s.backupMgr.OffboxFullScratchReady(app),
|
||||
FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")),
|
||||
HasRecentResult: hasRecentRestoreResult(st, app, time.Now()),
|
||||
}
|
||||
view := deriveWizardStep(in)
|
||||
|
||||
@@ -177,8 +249,12 @@ func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
// The running op's identity, so the execution card can say WHAT is running rather than a bare
|
||||
// "please wait" — including the case where it belongs to a different app.
|
||||
st := s.backupMgr.RestoreStatus()
|
||||
data["RunningStack"] = st.Stack
|
||||
// The outcome card for the „Eredmény" phase — the same message the redirect flash carried, but it
|
||||
// survives a reload, which the flash does not.
|
||||
if in.HasRecentResult {
|
||||
data["LastResult"] = st.Last
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "backups_restore_wizard", data)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -9,6 +12,8 @@ import (
|
||||
"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.
|
||||
@@ -53,37 +58,47 @@ func TestDeriveWizardStep_Table(t *testing.T) {
|
||||
{
|
||||
name: "no scratch, no op → intent; only verification is offered, full restore must be prepared first",
|
||||
in: restoreWizardInput{App: "immich"},
|
||||
want: restoreWizardView{Step: wizStepIntent, VerifyEnabled: true, PrepareEnabled: true},
|
||||
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, VerifyEnabled: true, PlaceEnabled: true, RestoreEnabled: 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, CommitPrepareEnabled: true},
|
||||
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, VerifyEnabled: true, PrepareEnabled: true},
|
||||
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},
|
||||
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},
|
||||
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},
|
||||
want: restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -322,3 +337,135 @@ func TestRestoreWizard_FieldContract(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,24 @@
|
||||
<!-- Phase strip: the customer can see there IS a sequence, and where they are in it. The round-2
|
||||
incident's second half was that the decisive step appeared only after the first was pressed,
|
||||
with nothing signposting that a second step existed at all. -->
|
||||
{{$phase := printf "%s" .Wizard.Phase}}
|
||||
<div class="restore-wizard-phases">
|
||||
<span class="restore-wizard-phase{{if eq (printf "%s" .Wizard.Step) "intent"}} is-current{{end}}">Előkészítés</span>
|
||||
<span class="restore-wizard-phase{{if eq (printf "%s" .Wizard.Step) "prepare-confirm"}} is-current{{end}}">Megerősítés</span>
|
||||
<span class="restore-wizard-phase{{if eq (printf "%s" .Wizard.Step) "execution"}} is-current{{end}}">Végrehajtás</span>
|
||||
<span class="restore-wizard-phase">Eredmény</span>
|
||||
<span class="restore-wizard-phase{{if eq $phase "elokeszites"}} is-current{{end}}">Előkészítés</span>
|
||||
<span class="restore-wizard-phase{{if eq $phase "megerosites"}} is-current{{end}}">Megerősítés</span>
|
||||
<span class="restore-wizard-phase{{if eq $phase "vegrehajtas"}} is-current{{end}}">Végrehajtás</span>
|
||||
<span class="restore-wizard-phase{{if eq $phase "eredmeny"}} is-current{{end}}">Eredmény</span>
|
||||
</div>
|
||||
|
||||
{{with .LastResult}}
|
||||
<!-- „Eredmény": the outcome of the restore that just finished, for THIS app. The redirect flash says
|
||||
the same thing but does not survive a reload; this does, for restoreResultWindow. -->
|
||||
<div class="settings-card">
|
||||
<h3>Eredmény</h3>
|
||||
<div class="alert {{if .OK}}alert-info{{else}}alert-error{{end}}">{{.Message}}</div>
|
||||
<p class="form-hint">Befejezve: {{fmtTime .FinishedAt}}. Ha szeretnéd, alább újra indíthatsz egy visszaállítást.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq (printf "%s" .Wizard.Step) "execution"}}
|
||||
<!-- EXECUTION — every mutation form is suppressed server-side. The manager's single-flight would
|
||||
refuse them anyway; offering a control guaranteed to fail is the same dishonesty class R-48
|
||||
|
||||
Reference in New Issue
Block a user