package memoriatest import ( "html" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "amadan.net/rastrillo/rastrillo/db" memoria "amadan.net/oficina/memoria/internal/memoria" ) // client is a browser in miniature for the shared-chrome gates: it keeps // cookies across requests (set, replace, delete), sends Accept: text/html // like a navigation does, and never follows a redirect on its own, so each // hop is asserted where it happens. type client struct { t *testing.T h http.Handler cookies map[string]*http.Cookie accept string } func newClient(t *testing.T, h http.Handler) *client { return &client{t: t, h: h, cookies: map[string]*http.Cookie{}, accept: "text/html,application/xhtml+xml,*/*;q=0.8"} } func (c *client) do(method, target string, form url.Values) *httptest.ResponseRecorder { c.t.Helper() var req *http.Request if form != nil { req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { req = httptest.NewRequest(method, target, nil) } if method != http.MethodGet && method != http.MethodHead { req.Header.Set("Origin", testOrigin) } return c.serve(req) } // serve sends req with the client's Accept and cookies, and keeps the // cookies the answer sets. func (c *client) serve(req *http.Request) *httptest.ResponseRecorder { req.Header.Set("Accept", c.accept) for _, ck := range c.cookies { req.AddCookie(&http.Cookie{Name: ck.Name, Value: ck.Value}) } rec := httptest.NewRecorder() c.h.ServeHTTP(rec, req) for _, ck := range rec.Result().Cookies() { if ck.MaxAge < 0 || ck.Value == "" { delete(c.cookies, ck.Name) } else { c.cookies[ck.Name] = ck } } return rec } func (c *client) get(target string) *httptest.ResponseRecorder { c.t.Helper() return c.do(http.MethodGet, target, nil) } // openLink follows an emailed sign-in link the way a person does since // rastrillo #64: the GET draws the confirm door and spends nothing, and the // door's one Sign in button POSTs the token back. It reads the action and // the token off the page (confirmForm) rather than off the link, so a door // that lost its form fails here, and it sends what a browser submitting // that form sends: Origin and Sec-Fetch-Site: same-origin. It returns the // POST's answer — what the GET used to answer. func (c *client) openLink(target string) *httptest.ResponseRecorder { c.t.Helper() rec := c.get(target) if rec.Code != http.StatusOK { c.t.Fatalf("GET %s = %d, want the confirm door; body %.300q", target, rec.Code, rec.Body.String()) } action, token := confirmForm(c.t, rec.Body.String()) return c.postSameOrigin(action, url.Values{"token": {token}}) } // postSameOrigin is a form POST carrying the evidence a browser attaches // to a same-origin form submission. func (c *client) postSameOrigin(target string, form url.Values) *httptest.ResponseRecorder { c.t.Helper() req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) req.Header.Set("Sec-Fetch-Site", "same-origin") return c.serve(req) } // confirmRe is the confirm door's form (templates/signin.html): the one // form on the page, posting the token in a hidden field. var confirmRe = regexp.MustCompile(`
\s*`) // confirmForm reads the confirm door off a page and returns where its Sign // in button posts and the token it carries. It fails the test unless the // page is the door with no address field — the shape renderConfirm draws. func confirmForm(t *testing.T, page string) (action, token string) { t.Helper() m := confirmRe.FindStringSubmatch(page) if m == nil { t.Fatalf("the link did not open the confirm door:\n%s", page) } if strings.Contains(page, `name="address"`) { t.Error("the confirm door has an address field; it is one Sign in button") } if !strings.Contains(page, ">Sign in") { t.Error("the confirm door has no Sign in button") } return html.UnescapeString(m[1]), html.UnescapeString(m[2]) } // location is the redirect a response gave, failing the test if it gave // none. func location(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { t.Fatalf("status %d, want a redirect; body %.300q", rec.Code, rec.Body.String()) } return rec.Header().Get("Location") } // chromeOwner is the one owner of the apps these gates build. const chromeOwner = "owner@example.test" // signedInClient signs chromeOwner in through the real magic-link flow, // on a production app (memoria.App with real auth), and returns the // client holding the session. dbPath lets a second app over the same // database see the same session. func signedInClient(t *testing.T, dbPath string) (*client, http.Handler) { t.Helper() h, mailer := admissionApp(t, dbPath, []string{chromeOwner}) c := newClient(t, h) if rec := c.do(http.MethodPost, "/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { t.Fatalf("POST /auth/begin = %d", rec.Code) } link := mailer.link() if link == "" { t.Fatalf("no magic link was sent: %q", mailer.text()) } u, err := url.Parse(link) if err != nil { t.Fatal(err) } landing := location(t, c.openLink(u.RequestURI())) for i := 0; i < 3 && landing != "/"; i++ { // The standalone landing (/auth/landed) goes on to the index when // nothing was remembered. landing = location(t, c.get(landing)) } if landing != "/" { t.Fatalf("sign-in landed on %q, want /", landing) } if rec := c.get("/"); rec.Code != http.StatusOK { t.Fatalf("GET / after sign-in = %d", rec.Code) } return c, h } func tempDB(t *testing.T) string { return filepath.Join(t.TempDir(), "app.db") } // newAppWithTestApp is newApp with the app's test seams exposed (AsPerson). func newAppWithTestApp(t *testing.T) (http.Handler, testApp) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("AppForTest: %v", err) } return mux, a }