v0.4.0 3027a40

hooks_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githttp_test
4
5import (
6	"bufio"
7	"bytes"
8	"context"
9	"encoding/json"
10	"errors"
11	"net"
12	"os"
13	"os/exec"
14	"path/filepath"
15	"strings"
16	"sync"
17	"testing"
18	"time"
19
20	"github.com/stretchr/testify/assert"
21	"github.com/stretchr/testify/require"
22
23	"bnema.dev/ermite/internal/adapters/githttp"
24	"bnema.dev/ermite/internal/domain"
25)
26
27const (
28	oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
29	oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
30)
31
32type call struct {
33	hook    string
34	p       domain.Principal
35	repo    domain.RepoName
36	updates []domain.RefUpdate
37}
38
39type fakeHooks struct {
40	mu       sync.Mutex
41	calls    []call
42	checkErr error
43	recErr   error
44	// onRecord, when set, runs inside Record with its context.
45	onRecord func(ctx context.Context)
46}
47
48func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
49	f.mu.Lock()
50	defer f.mu.Unlock()
51	f.calls = append(f.calls, call{"check", p, n, u})
52	return f.checkErr
53}
54
55func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
56	f.mu.Lock()
57	f.calls = append(f.calls, call{"record", p, n, u})
58	err, on := f.recErr, f.onRecord
59	f.mu.Unlock()
60	if on != nil {
61		on(ctx)
62	}
63	return err
64}
65
66func (f *fakeHooks) last() call {
67	f.mu.Lock()
68	defer f.mu.Unlock()
69	return f.calls[len(f.calls)-1]
70}
71
72func repoName(t *testing.T, s string) domain.RepoName {
73	t.Helper()
74	n, err := domain.ParseRepoName(s)
75	require.NoError(t, err)
76	return n
77}
78
79// startHooks serves a hook socket in a short temporary directory.
80func startHooks(t *testing.T, h githttp.PushHooks) (*githttp.HookServer, string) {
81	t.Helper()
82	dir, err := os.MkdirTemp("", "hk")
83	require.NoError(t, err)
84	t.Cleanup(func() { _ = os.RemoveAll(dir) })
85	sock := filepath.Join(dir, "hook.sock")
86	s := githttp.NewHookServer(sock, h)
87	require.NoError(t, s.Listen(context.Background()))
88	ctx, cancel := context.WithCancel(context.Background())
89	done := make(chan error, 1)
90	go func() { done <- s.Serve(ctx) }()
91	t.Cleanup(func() {
92		cancel()
93		select {
94		case err := <-done:
95			assert.NoError(t, err)
96		case <-time.After(5 * time.Second):
97			t.Error("hook server did not stop")
98		}
99	})
100	return s, sock
101}
102
103// raw sends one line and reads the reply line.
104func raw(t *testing.T, sock, line string) githttp.HookReply {
105	t.Helper()
106	conn, err := net.Dial("unix", sock)
107	require.NoError(t, err)
108	defer func() { _ = conn.Close() }()
109	_, err = conn.Write([]byte(line + "\n"))
110	require.NoError(t, err)
111	_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
112	reply, err := bufio.NewReader(conn).ReadBytes('\n')
113	require.NoError(t, err)
114	var r githttp.HookReply
115	require.NoError(t, json.Unmarshal(reply, &r), string(reply))
116	return r
117}
118
119func TestHookSocketMode(t *testing.T) {
120	_, sock := startHooks(t, &fakeHooks{})
121	fi, err := os.Stat(sock)
122	require.NoError(t, err)
123	assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
124	assert.NotZero(t, fi.Mode()&os.ModeSocket)
125}
126
127func TestHookServerReplacesStaleSocketFile(t *testing.T) {
128	dir, err := os.MkdirTemp("", "hk")
129	require.NoError(t, err)
130	defer func() { _ = os.RemoveAll(dir) }()
131	sock := filepath.Join(dir, "hook.sock")
132	require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
133	s := githttp.NewHookServer(sock, &fakeHooks{})
134	require.NoError(t, s.Listen(context.Background()))
135	s.Close(context.Background())
136}
137
138func TestHookServerRejectsTooLongSocketPath(t *testing.T) {
139	s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{})
140	err := s.Listen(context.Background())
141	require.Error(t, err)
142	assert.Contains(t, err.Error(), "too long")
143}
144
145func TestHookProtocol(t *testing.T) {
146	fh := &fakeHooks{}
147	srv, sock := startHooks(t, fh)
148	p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
149	tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p)
150	tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root())
151	assert.NotEqual(t, tokenA, tokenB)
152	assert.Len(t, tokenA, 64)
153
154	upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
155
156	t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
157		r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
158		assert.True(t, r.OK)
159		c := fh.last()
160		assert.Equal(t, "check", c.hook)
161		assert.Equal(t, repoName(t, "alpha"), c.repo)
162		assert.Equal(t, "tok1", c.p.TokenID())
163		assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates)
164
165		r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
166		assert.True(t, r.OK)
167		c = fh.last()
168		assert.Equal(t, "record", c.hook)
169		assert.Equal(t, repoName(t, "beta"), c.repo)
170		assert.True(t, c.p.IsRoot())
171	})
172
173	t.Run("a repository sent by the client is ignored", func(t *testing.T) {
174		raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
175		assert.Equal(t, repoName(t, "alpha"), fh.last().repo)
176	})
177
178	t.Run("refusal carries the message", func(t *testing.T) {
179		fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))
180		defer func() { fh.checkErr = nil }()
181		r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
182		assert.False(t, r.OK)
183		assert.Contains(t, r.Message, "rejected")
184	})
185
186	t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
187		fh.checkErr = errors.New("disk /secret/path exploded")
188		defer func() { fh.checkErr = nil }()
189		r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
190		assert.False(t, r.OK)
191		assert.NotContains(t, r.Message, "/secret/path")
192	})
193
194	t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
195		fh.recErr = errors.New("boom")
196		defer func() { fh.recErr = nil }()
197		r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
198		assert.True(t, r.OK)
199		assert.NotEmpty(t, r.Message)
200	})
201
202	t.Run("unknown, empty and released tokens are refused", func(t *testing.T) {
203		for _, tok := range []string{"nope", ""} {
204			r := raw(t, sock, `{"token":"`+tok+`","hook":"pre-receive","updates":[]}`)
205			assert.False(t, r.OK, tok)
206		}
207		releaseA()
208		before := len(fh.calls)
209		r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
210		assert.False(t, r.OK, "released token")
211		assert.Contains(t, r.Message, "token")
212		assert.Len(t, fh.calls, before, "no use case ran")
213		releaseB()
214	})
215
216	t.Run("malformed requests are refused", func(t *testing.T) {
217		tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root())
218		defer rel()
219		before := len(fh.calls)
220		for _, line := range []string{
221			`not json`,
222			`{}`,
223			`{"token":"` + tok + `","hook":"update","updates":[]}`,
224			`{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"zz","new":"` + oidA + `","ref":"refs/heads/x"}]}`,
225			`{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"` + oidA + `","new":"` + oidA + `","ref":""}]}`,
226		} {
227			r := raw(t, sock, line)
228			assert.False(t, r.OK, line)
229		}
230		assert.Len(t, fh.calls, before)
231	})
232
233	t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {
234		conn, err := net.Dial("unix", sock)
235		require.NoError(t, err)
236		_ = conn.Close()
237		r := raw(t, sock, `{"token":"x","hook":"pre-receive","updates":[]}`)
238		assert.False(t, r.OK)
239	})
240}
241
242// A shutdown while a push is being recorded must not drop the recording: the
243// push is already in the repository.
244func TestHookRecordSurvivesShutdown(t *testing.T) {
245	fh := &fakeHooks{}
246	started, proceed := make(chan struct{}), make(chan struct{})
247	var cancelled, hasDeadline bool
248	var ctxMu sync.Mutex
249	fh.onRecord = func(ctx context.Context) {
250		close(started)
251		<-proceed
252		ctxMu.Lock()
253		defer ctxMu.Unlock()
254		cancelled = ctx.Err() != nil
255		_, hasDeadline = ctx.Deadline()
256	}
257
258	dir, err := os.MkdirTemp("", "hk")
259	require.NoError(t, err)
260	t.Cleanup(func() { _ = os.RemoveAll(dir) })
261	sock := filepath.Join(dir, "hook.sock")
262	srv := githttp.NewHookServer(sock, fh)
263	require.NoError(t, srv.Listen(context.Background()))
264	ctx, cancel := context.WithCancel(context.Background())
265	done := make(chan error, 1)
266	go func() { done <- srv.Serve(ctx) }()
267
268	tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
269	defer rel()
270	replies := make(chan githttp.HookReply, 1)
271	go func() {
272		replies <- raw(t, sock, `{"token":"`+tok+`","hook":"post-receive","updates":[]}`)
273	}()
274	<-started
275	cancel() // the server is shutting down while Record runs
276	close(proceed)
277
278	select {
279	case r := <-replies:
280		assert.True(t, r.OK)
281	case <-time.After(5 * time.Second):
282		t.Fatal("no reply")
283	}
284	select {
285	case err := <-done:
286		assert.NoError(t, err)
287	case <-time.After(5 * time.Second):
288		t.Fatal("hook server did not stop")
289	}
290	ctxMu.Lock()
291	defer ctxMu.Unlock()
292	assert.False(t, cancelled, "Record is not cancelled by the shutdown")
293	assert.True(t, hasDeadline, "but it is bounded")
294}
295
296func TestHookConcurrentTokens(t *testing.T) {
297	fh := &fakeHooks{}
298	srv, sock := startHooks(t, fh)
299	var wg sync.WaitGroup
300	for i := range 16 {
301		wg.Add(1)
302		go func() {
303			defer wg.Done()
304			name := repoName(t, "r"+string(rune('a'+i)))
305			tok, rel := srv.Register(context.Background(), name, domain.Root())
306			defer rel()
307			r := raw(t, sock, `{"token":"`+tok+`","hook":"post-receive","updates":[{"old":"`+domain.ZeroOID+`","new":"`+oidB+`","ref":"refs/heads/x"}]}`)
308			assert.True(t, r.OK)
309		}()
310	}
311	wg.Wait()
312	seen := map[domain.RepoName]bool{}
313	for _, c := range fh.calls {
314		seen[c.repo] = true
315	}
316	assert.Len(t, seen, 16)
317}
318
319func TestRunHook(t *testing.T) {
320	ctx := context.Background()
321	fh := &fakeHooks{}
322	srv, sock := startHooks(t, fh)
323	tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
324	defer rel()
325	input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
326
327	t.Run("sends the lines git feeds the hook", func(t *testing.T) {
328		var errOut bytes.Buffer
329		require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
330		assert.Equal(t, []domain.RefUpdate{
331			{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
332			{Old: oidA, New: oidB, Ref: "refs/heads/dev"},
333		}, fh.last().updates)
334		assert.Empty(t, errOut.String())
335	})
336
337	t.Run("a ref name with spaces survives", func(t *testing.T) {
338		require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
339		assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref)
340	})
341
342	t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
343		fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))
344		defer func() { fh.checkErr = nil }()
345		var errOut bytes.Buffer
346		err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
347		require.ErrorIs(t, err, githttp.ErrHookRejected)
348		assert.Contains(t, errOut.String(), "rejected")
349	})
350
351	t.Run("pre-receive fails closed", func(t *testing.T) {
352		for name, args := range map[string][2]string{
353			"no socket":      {"", tok},
354			"no token":       {sock, ""},
355			"socket missing": {filepath.Join(t.TempDir(), "gone.sock"), tok},
356			"unknown token":  {sock, "unknown"},
357		} {
358			var errOut bytes.Buffer
359			err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1])
360			assert.ErrorIs(t, err, githttp.ErrHookRejected, name)
361		}
362		err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok)
363		assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")
364		assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok))
365	})
366
367	t.Run("a server that never answers is cut off by the context", func(t *testing.T) {
368		dir, err := os.MkdirTemp("", "hk")
369		require.NoError(t, err)
370		defer func() { _ = os.RemoveAll(dir) }()
371		silent := filepath.Join(dir, "s.sock")
372		ln, err := net.Listen("unix", silent)
373		require.NoError(t, err)
374		defer func() { _ = ln.Close() }()
375		go func() {
376			for {
377				c, err := ln.Accept()
378				if err != nil {
379					return
380				}
381				defer func() { _ = c.Close() }()
382			}
383		}()
384		cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
385		defer cancel()
386		start := time.Now()
387		err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok)
388		assert.ErrorIs(t, err, githttp.ErrHookRejected)
389		assert.Less(t, time.Since(start), 5*time.Second)
390	})
391
392	t.Run("post-receive never fails the push", func(t *testing.T) {
393		var errOut bytes.Buffer
394		require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok))
395		assert.Contains(t, errOut.String(), "post-receive")
396		errOut.Reset()
397		require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", ""))
398		assert.NotEmpty(t, errOut.String())
399	})
400}
401
402func TestWriteHookScripts(t *testing.T) {
403	dir := filepath.Join(t.TempDir(), "run", "hooks")
404	exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite"
405	require.NoError(t, githttp.WriteHookScripts(dir, exe))
406
407	for _, hook := range []string{"pre-receive", "post-receive"} {
408		path := filepath.Join(dir, hook)
409		fi, err := os.Stat(path)
410		require.NoError(t, err)
411		assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
412		body, err := os.ReadFile(path)
413		require.NoError(t, err)
414		assert.True(t, strings.HasPrefix(string(body), "#!/bin/sh\n"))
415		assert.Contains(t, string(body), "exec '/opt/it'\\''s here/\"ermite\" $HOME `x`/ermite' hook "+hook+"\n")
416	}
417	// Rewriting replaces the files and leaves nothing behind.
418	require.NoError(t, githttp.WriteHookScripts(dir, "/bin/true"))
419	entries, err := os.ReadDir(dir)
420	require.NoError(t, err)
421	assert.Len(t, entries, 2)
422
423	assert.Error(t, githttp.WriteHookScripts(dir, "relative/ermite"))
424	assert.Error(t, githttp.WriteHookScripts(dir, "/bin/er\nmite"))
425}
426
427// The shell really passes the awkward path and the arguments through.
428func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
429	base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z")
430	require.NoError(t, os.MkdirAll(base, 0o755))
431	exe := filepath.Join(base, "fake ermite")
432	out := filepath.Join(t.TempDir(), "args")
433	require.NoError(t, os.WriteFile(exe, []byte("#!/bin/sh\nprintf '%s|' \"$@\" > '"+out+"'\ncat > '"+out+".stdin'\n"), 0o755))
434	dir := t.TempDir()
435	require.NoError(t, githttp.WriteHookScripts(dir, exe))
436
437	cmd := exec.Command(filepath.Join(dir, "pre-receive"))
438	cmd.Stdin = strings.NewReader("line\n")
439	require.NoError(t, cmd.Run())
440	got, err := os.ReadFile(out)
441	require.NoError(t, err)
442	assert.Equal(t, "hook|pre-receive|", string(got))
443	in, err := os.ReadFile(out + ".stdin")
444	require.NoError(t, err)
445	assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
446}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close