Compare commits
3 Commits
f4796e0d00
...
33fcc502e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 33fcc502e4 | |||
| 2e936f43bf | |||
| 73b6dbc27d |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}}">
|
||||
|
||||
Reference in New Issue
Block a user