Compare commits

..

3 Commits

Author SHA1 Message Date
admin 33fcc502e4 CHANGELOG: controller v0.198.0 (R-204 items 1 and 3)
gates / gates (push) Successful in 10s
2026-08-05 07:17:32 +02:00
admin 2e936f43bf R-204 item 3: a restore says what it restored, and what it did not (v0.198.0)
mode=unit restores the recovery unit — the app's definition, configuration
and database dumps — and NOT the customer's own files: RestoreOffboxScratch
passes --include <unit path> and the userdata in the same snapshot is excluded
by it. The outcome was one sentence for both modes and named neither scope,
so on the last step of a disaster recovery the customer was told the app had
been restored after the thing they were looking for had not been.

restoreScratchOutcomeMsg states what came back, what did not, and the next
step that gets it. The wizard's intent card states its scope before the choice.
The full-restore size gate is untouched and pinned as unchanged; the default
stays unit, since all three wizard forms set mode explicitly.
2026-08-05 07:17:20 +02:00
admin 73b6dbc27d R-204 item 1: a freshly minted reset code works without a restart (v0.198.0)
--print-reset-code runs as a separate process and persists the new code;
the running server's cache was never told, so the code the customer was told
to type was refused until the controller restarted. Nothing said so — during
the 2026-08-04 drill that cost two attempts with an operator present.

