1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite-actions/workflow"
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"
19 "bnema.dev/ermite/internal/usecase"
20)
21
22func parsePR(t *testing.T, on string) *workflow.Workflow {
23 t.Helper()
24 wf, errs := workflow.Parse("pr.yml", []byte("on:\n"+on+"\njobs:\n j:\n runs-on: x\n steps:\n - run: 'true'\n"))
25 require.Empty(t, errs)
26 return wf
27}
28
29func TestMatchPullRequestTypes(t *testing.T) {
30 def := parsePR(t, " pull_request:\n")
31 for action, want := range map[string]bool{"opened": true, "synchronize": true, "reopened": true, "closed": false} {
32 assert.Equal(t, want, usecase.MatchPullRequest(def, action, "main", nil, false), "default types, "+action)
33 }
34 closed := parsePR(t, " pull_request:\n types: [closed]\n")
35 assert.True(t, usecase.MatchPullRequest(closed, "closed", "main", nil, false))
36 assert.False(t, usecase.MatchPullRequest(closed, "opened", "main", nil, false))
37 both := parsePR(t, " pull_request:\n types: [opened, closed]\n")
38 assert.True(t, usecase.MatchPullRequest(both, "opened", "main", nil, false))
39 assert.True(t, usecase.MatchPullRequest(both, "closed", "main", nil, false))
40 assert.False(t, usecase.MatchPullRequest(both, "synchronize", "main", nil, false))
41 assert.False(t, usecase.MatchPullRequest(parsePR(t, " push:\n"), "opened", "main", nil, false), "no pull_request event")
42 assert.True(t, usecase.MatchPullRequest(parsePR(t, " [push, pull_request]\n"), "opened", "main", nil, false))
43}
44
45// CI-4 D4: pull_request_target has the types and the filters of pull_request,
46// under its own name; types ermite never emits parse and never match.
47func TestMatchPullRequestTarget(t *testing.T) {
48 def := parsePR(t, " pull_request_target:\n")
49 for action, want := range map[string]bool{"opened": true, "synchronize": true, "reopened": true, "closed": false} {
50 assert.Equal(t, want, usecase.MatchPullRequestTarget(def, action, "main", nil, false), "default types, "+action)
51 }
52 assert.False(t, usecase.MatchPullRequest(def, "opened", "main", nil, false), "not a pull_request workflow")
53 assert.False(t, usecase.MatchPullRequestTarget(parsePR(t, " pull_request:\n"), "opened", "main", nil, false))
54 labeled := parsePR(t, " pull_request_target:\n types: [labeled, closed]\n")
55 assert.False(t, usecase.MatchPullRequestTarget(labeled, "opened", "main", nil, false))
56 assert.True(t, usecase.MatchPullRequestTarget(labeled, "closed", "main", nil, false))
57
58 wf := parsePR(t, " pull_request_target:\n branches: [main]\n paths: ['src/**']\n")
59 assert.True(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"src/a.go"}, false))
60 assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "dev", []string{"src/a.go"}, false), "the base branch is filtered")
61 assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"README.md"}, false), "the changed files are filtered")
62 ignore := parsePR(t, " pull_request_target:\n branches-ignore: [wip]\n")
63 assert.False(t, usecase.MatchPullRequestTarget(ignore, "opened", "wip", nil, false))
64}
65
66func TestMatchPullRequestFilters(t *testing.T) {
67 wf := parsePR(t, " pull_request:\n branches: [main, 'release/**']\n paths: ['src/**', '!src/gen/**']\n")
68 files := func(f ...string) []string { return f }
69 assert.True(t, usecase.MatchPullRequest(wf, "opened", "main", files("src/a.go"), false))
70 assert.True(t, usecase.MatchPullRequest(wf, "opened", "release/1.2", files("README.md", "src/b.go"), false))
71 assert.False(t, usecase.MatchPullRequest(wf, "opened", "dev", files("src/a.go"), false), "the base branch is filtered")
72 assert.False(t, usecase.MatchPullRequest(wf, "opened", "main", files("README.md"), false), "the changed files are filtered")
73 assert.False(t, usecase.MatchPullRequest(wf, "opened", "main", files("src/gen/x.go"), false))
74 assert.True(t, usecase.MatchPullRequest(wf, "opened", "main", nil, true), "an unavailable list passes the paths filter")
75
76 ignore := parsePR(t, " pull_request:\n branches-ignore: [wip]\n paths-ignore: ['docs/**']\n")
77 assert.True(t, usecase.MatchPullRequest(ignore, "synchronize", "main", files("docs/a.md", "x.go"), false))
78 assert.False(t, usecase.MatchPullRequest(ignore, "synchronize", "main", files("docs/a.md"), false))
79 assert.False(t, usecase.MatchPullRequest(ignore, "synchronize", "wip", files("x.go"), false))
80}
81
82func TestPullFilesLink(t *testing.T) {
83 const base = "http://x/pulls/1/files"
84 assert.Empty(t, usecase.PullFilesLink(base, 1, 30, 0))
85 assert.Empty(t, usecase.PullFilesLink(base, 1, 30, 30))
86 assert.Equal(t, `<http://x/pulls/1/files?per_page=30&page=2>; rel="next", <http://x/pulls/1/files?per_page=30&page=3>; rel="last"`, usecase.PullFilesLink(base, 1, 30, 61))
87 assert.Equal(t,
88 `<http://x/pulls/1/files?per_page=30&page=3>; rel="next", <http://x/pulls/1/files?per_page=30&page=3>; rel="last", <http://x/pulls/1/files?per_page=30&page=1>; rel="first", <http://x/pulls/1/files?per_page=30&page=1>; rel="prev"`,
89 usecase.PullFilesLink(base, 2, 30, 61))
90 last := usecase.PullFilesLink(base, 3, 30, 61)
91 assert.NotContains(t, last, "next")
92 assert.Contains(t, last, `page=2>; rel="prev"`)
93 beyond := usecase.PullFilesLink(base, 9, 30, 61)
94 assert.NotContains(t, beyond, "next")
95 assert.Contains(t, beyond, `page=3>; rel="prev"`)
96}
97
98func TestFilePatch(t *testing.T) {
99 f := domain.FileDiff{Hunks: []domain.Hunk{
100 {OldStart: 1, OldLines: 2, NewStart: 1, NewLines: 2, Header: "func x()", Lines: []domain.Line{
101 {Kind: domain.LineContext, Text: "a"}, {Kind: domain.LineDeletion, Text: "b"}, {Kind: domain.LineAddition, Text: "c", NoNewlineAtEOF: true},
102 }},
103 {OldStart: 9, OldLines: 1, NewStart: 9, NewLines: 1, Lines: []domain.Line{{Kind: domain.LineAddition, Text: "z"}}},
104 }}
105 got, ok := usecase.FilePatch(f)
106 require.True(t, ok)
107 assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)
108 _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})
109 assert.False(t, ok, "binary")
110 _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge})
111 assert.False(t, ok, "truncated")
112 _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})
113 assert.False(t, ok, "a pure rename has no hunk")
114}
115
116// CI-4 D1: every path of the trust of an activity that is not a push ends
117// untrusted unless the provenance of the code it runs is positively known.
118func TestPullTipsTrustedFailsClosed(t *testing.T) {
119 oid := func(c byte) domain.ObjectID {
120 id, err := domain.ParseObjectID(strings.Repeat(string(c), 40))
121 require.NoError(t, err)
122 return id
123 }
124 head, base, merge, merged, other := oid('1'), oid('2'), oid('3'), oid('4'), oid('9')
125 boom := errors.New("boom")
126 repo := name(t, "proj")
127 open := domain.MergeRequest{Repo: repo, Number: 1, Source: "feat", Target: "main", State: domain.MRStateOpen}
128 closed := open
129 closed.State = domain.MRStateClosed
130 mergedMR := open
131 mergedMR.State, mergedMR.HeadID, mergedMR.MergedCommitID = domain.MRStateMerged, head, merged
132 tip := func(ref string, sha domain.ObjectID, trusted bool) domain.CIRefTip {
133 return domain.CIRefTip{Repo: repo, Ref: ref, Sha: sha.String(), Trusted: trusted}
134 }
135 goodTips := []domain.CIRefTip{tip("refs/heads/feat", head, true), tip("refs/heads/main", base, true)}
136 mergeCommit := []domain.Commit{{ID: merge, Parents: []domain.ObjectID{base, head}}}
137
138 type world struct {
139 mr domain.MergeRequest
140 mrErr error
141 tips []domain.CIRefTip
142 tipErr error
143 mergeRef domain.ObjectID // zero: none
144 refErr error
145 commits []domain.Commit
146 listErr error
147 mb domain.ObjectID
148 mbErr error
149 }
150 opened := ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Head: head, Base: base}
151 closing := ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Head: head}
152 merging := ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Head: head, Merged: true}
153 mergedTips := []domain.CIRefTip{tip("refs/heads/main", merged, true)}
154
155 cases := []struct {
156 name string
157 ev ports.PullRequestEvent
158 w world
159 want bool
160 }{
161 {"opened: head and base pushed by the owner", opened, world{mr: open, tips: goodTips}, true},
162 {"opened: the request cannot be read", opened, world{mrErr: boom}, false},
163 {"opened: the request is gone", opened, world{mrErr: domain.ErrNotFound}, false},
164 {"opened: a record cannot be read", opened, world{mr: open, tipErr: boom}, false},
165 {"opened: unknown head", ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Base: base}, world{mr: open, tips: goodTips}, false},
166 {"opened: unknown base", ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Head: head}, world{mr: open, tips: goodTips}, false},
167 {"opened: head without a record", opened, world{mr: open, tips: goodTips[1:]}, false},
168 {"opened: base without a record", opened, world{mr: open, tips: goodTips[:1]}, false},
169 {"opened: base pushed by an agent", opened, world{mr: open, tips: []domain.CIRefTip{goodTips[0], tip("refs/heads/main", base, false)}}, false},
170 {"opened: base record for another commit", opened, world{mr: open, tips: []domain.CIRefTip{goodTips[0], tip("refs/heads/main", other, true)}}, false},
171 {"an unknown action is checked like an opening", ports.PullRequestEvent{Number: 1, Action: "edited", Head: head, Base: base}, world{mr: open, tips: goodTips[:1]}, false},
172 {"closed: no merge commit, owner's head", closing, world{mr: closed, tips: goodTips[:1]}, true},
173 {"closed: merge commit of owner's tips", closing, world{mr: closed, tips: goodTips, mergeRef: merge, commits: mergeCommit}, true},
174 {"closed: the merge ref cannot be read", closing, world{mr: closed, tips: goodTips, refErr: boom}, false},
175 {"closed: the merge commit cannot be read", closing, world{mr: closed, tips: goodTips, mergeRef: merge, listErr: boom}, false},
176 {"closed: the merge ref is not a merge", closing, world{mr: closed, tips: goodTips, mergeRef: merge, commits: []domain.Commit{{ID: merge, Parents: []domain.ObjectID{base}}}}, false},
177 {"closed: the merge commit is of another head", closing, world{mr: closed, tips: goodTips, mergeRef: merge, commits: []domain.Commit{{ID: merge, Parents: []domain.ObjectID{base, other}}}}, false},
178 {"closed: merge commit on an agent's base", closing, world{mr: closed, tips: []domain.CIRefTip{goodTips[0], tip("refs/heads/main", base, false)}, mergeRef: merge, commits: mergeCommit}, false},
179 {"closed, not by a merge, of a merged request: checked like a closing", closing, world{mr: mergedMR, tips: mergedTips}, false},
180 {"merged: the merger's", merging, world{mr: mergedMR, tips: mergedTips, mergeRef: merge, commits: mergeCommit, mb: base}, true},
181 {"merged without a merge commit", merging, world{mr: mergedMR, tips: mergedTips}, true},
182 {"merged flag on a request that is not merged", merging, world{mr: closed, tips: append(goodTips, mergedTips...)}, false},
183 {"merged flag on an opening", ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Head: head, Merged: true}, world{mr: mergedMR, tips: mergedTips}, false},
184 {"merged: another head than the merged one", ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Head: other, Merged: true}, world{mr: mergedMR, tips: mergedTips}, false},
185 {"merged: unknown head", ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Merged: true}, world{mr: mergedMR, tips: mergedTips}, false},
186 {"merged: the target moved since", merging, world{mr: mergedMR, tips: []domain.CIRefTip{tip("refs/heads/main", other, true)}}, false},
187 {"merged: the target set by an agent", merging, world{mr: mergedMR, tips: []domain.CIRefTip{tip("refs/heads/main", merged, false)}}, false},
188 {"merged: the base is not in the merge result", merging, world{mr: mergedMR, tips: mergedTips, mergeRef: merge, commits: mergeCommit, mb: other}, false},
189 {"merged: the merge base cannot be read", merging, world{mr: mergedMR, tips: mergedTips, mergeRef: merge, commits: mergeCommit, mbErr: boom}, false},
190 }
191 for _, tc := range cases {
192 t.Run(tc.name, func(t *testing.T) {
193 w := tc.w
194 mrs := mocks.NewMergeRequestStore(t)
195 mrs.EXPECT().Get(mock.Anything, repo, int64(1)).Return(w.mr, w.mrErr).Maybe()
196 tips := mocks.NewCIRefTipStore(t)
197 tips.EXPECT().GetRefTip(mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, ref string) (domain.CIRefTip, error) {
198 if w.tipErr != nil {
199 return domain.CIRefTip{}, w.tipErr
200 }
201 for _, rt := range w.tips {
202 if rt.Ref == ref {
203 return rt, nil
204 }
205 }
206 return domain.CIRefTip{}, domain.ErrNotFound
207 }).Maybe()
208 pull := mocks.NewPullGit(t)
209 refErr := w.refErr
210 if refErr == nil && w.mergeRef.IsZero() {
211 refErr = domain.ErrNotFound
212 }
213 pull.EXPECT().PullRef(mock.Anything, repo, domain.PullMergeRef(1)).Return(w.mergeRef, refErr).Maybe()
214 git := mocks.NewMergeGit(t)
215 git.EXPECT().ListRange(mock.Anything, repo, domain.ObjectID{}, merge, 1).Return(w.commits, false, w.listErr).Maybe()
216 git.EXPECT().MergeBase(mock.Anything, repo, mock.Anything, merged).Return(w.mb, w.mbErr).Maybe()
217
218 cfg := usecase.CIConfig{MRs: mrs, RefTips: tips, Pull: pull, Git: git}
219 got, _ := usecase.PullTipsTrusted(context.Background(), cfg, repo, tc.ev)
220 assert.Equal(t, tc.want, got)
221 })
222 }
223}