Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/ciconfig/ciconfig_test.go

Raw
package ciconfig

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/sblinch/kdl-go/document"
)

func TestRepositoryCIConfig(t *testing.T) {
	repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
	if err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(repoRoot)
	if err != nil {
		t.Fatalf("Load() error = %v", err)
	}
	want := map[string]bool{
		"go-test":          false,
		"go-race":          false,
		"go-staticcheck":   false,
		"go-vuln":          false,
		"rust-fmt":         false,
		"rust-clippy":      false,
		"rust-test":        false,
		"shell":            false,
		"infra-tofu-route": false,
		"infra-quadlets":   false,
	}
	for _, job := range cfg.Jobs {
		if _, ok := want[job.Name]; !ok {
			continue
		}
		if job.Name == "shell" && job.SourceFile == "shell.kdl" || (job.Name == "infra-tofu-route" || job.Name == "infra-quadlets") && job.SourceFile == "infra.kdl" || job.Name != "shell" && job.Name != "infra-tofu-route" && job.Name != "infra-quadlets" && job.SourceFile == "go.kdl" {
			want[job.Name] = true
		}
	}
	for name, found := range want {
		if !found {
			t.Errorf("repository CI job %q missing", name)
		}
	}
	for _, job := range cfg.Jobs {
		if job.SourceFile != "go.kdl" && job.SourceFile != "shell.kdl" && job.SourceFile != "infra.kdl" {
			continue
		}
		switch {
		case strings.HasPrefix(job.Name, "go-"):
			if job.Name != "go-vuln" && (len(job.Run) != 2 || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "scripts/local/go.sh") || !contains(job.Trigger.Push.Include, "scripts/local/check-staged.sh")) {
				t.Errorf("repository CI job %q is missing Go automation coverage: %#v", job.Name, job)
			}
			for _, command := range job.Run {
				if strings.Contains(command, "cd ") || !strings.Contains(command, "scripts/local/go.sh") || !strings.Contains(command, "GOTOOLCHAIN=local") || !strings.Contains(command, "GOMODCACHE=$PWD/.go-modcache") {
					t.Errorf("repository CI command %q must run bounded Go tooling from the workspace root", command)
				}
			}
		case strings.HasPrefix(job.Name, "rust-"):
			if job.Image != "docker.io/library/rust:1.98.0-bookworm" || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "services/luigit/**") || !contains(job.Trigger.Push.Include, "scripts/local/rust.sh") {
				t.Errorf("repository CI job %q is missing Rust automation coverage: %#v", job.Name, job)
			}
			if job.Name == "rust-fmt" {
				if len(job.Caches) != 0 || len(job.Run) != 1 || !strings.Contains(job.Run[0], "rustup component add rustfmt") {
					t.Errorf("repository CI fmt job must install rustfmt without a cache: %#v", job)
				}
			} else if len(job.Caches) != 1 || job.Caches[0].Path != ".cargo-home" {
				t.Errorf("repository CI job %q must cache Cargo state: %#v", job.Name, job)
			}
			for _, command := range job.Run {
				if strings.Contains(command, "cd ") || strings.Contains(command, "apk ") || !strings.Contains(command, "scripts/local/rust.sh") {
					t.Errorf("repository CI command %q must use Rust tooling from the workspace root", command)
				}
			}
		case strings.HasPrefix(job.Name, "infra-"):
			if job.SourceFile != "infra.kdl" || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "scripts/local/verify-infra.sh") || !contains(job.Trigger.Push.Include, "scripts/local/test-verify-infra.sh") || job.Name == "infra-tofu-route" && !contains(job.Trigger.Push.Include, "modules/klops/**") {
				t.Errorf("repository infrastructure CI job is incomplete: %#v", job)
			}
			if job.Name == "infra-tofu-route" && (len(job.Run) != 2 || strings.Contains(job.Run[1], "tofu version") || !strings.Contains(job.Run[1], "just verify infra tofu-route")) {
				t.Errorf("repository OpenTofu CI job must delegate diagnostics to verification: %#v", job)
			}
			if job.Name == "infra-quadlets" && (len(job.Run) != 2 || strings.Contains(job.Run[0], "dnf") || !strings.Contains(job.Run[0], "install-just.sh") || !contains(job.Trigger.Push.Include, "scripts/local/install-just.sh")) {
				t.Errorf("repository Quadlet CI job must bootstrap pinned Just without dnf: %#v", job)
			}
		case job.Name == "shell":
			if job.SourceFile != "shell.kdl" || job.Image != "docker.io/library/alpine:3.22.0" || job.Trigger.Push == nil || !contains(job.Trigger.Push.Include, "**/*.sh") || !contains(job.Trigger.Push.Include, "*.sh") || len(job.Run) != 3 || !strings.Contains(job.Run[0], "apk --root $PWD/.apk --initdb") || !strings.Contains(job.Run[0], "add --no-cache bash git shfmt shellcheck") || !strings.Contains(job.Run[1], "shellcheck --version") || !strings.Contains(job.Run[2], "scripts/local/shell-check.sh read") {
				t.Errorf("repository shell CI job is incomplete: %#v", job)
			}
			for _, command := range job.Run {
				if strings.Contains(command, "go.sh") || strings.Contains(command, "tofu") {
					t.Errorf("repository shell CI command %q must not run Go or OpenTofu", command)
				}
			}
		}
	}
}

