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, "