diff --git a/controller/internal/backup/offbox_abandon.go b/controller/internal/backup/offbox_abandon.go index 7093741..bd1ab2f 100644 --- a/controller/internal/backup/offbox_abandon.go +++ b/controller/internal/backup/offbox_abandon.go @@ -30,6 +30,11 @@ import ( // AbandonRemindAtDays) — visible, reversible, and running out in public. const abandonGraceDays = 14 +// AbandonGraceDays is the exported grace, for the customer-facing copy. The confirmation screen must +// state the SAME number the countdown uses — a literal typed into prose is how a promise drifts away +// from the code that keeps it. +const AbandonGraceDays = abandonGraceDays + // AbandonRemindAtDays are the remaining-day marks at which the abandoning box reminds the customer. // Descending, so the surface can pick the first one that has been reached. var AbandonRemindAtDays = []int{5, 3, 1} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 9e3a734..681fef0 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -119,6 +119,33 @@ type Settings struct { // happened. It is deliberately NOT cleared by anything except the situation ending. RecoveryNoticePostponed bool `json:"recovery_notice_postponed,omitempty"` + // ── THE OFFER EPOCH (v0.206.0, R-241) ─────────────────────────────────────────────────────── + // + // RecoveryOfferEpoch counts ENTRIES into the offered state; RecoveryOfferActive is the edge + // detector that makes counting possible. Together they turn "once ever" into "once per entry". + // + // ⚠ WHY THIS IS NOT THE FLAG §2.1 FORBIDS. That ruling forbids remembering *that the customer + // decided* so the screen can be suppressed while the underlying state stays wrong. These record + // something else entirely: WHICH SITUATION a dismissal was about. A box that abandons, is rebuilt + // months later and enters the offered state afresh is in a NEW situation, and a dismissal of the + // old one must not swallow it. `RecoveryNoticePostponed` alone did exactly that — it was + // deliberately never cleared by anything. + // + // RecoveryNoticePostponedEpoch / RecoveryRemindOptOutEpoch record the epoch a choice was made in. + // The full page interrupts while `Epoch > PostponedEpoch`, and the banner reminds while + // `Epoch > OptOutEpoch` — so a fresh entry resets BOTH by arithmetic, with nothing to clear and + // nothing that can be forgotten to clear (§7.1 condition 2). + RecoveryOfferEpoch int `json:"recovery_offer_epoch,omitempty"` + RecoveryOfferActive bool `json:"recovery_offer_active,omitempty"` + RecoveryNoticePostponedEpoch int `json:"recovery_notice_postponed_epoch,omitempty"` + // RecoveryRemindOptOutEpoch — the customer ticked „ne emlékeztessen újra" in this epoch. + // + // It silences THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts + // no countdown, and the entry point on the backups page never goes away because of it — silencing + // a reminder is not the same as removing the route, and this session exists partly because a route + // disappeared. + RecoveryRemindOptOutEpoch int `json:"recovery_remind_optout_epoch,omitempty"` + // Cached state DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` @@ -2149,3 +2176,85 @@ func sameDayStamp(a, b string) bool { } return ta.UTC().Format("2006-01-02") == tb.UTC().Format("2006-01-02") } + +// ── The recovery-offer epoch (v0.206.0, R-241) ───────────────────────────────── + +// RecoveryOfferView is the epoch read model. +type RecoveryOfferView struct { + Epoch int + Active bool + PostponedEpoch int + OptOutEpoch int +} + +// GetRecoveryOfferView returns the epoch state in one lock. +func (s *Settings) GetRecoveryOfferView() RecoveryOfferView { + s.mu.RLock() + defer s.mu.RUnlock() + return RecoveryOfferView{ + Epoch: s.RecoveryOfferEpoch, + Active: s.RecoveryOfferActive, + PostponedEpoch: s.RecoveryNoticePostponedEpoch, + OptOutEpoch: s.RecoveryRemindOptOutEpoch, + } +} + +// SyncRecoveryOfferEpoch advances the epoch on the EDGE into the offered state and returns the view. +// Idempotent: called on every landing page load, it writes only on a transition. +// +// THE LEGACY MIGRATION IS HERE AND HAPPENS ONCE. A box upgrading from ≤v0.205.0 may carry +// `RecoveryNoticePostponed=true` — a customer who already said "most nem" about the situation they +// are still in. Re-interrupting them on upgrade would be a regression dressed as a feature, so the +// first epoch inherits that choice and the legacy flag is retired. A box that never dismissed keeps +// PostponedEpoch 0 and is interrupted, which is correct. +func (s *Settings) SyncRecoveryOfferEpoch(offered bool) (RecoveryOfferView, error) { + s.mu.Lock() + defer s.mu.Unlock() + changed := false + if offered && !s.RecoveryOfferActive { + s.RecoveryOfferActive = true + s.RecoveryOfferEpoch++ + if s.RecoveryOfferEpoch == 1 && s.RecoveryNoticePostponed { + s.RecoveryNoticePostponedEpoch = 1 // inherit the pre-epoch dismissal, once + s.RecoveryNoticePostponed = false + } + changed = true + } else if !offered && s.RecoveryOfferActive { + s.RecoveryOfferActive = false + changed = true + } + view := RecoveryOfferView{ + Epoch: s.RecoveryOfferEpoch, Active: s.RecoveryOfferActive, + PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch, + } + if !changed { + return view, nil + } + return view, s.save() +} + +// PostponeRecoveryNoticeForEpoch records "most nem" for the CURRENT epoch. It suppresses the +// full-page interruption only — the banner and the backups-area entry point both survive. +func (s *Settings) PostponeRecoveryNoticeForEpoch() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.RecoveryNoticePostponedEpoch == s.RecoveryOfferEpoch { + return nil + } + s.RecoveryNoticePostponedEpoch = s.RecoveryOfferEpoch + return s.save() +} + +// OptOutRecoveryRemindersForEpoch records „ne emlékeztessen újra" for the CURRENT epoch. It silences +// the BANNER and nothing else: no countdown starts, nothing is abandoned, and the entry point on the +// backups page remains. A later fresh entry into the offered state advances the epoch and reminds +// again — the §7.1 conditions, satisfied by arithmetic rather than by remembering to clear a flag. +func (s *Settings) OptOutRecoveryRemindersForEpoch() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.RecoveryRemindOptOutEpoch == s.RecoveryOfferEpoch { + return nil + } + s.RecoveryRemindOptOutEpoch = s.RecoveryOfferEpoch + return s.save() +} diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go index 1bc2d83..578f973 100644 --- a/controller/internal/web/auth.go +++ b/controller/internal/web/auth.go @@ -206,6 +206,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { Secure: isSecure, }) + // R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder + // is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the + // browser is closed". The durable opt-out is a separate, explicit choice and is untouched here. + http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1}) + s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr) // Redirect to ?next= target if provided, otherwise to dashboard diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 2b3c4ed..91b1c1b 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -176,6 +176,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data := s.baseData("dashboard", "Vezérlőpult") + s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption data["Stacks"] = deployedStacks data["MissingStorage"] = s.missingStorageMap(deployedStacks) @@ -312,6 +313,7 @@ func (s *Server) launcherApps() []LauncherApp { // "Indítópult megosztása" share state (v0.165.0) for the modal. func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("launcher", "Indítópult") + s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit data["Apps"] = s.launcherApps() // Share modal state. The share URL is built from the request Host at render time (the canonical @@ -925,6 +927,21 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { // route to the data. A one-shot notice a flustered person clicks past is a notice that never // happened; this is what makes Scenario E true. data["RecoveryOffer"] = s.recoveryOffer() + // R-241 (v0.206.0): the abandonment countdown, stated on the page the customer chose it from. + // It is shown for the WHOLE window, not only at the reminder marks — the bar on other pages is a + // nudge, this is the record, and a deletion date must be findable on a quiet day too. + if s.backupMgr != nil { + if st := s.backupMgr.AbandonStatus(); st.Active { + data["AbandonActive"] = true + data["AbandonDaysLeft"] = st.DaysLeft + data["AbandonDate"] = st.DueAt.Format("2006-01-02") + } else if st.PurgeRequested { + // The store is deleted and the sealed package is on its way out. Say so rather than + // showing nothing, or the page silently loses a thing the customer was watching. + data["AbandonPurging"] = true + } + } + s.addRecoveryBanner(data, r) s.executeTemplate(w, r, "backups_remote", data) } diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 06bb122..6fd1b23 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -7,6 +7,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-193 — THE RECOVERY SCREEN. A customer whose machine was rebuilt has everything they need to get @@ -44,14 +45,67 @@ func (s *Server) recoveryOffer() bool { return s.backupMgr != nil && s.backupMgr.OffsiteRecoveryOffer() } -// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages. "Most nem" -// suppresses this and nothing else — recoveryOffer stays true, so the backups-area entry point -// survives. That asymmetry is the whole of Scenario E. +// recoveryBannerCookie is the PER-VISIT banner dismissal (v0.206.0, R-241, §7.1). It is a browser +// SESSION cookie — no MaxAge, no Expires — and it is cleared on login, so "I have seen this" lasts +// for the visit and the reminder is back next time. +// +// It is deliberately NOT persisted in settings. A dismissal that outlived the visit would be a +// permanently-dismissed banner over data still sitting there, which is the failure Scenario H exists +// to catch. The durable, deliberate version of "stop reminding me" is the tick-box (§7.1), and that +// one is an explicit decision the customer takes, not a click to get a bar off the screen. +const recoveryBannerCookie = "felhom_recovery_banner" + +// recoveryOfferEpoch advances and returns the offer-epoch view. Called from the landing-page +// interception, which runs on every dashboard/launcher GET — so the edge is detected promptly without +// a second scheduler job. Writes only on a transition. +func (s *Server) recoveryOfferEpoch() settings.RecoveryOfferView { + if s.settings == nil { + return settings.RecoveryOfferView{} + } + v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer()) + if err != nil { + s.logger.Printf("[WARN] [web] recovery: could not persist the offer epoch: %v", err) + } + return v +} + +// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages. +// +// ⚠ ONCE PER ENTRY INTO THE OFFERED STATE, NOT ONCE EVER (v0.206.0, §7.1). "Most nem" used to set a +// flag that nothing ever cleared, so a box that abandoned its history and was rebuilt months later — +// a genuinely NEW situation — would never show the page again. The epoch fixes that by arithmetic: +// a fresh entry advances it past the dismissal, with nothing to clear. +// +// It still suppresses the full page ONLY. `recoveryOffer` stays true, so the banner and the +// backups-area entry point both survive, and that asymmetry is the whole of Scenario E. func (s *Server) recoveryInterrupts() bool { - if !s.recoveryOffer() { + // ⚠ THE EPOCH IS SYNCED FIRST AND UNCONDITIONALLY, and that ordering is the whole mechanism. + // The first draft returned early when the offer was false, so the FALLING edge was never + // recorded — `RecoveryOfferActive` stayed true through a settled period and the next entry + // therefore counted as a continuation rather than a new situation. The page never came back. + // Caught by TestR241_FullPageAppearsOncePerEntryNotOnceEver, not by review. + if s.settings == nil { + return s.recoveryOffer() + } + v := s.recoveryOfferEpoch() + if !v.Active { return false } - return s.settings == nil || !s.settings.GetRecoveryNoticePostponed() + return v.Epoch > v.PostponedEpoch +} + +// recoveryBannerVisible reports whether the per-visit reminder bar should render on ordinary pages. +// Three conditions, and each is a separate lever: the situation holds, the customer has not opted out +// of reminders for THIS epoch, and they have not clicked the bar away during this visit. +func (s *Server) recoveryBannerVisible(r *http.Request) bool { + if !s.recoveryOffer() || s.settings == nil { + return false + } + if c, err := r.Cookie(recoveryBannerCookie); err == nil && c.Value == "1" { + return false // dismissed for this visit only + } + v := s.settings.GetRecoveryOfferView() + return v.Epoch > v.OptOutEpoch } // recoveryNoStore stamps the page uncacheable. The rendered page carries no secret, but it does carry @@ -112,6 +166,9 @@ func (s *Server) renderRecoveryState(w http.ResponseWriter, r *http.Request, err // unless the tier is orphaned. Showing a button that is guaranteed to refuse would be worse than // not showing it, and rewriting the move-aside is explicitly out of scope. data["CanSetAside"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned() + // §7.3: the confirmation states the grace in days, from the constant the countdown actually uses — + // never a literal in the copy, which is how a number in prose drifts away from the number in code. + data["AbandonGraceDays"] = backup.AbandonGraceDays data["ConfirmSetAside"] = r.URL.Query().Get("setaside") == "1" if inv != nil { data["Unlocked"] = true @@ -483,10 +540,89 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { // interruption ONLY: recoveryOffer stays true, so the backups-area entry point survives permanently. func (s *Server) recoveryPostponeHandler(w http.ResponseWriter, r *http.Request) { if s.settings != nil { - if err := s.settings.SetRecoveryNoticePostponed(true); err != nil { + // Recorded against the CURRENT epoch (v0.206.0): a dismissal is about the situation the + // customer is in, not about the screen for ever. A later fresh entry shows the page again. + if err := s.settings.PostponeRecoveryNoticeForEpoch(); err != nil { s.logger.Printf("[WARN] [web] recovery: recording the postpone failed: %v", err) } } - s.logger.Printf("[INFO] [web] recovery: the customer chose to postpone; the backups-area entry point stays") + s.logger.Printf("[INFO] [web] recovery: the customer chose to postpone; the banner and the backups-area entry point both stay") http.Redirect(w, r, "/launcher", http.StatusFound) } + +// recoveryBannerDismissHandler records "seen it, for now" (POST /recovery/banner/dismiss) — a browser +// SESSION cookie and nothing durable. The bar is back at the next login, because the data is still +// sitting there whether or not anyone clicked. +func (s *Server) recoveryBannerDismissHandler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: recoveryBannerCookie, Value: "1", Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, + // NO MaxAge and NO Expires — a session cookie, deliberately. See recoveryBannerCookie. + }) + http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound) +} + +// recoveryRemindOptOutHandler records „ne emlékeztessen újra" (POST /recovery/remind-optout). +// +// ⚠ IT SILENCES THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts +// no countdown, and it must never be presented as a way of deciding. The entry point on the backups +// page stays exactly where it was (condition 1) — silencing a reminder is not removing the route, and +// this whole session exists partly because a route disappeared. A fresh entry into the offered state +// reminds again (condition 2), by epoch arithmetic. +func (s *Server) recoveryRemindOptOutHandler(w http.ResponseWriter, r *http.Request) { + if s.settings != nil { + if err := s.settings.OptOutRecoveryRemindersForEpoch(); err != nil { + s.logger.Printf("[WARN] [web] recovery: recording the reminder opt-out failed: %v", err) + } + } + s.logger.Printf("[INFO] [web] recovery: reminders silenced for this situation at the customer's request — the backups-area entry point is UNCHANGED and no countdown was started") + http.Redirect(w, r, redirectBackTo(r, "/backups/remote"), http.StatusFound) +} + +// redirectBackTo returns a SAFE same-site redirect target from the form, or the fallback. Only a +// leading single "/" is accepted: "//evil.example" is a protocol-relative URL and must not pass. +func redirectBackTo(r *http.Request, fallback string) string { + v := r.FormValue("back") + if len(v) > 1 && v[0] == '/' && v[1] != '/' { + return v + } + return fallback +} + +// addRecoveryBanner decorates a page's data with the reminder bar's state (R-241, v0.206.0). +// +// It is an EXPLICIT call rather than a `baseData` change, deliberately: `baseData` has no request and +// the per-visit dismissal is a cookie, and threading a request through every caller to reach four +// pages would be a large diff for a small feature. The callers are the pages a customer actually +// lands on — the dashboard, the launcher and the backups area. +// +// ⚠ IT IS A REMINDER, NOT THE ROUTE. Nothing here gates the entry point on /backups/remote; that is +// driven by `.RecoveryOffer` in the template and stays put whatever the customer does about the bar. +func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) { + if !s.recoveryBannerVisible(r) { + return + } + data["RecoveryBanner"] = true + data["RecoveryBannerBack"] = r.URL.Path + if data["CSRFField"] == nil { + data["CSRFField"] = s.csrfField(r) + } + // While a countdown runs the bar counts it down instead of asking the same question — and the + // reminder opt-out is deliberately NOT offered there: a deletion date is not something to silence. + if s.backupMgr != nil { + if st := s.backupMgr.AbandonStatus(); st.Active { + for _, mark := range backup.AbandonRemindAtDays { + if st.DaysLeft <= mark { + data["RecoveryAbandonDays"] = st.DaysLeft + data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02") + break + } + } + if data["RecoveryAbandonDays"] == nil { + // Outside the reminder marks the countdown is visible on the backups page only — + // a bar on every screen for fourteen days is a bar nobody reads by day three. + delete(data, "RecoveryBanner") + } + } + } +} diff --git a/controller/internal/web/recovery_surface_r241_test.go b/controller/internal/web/recovery_surface_r241_test.go new file mode 100644 index 0000000..0592caa --- /dev/null +++ b/controller/internal/web/recovery_surface_r241_test.go @@ -0,0 +1,186 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// R-241 §7.1 / Scenario H — the three-state surface. +// +// The full page once PER ENTRY into the offered state (not once ever), a per-visit banner, and an +// entry point on the restore page that NOTHING removes. + +// ── THE FULL PAGE APPEARS ONCE PER ENTRY, NOT ONCE EVER ───────────────────────────────────────── +// +// RED-PROOF: make recoveryInterrupts read the legacy boolean again (`!GetRecoveryNoticePostponed()`). +// The second entry is then swallowed and this test fails — a box that abandoned its history and was +// rebuilt months later would never see the page again. +func TestR241_FullPageAppearsOncePerEntryNotOnceEver(t *testing.T) { + f := newRecoveryFixture(t) + + // Entry #1 → interrupts. + if !f.s.recoveryInterrupts() { + t.Fatal("the first entry into the offered state must interrupt") + } + rr := httptest.NewRecorder() + f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil)) + if f.s.recoveryInterrupts() { + t.Fatal("after 'most nem' the full page must stop interrupting for THIS situation") + } + + // The situation ends (the customer recovered, or the state was fixed): the offer goes false and + // the epoch's active edge falls. + if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil { + t.Fatal(err) + } + if f.s.recoveryInterrupts() { + t.Fatal("a settled box must not interrupt") + } + + // ENTRY #2 — a genuinely new situation months later. + if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil { + t.Fatal(err) + } + if !f.s.recoveryInterrupts() { + t.Fatal("a FRESH entry into the offered state must show the full page again — a dismissal is about a situation, not for ever") + } +} + +// ── THE BANNER IS PER-VISIT ───────────────────────────────────────────────────────────────────── +// +// RED-PROOF: persist the dismissal in settings (or give the cookie a MaxAge). It then survives the +// visit and this test fails — a permanently-dismissed banner over data still sitting there. +func TestR241_ScenarioH_BannerIsDismissedForTheVisitOnly(t *testing.T) { + f := newRecoveryFixture(t) + f.s.recoveryInterrupts() // establish the epoch + + req := httptest.NewRequest(http.MethodGet, "/launcher", nil) + if !f.s.recoveryBannerVisible(req) { + t.Fatal("the banner should be visible while the situation holds") + } + + rr := httptest.NewRecorder() + f.s.recoveryBannerDismissHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil)) + var dismissed *http.Cookie + for _, c := range rr.Result().Cookies() { + if c.Name == recoveryBannerCookie { + dismissed = c + } + } + if dismissed == nil { + t.Fatal("the dismissal must set its cookie") + } + // IT MUST BE A SESSION COOKIE — no MaxAge, no Expires. That is what makes it per-visit. + if dismissed.MaxAge != 0 || !dismissed.Expires.IsZero() { + t.Fatalf("the banner dismissal must be a SESSION cookie (MaxAge=0, no Expires), got MaxAge=%d Expires=%v", dismissed.MaxAge, dismissed.Expires) + } + // With the cookie presented, the banner is gone… + req2 := httptest.NewRequest(http.MethodGet, "/launcher", nil) + req2.AddCookie(dismissed) + if f.s.recoveryBannerVisible(req2) { + t.Fatal("the banner must be hidden for the rest of this visit") + } + // …and NOTHING durable was written: a fresh visit (no cookie) sees it again. + if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) { + t.Fatal("the banner must be back on the next visit — the dismissal must not be persisted") + } + if v := f.sett.GetRecoveryOfferView(); v.OptOutEpoch != 0 { + t.Fatalf("clicking the bar away must NOT record an opt-out, got %+v", v) + } +} + +// ── THE EXPLICIT OPT-OUT SILENCES THE BANNER AND NOTHING ELSE ─────────────────────────────────── +// +// RED-PROOF: make the opt-out also clear the offer (or gate the backups entry point on it). The +// route to the data then disappears and this test fails — the failure this whole session exists to +// remove. +func TestR241_ScenarioH_OptOutSilencesTheBannerOnly(t *testing.T) { + f := newRecoveryFixture(t) + f.s.recoveryInterrupts() + + rr := httptest.NewRecorder() + f.s.recoveryRemindOptOutHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil)) + if rr.Code != http.StatusFound { + t.Fatalf("opt-out = %d, want a redirect", rr.Code) + } + + // 3. It silences the BANNER… + if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) { + t.Fatal("the banner must be silenced after an explicit opt-out") + } + // 1. …and the ROUTE never goes away. + if !f.s.recoveryOffer() { + t.Fatal("CONDITION 1: the entry point must survive — silencing a reminder is not removing the route") + } + // …nor is it an abandonment: no countdown started. + if st := f.s.backupMgr.AbandonStatus(); st.Active { + t.Fatal("an opt-out must never start a countdown — it is not a decision about the data") + } + + // 2. A FRESH entry into the offered state reminds again. + if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil { + t.Fatal(err) + } + f.s.recoveryInterrupts() // the edge falls + if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil { + t.Fatal(err) + } + f.s.recoveryInterrupts() // a new epoch + if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) { + t.Fatal("CONDITION 2: a fresh entry into the offered state must remind again") + } +} + +// The backups-page entry point is bound to the OFFER and to nothing else — not to the interruption, +// not to the banner, not to the opt-out. Pinned here because every one of those is a lever someone +// could plausibly bind it to, and the last time a route disappeared it cost a walk. +func TestR241_EntryPointSurvivesEveryDismissal(t *testing.T) { + f := newRecoveryFixture(t) + f.s.recoveryInterrupts() + + f.s.recoveryPostponeHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil)) + f.s.recoveryRemindOptOutHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil)) + f.s.recoveryBannerDismissHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil)) + + if !f.s.recoveryOffer() { + t.Fatal("no combination of dismissals may remove the route to the customer's data") + } + // And the page itself still renders rather than redirecting away. + rr := httptest.NewRecorder() + f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET /recovery = %d after every dismissal, want 200", rr.Code) + } +} + +// A settled box shows no banner at all (Scenario D's surface half). +func TestR241_SettledBoxHasNoBanner(t *testing.T) { + f := newRecoveryFixture(t) + if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil { + t.Fatal(err) + } + if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) { + t.Fatal("a settled box must show nothing — no page, no banner, no entry point") + } + if f.s.recoveryInterrupts() { + t.Fatal("a settled box must not interrupt") + } +} + +// redirectBackTo must refuse an off-site target. A dismissal button that can be pointed at another +// host is an open redirect on an authenticated page. +func TestR241_BannerRedirectIsSameSiteOnly(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"/backups/remote", "/backups/remote"}, + {"//evil.example/x", "/launcher"}, + {"https://evil.example", "/launcher"}, + {"", "/launcher"}, + {"/", "/launcher"}, // len<=1 falls back; harmless and keeps the rule simple + } { + r := httptest.NewRequest(http.MethodPost, "/x?back="+tc.in, nil) + if got := redirectBackTo(r, "/launcher"); got != tc.want { + t.Errorf("redirectBackTo(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/controller/internal/web/recovery_test.go b/controller/internal/web/recovery_test.go index 2b14bbf..1900150 100644 --- a/controller/internal/web/recovery_test.go +++ b/controller/internal/web/recovery_test.go @@ -337,13 +337,20 @@ func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) { func TestRecovery_E_PostponeKeepsTheEntryPoint(t *testing.T) { f := newRecoveryFixture(t) + // v0.206.0 (R-241): the interruption is now epoch-scoped, so the epoch has to exist before a + // dismissal can be recorded against it. recoveryInterrupts advances it on the edge, exactly as the + // landing-page interception does in production. + if !f.s.recoveryInterrupts() { + t.Fatal("precondition: the full page should interrupt on the first entry into the offered state") + } rr := httptest.NewRecorder() f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil)) if rr.Code != http.StatusFound { t.Fatalf("postpone = %d, want a redirect", rr.Code) } - if !f.sett.GetRecoveryNoticePostponed() { - t.Fatal("the postpone was not recorded") + // Recorded against the CURRENT epoch — the situation, not the screen for ever. + if v := f.sett.GetRecoveryOfferView(); v.PostponedEpoch != v.Epoch || v.Epoch == 0 { + t.Fatalf("the postpone was not recorded against the current epoch: %+v", v) } // The full page no longer interrupts… if f.s.recoveryInterrupts() { diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index b863133..82c292e 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -416,6 +416,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.recoveryUnlockHandler(w, r) case path == "/recovery/postpone" && r.Method == http.MethodPost: s.recoveryPostponeHandler(w, r) + // R-241 (v0.206.0): the per-visit banner dismissal and the durable reminder opt-out. They are + // SEPARATE ROUTES because they are separate decisions — one is "not now", the other is "stop + // asking about this situation", and neither removes the entry point on the backups page. + case path == "/recovery/banner/dismiss" && r.Method == http.MethodPost: + s.recoveryBannerDismissHandler(w, r) + case path == "/recovery/remind-optout" && r.Method == http.MethodPost: + s.recoveryRemindOptOutHandler(w, r) case path == "/dashboard": s.dashboardHandler(w, r) case path == "/launcher": diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 8b5b538..116add9 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -133,7 +133,19 @@
Helyreállítási kód szükséges
A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.
- {{if .EscrowAgentOK}} + {{/* §7.3 / Q7 — THE TRAP THAT MUST NOT SURVIVE THIS SESSION. While a recovery is outstanding, + creating a NEW code seals the CURRENT key and demotes the package that opens the earlier + history to retained custody, which no shipped path can read (R-199). It also re-enables + the recovery screen through the orphan route while invalidating the code that screen + accepts. The button is therefore made UNAVAILABLE here rather than merely captioned: + a warning beside a button is a warning people click past. */}} + {{if .RecoveryOffer}} ++ Ehhez a géphez egy korábbi helyreállítási kód tartozik, és a korábbi mentéseid még megvannak. + Új kód létrehozása a régi mentéseidet elérhetetlenné tenné, ezért most nem indítható. + Előbb add meg a meglévő kódodat — vagy ott jelezheted, ha nem kéred vissza a korábbi adatokat. +
+ {{else if .EscrowAgentOK}} Helyreállítási kód létrehozása {{else}}A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.
@@ -156,6 +168,22 @@ {{if .EscrowAgentOK}}Új helyreállítási kód készítése{{end}}A korábbi mentések törlése folyamatban
++ A kérésed szerint a korábbi távoli mentéseidet {{.AbandonDate}} napján véglegesen töröljük + (még {{.AbandonDaysLeft}} nap). Addig meggondolhatod magad: ha megvan a helyreállítási kódod, + a mentéseid visszaszerezhetők, és a törlés elmarad. +
+ Mégis visszaszerzem a kóddal +A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van.
+Biztosan nem kéred vissza a korábbi mentéseket?
Ha megerősíted:
Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget.
+Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget — az semmit nem indít el.