func TestRepositoryCIPathFilters(t *testing.T) {
	repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
	if err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(repoRoot)
	if err != nil {
		t.Fatal(err)
	}

	assertMatches := func(path string, want ...string) {
		t.Helper()
		got := map[string]bool{}
		for _, job := range cfg.Jobs {
			if job.MatchesPush("refs/heads/trunk", []string{path}) {
				got[job.Name] = true
			}
		}
		for _, name := range want {
			if !got[name] {
				t.Errorf("change %q did not select %q: %#v", path, name, got)
			}
			delete(got, name)
		}
		if len(got) != 0 {
			t.Errorf("change %q selected unexpected jobs: %#v", path, got)
		}
	}

	assertMatches("services/luci/cmd/luci/main.go", "go-test", "go-race", "go-staticcheck")
	assertMatches("services/toad/cmd/toad/main.go", "go-test", "go-race", "go-staticcheck", "infra-tofu-route")
	assertMatches("services/luigit/src/main.rs", "rust-fmt", "rust-clippy", "rust-test", "infra-tofu-route")
	assertMatches("scripts/local/go.sh", "go-test", "go-race", "go-staticcheck", "shell")
	assertMatches("scripts/local/rust.sh", "rust-fmt", "rust-clippy", "rust-test", "shell")
	assertMatches("scripts/local/shell-check.sh", "shell")
	assertMatches("scripts/local/with space.sh", "shell")
	assertMatches(".ci/go.kdl", "go-test", "go-race", "go-staticcheck", "rust-fmt", "rust-clippy", "rust-test")
	assertMatches(".ci/shell.kdl", "go-test", "go-race", "go-staticcheck", "rust-fmt", "rust-clippy", "rust-test", "shell")
	assertMatches(".ci/infra.kdl", "go-test", "go-race", "go-staticcheck", "rust-fmt", "rust-clippy", "rust-test", "infra-tofu-route", "infra-quadlets")
	assertMatches("modules/klops/quadlets/caddy.container", "infra-tofu-route", "infra-quadlets")
	assertMatches("services/toad/deploy/gateway.Caddyfile", "go-test", "go-race", "go-staticcheck", "infra-tofu-route")
	assertMatches("scripts/local/install-just.sh", "infra-quadlets", "shell")
	assertMatches("README.md")
}

func TestParseDurableMounts(t *testing.T) {
	cfg, err := ParseFiles(map[string]string{"ci.kdl": `job "deploy" {
  image "alpine"
  run "true"
  mount "auth" "registry" "/root/.config/containers"
  mount "cache" "build" "/var/cache/build"
}`})
	if err != nil || len(cfg.Jobs[0].Mounts) != 2 {
		t.Fatalf("mounts=%#v err=%v", cfg, err)
	}
	for _, source := range []string{
		`mount "host" "name" "/target"`,
		`mount "auth" "../name" "/target"`,
		`mount "auth" "name" "target"`,
		`mount "cache" "one" "/same" mount "auth" "two" "/same/path"`,
		`mount "cache" "one" "/work/cache"`,
	} {
		_, err := ParseFiles(map[string]string{"ci.kdl": `job "bad" { image "alpine" run "true" ` + source + ` }`})
		if err == nil {
			t.Fatalf("accepted %s", source)
		}
	}
}

