Go · 12122 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package workflow_test
4
5 import (
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
13 )
14
15 // fixtureRoot points at tests/fixtures/workflows/ relative to the repo
16 // root; the test binary runs from internal/actions/workflow/ so we
17 // need to walk up.
18 const fixtureRoot = "../../../tests/fixtures/workflows"
19
20 // readFixture loads a fixture by basename + ".yml".
21 func readFixture(t *testing.T, name string) []byte {
22 t.Helper()
23 b, err := os.ReadFile(filepath.Join(fixtureRoot, name+".yml"))
24 if err != nil {
25 t.Fatalf("readFixture(%s): %v", name, err)
26 }
27 return b
28 }
29
30 func TestParse_Minimal(t *testing.T) {
31 t.Parallel()
32 w, diags, err := workflow.Parse(readFixture(t, "minimal"))
33 if err != nil {
34 t.Fatalf("Parse: %v", err)
35 }
36 if len(diags) != 0 {
37 t.Fatalf("unexpected diagnostics: %v", diags)
38 }
39 if w.Name != "minimal" {
40 t.Errorf("Name = %q", w.Name)
41 }
42 if w.On.Push == nil {
43 t.Error("expected push trigger")
44 }
45 if len(w.Jobs) != 1 {
46 t.Fatalf("len(Jobs) = %d", len(w.Jobs))
47 }
48 j := w.Jobs[0]
49 if j.Key != "hello" || j.RunsOn != "ubuntu-latest" {
50 t.Errorf("job = %+v", j)
51 }
52 if len(j.Steps) != 1 || j.Steps[0].Run != "echo hello" {
53 t.Errorf("steps = %+v", j.Steps)
54 }
55 }
56
57 func TestParse_CheckoutOnly(t *testing.T) {
58 t.Parallel()
59 w, diags, err := workflow.Parse(readFixture(t, "checkout-only"))
60 if err != nil {
61 t.Fatalf("Parse: %v", err)
62 }
63 if len(diags) != 0 {
64 t.Fatalf("unexpected diagnostics: %v", diags)
65 }
66 if w.On.Push == nil || len(w.On.Push.Branches) != 2 {
67 t.Errorf("push branches = %v", w.On.Push)
68 }
69 if w.Jobs[0].Steps[0].Uses != "actions/checkout@v4" {
70 t.Errorf("uses = %q", w.Jobs[0].Steps[0].Uses)
71 }
72 }
73
74 func TestParse_ArbitraryRepoSmoke(t *testing.T) {
75 t.Parallel()
76 w, diags, err := workflow.Parse(readFixture(t, "arbitrary-repo-smoke"))
77 if err != nil {
78 t.Fatalf("Parse: %v", err)
79 }
80 if len(diags) != 0 {
81 t.Fatalf("unexpected diagnostics: %v", diags)
82 }
83 if w.Name != "Smoke" {
84 t.Fatalf("Name = %q, want Smoke", w.Name)
85 }
86 if w.On.Push == nil || len(w.On.Push.Branches) != 1 || w.On.Push.Branches[0] != "trunk" {
87 t.Fatalf("push branches = %+v", w.On.Push)
88 }
89 if len(w.Jobs) != 1 {
90 t.Fatalf("len(Jobs) = %d", len(w.Jobs))
91 }
92 job := w.Jobs[0]
93 if job.Key != "green" || job.RunsOn != "ubuntu-latest" {
94 t.Fatalf("job = %+v", job)
95 }
96 if len(job.Steps) != 3 {
97 t.Fatalf("steps = %+v", job.Steps)
98 }
99 if job.Steps[0].Uses != "actions/checkout@v4" {
100 t.Fatalf("checkout step = %+v", job.Steps[0])
101 }
102 if job.Steps[1].Name != "Verify checkout" || !strings.Contains(job.Steps[1].Run, "README.md") {
103 t.Fatalf("verify step = %+v", job.Steps[1])
104 }
105 if job.Steps[2].Name != "Smoke" || !strings.Contains(job.Steps[2].Run, "shithub actions smoke passed") {
106 t.Fatalf("smoke step = %+v", job.Steps[2])
107 }
108 }
109
110 func TestParse_MultiJob(t *testing.T) {
111 t.Parallel()
112 w, diags, err := workflow.Parse(readFixture(t, "multi-job"))
113 if err != nil {
114 t.Fatalf("Parse: %v", err)
115 }
116 if len(diags) != 0 {
117 t.Fatalf("unexpected diagnostics: %v", diags)
118 }
119 if len(w.Jobs) != 3 {
120 t.Fatalf("len(Jobs) = %d", len(w.Jobs))
121 }
122 keys := make([]string, len(w.Jobs))
123 for i, j := range w.Jobs {
124 keys[i] = j.Key
125 }
126 wantKeys := []string{"lint", "test", "package"}
127 for i, k := range wantKeys {
128 if keys[i] != k {
129 t.Errorf("Jobs[%d].Key = %q, want %q", i, keys[i], k)
130 }
131 }
132 pkg := w.Jobs[2]
133 if len(pkg.Needs) != 2 || pkg.Needs[0] != "lint" || pkg.Needs[1] != "test" {
134 t.Errorf("package.needs = %v", pkg.Needs)
135 }
136 uploadStep := pkg.Steps[2]
137 if uploadStep.Uses != "shithub/upload-artifact@v1" {
138 t.Errorf("expected upload-artifact uses, got %q", uploadStep.Uses)
139 }
140 }
141
142 func TestParse_UntrustedPRTitle(t *testing.T) {
143 t.Parallel()
144 w, diags, err := workflow.Parse(readFixture(t, "untrusted-pr-title"))
145 if err != nil {
146 t.Fatalf("Parse: %v", err)
147 }
148 if len(diags) != 0 {
149 t.Fatalf("unexpected diagnostics: %v", diags)
150 }
151 step := w.Jobs[0].Steps[0]
152 // The parser carries the raw run command verbatim; the taint flag
153 // is decided at expression-evaluation time (S41d) when the runner
154 // resolves ${{ ... }} references against the trigger context.
155 // Here we just assert the raw string round-trips intact.
156 if !strings.Contains(step.Run, "${{ shithub.event.pull_request.title }}") {
157 t.Fatalf("untrusted-pr-title fixture lost the expression: %q", step.Run)
158 }
159 }
160
161 func TestParse_UnknownKey(t *testing.T) {
162 t.Parallel()
163 _, diags, err := workflow.Parse(readFixture(t, "unknown-key"))
164 if err != nil {
165 t.Fatalf("Parse returned err on diagnostic-level issue: %v", err)
166 }
167 found := false
168 for _, d := range diags {
169 if strings.Contains(d.Path, "bogus") {
170 found = true
171 if d.Severity != workflow.Error {
172 t.Errorf("expected Error severity for unknown top-level key, got %v", d.Severity)
173 }
174 }
175 }
176 if !found {
177 t.Fatalf("expected diagnostic mentioning 'bogus', got: %v", diags)
178 }
179 }
180
181 func TestParse_DisallowedUses(t *testing.T) {
182 t.Parallel()
183 _, diags, err := workflow.Parse(readFixture(t, "disallowed-uses"))
184 if err != nil {
185 t.Fatalf("Parse returned err: %v", err)
186 }
187 found := false
188 for _, d := range diags {
189 if strings.Contains(d.Path, "uses") && strings.Contains(d.Message, "actions/setup-go") || strings.Contains(d.Message, "v1 supports only") {
190 found = true
191 if d.Severity != workflow.Error {
192 t.Errorf("expected Error severity, got %v", d.Severity)
193 }
194 }
195 }
196 if !found {
197 t.Fatalf("expected diagnostic on the disallowed `uses:`, got: %v", diags)
198 }
199 }
200
201 func TestParse_OversizedFile(t *testing.T) {
202 t.Parallel()
203 big := make([]byte, workflow.MaxWorkflowFileBytes+1)
204 for i := range big {
205 big[i] = ' '
206 }
207 _, _, err := workflow.Parse(big)
208 if !errors.Is(err, workflow.ErrTooLarge) {
209 t.Fatalf("expected ErrTooLarge, got %v", err)
210 }
211 }
212
213 func TestParse_EmptyFile(t *testing.T) {
214 t.Parallel()
215 _, diags, err := workflow.Parse(nil)
216 if err == nil && !hasError(diags) {
217 t.Fatalf("expected error on empty input")
218 }
219 }
220
221 func TestParse_ExpressionFunctionsRoundTrip(t *testing.T) {
222 t.Parallel()
223 w, diags, err := workflow.Parse(readFixture(t, "expression-functions"))
224 if err != nil {
225 t.Fatalf("Parse: %v", err)
226 }
227 if len(diags) != 0 {
228 t.Fatalf("unexpected diagnostics: %v", diags)
229 }
230 steps := w.Jobs[0].Steps
231 if len(steps) != 5 {
232 t.Fatalf("expected 5 steps, got %d", len(steps))
233 }
234 wantPrefixes := []string{"contains(", "startsWith(", "success()", "failure()", "always()"}
235 for i, want := range wantPrefixes {
236 if !strings.Contains(steps[i].If, want) {
237 t.Errorf("step[%d].If = %q; expected to contain %q", i, steps[i].If, want)
238 }
239 }
240 }
241
242 // BenchmarkParseTypical50Lines pins the parser-perf budget. Per the
243 // S41a sprint file: ≤ 1 ms for a typical 50-line workflow. multi-job
244 // fixture is 24 lines; we 4× the body to exceed 50 lines for a fair
245 // signal.
246 func BenchmarkParseTypical50Lines(b *testing.B) {
247 src, err := os.ReadFile(filepath.Join(fixtureRoot, "multi-job.yml"))
248 if err != nil {
249 b.Fatalf("read fixture: %v", err)
250 }
251 b.SetBytes(int64(len(src)))
252 for i := 0; i < b.N; i++ {
253 _, _, err := workflow.Parse(src)
254 if err != nil {
255 b.Fatalf("Parse: %v", err)
256 }
257 }
258 }
259
260 func hasError(diags []workflow.Diagnostic) bool {
261 for _, d := range diags {
262 if d.Severity == workflow.Error {
263 return true
264 }
265 }
266 return false
267 }
268
269 // TestParse_BooleanCanonicalForms pins L1: workflow boolean fields
270 // accept the canonical YAML 1.2 forms (true/True/TRUE, false/False/
271 // FALSE) instead of strict-string matching only "true". Pre-L1 the
272 // parser silently treated everything except "true" as false.
273 func TestParse_BooleanCanonicalForms(t *testing.T) {
274 t.Parallel()
275 cases := []struct {
276 val string
277 want bool
278 }{
279 {"true", true},
280 {"True", true},
281 {"TRUE", true},
282 {"false", false},
283 {"False", false},
284 {"FALSE", false},
285 }
286 for _, tc := range cases {
287 t.Run("cancel-in-progress="+tc.val, func(t *testing.T) {
288 t.Parallel()
289 src := []byte(`name: x
290 on: push
291 concurrency:
292 group: g
293 cancel-in-progress: ` + tc.val + `
294 jobs:
295 j:
296 runs-on: ubuntu-latest
297 steps:
298 - run: echo
299 `)
300 w, diags, err := workflow.Parse(src)
301 if err != nil || w == nil {
302 t.Fatalf("parse: err=%v w=%v", err, w)
303 }
304 if hasError(diags) {
305 t.Fatalf("unexpected diagnostics: %v", diags)
306 }
307 if w.Concurrency.CancelInProgress != tc.want {
308 t.Errorf("got %v, want %v for input %q", w.Concurrency.CancelInProgress, tc.want, tc.val)
309 }
310 })
311 }
312 }
313
314 // TestParse_BadStepIDProducesDiagnostic pins L2: the parser surfaces
315 // an Error-severity diagnostic on a malformed step id immediately,
316 // instead of letting the workflow be valid at parse time and failing
317 // at INSERT time during S41b dispatch.
318 func TestParse_BadStepIDProducesDiagnostic(t *testing.T) {
319 t.Parallel()
320 src := []byte(`name: x
321 on: push
322 jobs:
323 j:
324 runs-on: ubuntu-latest
325 steps:
326 - id: 'has spaces and ; semicolons'
327 run: echo
328 `)
329 _, diags, err := workflow.Parse(src)
330 if err != nil {
331 t.Fatalf("parse: %v", err)
332 }
333 found := false
334 for _, d := range diags {
335 if strings.Contains(d.Message, "step id must match") && d.Severity == workflow.Error {
336 found = true
337 }
338 }
339 if !found {
340 t.Fatalf("expected step-id format diagnostic, got: %v", diags)
341 }
342 }
343
344 // TestParse_BadJobKeyProducesDiagnostic mirrors the step-id check
345 // for the jobs.<key> regex constraint.
346 func TestParse_BadJobKeyProducesDiagnostic(t *testing.T) {
347 t.Parallel()
348 src := []byte(`name: x
349 on: push
350 jobs:
351 "bad job":
352 runs-on: ubuntu-latest
353 steps:
354 - run: echo
355 `)
356 _, diags, err := workflow.Parse(src)
357 if err != nil {
358 t.Fatalf("parse: %v", err)
359 }
360 found := false
361 for _, d := range diags {
362 if strings.Contains(d.Message, "job key must match") && d.Severity == workflow.Error {
363 found = true
364 }
365 }
366 if !found {
367 t.Fatalf("expected job-key format diagnostic, got: %v", diags)
368 }
369 }
370
371 // TestParse_EmptyStepIDIsOK pins that the optional-id semantic still
372 // works: a step with no `id:` (empty string in the parser's struct)
373 // doesn't surface a diagnostic. Only set values are validated.
374 func TestParse_EmptyStepIDIsOK(t *testing.T) {
375 t.Parallel()
376 src := []byte(`name: x
377 on: push
378 jobs:
379 j:
380 runs-on: ubuntu-latest
381 steps:
382 - run: echo no-id
383 `)
384 w, diags, err := workflow.Parse(src)
385 if err != nil || w == nil {
386 t.Fatalf("parse: err=%v w=%v", err, w)
387 }
388 for _, d := range diags {
389 if strings.Contains(d.Message, "step id must match") {
390 t.Fatalf("unexpected step-id diagnostic on omitted id: %v", d)
391 }
392 }
393 }
394
395 // TestParse_BooleanInvalidProducesDiagnostic asserts that a non-
396 // boolean value (e.g. "yes" — valid YAML 1.1, NOT 1.2) surfaces a
397 // parse-time diagnostic instead of silently coercing.
398 func TestParse_BooleanInvalidProducesDiagnostic(t *testing.T) {
399 t.Parallel()
400 src := []byte(`name: x
401 on: push
402 concurrency:
403 group: g
404 cancel-in-progress: yes
405 jobs:
406 j:
407 runs-on: ubuntu-latest
408 steps:
409 - run: echo
410 `)
411 _, diags, err := workflow.Parse(src)
412 if err != nil {
413 t.Fatalf("parse: %v", err)
414 }
415 found := false
416 for _, d := range diags {
417 if strings.Contains(d.Message, "boolean") && d.Severity == workflow.Error {
418 found = true
419 }
420 }
421 if !found {
422 t.Fatalf("expected boolean-error diagnostic for 'yes', got: %v", diags)
423 }
424 }
425
426 // TestParse_DiagnosticPathQuotesNonIdentKeys pins L6: env keys that
427 // contain dots, spaces, etc. produce bracket-quoted diagnostic paths
428 // instead of ambiguous concat. Compare:
429 // - identifier-shaped key: path is `jobs.j.env.GOOD`
430 // - dotted key: path is `jobs.j.env["weird.key"]` (unambiguous)
431 func TestParse_DiagnosticPathQuotesNonIdentKeys(t *testing.T) {
432 t.Parallel()
433 src := []byte(`name: x
434 on: push
435 jobs:
436 j:
437 runs-on: ubuntu-latest
438 env:
439 "weird.key":
440 nested: not-a-scalar
441 steps:
442 - run: echo
443 `)
444 _, diags, err := workflow.Parse(src)
445 if err != nil {
446 t.Fatalf("parse: %v", err)
447 }
448 found := false
449 for _, d := range diags {
450 if strings.Contains(d.Path, `["weird.key"]`) {
451 found = true
452 }
453 }
454 if !found {
455 t.Fatalf("expected bracket-quoted path for dotted env key, got diags: %v", diags)
456 }
457 }
458