oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "context"
5 "html"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "sync"
13 "testing"
14
15 rdb "amadan.net/rastrillo/rastrillo/db"
16 "amadan.net/rastrillo/rastrillo/migrate"
17
18 "amadan.net/oficina/sheets/internal/sheets/seal"
19)
20
21// recorder is a mail.Sender that delivers nothing and remembers who it
22// was asked to write to.
23type recorder struct {
24 mu sync.Mutex
25 sent []string
26 bodies []string
27 // waits are the sign-in mail queues in front of this recorder:
28 // links go out after the response (mailqueue.go), so every read
29 // waits for them first.
30 waits []func()
31}
32
33func (m *recorder) attachQueue(wait func()) {
34 m.mu.Lock()
35 m.waits = append(m.waits, wait)
36 m.mu.Unlock()
37}
38
39func (m *recorder) flush() {
40 m.mu.Lock()
41 waits := append([]func(){}, m.waits...)
42 m.mu.Unlock()
43 for _, w := range waits {
44 w()
45 }
46}
47
48func (m *recorder) Send(_ context.Context, to, _, body string) error {
49 m.mu.Lock()
50 defer m.mu.Unlock()
51 m.sent = append(m.sent, to)
52 m.bodies = append(m.bodies, body)
53 return nil
54}
55
56// lastLink pulls the verify URL out of the most recent email.
57func (m *recorder) lastLink(t *testing.T) string {
58 t.Helper()
59 m.flush()
60 m.mu.Lock()
61 defer m.mu.Unlock()
62 if len(m.bodies) == 0 {
63 t.Fatal("no mail was sent")
64 }
65 link := regexp.MustCompile(`https?://\S*/auth/verify\?\S+`).
66 FindString(m.bodies[len(m.bodies)-1])
67 if link == "" {
68 t.Fatalf("no verify link in mail body: %q", m.bodies[len(m.bodies)-1])
69 }
70 return strings.TrimRight(link, ".,)\"'>")
71}
72
73func (m *recorder) addresses() []string {
74 m.flush()
75 m.mu.Lock()
76 defer m.mu.Unlock()
77 return append([]string(nil), m.sent...)
78}
79
80const testOrigin = "https://sheets.example.com"
81
82// appWithRecorder builds a signed-in-capable app whose CSRF check is
83// off, because Config.Origin is empty. Most tests want that: they post
84// forms without faking a browser's Sec-Fetch-Site header on every
85// request. appWithOrigin is the protected shape, and
86// TestCSRFRefusesACrossOriginPost is what exercises it.
87func appWithRecorder(t *testing.T) (*App, *recorder) {
88 t.Helper()
89 return appWithOrigin(t, "")
90}
91
92func appWithOrigin(t *testing.T, origin string) (*App, *recorder) {
93 t.Helper()
94 return appWithOwners(t, origin, signedInAddress)
95}
96
97// appWithOwners is appWithOrigin with the SHEETS_OWNERS list spelled out.
98func appWithOwners(t *testing.T, origin string, owners ...string) (*App, *recorder) {
99 t.Helper()
100 d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), quietLogger())
101 if err != nil {
102 t.Fatalf("open db: %v", err)
103 }
104 t.Cleanup(func() { d.Close() })
105 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
106 t.Fatalf("migrate: %v", err)
107 }
108 rec := &recorder{}
109 sealer, err := seal.New(make([]byte, dekBytes))
110 if err != nil {
111 t.Fatalf("seal.New: %v", err)
112 }
113 // The app is built first and auth attached second, the same order
114 // main uses and for the same reason: auth's admission gate reads
115 // this app's grants table so a shared-with guest can sign in.
116 app := New(Config{
117 DB: d.G, Sealer: sealer, Origin: origin, Admit: Admits(owners),
118 })
119 a, err := NewAuth(d.Writer(), AuthEnv{
120 Origin: testOrigin,
121 InstanceKey: "test-instance-key",
122 Owners: owners,
123 Mailer: rec,
124 }, quietLogger(), app)
125 if err != nil {
126 t.Fatalf("NewAuth: %v", err)
127 }
128 t.Cleanup(func() { CloseSigninMail(a) })
129 app.UseAuth(a)
130 rememberMailer(app, rec)
131 return app, rec
132}
133
134func postSignin(t *testing.T, app *App, address string) *httptest.ResponseRecorder {
135 t.Helper()
136 req := httptest.NewRequest(http.MethodPost, "/signin",
137 strings.NewReader("address="+address))
138 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
139 req.Header.Set("Origin", testOrigin)
140 w := httptest.NewRecorder()
141 app.Router().ServeHTTP(w, req)
142 return w
143}
144
145// confirmForm is the confirm door's one form, as a browser would read it.
146var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
147
148// openLink is opening an emailed link: a GET that must spend nothing and
149// answer with the confirm door. It returns the door's form action and the
150// token it carries, read off the page rather than the link, so a door
151// that dropped or mangled the token fails here.
152func openLink(t *testing.T, h http.Handler, link string, cookies ...*http.Cookie) (action, token string) {
153 t.Helper()
154 u, err := url.Parse(link)
155 if err != nil {
156 t.Fatalf("parse emailed link %q: %v", link, err)
157 }
158 r := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)
159 for _, c := range cookies {
160 r.AddCookie(c)
161 }
162 rec := httptest.NewRecorder()
163 h.ServeHTTP(rec, r)
164 if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
165 t.Fatalf("GET %s = %d with %d cookies, want 200 and the confirm door, minting nothing", u.Path, rec.Code, len(rec.Result().Cookies()))
166 }
167 m := confirmForm.FindStringSubmatch(rec.Body.String())
168 if m == nil {
169 t.Fatalf("GET %s: no confirm form on the page:\n%s", u.Path, rec.Body.String())
170 }
171 return html.UnescapeString(m[1]), html.UnescapeString(m[2])
172}
173
174// postLink is pressing the confirm door's Sign in button: the token,
175// POSTed back to the door's action with a browser's same-origin
176// evidence. This is the request that spends the link.
177func postLink(t *testing.T, h http.Handler, action, token string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
178 t.Helper()
179 r := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
180 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
181 r.Header.Set("Origin", testOrigin)
182 r.Header.Set("Sec-Fetch-Site", "same-origin")
183 for _, c := range cookies {
184 r.AddCookie(c)
185 }
186 rec := httptest.NewRecorder()
187 h.ServeHTTP(rec, r)
188 return rec
189}
190
191// followLink is both: open the emailed link, then press Sign in.
192func followLink(t *testing.T, h http.Handler, link string) *httptest.ResponseRecorder {
193 t.Helper()
194 action, token := openLink(t, h, link)
195 return postLink(t, h, action, token)
196}
197
198// TestBeginDoesNotEmailStrangers is the defect this handler exists for.
199// auth checks Authorize at redemption, so without the gate a stranger
200// still cannot get a session — but the instance cheerfully emails them a
201// live link, which is abuse of our sending identity, not access control.
202func TestBeginDoesNotEmailStrangers(t *testing.T) {
203 app, mail := appWithRecorder(t)
204 w := postSignin(t, app, "stranger@example.com")
205 if got := mail.addresses(); len(got) != 0 {
206 t.Errorf("emailed a stranger: %v", got)
207 }
208 if w.Code != http.StatusSeeOther {
209 t.Errorf("status = %d, want 303", w.Code)
210 }
211}
212
213// TestBeginStillEmailsMembers is the other half: the gate must not have
214// broken the thing it guards. Without this, refusing everyone would pass
215// the test above.
216func TestBeginStillEmailsMembers(t *testing.T) {
217 app, mail := appWithRecorder(t)
218 postSignin(t, app, "paul@example.com")
219 got := mail.addresses()
220 if len(got) != 1 || got[0] != "paul@example.com" {
221 t.Fatalf("member mail = %v, want one to paul@example.com", got)
222 }
223}
224
225// TestBeginIsNotAMembershipOracle: the refusal has to be invisible, or
226// the endpoint becomes a way to enumerate the team. Member and stranger
227// must be indistinguishable from outside — same status, same location.
228func TestBeginIsNotAMembershipOracle(t *testing.T) {
229 app, _ := appWithRecorder(t)
230 member := postSignin(t, app, "paul@example.com")
231 stranger := postSignin(t, app, "stranger@example.com")
232
233 if member.Code != stranger.Code {
234 t.Errorf("status differs: member %d, stranger %d", member.Code, stranger.Code)
235 }
236 if m, s := member.Header().Get("Location"), stranger.Header().Get("Location"); m != s {
237 t.Errorf("Location differs: member %q, stranger %q", m, s)
238 }
239 if member.Body.String() != stranger.Body.String() {
240 t.Error("body differs between member and stranger")
241 }
242}
243
244// TestBeginGateIsCaseInsensitive: the allowlist is compared lowercased,
245// so a member typing their address with different capitalisation must
246// still receive a link rather than being silently dropped.
247func TestBeginGateIsCaseInsensitive(t *testing.T) {
248 app, mail := appWithRecorder(t)
249 postSignin(t, app, "PAUL@example.com")
250 if got := mail.addresses(); len(got) != 1 {
251 t.Errorf("mail for capitalised member address = %v, want one", got)
252 }
253}
254