func contains(values []string, want string) bool {
	for _, value := range values {
		if value == want {
			return true
		}
	}
	return false
}

func TestLoadReadsCiFilesLexicographically(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "20-second.kdl"), `
job "second" {
  image "alpine"
  run "echo second"
}
`)
	mustWrite(t, filepath.Join(repo, ".ci", "10-first.kdl"), `
job "first" {
  image "alpine"
  run "echo first"
}
`)

	cfg, err := Load(repo)
	if err != nil {
		t.Fatalf("Load() error = %v", err)
	}
	if got := []string{cfg.Jobs[0].Name, cfg.Jobs[1].Name}; got[0] != "first" || got[1] != "second" {
		t.Fatalf("job order = %#v", got)
	}
}

func TestParseFilesTracksDeterministicJobSource(t *testing.T) {
	cfg, err := ParseFiles(map[string]string{
		"20-second.kdl": "job \"second\" {\n  image \"alpine\"\n  run \"echo second\"\n}",
		"10-first.kdl":  "job \"first\" {\n  image \"alpine\"\n  run \"echo first\"\n}",
	})
	if err != nil {
		t.Fatal(err)
	}
	if got, want := cfg.Jobs[0].Name, "first"; got != want {
		t.Fatalf("first job=%q", got)
	}
	if got, want := cfg.Jobs[0].SourceFile, "10-first.kdl"; got != want {
		t.Fatalf("source file=%q", got)
	}
	if got := cfg.Jobs[0].Source; !strings.Contains(got, `job "first"`) || !strings.Contains(got, `run "echo first"`) {
		t.Fatalf("source=%q", got)
	}
}

func TestLoadRejectsDuplicateJobs(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "a.kdl"), `
job "check" {
  image "alpine"
  run "echo a"
}
`)
	mustWrite(t, filepath.Join(repo, ".ci", "b.kdl"), `
job "check" {
  image "alpine"
  run "echo b"
}
`)

	_, err := Load(repo)
	if err == nil {
		t.Fatal("duplicate job accepted")
	}
	if got := err.Error(); got != "duplicate job \"check\"" {
		t.Fatalf("unexpected error: %s", got)
	}
}

func TestLoadParsesTriggerAndMatrix(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `
job "test" {
  image "alpine:${version}"
  run "echo ${version}"
  trigger {
    push {
      branch "main"
      tag "v*"
    }
    schedule "0 3 * * *"
  }
  matrix {
    version "3.20" "3.21"
  }
}
`)

	cfg, err := Load(repo)
	if err != nil {
		t.Fatalf("Load() error = %v", err)
	}
	job := cfg.Jobs[0]
	if job.Trigger.Push == nil || len(job.Trigger.Push.Branches) != 1 || job.Trigger.Push.Branches[0] != "main" || len(job.Trigger.Push.Tags) != 1 || job.Trigger.Push.Tags[0] != "v*" || job.Trigger.Schedule == nil {
		t.Fatalf("trigger = %#v", job.Trigger)
	}
	if len(job.Matrix.Axes) != 1 || job.Matrix.Axes[0].Name != "version" || len(job.Matrix.Axes[0].Values) != 2 || job.Matrix.Axes[0].Values[1] != "3.21" {
		t.Fatalf("matrix = %#v", job.Matrix)
	}
}

