8edadb5208260eff2e5321fba47e152f97967693 / internal/web/pulls_test.go · 13017 bytes · raw
package web
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.josie-c.com/josie/simplegit/internal/db"
)
func writeWork(t *testing.T, work, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(work, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
// cloneRepo clones ownerAuth'd repo {name} for pushing test branches.
func cloneRepo(t *testing.T, httpServer *httptest.Server, name string) string {
t.Helper()
u := mustParse(t, httpServer.URL)
repoURL := "http://josie:hunter2@" + u.Host + "/josie/" + name + ".git"
work := filepath.Join(t.TempDir(), "work")
runGit(t, "", "-c", "credential.helper=", "clone", "-q", repoURL, work)
runGit(t, work, "config", "user.email", "t@t")
runGit(t, work, "config", "user.name", "t")
return work
}
func TestPullRoutesRegister(t *testing.T) {
httpServer, _, _ := newTestServer(t)
client := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, client, httpServer, "pub", "public")
for _, path := range []string{"/josie/pub/pulls", "/josie/pub/pulls/new"} {
resp, err := client.Get(httpServer.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
}
}
}
func TestOwnerPullOpenViewMergeFastForward(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "base.txt", "base\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "base")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
runGit(t, work, "checkout", "-qb", "feature")
writeWork(t, work, "feature.txt", "feature\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "feature")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Add feature"}, "body": {"the why"}})
if err != nil {
t.Fatalf("open PR: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("open PR status = %d, want 303", resp.StatusCode)
}
anon := noFollow(&http.Client{})
view, err := anon.Get(httpServer.URL + "/josie/pub/pulls/1")
if err != nil {
t.Fatalf("GET PR: %v", err)
}
body := readAll(t, view)
if view.StatusCode != http.StatusOK || !strings.Contains(body, "Add feature") || !strings.Contains(body, "feature.txt") {
t.Fatalf("PR view status=%d body=%q", view.StatusCode, body)
}
merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
if err != nil {
t.Fatalf("merge: %v", err)
}
readAll(t, merge)
if merge.StatusCode != http.StatusSeeOther {
t.Fatalf("merge status = %d, want 303", merge.StatusCode)
}
pull := pullByNumber(t, database, "pub", 1)
if pull.State != "merged" || pull.MergeCommit == "" {
t.Errorf("pull after merge = %+v, want merged with commit", pull)
}
bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
mainSHA := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
featureSHA := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/feature"))
if mainSHA != featureSHA {
t.Errorf("main = %s, want fast-forwarded to feature %s", mainSHA, featureSHA)
}
}
func TestGuestPullPendingModeration(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "base.txt", "base\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "base")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
runGit(t, work, "checkout", "-qb", "feature")
writeWork(t, work, "feature.txt", "feature\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "feature")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
guest := noFollow(&http.Client{})
resp, err := guest.PostForm(httpServer.URL+"/josie/pub/pulls/new",
url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Guest idea"}, "author_name": {"anon"}})
if err != nil {
t.Fatalf("guest open PR: %v", err)
}
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
t.Fatalf("guest PR status=%d body=%q, want review notice", resp.StatusCode, body)
}
list, _ := guest.Get(httpServer.URL + "/josie/pub/pulls")
if body := readAll(t, list); strings.Contains(body, "Guest idea") {
t.Error("pending PR leaked to anonymous list")
}
direct, _ := guest.Get(httpServer.URL + "/josie/pub/pulls/1")
readAll(t, direct)
if direct.StatusCode != http.StatusNotFound {
t.Errorf("anonymous pending PR = %d, want 404", direct.StatusCode)
}
resp, err = owner.Post(httpServer.URL+"/josie/pub/pulls/1/approve", "", nil)
if err != nil {
t.Fatalf("approve: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("approve status = %d, want 303", resp.StatusCode)
}
published, _ := guest.Get(httpServer.URL + "/josie/pub/pulls/1")
if body := readAll(t, published); published.StatusCode != http.StatusOK || !strings.Contains(body, "Guest idea") {
t.Errorf("approved PR not public: %d %q", published.StatusCode, body)
}
}
func TestPullMergeConflictRefused(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "conflict.txt", "original\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "base")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
runGit(t, work, "checkout", "-qb", "feature")
writeWork(t, work, "conflict.txt", "feature\n")
runGit(t, work, "commit", "-aqm", "feature")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
runGit(t, work, "checkout", "-q", "main")
writeWork(t, work, "conflict.txt", "main\n")
runGit(t, work, "commit", "-aqm", "main edit")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Conflicting"}})
if err != nil {
t.Fatalf("open PR: %v", err)
}
readAll(t, resp)
bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
before := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
if err != nil {
t.Fatalf("merge: %v", err)
}
body := readAll(t, merge)
if merge.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "merge conflict") {
t.Fatalf("conflict merge status=%d body=%q", merge.StatusCode, body)
}
after := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
if after != before {
t.Errorf("main moved on conflict: %s -> %s", before, after)
}
pull := pullByNumber(t, database, "pub", 1)
if pull.State != "open" {
t.Errorf("pull state after conflict = %q, want open", pull.State)
}
}
// Merging branches with no common ancestor must be refused with a readable
// 422, not an internal error.
func TestPullMergeUnrelatedHistoriesRefused(t *testing.T) {
httpServer, database, dataDir := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
runGit(t, work, "checkout", "-q", "--orphan", "lonely")
writeWork(t, work, "b.txt", "b\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "b")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/lonely")
resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
url.Values{"base": {"main"}, "head": {"lonely"}, "title": {"Unrelated"}})
if err != nil {
t.Fatalf("open PR: %v", err)
}
readAll(t, resp)
bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
before := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
if err != nil {
t.Fatalf("merge: %v", err)
}
body := readAll(t, merge)
if merge.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "unrelated histories") {
t.Fatalf("unrelated merge status=%d body=%q", merge.StatusCode, body)
}
after := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
if after != before {
t.Errorf("main moved on unrelated merge: %s -> %s", before, after)
}
pull := pullByNumber(t, database, "pub", 1)
if pull.State != "open" {
t.Errorf("pull state after refused merge = %q, want open", pull.State)
}
}
func TestPullCloseReopenOwnerOnly(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
runGit(t, work, "checkout", "-qb", "feature")
writeWork(t, work, "b.txt", "b\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "b")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
owner.PostForm(httpServer.URL+"/josie/pub/pulls/new", url.Values{"base": {"main"}, "head": {"feature"}, "title": {"t"}})
addUser(t, database, "mallory", "pw")
mallory := noFollow(loginAs(t, httpServer, "mallory", "pw"))
resp, err := mallory.Post(httpServer.URL+"/josie/pub/pulls/1/close", "", nil)
if err != nil {
t.Fatalf("mallory close: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("non-owner close = %d, want 404", resp.StatusCode)
}
resp, err = owner.Post(httpServer.URL+"/josie/pub/pulls/1/close", "", nil)
if err != nil {
t.Fatalf("owner close: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("owner close = %d, want 303", resp.StatusCode)
}
}
func TestPullOwnerCommentHTMX(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
work := cloneRepo(t, httpServer, "pub")
writeWork(t, work, "a.txt", "a\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "a")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
runGit(t, work, "checkout", "-qb", "feature")
writeWork(t, work, "b.txt", "b\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-qm", "b")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
owner.PostForm(httpServer.URL+"/josie/pub/pulls/new", url.Values{"base": {"main"}, "head": {"feature"}, "title": {"t"}})
req, _ := http.NewRequest("POST", httpServer.URL+"/josie/pub/pulls/1/comments",
strings.NewReader(url.Values{"body": {"looks good"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := owner.Do(req)
if err != nil {
t.Fatalf("htmx comment: %v", err)
}
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="comment-`) || strings.Contains(body, "<html") {
t.Errorf("pull htmx fragment status=%d body=%q", resp.StatusCode, body)
}
}
// ---- helpers ----
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse URL %s: %v", raw, err)
}
return u
}
func runGitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func pullByNumber(t *testing.T, database *sql.DB, repoName string, number int64) db.Pull {
t.Helper()
repo, err := db.GetRepoByName(database, "josie", repoName)
if err != nil {
t.Fatalf("GetRepoByName: %v", err)
}
pull, err := db.GetPullByNumber(database, repo.ID, number)
if err != nil {
t.Fatalf("GetPullByNumber: %v", err)
}
return pull
}