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