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