R-353/R-357/R-358/R-360: the restore tells the truth (v0.226.0)
gates / gates (push) Successful in 11s

Four defects on the restore surface, all proven on demo-hp during the 2026-08-21
backup-truth drill, all still in shipped code. They share one acceptance idea: a
restore surface must state what it actually did, and must refuse what it cannot
do.

VERSION NOTE. The task specifying this targeted v0.224.0 against baseline
f8c9390. Both were consumed earlier the same day by R-330 (0.224.0) and R-331
(0.225.0). Drift re-confirmed against live Gitea before the first edit, operator
authorised proceeding, every symbol the spec named re-verified present at the
real baseline e5eee50.

R-353 -- a restore that gave back nothing still said it worked.
RestoreFromRecoveryUnit returned only error, so the surface printed
"<app> visszaallitva (<snapshot>)." -- equally true of a run that returned an
entire dataset and one that returned nothing. The count already existed and was
discarded one line deep: restoreDockerVolumesFrom always returned it, the
wrapper threw it away. Now (UnitRestoreResult, error), carrying replayed counts
AND what the manifest LISTED, because zero-replayed has two causes that are
opposite news. Three cases, three sentences, and EVERY one is a claim about the
BACKUP, never about the app -- this path has no SafetyDump discriminator, and
07-backup-architecture 6.3 records that an absent dump says nothing about the
app (R-361 destroyed canonical .sql files for four months).

R-357 -- the destructive restore had no free-space gate. offbox_reconstitute.go
contained ZERO references to offboxFree; all three existing gates guard
non-destructive paths. The gate now sits before mapOffsiteRestorePaths,
writeSafetyDump and StopStack, so a refusal costs nothing. Position IS the fix,
which is why the test asserts StopStack was never called. No headroom multiplier
(matches PlaceOffsiteRestore; the x1.1 elsewhere predicts a download). Fail
closed on either probe <= 0 -- otherwise `free < need` with need==0 is FALSE and
an unmeasurable scratch sails through: a gate present and inert.

R-358 -- a failed download was offered as a good one. The gate answered "the
directory exists and is non-empty", which is exactly what a part-way restic run
leaves. Now a completion marker written 0600 atomically AFTER restic returns
nil, with any stale one cleared BEFORE it starts; both orders pinned by an AST
test because resticStep is not a seam. Both handlers refuse server-side: the
wizard flags control a button, and a hidden button is not a guard.

SCENARIO F ANSWERED, and worse than the question assumed: a unit-only scratch IS
reachable through the real flow, by the most ordinary route. "Ellenorzo
visszaallitas" (mode=unit, advertised non-destructive) writes the SAME directory
-- offboxRestoreScratchDir ignores `full` and --include limits what restic
extracts, never where -- so a customer who ran the SAFE restore was then offered
the destructive one over a unit-only copy. Filed R-396; the marker closes it.

R-360 -- the delete refused only while a BACKUP ran. IsRunning() is FALSE for the
whole of a verification restore; the five sibling handlers all use
restoreOpBlocked(). Its doc comment claimed it already did this, which is why
nobody looked -- corrected in place.

Red-proofs, each printing the pre-fix behaviour, in CHANGELOG and REPORT. The
first R-357 red-proof exposed a hollow test OF MY OWN and it is recorded rather
than quietly fixed: the fixture refused earlier at the placement stat pre-pass,
so `stops == 0` passed against the pre-fix code. Fixture corrected, assertions
reordered so a removed gate reports the outage rather than "no error returned".

