1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"
20)
21
22func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
23 var v ports.RepoView
24 v.Settings.AllowedMergeModes = modes
25 return v
26}
27
28func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
29
30func TestMergeabilityView(t *testing.T) {
31 t.Parallel()
32 for _, tt := range []struct {
33 name string
34 m domain.Mergeability
35 label, detail string
36 ffOK, mergeOK bool
37 note string
38 paths []string
39 }{
40 {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
41 {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
42 {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
43 {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
44 {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
45 {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
46 {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
47 {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
48 {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
49 } {
50 t.Run(tt.name, func(t *testing.T) {
51 t.Parallel()
52 v := mergeabilityView(tt.m, "main")
53 assert.Equal(t, tt.label, v.Label)
54 assert.Equal(t, tt.detail, v.Detail)
55 assert.Equal(t, tt.ffOK, v.FFOk)
56 assert.Equal(t, tt.mergeOK, v.MergeOk)
57 assert.Equal(t, tt.note, v.PathsNote)
58 assert.Equal(t, tt.paths, v.Paths)
59 assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
60 assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
61 })
62 }
63}
64
65func TestMergeBoxModes(t *testing.T) {
66 t.Parallel()
67 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
68 s := &Server{}
69
70 behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
71 require.Len(t, behind.Modes, 3)
72 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
73 assert.NotEmpty(t, behind.Modes[0].Reason)
74 assert.False(t, behind.Modes[1].Disabled)
75 assert.False(t, behind.Modes[2].Disabled)
76 assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
77 assert.True(t, behind.CanMerge())
78 assert.True(t, behind.Signed)
79
80 conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
81 assert.False(t, conflict.CanMerge())
82 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
83 assert.Equal(t, 0, conflict.Checked())
84 assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
85 assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
86}
87
88func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
89 t.Parallel()
90 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
91 b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
92 assert.False(t, b.Signed, "no commit is created, nothing to explain")
93 assert.Len(t, b.Modes, 1)
94}
95
96func TestMergeBoxNamesTheSigningKey(t *testing.T) {
97 t.Parallel()
98 key := mocks.NewSigningKeyService(t)
99 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
100 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
101 b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
102 assert.Equal(t, "SHA256:abc", b.Fingerprint)
103}
104
105func TestMergeErrorTravelsInTheContext(t *testing.T) {
106 t.Parallel()
107 assert.Empty(t, mergeErrorOf(context.Background()))
108 assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
109}
110
111func TestMergeabilityLiveEvents(t *testing.T) {
112 t.Parallel()
113 assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
114 svc := mocks.NewMergeRequestService(t)
115 svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
116 Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
117 l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
118 ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
119
120 fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
121 require.NoError(t, err)
122 require.Len(t, fs, 1)
123 assert.Equal(t, "mr-mergeability", fs[0].event)
124 assert.Contains(t, fs[0].html, "Behind main by 3 commits")
125
126 // Another request, or a page that is not one: nothing.
127 fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
128 require.NoError(t, err)
129 assert.Empty(t, fs)
130 // The box is the owner's.
131 fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
132 require.NoError(t, err)
133 assert.Empty(t, fs)
134}
135
136func TestMRPanelsAndMenus(t *testing.T) {
137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
139 footer := renderToString(t, mrFooter(p))
140 assert.Contains(t, footer, `<footer class="mr-footer"><div data-mr-tabs data-js role="group"`)
141 assert.NotContains(t, footer, `<details`)
142 assert.NotContains(t, footer, `<summary`)
143 // Both panels are readable without scripts; JS enhances native buttons.
144 assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="group"`)
145 assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="group"`)
146 assert.NotContains(t, footer, `data-mr-panel hidden`)
147 css, err := staticFS.ReadFile("static/css/60-mr.css")
148 require.NoError(t, err)
149 assert.NotContains(t, string(css), `:not([data-panels])`)
150 assert.Contains(t, footer, `<button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">`)
151 assert.Contains(t, footer, `<button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">`)
152 assert.Contains(t, footer, `data-mr-tabs`)
153 assert.Contains(t, footer, `id="mr-commits"`)
154 assert.Contains(t, footer, `id="mr-checks"`)
155 assert.Contains(t, footer, `<table class="mr-commits" aria-label="Commits">`)
156 assert.Contains(t, footer, "500+")
157 assert.Contains(t, footer, "more commits")
158 assert.Contains(t, footer, "1/2 failed")
159 rail := renderToString(t, mrRail(p))
160 assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`)
161 for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
162 assert.NotContains(t, rail, text)
163 }
164 more := renderToString(t, moreMenu(p))
165 for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
166 assert.Contains(t, more, text)
167 }
168 merge := renderToString(t, mergeMenu(p))
169 assert.NotContains(t, merge, `<dl`)
170 assert.NotContains(t, merge, `mr-signing`)
171 assert.Contains(t, merge, "the source branch was deleted")
172 bar := renderToString(t, mrBar(MRPage{}))
173 assert.Contains(t, bar, `id="mr-ck"`)
174 assert.Contains(t, bar, `href="#mr-checks"`)
175 assert.Contains(t, bar, "Merge settings")
176 assert.NotContains(t, bar, ">Close</button>")
177}
178
179func TestMRReadPresentationCommitCount(t *testing.T) {
180 commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
181 for _, tc := range []struct {
182 name string
183 commits []domain.Commit
184 truncated bool
185 count string
186 note string
187 }{
188 {"empty", nil, false, "0", ""},
189 {"complete", commits(2), false, "2", ""},
190 {"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
191 } {
192 t.Run(tc.name, func(t *testing.T) {
193 s := &Server{cfg: Config{Now: time.Now}}
194 p := &MRPage{}
195 s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
196 assert.Equal(t, tc.count, p.CommitCount)
197 assert.Equal(t, tc.note, p.CommitsNote)
198 assert.Len(t, p.Commits, len(tc.commits))
199 })
200 }
201}
202
203func TestMergeBoxAutoMerge(t *testing.T) {
204 t.Parallel()
205 mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
206 mk := func(allow bool, runs ports.RunService) *MergeBox {
207 v := repoWithModes(domain.MergeModes()...)
208 v.Settings.AllowAutoMerge = allow
209 return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
210 }
211 assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
212 assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
213 on := mk(true, mocks.NewRunService(t))
214 assert.True(t, on.CanAutoMerge)
215
216 page := MRPage{Target: "main", Box: on}
217 html := renderToString(t, mergeMenu(page))
218 assert.Contains(t, html, "Merge when ready")
219 assert.Contains(t, html, `action="`+on.AutoAction+`"`)
220 assert.NotContains(t, html, "Cancel auto-merge")
221 // Current mergeability must only gate the immediate form. Every allowed
222 // mode remains selectable in the separate auto-merge form.
223 for i := range on.Modes {
224 on.Modes[i].Disabled = true
225 }
226 on.CanDeleteBranch = true
227 page.Source = "feat"
228 html = renderToString(t, mergeMenu(page))
229 _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
230 require.True(t, found)
231 autoForm, _, _ = strings.Cut(autoForm, "</form>")
232 assert.NotContains(t, autoForm, "delete_branch")
233 assert.NotContains(t, autoForm, "disabled")
234 assert.Contains(t, autoForm, `name="head_id"`)
235 for _, mode := range on.Modes {
236 assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
237 }
238 page.Box = mk(false, mocks.NewRunService(t))
239 assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
240
241 on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
242 html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
243 assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
244 assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
245 assert.Contains(t, html, "Cancel auto-merge")
246}
247
248func autoMergeRequest(path, body string) *http.Request {
249 r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
250 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
251 r.SetPathValue("repo", "demo")
252 r.SetPathValue("n", "3")
253 return r
254}
255
256func TestAutoMergeHandlers(t *testing.T) {
257 t.Parallel()
258 name, err := domain.ParseRepoName("demo")
259 require.NoError(t, err)
260 ctx := context.Background()
261
262 head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
263 require.NoError(t, err)
264 svc := mocks.NewMergeRequestService(t)
265 svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}).
266 Return(domain.MergeRequest{}, nil).Once()
267 svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
268 s := &Server{cfg: Config{MergeRequests: svc}}
269 to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String()))
270 require.NoError(t, err)
271 assert.Equal(t, mrHref("demo", 3), to)
272 to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
273 require.NoError(t, err)
274 assert.Equal(t, mrHref("demo", 3), to)
275
276 // A bad mode is refused before the service.
277 _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
278 var re *rerender
279 require.ErrorAs(t, err, &re)
280 assert.Equal(t, http.StatusBadRequest, re.status)
281
282 // A refusal of the use case is shown in the box, like a refused merge.
283 svc2 := mocks.NewMergeRequestService(t)
284 svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
285 Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
286 svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
287 Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
288 s2 := &Server{cfg: Config{MergeRequests: svc2}}
289 _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String()))
290 require.ErrorAs(t, err, &re)
291 assert.Equal(t, http.StatusConflict, re.status)
292 _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
293 require.ErrorIs(t, err, domain.ErrNotFound)
294}
295
296func TestAutoMergeLiveEvents(t *testing.T) {
297 t.Parallel()
298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
299 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
300}
301
302func TestMergeIgnoreChecks(t *testing.T) {
303 t.Parallel()
304 for _, kind := range []string{"bad", "running", "ok", ""} {
305 t.Run(kind, func(t *testing.T) {
306 t.Parallel()
307 box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
308 html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
309 if kind == "bad" || kind == "running" {
310 assert.Contains(t, html, `name="ignore_checks"`)
311 assert.Contains(t, html, "Ignore checks")
312 } else {
313 assert.NotContains(t, html, `name="ignore_checks"`)
314 }
315 assert.True(t, box.CanMerge())
316 })
317 }
318 name, err := domain.ParseRepoName("demo")
319 require.NoError(t, err)
320 head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
321 require.NoError(t, err)
322 for _, ignore := range []bool{false, true} {
323 svc := mocks.NewMergeRequestService(t)
324 svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
325 body := "mode=squash&head_id=" + head.String()
326 if ignore {
327 body += "&ignore_checks=on"
328 }
329 s := &Server{cfg: Config{MergeRequests: svc}}
330 to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
331 require.NoError(t, err)
332 assert.Equal(t, mrHref("demo", 3), to)
333 }
334}