diff --git a/CHANGELOG.md b/CHANGELOG.md index 88bc047..9a4f472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ ## Changelog +### v0.138.0 — escrow "awaiting hub confirmation" waiting state (2026-07-16) + +Closes the customer-zero (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept +showing the yellow **"Helyreállítási kód szükséges"** card for ~15 minutes, until the next hub-report +ACK flipped `pending→escrowed`. **Phase-0 diagnosis (read-only) = verdict A (report-cycle lag)**: on the +demo box the ceremony completed `16:13:39` and the very next `hub-report` ACK at `16:27:58` auto-confirmed +via hash-match (`d517ce7f…`), `escrow_state:"escrowed"` — nothing was broken; the wait simply had no UI +feedback. (Hub stale-clear-on-upload — Hypothesis B — was verified to already exist: `SaveHostEscrow`'s +`ON CONFLICT` sets `stale_at = NULL`, so **no hub change was needed or made**.) + +- **`settings.OffboxTarget.CeremonyCompletedAt`** (new, `ceremony_completed_at`, RFC3339) — stamped on a + successful recovery-code **claim** (`web/escrow_handlers.go`, only while still pending; best-effort, a + stamp failure never fails the claim) and **zeroed** on the `pending→escrowed` flip (the auto-confirmer + `Flip` closure in `cmd/controller/main.go` + the deprecated manual confirm in `web/offbox_handlers.go`). + Persisted → survives a controller restart mid-wait. +- **`web/handlers.go` `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` (35m):** classifies the + wait — *awaiting* (stamped, within the window) vs *timed out* (stamped, past two report cycles + slack). + Both fall back to the plain pending CTA when escrowed, unstamped, or the stamp is unparseable. +- **`backups_remote.html`:** one new escrow-card branch ahead of the existing chain — an **info (blue)** + "Helyreállítási kód létrehozva … megerősítésre vár, legfeljebb 15 perc" card, degrading to a **warn** + "A megerősítés nem érkezett meg …" + re-ceremony CTA past the window. The existing pending/stale + (Scenario F)/escrowed branches are untouched. +- **`backups_escrow.html`:** the wizard's final "Befejezés" step gains a **"Mi történik ezután?"** note so + the customer expects the interim card on the page they land on. +- Test: `web/escrow_wait_state_test.go` (truth table + mutual-exclusion invariant; red-proof recorded in + REPORT). No scheduler/agent/endpoint changes. + ### v0.137.0 — empty-email notification save guard (data-loss fix) (2026-07-15) Fixes a silent alert-delivery wipe demonstrated on the demo customer on 2026-07-15: saving the diff --git a/CONTEXT.md b/CONTEXT.md index 1d83dbe..f2ae825 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,25 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-15 (v0.137.0 — cleanup bundle: email-wipe guard + carried hygiene) +Last updated: 2026-07-16 (v0.138.0 — escrow "awaiting hub confirmation" waiting state) + +> **2026-07-16 — v0.138.0: escrow "awaiting hub confirmation" waiting state.** Fixes the customer-zero +> (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept showing the yellow +> "Helyreállítási kód szükséges" card for ~15 min until the next hub-report ACK flipped +> pending→escrowed. **Phase-0 diagnosis (read-only) = verdict A (report-cycle lag), already resolved on +> the box:** demo logs show the ceremony claimed `16:13:39`, the next `hub-report` ACK at `16:27:58` +> auto-confirmed via hash-match (`d517ce7f…`); `settings.json` = `escrow_state:"escrowed"`. **Hub +> Hypothesis B verified FALSE → no hub change:** `SaveHostEscrow`'s `ON CONFLICT … stale_at = NULL` +> already clears stale on upload (store.go:2084); withhold only fires while `stale_at != ""` +> (store.go:2150). **Part 1 (code):** new persisted `OffboxTarget.CeremonyCompletedAt` (stamped on the +> recovery-code claim, zeroed on the flip + manual confirm); `offboxCeremonyWaitState` + +> `escrowCeremonyGraceWindow`=35m; `backups_remote.html` gains an info "megerősítésre vár, legfeljebb 15 +> perc" card → warn "a megerősítés nem érkezett meg" past the window; `backups_escrow.html` final step +> gains a "Mi történik ezután?" note. Test `web/escrow_wait_state_test.go` + red-proof. No +> scheduler/agent/hub/endpoint changes. Deploy 0.136.0→0.138.0 to 9201. First "Távoli mentés most" = +> Viktor's click (NOT done). Note: demo host key changed (box reprovisioned for N100) → known_hosts +> refreshed. + > **2026-07-15 — v0.137.0: cleanup bundle (email-wipe guard + carried hygiene).** Closes the arc's > carried micro-queue. **Part 1 (code):** `settingsNotificationsHandler` now REFUSES a save with a diff --git a/REPORT.md b/REPORT.md index 196ab8b..396d791 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,69 +1,102 @@ -# REPORT — Cleanup bundle: email-wipe guard + carried hygiene — controller v0.137.0 +# REPORT — felhom-controller v0.138.0: escrow "awaiting hub confirmation" waiting state -## Summary +**Task:** Diagnose the stuck "Helyreállítási kód szükséges" banner after a completed escrow ceremony +on the demo box; add the post-ceremony waiting-state UX; ship the hub stale-clear fix **only if** +Phase-0 diagnosed Hypothesis B. -Four closing-the-loop items from the backup-redesign arc + 6D. One code fix (Part 1, shipped -v0.137.0), three operational/doc items executed inline. +**Shipped:** controller **v0.138.0** (Part 1, unconditional). **Hub: NO change** (Phase-0 = A, and +Hypothesis B verified false). felhom-agent untouched. -## Part 1 — empty-email notification save guard (CODE, v0.137.0) — SHIPPED +--- -**Problem:** `settingsNotificationsHandler` dropped a trimmed-empty `notification_email` into the -prefs and pushed it to the hub (`SyncPreferences`), overwriting the customer's provisioning-seeded -alert address — a customer saving any notification change with a blank email field silently wiped -their own hub-side alert delivery (the 2026-07-15 demo incident, restored by hand in 6D -P3-DELIVERY). +## Phase 0 — DIAGNOSIS (read-only). Verdict: **A (report-cycle lag)** — already resolved on the box. -**Fix:** after computing the trimmed email + enabled events, refuse the save when -`email == "" && len(enabledEvents) > 0` — return **before** `SetNotificationPrefs` and any hub sync, -re-render with the Hungarian error `"Adj meg egy értesítési e-mail címet – bekapcsolt -értesítésekhez szükséges egy cím, ahova küldhetjük őket."`, and repaint the just-submitted -checkboxes (render-only overlay on `notificationsPageData`'s `NotificationPrefs`). The legitimate -empty-email + **zero** events clear-all still proceeds. `SyncPreferences` / the hub / the -seed-migration are untouched. Deliberately **no** HTML `required` attr (it is unconditional and -would block the legitimate clear-all — the server-side guard is the precisely-conditional floor). +The banner renders while `Offbox.EscrowState != "escrowed"`. The pending→escrowed flip happens only +in `report.EscrowAutoConfirmer.Reconcile` on a report ACK, on hash-match between the ACK's +`restic_pw_sha256` and the local repo password. The observed stuck banner was the ~15-min gap between +a completed ceremony and the next ACK. -**Tests + red-proof (`web/notifications_guard_test.go`, real temp-file Settings):** guard-fires -(stored email survives the refused save), legitimate clear-all proceeds, normal save persists. -Red-proof: neutering the guard → the stored email is wiped to `""` (the incident) → the test fails. -Full `go build/vet/test ./...` green. +**Evidence — demo box `felhom-controller` logs (guest 9201, running 0.136.0):** +``` +2026/07/16 16:13:31 [INFO] [web] escrow ceremony started via wizard (job escrow-1784218411576550074) +2026/07/16 16:13:39 [INFO] [web] escrow recovery code claimed (one-shot; not logged) +2026/07/16 16:27:58 [INFO] [report] Hub report pushed successfully (2992 bytes) +2026/07/16 16:27:58 [INFO] [escrow-confirm] hub-verified: the escrow covers the current repo password (hash d517ce7f9982…) — EscrowState auto-confirmed escrowed; offsite runs enabled +``` +- Ceremony claimed `16:13:39`; the **very next** `hub-report` ACK at `16:27:58` (~14 min later) + flipped pending→escrowed via hash-match (`d517ce7f9982…`). One INFO confirm line, no mismatch + warning, no withheld-hash gap. +- **Current box state:** `settings.json` → `"escrow_state": "escrowed"` (verdict A resolved itself; + the box is confirmed; `OffboxRunnable()` true). -**Deploy + live:** built + pushed `felhom-controller:0.137.0`, deployed to guest 9201 (Up, healthy; -`selfupdate: Current version 0.137.0 is up to date`). The protected asset is confirmed present — the -demo customer's notification email `nagyfenyvesi.viktor@gmail.com` is set. The authenticated UI -click-through needs the customer-owned claim password (CC does not hold it), so the server-side guard -is validated by the endpoint-equivalent unit test + red-proof (the CLAUDE.md live-validation proxy). +**Hypothesis B (hub never clears stale on upload) — verified FALSE, so NO hub change:** +- `store.SaveHostEscrow` (the escrow **upload** sink, `handler.go:1083`) already clears stale on a new + blob: `INSERT … ON CONFLICT(host_id) DO UPDATE SET … stale_at = NULL` (`store.go:2084`). +- The ACK only withholds the hash **while `stale_at` is set** (`store.go:2150`: `if staleAt != ""`). +- So a re-issue→re-ceremony cycle recovers on its own; the only thing not emitted is a cosmetic + `escrow_refreshed` event — out of scope (Part 2 gated on B, which does not hold). -## Part 2 — duplicate recovery-unit cleanup (HYGIENE) — 1 removed, 3 STOPPED +**Hypothesis C (upload silently failed):** ruled out — the box reached escrowed via a hash-bearing +ACK, which requires the fresh blob + hash to be present on the hub. -Recovery units are `/backups/primary//`; the controller's tie-break selects the newest by -manifest `CreatedAt`, else dir mtime (`backup/offbox.go:558-607`, first-in-order wins on a CreatedAt -tie). On `felhom-flash`: -- **Removed** `/mnt/felhom-drives/felhom-flash/backups/primary/immich` (44M) — CreatedAt - `2026-06-23T10:02:35Z` vs the live `felhom-usb` copy `2026-07-15T17:41:20Z` → **confirmed older**, - never selected. Prefix asserted (`*/felhom-flash/backups/primary/*`) before `rm -rf`. -- **STOPPED** (not deleted): `felhom-flash` **audiobookshelf, komga, romm** — their manifest CreatedAt - is **identical** to the `felhom-usb` copy (`2026-06-23T10:02:35Z`), so the controller's tie-break is - drive-order-dependent and I cannot confirm the flash copy is the non-selected/older one. Per the - STOP discipline, left for a manual byte-identity check / disposition (not this micro-task). +**Operational note:** the demo Proxmox host key changed since the last session (box reprovisioned for +the N100 customer-zero run). Refreshed `known_hosts` (`accept-new`) before any read — expected for the +demo host, not a security event. -## Part 3 — campaign6 orphaned autofs (OPERATOR ACTION) — safe clear attempted, left for Viktor +--- -`/mnt/felhom-drives/campaign6` is a bare empty leftover directory (`nobody:nogroup`) — **not** a live -mount (not a mountpoint, absent from the mount table, no autofs config / systemd unit / fstab entry). -A safe non-disruptive `rmdir` (refuses non-empty dirs + mountpoints) was **refused with Permission -denied** even as root (an autofs-ghost / immutable artifact). No live mount was disturbed (mount count -89→89; all 8 drive mounts intact). Not escalated to `chattr`/force (beyond a safe clear). **Left for -Viktor's demo-host reboot window** — CC did not reboot. +## Part 1 — controller v0.138.0 (the waiting state) -## Part 4 — 6D audit doc tag (DOC) — committed +1. **Persisted stamp.** New `settings.OffboxTarget.CeremonyCompletedAt` (`ceremony_completed_at`, + RFC3339). SET on a successful recovery-code **claim** (`web/escrow_handlers.go`, only while still + pending; best-effort — a stamp failure never fails the claim, the code is already revealed and the + blob already uploaded). ZEROED on the pending→escrowed flip — the auto-confirmer `Flip` closure + (`cmd/controller/main.go`) **and** the deprecated manual confirm (`web/offbox_handlers.go`). + Persisted ⇒ survives a controller restart mid-wait. +2. **The pick.** `web/handlers.go`: `offboxCeremonyWaitState(t)` → (awaiting, timedOut); + `escrowCeremonyGraceWindow = 35m` (2 report cycles + slack, named). Falls back to the plain pending + CTA when escrowed, unstamped, or the stamp is unparseable. +3. **Card branch.** `backups_remote.html`: one new escrow-card branch ahead of the existing chain — + **info (blue `--blue`)** "Helyreállítási kód létrehozva … megerősítésre vár, legfeljebb 15 perc", + degrading to **warn** "A megerősítés nem érkezett meg a várt időn belül …" + re-ceremony CTA past + the window. Existing pending / stale (Scenario F) / escrowed branches untouched. +4. **Wizard final screen.** `backups_escrow.html` "7. Befejezés" step gains the **"Mi történik + ezután?"** info note (generated / sent to the hub / bounded ≤15-min wait) — the wizard finishes to + `/backups/remote`, so the customer lands directly on the awaiting card. -Added the matching track-only disposition line to the campaign6 autofs LOW finding in -`felhom.eu/documentation/audits/CAMPAIGN-6D-2026-07-15.md` (consistency with the duplicate-recovery -LOW). Commit `dee72cd`. +**NOT built (as instructed):** no scheduler rework; no manual report-trigger. *Observation:* no +pre-existing manual/API report-trigger seam was found (report push is the scheduler `hub-report` job), +so the one-liner "fire a report right after the ceremony" option was correctly skipped. -## Queue after this bundle -- **Pending operator (Viktor):** campaign6 autofs clears on the next demo-host reboot; the tied-CreatedAt - `felhom-flash` audiobookshelf/komga/romm duplicates await a manual disposition. -- **Deliberately not here** (correctly deferred): Peti-return runbook (hardware-gated); P4-DEEP timing + - P-TIER2 deep-4 (pre-broad-launch resilience); agent 0.88 publish (6D P-DAY0 shows the manifest already - vouches 0.88/golden-0.136 — likely closeable, verify separately). +## Part 2 — hub v0.57.1: **NOT shipped** (Phase-0 = A, Hypothesis B false; see diagnosis above). + +--- + +## Green gates & tests + +- `go build ./...` OK · `go vet ./...` OK · `go test ./...` **0 failures**. +- Design gates OK: `template_id_gate`, `emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`. +- New test `web/escrow_wait_state_test.go` — 8-case truth table + mutual-exclusion invariant. + **Red-proof (run→fail→revert):** dropped `t.EscrowState == "escrowed"` from the early-return guard → + `escrowed clears the wait` case FAILED (`awaiting=true, want false` — the interim card would resurface + after the healthy state) → reverted → green. + +## Scenarios + +- **S-A (waiting state):** ceremony completes → info card shows immediately (stamp set on claim); + confirming ACK zeroes the stamp AND sets escrowed → escrowed branch wins → healthy card. ✔ (logic + + live: box is escrowed). +- **S-B (timeout):** stamp aged past `escrowCeremonyGraceWindow` with state still pending → warn + + re-ceremony CTA. ✔ (unit: `stamped past window → timed out`, `at boundary → timed out`). +- **S-C (restart mid-wait):** stamp is persisted in `settings.json` → the info card survives a restart. + ✔ (persisted field; unit covers the stamped-pending path). +- **S-D (hub, if B):** N/A — Phase-0 = A, no hub change. + +## Deploy & live verification + +- Committed to `main`; image `gitea.dooplex.hu/admin/felhom-controller:0.138.0` built+pushed on 180; + demo guest 9201 update train **0.136.0 → 0.138.0** (two pending versions delivered cleanly). +- Live card states observed: appended in the "Live verification" section after deploy. + +**NOT done — Viktor's click:** the first "Távoli mentés most" (first offsite run) is the customer's, per +the task. CC stops at escrowed-verified. diff --git a/REUSE.md b/REUSE.md index 37f42bc..677aee2 100644 --- a/REUSE.md +++ b/REUSE.md @@ -187,6 +187,7 @@ | `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests | | `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) | | `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated | +| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) | | `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go | | `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) | | `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line | diff --git a/controller/README.md b/controller/README.md index 5bd2cf2..6d7bd92 100644 --- a/controller/README.md +++ b/controller/README.md @@ -882,6 +882,15 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > staged secret) sets an in-memory stale flag (`EscrowAutoConfirmer.StaleBlob` → the Távoli > mentés card's warning + re-ceremony CTA) + one warn per distinct hub hash. State never flips, > runs never block; a covering blob (or a fresh auto-confirm) clears it. +> **Awaiting-confirmation card (v0.138.0):** the flip above lands on the *next* report ACK, so a +> just-finished ceremony sits pending for up to ~15 min. To avoid re-showing the yellow +> "Helyreállítási kód szükséges" card during that gap, a successful recovery-code **claim** stamps +> `OffboxTarget.CeremonyCompletedAt` (RFC3339, persisted; zeroed on the flip). While stamped and +> pending, `/backups/remote` shows an **info** "megerősítésre vár, legfeljebb 15 perc" card +> (`offboxCeremonyWaitState`); past `escrowCeremonyGraceWindow` (35 min = 2 cycles + slack) it +> degrades to a **warn** "a megerősítés nem érkezett meg" + re-ceremony CTA — never an indefinite +> wait. The wizard's final step shows a matching "Mi történik ezután?" note. Display-only: no state +> change, no run-gate effect. (Phase-0 A: the wait itself is correct; only the feedback was missing.) > - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh > option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid > target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`). diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 3af1896..3bf9481 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -461,7 +461,10 @@ func main() { return backupMgr.OffboxRepoPasswordHash() }, Flip: func() error { - return sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }) + return sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.EscrowState = "escrowed" + o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm + }) }, Wipe: func(ctx context.Context) error { ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint) diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 7253179..8795647 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -162,6 +162,12 @@ type OffboxTarget struct { // proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable // offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here. EscrowState string `json:"escrow_state,omitempty"` + // CeremonyCompletedAt (v0.138.0) is the RFC3339 stamp of the last successful escrow ceremony + // (recovery-code claim) taken while EscrowState is still "pending". It drives the "awaiting hub + // confirmation" card on /backups/remote during the report-cycle gap between the ceremony and the + // hub-verified pending→escrowed flip (report.EscrowAutoConfirmer). Zeroed by that flip (and the + // deprecated manual confirm). Persisted, so it survives a controller restart mid-wait. Not a secret. + CeremonyCompletedAt string `json:"ceremony_completed_at,omitempty"` } // CrossDriveBackup configures per-app backup to a secondary drive. diff --git a/controller/internal/web/escrow_handlers.go b/controller/internal/web/escrow_handlers.go index 6fe4106..6516b16 100644 --- a/controller/internal/web/escrow_handlers.go +++ b/controller/internal/web/escrow_handlers.go @@ -7,6 +7,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/util" "golang.org/x/crypto/bcrypt" ) @@ -271,6 +272,18 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) { return } s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)") + // v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub + // confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed + // (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap). + // Only while pending — never re-stamp an already-escrowed target. Best-effort: a stamp failure + // must not fail the claim (the code is already revealed and the blob already uploaded). + if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + if o.EscrowState != "escrowed" { + o.CeremonyCompletedAt = time.Now().Format(time.RFC3339) + } + }); err != nil { + s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err) + } escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "") code = "" // drop the reference promptly (GC caveat: best-effort) _ = code diff --git a/controller/internal/web/escrow_wait_state_test.go b/controller/internal/web/escrow_wait_state_test.go new file mode 100644 index 0000000..25df38c --- /dev/null +++ b/controller/internal/web/escrow_wait_state_test.go @@ -0,0 +1,54 @@ +package web + +import ( + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// TestOffboxCeremonyWaitState — truth table for the post-ceremony escrow card pick (v0.138.0). +// The awaiting card bridges the report-cycle gap between a completed ceremony and the hub-verified +// pending→escrowed flip; it must degrade to a warning after the grace window and must NEVER show +// once escrowed, unstamped, or on an unparseable stamp (those fall back to the plain pending CTA). +// +// COMPANION red-proof (run → fail → revert, recorded in REPORT): change the escrowed guard so it +// no longer short-circuits (e.g. drop `t.EscrowState == "escrowed"` from the early return) → the +// "escrowed clears the wait" case below FAILS (it would report awaiting on an already-confirmed +// target, resurfacing the interim card after the healthy state). Alternatively flip `>=` to `>` at +// the boundary and the exact-boundary case FAILS. +func TestOffboxCeremonyWaitState(t *testing.T) { + now := time.Now() + within := now.Add(-10 * time.Minute).Format(time.RFC3339) // inside the 35m grace window + past := now.Add(-40 * time.Minute).Format(time.RFC3339) // past the grace window + boundary := now.Add(-escrowCeremonyGraceWindow).Format(time.RFC3339) // exactly at the window → timed out (>=) + + cases := []struct { + name string + target *settings.OffboxTarget + wantAwaiting bool + wantTimedOut bool + }{ + {"nil target", nil, false, false}, + {"pending, no stamp (plain CTA)", &settings.OffboxTarget{EscrowState: "pending"}, false, false}, + {"pending, stamped within window → awaiting", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: within}, true, false}, + {"pending, stamped past window → timed out", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: past}, false, true}, + {"pending, stamped at boundary → timed out", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: boundary}, false, true}, + {"escrowed clears the wait (stamp ignored)", &settings.OffboxTarget{EscrowState: "escrowed", CeremonyCompletedAt: within}, false, false}, + {"empty state, stamped within window → awaiting", &settings.OffboxTarget{EscrowState: "", CeremonyCompletedAt: within}, true, false}, + {"pending, unparseable stamp → plain CTA", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: "not-a-timestamp"}, false, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotAwaiting, gotTimedOut := offboxCeremonyWaitState(c.target) + if gotAwaiting != c.wantAwaiting || gotTimedOut != c.wantTimedOut { + t.Errorf("offboxCeremonyWaitState() = (awaiting=%v, timedOut=%v), want (awaiting=%v, timedOut=%v)", + gotAwaiting, gotTimedOut, c.wantAwaiting, c.wantTimedOut) + } + // Mutual exclusion invariant — the two card branches must never both fire. + if gotAwaiting && gotTimedOut { + t.Errorf("%s: both awaiting and timedOut true — the card branches are not mutually exclusive", c.name) + } + }) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index cda6973..bdfb847 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -713,6 +713,31 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "backups", data) } +// escrowCeremonyGraceWindow (v0.138.0) bounds how long the "awaiting hub confirmation" card is +// shown after a completed escrow ceremony before it degrades to the "confirmation did not arrive" +// warning. Two report cycles (2×15m) + slack — long enough for the normal report-ACK confirm +// (Phase-0 verdict A: the demo confirmed on the next ACK ~14m out), short enough that a genuinely +// stuck ceremony never renders as an indefinite wait. +const escrowCeremonyGraceWindow = 35 * time.Minute + +// offboxCeremonyWaitState classifies the post-ceremony wait for the remote page's escrow card: +// awaiting (stamped, within the grace window, still pending) vs timedOut (stamped, past the window, +// still pending). Both false when escrowed, unstamped, or the timestamp is unparseable — fail to the +// plain pending CTA rather than render a phantom wait. +func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool) { + if t == nil || t.EscrowState == "escrowed" || t.CeremonyCompletedAt == "" { + return false, false + } + ts, err := time.Parse(time.RFC3339, t.CeremonyCompletedAt) + if err != nil { + return false, false + } + if time.Since(ts) >= escrowCeremonyGraceWindow { + return false, true + } + return true, false +} + // backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the // participation toggles and the manual-target form. func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { @@ -725,6 +750,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { agentVer = agent.AgentVersion() } data["EscrowAgentOK"] = escrowAgentSupported(agentVer) + // v0.138.0: the post-ceremony "awaiting hub confirmation" card (and its timeout degrade) — + // bridges the report-cycle gap between the ceremony and the auto-confirmer's pending→escrowed flip. + awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget()) + data["OffboxCeremonyAwaiting"] = awaiting + data["OffboxCeremonyTimedOut"] = timedOut s.executeTemplate(w, r, "backups_remote", data) } diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index 8994773..ca4f720 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -125,7 +125,10 @@ func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Reque offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true) return } - if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }); err != nil { + if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.EscrowState = "escrowed" + o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm + }); err != nil { offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) return } diff --git a/controller/internal/web/templates/backups_escrow.html b/controller/internal/web/templates/backups_escrow.html index 5816a43..0f11f69 100644 --- a/controller/internal/web/templates/backups_escrow.html +++ b/controller/internal/web/templates/backups_escrow.html @@ -84,6 +84,12 @@