oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "fmt"
8 "html"
9 "io"
10 "log/slog"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "path/filepath"
16 "regexp"
17 "strings"
18 "time"
19)
20
21// TB is the slice of *testing.T this file needs.
22//
23// An interface rather than *testing.T because this is an ordinary
24// source file, not a _test.go one — other packages' tests need these
25// helpers, which is exactly why they cannot live in a test file. Naming
26// *testing.T here would link the testing package, and its flag
27// registrations, into the shipping binary.
28type TB interface {
29 Helper()
30 Fatalf(format string, args ...any)
31 Cleanup(f func())
32}
33
34// TestOrigin is the origin NewForTest wires, and the same-origin
35// evidence a test client must present on a state-changing request.
36const TestOrigin = "http://app.test"
37
38// NewForTest builds the whole app over a fresh temporary database,
39// wired as cmd/calendar/main.go wires it, with Env fixed at
40// "development" — which is what lets the allowlist admit everyone and
41// the mail recorder capture sign-in links without a mail server.
42//
43// Env is set here rather than taken from the caller so no test can
44// accidentally exercise a production-shaped configuration and pass for
45// the wrong reason.
46func NewForTest(t TB, dir string) *App {
47 t.Helper()
48 app, err := New(Config{
49 DBPath: filepath.Join(dir, "calendar.db"),
50 Env: "development",
51 Origin: TestOrigin,
52 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
53 })
54 if err != nil {
55 t.Fatalf("calendar.NewForTest: %v", err)
56 }
57 t.Cleanup(func() { app.Close() })
58 return app
59}
60
61// SignInForTest completes a real sign-in for address and returns the
62// session cookie it minted.
63//
64// It drives the actual flow — POST /signin, read the emailed link out
65// of the development mail recorder, open it and press the confirm
66// door's Sign in — rather than writing a
67// session row directly. A helper that forged a session would keep
68// passing after the real sign-in path broke, which is the one thing a
69// sign-in test must not do.
70func (a *App) SignInForTest(t TB, address string) *http.Cookie {
71 t.Helper()
72 if a.mailRecorder == nil {
73 t.Fatalf("calendar: SignInForTest needs Env == \"development\" (no mail recorder is wired otherwise)")
74 }
75
76 form := url.Values{"address": {address}, "force": {"1"}}
77 req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
78 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
79 req.Header.Set("Origin", TestOrigin)
80 a.mux.ServeHTTP(httptest.NewRecorder(), req)
81 // The link is sent after the response (signin_mail.go).
82 a.signinMail.wait()
83
84 link := magicLinkPattern.FindString(a.mailRecorder.last())
85 if link == "" {
86 t.Fatalf("calendar: SignInForTest: no sign-in link was mailed for %q", address)
87 }
88 target, err := linkPath(link)
89 if err != nil {
90 t.Fatalf("calendar: SignInForTest: %v", err)
91 }
92
93 // Opening the link is the confirm door, which spends nothing; its
94 // Sign in button POSTs the token back, and that POST signs in.
95 open := httptest.NewRecorder()
96 a.mux.ServeHTTP(open, httptest.NewRequest(http.MethodGet, target, nil))
97 action, token, ok := ConfirmFormForTest(open.Body.String())
98 if open.Code != http.StatusOK || !ok {
99 t.Fatalf("calendar: SignInForTest: opening the link answered %d with no confirm form", open.Code)
100 }
101 req = httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
102 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
103 req.Header.Set("Origin", TestOrigin)
104 req.Header.Set("Sec-Fetch-Site", "same-origin")
105 rec := httptest.NewRecorder()
106 a.mux.ServeHTTP(rec, req)
107 for _, c := range rec.Result().Cookies() {
108 if c.Name == a.auth.SessionCookie() {
109 return c
110 }
111 }
112 t.Fatalf("calendar: SignInForTest: confirming the link minted no session cookie (status %d)", rec.Code)
113 return nil
114}
115
116// confirmForm is the confirm door's one form, as a browser reads it.
117var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
118
119// ConfirmFormForTest reads the confirm door an emailed link opens on: the
120// form's action and the token it posts back, unescaped. Read off the
121// page rather than the link, so a door that dropped or mangled the token
122// fails the test that signs in through it.
123func ConfirmFormForTest(body string) (action, token string, ok bool) {
124 m := confirmForm.FindStringSubmatch(body)
125 if m == nil {
126 return "", "", false
127 }
128 return html.UnescapeString(m[1]), html.UnescapeString(m[2]), true
129}
130
131// RequestForTest builds a request already carrying session, with the
132// same-origin evidence csrf.Protect requires on a state-changing
133// method. Every current browser sends that evidence on a form
134// submission; a test client must too, or every POST 403s for a reason
135// unrelated to what the test is checking.
136func RequestForTest(session *http.Cookie, method, target string, form url.Values) *http.Request {
137 var req *http.Request
138 if form == nil {
139 req = httptest.NewRequest(method, target, nil)
140 } else {
141 req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
142 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
143 }
144 if method != http.MethodGet && method != http.MethodHead {
145 req.Header.Set("Origin", TestOrigin)
146 }
147 if session != nil {
148 req.AddCookie(session)
149 }
150 return req
151}
152
153// DBForTest is the raw writer handle, for tests that need to assert
154// something about the schema itself rather than about a page. Nothing
155// in the app uses it.
156func (a *App) DBForTest() *sql.DB {
157 db, err := a.db.G.DB()
158 if err != nil {
159 panic("calendar: DBForTest: " + err.Error())
160 }
161 return db
162}
163
164// GrepDatabaseForTest searches the raw bytes of this instance's SQLite
165// file, and its write-ahead log, for a string.
166//
167// The bytes, not a query. The one rule is about what the FILE holds,
168// because the platform replicates that file to object storage — so a
169// test that asked SQLite whether a title was readable would be asking
170// the wrong thing entirely, and a column that was never sealed looks
171// identical through Go while being completely different on disk.
172//
173// The WAL is searched too. A recent write may not have reached the main
174// file yet, and a test that checked only the main file would pass by
175// looking somewhere the data has not arrived.
176func (a *App) GrepDatabaseForTest(t TB, needle string) (bool, string) {
177 t.Helper()
178 for _, suffix := range []string{"", "-wal", "-shm"} {
179 path := a.cfg.DBPath + suffix
180 b, err := os.ReadFile(path)
181 if err != nil {
182 continue // an absent WAL is not a failure
183 }
184 if bytes.Contains(b, []byte(needle)) {
185 return true, filepath.Base(path)
186 }
187 }
188 return false, ""
189}
190
191// LastMailForTest returns the body of the most recent message this app
192// sent, or an error outside development where nothing records one.
193func (a *App) LastMailForTest() (string, error) {
194 if a.mailRecorder == nil {
195 return "", fmt.Errorf("calendar: no mail recorder (Env is %q, not \"development\")", a.cfg.Env)
196 }
197 a.signinMail.wait()
198 return a.mailRecorder.last(), nil
199}
200
201// StoreForTest is the app's store, for tests that exercise a store
202// function directly rather than through a page — the booking store,
203// whose handlers arrive in a later task than its tables.
204func (a *App) StoreForTest() *Store { return a.store }
205
206// PersonByAddressForTest returns the person row for an address that has
207// signed in, creating it as the first signed-in page view would.
208//
209// It goes through EnsurePersonSetup rather than a bare read because a
210// sign-in alone mints a session and nothing else: the person row, the
211// team grant and the personal calendar are written by requirePerson on
212// the first page view. A test that has signed in but not yet asked for
213// a page would otherwise find no row, and a booking host needs the
214// personal calendar the setup creates.
215func (a *App) PersonByAddressForTest(t TB, address string) Person {
216 t.Helper()
217 p, err := a.store.EnsurePersonSetup(context.Background(), address)
218 if err != nil {
219 t.Fatalf("calendar: PersonByAddressForTest: %v", err)
220 }
221 return p
222}
223
224// ZoneCacheSizeForTest counts the zones Zone() has memoised. The test
225// that uses it feeds Zone() a hundred names that do not exist and then
226// asserts the count stayed small: a cache that kept failures is one a
227// stranger can grow.
228func ZoneCacheSizeForTest() int {
229 n := 0
230 zoneCache.Range(func(_, _ any) bool {
231 n++
232 return true
233 })
234 return n
235}
236
237// SetAfterSlotCheckForTest installs a callback that runs between a
238// hold's response-time slot check and its transaction, so a test can
239// commit an event over the slot and prove the in-transaction re-check
240// behind the write lock catches it. nil removes it.
241func (a *App) SetAfterSlotCheckForTest(f func()) { a.store.afterSlotCheckForTest = f }
242
243// TrySendNowForTest is the immediate attempt a transition makes for one
244// booking's owed mail, and what it reports: true only when nothing is
245// owed afterwards.
246func (a *App) TrySendNowForTest(bookingID int64) bool {
247 return a.h.trySendNow(context.Background(), bookingID, time.Now())
248}
249
250// FlushSigninMailForTest waits until every sign-in link queued so far
251// has been sent or refused. Links go out after the response
252// (signin_mail.go), and so does the refusal's log line.
253func (a *App) FlushSigninMailForTest() {
254 a.signinMail.wait()
255}
256