oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12
13 "amadan.net/rastrillo/rastrillo/db"
14 "amadan.net/rastrillo/rastrillo/migrate"
15
16 "amadan.net/oficina/home/internal/home/seal"
17)
18
19// captureMailer is the Mailer every test App runs on. It records instead
20// of sending. The mutex is what makes it safe for concurrent use;
21// mail.Sender's contract is why it has to be.
22type captureMailer struct {
23 mu sync.Mutex
24 sent []sentMail
25}
26
27type sentMail struct{ To, Subject, Body string }
28
29func (m *captureMailer) Send(_ context.Context, to, subject, body string) error {
30 m.mu.Lock()
31 defer m.mu.Unlock()
32 m.sent = append(m.sent, sentMail{To: to, Subject: subject, Body: body})
33 return nil
34}
35
36// last returns the most recent mail, or false if none was sent.
37func (m *captureMailer) last() (sentMail, bool) {
38 m.mu.Lock()
39 defer m.mu.Unlock()
40 if len(m.sent) == 0 {
41 return sentMail{}, false
42 }
43 return m.sent[len(m.sent)-1], true
44}
45
46// testSecrets builds the Secrets a test App runs on. The keys are fixed
47// rather than random so a failure reproduces byte for byte.
48//
49// These are test values and are never the deployed ones: LoadSecrets is
50// the only path that reads real config, and it refuses rather than
51// generating anything.
52func testSecrets(t *testing.T) (Secrets, *captureMailer) {
53 t.Helper()
54 dek := make([]byte, 32)
55 for i := range dek {
56 dek[i] = byte(i)
57 }
58 sealer, err := seal.New(dek)
59 if err != nil {
60 t.Fatalf("seal.New: %v", err)
61 }
62 pepper := make([]byte, 32)
63 for i := range pepper {
64 pepper[i] = byte(0x40 + i)
65 }
66 var signinKey [32]byte
67 for i := range signinKey {
68 signinKey[i] = byte(0x80 + i)
69 }
70 mailer := &captureMailer{}
71 return Secrets{
72 Sealer: sealer,
73 Pepper: pepper,
74 SigninKey: signinKey,
75 Mailer: mailer,
76 }, mailer
77}
78
79// testApp is an App plus the handles a test needs to look behind it: the
80// mail it would have sent, and the database file itself, which gate 1
81// scans for plaintext.
82//
83// A struct rather than a widening tuple: later steps add the LinkStore
84// and the signin.Flow, and every one of those would otherwise be a
85// signature change rippling through every test already written.
86type testApp struct {
87 *App
88 mailer *captureMailer
89 db *db.DB
90 dbPath string
91}
92
93// newTestApp builds an App on test secrets against a fresh database.
94//
95// The connection hook is registered before db.Open, exactly as main does
96// and for the same reason: Open pings, which opens the writer pool's one
97// long-lived connection, and a hook registered after that misses the
98// only connection that ever deletes anything.
99func newTestApp(t *testing.T) *testApp {
100 t.Helper()
101 return newTestAppWithOrigins(t, Origins{Origin, directoryOrigin})
102}
103
104func newTestAppWithOrigins(t *testing.T, origins Origins) *testApp {
105 t.Helper()
106 secrets, mailer := testSecrets(t)
107
108 RegisterSecureDelete()
109
110 dbPath := filepath.Join(t.TempDir(), "home.db")
111 d, err := db.Open(dbPath, quietLogger())
112 if err != nil {
113 t.Fatalf("db.Open: %v", err)
114 }
115 t.Cleanup(func() { d.Close() })
116
117 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
118 t.Fatalf("migrate.Apply: %v", err)
119 }
120
121 app, err := NewWithOrigins(secrets, d, quietLogger(), origins)
122 if err != nil {
123 t.Fatalf("New: %v", err)
124 }
125 // The classifier must never touch the network from a test. An empty
126 // TXT result is NOT enough: signin falls through to delegate, which
127 // returns the domain itself, and domainInfo then makes a live HTTPS
128 // GET to https://<domain>/.well-known/keymail. Every signIn in the
129 // suite's tests would reach out. A real error stops it dead, and
130 // classification fails open, so the magic-link path is what runs.
131 app.flow.Classifier.LookupTXT = func(context.Context, string) ([]string, error) {
132 return nil, errors.New("no resolver in tests")
133 }
134
135 return &testApp{App: app, mailer: mailer, db: d, dbPath: dbPath}
136}
137
138// grepPackage returns the files in this package (non-test) containing
139// needle. Used by the guards that count call sites of the two functions
140// whose "exactly once" property is load-bearing.
141func grepPackage(t *testing.T, needle string) []string {
142 t.Helper()
143 files, err := filepath.Glob("*.go")
144 if err != nil {
145 t.Fatal(err)
146 }
147 var found []string
148 for _, f := range files {
149 if strings.HasSuffix(f, "_test.go") {
150 continue
151 }
152 b, err := os.ReadFile(f)
153 if err != nil {
154 t.Fatal(err)
155 }
156 for range strings.Count(string(b), needle) {
157 found = append(found, f)
158 }
159 }
160 return found
161}
162
163// dumpDatabase renders every row of every table, so a test can assert
164// that an operation changed nothing at all.
165//
166// Every table and every column, discovered from sqlite_master rather
167// than listed. The first version of this listed three columns of one
168// table and row COUNTS of three others — so a write to sealed_address,
169// an insert into any unlisted table, and any in-place update to a
170// counted one were all invisible. A snapshot that has to be kept in
171// step with the schema is a snapshot that will not be.
172//
173// Two things it still does not see, stated so a future reader does not
174// assume otherwise: schema changes (only `name` is read from
175// sqlite_master, never `sql`, so a CREATE INDEX is invisible) and
176// pragmas such as user_version. Neither is the class of write this
177// exists to catch, which is a handler quietly touching a row.
178//
179// And ORDER BY 1 is deterministic here only because every current
180// table's first column is its primary key. A future table whose first
181// column can tie would make this flaky rather than wrong — worth
182// knowing, given the whole argument above is that a snapshot must not
183// depend on the schema staying as it is.
184func dumpDatabase(t *testing.T, app *testApp) string {
185 t.Helper()
186 var tables []string
187 rows, err := app.db.Writer().Query(
188 `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`)
189 if err != nil {
190 t.Fatal(err)
191 }
192 for rows.Next() {
193 var name string
194 if err := rows.Scan(&name); err != nil {
195 rows.Close()
196 t.Fatal(err)
197 }
198 tables = append(tables, name)
199 }
200 rows.Close()
201 if err := rows.Err(); err != nil {
202 t.Fatal(err)
203 }
204 if len(tables) == 0 {
205 t.Fatal("no tables found: the snapshot would compare nothing")
206 }
207
208 var out strings.Builder
209 for _, table := range tables {
210 fmt.Fprintf(&out, "== %s\n", table)
211 // The identifier comes from sqlite_master, not from a caller.
212 r, err := app.db.Writer().Query(`SELECT * FROM "` + table + `" ORDER BY 1`)
213 if err != nil {
214 t.Fatalf("dump %s: %v", table, err)
215 }
216 cols, err := r.Columns()
217 if err != nil {
218 r.Close()
219 t.Fatal(err)
220 }
221 for r.Next() {
222 cells := make([]any, len(cols))
223 for i := range cells {
224 cells[i] = new(any)
225 }
226 if err := r.Scan(cells...); err != nil {
227 r.Close()
228 t.Fatal(err)
229 }
230 for i, c := range cells {
231 fmt.Fprintf(&out, " %s=%v\n", cols[i], *(c.(*any)))
232 }
233 out.WriteString(" --\n")
234 }
235 r.Close()
236 if err := r.Err(); err != nil {
237 t.Fatal(err)
238 }
239 }
240 return out.String()
241}
242