1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "testing"
7
8 "connectrpc.com/connect"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11 "google.golang.org/protobuf/types/known/fieldmaskpb"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// issueRig is a harness with the repositories pub and priv, and one caller per
19// kind of principal.
20type issueRig struct {
21 *harness
22 anon, root, reader, writer, blind, branch *clients
23}
24
25func newIssueRig(t *testing.T) *issueRig {
26 t.Helper()
27 h := newHarness(t, harnessOpts{})
28 r := &issueRig{harness: h}
29 h.seed("pub", domain.VisibilityPublic)
30 h.seed("priv", domain.VisibilityPrivate)
31 r.anon, r.root = h.api(""), h.api(adminToken)
32 // reader has no issue:write; writer reads and writes; blind holds only
33 // issue:write; branch is writer limited to branch prefixes.
34 r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
35 r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeIssueWrite)))
36 r.blind = h.api(h.token(scopes(domain.ScopeIssueWrite)))
37 secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeIssueWrite), []string{"agent/"})
38 r.branch = h.api(secret)
39 return r
40}
41
42func (r *issueRig) mode(repo string, mode domain.IssuesMode) {
43 r.t.Helper()
44 _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name(repo), ports.UpdateRepoParams{Issues: &mode})
45 require.NoError(r.t, err)
46}
47
48func TestIssueAccessMatrixOverTheAPI(t *testing.T) {
49 r := newIssueRig(t)
50 ctx := r.ctx
51 type who struct {
52 name string
53 c *clients
54 }
55 all := []who{{"anonymous", r.anon}, {"reader", r.reader}, {"writer", r.writer}, {"blind", r.blind}, {"branch-limited", r.branch}, {"root", r.root}}
56
57 calls := func(repo string) (reads, writes map[string]func(c *clients) error) {
58 reads = map[string]func(c *clients) error{
59 "ListIssues": func(c *clients) error {
60 _, err := c.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: repo}))
61 return err
62 },
63 "GetIssue": func(c *clients) error {
64 _, err := c.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: repo, Number: 1}))
65 return err
66 },
67 }
68 writes = map[string]func(c *clients) error{
69 "CreateIssue": func(c *clients) error {
70 _, err := c.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: repo, Title: "t", Body: "b"}))
71 return err
72 },
73 "AddIssueComment": func(c *clients) error {
74 _, err := c.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: repo, Number: 1, Text: "x"}))
75 return err
76 },
77 "CloseIssue": func(c *clients) error {
78 _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
79 return err
80 },
81 "ReopenIssue": func(c *clients) error {
82 _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
83 return err
84 },
85 }
86 return reads, writes
87 }
88 // setState puts issue 1 in the state a call needs to succeed.
89 setState := func(repo, call string) {
90 name := r.name(repo)
91 if call == "ReopenIssue" {
92 _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
93 } else {
94 _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
95 }
96 }
97
98 // Mode owner: the first issue of each repository is #1.
99 for _, repo := range []string{"pub", "priv"} {
100 r.mode(repo, domain.IssuesOwner)
101 _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
102 require.NoError(t, err)
103 }
104
105 readWant := map[string]map[string]string{
106 "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
107 "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
108 }
109 writeWant := map[string]map[string]string{
110 // A token with issue:write alone sees the repository for the action,
111 // as mr:write does; a reader without the scope is refused.
112 "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
113 "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
114 }
115 for _, repo := range []string{"priv", "pub"} {
116 reads, writes := calls(repo)
117 for _, w := range all {
118 for name, call := range reads {
119 assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
120 }
121 for name, call := range writes {
122 setState(repo, name)
123 assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124 }
125 }
126 }
127
128 // Issues off, an unknown repository: not found for everyone, root included.
129 r.mode("priv", domain.IssuesOff)
130 r.mode("pub", domain.IssuesOff)
131 for _, repo := range []string{"priv", "pub", "nope"} {
132 reads, writes := calls(repo)
133 for _, w := range all {
134 for name, call := range reads {
135 assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
136 }
137 for name, call := range writes {
138 assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
139 }
140 }
141 }
142}
143
144func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
145 r := newMRRig(t, domain.VisibilityPublic)
146 ctx := r.ctx
147 _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
148 require.NoError(t, err)
149
150 mr, err := r.create(r.root)
151 require.NoError(t, err)
152 assert.EqualValues(t, 1, mr.GetNumber())
153
154 created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
155 require.NoError(t, err)
156 issue := created.Msg.GetIssue()
157 assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
158 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
159 assert.Equal(t, "root", issue.GetAuthor())
160
161 // An issue is not a merge request, and the other way round.
162 _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
163 assert.Equal(t, "not_found", codeName(err))
164 _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
165 assert.Equal(t, "not_found", codeName(err))
166
167 _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
168 require.NoError(t, err)
169 _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
170 assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
171 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
172 assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
173
174 _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
175 assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
176 closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
177 require.NoError(t, err)
178 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
179 _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
180 assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
181
182 got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
183 require.NoError(t, err)
184 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
185 assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
186 require.Len(t, got.Msg.GetComments(), 1)
187 assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
188
189 reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
190 require.NoError(t, err)
191 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
192
193 list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
194 require.NoError(t, err)
195 require.Len(t, list.Msg.GetIssues(), 1)
196 list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
197 require.NoError(t, err)
198 assert.Empty(t, list.Msg.GetIssues())
199}
200
201func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
202 r := newIssueRig(t)
203 ctx := r.ctx
204 ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
205 require.NoError(t, err)
206 assert.Contains(t, ver.Msg.GetFeatures(), "issues")
207
208 update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
209 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
210 Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
211 }))
212 if err != nil {
213 return nil, err
214 }
215 return res.Msg.GetRepo(), nil
216 }
217 repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
218 require.NoError(t, err)
219 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
220
221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222 require.NoError(t, err)
223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
225 require.NoError(t, err)
226
227 updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
228 require.NoError(t, err, "public issues are accepted")
229 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
230 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
231 require.NoError(t, err)
232 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
233 assert.Equal(t, "invalid_argument", codeName(err))
234 repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
235 require.NoError(t, err)
236 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
237
238 _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
239 assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
240 _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
241 assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
242 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
243 require.NoError(t, err)
244}
245
246func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
247 r := newMRRig(t, domain.VisibilityPublic)
248 ctx := r.ctx
249 _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
250 require.NoError(t, err)
251 for _, title := range []string{"Crash", "Slow"} {
252 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
253 require.NoError(t, err)
254 }
255 // The merge request mentions #1 (closing) and #2 (plain).
256 created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
257 Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
258 }))
259 require.NoError(t, err)
260 number := created.Msg.GetMergeRequest().GetNumber()
261
262 get := func(n int64) *pb.GetIssueResponse {
263 res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
264 require.NoError(t, err)
265 return res.Msg
266 }
267 one := get(1)
268 require.Len(t, one.GetReferences(), 1)
269 ref := one.GetReferences()[0]
270 assert.Equal(t, number, ref.GetNumber())
271 assert.Equal(t, "Change a", ref.GetTitle())
272 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
273 assert.True(t, ref.GetCloses())
274 two := get(2)
275 require.Len(t, two.GetReferences(), 1)
276 assert.False(t, two.GetReferences()[0].GetCloses())
277 assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
278
279 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
280 require.NoError(t, err)
281 r.forge.MergeRequests.Wait(ctx)
282
283 one = get(1)
284 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, one.GetIssue().GetState())
285 assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
286 require.Len(t, one.GetReferences(), 1)
287 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
288 two = get(2)
289 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
290 assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
291
292 // Reopening by hand clears "closed by".
293 reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
294 require.NoError(t, err)
295 assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
296}