carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movementtest
2
3import (
4 "io"
5 "log/slog"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "testing"
13
14 "github.com/carlosframework/rastrillo/db"
15
16 movement "amadan.net/carlos/movement/internal/movement"
17)
18
19const adminAddr = "paul@example.com"
20
21// newAdminApp is the app with an allowlist, so the admin surface exists at
22// all. newApp deliberately has none.
23func newAdminApp(t *testing.T) *testApp {
24 t.Helper()
25 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
26 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
27 if err != nil {
28 t.Fatalf("db.Open: %v", err)
29 }
30 t.Cleanup(func() { d.Close() })
31 movement.SetLogger(logger)
32
33 box := &recorder{}
34 env := testEnv()
35 env.Admins = []string{adminAddr}
36 env.Mailer = box
37
38 mux, err := movement.App(d, env)
39 if err != nil {
40 t.Fatalf("App: %v", err)
41 }
42 return &testApp{Handler: mux, DB: d, Mail: box}
43}
44
45// adminRoutes is every route the admin surface adds. The fail-closed gate
46// iterates this rather than spot-checking, because a route added later
47// without being listed here is exactly the one that would stay live.
48var adminRoutes = []struct{ method, path string }{
49 {http.MethodGet, "/signin"},
50 {http.MethodPost, "/signin"},
51 {http.MethodGet, "/auth/callback"},
52 {http.MethodGet, "/auth/verify"},
53 {http.MethodPost, "/signout"},
54 {http.MethodGet, "/admin"},
55 {http.MethodGet, "/admin/export.csv"},
56}
57
58// With no allowlist there is no admin surface: not 403, not an open page,
59// but genuinely absent — indistinguishable from an app that never had one.
60//
61// Sign-in has to be included. Left live under an empty allowlist it would let
62// anyone make this app email a link from our verified sending identity to any
63// address they named.
64func TestAdminSurfaceIsAbsentWithoutAnAllowlist(t *testing.T) {
65 h := newApp(t) // testEnv sets no admins
66
67 for _, rt := range adminRoutes {
68 var rec *httptest.ResponseRecorder
69 if rt.method == http.MethodGet {
70 rec = get(t, h, rt.path)
71 } else {
72 rec = post(t, h, rt.path, url.Values{"address": {adminAddr}})
73 }
74 if rec.Code != http.StatusNotFound {
75 t.Errorf("%s %s: status %d, want 404", rt.method, rt.path, rec.Code)
76 }
77 }
78 if h.Mail.count() != 0 {
79 t.Error("a request to the unmounted admin surface still sent mail")
80 }
81}
82
83// A refused address gets exactly the response an accepted one gets. Saying
84// "that address cannot sign in" would turn this endpoint into a way to
85// enumerate who the admins are.
86func TestSigninRefusesAStrangerWithoutSayingSo(t *testing.T) {
87 h := newAdminApp(t)
88
89 stranger := post(t, h, "/signin", url.Values{"address": {"nobody@example.com"}})
90 if h.Mail.countTo("nobody@example.com") != 0 {
91 t.Error("a stranger's address was mailed a live sign-in link")
92 }
93
94 admin := post(t, h, "/signin", url.Values{"address": {adminAddr}})
95
96 if stranger.Code != admin.Code {
97 t.Errorf("stranger got %d, admin got %d — the difference is an oracle",
98 stranger.Code, admin.Code)
99 }
100 if stranger.Header().Get("Location") != admin.Header().Get("Location") {
101 t.Errorf("stranger redirected to %q, admin to %q",
102 stranger.Header().Get("Location"), admin.Header().Get("Location"))
103 }
104 if h.Mail.countTo(adminAddr) != 1 {
105 t.Error("the admin was not sent a link")
106 }
107}
108
109var anyLink = regexp.MustCompile(`https?://[^\s>"]+`)
110
111// signInAsAdmin drives the whole magic-link round trip and returns the
112// session cookies.
113func signInAsAdmin(t *testing.T, h *testApp) []*http.Cookie {
114 t.Helper()
115 post(t, h, "/signin", url.Values{"address": {adminAddr}})
116 body := h.Mail.waitForMailTo(t, adminAddr, 1)
117
118 link := anyLink.FindString(body)
119 if link == "" {
120 t.Fatalf("no sign-in link in the message:\n%s", body)
121 }
122 u, err := url.Parse(link)
123 if err != nil {
124 t.Fatalf("parsing %q: %v", link, err)
125 }
126
127 req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)
128 rec := httptest.NewRecorder()
129 h.ServeHTTP(rec, req)
130
131 cookies := rec.Result().Cookies()
132 if len(cookies) == 0 {
133 t.Fatalf("redeeming the link minted no session (status %d)", rec.Code)
134 }
135 return cookies
136}
137
138func getAs(t *testing.T, h *testApp, target string, cookies []*http.Cookie) *httptest.ResponseRecorder {
139 t.Helper()
140 req := httptest.NewRequest(http.MethodGet, target, nil)
141 for _, c := range cookies {
142 req.AddCookie(c)
143 }
144 rec := httptest.NewRecorder()
145 h.ServeHTTP(rec, req)
146 return rec
147}
148
149// The export restriction is the one rule's first half made mechanical: a row
150// nobody has confirmed is not on the list, so it is not in the file.
151func TestExportCarriesOnlyConfirmedRows(t *testing.T) {
152 h := newAdminApp(t)
153
154 postSignup(t, h, submission(t, "in@example.com", "curious"))
155 confirmSignup(t, h, "in@example.com")
156 postSignup(t, h, submission(t, "waiting@example.com", "never"))
157
158 cookies := signInAsAdmin(t, h)
159 rec := getAs(t, h, "/admin/export.csv", cookies)
160
161 if rec.Code != http.StatusOK {
162 t.Fatalf("GET /admin/export.csv: %d", rec.Code)
163 }
164 body := rec.Body.String()
165 if !strings.Contains(body, "in@example.com") {
166 t.Error("a confirmed address is missing from the export")
167 }
168 if strings.Contains(body, "waiting@example.com") {
169 t.Error("an unconfirmed address reached the export")
170 }
171}
172
173// This file is opened in a spreadsheet, and a cell beginning =, +, -, @, tab
174// or carriage return executes when it is. The background field is free text
175// somebody else wrote, which makes it the obvious carrier.
176func TestExportDefusesFormulaInjection(t *testing.T) {
177 h := newAdminApp(t)
178
179 form := submission(t, "sneaky@example.com", "curious")
180 form.Set("background", `=cmd|' /c calc'!A1`)
181 postSignup(t, h, form)
182 confirmSignup(t, h, "sneaky@example.com")
183
184 cookies := signInAsAdmin(t, h)
185 body := getAs(t, h, "/admin/export.csv", cookies).Body.String()
186
187 if strings.Contains(body, ",=cmd") || strings.Contains(body, "\"=cmd") {
188 t.Errorf("a formula reached the export unescaped:\n%s", body)
189 }
190 if !strings.Contains(body, "'=cmd") {
191 t.Errorf("the escaping prefix is missing:\n%s", body)
192 }
193}
194
195func TestAdminPageDoesNotListPendingRows(t *testing.T) {
196 h := newAdminApp(t)
197
198 postSignup(t, h, submission(t, "shown@example.com", "curious"))
199 confirmSignup(t, h, "shown@example.com")
200 postSignup(t, h, submission(t, "hidden@example.com", "never"))
201
202 cookies := signInAsAdmin(t, h)
203 body := getAs(t, h, "/admin", cookies).Body.String()
204
205 if !strings.Contains(body, "shown@example.com") {
206 t.Error("a confirmed address is missing from the admin list")
207 }
208 if strings.Contains(body, "hidden@example.com") {
209 t.Error("a pending address — unproven, and joined to a stance — was listed")
210 }
211}
212
213func TestAdminNeedsASession(t *testing.T) {
214 h := newAdminApp(t)
215
216 for _, path := range []string{"/admin", "/admin/export.csv"} {
217 rec := get(t, h, path)
218 if rec.Code == http.StatusOK {
219 t.Errorf("GET %s answered 200 with no session", path)
220 }
221 }
222}
223
224// auth consults Authorize once, when a link is redeemed. Without a
225// per-request re-check, removing somebody from the allowlist would do nothing
226// until their session expired.
227//
228// The test restarts the app rather than changing an environment variable
229// mid-run: a process's environment is fixed at exec, so t.Setenv would pass
230// while proving nothing about how removal actually takes effect.
231func TestRemovedAdminLosesAccessOnTheNextRequest(t *testing.T) {
232 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
233 dir := t.TempDir()
234
235 box := &recorder{}
236 env := testEnv()
237 env.Admins = []string{adminAddr}
238 env.Mailer = box
239
240 d, err := db.Open(filepath.Join(dir, "app.db"), logger)
241 if err != nil {
242 t.Fatalf("db.Open: %v", err)
243 }
244 movement.SetLogger(logger)
245 mux, err := movement.App(d, env)
246 if err != nil {
247 t.Fatalf("App: %v", err)
248 }
249 first := &testApp{Handler: mux, DB: d, Mail: box}
250
251 cookies := signInAsAdmin(t, first)
252 if rec := getAs(t, first, "/admin", cookies); rec.Code != http.StatusOK {
253 t.Fatalf("setup: admin got %d before removal", rec.Code)
254 }
255 d.Close()
256
257 // Same database, same session cookie, allowlist without them — which is
258 // the state the platform leaves behind after `carlos env set` converges
259 // and bounces the instance.
260 d2, err := db.Open(filepath.Join(dir, "app.db"), logger)
261 if err != nil {
262 t.Fatalf("reopening: %v", err)
263 }
264 t.Cleanup(func() { d2.Close() })
265 env.Admins = []string{"someone.else@example.com"}
266 mux2, err := movement.App(d2, env)
267 if err != nil {
268 t.Fatalf("App: %v", err)
269 }
270 second := &testApp{Handler: mux2, DB: d2, Mail: box}
271
272 if rec := getAs(t, second, "/admin", cookies); rec.Code == http.StatusOK {
273 t.Error("a removed admin still reached /admin with their old session")
274 }
275}
276
277// Signing in has exactly one purpose here, so a fresh session lands on the
278// list. auth's default is "/", which would send an admin who has just
279// authenticated back to the public bill they started from.
280func TestSigningInLandsOnTheAdminList(t *testing.T) {
281 h := newAdminApp(t)
282
283 post(t, h, "/signin", url.Values{"address": {adminAddr}})
284 body := h.Mail.waitForMailTo(t, adminAddr, 1)
285 link := anyLink.FindString(body)
286 if link == "" {
287 t.Fatalf("no sign-in link in the message:\n%s", body)
288 }
289 u, err := url.Parse(link)
290 if err != nil {
291 t.Fatal(err)
292 }
293
294 req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)
295 rec := httptest.NewRecorder()
296 h.ServeHTTP(rec, req)
297
298 if got := rec.Header().Get("Location"); got != "/admin" {
299 t.Errorf("a fresh session landed on %q, want /admin", got)
300 }
301}
302