Commit 3e9b5f03d6

3e9b5f03d68940709ad58dc38f855ee21b52ad17

parent: 34a4fc3e58

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 18:25 UTC

e2e: every ReadOnly command writes nothing

ReadOnly decides four things at once: read-scoped tokens may run the
command, GET /api/v1/read reaches it, it draws on the read rate budget,
and it is not audited. Nothing checked that a command flagged ReadOnly
actually only reads. The test builds an instance with a repository,
history, a tag, an MR with a thread, an issue with a label and
milestone, a release with an asset, a build, an org with a team, a
token, a session, a deploy key, a secret and a webhook, then runs every
ReadOnly command from the registry and hashes every table between
runs. A ReadOnly command without arguments in the test fails it. The
signature cache is exempt: a memo of a pure function is not state.

Closes #97

Layout: unified · split

e2e/readonly_test.go added +242
@@ -0,0 +1,242 @@
1package e2e
2
3import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 _ "modernc.org/sqlite"
14
15 "gitbay.org/gitbay/internal/control"
16)
17
18// ReadOnly is one flag with four consequences: a read-scoped token may run
19// the command, GET /api/v1/read reaches it, it draws on the read rate
20// budget, and it is not audited. A mutating command mis-flagged ReadOnly
21// becomes GET-able and unaudited in one line. This test runs every
22// ReadOnly command against a populated instance and fails on any command
23// that changes a row (#97).
24//
25// Every ReadOnly command needs an entry in readArgs; a new one without
26// arguments here fails the test rather than going untested.
27func TestReadOnlyCommandsWriteNothing(t *testing.T) {
28 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[webhooks]\nallow_local = true\n")
29 aliceKey := inst.newKey(t, "alice")
30 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
31 "--email", "alice@example.test", "--verified", "--admin")
32 deployKey := inst.newKey(t, "deploy")
33 must := func(stdin string, args ...string) string {
34 t.Helper()
35 out, errOut, code := inst.ssh(t, aliceKey, stdin, args...)
36 if code != 0 {
37 t.Fatalf("fixture %v: exit %d\n%s%s", args, code, out, errOut)
38 }
39 return out
40 }
41
42 // A repository with history, a tag, a branch, a build, and everything
43 // the read commands can look at.
44 must("", "repo", "create", "alice/app")
45 work := t.TempDir()
46 env := inst.gitEnv(aliceKey)
47 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
48 dir := filepath.Join(work, "w")
49 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
50 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n ok:\n steps:\n - echo hi\n"), 0o644)
51 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n\nhello\n"), 0o644)
52 os.WriteFile(filepath.Join(dir, "f.go"), []byte("package app\n"), 0o644)
53 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
54 mustGit(t, dir, env, "add", ".")
55 mustGit(t, dir, env, "commit", "-q", "-m", "base")
56 mustGit(t, dir, env, "tag", "v1")
57 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1")
58 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
59 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
60 os.WriteFile(filepath.Join(dir, "f.go"), []byte("package app\n\nvar V = 1\n"), 0o644)
61 mustGit(t, dir, env, "add", ".")
62 mustGit(t, dir, env, "commit", "-q", "-m", "change")
63 mustGit(t, dir, env, "push", "-q", "origin", "feat")
64
65 must("", "issue", "create", "alice/app", "--title", "one", "--body", "body")
66 must("", "issue", "label", "alice/app", "1", "--add", "bug")
67 must("", "label", "set", "alice/app", "bug", "--color", "ff0000")
68 must("", "milestone", "create", "alice/app", "m1")
69 must("", "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "change")
70 must("", "mr", "diff-comment", "alice/app", "1", "--path", "f.go", "--line", "3", "--message", "why")
71 must("", "status", "set", "alice/app", sha, "--context", "ci/x", "--state", "success")
72 must("", "release", "create", "alice/app", "v1", "--title", "first")
73 must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")
74 must("", "org", "create", "theorg")
75 must("", "org", "team", "create", "theorg", "core")
76 must("", "token", "create", "--name", "t")
77 must("", "web", "login")
78 pub, _ := os.ReadFile(deployKey + ".pub")
79 must(string(pub), "repo", "deploy-key", "add", "alice/app")
80 must("secret\n", "repo", "secret", "set", "alice/app", "S")
81 // Added last, for an event that already happened: no delivery is
82 // pending to be retried while the reads run.
83 must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created")
84
85 readArgs := map[string][]string{
86 "help": {},
87 "whoami": {},
88 "dashboard": {},
89 "feed": {},
90 "explore": {},
91 "audit": {},
92 "keys list": {},
93 "pgp list": {},
94 "token list": {},
95 "web sessions list": {},
96 "account export": {},
97 "org list": {},
98 "repo list": {},
99 "admin user list": {},
100 "admin runners": {},
101 "admin repo list": {},
102 "admin stats": {},
103 "admin user show": {"alice"},
104 "profile show": {"alice"},
105 "org show": {"theorg"},
106 "org members list": {"theorg"},
107 "org team list": {"theorg"},
108 "org team show": {"theorg", "core"},
109 "repo search": {"app"},
110 "repo show": {"alice/app"},
111 "repo access list": {"alice/app"},
112 "repo settings show": {"alice/app"},
113 "repo topics": {"alice/app"},
114 "repo refs": {"alice/app"},
115 "repo log": {"alice/app"},
116 "repo tree": {"alice/app"},
117 "repo cat": {"alice/app", "f.go"},
118 "repo blame": {"alice/app", "f.go"},
119 "repo grep": {"alice/app", "hello"},
120 "repo commit": {"alice/app", sha},
121 "repo download": {"alice/app"},
122 "repo deploy-key list": {"alice/app"},
123 "repo secret list": {"alice/app"},
124 "repo mirror list": {"alice/app"},
125 "repo domain list": {"alice/app"},
126 "repo deps status": {"alice/app"},
127 "status list": {"alice/app", sha},
128 "issue list": {"alice/app"},
129 "issue show": {"alice/app", "1"},
130 "issue templates": {"alice/app"},
131 "label list": {"alice/app"},
132 "milestone list": {"alice/app"},
133 "mr list": {"alice/app"},
134 "mr show": {"alice/app", "1"},
135 "mr diff": {"alice/app", "1"},
136 "mr threads": {"alice/app", "1"},
137 "build list": {"alice/app"},
138 "build jobs": {"alice/app"},
139 "build show": {"alice/app", "1"},
140 "build log": {"alice/app", "1"},
141 "release list": {"alice/app"},
142 "release show": {"alice/app", "v1"},
143 "release asset get": {"alice/app", "v1", "a.txt"},
144 "webhook list": {"alice/app"},
145 "webhook deliveries": {"alice/app"},
146 "wiki list": {"alice/app"},
147 "wiki show": {"alice/app"},
148 }
149 // Reads whose subject legitimately does not exist in this fixture.
150 notFoundOK := map[string]bool{"wiki list": true, "wiki show": true, "repo deps status": true}
151
152 dbPath := filepath.Join(inst.root, "gitbay.db")
153 before := dbFingerprint(t, dbPath)
154 for _, cmd := range control.Commands() {
155 if !cmd.ReadOnly {
156 continue
157 }
158 path := strings.Join(cmd.Path, " ")
159 args, ok := readArgs[path]
160 if !ok {
161 t.Errorf("%s is ReadOnly and has no arguments in this test; add an entry", path)
162 continue
163 }
164 _, errOut, code := inst.ssh(t, aliceKey, "", append(append([]string{}, cmd.Path...), args...)...)
165 if code != 0 && !(code == 3 && notFoundOK[path]) {
166 t.Errorf("%s: exit %d: %s", path, code, strings.TrimSpace(errOut))
167 }
168 after := dbFingerprint(t, dbPath)
169 for table, h := range after {
170 // The signature cache is filled by whichever read first shows
171 // a commit; a memo of a pure function is not state.
172 if table == "commit_signatures" {
173 continue
174 }
175 if before[table] != h {
176 t.Errorf("%s is ReadOnly but changed table %s", path, table)
177 }
178 }
179 before = after
180 }
181}
182
183// dbFingerprint hashes every row of every table, per table. Columns that
184// record a read happening (a key's last use, an account's last sight) are
185// left out: an ssh session touches them by design.
186func dbFingerprint(t *testing.T, path string) map[string]string {
187 t.Helper()
188 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
189 if err != nil {
190 t.Fatal(err)
191 }
192 defer db.Close()
193 rows, err := db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
194 if err != nil {
195 t.Fatal(err)
196 }
197 var tables []string
198 for rows.Next() {
199 var n string
200 rows.Scan(&n)
201 tables = append(tables, n)
202 }
203 rows.Close()
204 out := map[string]string{}
205 for _, table := range tables {
206 cols, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
207 if err != nil {
208 t.Fatal(err)
209 }
210 var names []string
211 for cols.Next() {
212 var cid int
213 var name, typ string
214 var notnull, pk int
215 var dflt any
216 cols.Scan(&cid, &name, &typ, &notnull, &dflt, &pk)
217 switch name {
218 case "last_used_at", "last_seen", "last_seen_at":
219 continue
220 }
221 names = append(names, fmt.Sprintf("%q", name))
222 }
223 cols.Close()
224 h := sha256.New()
225 data, err := db.Query(fmt.Sprintf("SELECT %s FROM %q ORDER BY %s", strings.Join(names, ","), table, strings.Join(names, ",")))
226 if err != nil {
227 t.Fatal(err)
228 }
229 vals := make([]any, len(names))
230 ptrs := make([]any, len(names))
231 for i := range vals {
232 ptrs[i] = &vals[i]
233 }
234 for data.Next() {
235 data.Scan(ptrs...)
236 fmt.Fprintf(h, "%v\n", vals)
237 }
238 data.Close()
239 out[table] = hex.EncodeToString(h.Sum(nil))
240 }
241 return out
242}