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 "ResolveIssue": func(c *clients) error {
78 _, err := c.issue.ResolveIssue(ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: repo, Number: 1}))
79 return err
80 },
81 "CloseIssue": func(c *clients) error {
82 _, err := c.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: repo, Number: 1}))
83 return err
84 },
85 "ReopenIssue": func(c *clients) error {
86 _, err := c.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: repo, Number: 1}))
87 return err
88 },
89 }
90 return reads, writes
91 }
92 // setState puts issue 1 in the state a call needs to succeed.
93 setState := func(repo, call string) {
94 name := r.name(repo)
95 if call == "ReopenIssue" {
96 _, _ = r.forge.Issues.Close(ctx, domain.Root(), name, 1)
97 } else {
98 _, _ = r.forge.Issues.Reopen(ctx, domain.Root(), name, 1)
99 }
100 }
101
102 // Mode owner: the first issue of each repository is #1.
103 for _, repo := range []string{"pub", "priv"} {
104 r.mode(repo, domain.IssuesOwner)
105 _, err := r.forge.Issues.Create(ctx, domain.Root(), r.name(repo), ports.CreateIssueParams{Title: "first"})
106 require.NoError(t, err)
107 }
108
109 readWant := map[string]map[string]string{
110 "priv": {"anonymous": "not_found", "reader": "ok", "writer": "ok", "blind": "not_found", "branch-limited": "ok", "root": "ok"},
111 "pub": {"anonymous": "ok", "reader": "ok", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
112 }
113 writeWant := map[string]map[string]string{
114 // A token with issue:write alone sees the repository for the action,
115 // as mr:write does; a reader without the scope is refused.
116 "priv": {"anonymous": "not_found", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
117 "pub": {"anonymous": "unauthenticated", "reader": "permission_denied", "writer": "ok", "blind": "ok", "branch-limited": "ok", "root": "ok"},
118 }
119 for _, repo := range []string{"priv", "pub"} {
120 reads, writes := calls(repo)
121 for _, w := range all {
122 for name, call := range reads {
123 assert.Equal(t, readWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
124 }
125 for name, call := range writes {
126 setState(repo, name)
127 assert.Equal(t, writeWant[repo][w.name], codeName(call(w.c)), "%s %s %s", repo, w.name, name)
128 }
129 }
130 }
131
132 // Issues off, an unknown repository: not found for everyone, root included.
133 r.mode("priv", domain.IssuesOff)
134 r.mode("pub", domain.IssuesOff)
135 for _, repo := range []string{"priv", "pub", "nope"} {
136 reads, writes := calls(repo)
137 for _, w := range all {
138 for name, call := range reads {
139 assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
140 }
141 for name, call := range writes {
142 assert.Equal(t, "not_found", codeName(call(w.c)), "%s %s %s", repo, w.name, name)
143 }
144 }
145 }
146}
147
148func TestIssueLifecycleAndSharedNumbersOverTheAPI(t *testing.T) {
149 r := newMRRig(t, domain.VisibilityPublic)
150 ctx := r.ctx
151 _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
152 require.NoError(t, err)
153
154 mr, err := r.create(r.root)
155 require.NoError(t, err)
156 assert.EqualValues(t, 1, mr.GetNumber())
157
158 created, err := r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: "Crash", Body: "it crashes"}))
159 require.NoError(t, err)
160 issue := created.Msg.GetIssue()
161 assert.EqualValues(t, 2, issue.GetNumber(), "numbers are shared with merge requests")
162 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issue.GetState())
163 assert.Equal(t, "root", issue.GetAuthor())
164
165 // An issue is not a merge request, and the other way round.
166 _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 2}))
167 assert.Equal(t, "not_found", codeName(err))
168 _, err = r.root.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 1}))
169 assert.Equal(t, "not_found", codeName(err))
170
171 _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: "seen"}))
172 require.NoError(t, err)
173 _, err = r.root.issue.AddIssueComment(ctx, connect.NewRequest(&pb.AddIssueCommentRequest{Repo: "proj", Number: 2, Text: " "}))
174 assert.Equal(t, "invalid_argument", codeName(err), "a blank comment")
175 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: ""}))
176 assert.Equal(t, "invalid_argument", codeName(err), "an empty title")
177
178 _, err = r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
179 assert.Equal(t, "failed_precondition", codeName(err), "reopening an open issue")
180 closed, err := r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
181 require.NoError(t, err)
182 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, closed.Msg.GetIssue().GetState())
183 _, err = r.root.issue.CloseIssue(ctx, connect.NewRequest(&pb.CloseIssueRequest{Repo: "proj", Number: 2}))
184 assert.Equal(t, "failed_precondition", codeName(err), "closing a closed issue")
185
186 got, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: 2}))
187 require.NoError(t, err)
188 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, got.Msg.GetIssue().GetState())
189 assert.EqualValues(t, 1, got.Msg.GetIssue().GetCommentCount())
190 require.Len(t, got.Msg.GetComments(), 1)
191 assert.Equal(t, "seen", got.Msg.GetComments()[0].GetText())
192
193 reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 2}))
194 require.NoError(t, err)
195 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
196
197 list, err := r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_OPEN}))
198 require.NoError(t, err)
199 require.Len(t, list.Msg.GetIssues(), 1)
200 list, err = r.anon.issue.ListIssues(ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "proj", State: pb.IssueState_ISSUE_STATE_CLOSED}))
201 require.NoError(t, err)
202 assert.Empty(t, list.Msg.GetIssues())
203}
204
205func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
206 r := newIssueRig(t)
207 ctx := r.ctx
208 ver, err := r.anon.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
209 require.NoError(t, err)
210 assert.Contains(t, ver.Msg.GetFeatures(), "issues")
211 assert.Contains(t, ver.Msg.GetFeatures(), "issues.resolve")
212
213 update := func(c *clients, mode pb.IssuesMode) (*pb.Repo, error) {
214 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
215 Repo: "priv", UpdateMask: &fieldmaskpb.FieldMask{}, Issues: &mode,
216 }))
217 if err != nil {
218 return nil, err
219 }
220 return res.Msg.GetRepo(), nil
221 }
222 repo, err := r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
223 require.NoError(t, err)
224 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, repo.Msg.GetRepo().GetSettings().GetIssues())
225
226 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
227 require.NoError(t, err)
228 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
229 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
230 require.NoError(t, err)
231
232 updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
233 require.NoError(t, err, "public issues are accepted")
234 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
235 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
236 require.NoError(t, err)
237 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
238 assert.Equal(t, "invalid_argument", codeName(err))
239 repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
240 require.NoError(t, err)
241 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, repo.Msg.GetRepo().GetSettings().GetIssues())
242
243 _, err = update(r.writer, pb.IssuesMode_ISSUES_MODE_OFF)
244 assert.Equal(t, "permission_denied", codeName(err), "repo:admin is needed")
245 _, err = update(r.anon, pb.IssuesMode_ISSUES_MODE_OFF)
246 assert.Equal(t, "not_found", codeName(err), "anonymous cannot see the private repository")
247 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OFF)
248 require.NoError(t, err)
249}
250
251func TestIssueReferencesAndClosedByOverTheAPI(t *testing.T) {
252 r := newMRRig(t, domain.VisibilityPublic)
253 ctx := r.ctx
254 _, err := r.forge.Repos.Update(ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: new(domain.IssuesOwner)})
255 require.NoError(t, err)
256 for _, title := range []string{"Crash", "Slow"} {
257 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "proj", Title: title}))
258 require.NoError(t, err)
259 }
260 // The merge request mentions #1 (closing) and #2 (plain).
261 created, err := r.merger.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
262 Repo: "proj", Title: "Change a", Description: "Fixes #1, see also #2", SourceBranch: "feat",
263 }))
264 require.NoError(t, err)
265 number := created.Msg.GetMergeRequest().GetNumber()
266
267 get := func(n int64) *pb.GetIssueResponse {
268 res, err := r.anon.issue.GetIssue(ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "proj", Number: n}))
269 require.NoError(t, err)
270 return res.Msg
271 }
272 one := get(1)
273 require.Len(t, one.GetReferences(), 1)
274 ref := one.GetReferences()[0]
275 assert.Equal(t, number, ref.GetNumber())
276 assert.Equal(t, "Change a", ref.GetTitle())
277 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, ref.GetState())
278 assert.True(t, ref.GetCloses())
279 two := get(2)
280 require.Len(t, two.GetReferences(), 1)
281 assert.False(t, two.GetReferences()[0].GetCloses())
282 assert.Zero(t, one.GetIssue().GetClosedByMergeRequest())
283
284 watch := r.watch(r.anon, "", "repo:proj")
285 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: number, HeadId: r.head}))
286 require.NoError(t, err)
287 r.forge.MergeRequests.Wait(ctx)
288 var streamed *pb.IssueResolved
289 for range 8 {
290 event := watch.next(t, watchWait)
291 if streamed = event.GetIssueResolved(); streamed != nil {
292 break
293 }
294 }
295 require.NotNil(t, streamed, "merge publishes issue_resolved")
296 assert.EqualValues(t, 1, streamed.GetIssue().GetNumber())
297 assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, streamed.GetIssue().GetState())
298
299 one = get(1)
300 assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, one.GetIssue().GetState())
301 assert.Equal(t, number, one.GetIssue().GetClosedByMergeRequest())
302 require.Len(t, one.GetEvents(), 2)
303 referenced, resolved := one.GetEvents()[0], one.GetEvents()[1]
304 assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, referenced.GetKind())
305 assert.True(t, referenced.GetCloses())
306 assert.Equal(t, "Change a", referenced.GetMergeRequestTitle())
307 assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, resolved.GetKind())
308 assert.Equal(t, number, resolved.GetMergeRequest())
309 assert.NotEmpty(t, resolved.GetActor())
310 assert.Equal(t, referenced.GetActor(), resolved.GetActor(), "same token opened and merged")
311 detail, err := r.anon.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: number}))
312 require.NoError(t, err)
313 require.Len(t, detail.Msg.GetIssues(), 2)
314 assert.EqualValues(t, 1, detail.Msg.GetIssues()[0].GetNumber())
315 assert.Equal(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_RESOLVED, detail.Msg.GetIssues()[0].GetState())
316 assert.True(t, detail.Msg.GetIssues()[0].GetCloses())
317 require.Len(t, detail.Msg.GetIssueEvents(), 3)
318
319 require.Len(t, one.GetReferences(), 1)
320 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, one.GetReferences()[0].GetState())
321 two = get(2)
322 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, two.GetIssue().GetState(), "a plain mention does not close")
323 assert.Zero(t, two.GetIssue().GetClosedByMergeRequest())
324
325 // Reopening by hand clears "closed by".
326 reopened, err := r.root.issue.ReopenIssue(ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "proj", Number: 1}))
327 require.NoError(t, err)
328 assert.Zero(t, reopened.Msg.GetIssue().GetClosedByMergeRequest())
329}
330
331func TestIssueResolveRoundTripOverTheAPI(t *testing.T) {
332 r := newIssueRig(t)
333 r.mode("pub", domain.IssuesOwner)
334 _, err := r.root.issue.CreateIssue(r.ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "pub", Title: "Fix"}))
335 require.NoError(t, err)
336 resolved, err := r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
337 require.NoError(t, err)
338 assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, resolved.Msg.GetIssue().GetState())
339 assert.Zero(t, resolved.Msg.GetIssue().GetClosedByMergeRequest())
340 _, err = r.root.issue.ResolveIssue(r.ctx, connect.NewRequest(&pb.ResolveIssueRequest{Repo: "pub", Number: 1}))
341 assert.Equal(t, "failed_precondition", codeName(err))
342 list, err := r.anon.issue.ListIssues(r.ctx, connect.NewRequest(&pb.ListIssuesRequest{Repo: "pub", State: pb.IssueState_ISSUE_STATE_RESOLVED}))
343 require.NoError(t, err)
344 require.Len(t, list.Msg.GetIssues(), 1)
345 reopened, err := r.root.issue.ReopenIssue(r.ctx, connect.NewRequest(&pb.ReopenIssueRequest{Repo: "pub", Number: 1}))
346 require.NoError(t, err)
347 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, reopened.Msg.GetIssue().GetState())
348 d, err := r.anon.issue.GetIssue(r.ctx, connect.NewRequest(&pb.GetIssueRequest{Repo: "pub", Number: 1}))
349 require.NoError(t, err)
350 require.Len(t, d.Msg.GetEvents(), 2)
351 assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, d.Msg.GetEvents()[0].GetKind())
352 assert.Equal(t, "root", d.Msg.GetEvents()[0].GetActor())
353 assert.Equal(t, pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED, d.Msg.GetEvents()[1].GetKind())
354}