package jobs import ( "strings" "testing" "time" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "git.unkin.net/unkin/repospawner/internal/config" "git.unkin.net/unkin/repospawner/internal/store" ) func testConfig() *config.Config { return &config.Config{ Namespace: "repospawner", Image: "artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner:v1.2.3", JobServiceAccount: "repospawner", GiteaURL: "https://git.unkin.net", TFGitRepo: "unkin/terraform-git", VaultAddr: "https://vault.service.consul:8200", VaultK8sMount: "k8s/au/syd1", VaultK8sRole: "repospawner", VaultSATokenPath: "/var/run/secrets/vault/token", GiteaCredsPath: "gitea/creds/repospawner", WoodpeckerServer: "https://ci.k8s.syd1.au.unkin.net", WoodpeckerTokenFile: "/etc/repospawner/woodpecker/token", WoodpeckerSecret: "repospawner-woodpecker", AllowedGroups: []string{"akP-repospawner-user"}, } } func testRequest() store.Request { return store.Request{ ID: "abc123", Name: "widget", Description: "does widgets", Woodpecker: true, StatusChecks: []string{"ci/woodpecker/pr/build", "ci/woodpecker/pr/test"}, PRNumber: 42, PRURL: "https://git.unkin.net/unkin/terraform-git/pulls/42", Created: time.Date(2026, 8, 30, 1, 2, 3, 0, time.UTC), } } func TestPRJobSpec(t *testing.T) { cfg, req := testConfig(), testRequest() j := PR(cfg, req) if j.Name != "repospawner-pr-abc123" || j.Namespace != "repospawner" { t.Errorf("object meta = %s/%s", j.Namespace, j.Name) } if j.Labels[LabelRequest] != "abc123" || j.Labels[LabelType] != string(TypePR) || j.Labels[LabelApp] != AppName { t.Errorf("labels = %v", j.Labels) } pod := j.Spec.Template.Spec if pod.ServiceAccountName != "repospawner" { t.Errorf("serviceAccountName = %q", pod.ServiceAccountName) } // The job only needs the vault-audience token, not a k8s API token. if pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken { t.Errorf("automountServiceAccountToken = %v, want false", pod.AutomountServiceAccountToken) } if len(pod.Containers) != 1 || pod.Containers[0].Image != cfg.Image { t.Fatalf("containers = %+v", pod.Containers) } args := strings.Join(pod.Containers[0].Args, " ") want := "job pr --request abc123 --name widget --description does widgets " + "--check ci/woodpecker/pr/build --check ci/woodpecker/pr/test" if args != want { t.Errorf("args = %q, want %q", args, want) } if pod.RestartPolicy != corev1.RestartPolicyNever { t.Errorf("restartPolicy = %q", pod.RestartPolicy) } if j.Spec.TTLSecondsAfterFinished == nil || *j.Spec.TTLSecondsAfterFinished != 3600 { t.Errorf("ttlSecondsAfterFinished = %v", j.Spec.TTLSecondsAfterFinished) } if j.Spec.BackoffLimit == nil || *j.Spec.BackoffLimit != 2 { t.Errorf("backoffLimit = %v", j.Spec.BackoffLimit) } // The vault-audience projected token is what makes the native login work. if len(pod.Volumes) != 1 || pod.Volumes[0].Projected == nil { t.Fatalf("volumes = %+v", pod.Volumes) } sat := pod.Volumes[0].Projected.Sources[0].ServiceAccountToken if sat == nil || sat.Audience != "vault" || sat.Path != "token" { t.Errorf("projected token = %+v", sat) } if pod.Containers[0].VolumeMounts[0].MountPath != "/var/run/secrets/vault" { t.Errorf("mountPath = %q", pod.Containers[0].VolumeMounts[0].MountPath) } env := map[string]string{} for _, e := range pod.Containers[0].Env { env[e.Name] = e.Value } for k, want := range map[string]string{ "GITEA_URL": "https://git.unkin.net", "VAULT_ADDR": "https://vault.service.consul:8200", "REPOSPAWNER_VAULT_K8S_MOUNT": "k8s/au/syd1", "REPOSPAWNER_GITEA_CREDS_PATH": "gitea/creds/repospawner", "REPOSPAWNER_TFGIT_REPO": "unkin/terraform-git", } { if env[k] != want { t.Errorf("env %s = %q, want %q", k, env[k], want) } } } func TestWatchJobSpec(t *testing.T) { j := Watch(testConfig(), testRequest()) if j.Name != "repospawner-watch-abc123" { t.Errorf("name = %q", j.Name) } args := strings.Join(j.Spec.Template.Spec.Containers[0].Args, " ") if args != "job watch --repo unkin/terraform-git --pr 42" { t.Errorf("args = %q", args) } if j.Annotations[AnnoPullRequestNo] != "42" || j.Annotations[AnnoPullRequest] == "" { t.Errorf("annotations = %v", j.Annotations) } // A terraform-git PR waits on a human, so the deadline is days not minutes. if j.Spec.ActiveDeadlineSeconds == nil || *j.Spec.ActiveDeadlineSeconds != 7*24*60*60 { t.Errorf("activeDeadlineSeconds = %v", j.Spec.ActiveDeadlineSeconds) } } func TestWoodpeckerJobMountsTokenSecret(t *testing.T) { cfg := testConfig() j := Woodpecker(cfg, testRequest()) if strings.Join(j.Spec.Template.Spec.Containers[0].Args, " ") != "job woodpecker-enable --name widget" { t.Errorf("args = %v", j.Spec.Template.Spec.Containers[0].Args) } var secret *corev1.SecretVolumeSource for _, v := range j.Spec.Template.Spec.Volumes { if v.Name == "woodpecker-token" { secret = v.Secret } } if secret == nil || secret.SecretName != "repospawner-woodpecker" { t.Fatalf("woodpecker volume = %+v", secret) } if secret.Items[0].Key != "token" || secret.Items[0].Path != "token" { t.Errorf("secret items = %+v", secret.Items) } var mounted bool for _, m := range j.Spec.Template.Spec.Containers[0].VolumeMounts { if m.Name == "woodpecker-token" && m.MountPath == "/etc/repospawner/woodpecker" { mounted = true } } if !mounted { t.Errorf("volumeMounts = %+v", j.Spec.Template.Spec.Containers[0].VolumeMounts) } } func TestDecodeResult(t *testing.T) { var res PRResult if !DecodeResult([]byte(`{"pr_number":7,"pr_url":"https://example/7"}`), &res) { t.Fatal("DecodeResult returned false for a valid message") } if res.PRNumber != 7 || res.PRURL != "https://example/7" { t.Errorf("res = %+v", res) } if DecodeResult(nil, &res) { t.Error("an empty termination message must not decode") } if DecodeResult([]byte(" \n"), &res) { t.Error("a whitespace termination message must not decode") } if DecodeResult([]byte("panic: boom"), &res) { t.Error("a non-JSON termination message must not decode") } } func TestViewOfAndRequestFrom(t *testing.T) { j := batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{LabelRequest: "abc123", LabelType: string(TypeWatch)}, Annotations: map[string]string{ AnnoName: "widget", AnnoDescription: "does widgets", AnnoWoodpecker: "true", AnnoStatusChecks: "a\nb", AnnoCreated: "2026-08-30T01:02:03Z", AnnoPullRequestNo: "42", AnnoPullRequest: "https://git.unkin.net/unkin/terraform-git/pulls/42", }, }, Status: batchv1.JobStatus{Succeeded: 1}, } v := ViewOf(j, []byte(`{"merged":true}`)) if v.Type != TypeWatch || v.Request != "abc123" || !v.Succeeded || v.Failed { t.Fatalf("view = %+v", v) } r := RequestFrom(v) if r.Name != "widget" || r.Description != "does widgets" || !r.Woodpecker { t.Errorf("request = %+v", r) } if len(r.StatusChecks) != 2 || r.StatusChecks[0] != "a" { t.Errorf("statusChecks = %v", r.StatusChecks) } if r.PRNumber != 42 || r.PRURL == "" { t.Errorf("pull request = %d %q", r.PRNumber, r.PRURL) } if !r.Created.Equal(time.Date(2026, 8, 30, 1, 2, 3, 0, time.UTC)) { t.Errorf("created = %v", r.Created) } } func TestViewOfFailedCondition(t *testing.T) { j := batchv1.Job{ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{LabelRequest: "x", LabelType: string(TypePR)}}, Status: batchv1.JobStatus{Conditions: []batchv1.JobCondition{ {Type: batchv1.JobFailed, Status: corev1.ConditionTrue}, }}, } if v := ViewOf(j, nil); !v.Failed { t.Errorf("view = %+v, want Failed", v) } }