v0.23.0 333c710

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}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302	assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303
304	update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
305		res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
306			Repo: "proj", Settings: s, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
307		}))
308		if err != nil {
309			return nil, err
310		}
311		return res.Msg.GetRepo(), nil
312	}
313	repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{
314		AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: false,
315	})
316	require.NoError(t, err)
317	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318	assert.True(t, repo.GetSettings().GetFailingChecksBlock(), "not named in the mask: unchanged")
319
320	repo, err = update(root, []string{"settings.failing_checks_block"}, &pb.RepoSettings{FailingChecksBlock: false})
321	require.NoError(t, err)
322	assert.False(t, repo.GetSettings().GetFailingChecksBlock())
323	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")
324
325	repo, err = update(root, []string{"settings"}, &pb.RepoSettings{
326		AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: true,
327	})
328	require.NoError(t, err)
329	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")
330	assert.True(t, repo.GetSettings().GetFailingChecksBlock())
331
332	for name, s := range map[string]*pb.RepoSettings{
333		"no modes":     {},
334		"unspecified":  {AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED}},
335		"unknown mode": {AllowedMergeModes: []pb.MergeMode{9}},
336	} {
337		_, err = update(root, []string{"settings.allowed_merge_modes"}, s)
338		assert.Equal(t, "invalid_argument", codeName(err), name)
339	}
340	_, err = update(root, []string{"settings.nope"}, &pb.RepoSettings{})
341	assert.Equal(t, "invalid_argument", codeName(err))
342
343	// repo:admin is needed.
344	_, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.failing_checks_block"}, &pb.RepoSettings{})
345	assert.Equal(t, "permission_denied", codeName(err))
346	got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
347	require.NoError(t, err)
348	assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
349}
350
351// AC4 over the API: a mode the settings do not allow is refused.
352func TestMergeModeNotAllowedOverTheAPI(t *testing.T) {
353	r := newMRRig(t, domain.VisibilityPublic)
354	_, err := r.create(r.writer)
355	require.NoError(t, err)
356	_, err = r.root.repo.UpdateRepo(r.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
357		Repo: "proj", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}},
358		Settings: &pb.RepoSettings{AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD}},
359	}))
360	require.NoError(t, err)
361	_, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
362		Repo: "proj", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: r.head,
363	}))
364	assert.Equal(t, "failed_precondition", codeName(err))
365	assert.Equal(t, r.bareGit("proj", "rev-parse", "main"), r.bareGit("proj", "rev-parse", "feat~2"), "main did not move")
366}
367
368// delete_source_branch reaches the merge and the response says what happened;
369// deleting the default branch is a failed precondition.
370func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) {
371	r := newMRRig(t, domain.VisibilityPublic)
372	_, err := r.create(r.writer)
373	require.NoError(t, err)
374	res, err := r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
375		Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,
376	}))
377	require.NoError(t, err)
378	assert.True(t, res.Msg.GetSourceBranchDeleted())
379	assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
380
381	// The default branch as a source: refused before anything changes.
382	r.bareGit("proj", "branch", "old", "main~1")
383	main := r.bareGit("proj", "rev-parse", "main")
384	_, err = r.root.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
385		Repo: "proj", Title: "back", SourceBranch: "main", TargetBranch: "old",
386	}))
387	require.NoError(t, err)
388	_, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
389		Repo: "proj", Number: 2, HeadId: main, DeleteSourceBranch: true,
390	}))
391	assert.Equal(t, "failed_precondition", codeName(err))
392	assert.Equal(t, main, r.bareGit("proj", "rev-parse", "main"))
393	assert.NotEqual(t, main, r.bareGit("proj", "rev-parse", "old"), "not merged")
394}
395
396// The signing key needs no authentication, whatever the credentials, and keeps
397// its shape.
398func TestGetSigningKey(t *testing.T) {
399	h := newHarness(t, harnessOpts{})
400	var first *pb.GetSigningKeyResponse
401	for _, who := range []string{"anonymous", "root", "bad token"} {
402		c := map[string]*clients{
403			"anonymous": h.api(""), "root": h.api(adminToken),
404			"bad token": newDisposableClients(h.url("/-/api"), "ermite_"+strings.Repeat("x", 43)),
405		}[who]
406		res, err := c.server.GetSigningKey(h.ctx, connect.NewRequest(&pb.GetSigningKeyRequest{}))
407		require.NoError(t, err, who)
408		k := res.Msg
409		assert.True(t, strings.HasPrefix(k.GetPublicKey(), "ssh-ed25519 "), who)
410		assert.True(t, strings.HasPrefix(k.GetFingerprint(), "SHA256:"), who)
411		assert.Equal(t, "ermite", k.GetCommitterName())
412		assert.Equal(t, "ermite@example.test", k.GetCommitterEmail(), "the host of ERMITE_PUBLIC_URL")
413		assert.Equal(t, k.GetCommitterEmail()+" "+k.GetPublicKey(), k.GetAllowedSigners())
414		if first == nil {
415			first = k
416		}
417		assert.Equal(t, first.GetPublicKey(), k.GetPublicKey(), "one key per instance")
418	}
419	// The private seed is nowhere in the logs.
420	assert.NotContains(t, h.logs.String(), "seed")
421	assert.Contains(t, h.logs.String(), first.GetFingerprint(), "the fingerprint is logged when the key is ready")
422}
423
424// AC5: the squash commit verifies with `git verify-commit`, against an
425// allowed_signers file built from what the server says about its key.
426func TestSquashCommitVerifiesWithGit(t *testing.T) {
427	if _, err := exec.LookPath("ssh-keygen"); err != nil {
428		if os.Getenv("CI") != "" {
429			t.Fatal("ssh-keygen not found: CI must have it (package openssh)")
430		}
431		t.Skip("ssh-keygen not found: install openssh to verify the signed commits")
432	}
433	r := newMRRig(t, domain.VisibilityPublic)
434	_, err := r.create(r.writer)
435	require.NoError(t, err)
436	merged, err := r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
437		Repo: "proj", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: r.head,
438	}))
439	require.NoError(t, err)
440	id := merged.Msg.GetMergeRequest().GetMergedCommitId()
441	require.NotEqual(t, r.head, id)
442
443	key, err := r.anon.server.GetSigningKey(r.ctx, connect.NewRequest(&pb.GetSigningKeyRequest{}))
444	require.NoError(t, err)
445	signers := filepath.Join(t.TempDir(), "allowed_signers")
446	require.NoError(t, os.WriteFile(signers, []byte(key.Msg.GetAllowedSigners()+"\n"), 0o600))
447	out, err := r.gitEnv(nil, r.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", id)
448	require.NoError(t, err, out)
449	assert.Contains(t, out, "Good \"git\" signature for ermite@example.test")
450	assert.Equal(t, "ermite <ermite@example.test>", r.bareGit("proj", "log", "-1", "--format=%cn <%ce>", id))
451	assert.Equal(t, "Change a (!1)", r.bareGit("proj", "log", "-1", "--format=%s", id))
452}
453
454// A private repository stays invisible in the event stream: merge request
455// events reach the callers that may read it, and nobody else.
456func TestMergeRequestEventsAreFilteredByVisibility(t *testing.T) {
457	r := newMRRig(t, domain.VisibilityPrivate)
458	anon := r.watch(r.anon, "", "home")
459	reader := r.watch(r.reader, "", "home")
460	noScope := r.watch(r.noScope, "", "home")
461
462	_, err := r.create(r.writer)
463	require.NoError(t, err)
464	_, err = r.writer.mr.AddComment(r.ctx, connect.NewRequest(&pb.AddCommentRequest{Repo: "proj", Number: 1, Text: "hello"}))
465	require.NoError(t, err)
466
467	opened := reader.next(t, watchWait)
468	require.NotNil(t, opened.GetMergeRequestOpened(), "%v", opened)
469	assert.EqualValues(t, 1, opened.GetMergeRequestOpened().GetMergeRequest().GetNumber())
470	assert.Equal(t, "proj", opened.GetMergeRequestOpened().GetMergeRequest().GetRepo())
471	added := reader.next(t, watchWait).GetCommentAdded()
472	require.NotNil(t, added)
473	assert.Equal(t, "proj", added.GetRepo())
474	assert.EqualValues(t, 1, added.GetNumber())
475	assert.Equal(t, "hello", added.GetComment().GetText())
476
477	// Merging publishes merged, then pushed.
478	_, err = r.merger.mr.MergeMergeRequest(r.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: 1, HeadId: r.head}))
479	require.NoError(t, err)
480	assert.NotNil(t, reader.next(t, watchWait).GetMergeRequestMerged())
481	pushed := reader.next(t, watchWait).GetPushed()
482	require.NotNil(t, pushed)
483	assert.Equal(t, "refs/heads/main", pushed.GetRefUpdates()[0].GetRef())
484	assert.Equal(t, r.head, pushed.GetRefUpdates()[0].GetNewId())
485
486	// A second request, closed.
487	r.mustGit(r.wt, "checkout", "-q", "-b", "two")
488	r.writeFile(r.wt, "two.txt", []byte("2\n"))
489	r.mustGit(r.wt, "push", "-q", r.urlAuth(adminToken, "/proj.git"), "two")
490	reader.next(t, watchWait) // the push of "two"
491	_, err = r.writer.mr.CreateMergeRequest(r.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "two", SourceBranch: "two"}))
492	require.NoError(t, err)
493	assert.NotNil(t, reader.next(t, watchWait).GetMergeRequestOpened())
494	_, err = r.writer.mr.CloseMergeRequest(r.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: 2}))
495	require.NoError(t, err)
496	closed := reader.next(t, watchWait).GetMergeRequestClosed()
497	require.NotNil(t, closed)
498	assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, closed.GetMergeRequest().GetState())
499
500	// The anonymous and unscoped watchers saw none of it: the sentinel, a
501	// public repository, is the first thing they get.
502	r.sentinel(t, anon, "s-anon")
503	r.sentinel2(t, noScope, "s-none")
504}
505
506// sentinel2 checks the next event of a watcher is the creation of a public
507// repository, which the sentinel call before it already made.
508func (r *mrRig) sentinel2(t *testing.T, s *stream, name string) {
509	t.Helper()
510	ev := s.next(t, watchWait)
511	assert.Equal(t, "s-anon", ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel %s: %v", name, ev)
512}
513
514// A topic of a private repository is refused to those who cannot read it, so
515// merge request events cannot be followed that way either.
516func TestPrivateRepositoryTopicIsNotFound(t *testing.T) {
517	r := newMRRig(t, domain.VisibilityPrivate)
518	assert.Equal(t, "not_found", codeName(watchErr(r.ctx, r.anon, "repo:proj")))
519	assert.Equal(t, "not_found", codeName(watchErr(r.ctx, r.noScope, "repo:proj")))
520}

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 44

↑↓ moveenter openesc close