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