oficina / docs Public

app: fix round 1(b) on Task 3 -- sign-in-specific fallback copy

GET /signin's switch on ?err= fell back to docs.error.generic
("Something went wrong.") for anything it did not have a specific
case for -- reachable in real use via ?err=keymail (a failed keymail
callback) and ?err=1 (a raw Begin failure), and confirmed directly
against this app's own router for the case a person hits constantly:
clicking a magic link a second time. rauth.Auth's Verify handler
gives one error for "unknown, used and expired alike" by design, so
that specific case was already landing on docs.signin.error.expired,
which does offer recovery -- but the switch's actual default branch,
for the other two paths, was not.

Every outcome on this one page means the same operational thing --
sign-in did not complete, ask for a new link -- so its fallback
should never borrow the app-wide generic string, which is written for
a render failure with nowhere styled to land, not for this page's own
ordinary, recoverable outcomes. Added docs.signin.error.unknown
alongside the existing docs.signin.error.* entries and used it in
that default: branch; docs.error.generic is untouched everywhere
else.

Added two tests: one exercises a real magic link clicked twice
end-to-end and confirms the second click resolves the specific
expired-link copy, not the generic one; the other hits the default
branch directly via ?err=keymail/1/unknown-future-value.
Paul Campbell pushed by paul@keymail.dev 0591ee81124c96af303e19c440fd0c2971957eb8 parent e777207
3 files changed, +118 −8
  • internal/docs/auth.go +13 −7
  • internal/docs/auth_test.go +92 −0
  • internal/docs/render.go +13 −1
