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