| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "html" |
| 5 | "io" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "net/url" |
| 10 | "path/filepath" |
| 11 | "regexp" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | |
| 15 | "amadan.net/rastrillo/rastrillo/db" |
| 16 | |
| 17 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 18 | ) |
| 19 | |
| 20 | // client is a browser in miniature for the shared-chrome gates: it keeps |
| 21 | // cookies across requests (set, replace, delete), sends Accept: text/html |
| 22 | // like a navigation does, and never follows a redirect on its own, so each |
| 23 | // hop is asserted where it happens. |
| 24 | type client struct { |
| 25 | t *testing.T |
| 26 | h http.Handler |
| 27 | cookies map[string]*http.Cookie |
| 28 | accept string |
| 29 | } |
| 30 | |
| 31 | func newClient(t *testing.T, h http.Handler) *client { |
| 32 | return &client{t: t, h: h, cookies: map[string]*http.Cookie{}, accept: "text/html,application/xhtml+xml,*/*;q=0.8"} |
| 33 | } |
| 34 | |
| 35 | func (c *client) do(method, target string, form url.Values) *httptest.ResponseRecorder { |
| 36 | c.t.Helper() |
| 37 | var req *http.Request |
| 38 | if form != nil { |
| 39 | req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) |
| 40 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 41 | } else { |
| 42 | req = httptest.NewRequest(method, target, nil) |
| 43 | } |
| 44 | if method != http.MethodGet && method != http.MethodHead { |
| 45 | req.Header.Set("Origin", testOrigin) |
| 46 | } |
| 47 | return c.serve(req) |
| 48 | } |
| 49 | |
| 50 | // serve sends req with the client's Accept and cookies, and keeps the |
| 51 | // cookies the answer sets. |
| 52 | func (c *client) serve(req *http.Request) *httptest.ResponseRecorder { |
| 53 | req.Header.Set("Accept", c.accept) |
| 54 | for _, ck := range c.cookies { |
| 55 | req.AddCookie(&http.Cookie{Name: ck.Name, Value: ck.Value}) |
| 56 | } |
| 57 | rec := httptest.NewRecorder() |
| 58 | c.h.ServeHTTP(rec, req) |
| 59 | for _, ck := range rec.Result().Cookies() { |
| 60 | if ck.MaxAge < 0 || ck.Value == "" { |
| 61 | delete(c.cookies, ck.Name) |
| 62 | } else { |
| 63 | c.cookies[ck.Name] = ck |
| 64 | } |
| 65 | } |
| 66 | return rec |
| 67 | } |
| 68 | |
| 69 | func (c *client) get(target string) *httptest.ResponseRecorder { |
| 70 | c.t.Helper() |
| 71 | return c.do(http.MethodGet, target, nil) |
| 72 | } |
| 73 | |
| 74 | // openLink follows an emailed sign-in link the way a person does since |
| 75 | // rastrillo #64: the GET draws the confirm door and spends nothing, and the |
| 76 | // door's one Sign in button POSTs the token back. It reads the action and |
| 77 | // the token off the page (confirmForm) rather than off the link, so a door |
| 78 | // that lost its form fails here, and it sends what a browser submitting |
| 79 | // that form sends: Origin and Sec-Fetch-Site: same-origin. It returns the |
| 80 | // POST's answer — what the GET used to answer. |
| 81 | func (c *client) openLink(target string) *httptest.ResponseRecorder { |
| 82 | c.t.Helper() |
| 83 | rec := c.get(target) |
| 84 | if rec.Code != http.StatusOK { |
| 85 | c.t.Fatalf("GET %s = %d, want the confirm door; body %.300q", target, rec.Code, rec.Body.String()) |
| 86 | } |
| 87 | action, token := confirmForm(c.t, rec.Body.String()) |
| 88 | return c.postSameOrigin(action, url.Values{"token": {token}}) |
| 89 | } |
| 90 | |
| 91 | // postSameOrigin is a form POST carrying the evidence a browser attaches |
| 92 | // to a same-origin form submission. |
| 93 | func (c *client) postSameOrigin(target string, form url.Values) *httptest.ResponseRecorder { |
| 94 | c.t.Helper() |
| 95 | req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| 96 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 97 | req.Header.Set("Origin", testOrigin) |
| 98 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 99 | return c.serve(req) |
| 100 | } |
| 101 | |
| 102 | // confirmRe is the confirm door's form (templates/signin.html): the one |
| 103 | // form on the page, posting the token in a hidden field. |
| 104 | var confirmRe = regexp.MustCompile(`<form rst-form estilo-signin-form method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]*)">`) |
| 105 | |
| 106 | // confirmForm reads the confirm door off a page and returns where its Sign |
| 107 | // in button posts and the token it carries. It fails the test unless the |
| 108 | // page is the door with no address field — the shape renderConfirm draws. |
| 109 | func confirmForm(t *testing.T, page string) (action, token string) { |
| 110 | t.Helper() |
| 111 | m := confirmRe.FindStringSubmatch(page) |
| 112 | if m == nil { |
| 113 | t.Fatalf("the link did not open the confirm door:\n%s", page) |
| 114 | } |
| 115 | if strings.Contains(page, `name="address"`) { |
| 116 | t.Error("the confirm door has an address field; it is one Sign in button") |
| 117 | } |
| 118 | if !strings.Contains(page, ">Sign in</button>") { |
| 119 | t.Error("the confirm door has no Sign in button") |
| 120 | } |
| 121 | return html.UnescapeString(m[1]), html.UnescapeString(m[2]) |
| 122 | } |
| 123 | |
| 124 | // location is the redirect a response gave, failing the test if it gave |
| 125 | // none. |
| 126 | func location(t *testing.T, rec *httptest.ResponseRecorder) string { |
| 127 | t.Helper() |
| 128 | if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { |
| 129 | t.Fatalf("status %d, want a redirect; body %.300q", rec.Code, rec.Body.String()) |
| 130 | } |
| 131 | return rec.Header().Get("Location") |
| 132 | } |
| 133 | |
| 134 | // chromeOwner is the one owner of the apps these gates build. |
| 135 | const chromeOwner = "owner@example.test" |
| 136 | |
| 137 | // signedInClient signs chromeOwner in through the real magic-link flow, |
| 138 | // on a production app (memoria.App with real auth), and returns the |
| 139 | // client holding the session. dbPath lets a second app over the same |
| 140 | // database see the same session. |
| 141 | func signedInClient(t *testing.T, dbPath string) (*client, http.Handler) { |
| 142 | t.Helper() |
| 143 | h, mailer := admissionApp(t, dbPath, []string{chromeOwner}) |
| 144 | c := newClient(t, h) |
| 145 | if rec := c.do(http.MethodPost, "/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { |
| 146 | t.Fatalf("POST /auth/begin = %d", rec.Code) |
| 147 | } |
| 148 | link := mailer.link() |
| 149 | if link == "" { |
| 150 | t.Fatalf("no magic link was sent: %q", mailer.text()) |
| 151 | } |
| 152 | u, err := url.Parse(link) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | landing := location(t, c.openLink(u.RequestURI())) |
| 157 | for i := 0; i < 3 && landing != "/"; i++ { |
| 158 | // The standalone landing (/auth/landed) goes on to the index when |
| 159 | // nothing was remembered. |
| 160 | landing = location(t, c.get(landing)) |
| 161 | } |
| 162 | if landing != "/" { |
| 163 | t.Fatalf("sign-in landed on %q, want /", landing) |
| 164 | } |
| 165 | if rec := c.get("/"); rec.Code != http.StatusOK { |
| 166 | t.Fatalf("GET / after sign-in = %d", rec.Code) |
| 167 | } |
| 168 | return c, h |
| 169 | } |
| 170 | |
| 171 | func tempDB(t *testing.T) string { return filepath.Join(t.TempDir(), "app.db") } |
| 172 | |
| 173 | // newAppWithTestApp is newApp with the app's test seams exposed (AsPerson). |
| 174 | func newAppWithTestApp(t *testing.T) (http.Handler, testApp) { |
| 175 | t.Helper() |
| 176 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 177 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 178 | if err != nil { |
| 179 | t.Fatalf("db.Open: %v", err) |
| 180 | } |
| 181 | t.Cleanup(func() { d.Close() }) |
| 182 | mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 183 | if err != nil { |
| 184 | t.Fatalf("AppForTest: %v", err) |
| 185 | } |
| 186 | return mux, a |
| 187 | } |
| 188 | |