|
|
|
@@ -0,0 +1,597 @@
|
|
|
|
|
package linter
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"git.k3nny.fr/glint/internal/model"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ── checkWhen ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckWhen(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
job model.Job
|
|
|
|
|
wantN int
|
|
|
|
|
wantRule string
|
|
|
|
|
}{
|
|
|
|
|
{"valid when", model.Job{When: "on_success"}, 0, ""},
|
|
|
|
|
{"invalid when", model.Job{When: "bad_value"}, 1, RuleInvalidWhen},
|
|
|
|
|
{"delayed requires start_in", model.Job{When: "delayed"}, 1, RuleDelayedNoStartIn},
|
|
|
|
|
{"delayed with start_in", model.Job{When: "delayed", StartIn: "30 minutes"}, 0, ""},
|
|
|
|
|
{"start_in without delayed", model.Job{When: "on_success", StartIn: "5m"}, 1, RuleStartInNoDelayed},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
got := checkWhen("j", tc.job)
|
|
|
|
|
if len(got) != tc.wantN {
|
|
|
|
|
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
|
|
|
|
|
}
|
|
|
|
|
if tc.wantRule != "" && len(got) > 0 && got[0].Rule != tc.wantRule {
|
|
|
|
|
t.Errorf("rule: got %q want %q", got[0].Rule, tc.wantRule)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkParallel ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckParallel(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
job model.Job
|
|
|
|
|
wantN int
|
|
|
|
|
}{
|
|
|
|
|
{"nil parallel", model.Job{}, 0},
|
|
|
|
|
{"valid int", model.Job{Parallel: 4}, 0},
|
|
|
|
|
{"int too low", model.Job{Parallel: 1}, 1},
|
|
|
|
|
{"int too high", model.Job{Parallel: 201}, 1},
|
|
|
|
|
{"map with matrix", model.Job{Parallel: map[string]any{"matrix": []any{}}}, 0},
|
|
|
|
|
{"map without matrix", model.Job{Parallel: map[string]any{"other": true}}, 1},
|
|
|
|
|
{"invalid type", model.Job{Parallel: "string"}, 1},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
got := checkParallel("j", tc.job)
|
|
|
|
|
if len(got) != tc.wantN {
|
|
|
|
|
t.Errorf("got %d findings want %d", len(got), tc.wantN)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkRetry ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckRetry(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
job model.Job
|
|
|
|
|
wantN int
|
|
|
|
|
}{
|
|
|
|
|
{"nil retry", model.Job{}, 0},
|
|
|
|
|
{"valid int 0", model.Job{Retry: 0}, 0},
|
|
|
|
|
{"valid int 2", model.Job{Retry: 2}, 0},
|
|
|
|
|
{"invalid int -1", model.Job{Retry: -1}, 1},
|
|
|
|
|
{"invalid int 3", model.Job{Retry: 3}, 1},
|
|
|
|
|
{"map with valid max", model.Job{Retry: map[string]any{"max": 1}}, 0},
|
|
|
|
|
{"map with invalid max", model.Job{Retry: map[string]any{"max": 5}}, 1},
|
|
|
|
|
{"map with valid when string", model.Job{Retry: map[string]any{"when": "always"}}, 0},
|
|
|
|
|
{"map with invalid when string", model.Job{Retry: map[string]any{"when": "bad_reason"}}, 1},
|
|
|
|
|
{"map with when slice", model.Job{Retry: map[string]any{"when": []any{"always", "script_failure"}}}, 0},
|
|
|
|
|
{"map with when slice invalid", model.Job{Retry: map[string]any{"when": []any{"bad_reason"}}}, 1},
|
|
|
|
|
{"invalid type", model.Job{Retry: "string"}, 1},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
got := checkRetry("j", tc.job)
|
|
|
|
|
if len(got) != tc.wantN {
|
|
|
|
|
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkAllowFailure ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckAllowFailure(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
job model.Job
|
|
|
|
|
wantN int
|
|
|
|
|
}{
|
|
|
|
|
{"nil", model.Job{}, 0},
|
|
|
|
|
{"bool true", model.Job{Allow: true}, 0},
|
|
|
|
|
{"bool false", model.Job{Allow: false}, 0},
|
|
|
|
|
{"map with exit_codes", model.Job{Allow: map[string]any{"exit_codes": []any{1, 2}}}, 0},
|
|
|
|
|
{"map without exit_codes", model.Job{Allow: map[string]any{"other": true}}, 1},
|
|
|
|
|
{"invalid type", model.Job{Allow: "yes"}, 1},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
got := checkAllowFailure("j", tc.job)
|
|
|
|
|
if len(got) != tc.wantN {
|
|
|
|
|
t.Errorf("got %d findings want %d", len(got), tc.wantN)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkInterruptible ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckInterruptible(t *testing.T) {
|
|
|
|
|
if len(checkInterruptible("j", model.Job{})) != 0 { t.Error("nil: expected clean") }
|
|
|
|
|
if len(checkInterruptible("j", model.Job{Interruptible: true})) != 0 { t.Error("bool true: expected clean") }
|
|
|
|
|
if len(checkInterruptible("j", model.Job{Interruptible: false})) != 0 { t.Error("bool false: expected clean") }
|
|
|
|
|
if len(checkInterruptible("j", model.Job{Interruptible: "yes"})) != 1 { t.Error("string: expected error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkTrigger ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckTrigger(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
job model.Job
|
|
|
|
|
wantN int
|
|
|
|
|
}{
|
|
|
|
|
{"nil trigger", model.Job{}, 0},
|
|
|
|
|
{"string trigger", model.Job{Trigger: "other/project"}, 0},
|
|
|
|
|
{"trigger with script", model.Job{Trigger: "x", Script: []any{"echo"}}, 1},
|
|
|
|
|
{"map trigger with project", model.Job{Trigger: map[string]any{"project": "g/p"}}, 0},
|
|
|
|
|
{"map trigger with include", model.Job{Trigger: map[string]any{"include": "ci.yml"}}, 0},
|
|
|
|
|
{"map trigger missing both", model.Job{Trigger: map[string]any{"strategy": "depend"}}, 1},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
got := checkTrigger("j", tc.job)
|
|
|
|
|
if len(got) != tc.wantN {
|
|
|
|
|
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkCoverage ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckCoverage(t *testing.T) {
|
|
|
|
|
if len(checkCoverage("j", model.Job{})) != 0 { t.Error("empty: clean") }
|
|
|
|
|
if len(checkCoverage("j", model.Job{Coverage: `/\d+%/`})) != 0 { t.Error("valid: clean") }
|
|
|
|
|
if len(checkCoverage("j", model.Job{Coverage: "bad"})) != 1 { t.Error("missing slashes: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkRelease ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckRelease(t *testing.T) {
|
|
|
|
|
if len(checkRelease("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkRelease("j", model.Job{Release: "string"})) != 1 { t.Error("string: error") }
|
|
|
|
|
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": "v1"}})) != 0 { t.Error("valid map: clean") }
|
|
|
|
|
if len(checkRelease("j", model.Job{Release: map[string]any{"description": "x"}})) != 1 { t.Error("no tag_name: error") }
|
|
|
|
|
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": ""}})) != 1 { t.Error("empty tag_name: error") }
|
|
|
|
|
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": nil}})) != 1 { t.Error("nil tag_name: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkEnvironment ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckEnvironment(t *testing.T) {
|
|
|
|
|
if len(checkEnvironment("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkEnvironment("j", model.Job{Environment: "production"})) != 0 { t.Error("string: clean") }
|
|
|
|
|
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod"}})) != 0 { t.Error("map with name: clean") }
|
|
|
|
|
// url without name
|
|
|
|
|
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"url": "https://x.com"}})) != 1 { t.Error("url no name: error") }
|
|
|
|
|
// invalid action
|
|
|
|
|
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "badaction"}})) != 1 { t.Error("bad action: error") }
|
|
|
|
|
// valid action
|
|
|
|
|
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "stop"}})) != 0 { t.Error("stop action: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkArtifacts ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckArtifacts(t *testing.T) {
|
|
|
|
|
if len(checkArtifacts("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
// non-map type: no-op
|
|
|
|
|
if len(checkArtifacts("j", model.Job{Artifacts: "string"})) != 0 { t.Error("string: clean") }
|
|
|
|
|
// valid when
|
|
|
|
|
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "on_failure"}})) != 0 { t.Error("valid when: clean") }
|
|
|
|
|
// invalid when
|
|
|
|
|
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "bad_when"}})) != 1 { t.Error("bad when: error") }
|
|
|
|
|
// expose_as without paths
|
|
|
|
|
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "Coverage"}})) != 1 { t.Error("expose_as no paths: error") }
|
|
|
|
|
// expose_as with paths
|
|
|
|
|
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "X", "paths": []any{"out/"}}})) != 0 { t.Error("expose_as with paths: clean") }
|
|
|
|
|
// pages job without public in paths
|
|
|
|
|
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 1 { t.Error("pages without public: warning") }
|
|
|
|
|
// pages job with public in paths
|
|
|
|
|
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"public"}}})) != 0 { t.Error("pages with public: clean") }
|
|
|
|
|
// pages job with pages: keyword set — GL033 check skipped
|
|
|
|
|
if len(checkArtifacts("pages", model.Job{Pages: map[string]any{}, Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 0 { t.Error("pages with pages keyword: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkCache ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckCache(t *testing.T) {
|
|
|
|
|
if len(checkCache("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
// map form valid
|
|
|
|
|
if len(checkCache("j", model.Job{Cache: map[string]any{"key": "abc"}})) != 0 { t.Error("map valid: clean") }
|
|
|
|
|
// map form invalid when
|
|
|
|
|
if len(checkCache("j", model.Job{Cache: map[string]any{"when": "bad"}})) != 1 { t.Error("invalid when: error") }
|
|
|
|
|
// map form invalid policy
|
|
|
|
|
if len(checkCache("j", model.Job{Cache: map[string]any{"policy": "bad_policy"}})) != 1 { t.Error("invalid policy: error") }
|
|
|
|
|
// slice form
|
|
|
|
|
if len(checkCache("j", model.Job{Cache: []any{
|
|
|
|
|
map[string]any{"when": "bad"},
|
|
|
|
|
map[string]any{"policy": "pull"},
|
|
|
|
|
}})) != 1 { t.Error("slice with one bad: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkRules ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckRules(t *testing.T) {
|
|
|
|
|
if len(checkRules("j", model.Job{})) != 0 { t.Error("no rules: clean") }
|
|
|
|
|
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "on_success"}}})) != 0 { t.Error("valid when: clean") }
|
|
|
|
|
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "bad_when"}}})) != 1 { t.Error("bad when: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkImage ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckImage(t *testing.T) {
|
|
|
|
|
if len(checkImage("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkImage("j", model.Job{Image: "alpine"})) != 0 { t.Error("string: clean") }
|
|
|
|
|
if len(checkImage("j", model.Job{Image: map[string]any{"name": "alpine"}})) != 0 { t.Error("map with name: clean") }
|
|
|
|
|
if len(checkImage("j", model.Job{Image: map[string]any{"pull_policy": "always"}})) != 1 { t.Error("map without name: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkInherit ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckInherit(t *testing.T) {
|
|
|
|
|
if len(checkInherit("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkInherit("j", model.Job{Inherit: "string"})) != 0 { t.Error("non-map: clean") }
|
|
|
|
|
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": true}})) != 0 { t.Error("bool: clean") }
|
|
|
|
|
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": []any{"image"}}})) != 0 { t.Error("list: clean") }
|
|
|
|
|
if len(checkInherit("j", model.Job{Inherit: map[string]any{"variables": "string"}})) != 1 { t.Error("invalid type: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkServices ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckServices(t *testing.T) {
|
|
|
|
|
if len(checkServices("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkServices("j", model.Job{Services: []any{"postgres"}})) != 0 { t.Error("string: clean") }
|
|
|
|
|
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg"}}})) != 0 { t.Error("valid map: clean") }
|
|
|
|
|
if len(checkServices("j", model.Job{Services: []any{map[string]any{"port": 5432}}})) != 1 { t.Error("no name: error") }
|
|
|
|
|
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "invalid alias!"}}})) != 1 { t.Error("bad alias: error") }
|
|
|
|
|
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "valid-alias"}}})) != 0 { t.Error("good alias: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkRulesGlobs ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckRulesGlobs(t *testing.T) {
|
|
|
|
|
if len(checkRulesGlobs("j", model.Job{})) != 0 { t.Error("no rules: clean") }
|
|
|
|
|
// Relative path: clean
|
|
|
|
|
relRule := model.Rule{Changes: []any{"src/**/*.go"}}
|
|
|
|
|
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{relRule}})) != 0 { t.Error("relative: clean") }
|
|
|
|
|
// Absolute path: warning
|
|
|
|
|
absRule := model.Rule{Changes: []any{"/absolute/path"}}
|
|
|
|
|
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{absRule}})) != 1 { t.Error("absolute: warning") }
|
|
|
|
|
// Map form with paths
|
|
|
|
|
mapRule := model.Rule{Changes: map[string]any{"paths": []any{"/abs"}}}
|
|
|
|
|
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{mapRule}})) != 1 { t.Error("map absolute: warning") }
|
|
|
|
|
// exists
|
|
|
|
|
existsRule := model.Rule{Exists: []any{"/bad"}}
|
|
|
|
|
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{existsRule}})) != 1 { t.Error("exists absolute: warning") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkTimeout ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckTimeout(t *testing.T) {
|
|
|
|
|
if len(checkTimeout("j", model.Job{})) != 0 { t.Error("empty: clean") }
|
|
|
|
|
if len(checkTimeout("j", model.Job{Timeout: "1h 30m"})) != 0 { t.Error("valid: clean") }
|
|
|
|
|
if len(checkTimeout("j", model.Job{Timeout: "90 minutes"})) != 0 { t.Error("valid2: clean") }
|
|
|
|
|
if len(checkTimeout("j", model.Job{Timeout: "not-a-duration"})) != 1 { t.Error("invalid: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckDefaultTimeout(t *testing.T) {
|
|
|
|
|
if len(checkDefaultTimeout("", "f.yml")) != 0 { t.Error("empty: clean") }
|
|
|
|
|
if len(checkDefaultTimeout("2 hours", "f.yml")) != 0 { t.Error("valid: clean") }
|
|
|
|
|
if len(checkDefaultTimeout("bad", "f.yml")) != 1 { t.Error("invalid: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkIDTokens ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckIDTokens(t *testing.T) {
|
|
|
|
|
if len(checkIDTokens("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkIDTokens("j", model.Job{IDTokens: "string"})) != 0 { t.Error("non-map: clean") }
|
|
|
|
|
// valid token with aud
|
|
|
|
|
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
|
|
|
|
|
"MY_TOKEN": map[string]any{"aud": "https://example.com"},
|
|
|
|
|
}})) != 0 { t.Error("valid: clean") }
|
|
|
|
|
// missing aud
|
|
|
|
|
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
|
|
|
|
|
"MY_TOKEN": map[string]any{"other": "x"},
|
|
|
|
|
}})) != 1 { t.Error("missing aud: error") }
|
|
|
|
|
// not a map
|
|
|
|
|
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
|
|
|
|
|
"MY_TOKEN": "string",
|
|
|
|
|
}})) != 1 { t.Error("token not a map: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkSecrets ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckSecrets(t *testing.T) {
|
|
|
|
|
if len(checkSecrets("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
if len(checkSecrets("j", model.Job{Secrets: "string"})) != 0 { t.Error("non-map: clean") }
|
|
|
|
|
// valid vault
|
|
|
|
|
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
|
|
|
|
|
"MY_SECRET": map[string]any{"vault": map[string]any{"engine": map[string]any{"name": "kv", "path": "x"}, "path": "y", "field": "z"}},
|
|
|
|
|
}})) != 0 { t.Error("vault: clean") }
|
|
|
|
|
// missing provider
|
|
|
|
|
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
|
|
|
|
|
"MY_SECRET": map[string]any{"other": "x"},
|
|
|
|
|
}})) != 1 { t.Error("missing provider: error") }
|
|
|
|
|
// not a map
|
|
|
|
|
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
|
|
|
|
|
"MY_SECRET": "string",
|
|
|
|
|
}})) != 1 { t.Error("not a map: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkPagesKeyword ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckPagesKeyword(t *testing.T) {
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{})) != 0 { t.Error("nil pages: clean") }
|
|
|
|
|
// pages keyword but no artifacts
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{Pages: map[string]any{}})) != 1 { t.Error("no artifacts: warning") }
|
|
|
|
|
// pages keyword with artifacts but no paths
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{
|
|
|
|
|
Pages: map[string]any{},
|
|
|
|
|
Artifacts: map[string]any{},
|
|
|
|
|
})) != 1 { t.Error("no paths: warning") }
|
|
|
|
|
// pages with public in paths
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{
|
|
|
|
|
Pages: map[string]any{},
|
|
|
|
|
Artifacts: map[string]any{"paths": []any{"public"}},
|
|
|
|
|
})) != 0 { t.Error("public in paths: clean") }
|
|
|
|
|
// pages with custom publish dir and matching path
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{
|
|
|
|
|
Pages: map[string]any{"publish": "dist"},
|
|
|
|
|
Artifacts: map[string]any{"paths": []any{"dist"}},
|
|
|
|
|
})) != 0 { t.Error("custom publish match: clean") }
|
|
|
|
|
// pages with custom publish dir missing from paths
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{
|
|
|
|
|
Pages: map[string]any{"publish": "dist"},
|
|
|
|
|
Artifacts: map[string]any{"paths": []any{"public"}},
|
|
|
|
|
})) != 1 { t.Error("custom publish missing: warning") }
|
|
|
|
|
// artifacts is a non-map (unexpected)
|
|
|
|
|
if len(checkPagesKeyword("j", model.Job{
|
|
|
|
|
Pages: map[string]any{},
|
|
|
|
|
Artifacts: "string",
|
|
|
|
|
})) != 0 { t.Error("non-map artifacts: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckCacheKeyFiles(t *testing.T) {
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{})) != 0 { t.Error("nil: clean") }
|
|
|
|
|
// valid key.files
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
|
|
|
|
|
"key": map[string]any{"files": []any{"Gemfile.lock"}},
|
|
|
|
|
}})) != 0 { t.Error("valid: clean") }
|
|
|
|
|
// glob pattern in key.files
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
|
|
|
|
|
"key": map[string]any{"files": []any{"*.lock"}},
|
|
|
|
|
}})) != 1 { t.Error("glob: warning") }
|
|
|
|
|
// files is not a list
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
|
|
|
|
|
"key": map[string]any{"files": "Gemfile.lock"},
|
|
|
|
|
}})) != 1 { t.Error("non-list: error") }
|
|
|
|
|
// no key
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{}})) != 0 { t.Error("no key: clean") }
|
|
|
|
|
// key is not a map
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{"key": "string"}})) != 0 { t.Error("key string: clean") }
|
|
|
|
|
// slice cache form
|
|
|
|
|
if len(checkCacheKeyFiles("j", model.Job{Cache: []any{
|
|
|
|
|
map[string]any{"key": map[string]any{"files": []any{"*.lock"}}},
|
|
|
|
|
}})) != 1 { t.Error("slice cache with glob: warning") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── linter.go level checks ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckStages(t *testing.T) {
|
|
|
|
|
// no stages → warning
|
|
|
|
|
p := &model.Pipeline{}
|
|
|
|
|
if len(checkStages(p)) != 1 { t.Error("no stages: warning") }
|
|
|
|
|
// with stages → clean
|
|
|
|
|
p2 := &model.Pipeline{Stages: []string{"build"}}
|
|
|
|
|
if len(checkStages(p2)) != 0 { t.Error("with stages: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckDuplicateStages(t *testing.T) {
|
|
|
|
|
p := &model.Pipeline{Stages: []string{"build", "test", "build"}}
|
|
|
|
|
if len(checkDuplicateStages(p)) != 1 { t.Error("duplicate: warning") }
|
|
|
|
|
p2 := &model.Pipeline{Stages: []string{"build", "test"}}
|
|
|
|
|
if len(checkDuplicateStages(p2)) != 0 { t.Error("no duplicate: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckWorkflow(t *testing.T) {
|
|
|
|
|
p := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "always"}}}}
|
|
|
|
|
if len(checkWorkflow(p)) != 0 { t.Error("valid when: clean") }
|
|
|
|
|
p2 := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "bad"}}}}
|
|
|
|
|
if len(checkWorkflow(p2)) != 1 { t.Error("invalid when: error") }
|
|
|
|
|
p3 := &model.Pipeline{}
|
|
|
|
|
if len(checkWorkflow(p3)) != 0 { t.Error("nil workflow: clean") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFindingString(t *testing.T) {
|
|
|
|
|
f := Finding{
|
|
|
|
|
Severity: Error,
|
|
|
|
|
Rule: "GL001",
|
|
|
|
|
Job: "build",
|
|
|
|
|
File: "ci.yml",
|
|
|
|
|
Line: 10,
|
|
|
|
|
Message: "missing script",
|
|
|
|
|
}
|
|
|
|
|
s := f.String()
|
|
|
|
|
if s == "" { t.Error("expected non-empty string") }
|
|
|
|
|
|
|
|
|
|
// Without file/line/job
|
|
|
|
|
f2 := Finding{Severity: Warning, Rule: "GL002", Message: "test"}
|
|
|
|
|
s2 := f2.String()
|
|
|
|
|
if s2 == "" { t.Error("expected non-empty string") }
|
|
|
|
|
|
|
|
|
|
// With file but no line
|
|
|
|
|
f3 := Finding{Severity: Error, Rule: "GL003", File: "ci.yml", Message: "x"}
|
|
|
|
|
if f3.String() == "" { t.Error("expected non-empty string") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestScriptNonEmpty(t *testing.T) {
|
|
|
|
|
if scriptNonEmpty(nil) { t.Error("nil") }
|
|
|
|
|
if scriptNonEmpty([]any{}) { t.Error("empty slice") }
|
|
|
|
|
if !scriptNonEmpty([]any{"echo"}) { t.Error("non-empty slice") }
|
|
|
|
|
if !scriptNonEmpty("echo") { t.Error("string") }
|
|
|
|
|
if scriptNonEmpty("") { t.Error("empty string") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkNeeds ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckNeeds(t *testing.T) {
|
|
|
|
|
p := &model.Pipeline{
|
|
|
|
|
Stages: []string{"build", "test"},
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
|
|
|
|
|
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
|
|
|
|
|
Needs: []any{"build-job"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if len(checkNeeds(p)) != 0 { t.Error("valid needs: clean") }
|
|
|
|
|
|
|
|
|
|
// needs unknown job
|
|
|
|
|
p2 := &model.Pipeline{
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if len(checkNeeds(p2)) != 1 { t.Error("unknown needs: error") }
|
|
|
|
|
|
|
|
|
|
// optional: true for unknown job → warning not error
|
|
|
|
|
p3 := &model.Pipeline{
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"test-job": {Name: "test-job", Stage: "test",
|
|
|
|
|
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
findings := checkNeeds(p3)
|
|
|
|
|
if len(findings) != 1 || findings[0].Severity != Warning {
|
|
|
|
|
t.Error("optional unknown needs: warning")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cross-pipeline need (ignored)
|
|
|
|
|
p4 := &model.Pipeline{
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"test-job": {Name: "test-job",
|
|
|
|
|
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if len(checkNeeds(p4)) != 0 { t.Error("cross-pipeline: clean") }
|
|
|
|
|
|
|
|
|
|
// needs later stage → error
|
|
|
|
|
p5 := &model.Pipeline{
|
|
|
|
|
Stages: []string{"build", "test"},
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
|
|
|
|
|
"early-job": {Name: "early-job", Stage: "build",
|
|
|
|
|
Needs: []any{"build-job"}, Script: []any{"echo"}},
|
|
|
|
|
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
|
|
|
|
|
Needs: []any{"build-job"}}, // ok
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
// Only cross-stage ordering violations should be found
|
|
|
|
|
_ = checkNeeds(p5)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckNeeds_Cycle(t *testing.T) {
|
|
|
|
|
p := &model.Pipeline{
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"a": {Name: "a", Needs: []any{"b"}},
|
|
|
|
|
"b": {Name: "b", Needs: []any{"a"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
findings := checkNeeds(p)
|
|
|
|
|
for _, f := range findings {
|
|
|
|
|
if f.Rule == RuleNeedsCycle { return }
|
|
|
|
|
}
|
|
|
|
|
t.Error("expected cycle detection finding")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkDependencies ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckDependencies(t *testing.T) {
|
|
|
|
|
p := &model.Pipeline{
|
|
|
|
|
Stages: []string{"build", "test"},
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
|
|
|
|
|
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
|
|
|
|
|
Dependencies: []string{"build-job"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if len(checkDependencies(p)) != 0 { t.Error("valid deps: clean") }
|
|
|
|
|
|
|
|
|
|
// unknown dep
|
|
|
|
|
p2 := &model.Pipeline{
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if len(checkDependencies(p2)) != 1 { t.Error("unknown dep: error") }
|
|
|
|
|
|
|
|
|
|
// dep in same or later stage → error
|
|
|
|
|
p3 := &model.Pipeline{
|
|
|
|
|
Stages: []string{"build", "test"},
|
|
|
|
|
Jobs: map[string]model.Job{
|
|
|
|
|
"test-job1": {Name: "test-job1", Stage: "test"},
|
|
|
|
|
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if len(checkDependencies(p3)) != 1 { t.Error("same stage dep: error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCheckCacheKeyFiles_NoFilesKey(t *testing.T) {
|
|
|
|
|
// cache.key is a map but has no "files" key → continue (line 823-824).
|
|
|
|
|
job := model.Job{
|
|
|
|
|
Cache: map[string]any{
|
|
|
|
|
"key": map[string]any{"prefix": "v1"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
|
|
|
|
|
t.Errorf("key map without 'files': expected no findings, got %v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckCacheKeyFiles_FilesNotList(t *testing.T) {
|
|
|
|
|
// cache.key.files is a scalar (not a list) → error finding (line 827-834).
|
|
|
|
|
job := model.Job{
|
|
|
|
|
Cache: map[string]any{
|
|
|
|
|
"key": map[string]any{"files": "single-file.lock"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
got := checkCacheKeyFiles("j", job)
|
|
|
|
|
if len(got) == 0 {
|
|
|
|
|
t.Error("files as scalar: expected error finding")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if got[0].Rule != RuleInvalidCacheKeyFiles || got[0].Severity != Error {
|
|
|
|
|
t.Errorf("unexpected finding: %v", got[0])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCheckCacheKeyFiles_NonStringItem(t *testing.T) {
|
|
|
|
|
// cache.key.files list has a non-string item → skip it (line 838-839).
|
|
|
|
|
job := model.Job{
|
|
|
|
|
Cache: map[string]any{
|
|
|
|
|
"key": map[string]any{
|
|
|
|
|
"files": []any{42, "go.sum"}, // 42 is non-string, go.sum is valid
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
// 42 is skipped; "go.sum" has no glob chars → no findings.
|
|
|
|
|
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
|
|
|
|
|
t.Errorf("non-string item: expected no findings, got %v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|