oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
4 "io"
5 "log/slog"
6 "net/http"
7 "net/url"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 calendar "amadan.net/oficina/calendar/internal/calendar"
13)
14
15// Everything behind the session group is behind it. A test per route
16// would rot the moment somebody adds one, so this walks the list and
17// the list is the thing to extend.
18func TestSignInRequiredForEverything(t *testing.T) {
19 h := newApp(t).Handler()
20 for _, target := range []string{"/"} {
21 rec := get(t, h, target)
22 if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
23 t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", target, rec.Code)
24 continue
25 }
26 if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/signin") {
27 t.Errorf("GET %s signed out: Location = %q, want /signin", target, loc)
28 }
29 }
30}
31
32func TestSignInMintsASession(t *testing.T) {
33 app, session := signedIn(t, "someone@example.com")
34 if session == nil || session.Value == "" {
35 t.Fatal("sign-in minted no session cookie")
36 }
37 if rec := follow(t, app.Handler(), session, "/"); rec.Code != http.StatusOK {
38 t.Fatalf("GET / signed in: status %d, want 200", rec.Code)
39 }
40}
41
42// The defect this pins is an open relay on our own mail-sending
43// identity: rastrillo/auth checks Config.Authorize at the CALLBACK,
44// after Begin has already emailed the link. Gating only Authorize
45// would let anyone POST any address and have this instance mail it a
46// working sign-in link.
47func TestSignInMailsNothingForANonPermittedAddress(t *testing.T) {
48 app := newAppWithAllowlist(t, "allowed@example.com")
49 h := app.Handler()
50
51 post(t, h, "/signin", url.Values{"address": {"stranger@example.com"}})
52 if body, _ := app.LastMailForTest(); body != "" {
53 t.Fatalf("a non-permitted address was mailed a sign-in link:\n%s", body)
54 }
55
56 post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}})
57 body, err := app.LastMailForTest()
58 if err != nil {
59 t.Fatalf("LastMailForTest: %v", err)
60 }
61 if body == "" {
62 t.Fatal("a permitted address was mailed nothing")
63 }
64}
65
66// Any observable difference between the two responses turns this
67// endpoint into a membership oracle: an attacker could learn which
68// addresses this instance admits by watching how it answers each one.
69// Byte-for-byte, not merely "both are 303".
70func TestSignInAnswersIdenticallyForPermittedAndNot(t *testing.T) {
71 h := newAppWithAllowlist(t, "allowed@example.com").Handler()
72
73 yes := post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}})
74 no := post(t, h, "/signin", url.Values{"address": {"stranger@example.com"}})
75
76 if yes.Code != no.Code {
77 t.Errorf("status differs: permitted %d, non-permitted %d", yes.Code, no.Code)
78 }
79 if yes.Header().Get("Location") != no.Header().Get("Location") {
80 t.Errorf("Location differs: permitted %q, non-permitted %q",
81 yes.Header().Get("Location"), no.Header().Get("Location"))
82 }
83 if yes.Body.String() != no.Body.String() {
84 t.Errorf("body differs:\npermitted: %q\nnon-permitted: %q",
85 yes.Body.String(), no.Body.String())
86 }
87}
88
89// A link minted for an address that is no longer permitted must not
90// mint a session — the second of the two wirings, defending admission
91// itself rather than delivery. The refusal is the door, with the
92// refusal on it, in auth's 403 (auth.Config.Refused, handlers.refused):
93// not rastrillo's bare text, and not a redirect to a query anybody could
94// type.
95func TestASessionIsRefusedForANonPermittedAddress(t *testing.T) {
96 dir := t.TempDir()
97 app := newAppWithAllowlistAt(t, dir, "allowed@example.com")
98 h := app.Handler()
99
100 post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}})
101 body, err := app.LastMailForTest()
102 if err != nil || body == "" {
103 t.Fatalf("no link was mailed: %v", err)
104 }
105 link := magicLink(t, body)
106 app.Close()
107
108 // The same database, reopened with a different allowlist: "the
109 // allowlist changed between the link being mailed and clicked".
110 other := newAppWithAllowlistAt(t, dir, "somebody-else@example.com")
111 action, token := openLink(t, other.Handler(), link)
112 rec := postLink(t, other.Handler(), action, token)
113 if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" {
114 t.Fatalf("refused redemption = %d %q, want 403 and the door itself\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
115 }
116 for _, c := range rec.Result().Cookies() {
117 if c.Value != "" && c.MaxAge >= 0 && strings.Contains(c.Name, "session") {
118 t.Fatalf("a refused sign-in set a session cookie %q", c.Name)
119 }
120 }
121 page := rec.Body.String()
122 for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "That address can’t sign in to Calendar."} {
123 if !strings.Contains(page, want) {
124 t.Errorf("refusal door lacks %q:\n%s", want, page)
125 }
126 }
127 if rec.Header().Get("Cache-Control") != "no-store" {
128 t.Errorf("refusal door Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control"))
129 }
130}
131
132// newAppWithAllowlist builds an app admitting only the given addresses.
133// Env stays "development" so mail is still recorded rather than sent;
134// the allowlist is what is under test, not the environment gate.
135func newAppWithAllowlist(t *testing.T, addresses ...string) *calendar.App {
136 t.Helper()
137 return newAppWithAllowlistAt(t, t.TempDir(), addresses...)
138}
139
140// newAppWithAllowlistAt is newAppWithAllowlist over the database in dir,
141// so a second app can reopen the first one's.
142func newAppWithAllowlistAt(t *testing.T, dir string, addresses ...string) *calendar.App {
143 t.Helper()
144 app, err := calendar.New(calendar.Config{
145 DBPath: filepath.Join(dir, "calendar.db"),
146 Env: "development",
147 Origin: testOrigin,
148 AllowedAddresses: strings.Join(addresses, ","),
149 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
150 })
151 if err != nil {
152 t.Fatalf("calendar.New: %v", err)
153 }
154 t.Cleanup(func() { app.Close() })
155 return app
156}
157
158// magicLink pulls the request target out of a mailed sign-in link.
159func magicLink(t *testing.T, body string) string {
160 t.Helper()
161 i := strings.Index(body, "/auth/verify?token=")
162 if i < 0 {
163 t.Fatalf("no sign-in link in mail body:\n%s", body)
164 }
165 rest := body[i:]
166 if j := strings.IndexAny(rest, " \r\n\t"); j >= 0 {
167 rest = rest[:j]
168 }
169 if _, err := url.Parse(rest); err != nil {
170 t.Fatalf("unparseable sign-in link %q: %v", rest, err)
171 }
172 return rest
173}
174