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, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302 assert.False(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
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}, AllowAutoMerge: false,
315 })
316 require.NoError(t, err)
317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318 assert.False(t, repo.GetSettings().GetAllowAutoMerge(), "not named in the mask: unchanged")
319
320 repo, err = update(root, []string{"settings.allow_auto_merge"}, &pb.RepoSettings{AllowAutoMerge: false})
321 require.NoError(t, err)
322 assert.False(t, repo.GetSettings().GetAllowAutoMerge())
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}, AllowAutoMerge: 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().GetAllowAutoMerge())
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.allow_auto_merge"}, &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().GetAllowAutoMerge())
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(), "requested")
379 r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response
380 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
381
382 // The default branch as a source: refused before anything changes.
383 r.bareGit("proj", "branch", "old", "main~1")
384 main := r.bareGit("proj", "rev-parse", "main")
385 _, err = r.root.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
386 Repo: "proj", Title: "back", SourceBranch: "main", TargetBranch: "old",
387 }))
388 require.NoError(t, err)
389 _, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
390 Repo: "proj", Number: 2, HeadId: main, DeleteSourceBranch: true,
391 }))
392 assert.Equal(t, "failed_precondition", codeName(err))
393 assert.Equal(t, main, r.bareGit("proj", "rev-parse", "main"))
394 assert.NotEqual(t, main, r.bareGit("proj", "rev-parse", "old"), "not merged")
395}
396
397// The signing key needs no authentication, whatever the credentials, and keeps
398// its shape.
399func TestGetSigningKey(t *testing.T) {
400 h := newHarness(t, harnessOpts{})
401 var first *pb.GetSigningKeyResponse
402 for _, who := range []string{"anonymous", "root", "bad token"} {
403 c := map[string]*clients{
404 "anonymous": h.api(""), "root": h.api(adminToken),
405 "bad token": newDisposableClients(h.url("/-/api"), "ermite_"+strings.Repeat("x", 43)),
406 }[who]
407 res, err := c.server.GetSigningKey(h.ctx, connect.NewRequest(&pb.GetSigningKeyRequest{}))
408 require.NoError(t, err, who)
409 k := res.Msg
410 assert.True(t, strings.HasPrefix(k.GetPublicKey(), "ssh-ed25519 "), who)
411 assert.True(t, strings.HasPrefix(k.GetFingerprint(), "SHA256:"), who)
412 assert.Equal(t, "ermite", k.GetCommitterName())
413 assert.Equal(t, "ermite@example.test", k.GetCommitterEmail(), "the host of ERMITE_PUBLIC_URL")
414 assert.Equal(t, k.GetCommitterEmail()+" "+k.GetPublicKey(), k.GetAllowedSigners())
415 if first == nil {
416 first = k
417 }
418 assert.Equal(t, first.GetPublicKey(), k.GetPublicKey(), "one key per instance")
419 }
420 // The private seed is nowhere in the logs.
421 assert.NotContains(t, h.logs.String(), "seed")
422 assert.Contains(t, h.logs.String(), first.GetFingerprint(), "the fingerprint is logged when the key is ready")
423}
424
425// AC5: the squash commit verifies with `git verify-commit`, against an
426// allowed_signers file built from what the server says about its key.
427func TestSquashCommitVerifiesWithGit(t *testing.T) {
428 if _, err := exec.LookPath("ssh-keygen"); err != nil {
429 if os.Getenv("CI") != "" {
430 t.Fatal("ssh-keygen not found: CI must have it (package openssh)")
431 }
432 t.Skip("ssh-keygen not found: install openssh to verify the signed commits")
433 }
434 r := newMRRig(t, domain.VisibilityPublic)
435 _, err := r.create(r.writer)
436 require.NoError(t, err)
437 merged, err := r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
438 Repo: "proj", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: r.head,
439 }))
440 require.NoError(t, err)
441 id := merged.Msg.GetMergeRequest().GetMergedCommitId()
442 require.NotEqual(t, r.head, id)
443
444 key, err := r.anon.server.GetSigningKey(r.ctx, connect.NewRequest(&pb.GetSigningKeyRequest{}))
445 require.NoError(t, err)
446 signers := filepath.Join(t.TempDir(), "allowed_signers")
447 require.NoError(t, os.WriteFile(signers, []byte(key.Msg.GetAllowedSigners()+"\n"), 0o600))
448 out, err := r.gitEnv(nil, r.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", id)
449 require.NoError(t, err, out)
450 assert.Contains(t, out, "Good \"git\" signature for ermite@example.test")
451 assert.Equal(t, "ermite <ermite@example.test>", r.bareGit("proj", "log", "-1", "--format=%cn <%ce>", id))
452 assert.Equal(t, "Change a (!1)", r.bareGit("proj", "log", "-1", "--format=%s", id))
453}
454
455// A private repository stays invisible in the event stream: merge request
456// events reach the callers that may read it, and nobody else.
457func TestMergeRequestEventsAreFilteredByVisibility(t *testing.T) {
458 r := newMRRig(t, domain.VisibilityPrivate)
459 anon := r.watch(r.anon, "", "home")
460 reader := r.watch(r.reader, "", "home")
461 noScope := r.watch(r.noScope, "", "home")
462
463 _, err := r.create(r.writer)
464 require.NoError(t, err)
465 _, err = r.writer.mr.AddComment(r.ctx, connect.NewRequest(&pb.AddCommentRequest{Repo: "proj", Number: 1, Text: "hello"}))
466 require.NoError(t, err)
467
468 opened := reader.next(t, watchWait)
469 require.NotNil(t, opened.GetMergeRequestOpened(), "%v", opened)
470 assert.EqualValues(t, 1, opened.GetMergeRequestOpened().GetMergeRequest().GetNumber())
471 assert.Equal(t, "proj", opened.GetMergeRequestOpened().GetMergeRequest().GetRepo())
472 added := reader.next(t, watchWait).GetCommentAdded()
473 require.NotNil(t, added)
474 assert.Equal(t, "proj", added.GetRepo())
475 assert.EqualValues(t, 1, added.GetNumber())
476 assert.Equal(t, "hello", added.GetComment().GetText())
477
478 // Merging publishes merged, then pushed.
479 _, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: r.head}))
480 require.NoError(t, err)
481 assert.NotNil(t, reader.next(t, watchWait).GetMergeRequestMerged())
482 pushed := reader.next(t, watchWait).GetPushed()
483 require.NotNil(t, pushed)
484 assert.Equal(t, "refs/heads/main", pushed.GetRefUpdates()[0].GetRef())
485 assert.Equal(t, r.head, pushed.GetRefUpdates()[0].GetNewId())
486
487 // A second request, closed.
488 r.mustGit(r.wt, "checkout", "-q", "-b", "two")
489 r.writeFile(r.wt, "two.txt", []byte("2\n"))
490 r.mustGit(r.wt, "push", "-q", r.urlAuth(adminToken, "/proj.git"), "two")
491 reader.next(t, watchWait) // the push of "two"
492 _, err = r.writer.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "two", SourceBranch: "two"}))
493 require.NoError(t, err)
494 assert.NotNil(t, reader.next(t, watchWait).GetMergeRequestOpened())
495 _, err = r.writer.mr.CloseMergeRequest(r.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: 2}))
496 require.NoError(t, err)
497 closed := reader.next(t, watchWait).GetMergeRequestClosed()
498 require.NotNil(t, closed)
499 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, closed.GetMergeRequest().GetState())
500
501 // The anonymous and unscoped watchers saw none of it: the sentinel, a
502 // public repository, is the first thing they get.
503 r.sentinel(t, anon, "s-anon")
504 r.sentinel2(t, noScope, "s-none")
505}
506
507// sentinel2 checks the next event of a watcher is the creation of a public
508// repository, which the sentinel call before it already made.
509func (r *mrRig) sentinel2(t *testing.T, s *stream, name string) {
510 t.Helper()
511 ev := s.next(t, watchWait)
512 assert.Equal(t, "s-anon", ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel %s: %v", name, ev)
513}
514
515// A topic of a private repository is refused to those who cannot read it, so
516// merge request events cannot be followed that way either.
517func TestPrivateRepositoryTopicIsNotFound(t *testing.T) {
518 r := newMRRig(t, domain.VisibilityPrivate)
519 assert.Equal(t, "not_found", codeName(watchErr(r.ctx, r.anon, "repo:proj")))
520 assert.Equal(t, "not_found", codeName(watchErr(r.ctx, r.noScope, "repo:proj")))
521}