ssh_test.go

  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}