Commit 3e9b5f03d6
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/readonly_test.go added +242
| @@ -0,0 +1,242 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 27 | func 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. | ||
| 186 | func 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, ¬null, &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 | } | ||