1package git
2
3import (
4 "fmt"
5 "net"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12
13 "github.com/charmbracelet/keygen"
14 "github.com/charmbracelet/wish"
15 "github.com/gliderlabs/ssh"
16)
17
18func TestGitMiddleware(t *testing.T) {
19 pubkey, pkPath := createKeyPair(t)
20
21 l, err := net.Listen("tcp", "127.0.0.1:0")
22 requireNoError(t, err)
23 remote := "ssh://" + l.Addr().String()
24
25 repoDir := t.TempDir()
26 hooks := &testHooks{
27 pushes: []action{},
28 fetches: []action{},
29 access: []accessDetails{
30 {pubkey, "repo1", AdminAccess},
31 {pubkey, "repo2", AdminAccess},
32 {pubkey, "repo3", AdminAccess},
33 {pubkey, "repo4", AdminAccess},
34 {pubkey, "repo5", NoAccess},
35 {pubkey, "repo6", ReadOnlyAccess},
36 {pubkey, "repo7", AdminAccess},
37 },
38 }
39 srv, err := wish.NewServer(
40 wish.WithMiddleware(Middleware(repoDir, hooks)),
41 wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
42 return true
43 }),
44 )
45 requireNoError(t, err)
46 go func() { srv.Serve(l) }()
47 t.Cleanup(func() { _ = srv.Close() })
48
49 t.Run("create repo on master", func(t *testing.T) {
50 cwd := t.TempDir()
51 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "master"))
52 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo1"))
53 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
54 requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "master"))
55 requireHasAction(t, hooks.pushes, pubkey, "repo1")
56 })
57
58 t.Run("create repo on main", func(t *testing.T) {
59 cwd := t.TempDir()
60 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
61 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo2"))
62 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
63 requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
64 requireHasAction(t, hooks.pushes, pubkey, "repo2")
65 })
66
67 t.Run("create and clone repo", func(t *testing.T) {
68 cwd := t.TempDir()
69 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
70 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo3"))
71 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
72 requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
73
74 cwd = t.TempDir()
75 requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo3"))
76
77 requireHasAction(t, hooks.pushes, pubkey, "repo3")
78 requireHasAction(t, hooks.fetches, pubkey, "repo3")
79 })
80
81 t.Run("clone repo that doesn't exist", func(t *testing.T) {
82 cwd := t.TempDir()
83 requireError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo4"))
84 })
85
86 t.Run("clone repo with no access", func(t *testing.T) {
87 cwd := t.TempDir()
88 requireError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo5"))
89 })
90
91 t.Run("push repo with with readonly", func(t *testing.T) {
92 cwd := t.TempDir()
93 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
94 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo6"))
95 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
96 requireError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
97 })
98
99 t.Run("create and clone repo on weird branch", func(t *testing.T) {
100 cwd := t.TempDir()
101 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "a-weird-branch-name"))
102 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo7"))
103 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
104 requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "a-weird-branch-name"))
105
106 cwd = t.TempDir()
107 requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo7"))
108
109 requireHasAction(t, hooks.pushes, pubkey, "repo7")
110 requireHasAction(t, hooks.fetches, pubkey, "repo7")
111 })
112}
113
114func runGitHelper(t *testing.T, pk, cwd string, args ...string) error {
115 t.Helper()
116
117 allArgs := []string{
118 "-c", "user.name='wish'",
119 "-c", "user.email='test@wish'",
120 "-c", "commit.gpgSign=false",
121 "-c", "tag.gpgSign=false",
122 "-c", "log.showSignature=false",
123 "-c", "ssh.variant=ssh",
124 }
125 allArgs = append(allArgs, args...)
126
127 cmd := exec.Command("git", allArgs...)
128 cmd.Dir = cwd
129 cmd.Env = []string{fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i %s -F /dev/null`, pk)}
130 out, err := cmd.CombinedOutput()
131 if err != nil {
132 t.Log("git out:", string(out))
133 }
134 return err
135}
136
137func requireNoError(t *testing.T, err error) {
138 t.Helper()
139
140 if err != nil {
141 t.Fatalf("expected no error, got %q", err.Error())
142 }
143}
144
145func requireError(t *testing.T, err error) {
146 t.Helper()
147
148 if err == nil {
149 t.Fatalf("expected an error, got nil")
150 }
151}
152
153func requireHasAction(t *testing.T, actions []action, key ssh.PublicKey, repo string) {
154 t.Helper()
155
156 for _, action := range actions {
157 r1 := repo
158 if !strings.HasSuffix(r1, ".git") {
159 r1 += ".git"
160 }
161 r2 := action.repo
162 if !strings.HasSuffix(r2, ".git") {
163 r2 += ".git"
164 }
165 if r1 == r2 && ssh.KeysEqual(key, action.key) {
166 return
167 }
168 }
169 t.Fatalf("expected action for %q, got none", repo)
170}
171
172func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
173 t.Helper()
174
175 keyDir := t.TempDir()
176 _, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
177 requireNoError(t, err)
178 pk := filepath.Join(keyDir, "id_ed25519")
179 pubBytes, err := os.ReadFile(filepath.Join(keyDir, "id_ed25519.pub"))
180 requireNoError(t, err)
181 pubkey, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes)
182 requireNoError(t, err)
183 return pubkey, pk
184}
185
186type accessDetails struct {
187 key ssh.PublicKey
188 repo string
189 level AccessLevel
190}
191
192type action struct {
193 key ssh.PublicKey
194 repo string
195}
196
197type testHooks struct {
198 sync.Mutex
199 pushes []action
200 fetches []action
201 access []accessDetails
202}
203
204func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) AccessLevel {
205 for _, dets := range h.access {
206 r1 := strings.TrimSuffix(dets.repo, ".git")
207 r2 := strings.TrimSuffix(repo, ".git")
208 if r1 == r2 && ssh.KeysEqual(key, dets.key) {
209 return dets.level
210 }
211 }
212 return NoAccess
213}
214
215func (h *testHooks) Push(repo string, key ssh.PublicKey) {
216 h.Lock()
217 defer h.Unlock()
218
219 h.pushes = append(h.pushes, action{key, repo})
220}
221
222func (h *testHooks) Fetch(repo string, key ssh.PublicKey) {
223 h.Lock()
224 defer h.Unlock()
225
226 h.fetches = append(h.fetches, action{key, repo})
227}