// Package e2e drives the full HTTP server in-process: the real router with // every middleware, a real listener, and a cookie jar per session. Tests // assert on the rendered HTML with goquery. A small browser suite in // browser_test.go covers the few flows that need JavaScript. package e2e import ( "bytes" "context" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/PuerkitoBio/goquery" "hearthforge/internal/ci" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/highlight" "hearthforge/internal/markdown" "hearthforge/internal/web" ) const adminPass = "correct-horse-battery" // env is one running server on a throwaway data directory. type env struct { t *testing.T Srv *web.Server DB *db.DB Cfg *config.Config Base string DataDir string http *httptest.Server } // newEnv starts a server. extraEnv overrides environment variables the way // the process would read them, e.g. "BASE_URL", "REGISTRATION_TYPE", // "CI_DOCKER_SOCKET". DATA_DIR, SSH_DISABLED and RATE_LIMIT_DISABLED are // always set. func newEnv(t *testing.T, extraEnv ...string) *env { t.Helper() for _, bin := range []string{"git", "ssh-keygen"} { if _, err := exec.LookPath(bin); err != nil { t.Skipf("%s not installed", bin) } } dataDir := t.TempDir() hs := httptest.NewUnstartedServer(nil) base := "http://" + hs.Listener.Addr().String() t.Setenv("DATA_DIR", dataDir) t.Setenv("SSH_DISABLED", "1") t.Setenv("RATE_LIMIT_DISABLED", "1") t.Setenv("BASE_URL", base) t.Setenv("CI_DOCKER_SOCKET", filepath.Join(dataDir, "no-such-socket")) for i := 0; i+1 < len(extraEnv); i += 2 { t.Setenv(extraEnv[i], extraEnv[i+1]) } cfg, err := config.Load() if err != nil { t.Fatal(err) } for _, dir := range []string{cfg.ReposDir(), cfg.AvatarsDir(), cfg.ReleasesDir(), cfg.CIArtifactsDir()} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } } database, err := db.Open(cfg.DBPath()) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) if _, err := database.InitAdmin(ctx, adminPass); err != nil { t.Fatal(err) } git := gitcmd.New(cfg) runner := ci.New(cfg, database) srv := &web.Server{ Cfg: cfg, DB: database, MD: markdown.New(), HL: highlight.New(cfg.InlineMaxBytes), CI: runner, Git: git, Patches: gitcmd.NewPatchCache(), } if err := srv.SyncRepos(ctx); err != nil { t.Fatal(err) } hs.Config.Handler = srv.Router() hs.Start() t.Cleanup(func() { hs.Close() cancel() database.Close() }) return &env{t: t, Srv: srv, DB: database, Cfg: cfg, Base: base, DataDir: dataDir, http: hs} } // session is one browser-like client with its own cookie jar. Redirects are // not followed, so tests can assert on them. type session struct { env *env client *http.Client } func (e *env) anon() *session { jar, _ := cookiejar.New(nil) return &session{env: e, client: &http.Client{ Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }} } // login signs in and fails the test when the credentials are rejected. func (e *env) login(username, password string) *session { e.t.Helper() s := e.anon() r := s.post("/login", url.Values{"username": {username}, "password": {password}}) if r.Code != http.StatusFound || r.Location() != "/" { e.t.Fatalf("login as %s: status %d, location %q, body %s", username, r.Code, r.Location(), r.Text("body")) } return s } // admin returns a session signed in as the admin. func (e *env) admin() *session { return e.login(db.AdminUsername, adminPass) } // register creates a user through the form and returns a signed-in session. func (e *env) register(username, password string) *session { e.t.Helper() s := e.anon() r := s.post("/register", url.Values{ "username": {username}, "password": {password}, "password2": {password}, }) if r.Code != http.StatusFound { e.t.Fatalf("register %s: status %d, body %s", username, r.Code, r.Text(".form-error")) } return s } // cookie returns the named cookie of the session, or nil. func (s *session) cookie(name string) *http.Cookie { u, _ := url.Parse(s.env.Base) for _, c := range s.client.Jar.Cookies(u) { if c.Name == name { return c } } return nil } // setCookie stores a cookie in the jar. func (s *session) setCookie(name, value string) { u, _ := url.Parse(s.env.Base) s.client.Jar.SetCookies(u, []*http.Cookie{{Name: name, Value: value, Path: "/"}}) } // response is a fully read HTTP response with lazy HTML parsing. type response struct { t *testing.T Code int Header http.Header Body []byte doc *goquery.Document } func (r *response) Location() string { return r.Header.Get("Location") } // Doc parses the body as HTML once. func (r *response) Doc() *goquery.Document { if r.doc == nil { d, err := goquery.NewDocumentFromReader(bytes.NewReader(r.Body)) if err != nil { r.t.Fatal(err) } r.doc = d } return r.doc } // Find selects elements by CSS selector. func (r *response) Find(sel string) *goquery.Selection { return r.Doc().Find(sel) } // Count returns how many elements match. func (r *response) Count(sel string) int { return r.Find(sel).Length() } // Has reports whether at least one element matches. func (r *response) Has(sel string) bool { return r.Count(sel) > 0 } // Text returns the trimmed text of the first match, or "" when none. func (r *response) Text(sel string) string { return strings.TrimSpace(r.Find(sel).First().Text()) } // Texts returns the trimmed text of every match. func (r *response) Texts(sel string) []string { var out []string r.Find(sel).Each(func(_ int, s *goquery.Selection) { out = append(out, strings.TrimSpace(s.Text())) }) return out } // Attr returns an attribute of the first match, or "" when none. func (r *response) Attr(sel, name string) string { v, _ := r.Find(sel).First().Attr(name) return v } // Value returns the value of the first matching input, or the selected // option of a select. func (r *response) Value(sel string) string { el := r.Find(sel).First() if goquery.NodeName(el) == "select" { v, _ := el.Find("option[selected]").First().Attr("value") if v == "" { v, _ = el.Find("option").First().Attr("value") } return v } if goquery.NodeName(el) == "textarea" { return el.Text() } v, _ := el.Attr("value") return v } // BodyString returns the raw body. func (r *response) BodyString() string { return string(r.Body) } // Contains reports whether the raw body contains s. func (r *response) Contains(s string) bool { return bytes.Contains(r.Body, []byte(s)) } func (s *session) do(req *http.Request) *response { s.env.t.Helper() res, err := s.client.Do(req) if err != nil { s.env.t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { s.env.t.Fatal(err) } return &response{t: s.env.t, Code: res.StatusCode, Header: res.Header, Body: body} } // get fetches a path. header pairs are optional extra request headers. func (s *session) get(path string, header ...string) *response { s.env.t.Helper() req, _ := http.NewRequest(http.MethodGet, s.env.Base+path, nil) for i := 0; i+1 < len(header); i += 2 { req.Header.Set(header[i], header[i+1]) } return s.do(req) } // post sends a urlencoded form. func (s *session) post(path string, form url.Values, header ...string) *response { s.env.t.Helper() req, _ := http.NewRequest(http.MethodPost, s.env.Base+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for i := 0; i+1 < len(header); i += 2 { req.Header.Set(header[i], header[i+1]) } return s.do(req) } // file is one upload part of a multipart form. type file struct { Field, Name string Content []byte } // postMultipart sends a multipart form with fields and files. Repeated // field values are sent as repeated parts. func (s *session) postMultipart(path string, fields url.Values, files ...file) *response { s.env.t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for k, vs := range fields { for _, v := range vs { _ = mw.WriteField(k, v) } } for _, f := range files { w, err := mw.CreateFormFile(f.Field, f.Name) if err != nil { s.env.t.Fatal(err) } _, _ = w.Write(f.Content) } mw.Close() req, _ := http.NewRequest(http.MethodPost, s.env.Base+path, &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) return s.do(req) } // follow GETs the Location of a redirect response. func (s *session) follow(r *response) *response { s.env.t.Helper() if r.Code < 300 || r.Code > 399 { s.env.t.Fatalf("expected redirect, got %d: %s", r.Code, r.BodyString()) } loc := r.Location() loc = strings.TrimPrefix(loc, s.env.Base) return s.get(loc) } // mustRedirect asserts a redirect to the given path prefix and returns the // location. func (r *response) mustRedirect(prefix string) string { r.t.Helper() if r.Code != http.StatusFound && r.Code != http.StatusSeeOther { r.t.Fatalf("status = %d, want redirect; body: %s", r.Code, firstLines(r.BodyString())) } if !strings.HasPrefix(r.Location(), prefix) { r.t.Fatalf("redirected to %q, want prefix %q", r.Location(), prefix) } return r.Location() } // mustStatus asserts the status code. func (r *response) mustStatus(code int) *response { r.t.Helper() if r.Code != code { r.t.Fatalf("status = %d, want %d; body: %s", r.Code, code, firstLines(r.BodyString())) } return r } func firstLines(s string) string { if len(s) > 400 { return s[:400] + "…" } return s } // ---------- git helpers ---------- // gitRun runs git in dir and fails the test on error. func gitRun(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } return strings.TrimSpace(string(out)) } // gitTry runs git and returns the error instead of failing. func gitTry(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0") out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } // repoPath is the bare repository on disk. func (e *env) repoPath(name string) string { return filepath.Join(e.Cfg.ReposDir(), name+".git") } // createRepo creates a repository through the admin form. func (e *env) createRepo(admin *session, name string, extra ...string) { e.t.Helper() form := url.Values{"name": {name}, "default_branch": {"main"}} for i := 0; i+1 < len(extra); i += 2 { form.Set(extra[i], extra[i+1]) } admin.post("/new", form).mustRedirect("/" + name) } // seedRepo commits the given files (path -> content) and pushes them to the // bare repo's main branch. It returns the new HEAD hash. Files are added on // top of the current main, so repeated calls stack commits. func (e *env) seedRepo(name string, files map[string]string, message ...string) string { e.t.Helper() if files == nil { files = map[string]string{ "README.md": "# " + name + "\n", "index.js": "console.log(\"hello\");\n", "logo.svg": `` + "\n", } } msg := "Initial commit" if len(message) > 0 { msg = message[0] } work := e.t.TempDir() gitRun(e.t, work, "clone", "-q", e.repoPath(name), ".") for p, content := range files { full := filepath.Join(work, p) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { e.t.Fatal(err) } if err := os.WriteFile(full, []byte(content), 0o644); err != nil { e.t.Fatal(err) } } gitRun(e.t, work, "add", "-A") gitRun(e.t, work, "commit", "-q", "-m", msg) gitRun(e.t, work, "push", "-q", "origin", "HEAD:main") e.Srv.Git.InvalidateRefCache(name) return gitRun(e.t, work, "rev-parse", "HEAD") } // seedBranch creates a branch from main with one extra commit and pushes it. func (e *env) seedBranch(name, branch string, files map[string]string) string { e.t.Helper() work := e.t.TempDir() gitRun(e.t, work, "clone", "-q", "-b", "main", e.repoPath(name), ".") gitRun(e.t, work, "checkout", "-q", "-b", branch) if files == nil { files = map[string]string{branch + ".txt": "on " + branch + "\n"} } for p, content := range files { full := filepath.Join(work, p) _ = os.MkdirAll(filepath.Dir(full), 0o755) if err := os.WriteFile(full, []byte(content), 0o644); err != nil { e.t.Fatal(err) } } gitRun(e.t, work, "add", "-A") gitRun(e.t, work, "commit", "-q", "-m", "Commit on "+branch) gitRun(e.t, work, "push", "-q", "origin", branch) e.Srv.Git.InvalidateRefCache(name) return gitRun(e.t, work, "rev-parse", "HEAD") } // headCommit returns the hash main points at. func (e *env) headCommit(name string) string { e.t.Helper() return gitRun(e.t, e.repoPath(name), "rev-parse", "main") } // authURL is the clone URL with admin Basic auth credentials. func (e *env) authURL(name string) string { u, _ := url.Parse(e.Base) u.User = url.UserPassword(db.AdminUsername, adminPass) return u.String() + "/" + name + ".git" } // ---------- misc ---------- // idFromPath returns the last numeric path segment, e.g. the issue number // of "/repo/issues/12". func idFromPath(t *testing.T, p string) string { t.Helper() p = strings.TrimRight(p, "/") i := strings.LastIndex(p, "/") if i < 0 || i == len(p)-1 { t.Fatalf("no id in %q", p) } return p[i+1:] } // contains reports whether any string in list contains sub. func contains(list []string, sub string) bool { for _, s := range list { if strings.Contains(s, sub) { return true } } return false }