1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/ports/mocks"
21)
22
23func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24 t.Helper()
25 svc := mocks.NewMergeRequestService(t)
26 browse := mocks.NewBrowseService(t)
27 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28 svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29 MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30 Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31 }, nil).Maybe()
32 return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33}
34
35func TestAsyncMergePost(t *testing.T) {
36 for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37 for _, hx := range []bool{false, true} {
38 t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39 s, svc := asyncMergeServer(t, domain.MRStateMerged)
40 svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41 r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42 if hx {
43 r.Header.Set("HX-Request", "true")
44 }
45 w := httptest.NewRecorder()
46 s.mrAction(s.mrMerge)(w, r)
47 if !hx {
48 assert.Equal(t, http.StatusSeeOther, w.Code)
49 return
50 }
51 assert.Equal(t, http.StatusOK, w.Code)
52 assert.Empty(t, w.Header().Get("Location"))
53 assert.Contains(t, w.Body.String(), `id="mr-state"`)
54 assert.Contains(t, w.Body.String(), `data-state="merged"`)
55 assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56 assert.NotContains(t, w.Body.String(), "<html")
57 assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58 })
59 }
60 }
61}
62
63func TestAsyncMergeRefusal(t *testing.T) {
64 s, svc := asyncMergeServer(t, domain.MRStateOpen)
65 svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66 r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67 r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68 r.Header.Set("HX-Request", "true")
69 w := httptest.NewRecorder()
70 s.mrAction(s.mrMerge)(w, r)
71 assert.Equal(t, http.StatusConflict, w.Code)
72 assert.Contains(t, w.Body.String(), `role="alert"`)
73 assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74 assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75 assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76 assert.Contains(t, w.Body.String(), "Merging…")
77 assert.NotContains(t, w.Body.String(), "<html")
78}
79
80func TestAsyncMergeLive(t *testing.T) {
81 s, _ := asyncMergeServer(t, domain.MRStateMerged)
82 l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83 fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84 require.NoError(t, err)
85 require.Len(t, fs, 1)
86 assert.Equal(t, "mr-mergeability", fs[0].event)
87 assert.Contains(t, fs[0].html, `data-state="merged"`)
88 assert.Contains(t, fs[0].html, `id="mr-actions"`)
89 assert.False(t, strings.Contains(fs[0].html, "reload"))
90 fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91 require.NoError(t, err)
92 assert.Empty(t, fs)
93}
94
95func TestAsyncMergeOtherFailures(t *testing.T) {
96 for _, tt := range []struct {
97 name string
98 mergeErr error
99 readFails bool
100 status int
101 message string
102 }{
103 {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104 {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105 {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106 } {
107 t.Run(tt.name, func(t *testing.T) {
108 s, svc := asyncMergeServer(t, domain.MRStateMerged)
109 if tt.readFails {
110 svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111 svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112 }
113 svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114 r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115 r.Header.Set("HX-Request", "true")
116 w := httptest.NewRecorder()
117 s.mrAction(s.mrMerge)(w, r)
118 assert.Equal(t, tt.status, w.Code)
119 assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120 assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121 assert.Contains(t, w.Body.String(), `role="alert"`)
122 assert.Contains(t, w.Body.String(), tt.message)
123 assert.NotContains(t, w.Body.String(), "private")
124 assert.NotContains(t, w.Body.String(), "<html")
125 })
126 }
127}
128
129func TestMergeGateRefusalFragment(t *testing.T) {
130 s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131 for _, origin := range []string{testPublicURL, "https://other.example"} {
132 for _, hx := range []bool{false, true} {
133 r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134 r.Header.Set("Origin", origin)
135 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136 if hx {
137 r.Header.Set("HX-Request", "true")
138 }
139 w := httptest.NewRecorder()
140 s.ServeHTTP(w, r)
141 assert.Equal(t, http.StatusForbidden, w.Code)
142 if hx {
143 assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144 assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145 if origin == testPublicURL {
146 assert.Contains(t, w.Body.String(), "your session has expired")
147 } else {
148 assert.Contains(t, w.Body.String(), "origin was not accepted")
149 }
150 } else {
151 assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152 }
153 }
154 }
155}
156
157func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158 s, svc := asyncMergeServer(t, domain.MRStateMerged)
159 svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160 svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161 r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162 w := httptest.NewRecorder()
163 s.mrAction(s.mrMerge)(w, r)
164 assert.Equal(t, http.StatusConflict, w.Code)
165 assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166}