8edadb5208260eff2e5321fba47e152f97967693 / internal/web/issues_test.go · 17968 bytes · raw
package web
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"git.josie-c.com/josie/simplegit/internal/db"
)
func postIssue(t *testing.T, client *http.Client, server string, owner, repo string, form url.Values) *http.Response {
t.Helper()
resp, err := client.PostForm(server+"/"+owner+"/"+repo+"/issues/new", form)
if err != nil {
t.Fatalf("POST issue: %v", err)
}
return resp
}
// noFollow stops the client at the redirect so tests can assert on 303s.
func noFollow(c *http.Client) *http.Client {
c.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return c
}
// The route table must register without a ServeMux conflict; New().Handler()
// panics if two issue patterns are mutually non-specific.
func TestIssueRoutesRegister(t *testing.T) {
httpServer, _, _ := newTestServer(t)
client := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, client, httpServer, "pub", "public")
for _, path := range []string{
"/josie/pub/issues",
"/josie/pub/issues/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 status = %d, want 200", path, resp.StatusCode)
}
}
}
func TestOwnerFilesPublishedIssue(t *testing.T) {
httpServer, database, _ := newTestServer(t)
client := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, client, httpServer, "pub", "public")
resp := postIssue(t, client, httpServer.URL, "josie", "pub", url.Values{
"title": {"hello"}, "body": {"**bold**"},
})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("owner POST issue status = %d, want 303", resp.StatusCode)
}
repo, err := db.GetRepoByName(database, "josie", "pub")
if err != nil {
t.Fatalf("GetRepoByName: %v", err)
}
issues, err := db.ListIssues(database, repo.ID, false, "")
if err != nil {
t.Fatalf("ListIssues: %v", err)
}
if len(issues) != 1 || issues[0].Pending {
t.Fatalf("issues = %+v, want one published issue", issues)
}
anonymous := &http.Client{}
view, err := anonymous.Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("anonymous GET issue: %v", err)
}
body := readAll(t, view)
if view.StatusCode != http.StatusOK {
t.Fatalf("anonymous issue status = %d, want 200", view.StatusCode)
}
if !strings.Contains(body, "opened by josie") || !strings.Contains(body, "owner") {
t.Errorf("issue page lacks owner attribution: %q", body)
}
if !strings.Contains(body, "<strong>bold</strong>") {
t.Errorf("issue body not rendered as markdown: %q", body)
}
}
// A guest filing the identical issue twice gets the success page twice but
// only one row is stored — no oracle for probing, no moderation pile-up.
func TestGuestIssueDuplicateSilentlyDeduped(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
guest := noFollow(&http.Client{})
form := url.Values{
"title": {"typo in readme"}, "body": {"please fix"}, "author_name": {"passer-by"},
}
first := postIssue(t, guest, httpServer.URL, "josie", "pub", form)
body := readAll(t, first)
if first.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
t.Fatalf("first submit status=%d body=%q", first.StatusCode, body)
}
second := postIssue(t, guest, httpServer.URL, "josie", "pub", form)
body = readAll(t, second)
if second.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
t.Fatalf("duplicate submit status=%d body=%q, want the same notice", second.StatusCode, body)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
issues, _ := db.ListIssues(database, repo.ID, true, "")
if len(issues) != 1 {
t.Errorf("issues = %d rows, want 1 after dedupe", len(issues))
}
}
// A guest claiming the repo owner's display name is stored as Anonymous, so
// an approved row can never read as the owner's.
func TestGuestCannotClaimOwnerName(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
guest := noFollow(&http.Client{})
resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
"title": {"hello"}, "body": {"world"}, "author_name": {"JoSie"},
})
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
t.Fatalf("guest submit status=%d body=%q", resp.StatusCode, body)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
issues, _ := db.ListIssues(database, repo.ID, true, "")
if len(issues) != 1 {
t.Fatalf("issues = %d rows, want 1", len(issues))
}
if issues[0].AuthorName != "Anonymous" {
t.Errorf("AuthorName = %q, want Anonymous for a guest claiming the owner name", issues[0].AuthorName)
}
}
// The shared guest_fields define must render on the anonymous new-issue
// form and on an issue page's comment form.
func TestGuestFieldsRender(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
resp, err := httpServer.Client().Get(httpServer.URL + "/josie/pub/issues/new")
if err != nil {
t.Fatalf("GET issues/new: %v", err)
}
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="author_name"`) || !strings.Contains(body, `name="author_email"`) {
t.Fatalf("anonymous new-issue form lacks the guest fieldset: status=%d body=%q", resp.StatusCode, body)
}
filed := postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{
"title": {"owner issue"}, "body": {"body"},
})
readAll(t, filed)
resp, err = httpServer.Client().Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("GET issue view: %v", err)
}
body = readAll(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="comment_name"`) {
t.Fatalf("anonymous issue view lacks the guest comment fieldset: status=%d body=%q", resp.StatusCode, body)
}
}
func TestGuestIssuePendingModeration(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
guest := noFollow(&http.Client{})
resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
"title": {"typo in readme"}, "body": {"please fix"}, "author_name": {"passer-by"},
})
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
t.Fatalf("guest submit status=%d body=%q, want a review notice", resp.StatusCode, body)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
issues, _ := db.ListIssues(database, repo.ID, true, "")
if len(issues) != 1 || !issues[0].Pending || issues[0].AuthorID.Valid {
t.Fatalf("issues = %+v, want one pending guest issue with NULL author_id", issues)
}
if issues[0].AuthorName != "passer-by" {
t.Errorf("AuthorName = %q, want passer-by", issues[0].AuthorName)
}
// Hidden from the public both in the list and by direct URL.
list, err := guest.Get(httpServer.URL + "/josie/pub/issues")
if err != nil {
t.Fatalf("anonymous list: %v", err)
}
if body := readAll(t, list); strings.Contains(body, "typo in readme") {
t.Error("pending issue leaked into the anonymous list")
}
direct, err := guest.Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("anonymous direct: %v", err)
}
readAll(t, direct)
if direct.StatusCode != http.StatusNotFound {
t.Errorf("anonymous pending issue status = %d, want 404", direct.StatusCode)
}
// The owner sees it and can approve it.
ownerList, err := owner.Get(httpServer.URL + "/josie/pub/issues")
if err != nil {
t.Fatalf("owner list: %v", err)
}
if body := readAll(t, ownerList); !strings.Contains(body, "typo in readme") || !strings.Contains(body, "awaiting review") {
t.Errorf("owner list lacks the pending issue: %q", body)
}
approve, err := owner.Post(httpServer.URL+"/josie/pub/issues/1/approve", "", nil)
if err != nil {
t.Fatalf("approve: %v", err)
}
readAll(t, approve)
if approve.StatusCode != http.StatusSeeOther {
t.Fatalf("approve status = %d, want 303", approve.StatusCode)
}
published, err := guest.Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("anonymous after approve: %v", err)
}
if body := readAll(t, published); published.StatusCode != http.StatusOK || !strings.Contains(body, "typo in readme") {
t.Errorf("approved issue not public: status=%d body=%q", published.StatusCode, body)
}
}
func TestPrivateRepoIssuesOwnerOnly(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "priv", "private")
addUser(t, database, "mallory", "pw")
// Anonymous on a private repo gets the sign-in redirect.
anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := anon.Get(httpServer.URL + "/josie/priv/issues")
if err != nil {
t.Fatalf("anonymous private issues: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("anonymous private issues = %d, want 404 (no existence oracle)", resp.StatusCode)
}
// A signed-in non-owner sees 404 and cannot file.
mallory := loginAs(t, httpServer, "mallory", "pw")
resp, err = mallory.Get(httpServer.URL + "/josie/priv/issues")
if err != nil {
t.Fatalf("mallory private issues: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("mallory private issues status = %d, want 404", resp.StatusCode)
}
resp = postIssue(t, mallory, httpServer.URL, "josie", "priv", url.Values{"title": {"x"}})
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("mallory file on private status = %d, want 404", resp.StatusCode)
}
}
func TestCloseReopenOwnerOnly(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
addUser(t, database, "mallory", "pw")
if resp := postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"t"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("owner issue status = %d", resp.StatusCode)
}
mallory := loginAs(t, httpServer, "mallory", "pw")
resp, err := mallory.Post(httpServer.URL+"/josie/pub/issues/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 status = %d, want 404", resp.StatusCode)
}
resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/close", "", nil)
if err != nil {
t.Fatalf("owner close: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("owner close status = %d, want 303", resp.StatusCode)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
issue, _ := db.GetIssueByNumber(database, repo.ID, 1)
if issue.State != "closed" || !issue.ClosedAt.Valid {
t.Errorf("issue after close = %+v, want closed with closed_at", issue)
}
}
func TestGuestCommentModeration(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"topic"}})
guest := noFollow(&http.Client{})
resp, err := guest.PostForm(httpServer.URL+"/josie/pub/issues/1/comments",
url.Values{"body": {"guest says hi"}, "author_name": {"anon"}})
if err != nil {
t.Fatalf("guest comment: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("guest comment status = %d, want 303", resp.StatusCode)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
issue, _ := db.GetIssueByNumber(database, repo.ID, 1)
// Guest comment is invisible to the public.
resp, err = guest.Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("guest view: %v", err)
}
if body := readAll(t, resp); strings.Contains(body, "guest says hi") {
t.Error("pending guest comment visible publicly")
}
comments, _ := db.ListComments(database, issue.ID, false)
if len(comments) != 0 {
t.Errorf("public comments = %d, want 0", len(comments))
}
// Owner sees it, approves it, and it becomes public.
resp, err = owner.Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("owner view: %v", err)
}
body := readAll(t, resp)
if !strings.Contains(body, "guest says hi") || !strings.Contains(body, "pending") {
t.Errorf("owner view lacks pending comment: %q", body)
}
comments, _ = db.ListComments(database, issue.ID, true)
if len(comments) != 1 {
t.Fatalf("owner comments = %d, want 1", len(comments))
}
resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/comments/"+strconv.FormatInt(comments[0].ID, 10)+"/approve", "", nil)
if err != nil {
t.Fatalf("approve comment: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("approve comment status = %d, want 303", resp.StatusCode)
}
resp, _ = guest.Get(httpServer.URL + "/josie/pub/issues/1")
if body := readAll(t, resp); !strings.Contains(body, "guest says hi") {
t.Error("approved comment still hidden")
}
}
func TestOwnerCommentHTMXFragment(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"topic"}})
req, err := http.NewRequest("POST", httpServer.URL+"/josie/pub/issues/1/comments",
strings.NewReader(url.Values{"body": {"a reply"}}.Encode()))
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
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 {
t.Fatalf("htmx comment status = %d, want 200", resp.StatusCode)
}
if !strings.Contains(body, `id="comment-`) {
t.Errorf("fragment lacks a comment article: %q", body)
}
if strings.Contains(body, "<html") {
t.Error("htmx response is a full page, want a fragment")
}
}
func TestIssueBodyEscapesHTML(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{
"title": {"xss"}, "body": {"<script>alert(1)</script>"},
})
resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/issues/1")
if err != nil {
t.Fatalf("GET issue: %v", err)
}
body := readAll(t, resp)
if strings.Contains(body, "<script>alert(1)</script>") {
t.Error("raw script survived markdown rendering")
}
if !strings.Contains(body, "<script>") {
t.Errorf("expected escaped script text: %q", body)
}
}
func TestGuestHoneypotDropsIssue(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
guest := noFollow(&http.Client{})
resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
"title": {"spam"}, "website": {"http://spam.example"},
})
readAll(t, resp)
repo, _ := db.GetRepoByName(database, "josie", "pub")
issues, _ := db.ListIssues(database, repo.ID, true, "")
if len(issues) != 0 {
t.Errorf("honeypot issue was stored: %+v", issues)
}
}
func TestIssueNumberNotFound(t *testing.T) {
httpServer, _, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
resp, err := owner.Get(httpServer.URL + "/josie/pub/issues/99")
if err != nil {
t.Fatalf("GET missing issue: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("missing issue status = %d, want 404", resp.StatusCode)
}
resp, err = owner.Get(httpServer.URL + "/josie/pub/issues/notanumber")
if err != nil {
t.Fatalf("GET bad issue number: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("bad issue number status = %d, want 404", resp.StatusCode)
}
}
func TestIssueDeletedOwnerOnly(t *testing.T) {
httpServer, database, _ := newTestServer(t)
owner := noFollow(newLoggedInClient(t, httpServer))
createRepo(t, owner, httpServer, "pub", "public")
addUser(t, database, "mallory", "pw")
postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"doomed"}})
mallory := loginAs(t, httpServer, "mallory", "pw")
resp, err := mallory.Post(httpServer.URL+"/josie/pub/issues/1/delete", "", nil)
if err != nil {
t.Fatalf("mallory delete: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("non-owner delete status = %d, want 404", resp.StatusCode)
}
resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/delete", "", nil)
if err != nil {
t.Fatalf("owner delete: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("owner delete status = %d, want 303", resp.StatusCode)
}
repo, _ := db.GetRepoByName(database, "josie", "pub")
if _, err := db.GetIssueByNumber(database, repo.ID, 1); !errors.Is(err, db.ErrNotFound) {
t.Errorf("issue after delete err = %v, want ErrNotFound", err)
}
}
func TestTruncateKeepsValidUTF8(t *testing.T) {
if got := truncate("é", 1); got != "" {
t.Errorf("truncate cut mid-rune: got %q, want empty", got)
}
if got := truncate("aé", 2); got != "a" {
t.Errorf("truncate = %q, want %q", got, "a")
}
if got := truncate("abc", 3); got != "abc" {
t.Errorf("truncate = %q, want %q", got, "abc")
}
}