josie / simplegit

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
}