1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "bytes"
7 "context"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/bnema/zerowrap"
19 "github.com/stretchr/testify/require"
20
21 "bnema.dev/ermite/internal/config"
22 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/ports"
24)
25
26// harness is a forge wired like `ermite serve`, behind an httptest server,
27// and a git client isolated from the machine's configuration.
28type harness struct {
29 t *testing.T
30 forge *forge
31 srv *httptest.Server
32 data string
33 logs *syncBuffer
34 home string // HOME of the git client
35 ctx context.Context
36 cancel context.CancelFunc
37 hooksDone chan error
38}
39
40type harnessOpts struct {
41 trustedProxies string
42 maintenance time.Duration
43 // hookExe is the executable the hook scripts exec; empty builds ermite.
44 hookExe string
45 // maxBody overrides the limit of a push body when positive.
46 maxBody int64
47 // eventBuffer overrides the size of the event ring when positive.
48 eventBuffer int
49 // revalidateEvery overrides how often a watcher's token is re-checked.
50 revalidateEvery time.Duration
51 // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
52 publicURL string
53}
54
55// newHarness starts a forge. The data directory is short: the hook socket
56// lives in it and Unix socket paths are limited to about 100 bytes.
57func newHarness(t *testing.T, o harnessOpts) *harness {
58 t.Helper()
59 return newHarnessWith(t, o, nil)
60}
61
62// newHarnessWith is newHarness with a change of the wiring options.
63func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness {
64 t.Helper()
65 gitBin := requireGit(t)
66 data, err := os.MkdirTemp("", "ermite")
67 require.NoError(t, err)
68 t.Cleanup(func() { _ = os.RemoveAll(data) })
69
70 env := map[string]string{
71 config.EnvDataDir: data,
72 config.EnvPublicURL: "http://example.test",
73 config.EnvAdminToken: adminToken,
74 config.EnvGit: gitBin,
75 config.EnvAddr: "127.0.0.1:1",
76 }
77 if o.publicURL != "" {
78 env[config.EnvPublicURL] = o.publicURL
79 }
80 if o.trustedProxies != "" {
81 env[config.EnvTrustedProxies] = o.trustedProxies
82 }
83 // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app
84 if r := os.Getenv(config.EnvGitReader); r != "" {
85 env[config.EnvGitReader] = r
86 }
87 cfg, err := config.Load(func(k string) string { return env[k] })
88 require.NoError(t, err)
89
90 logs := &syncBuffer{}
91 log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: logs})
92 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), log))
93
94 require.NoError(t, prepareDataDir(data))
95 hookExe := o.hookExe
96 if hookExe == "" {
97 hookExe = binary(t)
98 }
99 opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
100 if tweak != nil {
101 tweak(&opts)
102 }
103 f, err := newForge(ctx, cfg, opts)
104 require.NoError(t, err)
105
106 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
107 go func() { h.hooksDone <- f.hooks.Serve(ctx); close(h.hooksDone) }()
108 h.srv = httptest.NewUnstartedServer(f.server.Handler())
109 h.srv.Config.BaseContext = func(net.Listener) context.Context { return ctx }
110 h.srv.Start()
111
112 h.home = t.TempDir()
113 t.Cleanup(func() {
114 // Event streams never end by themselves: the real server closes them
115 // when its shutdown begins, the test server needs the same push.
116 f.EventLog.Close(context.WithoutCancel(ctx))
117 h.srv.Close()
118 cancel()
119 select {
120 case <-h.hooksDone:
121 case <-time.After(10 * time.Second):
122 t.Error("hook server did not stop")
123 }
124 f.Close(context.WithoutCancel(ctx))
125 })
126 return h
127}
128
129func (h *harness) url(path string) string { return h.srv.URL + path }
130
131// urlAuth returns a URL with the token as the password.
132func (h *harness) urlAuth(secret, path string) string {
133 return strings.Replace(h.srv.URL, "http://", "http://user:"+secret+"@", 1) + path
134}
135
136func (h *harness) name(s string) domain.RepoName {
137 h.t.Helper()
138 n, err := domain.ParseRepoName(s)
139 require.NoError(h.t, err)
140 return n
141}
142
143// clientEnv is the complete environment of the git client in tests: nothing
144// of the machine's configuration, a fixed identity and locale.
145func clientEnv(home string) []string {
146 return []string{
147 "PATH=" + os.Getenv("PATH"),
148 "HOME=" + home,
149 "GIT_CONFIG_GLOBAL=/dev/null",
150 "GIT_CONFIG_NOSYSTEM=1",
151 "GIT_TERMINAL_PROMPT=0",
152 "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.test",
153 "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.test",
154 "LC_ALL=C",
155 }
156}
157
158// git runs the git client in dir with an isolated configuration.
159func (h *harness) git(dir string, args ...string) (string, error) {
160 h.t.Helper()
161 return h.gitEnv(nil, dir, args...)
162}
163
164func (h *harness) mustGit(dir string, args ...string) string {
165 h.t.Helper()
166 out, err := h.git(dir, args...)
167 require.NoError(h.t, err, "git %s\n%s", strings.Join(args, " "), out)
168 return out
169}
170
171// workTree makes a work tree with one commit per file on branch.
172func (h *harness) workTree(branch string, files ...string) string {
173 h.t.Helper()
174 dir := h.t.TempDir()
175 h.mustGit(dir, "init", "-q", "--initial-branch="+branch)
176 for _, f := range files {
177 require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(dir, f)), 0o755))
178 require.NoError(h.t, os.WriteFile(filepath.Join(dir, f), []byte("content of "+f+"\n"), 0o644))
179 h.mustGit(dir, "add", f)
180 h.mustGit(dir, "commit", "-q", "-m", "add "+f)
181 }
182 return dir
183}
184
185// status does a plain HTTP request and returns the status code.
186func (h *harness) status(method, path string, hdr http.Header) int {
187 h.t.Helper()
188 req, err := http.NewRequest(method, h.url(path), nil)
189 require.NoError(h.t, err)
190 for k, vs := range hdr {
191 for _, v := range vs {
192 req.Header.Add(k, v)
193 }
194 }
195 resp, err := http.DefaultClient.Do(req)
196 require.NoError(h.t, err)
197 _ = resp.Body.Close()
198 return resp.StatusCode
199}
200
201// token creates a stored token through the use case, as root.
202func (h *harness) token(scopes []domain.Scope, repos ...string) string {
203 h.t.Helper()
204 p := ports.CreateTokenParams{Label: "e2e", Scopes: scopes}
205 for _, r := range repos {
206 p.Repos = append(p.Repos, h.name(r))
207 }
208 got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
209 require.NoError(h.t, err)
210 return got.Secret
211}
212
213// gitEnv runs git like git() with extra environment variables.
214func (h *harness) gitEnv(extra []string, dir string, args ...string) (string, error) {
215 h.t.Helper()
216 cmd := exec.Command("git", args...)
217 cmd.Dir = dir
218 cmd.Env = append(clientEnv(h.home), extra...)
219 var out bytes.Buffer
220 cmd.Stdout, cmd.Stderr = &out, &out
221 err := cmd.Run()
222 return out.String(), err
223}
224
225// repoDir is the bare repository on disk.
226func (h *harness) repoDir(name string) string {
227 return filepath.Join(h.data, "repos", name+".git")
228}
229
230// bareGit runs git against the bare repository on disk.
231func (h *harness) bareGit(name string, args ...string) string {
232 h.t.Helper()
233 return strings.TrimSpace(h.mustGit(h.repoDir(name), args...))
234}