detached a4c7fb6

test(cmd): cover merge requests and the signing key over the API

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close