carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1package contratest
2
3import (
4 "database/sql"
5 "io"
6 "log/slog"
7 "path/filepath"
8 "sort"
9 "testing"
10
11 "github.com/carlosframework/rastrillo/db"
12
13 contra "github.com/carlosframework/contra/internal/contra"
14)
15
16// newDB opens a database the way main.go does, and hands back the handle
17// so a test can look at what the connection is actually configured to do.
18func newDB(t *testing.T) *db.DB {
19 t.Helper()
20 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
21 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
22 if err != nil {
23 t.Fatalf("db.Open: %v", err)
24 }
25 t.Cleanup(func() { d.Close() })
26 return d
27}
28
29// The storage settings web/1 (discussion 7) requires. The framework sets
30// them and we do not hand-roll them, but "the framework does it" is a
31// claim, and this is what turns it into a fact — including the pragma
32// ORDER, which is the part with teeth: busy_timeout has to be set before
33// journal_mode or concurrent opens crash. Order cannot be observed after
34// the fact, so what is checked here is the outcome it exists to produce.
35func TestDatabasePragmas(t *testing.T) {
36 d := newDB(t)
37
38 for _, c := range []struct {
39 pragma string
40 want string
41 }{
42 {"journal_mode", "wal"},
43 {"busy_timeout", "5000"},
44 {"foreign_keys", "1"},
45 } {
46 var got string
47 if err := d.G.Raw("PRAGMA " + c.pragma).Scan(&got).Error; err != nil {
48 t.Fatalf("PRAGMA %s: %v", c.pragma, err)
49 }
50 if got != c.want {
51 t.Errorf("PRAGMA %s = %q, want %q", c.pragma, got, c.want)
52 }
53 }
54}
55
56// One writer connection, because SQLite has one writer and a pool of them
57// is a queue of lock contention wearing a pool's clothes.
58func TestWriterPoolIsSingleConnection(t *testing.T) {
59 d := newDB(t)
60 writer, err := d.G.DB()
61 if err != nil {
62 t.Fatalf("writer handle: %v", err)
63 }
64 if n := writer.Stats().MaxOpenConnections; n != 1 {
65 t.Errorf("writer MaxOpenConnections = %d, want 1", n)
66 }
67}
68
69// Migrations apply once and are never re-run. Building the app twice over
70// one database is the cheap proof: the second boot must apply nothing.
71func TestMigrationsApplyOnce(t *testing.T) {
72 d := newDB(t)
73 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
74
75 if _, err := contra.App(d, testConfig(), logger); err != nil {
76 t.Fatalf("first boot: %v", err)
77 }
78 before := tableNames(t, d)
79
80 if _, err := contra.App(d, testConfig(), logger); err != nil {
81 t.Fatalf("second boot: %v", err)
82 }
83 after := tableNames(t, d)
84
85 if len(before) != len(after) {
86 t.Errorf("second boot changed the schema:\n before %v\n after %v", before, after)
87 }
88}
89
90// The bootstrap tripwire is gone, replaced by the thing it was holding the
91// door for: the merciless greps in greps_test.go. What is left here is the
92// narrower question of WHICH tables exist, which the greps do not ask.
93//
94// A table appearing without anybody deciding to add it is how a storage
95// surface arrives unnoticed. So the set is written down, each with its
96// owner, and a new one fails until somebody adds it here — and, on the same
97// commit, to the greps' write list.
98func TestSchemaIsTheDeclaredSet(t *testing.T) {
99 d := newDB(t)
100 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
101 if _, err := contra.App(d, testConfig(), logger); err != nil {
102 t.Fatalf("App: %v", err)
103 }
104
105 owners := map[string]string{
106 "rastrillo_migrations": "rastrillo/migrate — the migration ledger",
107 "sessions": "rastrillo/sessions",
108 "sqlite_sequence": "sqlite, for AUTOINCREMENT",
109
110 // CONTRA's own. Every one holds ciphertext, public keys, opaque
111 // random ids, or the parameters needed to hand the right blob back.
112 "accounts": "an identity: a blind index, never an address",
113 "login_keys": "public keys to verify signed challenges against",
114 "wraps": "the three peer wraps of RK",
115 "vaults": "a vault's existence and its drop-box public key",
116 "vault_members": "vault keysets sealed to members — the sharing graph",
117 "items": "one opaque blob per item, quantised into buckets",
118 "login_challenges": "live sign-in state: a random nonce, deleted when spent",
119 "signup_sessions": "an address proved but no account yet: a blind index and a token hash",
120 "device_links": "a live handoff: a code hash, an ephemeral point, and an envelope the server has no key for",
121 }
122
123 found := map[string]bool{}
124 for _, name := range tableNames(t, d) {
125 found[name] = true
126 if _, ok := owners[name]; !ok {
127 t.Errorf("undeclared table %q. If you meant to add it: name its owner "+
128 "here, and add its write path to writeEverything in greps_test.go "+
129 "on this same commit. A storage surface lands with its canary or "+
130 "it does not land.", name)
131 }
132 }
133 for name := range owners {
134 if name == "sqlite_sequence" {
135 continue // only exists once something AUTOINCREMENTs
136 }
137 if !found[name] {
138 t.Errorf("declared table %q is missing: a migration did not apply", name)
139 }
140 }
141
142 if len(contra.Models) == 0 {
143 t.Error("contra.Models is empty, so `rastrillo migration check` is " +
144 "comparing nothing against nothing")
145 }
146}
147
148func tableNames(t *testing.T, d *db.DB) []string {
149 t.Helper()
150 var names []string
151 rows, err := d.G.Raw(
152 "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").Rows()
153 if err != nil {
154 t.Fatalf("list tables: %v", err)
155 }
156 defer rows.Close()
157 for rows.Next() {
158 var n string
159 if err := rows.Scan(&n); err != nil {
160 t.Fatalf("scan table name: %v", err)
161 }
162 names = append(names, n)
163 }
164 if err := rows.Err(); err != nil && err != sql.ErrNoRows {
165 t.Fatalf("list tables: %v", err)
166 }
167 sort.Strings(names)
168 return names
169}
170