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, "bold") { 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, "alert(1)"}, }) 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, "") { 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") } }