| 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 |