detached fda3c2b

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
119// isolatedEnv is the environment of a CLI run that must not see the user's
120// ERMITE_URL, ERMITE_TOKEN or config file: a version command with a server URL
121// also asks the server and prints its version.
122func isolatedEnv(t *testing.T) []string {
123	t.Helper()
124	cfg := filepath.Join(t.TempDir(), "config")
125	require.NoError(t, os.WriteFile(cfg, nil, 0o600))
126	return append(baseEnv(), "ERMITE_CONFIG="+cfg, "HOME="+t.TempDir(), "XDG_CONFIG_HOME="+t.TempDir())
127}
128
129func TestVersion(t *testing.T) {
130	cmd := exec.Command(binary(t), "version")
131	cmd.Env = isolatedEnv(t)
132	out, err := cmd.Output()
133	require.NoError(t, err)
134	assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out))
135	assert.Equal(t, 1, strings.Count(string(out), "\n"))
136}
137
138func TestUnknownCommandExitsNonZero(t *testing.T) {
139	err := exec.Command(binary(t), "nope").Run()
140	var exit *exec.ExitError
141	require.True(t, errors.As(err, &exit))
142	assert.Equal(t, 1, exit.ExitCode())
143}
144
145func TestServeMissingEnvListsEveryVariable(t *testing.T) {
146	cmd := exec.Command(binary(t), "serve")
147	cmd.Env = baseEnv()
148	var stderr bytes.Buffer
149	cmd.Stderr = &stderr
150	err := cmd.Run()
151
152	var exit *exec.ExitError
153	require.True(t, errors.As(err, &exit), "expected a failure, got %v", err)
154	assert.Equal(t, 1, exit.ExitCode())
155	for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN"} {
156		assert.Contains(t, stderr.String(), name)
157	}
158}
159
160func TestServeInvalidEnvNeverPrintsToken(t *testing.T) {
161	cmd := exec.Command(binary(t), "serve")
162	cmd.Env = append(baseEnv(),
163		"ERMITE_DATA_DIR=relative",
164		"ERMITE_PUBLIC_URL=ftp://x",
165		"ERMITE_ADMIN_TOKEN=short-secret-value",
166		"ERMITE_ADDR=bad",
167	)
168	var out bytes.Buffer
169	cmd.Stdout, cmd.Stderr = &out, &out
170	err := cmd.Run()
171	var exit *exec.ExitError
172	require.True(t, errors.As(err, &exit))
173	assert.Equal(t, 1, exit.ExitCode())
174	for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN", "ERMITE_ADDR"} {
175		assert.Contains(t, out.String(), name)
176	}
177	assert.NotContains(t, out.String(), "short-secret-value")
178}
179
180func TestServeGitTooOldNamesVariable(t *testing.T) {
181	script := filepath.Join(t.TempDir(), "git")
182	require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\necho 'git version 2.44.0'\n"), 0o700))
183	data := t.TempDir()
184
185	cmd := exec.Command(binary(t), "serve")
186	cmd.Env = append(baseEnv(),
187		"ERMITE_DATA_DIR="+data,
188		"ERMITE_PUBLIC_URL=http://localhost",
189		"ERMITE_ADMIN_TOKEN="+adminToken,
190		"ERMITE_ADDR="+unusedAddr,
191		"ERMITE_GIT="+script,
192	)
193	var out bytes.Buffer
194	cmd.Stdout, cmd.Stderr = &out, &out
195	err := cmd.Run()
196	var exit *exec.ExitError
197	require.True(t, errors.As(err, &exit))
198	assert.Equal(t, 1, exit.ExitCode())
199	assert.Contains(t, out.String(), "ERMITE_GIT")
200	assert.NotContains(t, out.String(), adminToken)
201}
202
203func TestServeMissingGitNamesVariable(t *testing.T) {
204	cmd := exec.Command(binary(t), "serve")
205	cmd.Env = append(baseEnv(),
206		"ERMITE_DATA_DIR="+t.TempDir(),
207		"ERMITE_PUBLIC_URL=http://localhost",
208		"ERMITE_ADMIN_TOKEN="+adminToken,
209		"ERMITE_ADDR="+unusedAddr,
210		"ERMITE_GIT=/nonexistent/git",
211	)
212	var out bytes.Buffer
213	cmd.Stdout, cmd.Stderr = &out, &out
214	err := cmd.Run()
215	var exit *exec.ExitError
216	require.True(t, errors.As(err, &exit))
217	assert.Equal(t, 1, exit.ExitCode())
218	assert.Contains(t, out.String(), "ERMITE_GIT")
219}
220
221func TestServeHealthAndSigterm(t *testing.T) {
222	requireGit(t)
223	data := t.TempDir()
224	// State from a previous run: run/ is recreated, an orphan repository is
225	// registered, a staging leftover is removed.
226	require.NoError(t, os.MkdirAll(filepath.Join(data, "run", "stale"), 0o700))
227	require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging", "leftover"), 0o700))
228	require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", "orphan.git"), 0o700))
229	addr := freeAddr(t)
230
231	cmd := exec.Command(binary(t), "serve")
232	cmd.Env = append(baseEnv(),
233		"ERMITE_ADDR="+addr,
234		"ERMITE_DATA_DIR="+data,
235		"ERMITE_PUBLIC_URL=http://"+addr,
236		"ERMITE_ADMIN_TOKEN="+adminToken,
237		"ERMITE_LOG_LEVEL=debug",
238		"ERMITE_LOG_FORMAT=json",
239		"GIT_DIR=/nonexistent", // must not leak into git
240	)
241	var logs syncBuffer
242	cmd.Stdout, cmd.Stderr = &logs, &logs
243	require.NoError(t, cmd.Start())
244	exited := make(chan error, 1)
245	go func() { exited <- cmd.Wait() }()
246	t.Cleanup(func() {
247		_ = cmd.Process.Kill()
248	})
249
250	base := "http://" + addr
251	var resp *http.Response
252	deadline := time.Now().Add(15 * time.Second)
253	for {
254		var err error
255		resp, err = http.Get(base + "/-/health")
256		if err == nil {
257			break
258		}
259		select {
260		case werr := <-exited:
261			t.Fatalf("server exited before answering: %v\n%s", werr, logs.String())
262		default:
263		}
264		if time.Now().After(deadline) {
265			t.Fatalf("health never answered: %v\n%s", err, logs.String())
266		}
267		time.Sleep(50 * time.Millisecond)
268	}
269	body, err := io.ReadAll(resp.Body)
270	require.NoError(t, err)
271	require.NoError(t, resp.Body.Close())
272	assert.Equal(t, 200, resp.StatusCode)
273	assert.Equal(t, "ok\n", string(body))
274	assert.Equal(t, "DENY", resp.Header.Get("X-Frame-Options"))
275	assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'")
276
277	resp404, err := http.Get(base + "/anything")
278	require.NoError(t, err)
279	_ = resp404.Body.Close()
280	assert.Equal(t, 404, resp404.StatusCode)
281
282	// Data directory layout and modes.
283	for _, d := range []string{"repos", "repos/.staging", "trash", "kv", "run", "run/git-home"} {
284		fi, err := os.Stat(filepath.Join(data, d))
285		require.NoError(t, err, d)
286		assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm(), d)
287	}
288	assert.NoDirExists(t, filepath.Join(data, "run", "stale"))
289	assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", "leftover"))
290	assert.Contains(t, logs.String(), `"registered":1`)
291
292	require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
293	select {
294	case err := <-exited:
295		require.NoError(t, err, "exit status 0 expected\n%s", logs.String())
296	case <-time.After(15 * time.Second):
297		t.Fatalf("server did not stop after SIGTERM\n%s", logs.String())
298	}
299	out := logs.String()
300	assert.Contains(t, out, "shutting down")
301	assert.NotContains(t, out, adminToken)
302	assert.NotContains(t, out, strconv.Quote(adminToken))
303	assert.False(t, strings.Contains(out, `"level":"error"`), "no error logged:\n%s", out)
304
305	_, err = http.Get(base + "/-/health")
306	assert.Error(t, err, "the port is closed")
307}
308
309func TestSecondServeLeavesRunDirUntouched(t *testing.T) {
310	requireGit(t)
311	data := t.TempDir()
312	addr := freeAddr(t)
313	env := append(baseEnv(),
314		"ERMITE_ADDR="+addr,
315		"ERMITE_DATA_DIR="+data,
316		"ERMITE_PUBLIC_URL=http://"+addr,
317		"ERMITE_ADMIN_TOKEN="+adminToken,
318	)
319
320	first := exec.Command(binary(t), "serve")
321	first.Env = env
322	var firstLogs syncBuffer
323	first.Stdout, first.Stderr = &firstLogs, &firstLogs
324	require.NoError(t, first.Start())
325	exited := make(chan error, 1)
326	go func() { exited <- first.Wait() }()
327	t.Cleanup(func() { _ = first.Process.Kill() })
328
329	deadline := time.Now().Add(15 * time.Second)
330	for {
331		resp, err := http.Get("http://" + addr + "/-/health")
332		if err == nil {
333			_ = resp.Body.Close()
334			break
335		}
336		select {
337		case werr := <-exited:
338			t.Fatalf("first server exited: %v\n%s", werr, firstLogs.String())
339		default:
340		}
341		if time.Now().After(deadline) {
342			t.Fatalf("health never answered: %v\n%s", err, firstLogs.String())
343		}
344		time.Sleep(50 * time.Millisecond)
345	}
346
347	marker := filepath.Join(data, "run", "marker")
348	require.NoError(t, os.WriteFile(marker, []byte("keep"), 0o600))
349
350	second := exec.Command(binary(t), "serve")
351	second.Env = append(env[:len(env):len(env)], "ERMITE_ADDR="+freeAddr(t))
352	var out bytes.Buffer
353	second.Stdout, second.Stderr = &out, &out
354	err := second.Run()
355	var exit *exec.ExitError
356	require.True(t, errors.As(err, &exit), "second serve must fail, got %v\n%s", err, out.String())
357	assert.NotZero(t, exit.ExitCode())
358
359	got, rerr := os.ReadFile(marker)
360	require.NoError(t, rerr, "run/ must be untouched by the second serve")
361	assert.Equal(t, "keep", string(got))
362	assert.DirExists(t, filepath.Join(data, "run", "git-home"))
363
364	require.NoError(t, first.Process.Signal(syscall.SIGTERM))
365	select {
366	case <-exited:
367	case <-time.After(15 * time.Second):
368		t.Fatal("first server did not stop")
369	}
370}

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