detached 59d5a0b

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

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 2

Tags 45

↑↓ moveenter openesc close