1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "connectrpc.com/connect"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 "google.golang.org/genproto/googleapis/rpc/errdetails"
16 "google.golang.org/protobuf/types/known/fieldmaskpb"
17
18 pb "bnema.dev/ermite-api/gen/ermite"
19 "bnema.dev/ermite/internal/domain"
20)
21
22// mrRig is a harness with a repository holding main and a branch feat, and one
23// caller per kind of principal.
24type mrRig struct {
25 *harness
26 wt string
27 head string
28 anon, root, reader, writer *clients
29 merger, noScope, agent *clients
30}
31
32func newMRRig(t *testing.T, vis domain.Visibility) *mrRig {
33 t.Helper()
34 h := newHarness(t, harnessOpts{})
35 r := &mrRig{harness: h}
36 r.seed("proj", vis, "a.txt")
37 r.wt = h.workTree("main")
38 h.mustGit(r.wt, "fetch", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
39 h.mustGit(r.wt, "reset", "-q", "--hard", "FETCH_HEAD")
40 h.mustGit(r.wt, "checkout", "-q", "-b", "feat")
41 h.writeFile(r.wt, "a.txt", []byte("changed\n"))
42 h.writeFile(r.wt, "b.txt", []byte("bee\n"))
43 h.mustGit(r.wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "feat")
44 r.head = h.bareGit("proj", "rev-parse", "feat")
45
46 r.anon, r.root = h.api(""), h.api(adminToken)
47 r.reader = h.api(h.token(scopes(domain.ScopeRepoRead)))
48 r.writer = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite)))
49 r.merger = h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge)))
50 // noScope holds neither repo:read nor an mr scope; agent is the agent preset.
51 r.noScope = h.api(h.token(scopes(domain.ScopeRepoWrite)))
52 r.agent = h.api(h.token(scopes(domain.ScopeRepoWrite, domain.ScopeMRWrite)))
53 return r
54}
55
56func (r *mrRig) create(c *clients) (*pb.MergeRequest, error) {
57 res, err := c.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
58 Repo: "proj", Title: "Change a", Description: "why", SourceBranch: "feat",
59 }))
60 if err != nil {
61 return nil, err
62 }
63 return res.Msg.GetMergeRequest(), nil
64}
65
66// AC2 for merge requests: who may read and who may write.
67func TestMergeRequestAccessOverTheAPI(t *testing.T) {
68 r := newMRRig(t, domain.VisibilityPrivate)
69 ctx := r.ctx
70 mr, err := r.create(r.writer)
71 require.NoError(t, err)
72 assert.EqualValues(t, 1, mr.GetNumber())
73 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, mr.GetState())
74 assert.Equal(t, "e2e", mr.GetAuthor(), "the label of the token")
75 assert.Equal(t, "main", mr.GetTargetBranch())
76 assert.Equal(t, r.head, mr.GetHeadId())
77
78 reads := map[string]func(c *clients) error{
79 "ListMergeRequests": func(c *clients) error {
80 _, err := c.mr.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "proj"}))
81 return err
82 },
83 "GetMergeRequest": func(c *clients) error {
84 _, err := c.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 1}))
85 return err
86 },
87 }
88 // A private repository: invisible without repo:read, never "forbidden".
89 for name, call := range reads {
90 assert.Equal(t, "not_found", codeName(call(r.anon)), name+" anonymous")
91 assert.Equal(t, "not_found", codeName(call(r.noScope)), name+" without repo:read")
92 assert.Equal(t, "ok", codeName(call(r.reader)), name+" reader")
93 assert.Equal(t, "ok", codeName(call(r.root)), name+" root")
94 }
95 writes := map[string]func(c *clients) error{
96 "CreateMergeRequest": func(c *clients) error {
97 _, err := c.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "t", SourceBranch: "nope"}))
98 return err
99 },
100 "AddComment": func(c *clients) error {
101 _, err := c.mr.AddComment(ctx, connect.NewRequest(&pb.AddCommentRequest{Repo: "proj", Number: 1, Text: "x"}))
102 return err
103 },
104 "ResolveComment": func(c *clients) error {
105 _, err := c.mr.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{Repo: "proj", Number: 1, CommentId: "1", Resolved: true}))
106 return err
107 },
108 "MarkRead": func(c *clients) error {
109 _, err := c.mr.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{Repo: "proj", Number: 1}))
110 return err
111 },
112 "MergeMergeRequest": func(c *clients) error {
113 _, err := c.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: r.head}))
114 return err
115 },
116 "CloseMergeRequest": func(c *clients) error {
117 _, err := c.mr.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: 99}))
118 return err
119 },
120 }
121 for name, call := range writes {
122 assert.Equal(t, "not_found", codeName(call(r.anon)), name+" anonymous")
123 assert.Equal(t, "not_found", codeName(call(r.noScope)), name+" without repo:read")
124 assert.Equal(t, "permission_denied", codeName(call(r.reader)), name+" reader")
125 if name == "MergeMergeRequest" {
126 assert.Equal(t, "permission_denied", codeName(call(r.writer)), "mr:write does not merge")
127 }
128 }
129
130 // GO-001: the agent preset has no repo:read, yet mr:write is enough to write
131 // merge requests on the private repository; it still does not read them nor merge.
132 assert.Equal(t, "invalid_argument", codeName(writes["CreateMergeRequest"](r.agent)), "create sees the repository: the branch is what is wrong")
133 assert.Equal(t, "ok", codeName(writes["AddComment"](r.agent)), "AddComment agent")
134 assert.Equal(t, "ok", codeName(writes["MarkRead"](r.agent)), "MarkRead agent")
135 assert.Equal(t, "not_found", codeName(writes["MergeMergeRequest"](r.agent)), "mr:merge is not held: the repository stays hidden")
136 for name, call := range reads {
137 assert.Equal(t, "not_found", codeName(call(r.agent)), name+" agent: repo:write does not read")
138 }
139
140 // Public: anonymous reads, and writing asks for credentials.
141 h2 := newMRRig(t, domain.VisibilityPublic)
142 assert.Equal(t, "ok", codeName(reads["ListMergeRequests"](h2.anon)), "public list")
143 _, err = h2.create(h2.anon)
144 assert.Equal(t, "unauthenticated", codeName(err))
145 _, err = h2.create(h2.reader)
146 assert.Equal(t, "permission_denied", codeName(err))
147 _, err = h2.create(h2.writer)
148 require.NoError(t, err)
149 _, err = h2.anon.mr.GetMergeRequest(h2.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 1}))
150 assert.NoError(t, err)
151
152 // Bad names and numbers.
153 _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "Proj", Number: 1}))
154 assert.Equal(t, "invalid_argument", codeName(err))
155 _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 0}))
156 assert.Equal(t, "not_found", codeName(err))
157 _, err = r.root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "ghost", Number: 1}))
158 assert.Equal(t, "not_found", codeName(err))
159}
160
161// AC3: a request that breaks a rule is rejected whole, and the error carries a
162// BadRequest detail naming the fields.
163func TestMergeRequestRulesCarryABadRequestDetail(t *testing.T) {
164 r := newMRRig(t, domain.VisibilityPublic)
165 _, err := r.root.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
166 Repo: "proj", Title: "two\nlines", Description: "bidi \u202e", SourceBranch: "feat", TargetBranch: "feat",
167 }))
168 require.Error(t, err)
169 assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
170 var ce *connect.Error
171 require.ErrorAs(t, err, &ce)
172 require.Len(t, ce.Details(), 1)
173 v, err := ce.Details()[0].Value()
174 require.NoError(t, err)
175 br, ok := v.(*errdetails.BadRequest)
176 require.True(t, ok, "%T", v)
177 fields := map[string]string{}
178 for _, f := range br.GetFieldViolations() {
179 fields[f.GetField()] = f.GetDescription()
180 }
181 assert.Contains(t, fields, "title")
182 assert.Contains(t, fields, "description")
183 assert.Contains(t, fields, "target_branch")
184
185 // Nothing was created.
186 res, err := r.root.mr.ListMergeRequests(r.ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "proj"}))
187 require.NoError(t, err)
188 assert.Empty(t, res.Msg.GetMergeRequests())
189
190 // Other bad requests.
191 _, err = r.root.mr.ListMergeRequests(r.ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "proj", State: 9}))
192 assert.Equal(t, "invalid_argument", codeName(err))
193 _, err = r.root.mr.ListMergeRequests(r.ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "proj", PageToken: "x"}))
194 assert.Equal(t, "invalid_argument", codeName(err))
195 _, err = r.root.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: "abc"}))
196 assert.Equal(t, "invalid_argument", codeName(err), "a short id is not a commit id")
197 _, err = r.root.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1}))
198 assert.Equal(t, "invalid_argument", codeName(err), "head_id is required")
199}
200
201func TestMergeRequestLifecycleOverTheAPI(t *testing.T) {
202 r := newMRRig(t, domain.VisibilityPublic)
203 ctx := r.ctx
204 mr, err := r.create(r.writer)
205 require.NoError(t, err)
206
207 _, err = r.create(r.writer)
208 assert.Equal(t, "already_exists", codeName(err))
209
210 // A comment on a line of the diff, a general one, and a wrong line.
211 c1, err := r.writer.mr.AddComment(ctx, connect.NewRequest(&pb.AddCommentRequest{
212 Repo: "proj", Number: 1, Path: "b.txt", Line: 1, Side: pb.DiffSide_DIFF_SIDE_NEW, Text: "nice",
213 }))
214 require.NoError(t, err)
215 assert.Equal(t, "1", c1.Msg.GetComment().GetId())
216 assert.Equal(t, r.head, c1.Msg.GetComment().GetCommitId())
217 _, err = r.writer.mr.AddComment(ctx, connect.NewRequest(&pb.AddCommentRequest{Repo: "proj", Number: 1, Text: "general"}))
218 require.NoError(t, err)
219 _, err = r.writer.mr.AddComment(ctx, connect.NewRequest(&pb.AddCommentRequest{
220 Repo: "proj", Number: 1, Path: "b.txt", Line: 7, Side: pb.DiffSide_DIFF_SIDE_NEW, Text: "no such line",
221 }))
222 assert.Equal(t, "invalid_argument", codeName(err))
223
224 // Resolve, idempotent; an id nobody issued is not found.
225 for range 2 {
226 res, err := r.writer.mr.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{Repo: "proj", Number: 1, CommentId: "1", Resolved: true}))
227 require.NoError(t, err)
228 assert.True(t, res.Msg.GetComment().GetResolved())
229 }
230 for _, id := range []string{"99", "0", "x", "01", ""} {
231 _, err = r.writer.mr.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{Repo: "proj", Number: 1, CommentId: id, Resolved: true}))
232 assert.Equal(t, "not_found", codeName(err), "comment %q", id)
233 }
234
235 // Get, anonymously: the commits, the diff and the comments.
236 got, err := r.anon.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 1}))
237 require.NoError(t, err)
238 m := got.Msg
239 assert.Equal(t, r.head, m.GetMergeRequest().GetHeadId())
240 assert.Len(t, m.GetCommits(), 2)
241 assert.False(t, m.GetCommitsTruncated())
242 assert.NotEmpty(t, m.GetDiffBaseId())
243 assert.EqualValues(t, 2, m.GetStats().GetFilesChanged())
244 assert.Len(t, m.GetDiff().GetFiles(), 2)
245 require.Len(t, m.GetComments(), 2)
246 assert.Equal(t, "1", m.GetComments()[0].GetId())
247 assert.True(t, m.GetComments()[0].GetResolved())
248 assert.Empty(t, m.GetLastReadId())
249
250 // MarkRead, then since_last_read after a push.
251 _, err = r.writer.mr.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{Repo: "proj", Number: 1}))
252 require.NoError(t, err)
253 r.writeFile(r.wt, "c.txt", []byte("see\n"))
254 r.mustGit(r.wt, "push", "-q", r.urlAuth(adminToken, "/proj.git"), "feat")
255 got, err = r.reader.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "proj", Number: 1, SinceLastRead: true}))
256 require.NoError(t, err)
257 assert.Equal(t, r.head, got.Msg.GetLastReadId())
258 assert.Equal(t, r.head, got.Msg.GetDiffBaseId())
259 require.Len(t, got.Msg.GetDiff().GetFiles(), 1)
260 assert.Equal(t, "c.txt", got.Msg.GetDiff().GetFiles()[0].GetNewPath())
261 assert.False(t, got.Msg.GetLastReadLost())
262
263 // The old head is refused: the branch moved.
264 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: r.head}))
265 assert.Equal(t, "failed_precondition", codeName(err))
266 newHead := r.bareGit("proj", "rev-parse", "feat")
267 merged, err := r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
268 Repo: "proj", Number: 1, Mode: pb.MergeMode_MERGE_MODE_FAST_FORWARD, HeadId: newHead,
269 }))
270 require.NoError(t, err)
271 mm := merged.Msg.GetMergeRequest()
272 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, mm.GetState())
273 assert.Equal(t, newHead, mm.GetMergedCommitId())
274 assert.Equal(t, pb.MergeMode_MERGE_MODE_FAST_FORWARD, mm.GetMergeMode())
275 assert.Equal(t, newHead, r.bareGit("proj", "rev-parse", "main"))
276
277 // Listing by state; repeating a merge or closing a merged request fails.
278 list := func(st pb.MergeRequestState) int {
279 res, err := r.anon.mr.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "proj", State: st}))
280 require.NoError(t, err)
281 return len(res.Msg.GetMergeRequests())
282 }
283 assert.Equal(t, 1, list(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED))
284 assert.Equal(t, 1, list(pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED))
285 assert.Equal(t, 0, list(pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN))
286 _, err = r.merger.mr.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: newHead}))
287 assert.Equal(t, "failed_precondition", codeName(err))
288 _, err = r.writer.mr.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: 1}))
289 assert.Equal(t, "failed_precondition", codeName(err))
290 _ = mr
291}
292
293func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
294 h := newHarness(t, harnessOpts{})
295 h.seed("proj", domain.VisibilityPublic)
296 root := h.api(adminToken)
297 ctx := h.ctx
298
299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300 require.NoError(t, err)
301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303
304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
306 Repo: "proj", Settings: s, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
307 }))
308 if err != nil {
309 return nil, err
310 }
311 return res.Msg.GetRepo(), nil
312 }
313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{
314 AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: false,
315 })
316 require.NoError(t, err)
317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318 assert.True(t, repo.GetSettings().GetFailingChecksBlock(), "not named in the mask: unchanged")
319
320 repo, err = update(root, []string{"settings.failing_checks_block"}, &pb.RepoSettings{FailingChecksBlock: false})
321 require.NoError(t, err)
322 assert.False(t, repo.GetSettings().GetFailingChecksBlock())
323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")
324
325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{
326 AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: true,
327 })
328 require.NoError(t, err)
329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")
330 assert.True(t, repo.GetSettings().GetFailingChecksBlock())
331
332 for name, s := range map[string]*pb.RepoSettings{
333 "no modes": {},
334 "unspecified": {AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED}},
335 "unknown mode": {AllowedMergeModes: []pb.MergeMode{9}},
336 } {
337 _, err = update(root, []string{"settings.allowed_merge_modes"}, s)
338 assert.Equal(t, "invalid_argument", codeName(err), name)
339 }
340 _, err = update(root, []string{"settings.nope"}, &pb.RepoSettings{})
341 assert.Equal(t, "invalid_argument", codeName(err))
342
343 // repo:admin is needed.
344 _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.failing_checks_block"}, &pb.RepoSettings{})
345 assert.Equal(t, "permission_denied", codeName(err))
346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
347 require.NoError(t, err)
348 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
349}
350
351// AC4 over the API: a mode the settings do not allow is refused.
352func TestMergeModeNotAllowedOverTheAPI(t *testing.T) {
353 r := newMRRig(t, domain.VisibilityPublic)
354 _, err := r.create(r.writer)
355 require.NoError(t, err)
356 _, err = r.root.repo.UpdateRepo(r.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
357 Repo: "proj", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}},
358 Settings: &pb.RepoSettings{AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD}},
359 }))
360 require.NoError(t, err)
361 _, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
362 Repo: "proj", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: r.head,
363 }))
364 assert.Equal(t, "failed_precondition", codeName(err))
365 assert.Equal(t, r.bareGit("proj", "rev-parse", "main"), r.bareGit("proj", "rev-parse", "feat~2"), "main did not move")
366}
367
368// delete_source_branch reaches the merge and the response says what happened;
369// deleting the default branch is a failed precondition.
370func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) {
371 r := newMRRig(t, domain.VisibilityPublic)
372 _, err := r.create(r.writer)
373 require.NoError(t, err)
374 res, err := r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
375 Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,
376 }))
377 require.NoError(t, err)
378 assert.True(t, res.Msg.GetSourceBranchDeleted())
379 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
380
381 // The default branch as a source: refused before anything changes.
382 r.bareGit("proj", "branch", "old", "main~1")
383 main := r.bareGit("proj", "rev-parse", "main")
384 _, err = r.root.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
385 Repo: "proj", Title: "back", SourceBranch: "main", TargetBranch: "old",
386 }))
387 require.NoError(t, err)
388 _, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
389 Repo: "proj", Number: 2, HeadId: main, DeleteSourceBranch: true,
390 }))
391 assert.Equal(t, "failed_precondition", codeName(err))
392 assert.Equal(t, main, r.bareGit("proj", "rev-parse", "main"))
393 assert.NotEqual(t, main, r.bareGit("proj", "rev-parse", "old"), "not merged")
394}
395
396// The signing key needs no authentication, whatever the credentials, and keeps
397// its shape.
398func TestGetSigningKey(t *testing.T) {
399 h := newHarness(t, harnessOpts{})
400 var first *pb.GetSigningKeyResponse
401 for _, who := range []string{"anonymous", "root", "bad token"} {
402 c := map[string]*clients{
403 "anonymous": h.api(""), "root": h.api(adminToken),
404 "bad token": newDisposableClients(h.url("/-/api"), "ermite_"+strings.Repeat("x", 43)),
405 }[who]
406 res, err := c.server.GetSigningKey(h.ctx, connect.NewRequest(&pb.GetSigningKeyRequest{}))
407 require.NoError(t, err, who)
408 k := res.Msg
409 assert.True(t, strings.HasPrefix(k.GetPublicKey(), "ssh-ed25519 "), who)
410 assert.True(t, strings.HasPrefix(k.GetFingerprint(), "SHA256:"), who)
411 assert.Equal(t, "ermite", k.GetCommitterName())
412 assert.Equal(t, "ermite@example.test", k.GetCommitterEmail(), "the host of ERMITE_PUBLIC_URL")
413 assert.Equal(t, k.GetCommitterEmail()+" "+k.GetPublicKey(), k.GetAllowedSigners())
414 if first == nil {
415 first = k
416 }
417 assert.Equal(t, first.GetPublicKey(), k.GetPublicKey(), "one key per instance")
418 }
419 // The private seed is nowhere in the logs.
420 assert.NotContains(t, h.logs.String(), "seed")
421 assert.Contains(t, h.logs.String(), first.GetFingerprint(), "the fingerprint is logged when the key is ready")
422}
423
424// AC5: the squash commit verifies with `git verify-commit`, against an
425// allowed_signers file built from what the server says about its key.
426func TestSquashCommitVerifiesWithGit(t *testing.T) {
427 if _, err := exec.LookPath("ssh-keygen"); err != nil {
428 if os.Getenv("CI") != "" {
429 t.Fatal("ssh-keygen not found: CI must have it (package openssh)")
430 }
431 t.Skip("ssh-keygen not found: install openssh to verify the signed commits")
432 }
433 r := newMRRig(t, domain.VisibilityPublic)
434 _, err := r.create(r.writer)
435 require.NoError(t, err)
436 merged, err := r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
437 Repo: "proj", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: r.head,
438 }))
439 require.NoError(t, err)
440 id := merged.Msg.GetMergeRequest().GetMergedCommitId()
441 require.NotEqual(t, r.head, id)
442
443 key, err := r.anon.server.GetSigningKey(r.ctx, connect.NewRequest(&pb.GetSigningKeyRequest{}))
444 require.NoError(t, err)
445 signers := filepath.Join(t.TempDir(), "allowed_signers")
446 require.NoError(t, os.WriteFile(signers, []byte(key.Msg.GetAllowedSigners()+"\n"), 0o600))
447 out, err := r.gitEnv(nil, r.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", id)
448 require.NoError(t, err, out)
449 assert.Contains(t, out, "Good \"git\" signature for ermite@example.test")
450 assert.Equal(t, "ermite <ermite@example.test>", r.bareGit("proj", "log", "-1", "--format=%cn <%ce>", id))
451 assert.Equal(t, "Change a (!1)", r.bareGit("proj", "log", "-1", "--format=%s", id))
452}
453
454// A private repository stays invisible in the event stream: merge request
455// events reach the callers that may read it, and nobody else.
456func TestMergeRequestEventsAreFilteredByVisibility(t *testing.T) {
457 r := newMRRig(t, domain.VisibilityPrivate)
458 anon := r.watch(r.anon, "", "home")
459 reader := r.watch(r.reader, "", "home")
460 noScope := r.watch(r.noScope, "", "home")
461
462 _, err := r.create(r.writer)
463 require.NoError(t, err)
464 _, err = r.writer.mr.AddComment(r.ctx, connect.NewRequest(&pb.AddCommentRequest{Repo: "proj", Number: 1, Text: "hello"}))
465 require.NoError(t, err)
466
467 opened := reader.next(t, watchWait)
468 require.NotNil(t, opened.GetMergeRequestOpened(), "%v", opened)
469 assert.EqualValues(t, 1, opened.GetMergeRequestOpened().GetMergeRequest().GetNumber())
470 assert.Equal(t, "proj", opened.GetMergeRequestOpened().GetMergeRequest().GetRepo())
471 added := reader.next(t, watchWait).GetCommentAdded()
472 require.NotNil(t, added)
473 assert.Equal(t, "proj", added.GetRepo())
474 assert.EqualValues(t, 1, added.GetNumber())
475 assert.Equal(t, "hello", added.GetComment().GetText())
476
477 // Merging publishes merged, then pushed.
478 _, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: r.head}))
479 require.NoError(t, err)
480 assert.NotNil(t, reader.next(t, watchWait).GetMergeRequestMerged())
481 pushed := reader.next(t, watchWait).GetPushed()
482 require.NotNil(t, pushed)
483 assert.Equal(t, "refs/heads/main", pushed.GetRefUpdates()[0].GetRef())
484 assert.Equal(t, r.head, pushed.GetRefUpdates()[0].GetNewId())
485
486 // A second request, closed.
487 r.mustGit(r.wt, "checkout", "-q", "-b", "two")
488 r.writeFile(r.wt, "two.txt", []byte("2\n"))
489 r.mustGit(r.wt, "push", "-q", r.urlAuth(adminToken, "/proj.git"), "two")
490 reader.next(t, watchWait) // the push of "two"
491 _, err = r.writer.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "two", SourceBranch: "two"}))
492 require.NoError(t, err)
493 assert.NotNil(t, reader.next(t, watchWait).GetMergeRequestOpened())
494 _, err = r.writer.mr.CloseMergeRequest(r.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: 2}))
495 require.NoError(t, err)
496 closed := reader.next(t, watchWait).GetMergeRequestClosed()
497 require.NotNil(t, closed)
498 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, closed.GetMergeRequest().GetState())
499
500 // The anonymous and unscoped watchers saw none of it: the sentinel, a
501 // public repository, is the first thing they get.
502 r.sentinel(t, anon, "s-anon")
503 r.sentinel2(t, noScope, "s-none")
504}
505
506// sentinel2 checks the next event of a watcher is the creation of a public
507// repository, which the sentinel call before it already made.
508func (r *mrRig) sentinel2(t *testing.T, s *stream, name string) {
509 t.Helper()
510 ev := s.next(t, watchWait)
511 assert.Equal(t, "s-anon", ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel %s: %v", name, ev)
512}
513
514// A topic of a private repository is refused to those who cannot read it, so
515// merge request events cannot be followed that way either.
516func TestPrivateRepositoryTopicIsNotFound(t *testing.T) {
517 r := newMRRig(t, domain.VisibilityPrivate)
518 assert.Equal(t, "not_found", codeName(watchErr(r.ctx, r.anon, "repo:proj")))
519 assert.Equal(t, "not_found", codeName(watchErr(r.ctx, r.noScope, "repo:proj")))
520}