krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
1// Package e2e drives a real gitbayd with the real ssh and git clients.
2package e2e
3
4import (
5 "encoding/json"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14)
15
16type instance struct {
17 gitbayd string // path to built binary
18 root string
19 config string
20 port int
21 httpPort int
22 gitPort int
23 proc *exec.Cmd
24 sshDir string // per-user client keys live here
25}
26
27func buildGitbayd(t *testing.T) string {
28 t.Helper()
29 bin := filepath.Join(t.TempDir(), "gitbayd")
30 cmd := exec.Command("go", "build", "-o", bin, "gitbay.org/gitbay/cmd/gitbayd")
31 cmd.Dir = ".."
32 if out, err := cmd.CombinedOutput(); err != nil {
33 t.Fatalf("build gitbayd: %v\n%s", err, out)
34 }
35 return bin
36}
37
38func freePort(t *testing.T) int {
39 t.Helper()
40 ln, err := net.Listen("tcp", "127.0.0.1:0")
41 if err != nil {
42 t.Fatal(err)
43 }
44 defer ln.Close()
45 return ln.Addr().(*net.TCPAddr).Port
46}
47
48func startInstance(t *testing.T) *instance {
49 return startInstanceWith(t, "")
50}
51
52// startInstanceWith appends extra TOML to the instance config.
53func startInstanceWith(t *testing.T, extra string) *instance {
54 t.Helper()
55 inst := &instance{
56 gitbayd: buildGitbayd(t),
57 root: t.TempDir(),
58 port: freePort(t),
59 httpPort: freePort(t),
60 gitPort: freePort(t),
61 sshDir: t.TempDir(),
62 }
63 inst.config = filepath.Join(inst.root, "config.toml")
64 cfg := fmt.Sprintf(`
65[server]
66root = %q
67site_url = "https://gitbay.test"
68[ssh]
69port = %d
70[http]
71addr = "127.0.0.1:%d"
72tls = "off"
73[git_daemon]
74enabled = true
75port = %d
76`, inst.root, inst.port, inst.httpPort, inst.gitPort)
77 cfg += extra + "\n"
78 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
79 t.Fatal(err)
80 }
81
82 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
83 inst.proc.Stderr = os.Stderr
84 if err := inst.proc.Start(); err != nil {
85 t.Fatal(err)
86 }
87 t.Cleanup(func() {
88 inst.proc.Process.Kill()
89 inst.proc.Wait()
90 })
91
92 // Wait for the listener.
93 deadline := time.Now().Add(10 * time.Second)
94 for {
95 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond)
96 if err == nil {
97 conn.Close()
98 return inst
99 }
100 if time.Now().After(deadline) {
101 t.Fatal("gitbayd did not start listening")
102 }
103 time.Sleep(50 * time.Millisecond)
104 }
105}
106
107// admin runs a gitbayd admin command against the instance's database.
108func (i *instance) admin(t *testing.T, args ...string) string {
109 t.Helper()
110 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
111 out, err := cmd.CombinedOutput()
112 if err != nil {
113 t.Fatalf("gitbayd %v: %v\n%s", args, err, out)
114 }
115 return string(out)
116}
117
118// forgedAdminErr runs an admin command expected to fail, returning output.
119func (i *instance) forgedAdminErr(t *testing.T, args ...string) string {
120 t.Helper()
121 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
122 out, err := cmd.CombinedOutput()
123 if err == nil {
124 t.Fatalf("gitbayd %v unexpectedly succeeded:\n%s", args, out)
125 }
126 return string(out)
127}
128
129// newKey generates a client keypair and returns the private key path.
130func (i *instance) newKey(t *testing.T, name string) string {
131 t.Helper()
132 priv := filepath.Join(i.sshDir, name)
133 cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", priv)
134 if out, err := cmd.CombinedOutput(); err != nil {
135 t.Fatalf("ssh-keygen: %v\n%s", err, out)
136 }
137 return priv
138}
139
140// ssh runs the real OpenSSH client against the instance with the given key.
141func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) {
142 t.Helper()
143 base := []string{
144 "-p", fmt.Sprint(i.port),
145 "-i", key,
146 "-o", "IdentitiesOnly=yes",
147 "-o", "StrictHostKeyChecking=no",
148 "-o", "UserKnownHostsFile=" + filepath.Join(i.sshDir, "known_hosts"),
149 "-o", "BatchMode=yes",
150 "git@127.0.0.1",
151 }
152 cmd := exec.Command("ssh", append(base, args...)...)
153 if stdin != "" {
154 cmd.Stdin = strings.NewReader(stdin)
155 }
156 var out, errOut strings.Builder
157 cmd.Stdout = &out
158 cmd.Stderr = &errOut
159 err := cmd.Run()
160 code := 0
161 if ee, ok := err.(*exec.ExitError); ok {
162 code = ee.ExitCode()
163 } else if err != nil {
164 t.Fatalf("ssh: %v", err)
165 }
166 return out.String(), errOut.String(), code
167}
168
169func TestControlPlaneOverBareSSH(t *testing.T) {
170 inst := startInstance(t)
171
172 aliceKey := inst.newKey(t, "alice")
173 inst.admin(t, "admin", "user", "create", "alice",
174 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
175
176 // whoami --json from bare OpenSSH.
177 out, errOut, code := inst.ssh(t, aliceKey, "", "whoami", "--json")
178 if code != 0 {
179 t.Fatalf("whoami exit %d, stderr: %s", code, errOut)
180 }
181 var env struct {
182 ProtocolVersion int `json:"protocol_version"`
183 Data struct {
184 Username string `json:"username"`
185 KeyScope string `json:"key_scope"`
186 } `json:"data"`
187 }
188 if err := json.Unmarshal([]byte(out), &env); err != nil {
189 t.Fatalf("whoami output not JSON: %v\n%s", err, out)
190 }
191 if env.Data.Username != "alice" || env.ProtocolVersion != 1 || env.Data.KeyScope != "full" {
192 t.Fatalf("whoami = %+v", env)
193 }
194
195 // Unknown key is refused at auth.
196 strangerKey := inst.newKey(t, "stranger")
197 _, _, code = inst.ssh(t, strangerKey, "", "whoami")
198 if code == 0 {
199 t.Fatal("unknown key was authenticated")
200 }
201
202 // keys add over stdin, then list shows both.
203 secondKey := inst.newKey(t, "alice2")
204 pub, _ := os.ReadFile(secondKey + ".pub")
205 out, errOut, code = inst.ssh(t, aliceKey, string(pub), "keys", "add", "--scope", "git")
206 if code != 0 {
207 t.Fatalf("keys add exit %d, stderr: %s", code, errOut)
208 }
209 out, _, code = inst.ssh(t, aliceKey, "", "keys", "list")
210 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
211 t.Fatalf("keys list exit %d:\n%s", code, out)
212 }
213
214 // The git-scoped key authenticates but is denied control commands.
215 out, errOut, code = inst.ssh(t, secondKey, "", "whoami")
216 if code != 4 {
217 t.Fatalf("git-scoped whoami: exit %d (want 4), stdout %q stderr %q", code, out, errOut)
218 }
219 if !strings.Contains(errOut, "does not allow control commands") {
220 t.Fatalf("scope denial message missing: %q", errOut)
221 }
222
223 // Duplicate key registration: bob cannot claim alice's key, and the
224 // message is the exact spec text, naming no account.
225 bobKey := inst.newKey(t, "bob")
226 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
227 alicePub, _ := os.ReadFile(aliceKey + ".pub")
228 _, errOut, code = inst.ssh(t, bobKey, string(alicePub), "keys", "add")
229 if code != 2 {
230 t.Fatalf("duplicate key add: exit %d, want 2", code)
231 }
232 want := "that key is already registered to another account; remove it there first or use a different key"
233 if !strings.Contains(errOut, want) {
234 t.Fatalf("duplicate key message = %q, want %q", errOut, want)
235 }
236 if strings.Contains(errOut, "alice") {
237 t.Fatalf("duplicate key message leaks account name: %q", errOut)
238 }
239
240 // Arguments with spaces survive the tokenizer round trip.
241 _, errOut, code = inst.ssh(t, aliceKey, "", "keys", "remove", "'no such fingerprint'")
242 if code != 3 {
243 t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut)
244 }
245}