| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 23 | type 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 | |
| 33 | func (m *recorder) attachQueue(wait func()) { |
| 34 | m.mu.Lock() |
| 35 | m.waits = append(m.waits, wait) |
| 36 | m.mu.Unlock() |
| 37 | } |
| 38 | |
| 39 | func (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 | |
| 48 | func (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. |
| 57 | func (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 | |
| 73 | func (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 | |
| 80 | const 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. |
| 87 | func appWithRecorder(t *testing.T) (*App, *recorder) { |
| 88 | t.Helper() |
| 89 | return appWithOrigin(t, "") |
| 90 | } |
| 91 | |
| 92 | func 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. |
| 98 | func 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 | |
| 134 | func 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. |
| 146 | var 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. |
| 152 | func 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. |
| 177 | func 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. |
| 192 | func 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. |
| 202 | func 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. |
| 216 | func 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. |
| 228 | func 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. |
| 247 | func 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 | |