func TestLoadParsesOptInPushPathsAndSchedule(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `job "test" {
  image "alpine"
  run "echo ok"
  trigger {
    push {
      branch "trunk"
      include "src/**"
      exclude "src/generated/**"
    }
    schedule "*/15 * * * *"
  }
}
job "manual" {
  image "alpine"
  run "echo manual"
}
`)
	cfg, err := Load(repo)
	if err != nil {
		t.Fatal(err)
	}
	push := cfg.Jobs[0].Trigger.Push
	if push == nil || len(push.Include) != 1 || len(push.Exclude) != 1 || cfg.Jobs[0].Trigger.Schedule == nil {
		t.Fatalf("trigger=%#v", cfg.Jobs[0].Trigger)
	}
	if cfg.Jobs[1].Trigger.Push != nil || cfg.Jobs[1].Trigger.Schedule != nil {
		t.Fatalf("manual trigger=%#v", cfg.Jobs[1].Trigger)
	}
}

func TestScheduleMatchesUTCMinute(t *testing.T) {
	minute := time.Date(2026, 3, 4, 3, 0, 45, 0, time.FixedZone("local", 3600))
	if !ScheduleMatches("0 2 * * *", minute) {
		t.Fatal("UTC minute schedule did not match")
	}
}

func TestParseScheduleBoundsMalformedInput(t *testing.T) {
	_, err := ParseSchedule(strings.Repeat("1", 70000) + " 0 * * *")
	if err == nil || len(err.Error()) > 100 {
		t.Fatalf("err=%v", err)
	}
}

func TestLoadRejectsOversizedMatrix(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `job "large" {
  image "alpine"
  run "echo ok"
  matrix {
    a "1" "2" "3" "4" "5" "6" "7" "8" "9"
    b "1" "2" "3" "4" "5" "6" "7" "8"
  }
}
`)
	if _, err := Load(repo); err == nil {
		t.Fatal("oversized matrix accepted")
	}
}

func TestLoadRejectsBadTriggerAndMatrix(t *testing.T) {
	cases := map[string]string{
		"bad-schedule": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger {
    schedule "@daily"
  }
}
`,
		"timezone-schedule": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger {
    schedule "TZ=UTC 0 0 * *"
  }
}
`,
		"push-property": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger {
    push branch="trunk"
  }
}
`,
		"duplicate-schedule": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger {
    schedule "0 3 * * *"
    schedule "0 4 * * *"
  }
}
`,
		"bad-path-glob": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger {
    push {
      include "["
    }
  }
}
`,
		"bad-trigger-child": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger { timer true }
}
`,
		"bad-push-type": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger { push "yes" }
}
`,
		"bad-push-args": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger { push true false }
}
`,
		"bad-branch-args": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger { branch "main" "dev" }
}
`,
		"bad-tag-args": `
job "test" {
  image "alpine"
  run "echo ok"
  trigger { tag "v*" "x*" }
}
`,
		"numeric-matrix-value": `
job "test" {
  image "alpine"
  run "echo ok"
  matrix { node 22 }
}
`,
		"duplicate-axis": `
job "test" {
  image "alpine"
  run "echo ok"
  matrix { arch "amd64" arch "arm64" }
}
`,
		"empty-axis": `
