| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package expr_test |
| 4 | |
| 5 | import ( |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/tenseleyFlow/shithub/internal/actions/expr" |
| 12 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 13 | ) |
| 14 | |
| 15 | // evalString is the test helper that lex + parse + eval in one shot. |
| 16 | func evalString(t *testing.T, src string, ctx *expr.Context) (expr.Value, error) { |
| 17 | t.Helper() |
| 18 | toks, err := expr.Lex(src) |
| 19 | if err != nil { |
| 20 | return expr.Value{}, err |
| 21 | } |
| 22 | ast, err := expr.Parse(toks) |
| 23 | if err != nil { |
| 24 | return expr.Value{}, err |
| 25 | } |
| 26 | return expr.Eval(ast, ctx) |
| 27 | } |
| 28 | |
| 29 | func defaultContext() *expr.Context { |
| 30 | return &expr.Context{ |
| 31 | Secrets: map[string]string{"MY_SECRET": "shh"}, |
| 32 | Vars: map[string]string{"REGION": "us-east-1"}, |
| 33 | Env: map[string]string{"GREETING": "hello"}, |
| 34 | Shithub: expr.ShithubContext{ |
| 35 | RunID: "42", |
| 36 | SHA: "deadbeef", |
| 37 | Ref: "refs/heads/trunk", |
| 38 | Actor: "alice", |
| 39 | Event: map[string]any{ |
| 40 | "pull_request": map[string]any{ |
| 41 | "title": "feat: add foo", |
| 42 | "head": map[string]any{ |
| 43 | "ref": "feat/foo", |
| 44 | }, |
| 45 | }, |
| 46 | "head_commit": map[string]any{ |
| 47 | "message": "WIP: testing", |
| 48 | }, |
| 49 | }, |
| 50 | }, |
| 51 | Untrusted: expr.DefaultUntrusted(), |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestEval_LiteralAndRefs(t *testing.T) { |
| 56 | t.Parallel() |
| 57 | ctx := defaultContext() |
| 58 | cases := []struct { |
| 59 | src string |
| 60 | want string |
| 61 | }{ |
| 62 | {`'hello'`, "hello"}, |
| 63 | {`secrets.MY_SECRET`, "shh"}, |
| 64 | {`vars.REGION`, "us-east-1"}, |
| 65 | {`env.GREETING`, "hello"}, |
| 66 | {`shithub.run_id`, "42"}, |
| 67 | {`shithub.sha`, "deadbeef"}, |
| 68 | {`shithub.actor`, "alice"}, |
| 69 | } |
| 70 | for _, tc := range cases { |
| 71 | t.Run(tc.src, func(t *testing.T) { |
| 72 | t.Parallel() |
| 73 | v, err := evalString(t, tc.src, ctx) |
| 74 | if err != nil { |
| 75 | t.Fatalf("eval: %v", err) |
| 76 | } |
| 77 | if v.S != tc.want { |
| 78 | t.Errorf("got %q, want %q", v.S, tc.want) |
| 79 | } |
| 80 | }) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestEval_TaintFromEvent(t *testing.T) { |
| 85 | t.Parallel() |
| 86 | ctx := defaultContext() |
| 87 | v, err := evalString(t, `shithub.event.pull_request.title`, ctx) |
| 88 | if err != nil { |
| 89 | t.Fatalf("eval: %v", err) |
| 90 | } |
| 91 | if v.S != "feat: add foo" { |
| 92 | t.Errorf("got %q", v.S) |
| 93 | } |
| 94 | if !v.Tainted { |
| 95 | t.Fatal("expected Tainted=true on shithub.event.* reference (load-bearing for S41d injection prevention)") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestEval_TaintNotFromTrustedSources(t *testing.T) { |
| 100 | t.Parallel() |
| 101 | ctx := defaultContext() |
| 102 | for _, src := range []string{ |
| 103 | `secrets.MY_SECRET`, |
| 104 | `vars.REGION`, |
| 105 | `env.GREETING`, |
| 106 | `shithub.run_id`, |
| 107 | `shithub.sha`, |
| 108 | `shithub.actor`, |
| 109 | } { |
| 110 | t.Run(src, func(t *testing.T) { |
| 111 | t.Parallel() |
| 112 | v, err := evalString(t, src, ctx) |
| 113 | if err != nil { |
| 114 | t.Fatalf("eval: %v", err) |
| 115 | } |
| 116 | if v.Tainted { |
| 117 | t.Fatalf("expected Tainted=false on %s; got Tainted=true (would falsely trip S41d guard)", src) |
| 118 | } |
| 119 | }) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestEval_SecretsAreSensitiveNotTainted(t *testing.T) { |
| 124 | t.Parallel() |
| 125 | ctx := defaultContext() |
| 126 | v, err := evalString(t, `secrets.MY_SECRET`, ctx) |
| 127 | if err != nil { |
| 128 | t.Fatalf("eval: %v", err) |
| 129 | } |
| 130 | if v.Tainted { |
| 131 | t.Fatal("secrets must not be tainted; they are operator-controlled") |
| 132 | } |
| 133 | if !v.Sensitive { |
| 134 | t.Fatal("secrets must be sensitive so runner argv never contains plaintext secret values") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestEval_EnvSensitivityPropagates(t *testing.T) { |
| 139 | t.Parallel() |
| 140 | ctx := defaultContext() |
| 141 | ctx.Env["TOKEN"] = "hunter2" |
| 142 | ctx.EnvSensitive = map[string]bool{"TOKEN": true} |
| 143 | v, err := evalString(t, `env.TOKEN`, ctx) |
| 144 | if err != nil { |
| 145 | t.Fatalf("Eval: %v", err) |
| 146 | } |
| 147 | if !v.Sensitive { |
| 148 | t.Fatal("env values resolved from secrets must remain sensitive") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestEval_TaintPropagatesThroughBinary(t *testing.T) { |
| 153 | t.Parallel() |
| 154 | ctx := defaultContext() |
| 155 | // 'WIP' compared to a tainted value → result tainted. |
| 156 | v, err := evalString(t, `shithub.event.pull_request.title == 'WIP'`, ctx) |
| 157 | if err != nil { |
| 158 | t.Fatalf("eval: %v", err) |
| 159 | } |
| 160 | if !v.Tainted { |
| 161 | t.Fatal("equality with a tainted operand must be tainted") |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestEval_TaintPropagatesThroughFunction(t *testing.T) { |
| 166 | t.Parallel() |
| 167 | ctx := defaultContext() |
| 168 | v, err := evalString(t, `contains(shithub.event.head_commit.message, 'WIP')`, ctx) |
| 169 | if err != nil { |
| 170 | t.Fatalf("eval: %v", err) |
| 171 | } |
| 172 | if !v.B { |
| 173 | t.Errorf("expected contains() to return true; got false") |
| 174 | } |
| 175 | if !v.Tainted { |
| 176 | t.Fatal("contains() with a tainted operand must be tainted") |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestEval_AllowedFunctions(t *testing.T) { |
| 181 | t.Parallel() |
| 182 | ctx := defaultContext() |
| 183 | cases := []struct { |
| 184 | src string |
| 185 | want bool |
| 186 | }{ |
| 187 | {`contains('hello world', 'world')`, true}, |
| 188 | {`contains('hello', 'WORLD')`, false}, |
| 189 | {`startsWith('refs/heads/release/v1', 'refs/heads/release/')`, true}, |
| 190 | {`endsWith('foo.tar.gz', '.gz')`, true}, |
| 191 | {`success()`, true}, // JobStatus zero-value: not failed, not cancelled |
| 192 | {`failure()`, false}, |
| 193 | {`always()`, true}, |
| 194 | {`cancelled()`, false}, |
| 195 | {`!success()`, false}, |
| 196 | {`true && false`, false}, |
| 197 | {`true || false`, true}, |
| 198 | {`'a' == 'a'`, true}, |
| 199 | {`'a' != 'b'`, true}, |
| 200 | } |
| 201 | for _, tc := range cases { |
| 202 | t.Run(tc.src, func(t *testing.T) { |
| 203 | t.Parallel() |
| 204 | v, err := evalString(t, tc.src, ctx) |
| 205 | if err != nil { |
| 206 | t.Fatalf("eval: %v", err) |
| 207 | } |
| 208 | if v.B != tc.want { |
| 209 | t.Errorf("got %v, want %v", v.B, tc.want) |
| 210 | } |
| 211 | }) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func TestEval_DisallowedFunctionFails(t *testing.T) { |
| 216 | t.Parallel() |
| 217 | ctx := defaultContext() |
| 218 | cases := []string{ |
| 219 | `fromJSON('{}')`, |
| 220 | `hashFiles('**/*.go')`, |
| 221 | `toJSON('foo')`, |
| 222 | `format('hello {0}', 'world')`, |
| 223 | } |
| 224 | for _, src := range cases { |
| 225 | t.Run(src, func(t *testing.T) { |
| 226 | t.Parallel() |
| 227 | _, err := evalString(t, src, ctx) |
| 228 | if err == nil { |
| 229 | t.Fatal("expected eval error for disallowed function") |
| 230 | } |
| 231 | if !strings.Contains(err.Error(), "unknown function") { |
| 232 | t.Errorf("expected 'unknown function' error, got: %v", err) |
| 233 | } |
| 234 | }) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | func TestEval_DisallowedNamespaceFails(t *testing.T) { |
| 239 | t.Parallel() |
| 240 | ctx := defaultContext() |
| 241 | // Each of these would let workflows reach into a namespace we |
| 242 | // don't want to support in v1. Some fail at lex (when the |
| 243 | // identifier shape isn't lex-valid Go-ish, e.g. `go-version`), |
| 244 | // some at eval. Both are correct rejections — neither lets |
| 245 | // the workflow author smuggle a value through. |
| 246 | cases := []string{ |
| 247 | `runner.os`, |
| 248 | `steps.foo.outputs.bar`, |
| 249 | `needs.lint.result`, |
| 250 | `matrix.versions`, |
| 251 | `inputs.foo`, |
| 252 | } |
| 253 | for _, src := range cases { |
| 254 | t.Run(src, func(t *testing.T) { |
| 255 | t.Parallel() |
| 256 | _, err := evalString(t, src, ctx) |
| 257 | if err == nil { |
| 258 | t.Fatal("expected eval error for disallowed namespace") |
| 259 | } |
| 260 | if !strings.Contains(err.Error(), "unknown namespace") { |
| 261 | t.Errorf("expected 'unknown namespace' error, got: %v", err) |
| 262 | } |
| 263 | }) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | func TestEval_MissingSecretIsError(t *testing.T) { |
| 268 | t.Parallel() |
| 269 | ctx := defaultContext() |
| 270 | _, err := evalString(t, `secrets.NOT_BOUND`, ctx) |
| 271 | if err == nil { |
| 272 | t.Fatal("expected error for unbound secret") |
| 273 | } |
| 274 | if !strings.Contains(err.Error(), "not bound") { |
| 275 | t.Errorf("expected 'not bound', got: %v", err) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func TestEval_MissingVarIsEmpty(t *testing.T) { |
| 280 | t.Parallel() |
| 281 | // vars (and env) match GHA semantics: missing → empty string, not error. |
| 282 | ctx := defaultContext() |
| 283 | v, err := evalString(t, `vars.MISSING`, ctx) |
| 284 | if err != nil { |
| 285 | t.Fatalf("eval: %v", err) |
| 286 | } |
| 287 | if v.S != "" { |
| 288 | t.Errorf("got %q", v.S) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func TestEval_MissingEventPathReturnsNull(t *testing.T) { |
| 293 | t.Parallel() |
| 294 | ctx := defaultContext() |
| 295 | v, err := evalString(t, `shithub.event.deeply.nested.missing`, ctx) |
| 296 | if err != nil { |
| 297 | t.Fatalf("eval: %v", err) |
| 298 | } |
| 299 | if v.Kind != expr.KindNull { |
| 300 | t.Errorf("got Kind=%v, want KindNull", v.Kind) |
| 301 | } |
| 302 | if !v.Tainted { |
| 303 | t.Errorf("missing-key result from event.* must still be tainted (it derives from the event payload)") |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | func TestEval_StringEscapeQuote(t *testing.T) { |
| 308 | t.Parallel() |
| 309 | ctx := defaultContext() |
| 310 | v, err := evalString(t, `'it''s ok'`, ctx) |
| 311 | if err != nil { |
| 312 | t.Fatalf("eval: %v", err) |
| 313 | } |
| 314 | if v.S != "it's ok" { |
| 315 | t.Errorf("got %q", v.S) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | // TestEval_GithubAliasResolves exercises the documented `${{ github.* }}` |
| 320 | // → `${{ shithub.* }}` rebrand alias. Workflow authors copy-pasting GHA |
| 321 | // workflows expect the github namespace to keep working; the evaluator |
| 322 | // rewrites at the namespace boundary and routes through shithub semantics. |
| 323 | // Audit S41a-H1 found this was dead code; this test pins it live. |
| 324 | func TestEval_GithubAliasResolves(t *testing.T) { |
| 325 | t.Parallel() |
| 326 | ctx := defaultContext() |
| 327 | cases := []struct { |
| 328 | src string |
| 329 | want string |
| 330 | }{ |
| 331 | {`github.run_id`, "42"}, |
| 332 | {`github.sha`, "deadbeef"}, |
| 333 | {`github.actor`, "alice"}, |
| 334 | {`github.ref`, "refs/heads/trunk"}, |
| 335 | {`github.event.pull_request.title`, "feat: add foo"}, |
| 336 | {`github.event.head_commit.message`, "WIP: testing"}, |
| 337 | } |
| 338 | for _, tc := range cases { |
| 339 | t.Run(tc.src, func(t *testing.T) { |
| 340 | t.Parallel() |
| 341 | v, err := evalString(t, tc.src, ctx) |
| 342 | if err != nil { |
| 343 | t.Fatalf("eval: %v", err) |
| 344 | } |
| 345 | if v.S != tc.want { |
| 346 | t.Errorf("got %q, want %q", v.S, tc.want) |
| 347 | } |
| 348 | }) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // TestEval_GithubAliasIsTainted asserts the rebrand alias preserves the |
| 353 | // load-bearing taint flag: github.event.* must taint exactly like |
| 354 | // shithub.event.*. If the alias rewrite happens after isUntrusted runs, |
| 355 | // taint quietly disappears and S41d's injection guard misses untrusted |
| 356 | // PR-title input. Pin it. |
| 357 | func TestEval_GithubAliasIsTainted(t *testing.T) { |
| 358 | t.Parallel() |
| 359 | ctx := defaultContext() |
| 360 | v, err := evalString(t, `github.event.pull_request.title`, ctx) |
| 361 | if err != nil { |
| 362 | t.Fatalf("eval: %v", err) |
| 363 | } |
| 364 | if !v.Tainted { |
| 365 | t.Fatal("github.event.* must be tainted (load-bearing for S41d injection prevention)") |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | // TestEval_GithubAliasNonEventNotTainted is the inverse pin: github.run_id |
| 370 | // (and friends) ride the same alias path but must NOT be tainted because |
| 371 | // they don't derive from the user-controlled event payload. |
| 372 | func TestEval_GithubAliasNonEventNotTainted(t *testing.T) { |
| 373 | t.Parallel() |
| 374 | ctx := defaultContext() |
| 375 | for _, src := range []string{`github.run_id`, `github.sha`, `github.actor`, `github.ref`} { |
| 376 | t.Run(src, func(t *testing.T) { |
| 377 | t.Parallel() |
| 378 | v, err := evalString(t, src, ctx) |
| 379 | if err != nil { |
| 380 | t.Fatalf("eval: %v", err) |
| 381 | } |
| 382 | if v.Tainted { |
| 383 | t.Fatalf("expected Tainted=false on %s; got Tainted=true (would falsely trip S41d guard)", src) |
| 384 | } |
| 385 | }) |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | func TestEval_EnvTaintPropagates(t *testing.T) { |
| 390 | t.Parallel() |
| 391 | ctx := defaultContext() |
| 392 | ctx.Env["TITLE"] = "hello" |
| 393 | ctx.EnvTaint = map[string]bool{"TITLE": true} |
| 394 | v, err := evalString(t, `env.TITLE`, ctx) |
| 395 | if err != nil { |
| 396 | t.Fatalf("Eval: %v", err) |
| 397 | } |
| 398 | if !v.Tainted { |
| 399 | t.Fatal("env values resolved from tainted expressions must remain tainted") |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | // TestEval_GithubUnknownFieldErrors confirms the alias is *narrow*: only |
| 404 | // the shithub.{run_id,sha,ref,actor,event} subset routes through. github |
| 405 | // fields we don't expose (event_name, repository, run_number, etc.) get |
| 406 | // the canonical shithub error message — slightly confusing for a github- |
| 407 | // flavored author but actionable. |
| 408 | func TestEval_GithubUnknownFieldErrors(t *testing.T) { |
| 409 | t.Parallel() |
| 410 | ctx := defaultContext() |
| 411 | for _, src := range []string{`github.event_name`, `github.repository`, `github.run_number`} { |
| 412 | t.Run(src, func(t *testing.T) { |
| 413 | t.Parallel() |
| 414 | _, err := evalString(t, src, ctx) |
| 415 | if err == nil { |
| 416 | t.Fatalf("expected eval error for unsupported github.* field %s", src) |
| 417 | } |
| 418 | if !strings.Contains(err.Error(), "unknown shithub field") { |
| 419 | t.Errorf("expected 'unknown shithub field' (canonical), got: %v", err) |
| 420 | } |
| 421 | }) |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | // TestEval_GithubAliasFixtureEndToEnd lifts the actual github-alias.yml |
| 426 | // fixture, parses the workflow, extracts the `${{ … }}` body from the |
| 427 | // step's run command, and evaluates it through the alias path. This is |
| 428 | // the end-to-end pin that would have caught S41a-H1: the fixture |
| 429 | // existed but no test exercised it through eval. |
| 430 | func TestEval_GithubAliasFixtureEndToEnd(t *testing.T) { |
| 431 | t.Parallel() |
| 432 | src, err := os.ReadFile(filepath.Join("../../../tests/fixtures/workflows", "github-alias.yml")) |
| 433 | if err != nil { |
| 434 | t.Fatalf("read fixture: %v", err) |
| 435 | } |
| 436 | w, diags, err := workflow.Parse(src) |
| 437 | if err != nil { |
| 438 | t.Fatalf("parse fixture: %v", err) |
| 439 | } |
| 440 | if len(diags) != 0 { |
| 441 | t.Fatalf("unexpected diagnostics: %v", diags) |
| 442 | } |
| 443 | // Step 0's run command is: echo "${{ github.event.head_commit.message }}" |
| 444 | run := w.Jobs[0].Steps[0].Run |
| 445 | body, ok := extractFirstExpression(run) |
| 446 | if !ok { |
| 447 | t.Fatalf("expected ${{ ... }} expression in run command, got %q", run) |
| 448 | } |
| 449 | v, err := evalString(t, body, &expr.Context{ |
| 450 | Shithub: expr.ShithubContext{ |
| 451 | Event: map[string]any{ |
| 452 | "head_commit": map[string]any{ |
| 453 | "message": "WIP: from fixture", |
| 454 | }, |
| 455 | }, |
| 456 | }, |
| 457 | Untrusted: expr.DefaultUntrusted(), |
| 458 | }) |
| 459 | if err != nil { |
| 460 | t.Fatalf("eval %q: %v", body, err) |
| 461 | } |
| 462 | if v.S != "WIP: from fixture" { |
| 463 | t.Errorf("got %q, want %q", v.S, "WIP: from fixture") |
| 464 | } |
| 465 | if !v.Tainted { |
| 466 | t.Error("github.event.head_commit.message must be tainted (S41d injection guard)") |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | // extractFirstExpression pulls the body of the first `${{ … }}` block |
| 471 | // out of s. Tiny helper used by the fixture round-trip test; the real |
| 472 | // runner-side templating in S41d will be more sophisticated. |
| 473 | func extractFirstExpression(s string) (string, bool) { |
| 474 | start := strings.Index(s, "${{") |
| 475 | if start < 0 { |
| 476 | return "", false |
| 477 | } |
| 478 | end := strings.Index(s[start:], "}}") |
| 479 | if end < 0 { |
| 480 | return "", false |
| 481 | } |
| 482 | return strings.TrimSpace(s[start+3 : start+end]), true |
| 483 | } |
| 484 | |
| 485 | // TestLex_UnicodeIdentifier pins rune-aware identifier lexing. The |
| 486 | // pre-M1 byte-level lexer would fail mid-byte on multi-byte UTF-8 |
| 487 | // characters with a confusing "unexpected character" error pointing |
| 488 | // at a continuation byte. After M1, identifiers can contain any |
| 489 | // unicode.IsLetter rune. This is a quality fix, not a security one |
| 490 | // — the byte-level lexer failed closed; the rune-aware one accepts |
| 491 | // more identifiers but the namespace allowlist still rejects them |
| 492 | // at eval time. |
| 493 | func TestLex_UnicodeIdentifier(t *testing.T) { |
| 494 | t.Parallel() |
| 495 | cases := []string{ |
| 496 | "αlpha", // Greek letter |
| 497 | "über", // Latin-1 supplement |
| 498 | "日本語", // CJK |
| 499 | "snake_α", // mixed ASCII + non-ASCII |
| 500 | "_underline", // leading underscore |
| 501 | } |
| 502 | for _, src := range cases { |
| 503 | t.Run(src, func(t *testing.T) { |
| 504 | t.Parallel() |
| 505 | toks, err := expr.Lex(src) |
| 506 | if err != nil { |
| 507 | t.Fatalf("lex %q: %v", src, err) |
| 508 | } |
| 509 | if len(toks) != 2 || toks[0].Kind != expr.TokIdent { |
| 510 | t.Fatalf("expected single Ident token + EOF, got %+v", toks) |
| 511 | } |
| 512 | if toks[0].Value != src { |
| 513 | t.Errorf("ident value: got %q, want %q", toks[0].Value, src) |
| 514 | } |
| 515 | }) |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | // TestLex_InvalidUTF8 surfaces a clean error message when src isn't |
| 520 | // valid UTF-8 — pre-M1 the lexer would feed RuneError bytes through |
| 521 | // the byte-level loop and produce a misleading offset. |
| 522 | func TestLex_InvalidUTF8(t *testing.T) { |
| 523 | t.Parallel() |
| 524 | _, err := expr.Lex(string([]byte{'a', 0x80, 'b'})) |
| 525 | if err == nil { |
| 526 | t.Fatal("expected error on invalid UTF-8") |
| 527 | } |
| 528 | if !strings.Contains(err.Error(), "invalid UTF-8") { |
| 529 | t.Errorf("expected 'invalid UTF-8' error, got: %v", err) |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | func TestEval_JobStatusFunctions(t *testing.T) { |
| 534 | t.Parallel() |
| 535 | cases := []struct { |
| 536 | failed, cancelled bool |
| 537 | src string |
| 538 | want bool |
| 539 | }{ |
| 540 | {false, false, `success()`, true}, |
| 541 | {true, false, `success()`, false}, |
| 542 | {true, false, `failure()`, true}, |
| 543 | {false, true, `cancelled()`, true}, |
| 544 | {false, true, `failure()`, false}, // cancelled overrides failure |
| 545 | {true, true, `failure()`, false}, |
| 546 | {true, true, `always()`, true}, |
| 547 | } |
| 548 | for _, tc := range cases { |
| 549 | t.Run(tc.src, func(t *testing.T) { |
| 550 | t.Parallel() |
| 551 | ctx := defaultContext() |
| 552 | ctx.JobStatus = expr.JobStatus{Failed: tc.failed, Cancelled: tc.cancelled} |
| 553 | v, err := evalString(t, tc.src, ctx) |
| 554 | if err != nil { |
| 555 | t.Fatalf("eval: %v", err) |
| 556 | } |
| 557 | if v.B != tc.want { |
| 558 | t.Errorf("failed=%v cancelled=%v %s = %v, want %v", |
| 559 | tc.failed, tc.cancelled, tc.src, v.B, tc.want) |
| 560 | } |
| 561 | }) |
| 562 | } |
| 563 | } |
| 564 |