oficina / memoria Public

Answer Astra's review (#21): forced-local return; a router-level panic test

1. Home's forced entry, /signin?force=1&return_to=…, now remembers its
   validated return like /signin/local does, so sent → Use a different
   address → second submission → verification lands on the page asked
   for. TestHomeLocalRetryKeepsTheReturnFromEitherEntry covers both
   entries; it failed first on the forced one (retry link /signin/local,
   no return).
2. TestARoutePanicIsTheErrorPageThroughTheRouter panics inside a real
   route through the production router, signed in and out: the 500 page
   with a reference and the viewer's chrome, and the panic's value in
   neither page nor log. Removing r.Use(a.recoverToErrorPage) turns it
   red (the panic escapes the router); the helper tests stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 99dad08c0ca03475b40f5b831b7299d84ad65b15 parent 917e2b6
2 files changed, +96 −1
  • internal/memoria/chrome_test.go +91 −0
  • internal/memoria/handlers.go +5 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.790s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	2.088s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[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/memoria/chrome_test.go b/internal/memoria/chrome_test.go
index 85f6ccd..905a37e 100644
--- a/internal/memoria/chrome_test.go
+++ b/internal/memoria/chrome_test.go
@@ -6,10 +6,13 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
+ "html"
"io"
"log/slog"
"net/http"
"net/http/httptest"
+ "net/url"
+ "regexp"
"strings"
"testing"
"time"
@@ -213,3 +216,91 @@ func homeEnv(t *testing.T) map[string]string {
"MEMORIA_HOME_KEYS_BASE64": base64.StdEncoding.EncodeToString(raw), "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)),
}
}
+
+// Astra on #21, finding 1: with Home configured, both ways into the local
+// form — connect's forced /signin?force=1 and /signin/local — keep an
+// explicit return_to through sent → "Use a different address" → a second
+// submission → verification. The forced entry used to land on "/".
+func TestHomeLocalRetryKeepsTheReturnFromEitherEntry(t *testing.T) {
+ for _, entry := range []string{"/signin?force=1&return_to=%2Fadd%3Ffrom%3Dmail", "/signin/local?return_to=%2Fadd%3Ffrom%3Dmail"} {
+ t.Run(entry, func(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ if w := f.do("GET", entry, ""); w.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d", entry, w.Code)
+ }
+ sent := homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1"))
+ page := f.do("GET", sent, "").Body.String()
+ m := regexp.MustCompile(`<a href="([^"]+)">Use a different address</a>`).FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("no \"Use a different address\" on the sent page:\n%s", page)
+ }
+ again := html.UnescapeString(m[1])
+ if again != "/signin/local?return_to=%2Fadd%3Ffrom%3Dmail" {
+ t.Fatalf("\"Use a different address\" goes to %q, want the local form with the return", again)
+ }
+ if w := f.do("GET", again, ""); w.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d", again, w.Code)
+ }
+ f.mail.body = ""
+ homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1"))
+ link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body)
+ u, err := url.Parse(link)
+ if err != nil || link == "" {
+ t.Fatal("no second link")
+ }
+ dest := homeRedirect(t, f.do("GET", u.RequestURI(), ""))
+ for i := 0; i < 3 && strings.HasPrefix(dest, "/auth/"); i++ {
+ dest = homeRedirect(t, f.do("GET", dest, ""))
+ }
+ if dest != "/add?from=mail" {
+ t.Errorf("sign-in landed on %q, want /add?from=mail", dest)
+ }
+ })
+ }
+}
+
+// Astra on #21, finding 2: the panic tests above build recoverToErrorPage
+// by hand, so removing it from the router left them green. This one panics
+// inside a REAL route (the return cookie's clock, read by /signin/local)
+// through the production router, signed in and out, and checks the generic
+// 500 page with a reference, the viewer's chrome, and that the panic's
+// value reaches neither the page nor the log.
+func TestARoutePanicIsTheErrorPageThroughTheRouter(t *testing.T) {
+ const sentinel = "secret-sentinel-passage"
+ for _, signed := range []bool{true, false} {
+ f := newHomeRouterFixture(t)
+ if signed {
+ f.local(t)
+ }
+ var logs bytes.Buffer
+ f.app.logger = slog.New(slog.NewTextHandler(&logs, nil))
+ f.app.clock = func() time.Time { panic(sentinel) }
+ r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local?return_to=%2Fadd", nil)
+ r.Header.Set("Accept", "text/html")
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ func() {
+ defer func() {
+ if v := recover(); v != nil {
+ t.Fatalf("signed in=%v: the panic escaped the router: %v", signed, v)
+ }
+ }()
+ f.handler.ServeHTTP(w, r)
+ }()
+ b := w.Body.String()
+ if w.Code != http.StatusInternalServerError || !strings.Contains(b, "<title>Something went wrong · Memoria</title>") || !strings.Contains(b, "rst-error-ref") {
+ t.Errorf("signed in=%v: = %d, want the 500 page with a reference:\n%s", signed, w.Code, b)
+ }
+ if strings.Contains(b, "estilo-account") != signed {
+ t.Errorf("signed in=%v: the 500 page's chrome is not the viewer's", signed)
+ }
+ if strings.Contains(b, sentinel) || strings.Contains(logs.String(), sentinel) {
+ t.Errorf("signed in=%v: the panic's value reached the page or the log", signed)
+ }
+ if !strings.Contains(logs.String(), "ref=") {
+ t.Errorf("signed in=%v: no reference in the log: %s", signed, logs.String())
+ }
+ }
+}
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 4670d5e..d8a7d10 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -1212,7 +1212,11 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
return
}
}
- if a.home == nil || r.URL.Path == "/signin/local" {
+ // Remember an explicit, validated return whenever this form renders —
+ // including Home's forced /signin?force=1 entry, which connect hands
+ // here at /signin (Astra on #21, finding 1). The fresh-start reset
+ // stays where it was: a bare standalone /signin or /signin/local.
+ if a.home == nil || r.URL.Path == "/signin/local" || hasReturn {
switch {
case hasReturn:
a.rememberReturn(w, ret)