package httpd
import (
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitbay.org/gitbay/internal/config"
"gitbay.org/gitbay/internal/store"
"gitbay.org/gitbay/internal/web"
)
// A heading precedes the issue's comment thread, matching the merge
// request page, so a screen-reader user skimming by heading has a
// landmark before the first comment rather than falling straight from
// the edit box into the body (#271).
func TestIssuePageHasDiscussionHeading(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "issue.html", struct {
repoPage
Issue store.Issue
BodyHTML template.HTML
Comments []renderedComment
CanEdit bool
CanWrite bool
Milestones []store.Milestone
Notice string
LabelColors map[string]template.CSS
Draft *draft
}{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "
Discussion
") {
t.Error("no Discussion heading")
}
}
// sessionCookieFor gives uid a real web session, the way canWriteRepo's
// call to s.viewer(r) needs (internal/httpd/accounts.go:37-47), since
// issueCreateForm gates the milestone/assignee fields on it rather than
// on the handler's own user parameter.
func sessionCookieFor(t *testing.T, s *Server, st *store.Store, uid int64) *http.Cookie {
t.Helper()
tok, hash, err := store.NewToken()
if err != nil {
t.Fatal(err)
}
if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
t.Fatal(err)
}
return s.sessionCookieFor(tok)
}
// The new-issue form takes milestone and assignee, resolved on the same
// issue create dispatch as the title and labels, so a typo in either
// creates nothing and the label/milestone/assign code paths still run
// notifications and events (#271).
func TestIssueCreateFormHasMilestoneAndAssigneeForWriter(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
u := store.User{ID: uid, Username: "alice"}
if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Web.Mode = "accounts"
s := New(cfg, st, nil)
req := httptest.NewRequest("GET", "/alice/app/issues/new", nil)
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
req.AddCookie(sessionCookieFor(t, s, st, uid))
rr := httptest.NewRecorder()
s.issueCreateForm(rr, req, u)
body := rr.Body.String()
if !strings.Contains(body, `name="labels"`) {
t.Error("no labels field for a writer")
}
if !strings.Contains(body, `name="milestone"`) {
t.Error("no milestone field for a writer")
}
if !strings.Contains(body, `name="assignee"`) {
t.Error("no assignee field for a writer")
}
}
// A reader (no write access) sees no milestone/assignee fields, and can
// still create an issue with title and body alone.
func TestIssueCreateFormHidesMilestoneAndAssigneeForReader(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
ownerID, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
readerID, err := st.CreateUser("bob", false)
if err != nil {
t.Fatal(err)
}
reader := store.User{ID: readerID, Username: "bob"}
if _, err := st.CreateRepo("user", ownerID, "app", "public"); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Web.Mode = "accounts"
s := New(cfg, st, nil)
req := httptest.NewRequest("GET", "/alice/app/issues/new", nil)
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
req.AddCookie(sessionCookieFor(t, s, st, readerID))
rr := httptest.NewRecorder()
s.issueCreateForm(rr, req, reader)
body := rr.Body.String()
if strings.Contains(body, `name="labels"`) {
t.Error("reader should not see a labels field")
}
if strings.Contains(body, `name="milestone"`) {
t.Error("reader should not see a milestone field")
}
if strings.Contains(body, `name="assignee"`) {
t.Error("reader should not see an assignee field")
}
form := url.Values{"title": {"a bug"}, "body": {"steps"}}
submit := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
submit.Header.Set("Content-Type", "application/x-www-form-urlencoded")
submit.SetPathValue("owner", "alice")
submit.SetPathValue("repo", "app")
rr2 := httptest.NewRecorder()
s.issueCreateSubmit(rr2, submit, reader)
if rr2.Code != http.StatusSeeOther {
t.Fatalf("reader create: status %d, body %q", rr2.Code, rr2.Body.String())
}
repo, err := st.RepoByPath("alice/app")
if err != nil {
t.Fatal(err)
}
issue, err := st.IssueByNumber(repo.ID, 1)
if err != nil {
t.Fatalf("issue not created: %v", err)
}
if issue.Title != "a bug" {
t.Fatalf("got title %q", issue.Title)
}
}
// A reader's hand-crafted POST carrying a labels value still creates a
// plain issue: issue create requires write access for --label, so
// issueCreateSubmit drops labels/milestone/assignee from the argv for a
// non-writer rather than sending them and failing the whole create.
func TestIssueCreateSubmitReaderLabelIsDropped(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
ownerID, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
readerID, err := st.CreateUser("bob", false)
if err != nil {
t.Fatal(err)
}
reader := store.User{ID: readerID, Username: "bob"}
if _, err := st.CreateRepo("user", ownerID, "app", "public"); err != nil {
t.Fatal(err)
}
repo, err := st.RepoByPath("alice/app")
if err != nil {
t.Fatal(err)
}
s := New(config.Default(), st, nil)
form := url.Values{
"title": {"a bug"},
"body": {"steps"},
"labels": {"bug"},
}
req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
rr := httptest.NewRecorder()
s.issueCreateSubmit(rr, req, reader)
if rr.Code != http.StatusSeeOther {
t.Fatalf("reader create: status %d, body %q", rr.Code, rr.Body.String())
}
issue, err := st.IssueByNumber(repo.ID, 1)
if err != nil {
t.Fatalf("issue not created: %v", err)
}
if len(issue.Labels) != 0 {
t.Errorf("labels = %v, want none", issue.Labels)
}
}
// A writer creates an issue with a milestone and an assignee in one
// request; both land on the issue because they go through the same
// dispatch as the create.
func TestIssueCreateSubmitSetsMilestoneAndAssignee(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
u := store.User{ID: uid, Username: "alice"}
if _, err := st.CreateUser("bob", false); err != nil {
t.Fatal(err)
}
if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
t.Fatal(err)
}
repo, err := st.RepoByPath("alice/app")
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateMilestone(repo, "v1", "", ""); err != nil {
t.Fatal(err)
}
s := New(config.Default(), st, nil)
form := url.Values{
"title": {"needs a fix"},
"body": {"details"},
"milestone": {"v1"},
"assignee": {"bob"},
}
req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
rr := httptest.NewRecorder()
s.issueCreateSubmit(rr, req, u)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status %d, body %q", rr.Code, rr.Body.String())
}
issue, err := st.IssueByNumber(repo.ID, 1)
if err != nil {
t.Fatalf("issue not created: %v", err)
}
if issue.Milestone != "v1" {
t.Errorf("milestone = %q, want v1", issue.Milestone)
}
if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
t.Errorf("assignees = %v, want [bob]", issue.Assignees)
}
}
// Preview carries the milestone and assignee back into the form, the same
// way it already carries title and labels, so a writer previewing the
// body does not lose what they picked (#271).
func TestIssueCreateFormPreviewKeepsMilestoneAndAssignee(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
u := store.User{ID: uid, Username: "alice"}
if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Web.Mode = "accounts"
s := New(cfg, st, nil)
form := url.Values{
"title": {"a bug"},
"body": {"**steps**"},
"milestone": {"v1"},
"assignee": {"bob"},
"preview": {"1"},
}
req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
req.AddCookie(sessionCookieFor(t, s, st, uid))
rr := httptest.NewRecorder()
s.issueCreateSubmit(rr, req, u)
body := rr.Body.String()
if !strings.Contains(body, `value="v1"`) {
t.Errorf("preview lost the milestone:\n%s", body)
}
if !strings.Contains(body, `value="bob"`) {
t.Errorf("preview lost the assignee:\n%s", body)
}
}
// A refused create — here a bad milestone — re-renders the new-issue form
// with the draft and a notice, rather than an http.Error page that drops
// everything the visitor typed (#271).
func TestIssueCreateSubmitRefusedKeepsDraft(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
u := store.User{ID: uid, Username: "alice"}
if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
t.Fatal(err)
}
repo, err := st.RepoByPath("alice/app")
if err != nil {
t.Fatal(err)
}
s := New(config.Default(), st, nil)
form := url.Values{
"title": {"needs a fix"},
"body": {"details"},
"milestone": {"no-such-milestone"},
}
req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
rr := httptest.NewRecorder()
s.issueCreateSubmit(rr, req, u)
if rr.Code == http.StatusSeeOther {
t.Fatalf("expected a failure status, got redirect")
}
body := rr.Body.String()
if !strings.Contains(body, `value="needs a fix"`) {
t.Errorf("refused create lost the title:\n%s", body)
}
if !strings.Contains(body, "details") {
t.Errorf("refused create lost the body:\n%s", body)
}
if !strings.Contains(body, `class="error"`) {
t.Errorf("refused create has no notice:\n%s", body)
}
if _, err := st.IssueByNumber(repo.ID, 1); err == nil {
t.Fatal("issue was created despite the bad milestone")
}
}
// A bad assignee creates nothing: issue create resolves the assignee
// before writing the issue, so a typo leaves the repo without a
// half-created issue (#271).
func TestIssueCreateSubmitBadAssigneeCreatesNothing(t *testing.T) {
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
u := store.User{ID: uid, Username: "alice"}
if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
t.Fatal(err)
}
repo, err := st.RepoByPath("alice/app")
if err != nil {
t.Fatal(err)
}
s := New(config.Default(), st, nil)
form := url.Values{
"title": {"needs a fix"},
"body": {"details"},
"assignee": {"nobody-such-user"},
}
req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("owner", "alice")
req.SetPathValue("repo", "app")
rr := httptest.NewRecorder()
s.issueCreateSubmit(rr, req, u)
if rr.Code == http.StatusSeeOther {
t.Fatalf("expected a failure status, got redirect")
}
if !strings.Contains(rr.Body.String(), "nobody-such-user") {
t.Errorf("error body %q does not name the bad assignee", rr.Body.String())
}
if _, err := st.IssueByNumber(repo.ID, 1); err == nil {
t.Fatal("issue was created despite the bad assignee")
}
}