v0.14.0 815fb98

main_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"io"
10	"net"
11	"net/http"
12	"os"
13	"os/exec"
14	"path/filepath"
15	"strconv"
16	"strings"
17	"sync"
18	"syscall"
19	"testing"
20	"time"
21
22	"github.com/stretchr/testify/assert"
23	"github.com/stretchr/testify/require"
24
25	"bnema.dev/ermite/internal/adapters/gitcli"
26)
27
28const adminToken = "0123456789abcdef0123456789abcdef-secret"
29
30var (
31	buildOnce sync.Once
32	binPath   string
33	buildErr  error
34)
35
36// binary builds the ermite binary once per test run.
37func binary(t *testing.T) string {
38	t.Helper()
39	buildOnce.Do(func() {
40		dir, err := os.MkdirTemp("", "ermite-bin-")
41		if err != nil {
42			buildErr = err
43			return
44		}
45		binPath = filepath.Join(dir, "ermite")
46		out, err := exec.Command("go", "build", "-o", binPath, "bnema.dev/ermite/cmd/ermite").CombinedOutput()
47		if err != nil {
48			buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))
49		}
50	})
51	require.NoError(t, buildErr)
52	return binPath
53}
54
55func TestMain(m *testing.M) {
56	code := m.Run()
57	if binPath != "" {
58		_ = os.RemoveAll(filepath.Dir(binPath))
59	}
60	os.Exit(code)
61}
62
63type syncBuffer struct {
64	mu sync.Mutex
65	b  bytes.Buffer
66}
67
68func (s *syncBuffer) Write(p []byte) (int, error) {
69	s.mu.Lock()
70	defer s.mu.Unlock()
71	return s.b.Write(p)
72}
73
74func (s *syncBuffer) String() string {
75	s.mu.Lock()
76	defer s.mu.Unlock()
77	return s.b.String()
78}
79
80func freeAddr(t *testing.T) string {
81	t.Helper()
82	ln, err := net.Listen("tcp", "127.0.0.1:0")
83	require.NoError(t, err)
84	addr := ln.Addr().String()
85	require.NoError(t, ln.Close())
86	return addr
87}
88
89// unusedAddr is a valid address no test listens on: serve fails before it
90// listens when it is used.
91const unusedAddr = "127.0.0.1:1"
92
93// requireGit returns a git usable by ermite. Locally the test is skipped
94// without one; in CI (CI set) it fails, so that CI cannot pass vacuously.
95func requireGit(t *testing.T) string {
96	t.Helper()
97	unusable := func(format string, args ...any) {
98		t.Helper()
99		if os.Getenv("CI") != "" {
100			t.Fatalf(format, args...)
101		}
102		t.Skipf(format, args...)
103	}
104	gitBin, err := exec.LookPath("git")
105	if err != nil {
106		unusable("git not found")
107	}
108	probe := gitcli.New(gitcli.Config{Bin: gitBin, DataDir: t.TempDir(), Path: os.Getenv("PATH")})
109	if _, err := probe.CheckVersion(context.Background()); err != nil {
110		unusable("git is not usable by ermite: %v", err)
111	}
112	return gitBin
113}
114
115func baseEnv() []string {
116	return []string{"PATH=" + os.Getenv("PATH")}
117}
118
119func TestVersion(t *testing.T) {
120	out, err := exec.Command(binary(t), "version").Output()
121	require.NoError(t, err)
122	assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out))
123	assert.Equal(t, 1, strings.Count(string(out), "\n"))
124}
125
126func TestUnknownCommandExitsNonZero(t *testing.T) {
127	err := exec.Command(binary(t), "nope").Run()
128	var exit *exec.ExitError
129	require.True(t, errors.As(err, &exit))
130	assert.Equal(t, 1, exit.ExitCode())
131}
132
133func TestServeMissingEnvListsEveryVariable(t *testing.T) {
134	cmd := exec.Command(binary(t), "serve")
135	cmd.Env = baseEnv()
136	var stderr bytes.Buffer
137	cmd.Stderr = &stderr
138	err := cmd.Run()
139
140	var exit *exec.ExitError
141	require.True(t, errors.As(err, &exit), "expected a failure, got %v", err)
142	assert.Equal(t, 1, exit.ExitCode())
143	for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN"} {
144		assert.Contains(t, stderr.String(), name)
145	}
146}
147
148func TestServeInvalidEnvNeverPrintsToken(t *testing.T) {
149	cmd := exec.Command(binary(t), "serve")
150	cmd.Env = append(baseEnv(),
151		"ERMITE_DATA_DIR=relative",
152		"ERMITE_PUBLIC_URL=ftp://x",
153		"ERMITE_ADMIN_TOKEN=short-secret-value",
154		"ERMITE_ADDR=bad",
155	)
156	var out bytes.Buffer
157	cmd.Stdout, cmd.Stderr = &out, &out
158	err := cmd.Run()
159	var exit *exec.ExitError
160	require.True(t, errors.As(err, &exit))
161	assert.Equal(t, 1, exit.ExitCode())
162	for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN", "ERMITE_ADDR"} {
163		assert.Contains(t, out.String(), name)
164	}
165	assert.NotContains(t, out.String(), "short-secret-value")
166}
167
168func TestServeGitTooOldNamesVariable(t *testing.T) {
169	script := filepath.Join(t.TempDir(), "git")
170	require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\necho 'git version 2.44.0'\n"), 0o700))
171	data := t.TempDir()
172
173	cmd := exec.Command(binary(t), "serve")
174	cmd.Env = append(baseEnv(),
175		"ERMITE_DATA_DIR="+data,
176		"ERMITE_PUBLIC_URL=http://localhost",
177		"ERMITE_ADMIN_TOKEN="+adminToken,
178		"ERMITE_ADDR="+unusedAddr,
179		"ERMITE_GIT="+script,
180	)
181	var out bytes.Buffer
182	cmd.Stdout, cmd.Stderr = &out, &out
183	err := cmd.Run()
184	var exit *exec.ExitError
185	require.True(t, errors.As(err, &exit))
186	assert.Equal(t, 1, exit.ExitCode())
187	assert.Contains(t, out.String(), "ERMITE_GIT")
188	assert.NotContains(t, out.String(), adminToken)
189}
190
191func TestServeMissingGitNamesVariable(t *testing.T) {
192	cmd := exec.Command(binary(t), "serve")
193	cmd.Env = append(baseEnv(),
194		"ERMITE_DATA_DIR="+t.TempDir(),
195		"ERMITE_PUBLIC_URL=http://localhost",
196		"ERMITE_ADMIN_TOKEN="+adminToken,
197		"ERMITE_ADDR="+unusedAddr,
198		"ERMITE_GIT=/nonexistent/git",
199	)
200	var out bytes.Buffer
201	cmd.Stdout, cmd.Stderr = &out, &out
202	err := cmd.Run()
203	var exit *exec.ExitError
204	require.True(t, errors.As(err, &exit))
205	assert.Equal(t, 1, exit.ExitCode())
206	assert.Contains(t, out.String(), "ERMITE_GIT")
207}
208
209func TestServeHealthAndSigterm(t *testing.T) {
210	requireGit(t)
211	data := t.TempDir()
212	// State from a previous run: run/ is recreated, an orphan repository is
213	// registered, a staging leftover is removed.
214	require.NoError(t, os.MkdirAll(filepath.Join(data, "run", "stale"), 0o700))
215	require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging", "leftover"), 0o700))
216	require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", "orphan.git"), 0o700))
217	addr := freeAddr(t)
218
219	cmd := exec.Command(binary(t), "serve")
220	cmd.Env = append(baseEnv(),
221		"ERMITE_ADDR="+addr,
222		"ERMITE_DATA_DIR="+data,
223		"ERMITE_PUBLIC_URL=http://"+addr,
224		"ERMITE_ADMIN_TOKEN="+adminToken,
225		"ERMITE_LOG_LEVEL=debug",
226		"ERMITE_LOG_FORMAT=json",
227		"GIT_DIR=/nonexistent", // must not leak into git
228	)
229	var logs syncBuffer
230	cmd.Stdout, cmd.Stderr = &logs, &logs
231	require.NoError(t, cmd.Start())
232	exited := make(chan error, 1)
233	go func() { exited <- cmd.Wait() }()
234	t.Cleanup(func() {
235		_ = cmd.Process.Kill()
236	})
237
238	base := "http://" + addr
239	var resp *http.Response
240	deadline := time.Now().Add(15 * time.Second)
241	for {
242		var err error
243		resp, err = http.Get(base + "/-/health")
244		if err == nil {
245			break
246		}
247		select {
248		case werr := <-exited:
249			t.Fatalf("server exited before answering: %v\n%s", werr, logs.String())
250		default:
251		}
252		if time.Now().After(deadline) {
253			t.Fatalf("health never answered: %v\n%s", err, logs.String())
254		}
255		time.Sleep(50 * time.Millisecond)
256	}
257	body, err := io.ReadAll(resp.Body)
258	require.NoError(t, err)
259	require.NoError(t, resp.Body.Close())
260	assert.Equal(t, 200, resp.StatusCode)
261	assert.Equal(t, "ok\n", string(body))
262	assert.Equal(t, "DENY", resp.Header.Get("X-Frame-Options"))
263	assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'")
264
265	resp404, err := http.Get(base + "/anything")
266	require.NoError(t, err)
267	_ = resp404.Body.Close()
268	assert.Equal(t, 404, resp404.StatusCode)
269
270	// Data directory layout and modes.
271	for _, d := range []string{"repos", "repos/.staging", "trash", "kv", "run", "run/git-home"} {
272		fi, err := os.Stat(filepath.Join(data, d))
273		require.NoError(t, err, d)
274		assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm(), d)
275	}
276	assert.NoDirExists(t, filepath.Join(data, "run", "stale"))
277	assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", "leftover"))
278	assert.Contains(t, logs.String(), `"registered":1`)
279
280	require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
281	select {
282	case err := <-exited:
283		require.NoError(t, err, "exit status 0 expected\n%s", logs.String())
284	case <-time.After(15 * time.Second):
285		t.Fatalf("server did not stop after SIGTERM\n%s", logs.String())
286	}
287	out := logs.String()
288	assert.Contains(t, out, "shutting down")
289	assert.NotContains(t, out, adminToken)
290	assert.NotContains(t, out, strconv.Quote(adminToken))
291	assert.False(t, strings.Contains(out, `"level":"error"`), "no error logged:\n%s", out)
292
293	_, err = http.Get(base + "/-/health")
294	assert.Error(t, err, "the port is closed")
295}
296
297func TestSecondServeLeavesRunDirUntouched(t *testing.T) {
298	requireGit(t)
299	data := t.TempDir()
300	addr := freeAddr(t)
301	env := append(baseEnv(),
302		"ERMITE_ADDR="+addr,
303		"ERMITE_DATA_DIR="+data,
304		"ERMITE_PUBLIC_URL=http://"+addr,
305		"ERMITE_ADMIN_TOKEN="+adminToken,
306	)
307
308	first := exec.Command(binary(t), "serve")
309	first.Env = env
310	var firstLogs syncBuffer
311	first.Stdout, first.Stderr = &firstLogs, &firstLogs
312	require.NoError(t, first.Start())
313	exited := make(chan error, 1)
314	go func() { exited <- first.Wait() }()
315	t.Cleanup(func() { _ = first.Process.Kill() })
316
317	deadline := time.Now().Add(15 * time.Second)
318	for {
319		resp, err := http.Get("http://" + addr + "/-/health")
320		if err == nil {
321			_ = resp.Body.Close()
322			break
323		}
324		select {
325		case werr := <-exited:
326			t.Fatalf("first server exited: %v\n%s", werr, firstLogs.String())
327		default:
328		}
329		if time.Now().After(deadline) {
330			t.Fatalf("health never answered: %v\n%s", err, firstLogs.String())
331		}
332		time.Sleep(50 * time.Millisecond)
333	}
334
335	marker := filepath.Join(data, "run", "marker")
336	require.NoError(t, os.WriteFile(marker, []byte("keep"), 0o600))
337
338	second := exec.Command(binary(t), "serve")
339	second.Env = append(env[:len(env):len(env)], "ERMITE_ADDR="+freeAddr(t))
340	var out bytes.Buffer
341	second.Stdout, second.Stderr = &out, &out
342	err := second.Run()
343	var exit *exec.ExitError
344	require.True(t, errors.As(err, &exit), "second serve must fail, got %v\n%s", err, out.String())
345	assert.NotZero(t, exit.ExitCode())
346
347	got, rerr := os.ReadFile(marker)
348	require.NoError(t, rerr, "run/ must be untouched by the second serve")
349	assert.Equal(t, "keep", string(got))
350	assert.DirExists(t, filepath.Join(data, "run", "git-home"))
351
352	require.NoError(t, first.Process.Signal(syscall.SIGTERM))
353	select {
354	case <-exited:
355	case <-time.After(15 * time.Second):
356		t.Fatal("first server did not stop")
357	}
358}

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 43

↑↓ moveenter openesc close