detached 59d5a0b

mr_api_test.go RawHistoryFolder

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

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 45

↑↓ moveenter openesc close