1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15 "bnema.dev/ermite/internal/ports/mocks"
16 "bnema.dev/ermite/internal/usecase"
17)
18
19// mergeUnit is a merge request that is ready to merge, with a checks reader.
20type mergeUnit struct {
21 *mrUnit
22 checks *mocks.CIChecksReader
23 trig *mocks.WorkflowTrigger
24 head domain.ObjectID
25}
26
27func newMergeUnit(t *testing.T, block bool) *mergeUnit {
28 t.Helper()
29 u := &mrUnit{
30 meta: mocks.NewRepoMetaStore(t),
31 store: mocks.NewMergeRequestStore(t),
32 repos: mocks.NewGitRepos(t),
33 git: mocks.NewMergeGit(t),
34 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35 }
36 u.repo.Settings = domain.DefaultRepoSettings()
37 u.repo.Settings.FailingChecksBlock = block
38 clock := mocks.NewClock(t)
39 clock.EXPECT().Now().Return(now).Maybe()
40 sched := mocks.NewMaintenanceScheduler(t)
41 sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42 m := &mergeUnit{mrUnit: u, checks: mocks.NewCIChecksReader(t), trig: mocks.NewWorkflowTrigger(t), head: someID(t)}
43 u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45 Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46 CI: m.trig, Checks: m.checks,
47 })
48 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
49 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
50 Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
51 }, nil)
52 tip, err := domain.ParseObjectID("1111111111111111111111111111111111111111")
53 require.NoError(t, err)
54 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
55 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
56 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil)
58 return m
59}
60
61func (m *mergeUnit) merge() error {
62 _, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
63 return err
64}
65
66// AC5: the merge rule under FailingChecksBlock.
67func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
68 t.Parallel()
69 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
70 return domain.CICheck{Name: name, Status: status, Conclusion: c}
71 }
72 tests := []struct {
73 name string
74 checks []domain.CICheck
75 blocked string // part of the message
76 }{
77 {"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
78 {"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
79 {"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
80 {"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
81 {"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
82 {"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
83 }
84 for _, tt := range tests {
85 t.Run(tt.name, func(t *testing.T) {
86 t.Parallel()
87 m := newMergeUnit(t, true)
88 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
89 // The merge stops at the rule: the branch mock expects no move.
90 err := m.merge()
91 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92 assert.Contains(t, err.Error(), tt.blocked)
93 })
94 }
95 t.Run("too many checks to tell", func(t *testing.T) {
96 t.Parallel()
97 m := newMergeUnit(t, true)
98 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
99 err := m.merge()
100 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
101 assert.Contains(t, err.Error(), "too many checks")
102 })
103 // The cases where the merge goes on (no check, all passing, rule off) need
104 // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
105}
106
107func TestCombineChecks(t *testing.T) {
108 t.Parallel()
109 ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
110 return domain.CICheck{Status: s, Conclusion: c}
111 }
112 assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
113 assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
114 assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
115 assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
116}
117
118func TestLabelsMatchAndRunnerLabels(t *testing.T) {
119 t.Parallel()
120 have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
121 assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
122 assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
123 assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
124 assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
125 assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
126 assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
127 assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
128}
129
130func TestStepRanges(t *testing.T) {
131 t.Parallel()
132 steps := []domain.CIStep{
133 {Number: 1, HasLines: true, LogStart: 0},
134 {Number: 2},
135 {Number: 3, HasLines: true, LogStart: 4},
136 {Number: 4, HasLines: true, LogStart: 9},
137 {Number: 5},
138 }
139 got := domain.StepRanges(steps, 12)
140 assert.Equal(t, []domain.StepRange{{Start: 0, End: 4}, {Start: 4, End: 4}, {Start: 4, End: 9}, {Start: 9, End: 12}, {Start: 12, End: 12}}, got)
141 assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
142 assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
143 assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
144}
145
146func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
147 t.Parallel()
148 id := domain.NewJobID(42, 3, 1)
149 run, index, combo, ok := domain.ParseJobID(id)
150 require.True(t, ok)
151 assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
152 for _, bad := range []string{"", "1-2", "1-2-3-4", "a-b-c", "0-1-1", "1--2", "01-2-3", "../../x", "1-2-3/4"} {
153 _, _, _, ok := domain.ParseJobID(bad)
154 assert.False(t, ok, bad)
155 }
156}
157
158func TestOutputLimitsAndNames(t *testing.T) {
159 t.Parallel()
160 assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
161 assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
162 assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
163 assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
164 assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
165 assert.Error(t, domain.CheckOutputs(map[string]string{"a": string(make([]byte, domain.CIMaxOutputBytes/2+1)), "b": string(make([]byte, domain.CIMaxOutputBytes/2+1))}), "1 MiB together")
166}
167
168func TestTokenShapes(t *testing.T) {
169 t.Parallel()
170 good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
171 assert.True(t, domain.IsJobToken(good))
172 assert.False(t, domain.IsRunnerToken(good))
173 assert.False(t, domain.IsJobToken(good[:len(good)-1]))
174 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
175 assert.False(t, domain.IsJobToken(good+"="))
176}