josie / simplegit

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, "&lt;script&gt;") {
		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")
	}
}