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(`