e2e/readonly_test.go
329 lines · 12587 bytes
5 symbols in this file
1package e2e
2
3import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "testing"
13 "unicode"
14
15 "golang.org/x/text/width"
16 _ "modernc.org/sqlite"
17
18 "gitbay.org/gitbay/internal/control"
19)
20
21// ReadOnly is one flag with four consequences: a read-scoped token may run
22// the command, GET /api/v1/read reaches it, it draws on the read rate
23// budget, and it is not audited. A mutating command mis-flagged ReadOnly
24// becomes GET-able and unaudited in one line. This test runs every
25// ReadOnly command against a populated instance and fails on any command
26// that changes a row (#97).
27//
28// Every ReadOnly command needs an entry in readArgs; a new one without
29// arguments here fails the test rather than going untested.
30func TestReadOnlyCommandsWriteNothing(t *testing.T) {
31 t.Parallel()
32 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[webhooks]\nallow_local = true\n")
33 aliceKey := inst.newKey(t, "alice")
34 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
35 "--email", "alice@example.test", "--verified", "--admin")
36 deployKey := inst.newKey(t, "deploy")
37 must := func(stdin string, args ...string) string {
38 t.Helper()
39 out, errOut, code := inst.ssh(t, aliceKey, stdin, args...)
40 if code != 0 {
41 t.Fatalf("fixture %v: exit %d\n%s%s", args, code, out, errOut)
42 }
43 return out
44 }
45
46 // A repository with history, a tag, a branch, a build, and everything
47 // the read commands can look at.
48 must("", "repo", "create", "alice/app")
49 work := t.TempDir()
50 env := inst.gitEnv(aliceKey)
51 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
52 dir := filepath.Join(work, "w")
53 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
54 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n ok:\n steps:\n - echo hi\n"), 0o644)
55 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n\nhello\n"), 0o644)
56 os.WriteFile(filepath.Join(dir, "f.go"), []byte("package app\n"), 0o644)
57 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
58 mustGit(t, dir, env, "add", ".")
59 mustGit(t, dir, env, "commit", "-q", "-m", "base")
60 mustGit(t, dir, env, "tag", "v1")
61 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1")
62 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
63 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
64 os.WriteFile(filepath.Join(dir, "f.go"), []byte("package app\n\nvar V = 1\n"), 0o644)
65 mustGit(t, dir, env, "add", ".")
66 mustGit(t, dir, env, "commit", "-q", "-m", "change")
67 mustGit(t, dir, env, "push", "-q", "origin", "feat")
68
69 must("", "issue", "create", "alice/app", "--title", "one", "--body", "body")
70 must("", "issue", "label", "alice/app", "1", "--add", "bug")
71 must("", "label", "set", "alice/app", "bug", "--color", "ff0000")
72 must("", "milestone", "create", "alice/app", "m1")
73 must("", "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "change")
74 must("", "mr", "diff-comment", "alice/app", "1", "--path", "f.go", "--line", "3", "--message", "why")
75 must("", "status", "set", "alice/app", sha, "--context", "ext/x", "--state", "success")
76 must("", "release", "create", "alice/app", "v1", "--title", "first")
77 must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")
78 snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
79 snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
80 must("", "org", "create", "theorg")
81 must("", "org", "team", "create", "theorg", "core")
82 must("", "token", "create", "--name", "t")
83 must("", "query", "save", "q", "is:open")
84 must("", "web", "login")
85 pub, _ := os.ReadFile(deployKey + ".pub")
86 must(string(pub), "repo", "deploy-key", "add", "alice/app")
87 must("secret\n", "repo", "secret", "set", "alice/app", "S")
88 // Added last, for an event that already happened: no delivery is
89 // pending to be retried while the reads run.
90 must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created")
91
92 readArgs := map[string][]string{
93 "help": {},
94 "whoami": {},
95 "dashboard": {},
96 "feed": {},
97 "explore": {},
98 "audit": {},
99 "keys list": {},
100 "email list": {},
101 "pgp list": {},
102 "token list": {},
103 "web sessions list": {},
104 "web theme show": {},
105 "web diff show": {},
106 "account export": {},
107 "org list": {},
108 "repo list": {},
109 "admin user list": {},
110 "admin runners": {},
111 "admin repo list": {},
112 "admin stats": {},
113 "admin mail inbound check": {},
114 "admin user show": {"alice"},
115 "profile show": {"alice"},
116 "org show": {"theorg"},
117 "org members list": {"theorg"},
118 "org team list": {"theorg"},
119 "org team show": {"theorg", "core"},
120 "org label list": {"theorg"},
121 "org milestone list": {"theorg"},
122 "repo search": {"app"},
123 "repo show": {"alice/app"},
124 "repo access list": {"alice/app"},
125 "repo settings show": {"alice/app"},
126 "repo topics": {"alice/app"},
127 "repo refs": {"alice/app"},
128 "repo readme": {"alice/app"},
129 "repo log": {"alice/app"},
130 "repo tree": {"alice/app"},
131 "repo cat": {"alice/app", "f.go"},
132 "repo blame": {"alice/app", "f.go"},
133 "repo grep": {"alice/app", "hello"},
134 "repo symbols": {"alice/app", "app"},
135 "repo diff": {"alice/app", "main", "feat"},
136 "repo commit": {"alice/app", sha},
137 "repo download": {"alice/app"},
138 "repo deploy-key list": {"alice/app"},
139 "repo runner list": {"alice/app"},
140 "repo secret list": {"alice/app"},
141 "repo mirror list": {"alice/app"},
142 "repo domain list": {"alice/app"},
143 "repo deps status": {"alice/app"},
144 "status list": {"alice/app", sha},
145 "issue list": {"alice/app"},
146 "issue show": {"alice/app", "1"},
147 "issue templates": {"alice/app"},
148 "label list": {"alice/app"},
149 "milestone list": {"alice/app"},
150 "mr list": {"alice/app"},
151 "mr show": {"alice/app", "1"},
152 "mr diff": {"alice/app", "1"},
153 "mr threads": {"alice/app", "1"},
154 "build list": {"alice/app"},
155 "build jobs": {"alice/app"},
156 "build show": {"alice/app", "1"},
157 "build log": {"alice/app", "1"},
158 "release list": {"alice/app"},
159 "release show": {"alice/app", "v1"},
160 "release asset get": {"alice/app", "v1", "a.txt"},
161 "snippet show": {snippetID},
162 "snippet list": {},
163 "snippet file get": {snippetID, "a.txt"},
164 "notifications list": nil,
165 "notifications settings show": nil,
166 "notifications device list": nil,
167 "repo bookmarks": nil,
168 "search": {"app"},
169 "query list": {},
170 "query show": {"q"},
171 "query run": {"q"},
172 "mr revisions": {"alice/app", "1"},
173 "mr range-diff": {"alice/app", "1"},
174 "webhook list": {"alice/app"},
175 "webhook deliveries": {"alice/app"},
176 "wiki list": {"alice/app"},
177 "wiki show": {"alice/app"},
178 }
179 // Reads whose subject legitimately does not exist in this fixture.
180 notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
181 // rawOutput prints content verbatim (a file, a log, a diff) and is
182 // not fitted to the terminal.
183 rawOutput := map[string]bool{
184 "repo download": true,
185 "account export": true,
186 }
187 // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not
188 // an SGR sequence escaping into plain output.
189 binaryOutput := map[string]bool{"repo download": true}
190
191 // The push to main asked for a symbol index; the worker writing it
192 // mid-loop would be blamed on whichever read ran then.
193 waitFor(t, "symbol index", func() bool {
194 _, _, code := inst.ssh(t, aliceKey, "", "repo", "symbols", "alice/app", "app")
195 return code == 0
196 })
197
198 dbPath := filepath.Join(inst.root, "gitbay.db")
199 before := dbFingerprint(t, dbPath)
200 for _, cmd := range control.Commands() {
201 if !cmd.ReadOnly {
202 continue
203 }
204 path := strings.Join(cmd.Path, " ")
205 args, ok := readArgs[path]
206 if !ok {
207 t.Errorf("%s is ReadOnly and has no arguments in this test; add an entry", path)
208 continue
209 }
210 _, errOut, code := inst.ssh(t, aliceKey, "", append(append([]string{}, cmd.Path...), args...)...)
211 if code != 0 && !(code == 3 && notFoundOK[path]) {
212 t.Errorf("%s: exit %d: %s", path, code, strings.TrimSpace(errOut))
213 }
214 after := dbFingerprint(t, dbPath)
215 for table, h := range after {
216 // The signature cache is filled by whichever read first shows
217 // a commit; a memo of a pure function is not state.
218 if table == "commit_signatures" {
219 continue
220 }
221 if before[table] != h {
222 t.Errorf("%s is ReadOnly but changed table %s", path, table)
223 }
224 }
225 before = after
226
227 argv := append(append([]string{}, cmd.Path...), args...)
228 plainOut, plainErrOut, plainCode := inst.sshTerm(t, aliceKey, "", argv...)
229 if plainCode != 0 && !(plainCode == 3 && notFoundOK[path]) {
230 t.Errorf("%s: --term= plain exit %d: %s", path, plainCode, strings.TrimSpace(plainErrOut))
231 }
232 if !binaryOutput[path] && strings.Contains(plainOut, "\x1b") {
233 t.Errorf("%s: SGR bytes in plain output", path)
234 }
235 termOut, termErrOut, termCode := inst.sshTerm(t, aliceKey, "60,color", argv...)
236 if termCode != 0 && !(termCode == 3 && notFoundOK[path]) {
237 t.Errorf("%s: --term=60,color exit %d: %s", path, termCode, strings.TrimSpace(termErrOut))
238 }
239 if !rawOutput[path] {
240 for _, line := range strings.Split(termOut, "\n") {
241 if w := displayCells(stripSGRe2e(line)); w > 60 {
242 t.Errorf("%s: line of %d cells at 60 columns: %q", path, w, line)
243 break
244 }
245 }
246 }
247 }
248}
249
250var sgrRe = regexp.MustCompile("\x1b\\[[0-9;]*m")
251
252func stripSGRe2e(s string) string {
253 return sgrRe.ReplaceAllString(s, "")
254}
255
256func displayCells(s string) int {
257 n := 0
258 for _, r := range s {
259 switch {
260 case unicode.In(r, unicode.Mn, unicode.Me):
261 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
262 n += 2
263 default:
264 n++
265 }
266 }
267 return n
268}
269
270// dbFingerprint hashes every row of every table, per table. Columns that
271// record a read happening (a key's last use, an account's last sight) are
272// left out: an ssh session touches them by design.
273func dbFingerprint(t *testing.T, path string) map[string]string {
274 t.Helper()
275 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
276 if err != nil {
277 t.Fatal(err)
278 }
279 defer db.Close()
280 rows, err := db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
281 if err != nil {
282 t.Fatal(err)
283 }
284 var tables []string
285 for rows.Next() {
286 var n string
287 rows.Scan(&n)
288 tables = append(tables, n)
289 }
290 rows.Close()
291 out := map[string]string{}
292 for _, table := range tables {
293 cols, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
294 if err != nil {
295 t.Fatal(err)
296 }
297 var names []string
298 for cols.Next() {
299 var cid int
300 var name, typ string
301 var notnull, pk int
302 var dflt any
303 cols.Scan(&cid, &name, &typ, ¬null, &dflt, &pk)
304 switch name {
305 case "last_used_at", "last_seen", "last_seen_at":
306 continue
307 }
308 names = append(names, fmt.Sprintf("%q", name))
309 }
310 cols.Close()
311 h := sha256.New()
312 data, err := db.Query(fmt.Sprintf("SELECT %s FROM %q ORDER BY %s", strings.Join(names, ","), table, strings.Join(names, ",")))
313 if err != nil {
314 t.Fatal(err)
315 }
316 vals := make([]any, len(names))
317 ptrs := make([]any, len(names))
318 for i := range vals {
319 ptrs[i] = &vals[i]
320 }
321 for data.Next() {
322 data.Scan(ptrs...)
323 fmt.Fprintf(h, "%v\n", vals)
324 }
325 data.Close()
326 out[table] = hex.EncodeToString(h.Sum(nil))
327 }
328 return out
329}