ssh_test.go

  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}