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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||