diff --git a/internal/docs/auth.go b/internal/docs/auth.go
index 8aa9a37..2b7d0c1 100644
--- a/internal/docs/auth.go
+++ b/internal/docs/auth.go
@@ -161,12 +161,18 @@ type signinPage struct {
// signin serves GET /signin — the form itself, and the landing spot
// for every outcome rauth.Auth's handlers redirect back to
-// (?sent=1, ?err=rate|address|expired|keymail|1, ?force=1). One
-// error message reads all the "something is wrong, try again" shapes:
-// rauth.Auth already refuses to be more specific than that between
-// "wrong" and "unknown" (Verify's own doc comment — telling those
-// apart would be an oracle), so there is nothing this page could add
-// by drawing finer distinctions of its own on Begin's outcomes either.
+// (?sent=1, ?err=rate|address|expired|keymail|1, ?force=1). rate,
+// address and expired each get their own copy below; keymail and 1
+// (a raw Begin failure logged server-side, and Verify's own "unknown,
+// used and expired alike" — its own doc comment says rauth.Auth
+// refuses to be more specific than that, so there is nothing this
+// page could add by drawing a finer distinction of its own) share the
+// switch's default branch, docs.signin.error.unknown — never
+// docs.error.generic (fix round 1 on Task 3): every outcome on this
+// one page means the same operational thing, "sign-in did not
+// complete, ask for a new link," and docs.error.generic is authored
+// for a render failure with nowhere styled to land, not for this
+// page's own ordinary, recoverable outcomes.
func (h *handlers) signin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data := signinPage{
@@ -188,7 +194,7 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) {
case "expired":
data.ErrorMessage = rastrillo.T(r, "docs.signin.error.expired")
default:
- data.ErrorMessage = rastrillo.T(r, "docs.error.generic")
+ data.ErrorMessage = rastrillo.T(r, "docs.signin.error.unknown")
}
if err := h.render.page(w, r, "signin", data); err != nil {
h.logger.Error("docs: render signin", "err", err)
diff --git a/internal/docs/auth_test.go b/internal/docs/auth_test.go
index c4a84d6..35bc797 100644
--- a/internal/docs/auth_test.go
+++ b/internal/docs/auth_test.go
@@ -188,3 +188,95 @@ func TestNewRefusesMissingMailOutsideDevelopment(t *testing.T) {
t.Fatal("New must refuse to start outside development with no mail configured")
}
}
+
+// TestSecondClickOfTheSameMagicLinkOffersRecovery is fix round 1 on
+// Task 3's own regression test: rauth.Auth's Verify handler
+// deliberately gives one error for "unknown, used and expired alike"
+// (its own doc comment), which means a magic link clicked a second
+// time — the ordinary case of someone opening an old sign-in email —
+// lands on the exact same ?err=expired outcome as a link that was
+// never valid at all. Confirmed directly against this app's own
+// router, not assumed: the first click consumes the link and sets a
+// session; the second click, with no cookie attached (a fresh tab, or
+// the same old email opened again after the first session already
+// expired), must redirect back to /signin with the specific "expired
+// or already used... enter your email for a new one" copy — never a
+// bare, uninformative dead end.
+func TestSecondClickOfTheSameMagicLinkOffersRecovery(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link, err := app.TestSignIn(t, "clicktwice@example.com")
+ if err != nil {
+ t.Fatalf("TestSignIn: %v", err)
+ }
+
+ firstReq := httptest.NewRequest(http.MethodGet, link, nil)
+ firstRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(firstRec, firstReq)
+ if len(firstRec.Result().Cookies()) == 0 {
+ t.Fatal("first click set no session cookie")
+ }
+
+ secondReq := httptest.NewRequest(http.MethodGet, link, nil)
+ secondRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(secondRec, secondReq)
+ loc := secondRec.Header().Get("Location")
+ if !strings.Contains(loc, "err=expired") {
+ t.Fatalf("second click redirected to %q, want an err=expired outcome", loc)
+ }
+
+ signinReq := httptest.NewRequest(http.MethodGet, loc, nil)
+ signinRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(signinRec, signinReq)
+ // NewForTest's app.Handler() never wires rastrillo.Options.BaseCatalog/
+ // Locales — only cmd/docs/main.go does, for real serving — so
+ // rastrillo.T falls back to returning its key verbatim here
+ // (render.go's own doc comment on Locales: "documented, harmless").
+ // That makes the raw catalog key the correct, precise thing to
+ // assert on in this handler-level test: it pins exactly which
+ // branch of signin's switch ran, without depending on the English
+ // copy's exact wording.
+ body := signinRec.Body.String()
+ if !strings.Contains(body, "docs.signin.error.expired") {
+ t.Errorf("signin page after a second click did not resolve docs.signin.error.expired; body: %s", body)
+ }
+ if strings.Contains(body, "docs.error.generic") {
+ t.Errorf("signin page after a second click fell back to the app-wide docs.error.generic instead of the sign-in-specific expired copy")
+ }
+}
+
+// TestSigninUnrecognizedErrorUsesSigninSpecificFallback is fix round
+// 1 on Task 3's own finding: GET /signin's switch on ?err= had a
+// default: branch falling back to docs.error.generic ("Something went
+// wrong.") — the app-wide string written for a render failure with
+// nowhere styled to land, not for this page's own ordinary outcomes.
+// ?err=keymail (a failed keymail callback, rastrillo/auth's own
+// Callback handler) and ?err=1 (a raw Begin failure, logged
+// server-side) both reach that default branch in real use — this
+// pins both directly against the handler, without depending on
+// rastrillo internals staying exactly as they are today, and checks
+// the fallback is sign-in-specific rather than the app-wide string.
+func TestSigninUnrecognizedErrorUsesSigninSpecificFallback(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // See TestSecondClickOfTheSameMagicLinkOffersRecovery's own comment
+ // on why the raw catalog key, not the resolved English copy, is
+ // what this handler-level test can observe and correctly should
+ // assert on.
+ for _, errParam := range []string{"keymail", "1", "some-future-value-nobody-has-written-a-case-for-yet"} {
+ req := httptest.NewRequest(http.MethodGet, "/signin?err="+errParam, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ body := rec.Body.String()
+ if !strings.Contains(body, "docs.signin.error.unknown") {
+ t.Errorf("err=%q: signin page did not resolve docs.signin.error.unknown; body: %s", errParam, body)
+ }
+ if strings.Contains(body, "docs.error.generic") {
+ t.Errorf("err=%q: signin page fell back to the app-wide docs.error.generic instead of the sign-in-specific fallback", errParam)
+ }
+ }
+}
diff --git a/internal/docs/render.go b/internal/docs/render.go
index 67c481d..2be369e 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -71,7 +71,18 @@ var Catalog = rastrillo.Catalog{
// docs.signin.* — GET /signin (auth.go's signin handler): the
// form itself, and every outcome rastrillo/auth's own handlers
- // redirect back here with (?sent=1, ?err=rate|address|expired|1).
+ // redirect back here with (?sent=1,
+ // ?err=rate|address|expired|keymail|1). docs.signin.error.unknown
+ // is that switch's own default: branch (fix round 1 on Task 3):
+ // every one of these outcomes means the same operational thing on
+ // this one page — sign-in did not complete, ask for a new link —
+ // so its fallback must not borrow docs.error.generic ("Something
+ // went wrong."), an app-wide string written for a render failure
+ // with nowhere else to land, not for this page's own normal,
+ // recoverable outcomes. See auth.go's signin handler for why no
+ // finer distinction between ?err=keymail and ?err=1 is drawn
+ // either (rastrillo/auth's own handlers already refuse to be an
+ // oracle between "wrong" and "unknown").
"docs.signin.title": "Sign in",
"docs.signin.body": "Enter your email address and we'll send you a link to sign in.",
"docs.signin.address.label": "Email address",
@@ -80,6 +91,7 @@ var Catalog = rastrillo.Catalog{
"docs.signin.error.rate": "Too many attempts. Wait a few minutes and try again.",
"docs.signin.error.address": "That doesn't look like a valid email address.",
"docs.signin.error.expired": "That link has expired or was already used. Enter your email below for a new one.",
+ "docs.signin.error.unknown": "We couldn't sign you in. Enter your email below for a new link.",
// docs.signout.label is the sign-out control's text (list.html's
// and show.html's page headers) — both its button label and its