1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "net/url"
7 "regexp"
8 "strconv"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18var reIssueLocation = regexp.MustCompile(`^/proj/issues/(\d+)$`)
19
20// The issue pages end to end: the owner opens, comments on, closes and
21// reopens an issue through the forms; an anonymous visitor reads them on a
22// public repository and cannot write; with issues off every page is a 404.
23func TestWebIssuePagesEndToEnd(t *testing.T) {
24 r := newMRRepo(t, domain.VisibilityPublic)
25 b := browser{r.harness}
26 token := r.signIn(t)
27
28 // Off by default: no page, no tab, for the owner as well.
29 assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30 assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31 assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="g i"`)
32
33 mode := domain.IssuesOwner
34 _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
35 require.NoError(t, err)
36
37 home := b.get("/proj", nil)
38 require.Equal(t, 200, home.status)
39 assert.Contains(t, home.body, `href="/proj/issues"`, "the Issues tab")
40
41 empty := b.get("/proj/issues", nil)
42 require.Equal(t, 200, empty.status, empty.body)
43 assert.Contains(t, empty.body, "no open issues")
44 assert.NotContains(t, empty.body, "New issue")
45
46 // The form is the owner's.
47 assert.Equal(t, 404, b.get("/proj/issues/new", nil).status)
48 form := b.get("/proj/issues/new", as(token))
49 require.Equal(t, 200, form.status)
50 assert.Contains(t, form.body, `action="/proj/issues"`)
51
52 // A POST without the session is refused.
53 anon := b.do("POST", "/proj/issues", map[string]string{"Origin": publicOrigin}, url.Values{"title": {"x"}}.Encode())
54 assert.Equal(t, 403, anon.status)
55
56 // Create.
57 created := b.form("/proj/issues", token, url.Values{"title": {"search is slow"}, "body": {"## Steps\n\nrun **find**"}})
58 require.Equal(t, 303, created.status, created.body)
59 assert.Equal(t, "no-store", created.hdr.Get("Cache-Control"))
60 m := reIssueLocation.FindStringSubmatch(created.hdr.Get("Location"))
61 require.Len(t, m, 2, created.hdr.Get("Location"))
62 path := "/proj/issues/" + m[1]
63
64 owner := b.get(path, as(token))
65 require.Equal(t, 200, owner.status, owner.body)
66 assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
67 assert.Contains(t, owner.body, "search is slow")
68 assert.Contains(t, owner.body, "<strong>find</strong>")
69 assert.Contains(t, owner.body, `action="`+path+`/comments"`)
70 assert.Contains(t, owner.body, `action="`+path+`/close"`)
71
72 // An invalid title is a state page, nothing is created.
73 bad := b.form("/proj/issues", token, url.Values{"title": {""}})
74 assert.Equal(t, 400, bad.status)
75
76 // Comment.
77 com := b.form(path+"/comments", token, url.Values{"text": {"reproduced\r\non main"}})
78 require.Equal(t, 303, com.status, com.body)
79 assert.Equal(t, path+"#c1", com.hdr.Get("Location"))
80 assert.Equal(t, 400, b.form(path+"/comments", token, url.Values{"text": {" "}}).status, "a blank comment")
81
82 // Anonymous reads a public repository: no forms, public cache headers.
83 pub := b.get(path, nil)
84 require.Equal(t, 200, pub.status)
85 assert.Equal(t, "no-cache", pub.hdr.Get("Cache-Control"))
86 assert.Contains(t, pub.body, "reproduced")
87 assert.NotContains(t, pub.body, `class="cform"`)
88 assert.NotContains(t, pub.body, path+"/close")
89 list := b.get("/proj/issues", nil)
90 assert.Contains(t, list.body, `href="`+path+`"`)
91 assert.Contains(t, list.body, "search is slow")
92 assert.Equal(t, 403, b.do("POST", path+"/close", map[string]string{"Origin": publicOrigin}, "").status)
93
94 // Close, then reopen.
95 closed := b.form(path+"/close", token, nil)
96 require.Equal(t, 303, closed.status, closed.body)
97 assert.Equal(t, path, closed.hdr.Get("Location"))
98 assert.Contains(t, b.get("/proj/issues?state=closed", nil).body, `href="`+path+`"`)
99 assert.NotContains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
100 again := b.get(path, as(token))
101 assert.Contains(t, again.body, `action="`+path+`/reopen"`)
102 assert.Equal(t, 409, b.form(path+"/close", token, nil).status, "closing a closed issue")
103
104 reopened := b.form(path+"/reopen", token, nil)
105 require.Equal(t, 303, reopened.status, reopened.body)
106 assert.Contains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
107
108 // The number is shared with the merge requests: #n goes to the right page.
109 mr, err := r.forge.MergeRequests.Create(r.ctx, domain.Root(), r.name("proj"), ports.CreateMergeRequestParams{
110 Title: "add search", Source: "feat/search", Target: "main",
111 })
112 require.NoError(t, err)
113 to := b.get("/proj/issues/"+itoa64(mr.Number), nil)
114 assert.Equal(t, 303, to.status)
115 assert.Equal(t, "/proj/merge-requests/"+itoa64(mr.Number), to.hdr.Get("Location"))
116 back := b.get("/proj/merge-requests/"+m[1], nil)
117 assert.Equal(t, 303, back.status)
118 assert.Equal(t, path, back.hdr.Get("Location"))
119 assert.Equal(t, 404, b.get("/proj/issues/999", nil).status)
120 assert.Equal(t, 404, b.get("/proj/merge-requests/999", nil).status)
121
122 // Private repository: invisible to an anonymous visitor.
123 priv := domain.VisibilityPrivate
124 _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
125 require.NoError(t, err)
126 assert.Equal(t, 404, b.get("/proj/issues", nil).status)
127 assert.Equal(t, 404, b.get(path, nil).status)
128 assert.Equal(t, 200, b.get(path, as(token)).status)
129
130 // Off again: 404 for everyone.
131 off := domain.IssuesOff
132 _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &off})
133 require.NoError(t, err)
134 assert.Equal(t, 404, b.get(path, as(token)).status)
135 assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
136 assert.Equal(t, 404, b.form(path+"/comments", token, url.Values{"text": {"x"}}).status)
137}
138
139func itoa64(n int64) string { return strconv.FormatInt(n, 10) }