Commit c05ce58604

c05ce58604ada9a5cb0ecc909471e9ec6d3136d5

parent: 4bfcb3893d

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 01:05 UTC

tests: parse failures per command, non-ASCII paths, concurrent pushes

Control handlers were exercised end to end only. TestCommandParseFailures
runs sixteen argv every command must refuse before touching the store
and expects exit 2 with the usage. TestNonASCIIPaths pushes
dokumente/übersicht — ünïcode.txt and reads it through tree, cat, grep,
blame and the web blob page; it found ls-tree returning the name as
octal escapes, which core.quotepath=off in every git the server runs
now prevents. TestConcurrentPushes lands six branches pushed at once.

Closes #129

Layout: unified · split

e2e/robustness_test.go added +113
@@ -0,0 +1,113 @@
1package e2e
2
3import (
4 "net/http"
5 "net/url"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12)
13
14// Paths and content outside ASCII go through every read surface: the
15// tree, cat, grep, blame and the web blob page. Nothing in the suite
16// covered a non-ASCII path before (#129).
17func TestNonASCIIPaths(t *testing.T) {
18 inst := startInstance(t)
19 aliceKey := inst.newKey(t, "alice")
20 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 work := t.TempDir()
25 env := inst.gitEnv(aliceKey)
26 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
27 dir := filepath.Join(work, "w")
28 const name = "dokumente/übersicht — ünïcode.txt"
29 os.MkdirAll(filepath.Join(dir, "dokumente"), 0o755)
30 os.WriteFile(filepath.Join(dir, name), []byte("Grüße aus Nürnberg\n日本語の行\n"), 0o644)
31 mustGit(t, dir, env, "-c", "core.quotepath=off", "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, env, "commit", "-q", "-m", "ünïcode")
34 mustGit(t, dir, env, "push", "-q", "origin", "main")
35
36 if out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "tree", "alice/app", "dokumente", "--json"); code != 0 || !strings.Contains(out, "übersicht") {
37 t.Fatalf("repo tree: exit %d\n%s%s", code, out, errOut)
38 }
39 // inst.ssh joins arguments into one command line; the name has spaces.
40 quoted := "'" + name + "'"
41 if out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "cat", "alice/app", quoted); code != 0 || !strings.Contains(out, "Nürnberg") {
42 t.Fatalf("repo cat: exit %d\n%s%s", code, out, errOut)
43 }
44 if out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "grep", "alice/app", "日本語"); code != 0 || !strings.Contains(out, "übersicht") {
45 t.Fatalf("repo grep: exit %d\n%s%s", code, out, errOut)
46 }
47 if out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "blame", "alice/app", quoted); code != 0 || !strings.Contains(out, "Nürnberg") {
48 t.Fatalf("repo blame: exit %d\n%s%s", code, out, errOut)
49 }
50 page := inst.base() + "/alice/app/blob/main/" + url.PathEscape("dokumente") + "/" + url.PathEscape("übersicht — ünïcode.txt")
51 resp, err := http.Get(page)
52 if err != nil {
53 t.Fatal(err)
54 }
55 defer resp.Body.Close()
56 if resp.StatusCode != 200 {
57 t.Fatalf("web blob page: %d", resp.StatusCode)
58 }
59}
60
61// Pushes to different branches of one repository at the same time all
62// land: the receive hooks, the post-receive work and the store take
63// them concurrently without losing one (#129).
64func TestConcurrentPushes(t *testing.T) {
65 inst := startInstance(t)
66 aliceKey := inst.newKey(t, "alice")
67 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
68 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
69 t.Fatalf("repo create: %s", errOut)
70 }
71 env := inst.gitEnv(aliceKey)
72 seed := filepath.Join(t.TempDir(), "seed")
73 mustGit(t, t.TempDir(), env, "clone", inst.sshURL("alice/app"), seed)
74 os.WriteFile(filepath.Join(seed, "f.txt"), []byte("x\n"), 0o644)
75 mustGit(t, seed, env, "checkout", "-q", "-b", "main")
76 mustGit(t, seed, env, "add", ".")
77 mustGit(t, seed, env, "commit", "-q", "-m", "base")
78 mustGit(t, seed, env, "push", "-q", "origin", "main")
79
80 const n = 6
81 var wg sync.WaitGroup
82 errs := make([]string, n)
83 for i := 0; i < n; i++ {
84 branch := "b" + string(rune('0'+i))
85 clone := filepath.Join(t.TempDir(), branch)
86 mustGit(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/app"), clone)
87 mustGit(t, clone, env, "checkout", "-q", "-b", branch)
88 os.WriteFile(filepath.Join(clone, branch+".txt"), []byte(branch+"\n"), 0o644)
89 mustGit(t, clone, env, "add", ".")
90 mustGit(t, clone, env, "commit", "-q", "-m", branch)
91 wg.Add(1)
92 go func(i int, clone, branch string) {
93 defer wg.Done()
94 cmd := exec.Command("git", "push", "-q", "origin", branch)
95 cmd.Dir, cmd.Env = clone, env
96 if out, err := cmd.CombinedOutput(); err != nil {
97 errs[i] = string(out)
98 }
99 }(i, clone, branch)
100 }
101 wg.Wait()
102 for i, e := range errs {
103 if e != "" {
104 t.Errorf("push %d failed:\n%s", i, e)
105 }
106 }
107 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "refs", "alice/app")
108 for i := 0; i < n; i++ {
109 if !strings.Contains(out, "b"+string(rune('0'+i))) {
110 t.Errorf("branch b%d missing after concurrent pushes:\n%s", i, out)
111 }
112 }
113}
internal/control/parse_test.go added +52
@@ -0,0 +1,52 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// TestCommandParseFailures runs argv that every command must refuse
13// before touching the store: an unknown flag, a flag with no value, too
14// many arguments, a value outside its range. Each is exit 2 with the
15// command's usage in the message (#129, #96).
16func TestCommandParseFailures(t *testing.T) {
17 admin := store.User{Username: "root", IsAdmin: true}
18 cases := []struct {
19 argv []string
20 user store.User
21 want string // substring of the message
22 }{
23 {[]string{"issue", "create", "a/b", "--bogus"}, store.User{}, "unknown flag"},
24 {[]string{"issue", "create", "a/b", "--title"}, store.User{}, "requires a value"},
25 {[]string{"issue", "list", "a/b", "c/d"}, store.User{}, "unexpected argument"},
26 {[]string{"mr", "create", "a/b", "--source"}, store.User{}, "requires a value"},
27 {[]string{"mr", "merge", "a/b", "1", "--strategy"}, store.User{}, "requires a value"},
28 {[]string{"mr", "review", "a/b", "1", "--bogus"}, store.User{}, "usage"},
29 {[]string{"repo", "grep", "a/b", "x", "y"}, store.User{}, "unexpected argument"},
30 {[]string{"repo", "create", "a/b", "--visibility", "x"}, store.User{}, "unknown flag"},
31 {[]string{"repo", "log", "a/b", "--limit", "0"}, store.User{}, "--limit must be"},
32 {[]string{"keys", "add", "--scope"}, store.User{}, "requires a value"},
33 {[]string{"token", "create", "extra"}, store.User{}, "unexpected argument"},
34 {[]string{"webhook", "deliveries", "a/b", "--limit", "500"}, store.User{}, "--limit must be"},
35 {[]string{"admin", "repo", "list", "--visibility", "secret"}, admin, "--visibility requires"},
36 {[]string{"admin", "user", "list", "extra"}, admin, "unexpected argument"},
37 {[]string{"audit", "--limit", "0"}, admin, "--limit must be"},
38 {[]string{"account", "import-bundle", "x"}, store.User{}, "unexpected argument"},
39 }
40 for _, tc := range cases {
41 var out, errOut bytes.Buffer
42 c := &Ctx{User: tc.user, Scope: "full", Stdout: &out, Stderr: &errOut}
43 code := Dispatch(c, tc.argv)
44 if code != protocol.ExitUsage {
45 t.Errorf("%v: exit %d, want %d (%s)", tc.argv, code, protocol.ExitUsage, strings.TrimSpace(errOut.String()))
46 continue
47 }
48 if !strings.Contains(errOut.String(), tc.want) {
49 t.Errorf("%v: message %q lacks %q", tc.argv, strings.TrimSpace(errOut.String()), tc.want)
50 }
51 }
52}
internal/gitutil/gitutil.go +14
@@ -226,3 +226,17 @@ func DirSize(dir string) int64 {
226 }) 226 })
227 return total 227 return total
228} 228}
229
230// Every git this package runs prints paths as they are, not quoted with
231// octal escapes the way core.quotepath does by default, so a file called
232// übersicht.txt lists, greps, blames and diffs under its own name.
233// GIT_CONFIG_PARAMETERS reaches every subprocess, hooks included,
234// without touching each call site (#129).
235func init() {
236 const q = "'core.quotepath=off'"
237 if cur := os.Getenv("GIT_CONFIG_PARAMETERS"); cur != "" {
238 os.Setenv("GIT_CONFIG_PARAMETERS", cur+" "+q)
239 } else {
240 os.Setenv("GIT_CONFIG_PARAMETERS", q)
241 }
242}