effectiveClaimCode now reads through to the persisted state before applying
the settings-vs-config precedence, which is itself unchanged. Read-through,
not a TTL: a TTL would leave a window in which a superseded code still works,
which is worse than the bug. Fails closed on an unreadable state; an absent
file is not an error.
2026-08-05 07:17:13 +02:00
8 changed files with 637 additions and 15 deletions
+50
View File
@@ -1,3 +1,53 @@
## v0.198.0 — the four steps a customer would have hit alone: two of them closed (2026-08-05, R-204 items 1 & 3)
The 2026-08-04 recovery drill (R-201) passed — and it only passed because a person was there. Four
manual interventions stood between "the key is recoverable" and "the file is back". None of them is in
any design document. Two of the three defects are in this repo.
### Item 1 — a freshly minted reset code now works on the first attempt
`--print-reset-code` runs as a **separate process** (`docker exec`): it loads settings itself, mints a
code, persists it and exits. The running server's cache was never told, so it kept validating against
the previous hash. **The code the customer was told to type was refused until the controller
restarted, and nothing said so.** During the drill that cost two failed attempts with an operator
present; a customer alone stops there.
`effectiveClaimCode` now READS THROUGH to the persisted state (`settings.ReloadClaimCode`) before
applying the settings-vs-config precedence. **The precedence rule is unchanged and deliberate** — the
defect was the freshness of the settings value, not which source wins.
- **Read-through, not a watcher, a signal handler or a TTL.** A TTL is worse than the bug being fixed:
it opens a window in which a SUPERSEDED code still works. That is the mutation
`TestClaimCode_SupersededByASecondMint_RefusedImmediately` exists to kill, and its red-proof is
exactly that TTL — demonstrated failing with "the SUPERSEDED code was accepted".
- **Fail closed.** An unreadable persisted state keeps the gate up, refuses the claim and logs why. An
ABSENT file is not an error (a box before its first save falls back to the controller.yaml bake).
- Cost: one small file read per request **only while the box carries no password**`claimGateActive`
returns on `authEnabled()` before touching it, so a claimed box never reads.
### Item 3 — a restore now says what it did NOT restore
The default restore (`mode=unit`) recovers the recovery unit: the app's definition, its configuration
and its database dumps. It does **not** recover the customer's own files — `RestoreOffboxScratch`
passes `--include <unit path>`, and the userdata that is in the same snapshot is excluded by it. The
old outcome was one sentence for both modes and named neither scope, so on the last step of a disaster
recovery the customer was told „visszaállítva" after the thing they were looking for had not been.
- `restoreScratchOutcomeMsg` (pure, unit-testable) now states, for a unit restore: what came back, that
the customer's own files did NOT, and the next step that gets them. The full case says the files came
with it — otherwise the absence of the warning would be the only difference, and an absence is not a
statement.
- The wizard's intent card 1 states its scope **before** the choice, not only in the outcome.
- **The full-restore size gate is untouched** — still compute, reveal, confirm, re-check at execution.
Pinned by `TestOffboxRestore_FullPathUnchanged`, which asserts no restore runs before the confirm.
- **The default stays `unit`.** All three wizard forms set `mode` explicitly, so the `mode==""` fallback
is reachable only by a hand-crafted POST: changing it would alter nothing the customer sees while
silently changing that POST's behaviour. The defect was silence, and silence is what was fixed.
`TestOffboxRestore_DefaultModeGetsTheScopedOutcome` pins the mode-less POST to the scoped wording.
Item 2 of R-204 (a re-issue marking a healthy escrow stale) is the hub's half — felhom.eu v0.95.0.
Item 4 (a rebuilt box cannot obtain an off-site credential unaided) is R-193 and remains open.
## v0.197.0 — the app and its backup look in the same place, and "ok" means it (2026-08-04, R-203)
Found when the R-201 drill halted at its pre-wipe backup rather than wiping a machine: the run
+52
View File
@@ -628,6 +628,58 @@ func (s *Settings) GetClaimCode() (hash string, generation int, issuedAt string)
return s.ClaimCodeHash, s.ClaimCodeGeneration, s.ClaimCodeIssuedAt
}
// ReloadClaimCode re-reads the PERSISTED claim-code state from settings.json into the in-process
// cache, so a code minted by ANOTHER PROCESS is visible without restarting this one.
//
// WHY THIS EXISTS (R-204 item 1, v0.198.0). `--print-reset-code` runs as a separate process
// (`docker exec`): it loads settings itself, mints a code, persists it and exits. The running
// server's cache never heard, so it kept validating against the previous hash and the code the
// customer was told to type was refused until the controller restarted. Nothing said so. During the
// 2026-08-04 recovery drill that cost two failed attempts with an operator present; a customer alone
// stops there. THE READ IS THE FIX — it is not a cache refresh for tidiness.
//
// READ-THROUGH, NOT A WATCHER AND NOT A TTL, deliberately (R-204 §8.1). A watcher/signal/background
// reloader is a new failure mode for one stale read. A TTL is worse than the bug: it opens a window
// in which a SUPERSEDED code still works. Pinned by
// web.TestClaimCode_SupersededByASecondMint_RefusedImmediately, whose stated red-proof is exactly
// that TTL. The read happens on the claim path only — see web.effectiveClaimCode — and only while the
// box carries no password, i.e. exactly the gate window.
//
// WHAT IT REFRESHES AND WHAT IT DELIBERATELY DOES NOT: hash/generation/issuedAt only.
// ClaimConsumedGeneration is NOT re-read. This process is its only writer and its in-memory value is
// monotonic; re-reading it could move it BACKWARDS if a save had failed, which would resurrect an
// already-consumed code — the exact widening this fix is not allowed to introduce.
//
// An ABSENT file is not an error: a box before its first save legitimately has no persisted state and
// falls back to the controller.yaml bake. A present-but-unreadable or corrupt file IS an error, and
// the caller fails closed on it.
func (s *Settings) ReloadClaimCode() error {
if s.path == "" {
return nil // no persistence configured (tests) — the cache is all there is
}
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("reading persisted claim state: %w", err)
}
var onDisk struct {
ClaimCodeHash string `json:"claim_code_hash"`
ClaimCodeGeneration int `json:"claim_code_generation"`
ClaimCodeIssuedAt string `json:"claim_code_issued_at"`
}
if err := json.Unmarshal(data, &onDisk); err != nil {
return fmt.Errorf("parsing persisted claim state: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
s.ClaimCodeHash = onDisk.ClaimCodeHash
s.ClaimCodeGeneration = onDisk.ClaimCodeGeneration
s.ClaimCodeIssuedAt = onDisk.ClaimCodeIssuedAt
return nil
}
// SetClaimCode caches a hub-delivered code state (idempotent by generation — the caller guards).
func (s *Settings) SetClaimCode(hash string, generation int, issuedAt string) error {
s.mu.Lock()
+44 -7
View File
@@ -43,19 +43,32 @@ type claimAttempt struct {
// effectiveClaimCode returns the freshest hub-delivered claim-code state: the ACK-cached
// settings value when its generation is at least the config-baked one (fresher), else the
// controller.yaml bake. Returns ("", 0, "") when neither carries a code.
func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string) {
//
// R-204 item 1 (v0.198.0): it now READS THROUGH to the persisted settings first, because
// `--print-reset-code` mints its code in a SEPARATE PROCESS and this one's cache never heard — so a
// freshly minted code was refused until the controller restarted, and nothing said so. The
// PRECEDENCE RULE BELOW IS UNCHANGED and deliberate (settings wins only at an equal-or-newer
// generation); the defect was the freshness of the settings value, not which source wins.
//
// The read-through is why the ERROR RETURN exists: a persisted state that cannot be read must FAIL
// CLOSED at every caller (an absent file is not an error — see settings.ReloadClaimCode). A gate that
// opens because it could not read its own state is the shape this project has removed four times.
func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string, err error) {
var sHash, sIssued string
var sGen int
if s.settings != nil {
if rerr := s.settings.ReloadClaimCode(); rerr != nil {
return "", 0, "", rerr
}
sHash, sGen, sIssued = s.settings.GetClaimCode()
}
cHash := s.cfg.Web.ClaimCodeHash
cGen := s.cfg.Web.ClaimCodeGeneration
cIssued := s.cfg.Web.ClaimCodeIssuedAt
if sHash != "" && sGen >= cGen {
return sHash, sGen, sIssued
return sHash, sGen, sIssued, nil
}
return cHash, cGen, cIssued
return cHash, cGen, cIssued, nil
}
// claimGateActive reports whether the unclaimed-gate applies: no password set anywhere, a claim
@@ -65,7 +78,13 @@ func (s *Server) claimGateActive() bool {
if s.authEnabled() {
return false // a password beats the gate (claimed boxes, or an operator-set one)
}
hash, _, _ := s.effectiveClaimCode()
hash, _, _, err := s.effectiveClaimCode()
if err != nil {
// FAIL CLOSED. Unreadable claim state must not open the dashboard — keep the gate up. The
// claim page itself stays reachable (claimPageAllowedPath), so this is recoverable, not a brick.
s.logger.Printf("[ERROR] [web] claim: cannot read the persisted claim state — keeping the gate CLOSED: %v", err)
return true
}
if hash == "" {
return false // legacy-open (transition state) — no code to gate on
}
@@ -81,7 +100,10 @@ func (s *Server) claimLegacyOpen() bool {
if s.authEnabled() {
return false
}
hash, _, _ := s.effectiveClaimCode()
hash, _, _, err := s.effectiveClaimCode()
if err != nil {
return false // FAIL CLOSED: an unreadable state is not evidence the box is legacy-open
}
return hash == ""
}
@@ -244,7 +266,15 @@ func (s *Server) serveClaimGate(w http.ResponseWriter, r *http.Request) {
// the strong factor. For a claimed box (password set) it doubles as the reset-code entry.
func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) {
csrf := s.setClaimCSRFCookie(w, r)
hash, _, _ := s.effectiveClaimCode()
hash, _, _, cerr := s.effectiveClaimCode()
if cerr != nil {
// FAIL CLOSED on the page too: never invite a code we could not read the state for.
s.logger.Printf("[ERROR] [web] claim: cannot read the persisted claim state while rendering the claim page: %v", cerr)
hash = ""
if errorMsg == "" {
errorMsg = "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva."
}
}
reset := s.authEnabled() // a set password means this is the reset flow, not first-claim
data := map[string]interface{}{
"Title": "A szerver beállítása",
@@ -290,7 +320,14 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
newPassword := r.FormValue("new_password")
confirm := r.FormValue("confirm_password")
hash, generation, issuedAt := s.effectiveClaimCode()
hash, generation, issuedAt, cerr := s.effectiveClaimCode()
if cerr != nil {
// FAIL CLOSED: refuse the claim rather than validate against a possibly-superseded cache.
// NOT counted as a failed attempt — the customer typed nothing wrong.
s.logger.Printf("[ERROR] [web] claim: refusing the submission — the persisted claim state is unreadable: %v", cerr)
s.handleClaimPage(w, r, "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva.", "")
return
}
if hash == "" {
s.handleClaimPage(w, r, "Nincs aktív kód — kérj újat az alábbi gombbal.", "")
return
@@ -0,0 +1,211 @@
package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-204 item 1 — a freshly minted reset code must work on the FIRST attempt, with nothing restarted.
//
// WHAT THESE TESTS REPRODUCE, AND WHY THE SEAM IS WHERE IT IS. `--print-reset-code` is a SEPARATE
// PROCESS (`docker exec`): it calls settings.Load on the same file, mints, persists and exits. The
// running server never hears. So the mint below runs against a SECOND settings.Settings loaded from
// the SAME path — that is the whole defect, and a test that reused the server's own *Settings would
// mint straight into the cache and prove nothing. The submission then goes through the FULL MUX
// (RequireAuth + CsrfProtect + ServeHTTP), i.e. the production path, not handleClaimSubmit directly.
// claimReadThroughServer is claimTestServer's sibling: same wiring, but it installs NO code and
// returns the settings PATH, because these tests need a second process's view of that file.
func claimReadThroughServer(t *testing.T) (*Server, *config.Config, string) {
t.Helper()
lg := log.New(io.Discard, "", 0)
dir := t.TempDir()
cfg := &config.Config{}
cfg.Customer.ID = "c1"
cfg.Customer.Name = "Teszt"
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
cfg.Web.SessionSecret = "test-session-secret-abcdef"
path := filepath.Join(dir, "settings.json")
sett, err := settings.Load(path, lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatalf("stacks: %v", err)
}
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
s.loadTemplates()
return s, cfg, path
}
var hatchCodeRe = regexp.MustCompile(`(?m)^\s{4}([a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\s*$`)
// mintCodeViaHatch runs the REAL escape hatch exactly as `docker exec ... --print-reset-code` does:
// a fresh settings.Load on the same file (a separate process's cache), PrintLocalResetCode, exit.
// Returns the plaintext the operator would read off the terminal.
func mintCodeViaHatch(t *testing.T, path string, cfg *config.Config) string {
t.Helper()
other, err := settings.Load(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("hatch: settings.Load: %v", err)
}
r, w, perr := os.Pipe()
if perr != nil {
t.Fatalf("hatch: pipe: %v", perr)
}
saved := os.Stdout
os.Stdout = w
rc := PrintLocalResetCode(other, cfg)
os.Stdout = saved
w.Close()
out, _ := io.ReadAll(r)
r.Close()
if rc != 0 {
t.Fatalf("hatch: PrintLocalResetCode rc=%d, out=%q", rc, string(out))
}
m := hatchCodeRe.FindStringSubmatch(string(out))
if m == nil {
t.Fatalf("hatch: no code found in output %q", string(out))
}
return m[1]
}
// submitClaim POSTs the code + a new password through the FULL MUX. Returns the recorder.
func submitClaim(t *testing.T, s *Server, code, pw string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {pw}, "confirm_password": {pw}}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, req)
return rr
}
// SCENARIO A — a code minted by the hatch is accepted immediately, with NOTHING restarted.
//
// RED-PROOF (task §10): delete the `s.settings.ReloadClaimCode()` call in effectiveClaimCode. The
// server then validates against its startup cache (empty here), the submission is refused with
// „Nincs aktív kód" and this test fails. That mutation removes the ONLY guard this test covers.
func TestClaimCode_FreshlyMintedByHatch_AcceptedWithoutRestart(t *testing.T) {
s, cfg, path := claimReadThroughServer(t)
// Precondition: the server's cache is empty — this is the running process before the hatch.
if h, _, _, err := s.effectiveClaimCode(); err != nil || h != "" {
t.Fatalf("precondition: want no code and no error, got hash=%q err=%v", h, err)
}
code := mintCodeViaHatch(t, path, cfg)
// NOTHING is restarted and no reload is invoked by the test — the server must see it by itself.
rr := submitClaim(t, s, code, "a-strong-passphrase-12")
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" {
t.Fatalf("freshly minted code refused: got %d loc=%q body=%q — the running server did not see the persisted code",
rr.Code, rr.Header().Get("Location"), claimFirstLine(rr.Body.String()))
}
// Assert the EFFECT, not the absence of an error: the box is claimed and the password is live.
if !s.settings.GetClaimed() {
t.Fatal("box not marked claimed after accepting the freshly minted code")
}
if !s.authEnabled() {
t.Fatal("password not set after accepting the freshly minted code")
}
}
// SCENARIO B — the MOMENT a second code is minted, the first one stops working.
//
// This is the guard that matters: the plausible wrong fix is a TTL/interval cache, which would make
// the new code visible but leave a window in which the SUPERSEDED one still works — worse than the
// bug being fixed (task §8.1).
//
// RED-PROOF: substitute a TTL cache for the read-through (reload only when a stored deadline has
// passed, e.g. 30s). Within the window the server keeps the first hash AND the first generation, so
// the first code VERIFIES and „the SUPERSEDED code was accepted" fires.
//
// THE SETUP IS LOAD-BEARING, not ceremony: the server must have ALREADY SEEN the first code before
// the second is minted. Without that step the TTL mutation leaves the cache empty, the first code is
// refused for having no code at all, and the test would fail for the wrong reason — which is the
// red-proof-that-passes-for-the-wrong-reason trap (task §10). The seeing is done through
// claimGateActive(), the production path a customer's page load takes, not a test-only reload.
func TestClaimCode_SupersededByASecondMint_RefusedImmediately(t *testing.T) {
s, cfg, path := claimReadThroughServer(t)
first := mintCodeViaHatch(t, path, cfg)
if !s.claimGateActive() {
t.Fatal("the gate is not active after the first mint — the server never saw the first code")
}
if h, _, _, _ := s.effectiveClaimCode(); h == "" {
t.Fatal("precondition: the server must hold the FIRST code before the second is minted")
}
second := mintCodeViaHatch(t, path, cfg)
if first == second {
t.Fatal("the two mints produced the same code — the test cannot distinguish them")
}
// The FIRST code must be dead the instant the second exists — no window, no TTL.
rr := submitClaim(t, s, first, "a-strong-passphrase-12")
if rr.Code == http.StatusFound {
t.Fatal("the SUPERSEDED code was accepted — a stale/TTL read left an old code alive")
}
if !strings.Contains(rr.Body.String(), "Hib") { // „Hibás vagy lejárt kód" (ASCII-safe substring)
t.Fatalf("superseded code: want the wrong-code error, body=%q", claimFirstLine(rr.Body.String()))
}
if s.settings.GetClaimed() {
t.Fatal("box was claimed by a superseded code")
}
if s.authEnabled() {
t.Fatal("a password was set by a superseded code")
}
// ...and the SECOND code — the one the customer was actually told — works on the first attempt.
rr = submitClaim(t, s, second, "a-strong-passphrase-12")
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" {
t.Fatalf("the current code was refused: got %d body=%q", rr.Code, claimFirstLine(rr.Body.String()))
}
}
// FAIL CLOSED (§8.1) — an unreadable persisted state must never open the gate or accept a claim.
// The mutation that breaks it: return the cached value instead of the error from effectiveClaimCode.
func TestClaimCode_UnreadablePersistedState_FailsClosed(t *testing.T) {
s, cfg, path := claimReadThroughServer(t)
code := mintCodeViaHatch(t, path, cfg)
// Corrupt the persisted state the way a truncated write would (present but unparseable).
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatalf("corrupting settings: %v", err)
}
if _, _, _, err := s.effectiveClaimCode(); err == nil {
t.Fatal("an unparseable settings file did not surface an error")
}
if !s.claimGateActive() {
t.Fatal("the gate OPENED on an unreadable claim state — must fail closed")
}
if s.claimLegacyOpen() {
t.Fatal("an unreadable claim state was reported as legacy-open — must fail closed")
}
rr := submitClaim(t, s, code, "a-strong-passphrase-12")
if rr.Code == http.StatusFound {
t.Fatal("a claim was accepted while the persisted state was unreadable")
}
if s.authEnabled() {
t.Fatal("a password was set while the persisted state was unreadable")
}
}
+30 -5
View File
@@ -345,15 +345,40 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
// customer had no way to look at what they had just asked for. Resolve the real path and say
// it. Fall back to the vague wording only if the path can no longer be resolved.
where := s.backupMgr.OffsiteRestoreScratchPath(app)
msg := "A(z) " + app + " visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok)."
if where != "" {
msg = "A(z) " + app + " visszaállítva ellenőrző mappába: " + where + " (a meglévő adatok változatlanok)."
}
s.backupMgr.EndRestoreOp(true, msg)
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false)
}
// restoreScratchOutcomeMsg builds the OUTCOME flash for a completed scratch restore. Pure, so the
// wording is unit-testable — this string is the customer's only evidence of WHAT they now have.
//
// R-204 item 3 (v0.198.0) — THE DEFECT IT CLOSES. The default restore (`mode=unit`) recovers the
// recovery unit: the app's definition, its configuration and its database dumps. It does NOT recover
// the customer's own files; `RestoreOffboxScratch` passes `--include <unit path>` and the userdata
// paths that ARE in the same snapshot are excluded by it. The old message was one sentence for both
// modes and named neither scope, so a customer on the last step of a disaster recovery was told
// „visszaállítva" after the thing they were looking for had not been restored. A success message
// that does not name its scope is a silent wrong answer, which is this project's most-repeated
// failure shape.
//
// So the unit case states three things in order: what came back, what did NOT, and the next step
// that gets it. The full case says the files came with it, because otherwise the absence of the
// warning would be the only difference and an absence is not a statement.
func restoreScratchOutcomeMsg(app, where string, full bool) string {
at := " ellenőrző mappába"
if where != "" {
at = " ellenőrző mappába: " + where
}
if full {
return "A(z) " + app + " teljes mentése visszaállítva" + at +
" — a saját fájljaiddal együtt. A meglévő adatok változatlanok."
}
return "A(z) " + app + " beállításai és adatbázisa visszaállítva" + at +
". A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. " +
"Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok."
}
// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
// snapshot's version + that same snapshot's database replayed + the app restarted, with a safety
// dump of the current database taken first.
@@ -0,0 +1,239 @@
package web
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"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-204 item 3 — a restore must say WHAT IT RESTORED, and for the default mode, what it did not.
//
// THESE TESTS DRIVE THE REAL HANDLER, not the message helper (task §10: a test that reaches a helper
// while the mutation lives in the handler cannot observe it). offboxRestoreHandler runs, the restic
// exec is the only thing stubbed, and the assertion is on the flash the customer actually receives —
// backupMgr.RestoreStatus().Last.Message, the same field the wizard renders.
// scopeRunner is the restic exec seam. It answers the three calls a scratch restore makes
// (snapshots / unlock / restore) and RECORDS the restore argv, so Scenario F can assert the
// unit-vs-full distinction is still carried where it matters.
type scopeRunner struct {
mu sync.Mutex
unitPath string
restoreArgs []string
sizeBytes int64
}
func (sr *scopeRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) {
sr.mu.Lock()
defer sr.mu.Unlock()
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, " snapshots ") || strings.HasSuffix(joined, " snapshots"):
out, _ := json.Marshal([]map[string]any{{
"short_id": "abc1234",
"id": "abc1234deadbeef",
"time": time.Now().UTC().Format(time.RFC3339),
// The snapshot holds BOTH the recovery unit and the customer's userdata — which is the
// whole point: a unit restore leaves the second one behind.
"paths": []string{sr.unitPath, filepath.Dir(filepath.Dir(sr.unitPath)) + "/userdata/immich"},
}})
return out, nil
case strings.Contains(joined, " stats "):
out, _ := json.Marshal(map[string]any{"total_size": sr.sizeBytes})
return out, nil
case strings.Contains(joined, " restore "):
sr.restoreArgs = append([]string{}, args...)
return []byte("restored"), nil
}
return []byte(""), nil // unlock and anything else: a clean no-op
}
func (sr *scopeRunner) lastRestoreArgs() []string {
sr.mu.Lock()
defer sr.mu.Unlock()
return append([]string{}, sr.restoreArgs...)
}
// scopeServer wires a Server with a configured offbox manager whose restic exec is the stub above.
// The drive is a real temp dir registered as schedulable, so the scratch path resolves for real.
func scopeServer(t *testing.T) (*Server, *backup.Manager, *scopeRunner) {
t.Helper()
lg := log.New(io.Discard, "", 0)
dir := t.TempDir()
drive := filepath.Join(dir, "usb")
if err := os.MkdirAll(drive, 0o755); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Web.SessionSecret = "test-session-secret-abcdef"
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
m := backup.NewManager(cfg, sett, lg)
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err)
}
if !m.OffboxConfigured() {
t.Fatal("offbox target not configured — the handler would refuse before reaching the outcome")
}
sr := &scopeRunner{
// The unit path as it appears INSIDE the snapshot: <nsRoot>/backups/primary/<app>.
unitPath: strings.TrimSuffix(m.OffsiteRestoreScratchPath("immich"), "/backups/offsite-restore/immich") + "/backups/primary/immich",
sizeBytes: 4 << 20, // 4 MiB — comfortably inside the headroom of a temp dir
}
m.SetOffboxRunner(sr.run)
s := &Server{cfg: cfg, settings: sett, backupMgr: m, logger: lg, version: "test"}
s.loadTemplates()
return s, m, sr
}
// postRestore drives the REAL handler and waits for the async restore to finish.
func postRestore(t *testing.T, s *Server, m *backup.Manager, form url.Values) backup.RestoreOpStatus {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/restore", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.offboxRestoreHandler(rr, req)
// Wait on a REAL completion marker (a finished Last with a FinishedAt), never a fixed sleep.
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
st := m.RestoreStatus()
if !st.Running && st.Last != nil && !st.Last.FinishedAt.IsZero() {
return st
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("restore did not finish within the deadline (status=%+v)", m.RestoreStatus())
return backup.RestoreOpStatus{}
}
// SCENARIO E — the DEFAULT (unit) restore's outcome names what it did NOT restore, and the next step.
//
// RED-PROOF: delete the „NEM kerültek vissza" sentence from restoreScratchOutcomeMsg (or revert the
// function to the single pre-R-204 sentence). The handler still succeeds and still flashes a
// „visszaállítva" message — and this test fails, which is exactly the silence being closed.
func TestOffboxRestore_UnitOutcomeNamesWhatItDidNotRestore(t *testing.T) {
s, m, _ := scopeServer(t)
st := postRestore(t, s, m, url.Values{"app": {"immich"}, "mode": {"unit"}})
if !st.Last.OK {
t.Fatalf("unit restore failed: %q", st.Last.Message)
}
msg := st.Last.Message
// It must name what CAME BACK…
for _, want := range []string{"immich", "be" + "állításai és adatbázisa visszaállítva"} {
if !strings.Contains(msg, want) {
t.Errorf("outcome does not state what was restored (missing %q): %q", want, msg)
}
}
// …and, the point of R-204 item 3, what did NOT.
if !strings.Contains(msg, "NEM kerültek vissza") {
t.Errorf("outcome does not state that the customer's own files were NOT restored: %q", msg)
}
if !strings.Contains(msg, "dokumentumok") {
t.Errorf("outcome does not name the files it left behind: %q", msg)
}
// …and the next step that actually gets them.
if !strings.Contains(msg, "Teljes vissza"+"állítás előkészítése") {
t.Errorf("outcome does not name the next step that returns the files: %q", msg)
}
// The scratch path is still named (the v0.147.0 4a guarantee must not regress).
if !strings.Contains(msg, m.OffsiteRestoreScratchPath("immich")) {
t.Errorf("outcome no longer names the folder it restored into: %q", msg)
}
}
// The DEFAULT is `unit` (mode absent) and it must produce the SAME scoped outcome — the wizard always
// sets mode, but a mode-less POST must not fall into a message that overstates what it did.
func TestOffboxRestore_DefaultModeGetsTheScopedOutcome(t *testing.T) {
s, m, _ := scopeServer(t)
st := postRestore(t, s, m, url.Values{"app": {"immich"}}) // no mode at all
if !strings.Contains(st.Last.Message, "NEM kerültek vissza") {
t.Fatalf("the DEFAULT restore did not state its scope: %q", st.Last.Message)
}
}
// SCENARIO F — the full restore is unchanged: still two-step and size-gated, and its outcome does NOT
// carry the unit warning (a full restore did bring the files).
func TestOffboxRestore_FullPathUnchanged(t *testing.T) {
s, m, sr := scopeServer(t)
// Step 1: mode=full WITHOUT confirm must NOT restore — it computes and redirects with the reveal.
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/restore",
strings.NewReader(url.Values{"app": {"immich"}, "mode": {"full"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.offboxRestoreHandler(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("full step 1: want a redirect, got %d", rr.Code)
}
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "full_prep=immich") || !strings.Contains(loc, "full_size=") {
t.Fatalf("full step 1 did not reveal the size gate: Location=%q", loc)
}
if len(sr.lastRestoreArgs()) != 0 {
t.Fatal("full step 1 ran a restore before the customer confirmed — the size gate is bypassed")
}
// Step 2: the revealed confirm executes, and the outcome says the files came with it.
st := postRestore(t, s, m, url.Values{"app": {"immich"}, "mode": {"full"}, "confirm": {"1"}})
if !st.Last.OK {
t.Fatalf("full restore failed: %q", st.Last.Message)
}
if strings.Contains(st.Last.Message, "NEM kerültek vissza") {
t.Fatalf("the FULL restore wrongly claims the files were left behind: %q", st.Last.Message)
}
if !strings.Contains(st.Last.Message, "saját fájljaiddal együtt") {
t.Fatalf("the full outcome does not state that the files came with it: %q", st.Last.Message)
}
// And the mechanism that makes the two modes differ is still carried: unit passes --include, full
// does not. Asserted on the REAL argv the manager built.
if args := sr.lastRestoreArgs(); strings.Contains(strings.Join(args, " "), "--include") {
t.Fatalf("a FULL restore must not restrict to the unit: %v", args)
}
}
// The unit restore's mechanism half: it DOES restrict to the unit path. Without this, Scenario E's
// message could be true today and quietly become a lie if --include were dropped.
func TestOffboxRestore_UnitRestrictsToTheUnitPath(t *testing.T) {
s, m, sr := scopeServer(t)
postRestore(t, s, m, url.Values{"app": {"immich"}, "mode": {"unit"}})
joined := strings.Join(sr.lastRestoreArgs(), " ")
if !strings.Contains(joined, "--include") {
t.Fatalf("a unit restore must restrict to the unit path, argv=%q", joined)
}
if !strings.Contains(joined, "/backups/primary/immich") {
t.Fatalf("a unit restore did not include the recovery unit path, argv=%q", joined)
}
}
@@ -192,7 +192,11 @@ func TestRestoreWizard_ThreeIntentCards(t *testing.T) {
// Each intent must state its CONSEQUENCE, not just its name.
for _, want := range []string{
"Ellenőrzés külön mappába",
"az élő adataid nem változnak",
"Az élő adataid nem változnak",
// R-204 item 3: intent 1's SCOPE is stated before the choice, not only in the outcome. A
// disaster-recovery customer picked this card expecting their documents; it does not return
// them. If this substring goes, the card is back to promising „a mentés tartalma".
"A saját fájljaidat (dokumentumok, képek, feltöltések) <strong>nem</strong> hozza vissza",
"Hiányzó fájlok visszahozása",
"törölt tartalom ettől nem jelenik meg újra",
"Teljes visszaállítás (fájlok + adatbázis)",
@@ -74,8 +74,12 @@
harmless first, irreversible-looking last. -->
<div class="settings-card">
<h3>1. Ellenőrzés külön mappába</h3>
<p>A mentés tartalma egy külön ellenőrző mappába kerül — az élő adataid nem változnak.</p>
<h3>1. Ellenőrzés külön mappába (beállítások és adatbázis)</h3>
<!-- R-204 item 3: this card's scope is stated BEFORE the choice, not only in the outcome. It
restores the recovery unit only; the customer's own files stay in the backup. Saying „a
mentés tartalma" here was how a disaster-recovery customer chose the one intent that does
not return their documents. -->
<p>Az alkalmazás beállításait és adatbázisát hozza vissza egy külön ellenőrző mappába. A saját fájljaidat (dokumentumok, képek, feltöltések) <strong>nem</strong> hozza vissza — azokhoz a 3. pont teljes visszaállítása kell. Az élő adataid nem változnak.</p>
<div class="form-actions">
<form method="POST" action="/backup/offbox/restore">{{.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">