diff --git a/controller/internal/backup/r103_file_restore_untouched_test.go b/controller/internal/backup/r103_file_restore_untouched_test.go new file mode 100644 index 0000000..69dc43b --- /dev/null +++ b/controller/internal/backup/r103_file_restore_untouched_test.go @@ -0,0 +1,103 @@ +package backup + +import ( + "os" + "path/filepath" + "testing" +) + +// R-103 Group C — the ADDITIVE file restore is untouched. +// +// R-102 gave the Tier-2 copy a second, DESTRUCTIVE action. The file restore beside it is proven live +// (Campaign 9 A1 and A3, 39 s and 46 s, byte-identical returns) and its promises are load-bearing: +// nothing live is overwritten and nothing is deleted. This group is the non-regression assertion, and +// it is written first-class rather than as an afterthought, because a silent widening here would turn +// „restore my deleted files" into „overwrite everything with last night's copy". + +// C1 — TestR103_CanRestoreStillAnswersLegsOnly. +// +// CanRestore() gates the additive restore and must keep answering exactly one question. Widening it +// to include the unit is R-356 arriving a second time: there, ONE predicate meant both "has this app +// a drive?" and "is this app installed?", and it refused 40 running apps for months. +// +// Red-proof (recorded in REPORT.md): make CanRestore return `len(c.Legs) > 0 || c.HasUnit` → the +// unit-only case below fails. +func TestR103_CanRestoreStillAnswersLegsOnly(t *testing.T) { + cases := []struct { + name string + cov Tier2Coverage + want bool + }{ + {"no legs, no unit", Tier2Coverage{}, false}, + {"no legs, unit present", Tier2Coverage{HasUnit: true}, false}, + {"no legs, unit RESTORABLE", Tier2Coverage{HasUnit: true, UnitRestorable: true}, false}, + {"legs present", Tier2Coverage{Legs: []string{"hdd"}}, true}, + {"legs and unit", Tier2Coverage{Legs: []string{"hdd", "userdata"}, HasUnit: true, UnitRestorable: true}, true}, + } + for _, c := range cases { + if got := c.cov.CanRestore(); got != c.want { + t.Errorf("%s: CanRestore() = %v, want %v", c.name, got, c.want) + } + } + // And the second predicate answers its own question, independently of the first. + if (Tier2Coverage{Legs: []string{"hdd"}}).CanRestoreUnit() { + t.Error("CanRestoreUnit() went true on file legs alone — the two predicates are entangled") + } + if !(Tier2Coverage{UnitRestorable: true}).CanRestoreUnit() { + t.Error("CanRestoreUnit() went false on a restorable unit") + } +} + +// C2 — TestR103_FileRestoreBehaviourUnchanged. The additive restore's COUNTS and its two +// non-destruction promises, over a copy that also holds a restorable unit — the case R-102 created +// and the one where a leak between the paths would show. +func TestR103_FileRestoreBehaviourUnchanged(t *testing.T) { + m, fake, liveDrive, destDrive := newT2RManager(t) + destBase := filepath.Join(destDrive, "backups", "secondary", "app") + + // The copy holds BOTH: a file leg and a full, openable recovery unit. + mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "photos", "gone.jpg"), "RESTORED") + mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "photos", "kept.jpg"), "FROM-THE-BACKUP") + unit := tier2UnitDir(destBase) + mustWrite(t, UnitManifestFile(unit), `{"schema_version":1,"app_name":"app"}`) + mustWrite(t, filepath.Join(UnitVolumeDumpDir(unit), "vol_a.tar"), "tar") + + // Live: one of the two files exists with DIFFERENT content, and a third file exists only live. + liveDir := filepath.Join(liveDrive, "appdata", "photos") + mustWrite(t, filepath.Join(liveDir, "kept.jpg"), "THE-CUSTOMERS-NEWER-EDIT") + mustWrite(t, filepath.Join(liveDir, "only-live.jpg"), "NOT-IN-THE-BACKUP") + + cov, err := m.Tier2RestoreCoverage("app") + if err != nil { + t.Fatalf("coverage: %v", err) + } + if !cov.CanRestoreUnit() { + t.Fatal("fixture is wrong: the unit must be restorable, or this proves nothing") + } + + n, err := m.RestoreTier2Files("app") + if err != nil { + t.Fatalf("file restore: %v", err) + } + // Two: `gone.jpg` above and `a.jpg` from the shared fixture. NOT three — `kept.jpg` exists live + // and is skipped — and NOT more, which is what a leak from the unit's volume tars would look like. + if n != 2 { + t.Errorf("filesRestored = %d, want 2 (the two MISSING files only) — the file restore's reach changed", n) + } + if got, _ := os.ReadFile(filepath.Join(liveDir, "gone.jpg")); string(got) != "RESTORED" { + t.Errorf("the missing file did not come back: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(liveDir, "kept.jpg")); string(got) != "THE-CUSTOMERS-NEWER-EDIT" { + t.Errorf("an EXISTING live file was overwritten: %q — the additive promise is broken", got) + } + if _, sErr := os.Stat(filepath.Join(liveDir, "only-live.jpg")); sErr != nil { + t.Error("a live file absent from the backup was DELETED — the second non-destruction promise is broken") + } + if len(fake.stopped) != 1 || len(fake.started) != 1 { + t.Errorf("lifecycle changed: stopped=%v started=%v", fake.stopped, fake.started) + } + // The unit the file restore does not read is untouched by it. + if got, _ := os.ReadFile(filepath.Join(UnitVolumeDumpDir(unit), "vol_a.tar")); string(got) != "tar" { + t.Error("the file restore wrote into the copy's recovery unit") + } +} diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index bcad5e7..d5a5384 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -309,13 +309,12 @@ func (s *Server) templateFuncMap() template.FuncMap { // moment the customer decides whether to restore — a UTC machine timestamp is not something a // customer can reason about. Absolute rather than relative here on purpose: "3 napja" is fine on // a status card, but a restore decision deserves the actual date. - "fmtTimeStr": func(s string) string { - t, err := time.Parse(time.RFC3339, s) - if err != nil { - return s - } - return t.In(loc).Format("2006-01-02 15:04") - }, + // + // R-102: it delegates to the package-level fmtRFC3339Local so a HANDLER can render the same + // date the same way. The Tier-2 unit restore names the copy's date in its confirm (template) + // and again in its outcome (Go); two renderings of one decision that could disagree is how a + // customer ends up confirming one date and being told another. + "fmtTimeStr": fmtRFC3339Local, "fmtTime": func(t time.Time) string { if t.IsZero() { return "–" @@ -497,3 +496,14 @@ func (s *Server) templateFuncMap() template.FuncMap { }, } } + +// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time, returning the +// input unchanged when it does not parse. The ONE implementation behind the `fmtTimeStr` template +// helper and every Go-side caller. See the comment on the funcmap entry for why it is shared. +func fmtRFC3339Local(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return t.In(getTimezone()).Format("2006-01-02 15:04") +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 31757ba..ace7bd6 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1187,6 +1187,23 @@ type AppBackupRow struct { Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" Tier2SizeHuman string + // R-102/R-103 — the SECOND predicate, and it is a second field on purpose. + // + // Tier2UnitRestorable the copy holds an OPENABLE recovery unit (manifest present and parseable) + // → the destructive „Teljes visszaállítás" action is offered + // Tier2CopyDate the RFC3339 stamp of the copy that action would write over live data with + // Tier2CopyDateProven false = that stamp is only an ATTEMPT clock, never a proven copy (R-101) + // + // Tier2UnitRestorable is NOT derived from Tier2LastStatus, Tier2SizeHuman or anything else on this + // row: it is computed from what the copy on disk actually holds, because a row that says a backup + // succeeded is not evidence that the package inside it can be opened. + Tier2UnitRestorable bool + Tier2CopyDate string + Tier2CopyDateProven bool + // Tier2UnitConfirm is the assembled destructive-confirm sentence (tier2UnitConfirmMsg). Built in + // Go, not in the attribute, so it is one named string a test can assert verbatim. + Tier2UnitConfirm string + // Drive disconnected — app's home drive is currently disconnected DriveDisconnected bool // Tier2 destination drive is currently disconnected (backup paused, not failed) @@ -1404,6 +1421,16 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup row.Tier2LastError = cd.LastError row.Tier2LastWarning = cd.LastWarning row.Tier2SizeHuman = cd.LastSizeHuman + // R-102: ask the COPY, not the config. Tier2RestoreCoverage stats the recorded copy + // and reads its manifest, and it raises the same refusals the restore itself would — + // a disconnected destination, a pre-v2 layout — so an offer is never rendered for a + // copy the action would refuse. On any refusal the action is simply not offered; the + // row keeps rendering everything else it already showed. + if cov, covErr := s.backupMgr.Tier2RestoreCoverage(app.StackName); covErr == nil { + row.Tier2UnitRestorable = cov.CanRestoreUnit() + row.Tier2CopyDate, row.Tier2CopyDateProven = cov.Tier2CopyDate() + row.Tier2UnitConfirm = tier2UnitConfirmMsg(row.Tier2CopyDate, row.Tier2CopyDateProven) + } switch cd.LastStatus { case "ok": row.Tier2StatusBadge = "Sikeres" @@ -1580,18 +1607,102 @@ const monitoringIntegritySchedule = "Hetente, kimarado ellenorzest potol" // verbatim by tests — a silent edit to either is the way an honest message drifts back into a // comforting one. const ( - // tier2NoCoverageMsg is shown when this app's data cannot come from the secondary copy at all. - // It NAMES the action that works rather than leaving a dead end: the keep-side recovery-unit - // restore on /backups/restore, which does restore named volumes and DB dumps (proven live, - // Campaign 9 A2). It also states plainly that no outage was taken, because the previous behaviour - // took one. + // tier2NoCoverageMsg is shown when this app's data cannot come from the secondary copy AT ALL — + // no file legs AND no openable recovery unit in the copy. It NAMES the action that works rather + // than leaving a dead end: the keep-side recovery-unit restore on /backups/restore, which does + // restore named volumes and DB dumps (proven live, Campaign 9 A2). It also states plainly that no + // outage was taken, because the previous behaviour took one. + // + // R-103 NARROWED IT. Until v0.229.0 this same sentence was also shown to the far commoner case — + // no file legs but a full unit mirror sitting in the copy — and it sent those customers to a + // button on another page for data that is now restorable on the page they are already looking at. + // tier2UnitAvailableMsg is that case now. tier2NoCoverageMsg = "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon." - // tier2UnitNotCoveredMsg is appended wherever the restore DID run, so a clean result never reads - // as a clean bill of health for data the operation never opened. + // tier2UnitAvailableMsg (R-103) — the copy holds no restorable FILES, but it does hold an openable + // recovery unit, so the answer is the action beside this one, not a different page. It names the + // button by its own label and says why the two differ, because the difference is the whole reason + // they are not one button: this one overwrites. + tier2UnitAvailableMsg = "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja." + + // tier2UnitNotCoveredMsg is appended wherever the FILE restore DID run, so a clean result never + // reads as a clean bill of health for data the operation never opened. + // + // R-103: it is NOT deleted now that the unit is restorable. It is appended where the FILE restore + // ran, and it is still exactly true of that restore — the file merge still never opens the + // database or the named volumes. Deleting it would let a clean file-restore result read as a clean + // bill of health for data the operation did not look at, which is the sentence it exists to + // prevent. tier2UnitNotCoveredMsg = "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba." + + // The Tier-2 UNIT restore's outcome suffix (R-102, Scenario E). The outcome names WHICH copy was + // just written over the app's live data — an action that overwrites must say what it overwrote + // with, in the sentence the customer is left holding. + tier2UnitRestoreSourceMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s)." + + // …and the R-101 variant. CopyLastRun is the ATTEMPT clock: it advances on a FAILED Tier-2 run + // too. Where no success has ever been recorded for this app, the date shown is evidence that a + // copy was attempted and nothing more, and the sentence must not present it as evidence of a copy. + tier2UnitRestoreSourceUnprovenMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt)." + + // R-102/R-103 — the DESTRUCTIVE CONFIRM, in pieces, and in Go rather than in the template. + // + // It lives here for the same reason the R-353 outcome strings do: this sentence is the only thing + // standing between a customer and the loss of everything they made since the copy was taken, and a + // silent edit to it is how a warning drifts into a reassurance. Named constants can be asserted + // verbatim by a test; a sentence assembled inside an HTML attribute cannot, and R-364 makes + // grepping accented Hungarian out of rendered markup an unreliable check on top of that. + // + // The three parts are three separate obligations: + // Base — what this action DOES: it overwrites, including the database and internal volumes. + // Date — WHICH copy it overwrites with. Two forms, because a stamp that only records an + // ATTEMPT must not be presented as the date of a copy (R-101). + // Contrast — how it differs from the additive button beside it. The register's own requirement: + // a destructive operation reached from a non-destructive surface must carry the + // difference in the confirm, not rely on the customer inferring it from a label. + tier2UnitActionLabel = "Teljes visszaállítás a másolatból" + + tier2UnitConfirmBase = "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik." + + tier2UnitConfirmDateFmt = " A másolat kelte: %s." + + tier2UnitConfirmDateUnprovenFmt = " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt." + + tier2UnitConfirmContrast = " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll." ) +// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so +// the wording is unit-testable; the date is rendered by the SAME helper the rest of the surface uses. +// +// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the +// whole confirm because a date is missing would remove the warning and keep the destruction. +func tier2UnitConfirmMsg(copyDate string, proven bool) string { + msg := tier2UnitConfirmBase + if copyDate != "" { + if proven { + msg += fmt.Sprintf(tier2UnitConfirmDateFmt, fmtRFC3339Local(copyDate)) + } else { + msg += fmt.Sprintf(tier2UnitConfirmDateUnprovenFmt, fmtRFC3339Local(copyDate)) + } + } + return msg + tier2UnitConfirmContrast +} + +// tier2UnitSourceMsg renders the "which copy" clause for the Tier-2 unit restore's outcome, or "" if +// no date is recorded at all. It asks Tier2CopyDate — the SAME resolver the surface uses to pick the +// date it puts in the confirm — so the sentence the customer approves and the sentence they are left +// with cannot name different copies. +func tier2UnitSourceMsg(cov backup.Tier2Coverage) string { + date, proven := cov.Tier2CopyDate() + if date == "" { + return "" + } + if proven { + return fmt.Sprintf(tier2UnitRestoreSourceMsgFmt, fmtRFC3339Local(date)) + } + return fmt.Sprintf(tier2UnitRestoreSourceUnprovenMsgFmt, fmtRFC3339Local(date)) +} + // backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its // recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is // ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler. @@ -1626,10 +1737,19 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques // about data the restore never examined, at the exact moment they pressed it BECAUSE data was // missing. Only the no-coverage case is pre-flighted; every other refusal keeps its existing async // path so this change cannot alter behaviour anywhere else. + // + // R-103 SPLIT THE REFUSAL IN TWO. „no files to restore" has two different answers now, and giving + // both customers the same sentence is what sent one of them to another page for data that is + // restorable on this one. cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName) if covErr == nil && !cov.CanRestore() { - s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit) - http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound) + msg := tier2NoCoverageMsg + if cov.CanRestoreUnit() { + msg = tier2UnitAvailableMsg + } + s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v unit_restorable=%v) — app NOT stopped", + stackName, cov.HasUnit, cov.CanRestoreUnit()) + http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound) return } @@ -1667,6 +1787,84 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } +// backupTier2UnitRestoreHandler (R-102/R-103) restores an app IN FULL from the recovery unit mirrored +// onto the SECOND DRIVE — the app's database dumps and named-volume tars, not just its loose files. +// +// It is the destructive twin of backupTier2RestoreHandler above and shares its shape deliberately: +// same guards, same single-writer refusal, same async goroutine, same status banner. What it does not +// share is its promise. The file restore only ever ADDS what is missing; this one OVERWRITES the +// app's live data with the copy's. The template's confirm carries that difference in words, and the +// two actions stay two buttons for exactly that reason (§8: they are different promises). +// +// The pre-flight refusal is the fail-closed gate: a `recovery-unit/` directory that exists is not a +// package. Refusing here means the app is never stopped for a mirror that could not have been read. +func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + stackName := r.FormValue("stack_name") + + if stackName == "" { + http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) + return + } + // Same F2-defense as both restores beside it: a stack name is a single segment, never a path. + if !validStackName(stackName) { + s.logger.Printf("[WARN] [web] Tier-2 unit restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr) + http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) + return + } + if s.backupMgr == nil { + http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) + return + } + // R-351b (Scenario H): a second press — by button or by a direct POST — must not start a second + // run. restoreOpBlocked() and not IsRunning(), because the concurrency flag is only taken inside + // the goroutine, after this handler has already returned. + if msg, blocked := s.restoreOpBlocked(); blocked { + http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(msg), http.StatusFound) + return + } + + // Pre-flight, before any op is begun and before the app is stopped: does this copy hold a unit + // this restore can actually open? Only the no-unit case is pre-flighted; every other refusal keeps + // its existing async path, so this cannot alter behaviour anywhere else. + cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName) + if covErr != nil { + s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s: %v — app NOT stopped", stackName, covErr) + http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(covErr.Error()), http.StatusFound) + return + } + if !cov.CanRestoreUnit() { + s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s has no openable unit in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit) + http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound) + return + } + + s.logger.Printf("[WARN] [web] Tier-2 UNIT restore requested (async, OVERWRITES live data): stack=%s from %s", stackName, r.RemoteAddr) + s.backupMgr.BeginRestoreOp("tier2-unit-restore", stackName) + go func() { + start := time.Now() + res, err := s.backupMgr.RestoreTier2Unit(stackName) + if err != nil { + s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err) + s.backupMgr.EndRestoreOp(false, "Teljes visszaállítás sikertelen: "+err.Error()) + return + } + s.logger.Printf("[INFO] [web] Tier-2 unit 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) + // The outcome sentence is yesterday's — unitRestoreOutcomeMsg, unchanged, because what came + // back is the same fact whichever unit it came out of, and a second wording of it would be a + // second thing to keep honest. What IS added is which copy it came from and how old that copy + // is: this action overwrote the customer's live data, and the sentence they are left with has + // to say what it overwrote it with (Scenario E). + msg := unitRestoreOutcomeMsg(stackName, res) + if src := tier2UnitSourceMsg(cov); src != "" { + msg += " " + src + } + s.backupMgr.EndRestoreOp(true, msg) + }() + http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Teljes visszaállítás elindult — az állapot itt frissül."), http.StatusFound) +} + // settingsBaseData is the shared identity block used by every settings-family subpage // (D1 split: /settings, /settings/notifications, /settings/security, /storage). func (s *Server) settingsBaseData(page, title string) map[string]interface{} { diff --git a/controller/internal/web/r103_tier2_action_test.go b/controller/internal/web/r103_tier2_action_test.go new file mode 100644 index 0000000..dfd8c4b --- /dev/null +++ b/controller/internal/web/r103_tier2_action_test.go @@ -0,0 +1,413 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "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-103 Group D — the surface: the refusal becomes an action. +// +// Before v0.229.0 an app whose Tier-2 copy held only a recovery unit got a message telling it to +// press a button on a DIFFERENT page. The data it was asking for is in the copy it is looking at, and +// since R-102 it is restorable from there. The action now lives where the refusal was. +// +// Every string assertion here compares against the NAMED CONSTANT rather than a Hungarian literal +// retyped in the test. That is R-364 discipline in its strongest form: a grep for accented text +// returned zero for strings that were present, and a re-typed literal can differ from the shipped one +// by a character nobody sees. + +// --- the surface (template) ----------------------------------------------------------------- + +// r103Row is a Tier-2-configured row with a successful copy — the shape the template needs before it +// will render the actions block at all. +func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow { + row := AppBackupRow{ + StackName: "docmost", DisplayName: "Docmost", + Tier2Configured: true, Tier2Dest: "flash", Tier2Schedule: "Naponta", + Tier2LastRun: date, Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres", + Tier2LastSuccess: date, Tier2SuccessTracked: true, + Tier2UnitRestorable: unitRestorable, + } + if unitRestorable { + row.Tier2CopyDate, row.Tier2CopyDateProven = date, proven + row.Tier2UnitConfirm = tier2UnitConfirmMsg(date, proven) + } + return row +} + +// D1 — TestR103_UnitActionOfferedWhenTheMirrorExists. +func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) { + html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ + r103Row(true, "2026-08-25T03:30:00Z", true), + })) + if !strings.Contains(html, `action="/backup/tier2/unit-restore"`) { + t.Error("the unit-restore form is not on the page — the refusal is still a dead end") + } + if !strings.Contains(html, tier2UnitActionLabel) { + t.Errorf("the action is not labelled %q", tier2UnitActionLabel) + } + // The additive action must still be there, separately. Merging them is forbidden: they are + // different promises (one adds, one overwrites). + if !strings.Contains(html, `action="/backup/tier2/restore"`) { + t.Error("the additive file restore disappeared from the row") + } + // The destructive one is visually distinct from the additive one beside it. + if !strings.Contains(html, "btn-danger-outline") { + t.Error("the destructive action does not carry a danger style") + } +} + +// D2 — TestR103_UnitActionAbsentWhenItDoesNot. Scenario G: no legs and no openable unit → an honest +// refusal, and NO unit action. A button offered over a copy the restore would refuse is worse than no +// button, because it is a promise withdrawn at the moment of use. +func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) { + html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ + r103Row(false, "2026-08-25T03:30:00Z", true), + })) + if strings.Contains(html, "/backup/tier2/unit-restore") { + t.Error("the unit action was offered for a copy with no openable unit") + } + if strings.Contains(html, tier2UnitActionLabel) { + t.Error("the unit action's label leaked onto a row that must not offer it") + } + // NEGATIVE CONTROL for D1's assertions: the row itself did render, so D1's positives were not + // an artefact of the whole block being absent. + if !strings.Contains(html, `action="/backup/tier2/restore"`) { + t.Fatal("the row did not render at all — D1's positive assertions prove nothing") + } +} + +// D3 — TestR103_ConfirmStatesTheOverwrite. The confirm must carry the DIFFERENCE the register +// requires: a destructive operation reached from a non-destructive surface says so, and says how it +// differs from the action beside it. +func TestR103_ConfirmStatesTheOverwrite(t *testing.T) { + confirm := tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true) + + if !strings.Contains(confirm, tier2UnitConfirmBase) { + t.Error("the confirm does not state that the operation OVERWRITES live data") + } + if !strings.Contains(confirm, tier2UnitConfirmContrast) { + t.Error("the confirm does not state how it differs from the additive restore beside it") + } + // NEGATIVE CONTROL: the additive restore's own confirm must NOT have acquired this language. + // Without it, a test that passed because both buttons warn about overwriting would look green. + html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{ + r103Row(true, "2026-08-25T03:30:00Z", true), + })) + fileConfirmStart := strings.Index(html, "Visszaállítja a hiányzó fájlokat") + if fileConfirmStart < 0 { + t.Fatal("the additive confirm is gone — the negative control has nothing to check") + } + fileConfirm := html[fileConfirmStart:] + if end := strings.Index(fileConfirm, `">`); end > 0 { + fileConfirm = fileConfirm[:end] + } + if strings.Contains(fileConfirm, "FEL") { + t.Errorf("the ADDITIVE confirm gained overwrite language: %q", fileConfirm) + } + // And the confirm reaches the markup as the rendered attribute, not only as a Go constant. + if !strings.Contains(html, `data-confirm=`) { + t.Error("no data-confirm attribute rendered") + } + if !strings.Contains(html, "FEL") { + t.Error("the destructive wording never reached the page") + } +} + +// D4 — TestR103_ConfirmNamesTheCopyDate. Scenario E. The action overwrites live data with a copy of a +// certain age, and „nobody restores last week over today by accident" is only true if the date is on +// the screen. R-101 governs the wording when the date is an ATTEMPT rather than a proven copy. +func TestR103_ConfirmNamesTheCopyDate(t *testing.T) { + const stamp = "2026-08-25T03:30:00Z" + rendered := fmtRFC3339Local(stamp) + if rendered == stamp { + t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous") + } + + proven := tier2UnitConfirmMsg(stamp, true) + if !strings.Contains(proven, rendered) { + t.Errorf("the confirm does not name the copy's date: %q", proven) + } + unproven := tier2UnitConfirmMsg(stamp, false) + if !strings.Contains(unproven, rendered) { + t.Errorf("the unproven confirm does not name the date: %q", unproven) + } + if proven == unproven { + t.Error("a proven copy and a bare attempt got the SAME sentence — R-101 exactly") + } + // A copy with no recorded date still gets the warning; it just cannot name one. + none := tier2UnitConfirmMsg("", false) + if !strings.Contains(none, tier2UnitConfirmBase) || !strings.Contains(none, tier2UnitConfirmContrast) { + t.Errorf("a dateless copy lost its confirm entirely: %q", none) + } + + // And it is on the page, not merely constructible. + html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(true, stamp, true)})) + if !strings.Contains(html, rendered) { + t.Errorf("the copy's date %q is not in the rendered row", rendered) + } +} + +// TestR103_AvailableMsgNamesTheButtonByItsLabel — the refusal that now points AT the new action must +// name it by the label the button actually carries, or it points at nothing. +func TestR103_AvailableMsgNamesTheButtonByItsLabel(t *testing.T) { + if !strings.Contains(tier2UnitAvailableMsg, tier2UnitActionLabel) { + t.Errorf("tier2UnitAvailableMsg does not name %q", tier2UnitActionLabel) + } + // It must NOT send anyone to another page any more; that is the R-103 defect it replaces. + if strings.Contains(tier2UnitAvailableMsg, "oldalon") { + t.Error("the message still routes the customer to another page for a copy restorable here") + } + // The surviving no-coverage message still names the route that works. + if !strings.Contains(tier2NoCoverageMsg, "oldalon") { + t.Error("tier2NoCoverageMsg no longer names any route — Scenario G requires one") + } + // C3 — tier2UnitNotCoveredMsg is NOT deleted: it is still appended where the FILE restore ran. + if tier2UnitNotCoveredMsg == "" { + t.Fatal("tier2UnitNotCoveredMsg was deleted") + } + if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + tier2UnitNotCoveredMsg`) { + t.Error("tier2UnitNotCoveredMsg is no longer appended by the file-restore handler") + } +} + +func readSourceFile(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// --- the handler ---------------------------------------------------------------------------- + +// r103Provider is a StackDataProvider that completes every lifecycle call, so the restore goroutine +// runs to its outcome instead of parking. +type r103Provider struct{ hdd string } + +func (p *r103Provider) GetStackComposePath(string) (string, bool) { return "", false } +func (p *r103Provider) ListDeployedStacks() []backup.StackSummary { return nil } +func (p *r103Provider) GetStackHDDMounts(string) []string { return nil } +func (p *r103Provider) GetStackHDDPath(string) string { return p.hdd } +func (p *r103Provider) GetImportRoot() string { return "" } +func (p *r103Provider) GetDockerVolumes(string) []string { return nil } +func (p *r103Provider) StopStack(string) error { return nil } +func (p *r103Provider) StartStack(string) error { return nil } +func (p *r103Provider) RefreshAndIsRunning(string) bool { return true } +func (p *r103Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { + return backup.RecoveryInfo{}, false +} +func (p *r103Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) { + return nil, false +} +func (p *r103Provider) RecoverStackSecrets(string, []string) map[string]string { return nil } +func (p *r103Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error { + return nil +} +func (p *r103Provider) StartStackServices(string, []string) error { return nil } + +const r103CopyStamp = "2026-08-25T03:30:00Z" + +// newR103Server wires a Server over a real backup.Manager whose recorded Tier-2 copy for "app" holds +// an OPENABLE recovery unit mirror. Nothing is stubbed between the handler and the manager: the whole +// point of D6 is that the wiring is what is under test. +func newR103Server(t *testing.T, withUnit bool) (*Server, *backup.Manager) { + t.Helper() + tmp := t.TempDir() + live := filepath.Join(tmp, "usb") + dest := filepath.Join(tmp, "flash") + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{live, dest} { + if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil { + t.Fatal(err) + } + } + if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{ + Enabled: true, Method: "rsync", DestinationPath: dest, + LastRun: r103CopyStamp, LastSuccess: r103CopyStamp, SuccessTracked: true, LastStatus: "ok", + }); err != nil { + t.Fatal(err) + } + destBase := filepath.Join(dest, "backups", "secondary", "app") + write := func(rel, body string) { + p := filepath.Join(destBase, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write(".felhom-tier2-layout", "2") + if withUnit { + write("recovery-unit/manifest.json", `{"schema_version":1,"app_name":"app"}`) + write("recovery-unit/compose/app.yaml", "deployed: true\nenv:\n SUBDOMAIN: app\n") + write("recovery-unit/compose/docker-compose.yml", "services:\n app:\n image: example/app:1\n") + } else { + // A directory that exists and is not a package — the fail-closed case. + if err := os.MkdirAll(filepath.Join(destBase, "recovery-unit"), 0o755); err != nil { + t.Fatal(err) + } + } + cfg := &config.Config{} + cfg.Paths.DataDir = tmp + m := backup.NewManager(cfg, sett, lg) + m.SetStackProvider(&r103Provider{hdd: live}) + return &Server{cfg: cfg, backupMgr: m, logger: lg}, m +} + +func postTier2UnitRestore(t *testing.T, s *Server, stack string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"stack_name": {stack}} + req := httptest.NewRequest(http.MethodPost, "/backup/tier2/unit-restore", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + s.backupTier2UnitRestoreHandler(rec, req) + return rec +} + +func waitRestoreDone(t *testing.T, m *backup.Manager) backup.RestoreOpStatus { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + st := m.RestoreStatus() + if !st.Running && st.Last != nil { + return st + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("the restore never published a result") + return backup.RestoreOpStatus{} +} + +// D5 — TestR103_SecondPressIsRefused. Scenario H, R-351b. The concurrency flag is only taken inside +// the goroutine, AFTER the handler returns, so a guard reading IsRunning() alone leaves a window in +// which a second press starts a second run and is told „elindult". +func TestR103_SecondPressIsRefused(t *testing.T) { + s, m := newR103Server(t, true) + m.BeginRestoreOp("restore", "other-app") // a restore is already in flight, display-flag set + + rec := postTier2UnitRestore(t, s, "app") + loc := rec.Header().Get("Location") + if !strings.Contains(loc, "flash_error") { + t.Fatalf("a second press was accepted: %q", loc) + } + // The identity of the FIRST operation survives — the consequence, not which flag was read. + if st := m.RestoreStatus(); st.Stack != "other-app" { + t.Errorf("the in-flight op was replaced by the refused one: %+v", st) + } +} + +// D6 — TestR103_HandlerPublishesTheOutcome. THE SEAM TEST. +// +// It reads the outcome off RestoreStatus().Last.Message — the only place a customer sees it — rather +// than calling the manager and inspecting a return value. Three shipped defects in this project came +// from testing a component whose caller never invoked it (R-106's fakeObserver being the clearest), +// and the mechanism here is exactly that shape: a correct RestoreTier2Unit wired to nothing would +// leave the banner silent and every manager-level test green. +// +// Red-proof (recorded in REPORT.md): drop the `s.backupMgr.EndRestoreOp(true, msg)` call at the end +// of the handler's goroutine → this test fails on "the restore never published a result". +func TestR103_HandlerPublishesTheOutcome(t *testing.T) { + s, m := newR103Server(t, true) + + rec := postTier2UnitRestore(t, s, "app") + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rec.Code) + } + if loc := rec.Header().Get("Location"); strings.Contains(loc, "flash_error") { + t.Fatalf("the restore was refused: %q", loc) + } + + st := waitRestoreDone(t, m) + if !st.Last.OK { + t.Fatalf("outcome reported failure: %q", st.Last.Message) + } + if st.Last.Stack != "app" || st.Last.Op != "tier2-unit-restore" { + t.Errorf("the published result names the wrong operation: op=%q stack=%q", st.Last.Op, st.Last.Stack) + } + // The sentence is yesterday's honest outcome PLUS the clause naming which copy overwrote the live + // data (Scenario E). Asserted as an exact composition so neither half can silently vanish. + wantOutcome := unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{}) + wantSource := tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp}) + if wantSource == "" { + t.Fatal("the fixture recorded no copy date — the assertion below would be vacuous") + } + if want := wantOutcome + " " + wantSource; st.Last.Message != want { + t.Errorf("published message =\n %q\nwant\n %q", st.Last.Message, want) + } + if !strings.Contains(st.Last.Message, fmtRFC3339Local(r103CopyStamp)) { + t.Error("the outcome does not name the date of the copy it restored from") + } +} + +// TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping — the handler's fail-closed pre-flight. +// A directory is not a package, and refusing before BeginRestoreOp means no banner, no outage and no +// rewritten definition. +func TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping(t *testing.T) { + s, m := newR103Server(t, false) + + rec := postTier2UnitRestore(t, s, "app") + loc := rec.Header().Get("Location") + if !strings.Contains(loc, "flash_error") { + t.Fatalf("the restore was accepted over an unopenable mirror: %q", loc) + } + if !strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) { + t.Errorf("refusal flash = %q, want tier2NoCoverageMsg", loc) + } + if st := m.RestoreStatus(); st.Running || st.Last != nil { + t.Errorf("an operation was begun despite the refusal: %+v", st) + } +} + +// TestR103_UnitRestoreHandlerGuards — the same three guards the two restores beside it carry, proven +// to run BEFORE any work (backupMgr is nil, so reaching it would panic). +func TestR103_UnitRestoreHandlerGuards(t *testing.T) { + s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose + for name, want := range map[string]string{ + "../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", + "a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", + "": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek", + } { + rec := postTier2UnitRestore(t, s, name) + if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) { + t.Errorf("%q: redirect = %q, want flash %q", name, loc, want) + } + } + rec := postTier2UnitRestore(t, s, "docmost") + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") { + t.Errorf("nil backupMgr: redirect = %q", loc) + } +} + +// TestR103_FileRestoreRefusalPointsAtTheActionThatWorks — the R-103 split. An app with no file legs +// but a restorable unit gets the message that names the button beside it, NOT the one that sends it +// to another page. +func TestR103_FileRestoreRefusalPointsAtTheActionThatWorks(t *testing.T) { + s, _ := newR103Server(t, true) + rec := postTier2Restore(t, s, "app") + loc := rec.Header().Get("Location") + if !strings.Contains(loc, url.QueryEscape(tier2UnitAvailableMsg)) { + t.Errorf("refusal flash = %q, want tier2UnitAvailableMsg", loc) + } + if strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) { + t.Error("the old dead-end message is still shown for a copy restorable here") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 829d500..2ef251e 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -537,6 +537,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files) case path == "/backup/tier2/restore" && r.Method == http.MethodPost: s.backupTier2RestoreHandler(w, r) + // R-102/R-103: the DESTRUCTIVE twin — a full recovery-unit restore read from the SECOND DRIVE's + // mirror. Separate route because it is a separate promise: this one overwrites live data. + case path == "/backup/tier2/unit-restore" && r.Method == http.MethodPost: + s.backupTier2UnitRestoreHandler(w, r) // Off-box (NAS) restic-SFTP backup (Part B) case path == "/backup/offbox/config" && r.Method == http.MethodPost: s.offboxConfigHandler(w, r) diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index fa03a31..5e4a560 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -261,6 +261,17 @@ {{else}} Még nincs sikeres másolat, amiből vissza lehetne állítani. {{end}} + {{/* R-102/R-103: the DESTRUCTIVE twin of the button above. Offered only when the + copy holds an openable recovery unit (Tier2UnitRestorable — a second + predicate, never a widening of the file restore's). It is deliberately a + SEPARATE button in a danger style: the one beside it only adds what is + missing, this one overwrites the app's live data with the copy's. */}} + {{if .Tier2UnitRestorable}} +
{{$.CSRFField}} + + +
+ {{end}} Beállítás {{else}}