| 1 | package home |
| 2 | |
| 3 | import ( |
| 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. |
| 22 | type captureMailer struct { |
| 23 | mu sync.Mutex |
| 24 | sent []sentMail |
| 25 | } |
| 26 | |
| 27 | type sentMail struct{ To, Subject, Body string } |
| 28 | |
| 29 | func (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. |
| 37 | func (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. |
| 52 | func 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. |
| 86 | type 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. |
| 99 | func newTestApp(t *testing.T) *testApp { |
| 100 | t.Helper() |
| 101 | return newTestAppWithOrigins(t, Origins{Origin, directoryOrigin}) |
| 102 | } |
| 103 | |
| 104 | func 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. |
| 141 | func 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. |
| 184 | func 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 | |