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