1package ssh
2
3import (
4 "fmt"
5 "net"
6 "net/mail"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13
14 "github.com/charmbracelet/keygen"
15 "github.com/charmbracelet/soft-serve/proto"
16 "github.com/charmbracelet/wish"
17 "github.com/gliderlabs/ssh"
18 gossh "golang.org/x/crypto/ssh"
19)
20
21func TestGitMiddleware(t *testing.T) {
22 pubkey, pkPath := createKeyPair(t)
23
24 l, err := net.Listen("tcp", "127.0.0.1:0")
25 requireNoError(t, err)
26 remote := "ssh://" + l.Addr().String()
27
28 repoDir := t.TempDir()
29 hooks := &testHooks{
30 access: []accessDetails{
31 {pubkey, "repo1", proto.AdminAccess},
32 {pubkey, "repo2", proto.AdminAccess},
33 {pubkey, "repo3", proto.AdminAccess},
34 {pubkey, "repo4", proto.AdminAccess},
35 {pubkey, "repo5", proto.NoAccess},
36 {pubkey, "repo6", proto.ReadOnlyAccess},
37 {pubkey, "repo7", proto.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 })
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 })
65
66 t.Run("create and clone repo", func(t *testing.T) {
67 cwd := t.TempDir()
68 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
69 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo3"))
70 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
71 requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
72
73 cwd = t.TempDir()
74 requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo3"))
75 })
76
77 t.Run("clone repo that doesn't exist", func(t *testing.T) {
78 cwd := t.TempDir()
79 requireError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo4"))
80 })
81
82 t.Run("clone repo with no access", func(t *testing.T) {
83 cwd := t.TempDir()
84 requireError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo5"))
85 })
86
87 t.Run("push repo with with readonly", func(t *testing.T) {
88 cwd := t.TempDir()
89 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
90 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo6"))
91 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
92 requireError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
93 })
94
95 t.Run("create and clone repo on weird branch", func(t *testing.T) {
96 cwd := t.TempDir()
97 requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "a-weird-branch-name"))
98 requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo7"))
99 requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
100 requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "a-weird-branch-name"))
101
102 cwd = t.TempDir()
103 requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo7"))
104 })
105}
106
107func runGitHelper(t *testing.T, pk, cwd string, args ...string) error {
108 t.Helper()
109
110 allArgs := []string{
111 "-c", "user.name='wish'",
112 "-c", "user.email='test@wish'",
113 "-c", "commit.gpgSign=false",
114 "-c", "tag.gpgSign=false",
115 "-c", "log.showSignature=false",
116 "-c", "ssh.variant=ssh",
117 }
118 allArgs = append(allArgs, args...)
119
120 cmd := exec.Command("git", allArgs...)
121 cmd.Dir = cwd
122 cmd.Env = []string{fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i "%s" -F /dev/null`, pk)}
123 out, err := cmd.CombinedOutput()
124 t.Log("git out:", string(out))
125 return err
126}
127
128func requireNoError(t *testing.T, err error) {
129 t.Helper()
130
131 if err != nil {
132 t.Fatalf("expected no error, got %q", err.Error())
133 }
134}
135
136func requireError(t *testing.T, err error) {
137 t.Helper()
138
139 if err == nil {
140 t.Fatalf("expected an error, got nil")
141 }
142}
143
144func requireHasAction(t *testing.T, actions []action, key ssh.PublicKey, repo string) {
145 t.Helper()
146
147 for _, action := range actions {
148 t.Logf("action: %q", action.repo)
149 if repo == strings.TrimSuffix(action.repo, ".git") && ssh.KeysEqual(key, action.key) {
150 return
151 }
152 }
153 t.Fatalf("expected action for %q, got none", repo)
154}
155
156func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
157 t.Helper()
158
159 keyDir := t.TempDir()
160 t.Logf("Tempdir %s", keyDir)
161 kp, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
162 kp.KeyPairExists()
163 requireNoError(t, err)
164 pk := filepath.Join(keyDir, "id_ed25519")
165 t.Logf("pk %s", pk)
166 pubBytes, err := os.ReadFile(filepath.Join(keyDir, "id_ed25519.pub"))
167 requireNoError(t, err)
168 pubkey, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes)
169 requireNoError(t, err)
170 return pubkey, pk
171}
172
173type accessDetails struct {
174 key ssh.PublicKey
175 repo string
176 level proto.AccessLevel
177}
178
179type action struct {
180 key ssh.PublicKey
181 repo string
182}
183
184type testHooks struct {
185 sync.Mutex
186 pushes []action
187 fetches []action
188 access []accessDetails
189}
190
191func (h *testHooks) Open(string) (proto.Repository, error) {
192 return nil, nil
193}
194
195func (h *testHooks) ListRepos() ([]proto.Metadata, error) {
196 return nil, nil
197}
198
199func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) proto.AccessLevel {
200 for _, dets := range h.access {
201 if strings.TrimSuffix(dets.repo, ".git") == repo && ssh.KeysEqual(key, dets.key) {
202 return dets.level
203 }
204 }
205 return proto.NoAccess
206}
207
208type testUser struct{}
209
210func (u *testUser) Name() string {
211 return "test"
212}
213
214func (u *testUser) Email() *mail.Address {
215 return &mail.Address{
216 Name: "test",
217 Address: "test@wish",
218 }
219}
220
221func (u *testUser) IsAdmin() bool {
222 return false
223}
224
225func (u *testUser) Login() *string {
226 l := "test"
227 return &l
228}
229
230func (u *testUser) Password() *string {
231 return nil
232}
233
234func (u *testUser) PublicKeys() []gossh.PublicKey {
235 return nil
236}
237
238func (h *testHooks) User(pk ssh.PublicKey) (proto.User, error) {
239 return &testUser{}, nil
240}
241
242func (h *testHooks) IsAdmin(pk ssh.PublicKey) bool {
243 return false
244}
245
246func (h *testHooks) IsCollab(repo string, pk ssh.PublicKey) bool {
247 return false
248}
249
250func (h *testHooks) Create(name, projectName, description string, isPrivate bool) error {
251 return nil
252}
253
254func (h *testHooks) Delete(repo string) error {
255 return nil
256}
257
258func (h *testHooks) Rename(repo, name string) error {
259 return nil
260}
261
262func (h *testHooks) SetProjectName(repo, projectName string) error {
263 return nil
264}
265
266func (h *testHooks) SetDescription(repo, description string) error {
267 return nil
268}
269
270func (h *testHooks) SetPrivate(repo string, isPrivate bool) error {
271 return nil
272}
273
274func (h *testHooks) SetDefaultBranch(repo, branch string) error {
275 return nil
276}
277
278func (h *testHooks) Push(repo string, key ssh.PublicKey) {
279 h.Lock()
280 defer h.Unlock()
281
282 h.pushes = append(h.pushes, action{key, strings.TrimSuffix(repo, ".git")})
283}
284
285func (h *testHooks) Fetch(repo string, key ssh.PublicKey) {
286 h.Lock()
287 defer h.Unlock()
288
289 h.fetches = append(h.fetches, action{key, repo})
290}