e2e/readonly_test.go
314 lines · 11976 bytes
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", "ci/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("", "web", "login")
84 pub, _ := os.ReadFile(deployKey + ".pub")
85 must(string(pub), "repo", "deploy-key", "add", "alice/app")
86 must("secret\n", "repo", "secret", "set", "alice/app", "S")
87 // Added last, for an event that already happened: no delivery is
88 // pending to be retried while the reads run.
89 must("", "webhook", "add", "alice/app", "http://127.0.0.1:1/hook", "--events", "release.created")
90
91 readArgs := map[string][]string{
92 "help": {},
93 "whoami": {},
94 "dashboard": {},
95 "feed": {},
96 "explore": {},
97 "audit": {},
98 "keys list": {},
99 "email list": {},
100 "pgp list": {},
101 "token list": {},
102 "web sessions list": {},
103 "web theme show": {},
104 "account export": {},
105 "org list": {},
106 "repo list": {},
107 "admin user list": {},
108 "admin runners": {},
109 "admin repo list": {},
110 "admin stats": {},
111 "admin user show": {"alice"},
112 "profile show": {"alice"},
113 "org show": {"theorg"},
114 "org members list": {"theorg"},
115 "org team list": {"theorg"},
116 "org team show": {"theorg", "core"},
117 "org label list": {"theorg"},
118 "org milestone list": {"theorg"},
119 "repo search": {"app"},
120 "repo show": {"alice/app"},
121 "repo access list": {"alice/app"},
122 "repo settings show": {"alice/app"},
123 "repo topics": {"alice/app"},
124 "repo refs": {"alice/app"},
125 "repo log": {"alice/app"},
126 "repo tree": {"alice/app"},
127 "repo cat": {"alice/app", "f.go"},
128 "repo blame": {"alice/app", "f.go"},
129 "repo grep": {"alice/app", "hello"},
130 "repo diff": {"alice/app", "main", "feat"},
131 "repo commit": {"alice/app", sha},
132 "repo download": {"alice/app"},
133 "repo deploy-key list": {"alice/app"},
134 "repo runner list": {"alice/app"},
135 "repo secret list": {"alice/app"},
136 "repo mirror list": {"alice/app"},
137 "repo domain list": {"alice/app"},
138 "repo deps status": {"alice/app"},
139 "status list": {"alice/app", sha},
140 "issue list": {"alice/app"},
141 "issue show": {"alice/app", "1"},
142 "issue templates": {"alice/app"},
143 "label list": {"alice/app"},
144 "milestone list": {"alice/app"},
145 "mr list": {"alice/app"},
146 "mr show": {"alice/app", "1"},
147 "mr diff": {"alice/app", "1"},
148 "mr threads": {"alice/app", "1"},
149 "build list": {"alice/app"},
150 "build jobs": {"alice/app"},
151 "build show": {"alice/app", "1"},
152 "build log": {"alice/app", "1"},
153 "release list": {"alice/app"},
154 "release show": {"alice/app", "v1"},
155 "release asset get": {"alice/app", "v1", "a.txt"},
156 "snippet show": {snippetID},
157 "snippet list": {},
158 "snippet file get": {snippetID, "a.txt"},
159 "notifications list": nil,
160 "notifications settings show": nil,
161 "notifications device list": nil,
162 "repo bookmarks": nil,
163 "search": {"app"},
164 "mr revisions": {"alice/app", "1"},
165 "mr range-diff": {"alice/app", "1"},
166 "webhook list": {"alice/app"},
167 "webhook deliveries": {"alice/app"},
168 "wiki list": {"alice/app"},
169 "wiki show": {"alice/app"},
170 }
171 // Reads whose subject legitimately does not exist in this fixture.
172 notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
173 // rawOutput prints content verbatim (a file, a log, a diff) and is
174 // not fitted to the terminal.
175 rawOutput := map[string]bool{
176 "repo download": true,
177 "account export": true,
178 }
179 // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not
180 // an SGR sequence escaping into plain output.
181 binaryOutput := map[string]bool{"repo download": true}
182
183 dbPath := filepath.Join(inst.root, "gitbay.db")
184 before := dbFingerprint(t, dbPath)
185 for _, cmd := range control.Commands() {
186 if !cmd.ReadOnly {
187 continue
188 }
189 path := strings.Join(cmd.Path, " ")
190 args, ok := readArgs[path]
191 if !ok {
192 t.Errorf("%s is ReadOnly and has no arguments in this test; add an entry", path)
193 continue
194 }
195 _, errOut, code := inst.ssh(t, aliceKey, "", append(append([]string{}, cmd.Path...), args...)...)
196 if code != 0 && !(code == 3 && notFoundOK[path]) {
197 t.Errorf("%s: exit %d: %s", path, code, strings.TrimSpace(errOut))
198 }
199 after := dbFingerprint(t, dbPath)
200 for table, h := range after {
201 // The signature cache is filled by whichever read first shows
202 // a commit; a memo of a pure function is not state.
203 if table == "commit_signatures" {
204 continue
205 }
206 if before[table] != h {
207 t.Errorf("%s is ReadOnly but changed table %s", path, table)
208 }
209 }
210 before = after
211
212 argv := append(append([]string{}, cmd.Path...), args...)
213 plainOut, plainErrOut, plainCode := inst.sshTerm(t, aliceKey, "", argv...)
214 if plainCode != 0 && !(plainCode == 3 && notFoundOK[path]) {
215 t.Errorf("%s: --term= plain exit %d: %s", path, plainCode, strings.TrimSpace(plainErrOut))
216 }
217 if !binaryOutput[path] && strings.Contains(plainOut, "\x1b") {
218 t.Errorf("%s: SGR bytes in plain output", path)
219 }
220 termOut, termErrOut, termCode := inst.sshTerm(t, aliceKey, "60,color", argv...)
221 if termCode != 0 && !(termCode == 3 && notFoundOK[path]) {
222 t.Errorf("%s: --term=60,color exit %d: %s", path, termCode, strings.TrimSpace(termErrOut))
223 }
224 if !rawOutput[path] {
225 for _, line := range strings.Split(termOut, "\n") {
226 if w := displayCells(stripSGRe2e(line)); w > 60 {
227 t.Errorf("%s: line of %d cells at 60 columns: %q", path, w, line)
228 break
229 }
230 }
231 }
232 }
233}
234
235var sgrRe = regexp.MustCompile("\x1b\\[[0-9;]*m")
236
237func stripSGRe2e(s string) string {
238 return sgrRe.ReplaceAllString(s, "")
239}
240
241func displayCells(s string) int {
242 n := 0
243 for _, r := range s {
244 switch {
245 case unicode.In(r, unicode.Mn, unicode.Me):
246 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
247 n += 2
248 default:
249 n++
250 }
251 }
252 return n
253}
254
255// dbFingerprint hashes every row of every table, per table. Columns that
256// record a read happening (a key's last use, an account's last sight) are
257// left out: an ssh session touches them by design.
258func dbFingerprint(t *testing.T, path string) map[string]string {
259 t.Helper()
260 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
261 if err != nil {
262 t.Fatal(err)
263 }
264 defer db.Close()
265 rows, err := db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
266 if err != nil {
267 t.Fatal(err)
268 }
269 var tables []string
270 for rows.Next() {
271 var n string
272 rows.Scan(&n)
273 tables = append(tables, n)
274 }
275 rows.Close()
276 out := map[string]string{}
277 for _, table := range tables {
278 cols, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
279 if err != nil {
280 t.Fatal(err)
281 }
282 var names []string
283 for cols.Next() {
284 var cid int
285 var name, typ string
286 var notnull, pk int
287 var dflt any
288 cols.Scan(&cid, &name, &typ, ¬null, &dflt, &pk)
289 switch name {
290 case "last_used_at", "last_seen", "last_seen_at":
291 continue
292 }
293 names = append(names, fmt.Sprintf("%q", name))
294 }
295 cols.Close()
296 h := sha256.New()
297 data, err := db.Query(fmt.Sprintf("SELECT %s FROM %q ORDER BY %s", strings.Join(names, ","), table, strings.Join(names, ",")))
298 if err != nil {
299 t.Fatal(err)
300 }
301 vals := make([]any, len(names))
302 ptrs := make([]any, len(names))
303 for i := range vals {
304 ptrs[i] = &vals[i]
305 }
306 for data.Next() {
307 data.Scan(ptrs...)
308 fmt.Fprintf(h, "%v\n", vals)
309 }
310 data.Close()
311 out[table] = hex.EncodeToString(h.Sum(nil))
312 }
313 return out
314}