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) } } }