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 return m
58}
59
60func (m *mergeUnit) merge() error {
61 _, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
62 return err
63}
64
65// AC5: the merge rule under FailingChecksBlock.
66func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
67 t.Parallel()
68 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
69 return domain.CICheck{Name: name, Status: status, Conclusion: c}
70 }
71 tests := []struct {
72 name string
73 checks []domain.CICheck
74 blocked string // part of the message
75 }{
76 {"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
77 {"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
78 {"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
79 {"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
80 {"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
81 {"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
82 }
83 for _, tt := range tests {
84 t.Run(tt.name, func(t *testing.T) {
85 t.Parallel()
86 m := newMergeUnit(t, true)
87 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
88 // The merge stops at the rule: the branch mock expects no move.
89 err := m.merge()
90 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
91 assert.Contains(t, err.Error(), tt.blocked)
92 })
93 }
94 t.Run("too many checks to tell", func(t *testing.T) {
95 t.Parallel()
96 m := newMergeUnit(t, true)
97 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
98 err := m.merge()
99 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
100 assert.Contains(t, err.Error(), "too many checks")
101 })
102 // The cases where the merge goes on (no check, all passing, rule off) need
103 // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
104}
105
106func TestCombineChecks(t *testing.T) {
107 t.Parallel()
108 ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
109 return domain.CICheck{Status: s, Conclusion: c}
110 }
111 assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
112 assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
113 assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
114 assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
115}
116
117func TestLabelsMatchAndRunnerLabels(t *testing.T) {
118 t.Parallel()
119 have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
120 assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
121 assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
122 assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
123 assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
124 assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
125 assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
126 assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
127}
128
129func TestStepRanges(t *testing.T) {
130 t.Parallel()
131 steps := []domain.CIStep{
132 {Number: 1, HasLines: true, LogStart: 0},
133 {Number: 2},
134 {Number: 3, HasLines: true, LogStart: 4},
135 {Number: 4, HasLines: true, LogStart: 9},
136 {Number: 5},
137 }
138 got := domain.StepRanges(steps, 12)
139 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)
140 assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
141 assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
142 assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
143}
144
145func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
146 t.Parallel()
147 id := domain.NewJobID(42, 3, 1)
148 run, index, combo, ok := domain.ParseJobID(id)
149 require.True(t, ok)
150 assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
151 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"} {
152 _, _, _, ok := domain.ParseJobID(bad)
153 assert.False(t, ok, bad)
154 }
155}
156
157func TestOutputLimitsAndNames(t *testing.T) {
158 t.Parallel()
159 assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
160 assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
161 assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
162 assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
163 assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
164 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")
165}
166
167func TestTokenShapes(t *testing.T) {
168 t.Parallel()
169 good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
170 assert.True(t, domain.IsJobToken(good))
171 assert.False(t, domain.IsRunnerToken(good))
172 assert.False(t, domain.IsJobToken(good[:len(good)-1]))
173 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
174 assert.False(t, domain.IsJobToken(good+"="))
175}