Standalone sign-out lands on the signed-out page
Without Home, /signed-out was a 404 and Sign out went back to the sign-in
form, as if nothing had happened. Now /signed-out serves the same estilo
signed-out door Home mode lands on, in Calendar's words ("Signed out",
"You’re signed out of Calendar on this browser.", Sign in), and the
standalone POST /signout lands there: rastrillo's Signout still revokes
the session; only its final 303 is retargeted from /signin, which every
other auth outcome also uses.
TestStandaloneSignOutLandsOnTheSignedOutPage failed before (303 to
/signin). Fable 5.1 follow-up finding 4 on #17.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+75
−1
internal/calendar/routes.go+2 −1internal/calendar/signin.go+29 −0internal/calendartest/signed_out_test.go+44 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 4.614s ? amadan.net/oficina/calendar/internal/calendar/icons [no test files] ? amadan.net/oficina/calendar/internal/calendar/seal [no test files] ok amadan.net/oficina/calendar/internal/calendartest 2.360s ok amadan.net/oficina/calendar/internal/guardstest 0.045s ? amadan.net/oficina/calendar/scripts/writeestilo [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 84c9c31..a5162ee 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -56,6 +56,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.With(a.home.sessions.Require).Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| } else { |
| r.Get("/signin", h.signin) |
| + r.Get("/signed-out", h.signedOut) |
| } |
| // One counter for the life of the router, so the log line can say |
| // how many refusals this boot has seen rather than only that one |
| @@ -76,7 +77,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Post("/signin", begin) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + r.Post("/signout", signOutToSignedOut(authPlugin.Signout)) |
| } |
| // The light/dark switcher, outside the session on purpose: the |
diff --git a/internal/calendar/signin.go b/internal/calendar/signin.go| index c34437c..1a2a350 100644 |
| --- a/internal/calendar/signin.go |
| +++ b/internal/calendar/signin.go |
| @@ -95,3 +95,32 @@ func signedOutDoor(r *http.Request, heading, body string, link chrome.Link) sign |
| Link: &link, |
| }} |
| } |
| + |
| +// signedOut serves GET /signed-out without Home: the same estilo door Home |
| +// mode's sign-out lands on, in Calendar's own words, so signing out is a |
| +// page that says so rather than the sign-in form again (or, before, a |
| +// 404 for anyone who reached the address). |
| +func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + render(w, "signin", h.page(r, "Signed out", signedOutDoor(r, "Signed out", |
| + "You’re signed out of Calendar on this browser.", chrome.Link{Href: "/signin", Label: "Sign in"}))) |
| +} |
| + |
| +// signOutToSignedOut is rastrillo's standalone Sign out with one change: |
| +// where it lands. auth.Signout revokes the session and answers 303 to its |
| +// SigninPath, which every other auth outcome also uses, so the path cannot |
| +// change; this retargets that one redirect to /signed-out. |
| +func signOutToSignedOut(signout http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + signout(&signedOutRedirect{ResponseWriter: w}, r) |
| + } |
| +} |
| + |
| +type signedOutRedirect struct{ http.ResponseWriter } |
| + |
| +func (s *signedOutRedirect) WriteHeader(code int) { |
| + if code == http.StatusSeeOther && s.Header().Get("Location") == "/signin" { |
| + s.Header().Set("Location", "/signed-out") |
| + } |
| + s.ResponseWriter.WriteHeader(code) |
| +} |
diff --git a/internal/calendartest/signed_out_test.go b/internal/calendartest/signed_out_test.go| new file mode 100644 |
| index 0000000..ae50df1 |
| --- /dev/null |
| +++ b/internal/calendartest/signed_out_test.go |
| @@ -0,0 +1,44 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Standalone (no Home), signing out lands on a real page: the estilo |
| +// signed-out door at /signed-out, as Home mode's sign-out does, rather than |
| +// back on the sign-in form as if nothing happened. /signed-out used to 404 |
| +// without Home (Fable 5.1 follow-up finding 4 on #17). |
| +func TestStandaloneSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + rec := postAs(t, h, session, "/signout", nil) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" { |
| + t.Fatalf("POST /signout: %d to %q, want 303 to /signed-out", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if after := getAs(t, h, session, "/day/2026-09-24"); after.Code != http.StatusSeeOther { |
| + t.Fatalf("the session survived sign-out: GET a view answered %d", after.Code) |
| + } |
| + |
| + page := get(t, h, "/signed-out") |
| + body := page.Body.String() |
| + if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { |
| + t.Fatalf("GET /signed-out: %d %s", page.Code, page.Header().Get("Content-Type")) |
| + } |
| + for _, want := range []string{ |
| + "<title>Signed out · Calendar</title>", |
| + "estilo-door", |
| + "<h1>Signed out</h1>", |
| + `<a rst-btn="primary" href="/signin">Sign in</a>`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("/signed-out is missing %q:\n%s", want, body) |
| + } |
| + } |
| + // The page that IS signing out shows nothing in the bar's corner. |
| + if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| + t.Error("/signed-out drew a Sign in button or an account menu in the bar") |
| + } |
| +} |