harness_test.go
⎇
Raw
1// Package e2e drives the full HTTP server in-process: the real router with
2// every middleware, a real listener, and a cookie jar per session. Tests
3// assert on the rendered HTML with goquery. A small browser suite in
4// browser_test.go covers the few flows that need JavaScript.
5package e2e
6
7import (
8 "bytes"
9 "context"
10 "io"
11 "mime/multipart"
12 "net/http"
13 "net/http/cookiejar"
14 "net/http/httptest"
15 "net/url"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strings"
20 "testing"
21
22 "github.com/PuerkitoBio/goquery"
23
24 "hearthforge/internal/ci"
25 "hearthforge/internal/config"
26 "hearthforge/internal/db"
27 "hearthforge/internal/gitcmd"
28 "hearthforge/internal/highlight"
29 "hearthforge/internal/markdown"
30 "hearthforge/internal/web"
31)
32
33const adminPass = "correct-horse-battery"
34
35// env is one running server on a throwaway data directory.
36type env struct {
37 t *testing.T
38 Srv *web.Server
39 DB *db.DB
40 Cfg *config.Config
41 Base string
42 DataDir string
43 http *httptest.Server
44}
45
46// newEnv starts a server. extraEnv overrides environment variables the way
47// the process would read them, e.g. "BASE_URL", "REGISTRATION_TYPE",
48// "CI_DOCKER_SOCKET". DATA_DIR, SSH_DISABLED and RATE_LIMIT_DISABLED are
49// always set.
50func newEnv(t *testing.T, extraEnv ...string) *env {
51 t.Helper()
52 for _, bin := range []string{"git", "ssh-keygen"} {
53 if _, err := exec.LookPath(bin); err != nil {
54 t.Skipf("%s not installed", bin)
55 }
56 }
57 dataDir := t.TempDir()
58 hs := httptest.NewUnstartedServer(nil)
59 base := "http://" + hs.Listener.Addr().String()
60
61 t.Setenv("DATA_DIR", dataDir)
62 t.Setenv("SSH_DISABLED", "1")
63 t.Setenv("RATE_LIMIT_DISABLED", "1")
64 t.Setenv("BASE_URL", base)
65 t.Setenv("CI_DOCKER_SOCKET", filepath.Join(dataDir, "no-such-socket"))
66 for i := 0; i+1 < len(extraEnv); i += 2 {
67 t.Setenv(extraEnv[i], extraEnv[i+1])
68 }
69 cfg, err := config.Load()
70 if err != nil {
71 t.Fatal(err)
72 }
73 for _, dir := range []string{cfg.ReposDir(), cfg.AvatarsDir(), cfg.ReleasesDir(), cfg.CIArtifactsDir()} {
74 if err := os.MkdirAll(dir, 0o755); err != nil {
75 t.Fatal(err)
76 }
77 }
78 database, err := db.Open(cfg.DBPath())
79 if err != nil {
80 t.Fatal(err)
81 }
82 ctx, cancel := context.WithCancel(context.Background())
83 if _, err := database.InitAdmin(ctx, adminPass); err != nil {
84 t.Fatal(err)
85 }
86 git := gitcmd.New(cfg)
87 runner := ci.New(cfg, database)
88 srv := &web.Server{
89 Cfg: cfg,
90 DB: database,
91 MD: markdown.New(),
92 HL: highlight.New(cfg.InlineMaxBytes),
93 CI: runner,
94 Git: git,
95 Patches: gitcmd.NewPatchCache(),
96 }
97 runner.ImportImage = srv.ImportImage
98 git.OnRefUpdate = func(repo, ref, rev string) {
99 go runner.TriggerForRef(context.Background(), repo, ref, rev)
100 }
101 if err := srv.SyncRepos(ctx); err != nil {
102 t.Fatal(err)
103 }
104 hs.Config.Handler = srv.Router()
105 hs.Start()
106 t.Cleanup(func() {
107 hs.Close()
108 cancel()
109 database.Close()
110 })
111 return &env{t: t, Srv: srv, DB: database, Cfg: cfg, Base: base, DataDir: dataDir, http: hs}
112}
113
114// session is one browser-like client with its own cookie jar. Redirects are
115// not followed, so tests can assert on them.
116type session struct {
117 env *env
118 client *http.Client
119}
120
121func (e *env) anon() *session {
122 jar, _ := cookiejar.New(nil)
123 return &session{env: e, client: &http.Client{
124 Jar: jar,
125 CheckRedirect: func(*http.Request, []*http.Request) error {
126 return http.ErrUseLastResponse
127 },
128 }}
129}
130
131// login signs in and fails the test when the credentials are rejected.
132func (e *env) login(username, password string) *session {
133 e.t.Helper()
134 s := e.anon()
135 r := s.post("/login", url.Values{"username": {username}, "password": {password}})
136 if r.Code != http.StatusFound || r.Location() != "/" {
137 e.t.Fatalf("login as %s: status %d, location %q, body %s", username, r.Code, r.Location(), r.Text("body"))
138 }
139 return s
140}
141
142// admin returns a session signed in as the admin.
143func (e *env) admin() *session { return e.login(db.AdminUsername, adminPass) }
144
145// register creates a user through the form and returns a signed-in session.
146func (e *env) register(username, password string) *session {
147 e.t.Helper()
148 s := e.anon()
149 r := s.post("/register", url.Values{
150 "username": {username}, "password": {password}, "password2": {password},
151 })
152 if r.Code != http.StatusFound {
153 e.t.Fatalf("register %s: status %d, body %s", username, r.Code, r.Text(".form-error"))
154 }
155 return s
156}
157
158// cookie returns the named cookie of the session, or nil.
159func (s *session) cookie(name string) *http.Cookie {
160 u, _ := url.Parse(s.env.Base)
161 for _, c := range s.client.Jar.Cookies(u) {
162 if c.Name == name {
163 return c
164 }
165 }
166 return nil
167}
168
169// setCookie stores a cookie in the jar.
170func (s *session) setCookie(name, value string) {
171 u, _ := url.Parse(s.env.Base)
172 s.client.Jar.SetCookies(u, []*http.Cookie{{Name: name, Value: value, Path: "/"}})
173}
174
175// response is a fully read HTTP response with lazy HTML parsing.
176type response struct {
177 t *testing.T
178 Code int
179 Header http.Header
180 Body []byte
181 doc *goquery.Document
182}
183
184func (r *response) Location() string { return r.Header.Get("Location") }
185
186// Doc parses the body as HTML once.
187func (r *response) Doc() *goquery.Document {
188 if r.doc == nil {
189 d, err := goquery.NewDocumentFromReader(bytes.NewReader(r.Body))
190 if err != nil {
191 r.t.Fatal(err)
192 }
193 r.doc = d
194 }
195 return r.doc
196}
197
198// Find selects elements by CSS selector.
199func (r *response) Find(sel string) *goquery.Selection { return r.Doc().Find(sel) }
200
201// Count returns how many elements match.
202func (r *response) Count(sel string) int { return r.Find(sel).Length() }
203
204// Has reports whether at least one element matches.
205func (r *response) Has(sel string) bool { return r.Count(sel) > 0 }
206
207// Text returns the trimmed text of the first match, or "" when none.
208func (r *response) Text(sel string) string {
209 return strings.TrimSpace(r.Find(sel).First().Text())
210}
211
212// Texts returns the trimmed text of every match.
213func (r *response) Texts(sel string) []string {
214 var out []string
215 r.Find(sel).Each(func(_ int, s *goquery.Selection) {
216 out = append(out, strings.TrimSpace(s.Text()))
217 })
218 return out
219}
220
221// Attr returns an attribute of the first match, or "" when none.
222func (r *response) Attr(sel, name string) string {
223 v, _ := r.Find(sel).First().Attr(name)
224 return v
225}
226
227// Value returns the value of the first matching input, or the selected
228// option of a select.
229func (r *response) Value(sel string) string {
230 el := r.Find(sel).First()
231 if goquery.NodeName(el) == "select" {
232 v, _ := el.Find("option[selected]").First().Attr("value")
233 if v == "" {
234 v, _ = el.Find("option").First().Attr("value")
235 }
236 return v
237 }
238 if goquery.NodeName(el) == "textarea" {
239 return el.Text()
240 }
241 v, _ := el.Attr("value")
242 return v
243}
244
245// BodyString returns the raw body.
246func (r *response) BodyString() string { return string(r.Body) }
247
248// Contains reports whether the raw body contains s.
249func (r *response) Contains(s string) bool { return bytes.Contains(r.Body, []byte(s)) }
250
251func (s *session) do(req *http.Request) *response {
252 s.env.t.Helper()
253 res, err := s.client.Do(req)
254 if err != nil {
255 s.env.t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
256 }
257 defer res.Body.Close()
258 body, err := io.ReadAll(res.Body)
259 if err != nil {
260 s.env.t.Fatal(err)
261 }
262 return &response{t: s.env.t, Code: res.StatusCode, Header: res.Header, Body: body}
263}
264
265// get fetches a path. header pairs are optional extra request headers.
266func (s *session) get(path string, header ...string) *response {
267 s.env.t.Helper()
268 req, _ := http.NewRequest(http.MethodGet, s.env.Base+path, nil)
269 for i := 0; i+1 < len(header); i += 2 {
270 req.Header.Set(header[i], header[i+1])
271 }
272 return s.do(req)
273}
274
275// post sends a urlencoded form.
276func (s *session) post(path string, form url.Values, header ...string) *response {
277 s.env.t.Helper()
278 req, _ := http.NewRequest(http.MethodPost, s.env.Base+path, strings.NewReader(form.Encode()))
279 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
280 for i := 0; i+1 < len(header); i += 2 {
281 req.Header.Set(header[i], header[i+1])
282 }
283 return s.do(req)
284}
285
286// file is one upload part of a multipart form.
287type file struct {
288 Field, Name string
289 Content []byte
290}
291
292// postMultipart sends a multipart form with fields and files. Repeated
293// field values are sent as repeated parts.
294func (s *session) postMultipart(path string, fields url.Values, files ...file) *response {
295 s.env.t.Helper()
296 var buf bytes.Buffer
297 mw := multipart.NewWriter(&buf)
298 for k, vs := range fields {
299 for _, v := range vs {
300 _ = mw.WriteField(k, v)
301 }
302 }
303 for _, f := range files {
304 w, err := mw.CreateFormFile(f.Field, f.Name)
305 if err != nil {
306 s.env.t.Fatal(err)
307 }
308 _, _ = w.Write(f.Content)
309 }
310 mw.Close()
311 req, _ := http.NewRequest(http.MethodPost, s.env.Base+path, &buf)
312 req.Header.Set("Content-Type", mw.FormDataContentType())
313 return s.do(req)
314}
315
316// follow GETs the Location of a redirect response.
317func (s *session) follow(r *response) *response {
318 s.env.t.Helper()
319 if r.Code < 300 || r.Code > 399 {
320 s.env.t.Fatalf("expected redirect, got %d: %s", r.Code, r.BodyString())
321 }
322 loc := r.Location()
323 loc = strings.TrimPrefix(loc, s.env.Base)
324 return s.get(loc)
325}
326
327// mustRedirect asserts a redirect to the given path prefix and returns the
328// location.
329func (r *response) mustRedirect(prefix string) string {
330 r.t.Helper()
331 if r.Code != http.StatusFound && r.Code != http.StatusSeeOther {
332 r.t.Fatalf("status = %d, want redirect; body: %s", r.Code, firstLines(r.BodyString()))
333 }
334 if !strings.HasPrefix(r.Location(), prefix) {
335 r.t.Fatalf("redirected to %q, want prefix %q", r.Location(), prefix)
336 }
337 return r.Location()
338}
339
340// mustStatus asserts the status code.
341func (r *response) mustStatus(code int) *response {
342 r.t.Helper()
343 if r.Code != code {
344 r.t.Fatalf("status = %d, want %d; body: %s", r.Code, code, firstLines(r.BodyString()))
345 }
346 return r
347}
348
349func firstLines(s string) string {
350 if len(s) > 400 {
351 return s[:400] + "…"
352 }
353 return s
354}
355
356// ---------- git helpers ----------
357
358// gitRun runs git in dir and fails the test on error.
359func gitRun(t *testing.T, dir string, args ...string) string {
360 t.Helper()
361 cmd := exec.Command("git", args...)
362 cmd.Dir = dir
363 cmd.Env = append(os.Environ(),
364 "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com",
365 "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com",
366 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
367 "GIT_TERMINAL_PROMPT=0")
368 out, err := cmd.CombinedOutput()
369 if err != nil {
370 t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
371 }
372 return strings.TrimSpace(string(out))
373}
374
375// gitTry runs git and returns the error instead of failing.
376func gitTry(dir string, args ...string) (string, error) {
377 cmd := exec.Command("git", args...)
378 cmd.Dir = dir
379 cmd.Env = append(os.Environ(),
380 "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com",
381 "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com",
382 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
383 "GIT_TERMINAL_PROMPT=0")
384 out, err := cmd.CombinedOutput()
385 return strings.TrimSpace(string(out)), err
386}
387
388// repoPath is the bare repository on disk.
389func (e *env) repoPath(name string) string {
390 return filepath.Join(e.Cfg.ReposDir(), name+".git")
391}
392
393// createRepo creates a repository through the admin form.
394func (e *env) createRepo(admin *session, name string, extra ...string) {
395 e.t.Helper()
396 form := url.Values{"name": {name}, "default_branch": {"main"}}
397 for i := 0; i+1 < len(extra); i += 2 {
398 form.Set(extra[i], extra[i+1])
399 }
400 admin.post("/new", form).mustRedirect("/" + name)
401}
402
403// seedRepo commits the given files (path -> content) and pushes them to the
404// bare repo's main branch. It returns the new HEAD hash. Files are added on
405// top of the current main, so repeated calls stack commits.
406func (e *env) seedRepo(name string, files map[string]string, message ...string) string {
407 e.t.Helper()
408 if files == nil {
409 files = map[string]string{
410 "README.md": "# " + name + "\n",
411 "index.js": "console.log(\"hello\");\n",
412 "logo.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>` + "\n",
413 }
414 }
415 msg := "Initial commit"
416 if len(message) > 0 {
417 msg = message[0]
418 }
419 work := e.t.TempDir()
420 gitRun(e.t, work, "clone", "-q", e.repoPath(name), ".")
421 for p, content := range files {
422 full := filepath.Join(work, p)
423 if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
424 e.t.Fatal(err)
425 }
426 if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
427 e.t.Fatal(err)
428 }
429 }
430 gitRun(e.t, work, "add", "-A")
431 gitRun(e.t, work, "commit", "-q", "-m", msg)
432 gitRun(e.t, work, "push", "-q", "origin", "HEAD:main")
433 e.Srv.Git.InvalidateRefCache(name)
434 return gitRun(e.t, work, "rev-parse", "HEAD")
435}
436
437// seedBranch creates a branch from main with one extra commit and pushes it.
438func (e *env) seedBranch(name, branch string, files map[string]string) string {
439 e.t.Helper()
440 work := e.t.TempDir()
441 gitRun(e.t, work, "clone", "-q", "-b", "main", e.repoPath(name), ".")
442 gitRun(e.t, work, "checkout", "-q", "-b", branch)
443 if files == nil {
444 files = map[string]string{branch + ".txt": "on " + branch + "\n"}
445 }
446 for p, content := range files {
447 full := filepath.Join(work, p)
448 _ = os.MkdirAll(filepath.Dir(full), 0o755)
449 if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
450 e.t.Fatal(err)
451 }
452 }
453 gitRun(e.t, work, "add", "-A")
454 gitRun(e.t, work, "commit", "-q", "-m", "Commit on "+branch)
455 gitRun(e.t, work, "push", "-q", "origin", branch)
456 e.Srv.Git.InvalidateRefCache(name)
457 return gitRun(e.t, work, "rev-parse", "HEAD")
458}
459
460// headCommit returns the hash main points at.
461func (e *env) headCommit(name string) string {
462 e.t.Helper()
463 return gitRun(e.t, e.repoPath(name), "rev-parse", "main")
464}
465
466// authURL is the clone URL with admin Basic auth credentials.
467func (e *env) authURL(name string) string {
468 u, _ := url.Parse(e.Base)
469 u.User = url.UserPassword(db.AdminUsername, adminPass)
470 return u.String() + "/" + name + ".git"
471}
472
473// ---------- misc ----------
474
475// idFromPath returns the last numeric path segment, e.g. the issue number
476// of "/repo/issues/12".
477func idFromPath(t *testing.T, p string) string {
478 t.Helper()
479 p = strings.TrimRight(p, "/")
480 i := strings.LastIndex(p, "/")
481 if i < 0 || i == len(p)-1 {
482 t.Fatalf("no id in %q", p)
483 }
484 return p[i+1:]
485}
486
487// contains reports whether any string in list contains sub.
488func contains(list []string, sub string) bool {
489 for _, s := range list {
490 if strings.Contains(s, sub) {
491 return true
492 }
493 }
494 return false
495}
496