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}