diff --git a/.github/workflows/daily-model-inventory.lock.yml b/.github/workflows/daily-model-inventory.lock.yml index 4ad7596a490..89bcc656530 100644 --- a/.github/workflows/daily-model-inventory.lock.yml +++ b/.github/workflows/daily-model-inventory.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"941bf2df6cf246f352cfb0607cd13f711a6fa33bb44bfd133f7ff0ccd4f3aa33","body_hash":"eddd84cb14b52557f5fd4337e9e7d5fcf36fa38a1bbe0e74d773293aa86b64b6","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"941bf2df6cf246f352cfb0607cd13f711a6fa33bb44bfd133f7ff0ccd4f3aa33","body_hash":"1411aa03bf28a3c98eff9d54cdee03ed6e87584a3b11c9f6dd740cb2b466ea00","strict":true,"agent_id":"copilot"} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GEMINI_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.58"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.22"},{"image":"ghcr.io/github/github-mcp-server:v1.1.0"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]} # ___ _ _ # / _ \ | | (_) diff --git a/pkg/workflow/frontmatter_extraction_yaml_test.go b/pkg/workflow/frontmatter_extraction_yaml_test.go index 6119668246d..84d989a6fe3 100644 --- a/pkg/workflow/frontmatter_extraction_yaml_test.go +++ b/pkg/workflow/frontmatter_extraction_yaml_test.go @@ -2,12 +2,220 @@ package workflow -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsGitHubAppNestedField(t *testing.T) { - t.Run("supports ignore-if-missing field", func(t *testing.T) { - if !isGitHubAppNestedField("ignore-if-missing: true") { - t.Fatal("expected ignore-if-missing to be treated as on.github-app nested field") - } - }) + tests := []struct { + name string + line string + want bool + }{ + {name: "app id field", line: "app-id: 123", want: true}, + {name: "client id field", line: "client-id: abc", want: true}, + {name: "private key field", line: "private-key: ${{ secrets.KEY }}", want: true}, + {name: "ignore-if-missing field", line: "ignore-if-missing: true", want: true}, + {name: "owner field", line: "owner: octocat", want: true}, + {name: "repositories field", line: "repositories:", want: true}, + {name: "array item", line: "- gh-aw", want: true}, + {name: "non-matching field", line: "ignored-field: value", want: false}, + {name: "partial field name", line: "app-idd: 123", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGitHubAppNestedField(tt.line) + assert.Equal(t, tt.want, got, "isGitHubAppNestedField(%q) should return %t", tt.line, tt.want) + }) + } +} + +func TestIsValidWorkflowRunConclusion(t *testing.T) { + tests := []struct { + name string + conclusion string + want bool + }{ + {name: "success", conclusion: "success", want: true}, + {name: "failure", conclusion: "failure", want: true}, + {name: "neutral", conclusion: "neutral", want: true}, + {name: "cancelled", conclusion: "cancelled", want: true}, + {name: "skipped", conclusion: "skipped", want: true}, + {name: "timed_out", conclusion: "timed_out", want: true}, + {name: "action_required", conclusion: "action_required", want: true}, + {name: "stale", conclusion: "stale", want: true}, + {name: "unknown value", conclusion: "done", want: false}, + {name: "expression injection attempt", conclusion: "success' || contains(github.event.comment.body, 'x') || '", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidWorkflowRunConclusion(tt.conclusion) + assert.Equal(t, tt.want, got, "isValidWorkflowRunConclusion(%q) should return %t", tt.conclusion, tt.want) + }) + } +} + +func TestIndentYAMLLines(t *testing.T) { + compiler := NewCompiler() + + tests := []struct { + name string + input string + indent string + wantOut string + }{ + { + name: "empty input", + input: "", + indent: " ", + wantOut: "", + }, + { + name: "single line", + input: "name: test", + indent: " ", + wantOut: "name: test", + }, + { + name: "multi line", + input: "name: test\nruns-on: ubuntu-latest\nsteps:", + indent: " ", + wantOut: "name: test\n runs-on: ubuntu-latest\n steps:", + }, + { + name: "blank lines are preserved without extra indentation", + input: "first: value\n\nsecond: value\n \nthird: value", + indent: " ", + wantOut: "first: value\n\n second: value\n \n third: value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compiler.indentYAMLLines(tt.input, tt.indent) + assert.Equal(t, tt.wantOut, got, "indentYAMLLines should preserve formatting for %q", tt.name) + }) + } +} + +func TestExtractDeploymentStatusStateCondition(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + want string + }{ + { + name: "missing on section", + frontmatter: map[string]any{}, + want: "", + }, + { + name: "single state", + frontmatter: map[string]any{ + "on": map[string]any{ + "deployment_status": map[string]any{ + "state": "success", + }, + }, + }, + want: "github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success')", + }, + { + name: "multiple states", + frontmatter: map[string]any{ + "on": map[string]any{ + "deployment_status": map[string]any{ + "state": []any{"success", "failure"}, + }, + }, + }, + want: "github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success' || github.event.deployment_status.state == 'failure')", + }, + { + name: "mixed array keeps only strings", + frontmatter: map[string]any{ + "on": map[string]any{ + "deployment_status": map[string]any{ + "state": []any{"success", 42}, + }, + }, + }, + want: "github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success')", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractDeploymentStatusStateCondition(tt.frontmatter) + assert.Equal(t, tt.want, got, "extractDeploymentStatusStateCondition should match expected expression for %q", tt.name) + }) + } +} + +func TestExtractWorkflowRunConclusionConditionHelper(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + want string + wantErr bool + }{ + { + name: "missing on section", + frontmatter: map[string]any{}, + want: "", + wantErr: false, + }, + { + name: "single valid conclusion", + frontmatter: map[string]any{ + "on": map[string]any{ + "workflow_run": map[string]any{ + "conclusion": "failure", + }, + }, + }, + want: "github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'failure')", + wantErr: false, + }, + { + name: "multiple valid conclusions", + frontmatter: map[string]any{ + "on": map[string]any{ + "workflow_run": map[string]any{ + "conclusion": []any{"failure", "timed_out"}, + }, + }, + }, + want: "github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'timed_out')", + wantErr: false, + }, + { + name: "invalid conclusion rejects injection attempt", + frontmatter: map[string]any{ + "on": map[string]any{ + "workflow_run": map[string]any{ + "conclusion": "failure' || github.actor == 'attacker' || '", + }, + }, + }, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractWorkflowRunConclusionCondition(tt.frontmatter) + if tt.wantErr { + assert.Error(t, err, "extractWorkflowRunConclusionCondition should reject invalid conclusion for %q", tt.name) + } else { + assert.NoError(t, err, "extractWorkflowRunConclusionCondition should not return error for %q", tt.name) + } + assert.Equal(t, tt.want, got, "extractWorkflowRunConclusionCondition should return expected expression for %q", tt.name) + }) + } }