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