R-241 part 4: the three-state surface, and the copy tells the truth about the date

FULL PAGE ONCE PER ENTRY, NOT ONCE EVER. "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 see the page again. The
offer now carries an EPOCH, advanced on the edge into the offered state, and a
dismissal is recorded against the epoch it was made in. A fresh entry passes
the dismissal by arithmetic, with nothing to clear and nothing that can be
forgotten to clear.

That is NOT the flag the operator's ruling forbids. The forbidden thing
remembers that the customer decided so the screen can be suppressed while the
state stays wrong. This records WHICH SITUATION a dismissal was about.

A REAL BUG, caught by the test and not by review: the first draft returned
early from recoveryInterrupts when the offer was false, so the FALLING edge
was never recorded, RecoveryOfferActive stayed true through a settled period,
and the next entry counted as a continuation. The page never came back - the
exact defect the epoch exists to fix, reintroduced inside the fix. The sync is
now unconditional and the ordering is commented as load-bearing.

THREE LEVERS, THREE SCOPES, and none of them removes the route:
  - clicking the bar away  -> a browser SESSION cookie, cleared on login, so
    the reminder is genuinely back at the next login. Nothing persisted.
  - "ne emlekeztessen ujra" -> durable, epoch-scoped, silences the BANNER ONLY.
    It starts no countdown, abandons nothing, and a fresh entry reminds again.
  - "most nem" -> suppresses the full page only, as before.
The entry point on /backups/remote is bound to the OFFER and to nothing else,
pinned by a test that fires all three dismissals and asserts it survives.

SEC 7.3 / Q7 - THE TRAP DOES NOT SURVIVE THIS SESSION. While a recovery is
outstanding the "Helyrealitasi kod letrehozasa" button is UNAVAILABLE, not
merely captioned: creating a new code seals the current key, demotes the
package that opens the earlier history to retained custody that no shipped
path can read (R-199), and re-enables the recovery screen through the orphan
route while invalidating the code that screen accepts. A warning beside a
button is a warning people click past. The card now explains and points at
/recovery instead.

SEC 2.4 - the abandon confirmation changes with the behaviour. It used to
promise "felretesszuk - nem toroljuk". It now states the grace in days (from
the constant the countdown actually uses, never a literal in prose), that the
sealed package goes with it, that the customer can change their mind, where
the date is visible, and that the question does not come back afterwards.

The countdown is shown on /backups/remote for the WHOLE window - the bar
elsewhere is a nudge, this is the record, and a deletion date must be findable
on a quiet day too.

Tests: once-per-entry across a full settle-and-re-enter cycle; the banner
dismissal proven to be a session cookie (MaxAge 0, no Expires) and to persist
nothing; the opt-out proven to silence the banner while leaving the offer, the
route and the countdown untouched, and to remind again on a fresh entry; the
entry point surviving all three dismissals; a settled box showing nothing; and
the back-redirect refusing "//evil.example".

An existing test (TestRecovery_E) was updated: it asserted the legacy boolean,
which the epoch replaces. It now asserts the dismissal landed on the current
epoch, which is the stronger property.

Green: go build, go vet, go test ./... all pass; controller gates OK.
This commit is contained in:
2026-08-07 12:01:30 +02:00
parent a5d90ff801
commit de39e47f53
11 changed files with 551 additions and 14 deletions
@@ -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)
}
}
}