job "test" {
  image "alpine"
  run "echo ok"
  matrix { arch }
}
`,
	}
	for name, content := range cases {
		t.Run(name, func(t *testing.T) {
			repo := t.TempDir()
			mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), content)
			if _, err := Load(repo); err == nil {
				t.Fatal("bad config accepted")
			}
		})
	}
}

func TestLoadRejectsBadFilesAndJobShape(t *testing.T) {
	if _, err := Load("["); err == nil {
		t.Fatal("bad glob accepted")
	}

	repo := t.TempDir()
	if err := os.MkdirAll(filepath.Join(repo, ".ci"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink("missing", filepath.Join(repo, ".ci", "missing.kdl")); err != nil {
		t.Fatal(err)
	}
	if _, err := Load(repo); err == nil {
		t.Fatal("broken symlink accepted")
	}

	cases := map[string]string{
		"invalid-kdl":      `job "x" {`,
		"top-level":        `pipeline "x" {}`,
		"job-no-arg":       `job { image "alpine" run "echo ok" }`,
		"unknown-job-node": `job "x" { image "alpine" run "echo ok" artifact "x" }`,
		"bad-image-args":   `job "x" { image "alpine" "edge" run "echo ok" }`,
		"bad-run-args":     `job "x" { image "alpine" run }`,
		"missing-image":    `job "x" { run "echo ok" }`,
		"missing-run":      `job "x" { image "alpine" }`,
		"secret-no-arg":    `job "x" { image "alpine" run "echo ok" secret env="TOKEN" }`,
		"secret-both":      `job "x" { image "alpine" run "echo ok" secret "TOKEN" env="TOKEN" file="token" }`,
		"secret-neither":   `job "x" { image "alpine" run "echo ok" secret "TOKEN" }`,
	}
	for name, content := range cases {
		t.Run(name, func(t *testing.T) {
			repo := t.TempDir()
			mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), content)
			if _, err := Load(repo); err == nil {
				t.Fatal("bad config accepted")
			}
		})
	}
}

func TestLoadRejectsMultilineBadJobShapes(t *testing.T) {
	cases := map[string]string{
		"job-no-arg": `job {
  image "alpine"
  run "echo ok"
}
`,
		"unknown-job-node": `job "x" {
  image "alpine"
  run "echo ok"
  artifact "x"
}
`,
		"bad-image-args": `job "x" {
  image "alpine" "edge"
  run "echo ok"
}
`,
		"bad-run-args": `job "x" {
  image "alpine"
  run
}
`,
		"missing-image": `job "x" {
  run "echo ok"
}
`,
		"missing-run": `job "x" {
  image "alpine"
}
`,
		"secret-no-arg": `job "x" {
  image "alpine"
  run "echo ok"
  secret env="TOKEN"
}
`,
		"secret-both": `job "x" {
  image "alpine"
  run "echo ok"
  secret "TOKEN" env="TOKEN" file="token"
}
`,
		"secret-neither": `job "x" {
  image "alpine"
  run "echo ok"
  secret "TOKEN"
}
`,
		"bad-trigger-branch": `job "x" {
  image "alpine"
  run "echo ok"
  trigger {
    branch "main" "dev"
  }
}
`,
		"bad-trigger-tag": `job "x" {
  image "alpine"
  run "echo ok"
  trigger {
    tag "v*" "x*"
  }
}
`,
		"bad-trigger-push": `job "x" {
  image "alpine"
  run "echo ok"
  trigger {
    push true false
  }
}
`,
		"bad-trigger-push-type": `job "x" {
  image "alpine"
  run "echo ok"
  trigger {
    push "yes"
  }
}
`,
		"bad-trigger-child": `job "x" {
  image "alpine"
  run "echo ok"
  trigger {
    timer true
  }
}
`,
		"bad-matrix-duplicate": `job "x" {
  image "alpine"
  run "echo ok"
  matrix {
    arch "amd64"
    arch "arm64"
  }
}
`,
		"bad-matrix-empty": `job "x" {
  image "alpine"
  run "echo ok"
  matrix {
    arch
  }
}
`,
		"unsafe-secret-file": `job "x" {
  image "alpine"
  run "echo ok"
  secret "KEY" file="../key"
}
`,
		"bad-cache": `job "x" {
  image "alpine"
  run "echo ok"
  cache "deps" {
    path "../deps"
    key { file "lock" }
  }
}
`,
		"bad-publish": `job "x" {
  image "alpine"
  run "echo ok"
  publish "site" { from "dist" }
}
`,
		"registry-host-image": `job "x" {
  image "alpine"
  run "echo ok"
  publish "registry" {
    image "app:latest"
    to "registry.invalid/app:latest"
  }
}
`,
		"registry-missing-archive": `job "x" {
  image "alpine"
  run "echo ok"
  publish "registry" { to "registry.invalid/app:latest" }
}
`,
	}
	for name, content := range cases {
		t.Run(name, func(t *testing.T) {
			repo := t.TempDir()
			mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), content)
			if _, err := Load(repo); err == nil {
				t.Fatal("bad config accepted")
			}
		})
	}
}

func TestRegistryImagePublishHasMigrationError(t *testing.T) {
	_, err := ParseFiles(map[string]string{"ci.kdl": `job "x" {
  image "alpine"
  run "true"
  publish "registry" {
    image "app:latest"
    to "registry.invalid/app:latest"
  }
}`})
	if err == nil || !strings.Contains(err.Error(), "write an OCI archive") {
		t.Fatalf("err=%v", err)
	}
}

func TestLowLevelParserHelpers(t *testing.T) {
	if nodeName(nil) != "" || nodeName(&document.Node{}) != "" {
		t.Fatal("nil node name not empty")
	}
	if _, err := oneStringArg(&document.Node{}, "x"); err == nil {
		t.Fatal("oneStringArg accepted missing arg")
	}
	if _, err := oneBoolArg(&document.Node{}, "x"); err == nil {
		t.Fatal("oneBoolArg accepted missing arg")
	}
	if stringValue(nil) != "" {
		t.Fatal("nil stringValue not empty")
	}
}

func TestLoadParsesNumericMatrixValue(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `job "x" {
  image "alpine"
  run "echo ok"
  matrix {
    node 22
  }
}
`)
	cfg, err := Load(repo)
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Jobs[0].Matrix.Axes[0].Values[0] != "22" {
		t.Fatalf("matrix = %#v", cfg.Jobs[0].Matrix)
	}
}

func TestLoadParsesCoreJobFields(t *testing.T) {
	repo := t.TempDir()
	mustWrite(t, filepath.Join(repo, ".ci", "ci.kdl"), `
job "test" {
  image "docker.io/library/node:${node}-alpine"
  run "npm ci"
  run "npm test"
  secret "HCLOUD_TOKEN" env="HCLOUD_TOKEN"
  secret "DEPLOY_KEY" file=".secrets/deploy_key"
  cache "npm" {
    path ".npm"
    key {
      file "package-lock.json"
    }
  }
  publish "site" {
    from "dist"
    to "docs/"
  }
}
`)

	cfg, err := Load(repo)
	if err != nil {
		t.Fatalf("Load() error = %v", err)
	}
	job := cfg.Jobs[0]
	if job.Name != "test" || job.Image != "docker.io/library/node:${node}-alpine" {
		t.Fatalf("job = %#v", job)
	}
	if len(job.Run) != 2 || job.Run[0] != "npm ci" || job.Run[1] != "npm test" {
		t.Fatalf("run = %#v", job.Run)
	}
	if len(job.Secrets) != 2 || job.Secrets[0].Name != "HCLOUD_TOKEN" || job.Secrets[0].Env != "HCLOUD_TOKEN" || job.Secrets[1].File != ".secrets/deploy_key" {
		t.Fatalf("secrets = %#v", job.Secrets)
	}
	if len(job.Caches) != 1 || job.Caches[0].Name != "npm" || job.Caches[0].Path != ".npm" || len(job.Caches[0].KeyFiles) != 1 {
		t.Fatalf("caches = %#v", job.Caches)
	}
	if len(job.Publishes) != 1 || job.Publishes[0].Adapter != "site" || job.Publishes[0].From != "dist" || job.Publishes[0].To != "docs/" {
		t.Fatalf("publishes = %#v", job.Publishes)
	}
}

func mustWrite(t *testing.T, path string, content string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestConfigJobMissing(t *testing.T) {
	cfg := Config{Jobs: []Job{{Name: "check"}}}
	if job, ok := cfg.Job("missing"); ok || job.Name != "" {
		t.Fatalf("missing job = %#v %v", job, ok)
	}
}

func TestTriggerMatching(t *testing.T) {
	all := &Push{}
	if !(Job{Name: "check", Trigger: Trigger{Push: all}}).MatchesPush("refs/heads/anything", nil) {
		t.Fatal("empty branch patterns did not match")
	}
	if !(Job{Name: "check", Trigger: Trigger{Push: all}}).MatchesPush("refs/tags/v1", nil) {
		t.Fatal("empty tag patterns did not match")
	}
	if (Job{Name: "check", Trigger: Trigger{Push: &Push{Branches: []string{"main"}}}}).MatchesPush("refs/tags/v1", nil) {
		t.Fatal("branch-only trigger matched tag")
	}
	if (Job{Name: "check", Trigger: Trigger{Push: &Push{Tags: []string{"v*"}}}}).MatchesPush("refs/heads/main", nil) {
		t.Fatal("tag-only trigger matched branch")
	}
	job := Job{Name: "check", Trigger: Trigger{Push: &Push{Branches: []string{"main", "release/*"}, Tags: []string{"v*"}, Include: []string{"src/**"}, Exclude: []string{"src/generated/**"}}}}

	if !job.MatchesPush("refs/heads/main", []string{"src/main/app.go"}) {
		t.Fatal("included nested path did not match")
	}
	if job.MatchesPush("refs/heads/main", []string{"src/generated/app.go"}) {
		t.Fatal("excluded path matched")
	}
	if !job.MatchesPush("refs/tags/v1.0.0", []string{"src/app.go"}) {
		t.Fatal("tag did not match")
	}
	if job.MatchesPush("refs/heads/feature/x", []string{"src/app.go"}) {
		t.Fatal("feature branch matched")
	}
	if (Job{Name: "manual"}).MatchesPush("refs/heads/main", nil) {
		t.Fatal("manual-only job matched push")
	}
}

func TestManualLookupIgnoresTriggers(t *testing.T) {
	cfg := Config{Jobs: []Job{{Name: "deploy"}}}
	job, ok := cfg.Job("deploy")
	if !ok || job.Name != "deploy" {
		t.Fatalf("manual lookup failed: %#v %v", job, ok)
	}
}

func TestMatrixExpansionSingleJob(t *testing.T) {
	expanded := (Job{Name: "check"}).ExpandMatrix()
	if len(expanded) != 1 || expanded[0].Name != "check" || len(expanded[0].Values) != 0 {
		t.Fatalf("expanded = %#v", expanded)
	}
}

func TestMatrixExpansionOrder(t *testing.T) {
	job := Job{
		Name: "test",
		Matrix: Matrix{Axes: []Axis{
			{Name: "node", Values: []string{"22", "24"}},
			{Name: "distro", Values: []string{"fedora43", "debian13"}},
		}},
	}

	expanded := job.ExpandMatrix()
	wantNames := []string{
		"test[node=22,distro=fedora43]",
		"test[node=22,distro=debian13]",
		"test[node=24,distro=fedora43]",
		"test[node=24,distro=debian13]",
	}
	for i, want := range wantNames {
		if expanded[i].Name != want {
			t.Fatalf("expanded[%d].Name = %q, want %q", i, expanded[i].Name, want)
		}
	}
	if expanded[2].Values["node"] != "24" || expanded[2].Values["distro"] != "fedora43" {
		t.Fatalf("expanded[2].Values = %#v", expanded[2].Values)
	}
}

func TestValidateSecretPaths(t *testing.T) {
	job := Job{Name: "deploy", Secrets: []Secret{{Name: "HCLOUD_TOKEN", Env: "HCLOUD_TOKEN"}}}
	paths, err := job.SecretPaths("/data/ci", "bugabinga.net")
	if err != nil {
		t.Fatalf("SecretPaths() error = %v", err)
	}
	want := filepath.Join("/data/ci", "secrets", "bugabinga.net", "deploy", "HCLOUD_TOKEN")
	if len(paths) != 1 || paths["HCLOUD_TOKEN"] != want {
		t.Fatalf("paths = %#v", paths)
	}

	bad := Job{Name: "../deploy", Secrets: []Secret{{Name: "TOKEN", Env: "TOKEN"}}}
	if _, err := bad.SecretPaths("/data/ci", "bugabinga.net"); err == nil {
		t.Fatal("unsafe job name accepted")
	}
}

func TestRejectsSecretEnvironmentCollisions(t *testing.T) {
	for name, source := range map[string]string{
		"reserved": `job "check" {
  image "alpine"
  run "true"
  secret "token" env="CI_RUN_ID"
}`,
		"duplicate": `job "check" {
  image "alpine"
  run "true"
  secret "first" env="TOKEN"
  secret "second" env="TOKEN"
}`,
		"matrix": `job "check" {
  image "alpine"
  run "true"
  matrix {
    foo-bar "x"
    foo_bar "y"
  }
}`,
	} {
		t.Run(name, func(t *testing.T) {
			if _, err := ParseFiles(map[string]string{"ci.kdl": source}); err == nil {
				t.Fatal("invalid environment accepted")
			}
		})
	}
}