orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "database/sql"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "testing"
11 "time"
12
13 "auth.bythewood.me/web"
14)
15
16// newTestSite builds the real thing against a temporary database and a stub
17// ntfy, so a template referencing a field that does not exist fails here rather
18// than at execute time on the live site.
19func newTestSite(t *testing.T) (*site, *stubNtfy) {
20 t.Helper()
21
22 db, err := openDB(t.TempDir() + "/db.sqlite3")
23 if err != nil {
24 t.Fatalf("open db: %v", err)
25 }
26 t.Cleanup(func() { db.Close() })
27
28 dist := distFS()
29 assets, err := web.LoadAssets(dist)
30 if err != nil {
31 t.Fatalf("load assets (run `make build SITE=auth.bythewood.me` first): %v", err)
32 }
33
34 templates, err := templateSub()
35 if err != nil {
36 t.Fatalf("templates: %v", err)
37 }
38 renderer, err := web.NewRenderer(templates, templateFuncs,
39 []string{"base.html", "partials.html"}, allPages)
40 if err != nil {
41 t.Fatalf("renderer: %v", err)
42 }
43
44 stub := newStubNtfy(t)
45 s := &site{
46 renderer: renderer,
47 db: db,
48 dist: dist,
49 assets: assets,
50 notifier: stub.notifier,
51 baseScript: assets.Script("static_src/base/index.js"),
52 baseStyles: assets.Styles("static_src/base/index.js"),
53 }
54 return s, stub
55}
56
57type stubNtfy struct {
58 notifier *Notifier
59 server *httptest.Server
60 bodies []string
61 titles []string
62}
63
64func newStubNtfy(t *testing.T) *stubNtfy {
65 t.Helper()
66 s := &stubNtfy{}
67 s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68 body, _ := io.ReadAll(r.Body)
69 s.bodies = append(s.bodies, string(body))
70 s.titles = append(s.titles, r.Header.Get("Title"))
71 w.WriteHeader(http.StatusOK)
72 }))
73 t.Cleanup(s.server.Close)
74
75 s.notifier = &Notifier{
76 client: s.server.Client(),
77 base: s.server.URL,
78 topic: ntfyTopic,
79 token: "tk_test",
80 }
81 return s
82}
83
84// lastCode pulls the six digits out of the stubbed notification title, which is
85// the only place a test can see them, the same as a phone.
86func (s *stubNtfy) lastCode(t *testing.T) string {
87 t.Helper()
88 if len(s.titles) == 0 {
89 t.Fatal("no notification was published")
90 }
91 title := s.titles[len(s.titles)-1]
92 fields := strings.Fields(title)
93 return fields[len(fields)-1]
94}
95
96func TestEveryPageRenders(t *testing.T) {
97 s, stub := newTestSite(t)
98 if err := runInit(s.db); err != nil {
99 t.Fatalf("init: %v", err)
100 }
101
102 // Signed out first: the four public pages.
103 for _, path := range []string{"/", "/login", "/recovery"} {
104 rec := httptest.NewRecorder()
105 s.handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
106 if rec.Code != http.StatusOK {
107 t.Fatalf("GET %s signed out: %d", path, rec.Code)
108 }
109 }
110
111 cookie := signIn(t, s, stub)
112
113 for _, path := range []string{"/account", "/sessions", "/security", "/activity"} {
114 req := httptest.NewRequest(http.MethodGet, path, nil)
115 req.AddCookie(cookie)
116 rec := httptest.NewRecorder()
117 s.handler().ServeHTTP(rec, req)
118 if rec.Code != http.StatusOK {
119 t.Fatalf("GET %s signed in: %d, body %s", path, rec.Code, rec.Body.String())
120 }
121 }
122
123 // The pages with no route of their own.
124 for name, render := range map[string]func(){
125 "notfound": func() {
126 rec := httptest.NewRecorder()
127 s.notFound(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
128 if rec.Code != http.StatusNotFound {
129 t.Fatalf("notfound: %d", rec.Code)
130 }
131 },
132 "uninitialized": func() {
133 rec := httptest.NewRecorder()
134 s.uninitialized(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
135 if rec.Code != http.StatusServiceUnavailable {
136 t.Fatalf("uninitialized: %d", rec.Code)
137 }
138 },
139 "error": func() {
140 rec := httptest.NewRecorder()
141 s.fail(rec, httptest.NewRequest(http.MethodGet, "/", nil), "testing", sql.ErrNoRows)
142 if rec.Code != http.StatusInternalServerError {
143 t.Fatalf("error: %d", rec.Code)
144 }
145 },
146 "codes": func() {
147 // Rendered by the rotate handler rather than a GET route, so
148 // nothing else here would catch a bad field on it.
149 rec := httptest.NewRecorder()
150 data := s.page(httptest.NewRequest(http.MethodGet, "/security", nil),
151 "Recovery codes", "")
152 data.NewCodes = []string{"aaaa-bbbb-cccc", "dddd-eeee-ffff"}
153 data.Remaining = 2
154 s.renderer.Render(rec, http.StatusOK, "codes.html", data)
155 if rec.Code != http.StatusOK || rec.Body.Len() < 500 {
156 t.Fatalf("codes: %d, %d bytes", rec.Code, rec.Body.Len())
157 }
158 },
159 "code": func() {
160 rec := httptest.NewRecorder()
161 s.codePage(rec, httptest.NewRequest(http.MethodGet, "/code", nil), "", "", http.StatusOK)
162 if rec.Code != http.StatusOK {
163 t.Fatalf("code: %d", rec.Code)
164 }
165 },
166 } {
167 t.Run(name, func(t *testing.T) { render() })
168 }
169}
170
171// signIn walks the real two step flow and returns the session cookie.
172func signIn(t *testing.T, s *site, stub *stubNtfy) *http.Cookie {
173 t.Helper()
174
175 rec := httptest.NewRecorder()
176 s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
177 if rec.Code != http.StatusOK {
178 t.Fatalf("POST /login: %d, body %s", rec.Code, rec.Body.String())
179 }
180 pending := cookieNamed(rec.Result().Cookies(), pendingCookie)
181 if pending == nil {
182 t.Fatal("no pending cookie was set")
183 }
184
185 req := postForm("/code", url.Values{"code": {stub.lastCode(t)}})
186 req.AddCookie(pending)
187 rec = httptest.NewRecorder()
188 s.handler().ServeHTTP(rec, req)
189 if rec.Code != http.StatusSeeOther {
190 t.Fatalf("POST /code: %d, body %s", rec.Code, rec.Body.String())
191 }
192
193 session := cookieNamed(rec.Result().Cookies(), sessionCookie)
194 if session == nil {
195 t.Fatal("no session cookie was set")
196 }
197 return session
198}
199
200func postForm(path string, values url.Values) *http.Request {
201 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
202 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
203 return req
204}
205
206func cookieNamed(cookies []*http.Cookie, name string) *http.Cookie {
207 for _, c := range cookies {
208 if c.Name == name && c.Value != "" {
209 return c
210 }
211 }
212 return nil
213}
214
215// A code pushed to the phone has to be useless in a browser that did not ask
216// for it, or somebody who can see the notification can sign in with it.
217func TestCodeIsBoundToTheBrowserThatAskedForIt(t *testing.T) {
218 s, stub := newTestSite(t)
219 if err := runInit(s.db); err != nil {
220 t.Fatal(err)
221 }
222
223 rec := httptest.NewRecorder()
224 s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
225 code := stub.lastCode(t)
226
227 // The right code, in a browser carrying no pending cookie.
228 rec = httptest.NewRecorder()
229 s.handler().ServeHTTP(rec, postForm("/code", url.Values{"code": {code}}))
230 if rec.Code == http.StatusSeeOther {
231 t.Fatal("a code was accepted from a browser that never asked for one")
232 }
233
234 // And in a browser carrying somebody else's.
235 req := postForm("/code", url.Values{"code": {code}})
236 req.AddCookie(&http.Cookie{Name: pendingCookie, Value: "someone-elses-token"})
237 rec = httptest.NewRecorder()
238 s.handler().ServeHTTP(rec, req)
239 if rec.Code == http.StatusSeeOther {
240 t.Fatal("a code was accepted with the wrong pending cookie")
241 }
242}
243
244// The rule that turns a flood of requests into one notification.
245func TestOnlyOneCodeIsOutstandingAtATime(t *testing.T) {
246 s, stub := newTestSite(t)
247 if err := runInit(s.db); err != nil {
248 t.Fatal(err)
249 }
250
251 for i := 0; i < 4; i++ {
252 rec := httptest.NewRecorder()
253 s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
254 }
255 if len(stub.titles) != 1 {
256 t.Fatalf("four login requests published %d notifications, want 1", len(stub.titles))
257 }
258}
259
260// The ceiling counts published notifications for the account and ignores where
261// the request came from, which is what still holds when every request arrives
262// from a different address.
263func TestSendCeilingIgnoresTheSourceAddress(t *testing.T) {
264 s, stub := newTestSite(t)
265 if err := runInit(s.db); err != nil {
266 t.Fatal(err)
267 }
268
269 for i := 0; i < sendCeiling+3; i++ {
270 // A fresh address every time, and each code consumed so the
271 // one-outstanding rule is not what stops it.
272 req := postForm("/login", url.Values{"username": {seedUsername}})
273 req.Header.Set("CF-Connecting-IP", "203.0.113."+itoa(i+1))
274 rec := httptest.NewRecorder()
275 s.handler().ServeHTTP(rec, req)
276
277 _, _ = s.db.Exec(`UPDATE pending_logins SET consumed = 1`)
278 loginBucket.tokens = loginBucket.burst
279 }
280
281 if len(stub.titles) > sendCeiling {
282 t.Fatalf("published %d notifications from %d addresses, ceiling is %d",
283 len(stub.titles), sendCeiling+3, sendCeiling)
284 }
285}
286
287func TestWrongCodesAreBurnedAfterFiveTries(t *testing.T) {
288 s, stub := newTestSite(t)
289 if err := runInit(s.db); err != nil {
290 t.Fatal(err)
291 }
292
293 rec := httptest.NewRecorder()
294 s.handler().ServeHTTP(rec, postForm("/login", url.Values{"username": {seedUsername}}))
295 pending := cookieNamed(rec.Result().Cookies(), pendingCookie)
296 real := stub.lastCode(t)
297
298 wrong := "000000"
299 if real == wrong {
300 wrong = "111111"
301 }
302 for i := 0; i < maxAttempts; i++ {
303 req := postForm("/code", url.Values{"code": {wrong}})
304 req.AddCookie(pending)
305 rec = httptest.NewRecorder()
306 s.handler().ServeHTTP(rec, req)
307 codeBucket.tokens = codeBucket.burst
308 }
309
310 // The real code must be dead too, or five guesses buys a sixth.
311 req := postForm("/code", url.Values{"code": {real}})
312 req.AddCookie(pending)
313 rec = httptest.NewRecorder()
314 s.handler().ServeHTTP(rec, req)
315 if rec.Code == http.StatusSeeOther {
316 t.Fatal("the code still worked after five wrong guesses")
317 }
318}
319
320func TestRecoveryCodesWorkOnceEach(t *testing.T) {
321 s, _ := newTestSite(t)
322 codes, err := regenerateRecoveryCodes(s.db)
323 if err != nil {
324 t.Fatal(err)
325 }
326 if len(codes) != recoveryCount {
327 t.Fatalf("generated %d codes, want %d", len(codes), recoveryCount)
328 }
329
330 remaining, err := useRecoveryCode(s.db, codes[0])
331 if err != nil {
332 t.Fatalf("first use: %v", err)
333 }
334 if remaining != recoveryCount-1 {
335 t.Fatalf("remaining %d, want %d", remaining, recoveryCount-1)
336 }
337
338 if _, err := useRecoveryCode(s.db, codes[0]); err == nil {
339 t.Fatal("the same recovery code worked twice")
340 }
341 if _, err := useRecoveryCode(s.db, "not-a-real-code"); err == nil {
342 t.Fatal("a made up recovery code was accepted")
343 }
344
345 // Regenerating has to kill the old set, or an old slip of paper still works.
346 if _, err := regenerateRecoveryCodes(s.db); err != nil {
347 t.Fatal(err)
348 }
349 if _, err := useRecoveryCode(s.db, codes[1]); err == nil {
350 t.Fatal("an old code survived a regenerate")
351 }
352}
353
354// Revocation is the reason sessions are opaque rows rather than signed cookies,
355// so it has to actually take effect on the next request.
356func TestRevokingASessionEndsIt(t *testing.T) {
357 s, stub := newTestSite(t)
358 if err := runInit(s.db); err != nil {
359 t.Fatal(err)
360 }
361 cookie := signIn(t, s, stub)
362
363 req := httptest.NewRequest(http.MethodGet, "/account", nil)
364 req.AddCookie(cookie)
365 rec := httptest.NewRecorder()
366 s.handler().ServeHTTP(rec, req)
367 if rec.Code != http.StatusOK {
368 t.Fatalf("account before revoke: %d", rec.Code)
369 }
370
371 l, err := lookupSession(s.db, req)
372 if err != nil {
373 t.Fatal(err)
374 }
375 if err := revokeSession(s.db, l.ID); err != nil {
376 t.Fatal(err)
377 }
378
379 req = httptest.NewRequest(http.MethodGet, "/account", nil)
380 req.AddCookie(cookie)
381 rec = httptest.NewRecorder()
382 s.handler().ServeHTTP(rec, req)
383 if rec.Code != http.StatusSeeOther {
384 t.Fatalf("account after revoke: %d, want a redirect to the login", rec.Code)
385 }
386}
387
388func TestVerifyAnswersForTheOtherSites(t *testing.T) {
389 s, stub := newTestSite(t)
390 if err := runInit(s.db); err != nil {
391 t.Fatal(err)
392 }
393
394 rec := httptest.NewRecorder()
395 s.handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/verify", nil))
396 if rec.Code != http.StatusUnauthorized {
397 t.Fatalf("/verify with no cookie: %d", rec.Code)
398 }
399
400 cookie := signIn(t, s, stub)
401 req := httptest.NewRequest(http.MethodGet, "/verify", nil)
402 req.AddCookie(cookie)
403 rec = httptest.NewRecorder()
404 s.handler().ServeHTTP(rec, req)
405 if rec.Code != http.StatusOK {
406 t.Fatalf("/verify with a live cookie: %d", rec.Code)
407 }
408 if !strings.Contains(rec.Body.String(), `"username":"`+seedUsername+`"`) {
409 t.Fatalf("/verify said %s", rec.Body.String())
410 }
411}
412
413// A login must never be able to bounce somebody off the platform.
414func TestSafeNextStaysOnThePlatform(t *testing.T) {
415 for next, want := range map[string]string{
416 "": defaultNext,
417 "/sessions": "/sessions",
418 "//evil.example": defaultNext,
419 "/\\evil.example": defaultNext,
420 "https://evil.example/": defaultNext,
421 "http://analytics.bythewood.me/": defaultNext,
422 "https://analytics.bythewood.me/x": "https://analytics.bythewood.me/x",
423 "https://bythewood.me.evil.example/": defaultNext,
424 "https://logging.bythewood.me/search": "https://logging.bythewood.me/search",
425 } {
426 if got := safeNext(next); got != want {
427 t.Errorf("safeNext(%q) = %q, want %q", next, got, want)
428 }
429 }
430}
431
432func TestInitIsIdempotent(t *testing.T) {
433 s, _ := newTestSite(t)
434 if err := runInit(s.db); err != nil {
435 t.Fatal(err)
436 }
437 codes, err := countRecoveryCodes(s.db)
438 if err != nil || codes != recoveryCount {
439 t.Fatalf("after init: %d codes, %v", codes, err)
440 }
441
442 if _, err := useRecoveryCode(s.db, "aaaa-bbbb-cccc"); err == nil {
443 t.Fatal("a made up code was accepted")
444 }
445
446 // A second init must not replace the codes somebody already wrote down.
447 if err := runInit(s.db); err != nil {
448 t.Fatal(err)
449 }
450 again, _ := countRecoveryCodes(s.db)
451 if again != recoveryCount {
452 t.Fatalf("a second init changed the code count to %d", again)
453 }
454}
455
456func TestSudoExpires(t *testing.T) {
457 l := live{SudoAt: time.Now()}
458 if !l.inSudo() {
459 t.Fatal("a fresh login is not in sudo")
460 }
461 l.SudoAt = time.Now().Add(-sudoWindow - time.Minute)
462 if l.inSudo() {
463 t.Fatal("sudo outlived its window")
464 }
465}
466
467// A site's own /login is a redirect to auth, so handing it back as the return
468// address is the infinite loop it caused on logging.bythewood.me: auth sends you
469// there, the stub sends you to auth, auth sees a session and sends you there.
470func TestLoginURLNeverReturnsToALoginStub(t *testing.T) {
471 for path, want := range map[string]string{
472 "/login": "https://logging.bythewood.me/",
473 "/login?next=/overview": "https://logging.bythewood.me/overview",
474 "/login?next=//evil": "https://logging.bythewood.me/",
475 "/login?next=/login": "https://logging.bythewood.me/",
476 "/overview": "https://logging.bythewood.me/overview",
477 } {
478 r := httptest.NewRequest(http.MethodGet, path, nil)
479 r.Host = "logging.bythewood.me"
480 got := web.LoginURL(r)
481 if !strings.HasSuffix(got, url.QueryEscape(want)) {
482 t.Errorf("LoginURL(%q) = %q, want it to return to %q", path, got, want)
483 }
484 }
485}