ssh_test.go

  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}