Green gate clean: 28 packages, rc 0. All 12 controller gates OK.
This commit is contained in:
2026-08-30 19:31:31 +02:00
parent e5eee501b5
commit b8af72764d
18 changed files with 1412 additions and 53 deletions
+76 -3
View File
@@ -1470,17 +1470,90 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
// on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil {
res, err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start))
s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").")
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
stackName, time.Since(start), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
// R-353: this used to read `stackName+" visszaállítva ("+snapshotID+")."` — a sentence that is
// true of a run which returned an app's entire dataset AND of one that returned nothing at all.
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}
// unitRestoreOutcomeMsg builds the OUTCOME sentence for a completed LOCAL recovery-unit restore. Pure,
// so the wording is unit-testable — this string is the customer's only evidence that the operation did
// what its label promised.
//
// R-353. Modelled on reconstituteOutcomeMsg (the off-site twin), and it is the same defect arriving on
// the Tier-1 path: on 2026-08-21 an opengist restore reported „opengist visszaállítva (<snapshot>)."
// over a unit that held manifest.json and compose/ and nothing else. Every clause below is earned by
// having done the thing, so a restore that really returned data reads exactly as confidently as before.
//
// THE THREE CASES ARE THREE DIFFERENT FACTS, and collapsing any two is the whole bug:
//
// - something came back → name what, and how much.
// - nothing came back AND the unit listed nothing → the BACKUP held only settings. This is a claim
// about the backup, and it is the only claim the manifest can support.
// - nothing came back BUT the unit listed dumps → something is wrong. The customer's live data was
// never removed (the volume replay only ever writes), so say that, and stop them retrying blind.
//
// R-355 IS THE RULE THIS OBEYS AND THE REASON THE MIDDLE CASE IS WORDED AS IT IS. „ennek az
// alkalmazásnak nincs adata" is a claim ABOUT THE APP and must never be inferred from a counter. On the
// off-site path SafetyDump is the honest discriminator for the database question; this path has none,
// so no claim about the app is available here at all. 07-backup-architecture §6.3 is why that is not
// pedantry: R-361 destroyed apps' canonical .sql dumps for four months, so an absent dump has causes
// that have nothing to do with whether the app has a database.
//
// No filesystem path appears in the message, only counts — same rule as reconstituteOutcomeMsg.
func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
if res.VolumesReplayed > 0 || res.DBsReplayed > 0 {
var what string
if res.VolumesReplayed > 0 {
what = fmt.Sprintf("%d adatkötet", res.VolumesReplayed)
}
if res.DBsReplayed > 0 {
if what != "" {
what += " és az adatbázis"
} else {
what = "az adatbázis"
}
}
return fmt.Sprintf(unitRestoreDataMsgFmt, app, what)
}
if res.ManifestVolumes+res.ManifestDBs > 0 {
return fmt.Sprintf(unitRestoreNoneReturnedMsgFmt, app, res.ManifestVolumes, res.ManifestDBs)
}
return fmt.Sprintf(unitRestoreSettingsOnlyMsgFmt, app)
}
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
// r353_unit_outcome_test.go — a silent edit to any of them is how an honest message drifts back into a
// comforting one, which is the exact history of the sentence they replace.
const (
// unitRestoreDataMsgFmt — data really came back. %s app, %s the "N adatkötet[ és az adatbázis]"
// clause built above.
unitRestoreDataMsgFmt = "A(z) %s: %s visszaállítva — az alkalmazás újraindult."
// unitRestoreSettingsOnlyMsgFmt — nothing came back and the unit listed nothing. The FIGYELEM
// sentence is a statement about THE BACKUP; it deliberately says nothing about whether the app has
// data of its own, because the manifest cannot answer that (R-355).
unitRestoreSettingsOnlyMsgFmt = "A(z) %s: a beállítások visszaálltak — az alkalmazás újraindult. FIGYELEM: ez a mentés csak a beállításokat tartalmazta, adatot nem. Az alkalmazás adatai NEM álltak vissza ebből a mentésből."
// unitRestoreNoneReturnedMsgFmt — the unit listed data and none of it returned. %d volumes, %d
// database dumps LISTED. It states the data is unchanged because that is true and load-bearing: the
// replay only ever writes into volumes, so a replay that did nothing removed nothing, and a customer
// who believes otherwise will do something worse than waiting.
unitRestoreNoneReturnedMsgFmt = "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod."
)
// C9-F1 customer-facing strings. Kept as named constants, not inlined, because both are asserted
// verbatim by tests — a silent edit to either is the way an honest message drifts back into a
// comforting one.
+39 -4
View File
@@ -438,6 +438,15 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
// R-358: the same server-side refusal as the place handler, and it matters MORE here — this is the
// destructive path. On 2026-08-21 „Teljes visszaállítás indítása" was offered over a part-copy left
// by a failed download and reported ok=true. The wizard's RestoreEnabled flag controls a button;
// this controls the operation.
if !s.backupMgr.OffboxFullScratchReady(app) {
s.logger.Printf("[WARN] [web] off-box reconstitute refused for %s: the restore scratch carries no completion marker", app)
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
return
}
// R-351: a SEPARATE field from `confirm`. The restore's own confirm answers "overwrite my live
// data"; this one answers "yes, into a different place than the backup recorded". One checkbox
// carrying both would be the two-decisions-one-button shape R-48 removed from this surface.
@@ -459,6 +468,12 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás elindult — az állapot itt frissül.", false)
}
// offsiteScratchIncompleteMsg (R-358) is the server-side refusal shown when a place or reconstitute is
// attempted over a scratch that carries no completion marker. Named because two handlers assert it and
// two tests assert it verbatim. It names the action that works — Lane 1 is customer-owned, so a refusal
// that leaves the customer with no next step is not a refusal, it is a dead end.
const offsiteScratchIncompleteMsg = "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését."
// reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the
// wording is unit-testable — this string is the customer's only evidence that the operation did
// what its label promised, and the zero-file and no-database cases must each read truthfully rather
@@ -509,8 +524,15 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
// `backups/offsite-restore` root it computed itself and refuses anything that lands outside (see
// DeleteOffsiteRestoreCopy). The template double-confirms before POSTing.
//
// It refuses while a backup/restore op is running: the copy being deleted could be the one currently
// being written.
// R-360 — THE DOC COMMENT USED TO CLAIM THIS AND THE CODE DID NOT DO IT, which is why nobody looked.
// It read "It refuses while a backup/restore op is running"; the guard was `s.backupMgr.IsRunning()`,
// which answers "is a BACKUP running" and is FALSE for the whole of a verification restore (see the
// standing note on the two running flags). Its five siblings on this surface all used
// `restoreOpBlocked()`, which consults both; this one was missed. Observed live 2026-08-21 22:35:
// `RestoreStatus().Running == true` while `IsRunning() == false`, and the delete of the copy the
// restore was writing into went through.
//
// It now refuses while ANY backup or restore op is running.
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
@@ -526,8 +548,13 @@ func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Re
offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet fut — a törlés most nem biztonságos.", true)
// R-360: `restoreOpBlocked()` and not `IsRunning()`. No app-name comparison is added deliberately:
// refusing during ANY restore is strictly stronger than refusing only for the restoring app, and it
// is the rule the other five handlers on this surface already follow. Uniformity is worth more than
// precision here — the defect was one handler being different.
if msg, blocked := s.restoreOpBlocked(); blocked {
s.logger.Printf("[WARN] [web] verification-copy delete refused for %s: a backup/restore op is running", stack)
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
@@ -556,6 +583,14 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
// R-358: refuse SERVER-SIDE over an incomplete scratch. The wizard already hides the button when
// PlaceEnabled is false — and a hidden button is not a guard. This handler is reachable by a POST,
// and before v0.226.0 a POST over a failed download was accepted and reported success.
if !s.backupMgr.OffboxFullScratchReady(app) {
s.logger.Printf("[WARN] [web] off-box place refused for %s: the restore scratch carries no completion marker", app)
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
return
}
s.backupMgr.BeginRestoreOp("offbox-place", app)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
@@ -0,0 +1,183 @@
package web
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"io"
"log"
"os"
"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-353 — a local restore that gave back nothing still said it worked ──────────────────────────
//
// Observed on demo-hp 2026-08-21: an `opengist` restore reported „opengist visszaállítva (<snapshot>)."
// over a recovery unit holding manifest.json and compose/ and nothing else. The customer reads that as
// "my data is back". It was not, and no screen in the product could have said so — the count of what
// came back was discarded one line below the function that produced it.
//
// The three cases below are three DIFFERENT facts and collapsing any two is the whole defect. The
// wording of the middle one is constrained by R-355 and by 07-backup-architecture §6.3: it is a claim
// about THE BACKUP, never about the app, because an absent dump has causes that say nothing about
// whether the app has data (R-361 destroyed apps' canonical .sql files for four months).
func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) {
msg := unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{
VolumesReplayed: 2, DBsReplayed: 1, ManifestVolumes: 2, ManifestDBs: 1,
})
for _, want := range []string{"2 adatkötet", "az adatbázis"} {
if !strings.Contains(msg, want) {
t.Errorf("a restore that returned data must NAME it; missing %q in %q", want, msg)
}
}
if strings.Contains(msg, "FIGYELEM") {
t.Errorf("a fully successful restore must not carry a warning; got %q", msg)
}
if strings.Contains(msg, "visszaállítva (") {
t.Errorf("the snapshot-id sentence is the pre-fix shape and says nothing about what came back; got %q", msg)
}
}
func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) {
msg := unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{})
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
if !strings.Contains(msg, want) {
t.Errorf("a restore that returned no data must say so plainly; missing %q in %q", want, msg)
}
}
// R-355: the forbidden inference. The manifest cannot support a claim about the APP, and on the
// off-site path the equivalent sentence was printed over a live 72-table PostgreSQL.
for _, forbidden := range []string{"nincs adata", "nincs adatbázisa", "alkalmazásnak nincs"} {
if strings.Contains(msg, forbidden) {
t.Fatalf("FALSE CLAIM about the app inferred from a counter (%q) in %q", forbidden, msg)
}
}
}
func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) {
msg := unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{
VolumesReplayed: 0, DBsReplayed: 0, ManifestVolumes: 2, ManifestDBs: 1,
})
for _, want := range []string{"2 adatkötetet", "1 adatbázis-mentést", "változatlanok maradtak"} {
if !strings.Contains(msg, want) {
t.Errorf("the unit listed data that did not come back — the message must say so; missing %q in %q", want, msg)
}
}
// The Scenario B sentence would say the backup held only settings, which the manifest contradicts.
if strings.Contains(msg, "csak a beállításokat tartalmazta") {
t.Fatalf("wrong case: said the backup held only settings while its manifest lists 3 dumps; got %q", msg)
}
}
func TestUnitRestoreOutcome_DatabaseOnly(t *testing.T) {
msg := unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{
VolumesReplayed: 0, DBsReplayed: 1, ManifestVolumes: 0, ManifestDBs: 1,
})
if !strings.Contains(msg, "az adatbázis visszaállítva") {
t.Errorf("a database-only restore must read naturally; got %q", msg)
}
if strings.Contains(msg, "adatkötet") {
t.Fatalf("named a volume count for a restore that replayed none; got %q", msg)
}
if strings.Contains(msg, "FIGYELEM") {
t.Errorf("data came back — this is not a warning case; got %q", msg)
}
}
// --- A5: THE SEAM TEST (§10) --------------------------------------------------------------------
//
// The one that matters. It drives the REAL backupRestoreHandler and reads the sentence off the
// op-status surface the customer's banner polls — not unitRestoreOutcomeMsg directly. Three shipped
// defects in this project came from testing a component whose caller never invoked it, and R-353 is
// itself an instance: restoreDockerVolumesFrom returned the count correctly the whole time.
type r353Provider struct {
hdd string
starts int32
}
func (p *r353Provider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *r353Provider) ListDeployedStacks() []backup.StackSummary { return nil }
func (p *r353Provider) GetStackHDDMounts(string) []string { return nil }
func (p *r353Provider) GetStackHDDPath(string) string { return p.hdd }
func (p *r353Provider) GetImportRoot() string { return "" }
func (p *r353Provider) GetDockerVolumes(string) []string { return nil }
func (p *r353Provider) StopStack(string) error { return nil }
func (p *r353Provider) StartStack(string) error { atomic.AddInt32(&p.starts, 1); return nil }
func (p *r353Provider) RefreshAndIsRunning(string) bool { return true }
func (p *r353Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
func (p *r353Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *r353Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
return nil
}
func (p *r353Provider) StartStackServices(string, []string) error { return nil }
func (p *r353Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
return nil, false
}
func TestR353_HandlerPublishesTheOutcome(t *testing.T) {
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
live := filepath.Join(tmp, "live")
if err := os.MkdirAll(live, 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: live, Label: "live"}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
prov := &r353Provider{hdd: live}
m.SetStackProvider(prov)
s := &Server{cfg: cfg, backupMgr: m, logger: lg}
req := httptest.NewRequest(http.MethodPost, "/backup/restore",
strings.NewReader("stack_name=opengist&snapshot_id=snap-123"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.backupRestoreHandler(w, req)
if w.Code != http.StatusFound {
t.Fatalf("want 302, got %d", w.Code)
}
// The restore runs in a background goroutine; poll the surface the banner polls.
var last string
for i := 0; i < 900; i++ {
st := m.RestoreStatus()
if !st.Running && st.Last.Message != "" {
last = st.Last.Message
break
}
time.Sleep(10 * time.Millisecond)
}
if last == "" {
t.Fatal("the restore never reached a terminal status")
}
// There is no recovery unit and no data on this drive, so nothing came back: Scenario B.
if strings.Contains(last, "visszaállítva (snap-123)") {
t.Fatalf("THE PRE-FIX SENTENCE REACHED THE CUSTOMER: %q — it is true of a restore that "+
"returned an entire dataset and of one that returned nothing", last)
}
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
if !strings.Contains(last, want) {
t.Fatalf("the published outcome does not state that no data came back; missing %q in %q", want, last)
}
}
}
@@ -0,0 +1,223 @@
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")
}
}