e2e/readonly_test.go

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

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