e2e/readonly_test.go

v1.14.0
gitbay/e2e/readonly_test.go history · blame · raw

248 lines · 9183 bytes

  1package e2e
  2
  3import (
  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.
 27func 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 diff":            {"alice/app", "main", "feat"},
121		"repo commit":          {"alice/app", sha},
122		"repo download":        {"alice/app"},
123		"repo deploy-key list": {"alice/app"},
124		"repo secret list":     {"alice/app"},
125		"repo mirror list":     {"alice/app"},
126		"repo domain list":     {"alice/app"},
127		"repo deps status":     {"alice/app"},
128		"status list":          {"alice/app", sha},
129		"issue list":           {"alice/app"},
130		"issue show":           {"alice/app", "1"},
131		"issue templates":      {"alice/app"},
132		"label list":           {"alice/app"},
133		"milestone list":       {"alice/app"},
134		"mr list":              {"alice/app"},
135		"mr show":              {"alice/app", "1"},
136		"mr diff":              {"alice/app", "1"},
137		"mr threads":           {"alice/app", "1"},
138		"build list":           {"alice/app"},
139		"build jobs":           {"alice/app"},
140		"build show":           {"alice/app", "1"},
141		"build log":            {"alice/app", "1"},
142		"release list":         {"alice/app"},
143		"release show":         {"alice/app", "v1"},
144		"release asset get":    {"alice/app", "v1", "a.txt"},
145		"notifications list":   nil,
146		"repo bookmarks":       nil,
147		"search":               {"app"},
148		"mr revisions":         {"alice/app", "1"},
149		"mr range-diff":        {"alice/app", "1"},
150		"webhook list":         {"alice/app"},
151		"webhook deliveries":   {"alice/app"},
152		"wiki list":            {"alice/app"},
153		"wiki show":            {"alice/app"},
154	}
155	// Reads whose subject legitimately does not exist in this fixture.
156	notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
157
158	dbPath := filepath.Join(inst.root, "gitbay.db")
159	before := dbFingerprint(t, dbPath)
160	for _, cmd := range control.Commands() {
161		if !cmd.ReadOnly {
162			continue
163		}
164		path := strings.Join(cmd.Path, " ")
165		args, ok := readArgs[path]
166		if !ok {
167			t.Errorf("%s is ReadOnly and has no arguments in this test; add an entry", path)
168			continue
169		}
170		_, errOut, code := inst.ssh(t, aliceKey, "", append(append([]string{}, cmd.Path...), args...)...)
171		if code != 0 && !(code == 3 && notFoundOK[path]) {
172			t.Errorf("%s: exit %d: %s", path, code, strings.TrimSpace(errOut))
173		}
174		after := dbFingerprint(t, dbPath)
175		for table, h := range after {
176			// The signature cache is filled by whichever read first shows
177			// a commit; a memo of a pure function is not state.
178			if table == "commit_signatures" {
179				continue
180			}
181			if before[table] != h {
182				t.Errorf("%s is ReadOnly but changed table %s", path, table)
183			}
184		}
185		before = after
186	}
187}
188
189// dbFingerprint hashes every row of every table, per table. Columns that
190// record a read happening (a key's last use, an account's last sight) are
191// left out: an ssh session touches them by design.
192func dbFingerprint(t *testing.T, path string) map[string]string {
193	t.Helper()
194	db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
195	if err != nil {
196		t.Fatal(err)
197	}
198	defer db.Close()
199	rows, err := db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
200	if err != nil {
201		t.Fatal(err)
202	}
203	var tables []string
204	for rows.Next() {
205		var n string
206		rows.Scan(&n)
207		tables = append(tables, n)
208	}
209	rows.Close()
210	out := map[string]string{}
211	for _, table := range tables {
212		cols, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
213		if err != nil {
214			t.Fatal(err)
215		}
216		var names []string
217		for cols.Next() {
218			var cid int
219			var name, typ string
220			var notnull, pk int
221			var dflt any
222			cols.Scan(&cid, &name, &typ, &notnull, &dflt, &pk)
223			switch name {
224			case "last_used_at", "last_seen", "last_seen_at":
225				continue
226			}
227			names = append(names, fmt.Sprintf("%q", name))
228		}
229		cols.Close()
230		h := sha256.New()
231		data, err := db.Query(fmt.Sprintf("SELECT %s FROM %q ORDER BY %s", strings.Join(names, ","), table, strings.Join(names, ",")))
232		if err != nil {
233			t.Fatal(err)
234		}
235		vals := make([]any, len(names))
236		ptrs := make([]any, len(names))
237		for i := range vals {
238			ptrs[i] = &vals[i]
239		}
240		for data.Next() {
241			data.Scan(ptrs...)
242			fmt.Fprintf(h, "%v\n", vals)
243		}
244		data.Close()
245		out[table] = hex.EncodeToString(h.Sum(nil))
246	}
247	return out
248}