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}