oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
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.
24type client struct {
25 t *testing.T
26 h http.Handler
27 cookies map[string]*http.Cookie
28 accept string
29}
30
31func 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
35func (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.
52func (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
69func (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.
81func (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.
93func (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.
104var 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.
109func 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.
126func 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.
135const 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.
141func 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
171func 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).
174func 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