package server import ( "context" "encoding/json" "errors" "io/fs" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "testing/fstest" "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/gitea" "git.unkin.net/unkin/repospawner/internal/jobs" "git.unkin.net/unkin/repospawner/internal/store" ) // fakeCluster records what the server would have created. type fakeCluster struct { mu sync.Mutex created []*batchv1.Job jobs []batchv1.Job pods []corev1.Pod createErr error pingErr error } func (f *fakeCluster) CreateJob(_ context.Context, job *batchv1.Job) error { f.mu.Lock() defer f.mu.Unlock() if f.createErr != nil { return f.createErr } f.created = append(f.created, job) f.jobs = append(f.jobs, *job) return nil } func (f *fakeCluster) ListJobs(context.Context) ([]batchv1.Job, error) { f.mu.Lock() defer f.mu.Unlock() return append([]batchv1.Job(nil), f.jobs...), nil } func (f *fakeCluster) ListPods(context.Context) ([]corev1.Pod, error) { f.mu.Lock() defer f.mu.Unlock() return append([]corev1.Pod(nil), f.pods...), nil } func (f *fakeCluster) Ping(context.Context) error { return f.pingErr } func (f *fakeCluster) createdNames() []string { f.mu.Lock() defer f.mu.Unlock() out := make([]string, 0, len(f.created)) for _, j := range f.created { out = append(out, j.Name) } return out } func testAssets() fs.FS { return fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("repospawner")}, "app.css": &fstest.MapFile{Data: []byte("body{}")}, } } func testCfg(t *testing.T) *config.Config { t.Helper() return &config.Config{ Listen: ":0", Namespace: "repospawner", Image: "repospawner:test", JobServiceAccount: "repospawner", GiteaURL: "https://git.unkin.net", TFGitRepo: "unkin/terraform-git", VaultAddr: "https://vault.invalid", VaultK8sMount: "k8s/au/syd1", VaultK8sRole: "repospawner", VaultSATokenPath: "/var/run/secrets/vault/token", GiteaCredsPath: "gitea/creds/repospawner", WoodpeckerServer: "https://ci.invalid", WoodpeckerTokenFile: filepath.Join(t.TempDir(), "absent"), WoodpeckerSecret: "repospawner-woodpecker", GroupsHeader: "X-Forwarded-Groups", AllowedGroups: []string{"akP-repospawner-user"}, } } // newTestServer wires a Server against a stub forge whose contents endpoint // answers exists for every name in taken. func newTestServer(t *testing.T, cfg *config.Config, cluster Cluster, taken ...string) (*Server, *store.Store) { t.Helper() forgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for _, name := range taken { if strings.HasSuffix(r.URL.Path, "/"+name+".yaml") { _, _ = w.Write([]byte(`{"name":"` + name + `.yaml"}`)) return } } w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message":"object does not exist"}`)) })) t.Cleanup(forgeSrv.Close) forge := gitea.New(forgeSrv.URL, func(context.Context, bool) (string, error) { return "tok", nil }) st := store.New() log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) return New(cfg, st, forge, cluster, testAssets(), log), st } func post(t *testing.T, h http.Handler, body string, groups string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") if groups != "" { r.Header.Set("X-Forwarded-Groups", groups) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } const allowed = "akP-repospawner-user" func TestCreateAcceptsAndLaunchesPRJob(t *testing.T) { cluster := &fakeCluster{} srv, st := newTestServer(t, testCfg(t), cluster) h := srv.Handler() w := post(t, h, `{"name":"widget","description":"does widgets","woodpecker":false,"status_checks":["ci/woodpecker/pr/test"]}`, allowed) if w.Code != http.StatusAccepted { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } var res struct { ID string `json:"id"` StatusURL string `json:"status_url"` State string `json:"state"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { t.Fatalf("decode: %v", err) } if res.ID == "" || res.StatusURL != "/api/requests/"+res.ID || res.State != string(store.StateOpeningPR) { t.Fatalf("response = %+v", res) } if got := w.Header().Get("Location"); got != res.StatusURL { t.Errorf("Location = %q, want %q", got, res.StatusURL) } if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-pr-"+res.ID { t.Errorf("created jobs = %v", names) } if _, ok := st.Get(res.ID); !ok { t.Error("the accepted request was not recorded") } // The status URL the caller was handed must resolve. getReq := httptest.NewRequest(http.MethodGet, res.StatusURL, nil) getReq.Header.Set("X-Forwarded-Groups", allowed) getRec := httptest.NewRecorder() h.ServeHTTP(getRec, getReq) if getRec.Code != http.StatusOK { t.Fatalf("GET %s = %d", res.StatusURL, getRec.Code) } var stored store.Request if err := json.Unmarshal(getRec.Body.Bytes(), &stored); err != nil { t.Fatalf("decode: %v", err) } if stored.Name != "widget" || stored.State != store.StateOpeningPR || stored.PRURL != "" { t.Errorf("stored = %+v (pr_url is empty until the job reports it)", stored) } } func TestCreateValidationErrors(t *testing.T) { srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) h := srv.Handler() cases := []struct { name string body string status int fields []string }{ {name: "not json", body: `nope`, status: http.StatusBadRequest}, {name: "unknown field", body: `{"name":"a","description":"d","status_checks":["x"],"private":true}`, status: http.StatusBadRequest}, { name: "missing everything", body: `{}`, status: http.StatusBadRequest, fields: []string{"name", "description", "status_checks"}, }, { name: "bad name", body: `{"name":"Widget","description":"d","status_checks":["x"]}`, status: http.StatusBadRequest, fields: []string{"name"}, }, { name: "blank status checks", body: `{"name":"widget","description":"d","status_checks":[" ",""]}`, status: http.StatusBadRequest, fields: []string{"status_checks"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := post(t, h, tc.body, allowed) if w.Code != tc.status { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } if len(tc.fields) == 0 { return } var res struct { Fields map[string]string `json:"fields"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { t.Fatalf("decode: %v", err) } if len(res.Fields) != len(tc.fields) { t.Fatalf("fields = %v, want %v", res.Fields, tc.fields) } for _, f := range tc.fields { if _, ok := res.Fields[f]; !ok { t.Errorf("missing field error %q in %v", f, res.Fields) } } }) } } func TestCreateRejectsNameAlreadyInTerraformGit(t *testing.T) { cluster := &fakeCluster{} srv, _ := newTestServer(t, testCfg(t), cluster, "widget") w := post(t, srv.Handler(), `{"name":"widget","description":"d","status_checks":["x"]}`, allowed) if w.Code != http.StatusConflict { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } if len(cluster.createdNames()) != 0 { t.Error("a rejected request must not launch a job") } } func TestCreateRejectsDuplicateInFlightName(t *testing.T) { srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) h := srv.Handler() body := `{"name":"widget","description":"d","status_checks":["x"]}` if w := post(t, h, body, allowed); w.Code != http.StatusAccepted { t.Fatalf("first request status = %d", w.Code) } if w := post(t, h, body, allowed); w.Code != http.StatusConflict { t.Fatalf("second request status = %d, want 409", w.Code) } } func TestCreateRejectsWoodpeckerWithoutToken(t *testing.T) { srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) w := post(t, srv.Handler(), `{"name":"widget","description":"d","woodpecker":true,"status_checks":["x"]}`, allowed) if w.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "woodpecker") { t.Errorf("body = %s", w.Body.String()) } } func TestCreateAcceptsWoodpeckerWhenTokenMounted(t *testing.T) { cfg := testCfg(t) cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "token") if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp-token\n"), 0o600); err != nil { t.Fatalf("write token: %v", err) } srv, _ := newTestServer(t, cfg, &fakeCluster{}) w := post(t, srv.Handler(), `{"name":"widget","description":"d","woodpecker":true,"status_checks":["x"]}`, allowed) if w.Code != http.StatusAccepted { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } } func TestCreateSurfacesJobFailure(t *testing.T) { cluster := &fakeCluster{createErr: errors.New("forbidden")} srv, st := newTestServer(t, testCfg(t), cluster) w := post(t, srv.Handler(), `{"name":"widget","description":"d","status_checks":["x"]}`, allowed) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d", w.Code) } if len(st.List()) != 0 { t.Error("a request whose job could not be created must not be recorded") } } func TestGroupGateAndProbes(t *testing.T) { cluster := &fakeCluster{} srv, _ := newTestServer(t, testCfg(t), cluster) h := srv.Handler() for _, path := range []string{"/", "/api/requests"} { w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) if w.Code != http.StatusForbidden { t.Errorf("GET %s without a group = %d, want 403", path, w.Code) } } if w := post(t, h, `{"name":"widget","description":"d","status_checks":["x"]}`, ""); w.Code != http.StatusForbidden { t.Errorf("POST without a group = %d, want 403", w.Code) } // Probes are ungated: the kubelet sends no identity header. for _, path := range []string{"/livez", "/readyz"} { w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) if w.Code != http.StatusOK { t.Errorf("GET %s = %d, want 200", path, w.Code) } } cluster.pingErr = errors.New("connection refused") w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if w.Code != http.StatusServiceUnavailable { t.Errorf("readyz with an unreachable API = %d, want 503", w.Code) } } func TestSecureHeadersAndUI(t *testing.T) { srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) r := httptest.NewRequest(http.MethodGet, "/", nil) r.Header.Set("X-Forwarded-Groups", allowed) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "repospawner") { t.Fatalf("status = %d body = %q", w.Code, w.Body.String()) } if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'self'") { t.Errorf("CSP = %q", got) } if w.Header().Get("X-Content-Type-Options") != "nosniff" { t.Errorf("X-Content-Type-Options = %q", w.Header().Get("X-Content-Type-Options")) } // An unknown /api path must 404 rather than fall through to index.html. r2 := httptest.NewRequest(http.MethodGet, "/api/nope", nil) r2.Header.Set("X-Forwarded-Groups", allowed) w2 := httptest.NewRecorder() srv.Handler().ServeHTTP(w2, r2) if w2.Code != http.StatusNotFound { t.Errorf("GET /api/nope = %d, want 404", w2.Code) } } func TestListNewestFirst(t *testing.T) { srv, st := newTestServer(t, testCfg(t), &fakeCluster{}) base := time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC) now := base st.SetClock(func() time.Time { return now }) for _, name := range []string{"one", "two"} { if w := post(t, srv.Handler(), `{"name":"`+name+`","description":"d","status_checks":["x"]}`, allowed); w.Code != http.StatusAccepted { t.Fatalf("post %s = %d", name, w.Code) } now = now.Add(time.Minute) } r := httptest.NewRequest(http.MethodGet, "/api/requests", nil) r.Header.Set("X-Forwarded-Groups", allowed) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, r) var res struct { Requests []store.Request `json:"requests"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { t.Fatalf("decode: %v", err) } if len(res.Requests) != 2 || res.Requests[0].Name != "two" { t.Errorf("requests = %+v, want newest first", res.Requests) } } func TestGetUnknownRequest(t *testing.T) { srv, _ := newTestServer(t, testCfg(t), &fakeCluster{}) r := httptest.NewRequest(http.MethodGet, "/api/requests/nope", nil) r.Header.Set("X-Forwarded-Groups", allowed) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, r) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } func TestCapabilitiesReportsWoodpecker(t *testing.T) { cfg := testCfg(t) srv, _ := newTestServer(t, cfg, &fakeCluster{}) read := func() bool { r := httptest.NewRequest(http.MethodGet, "/api/capabilities", nil) r.Header.Set("X-Forwarded-Groups", allowed) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, r) var res struct { Woodpecker bool `json:"woodpecker"` } if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { t.Fatalf("decode: %v", err) } return res.Woodpecker } if read() { t.Error("woodpecker must be unavailable with no token file") } if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil { t.Fatalf("write token: %v", err) } if !read() { t.Error("woodpecker must become available once the token is mounted") } } // jobFor builds a finished Job with its pod, as the reconciler would observe it. func jobFor(id string, t jobs.Type, anno map[string]string, succeeded bool, message string) (batchv1.Job, corev1.Pod) { labels := map[string]string{jobs.LabelApp: jobs.AppName, jobs.LabelRequest: id, jobs.LabelType: string(t)} job := batchv1.Job{ ObjectMeta: metav1.ObjectMeta{Name: jobs.Name(t, id), Labels: labels, Annotations: anno}, } if succeeded { job.Status.Succeeded = 1 } pod := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: jobs.Name(t, id) + "-xyz", Labels: labels}, Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: message}}, }}}, } return job, pod } func TestReconcileRebuildsStateFromJobs(t *testing.T) { anno := map[string]string{ jobs.AnnoName: "widget", jobs.AnnoDescription: "does widgets", jobs.AnnoWoodpecker: "false", jobs.AnnoStatusChecks: "ci/woodpecker/pr/test", jobs.AnnoCreated: "2026-08-30T01:02:03Z", } prJob, prPod := jobFor("abc123", jobs.TypePR, anno, true, `{"pr_number":42,"pr_url":"https://git.unkin.net/unkin/terraform-git/pulls/42"}`) cluster := &fakeCluster{jobs: []batchv1.Job{prJob}, pods: []corev1.Pod{prPod}} // A fresh store, as after a restart: everything comes from the cluster. srv, st := newTestServer(t, testCfg(t), cluster) if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } got, ok := st.Get("abc123") if !ok { t.Fatal("the request was not rebuilt from its job") } if got.Name != "widget" || got.Description != "does widgets" { t.Errorf("rebuilt request = %+v", got) } if got.State != store.StatePROpen || got.PRNumber != 42 || got.PRURL == "" { t.Errorf("state = %q pr = %d %q", got.State, got.PRNumber, got.PRURL) } if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-watch-abc123" { t.Fatalf("created jobs = %v, want the watch job", names) } // A second pass must not create the watch job again. if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } if names := cluster.createdNames(); len(names) != 1 { t.Errorf("created jobs after a second reconcile = %v", names) } } // mergedWoodpeckerCluster holds a request whose terraform-git PR merged and // which asked for Woodpecker enablement. func mergedWoodpeckerCluster() *fakeCluster { anno := map[string]string{ jobs.AnnoName: "widget", jobs.AnnoWoodpecker: "true", jobs.AnnoCreated: "2026-08-30T01:02:03Z", jobs.AnnoPullRequest: "https://git.unkin.net/unkin/terraform-git/pulls/42", jobs.AnnoPullRequestNo: "42", } watchJob, watchPod := jobFor("abc123", jobs.TypeWatch, anno, true, `{"merged":true}`) return &fakeCluster{jobs: []batchv1.Job{watchJob}, pods: []corev1.Pod{watchPod}} } func TestReconcileWaitsForWoodpeckerToken(t *testing.T) { cluster := mergedWoodpeckerCluster() srv, st := newTestServer(t, testCfg(t), cluster) // Short of the bound the request keeps waiting; the token may still arrive. for i := range maxWoodpeckerTokenWaits - 1 { if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile %d: %v", i, err) } got, _ := st.Get("abc123") if got.State != store.StateEnablingCI { t.Fatalf("state after %d passes = %q, want enabling-ci", i+1, got.State) } } if len(cluster.createdNames()) != 0 { t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames()) } } func TestReconcileFailsRequestWhenWoodpeckerTokenNeverArrives(t *testing.T) { cluster := mergedWoodpeckerCluster() srv, st := newTestServer(t, testCfg(t), cluster) for i := range maxWoodpeckerTokenWaits { if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile %d: %v", i, err) } } got, _ := st.Get("abc123") if got.State != store.StateFailed { t.Fatalf("state = %q, want failed", got.State) } if got.Error != woodpeckerTokenUnavailable { t.Errorf("error = %q, want %q", got.Error, woodpeckerTokenUnavailable) } if len(cluster.createdNames()) != 0 { t.Errorf("no woodpecker job may be created without a token: %v", cluster.createdNames()) } // The failure sticks across later passes, and the API surfaces the reason. if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } r := httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil) r.Header.Set("X-Forwarded-Groups", allowed) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("GET = %d", w.Code) } var served store.Request if err := json.Unmarshal(w.Body.Bytes(), &served); err != nil { t.Fatalf("decode: %v", err) } if served.State != store.StateFailed || served.Error != woodpeckerTokenUnavailable { t.Errorf("served = %+v, want a failed request naming the missing token", served) } } func TestReconcileEnablesWoodpeckerOnceTheTokenReturns(t *testing.T) { cfg := testCfg(t) cluster := mergedWoodpeckerCluster() srv, st := newTestServer(t, cfg, cluster) if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp\n"), 0o600); err != nil { t.Fatalf("write token: %v", err) } if err := srv.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } if names := cluster.createdNames(); len(names) != 1 || names[0] != "repospawner-woodpecker-abc123" { t.Fatalf("created jobs = %v, want the woodpecker job", names) } if got, _ := st.Get("abc123"); got.State != store.StateEnablingCI { t.Errorf("state = %q, want enabling-ci", got.State) } } func TestCreateSerialisesConcurrentSubmissionsOfOneName(t *testing.T) { srv, st := newTestServer(t, testCfg(t), &fakeCluster{}) h := srv.Handler() body := `{"name":"widget","description":"d","status_checks":["x"]}` const submissions = 8 codes := make([]int, submissions) start := make(chan struct{}) var wg sync.WaitGroup for i := range submissions { wg.Add(1) go func() { defer wg.Done() <-start r := httptest.NewRequest(http.MethodPost, "/api/requests", strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Forwarded-Groups", allowed) w := httptest.NewRecorder() h.ServeHTTP(w, r) codes[i] = w.Code }() } close(start) wg.Wait() accepted, conflicted := 0, 0 for _, c := range codes { switch c { case http.StatusAccepted: accepted++ case http.StatusConflict: conflicted++ } } if accepted != 1 || conflicted != submissions-1 { t.Fatalf("codes = %v, want exactly one 202 and %d 409s", codes, submissions-1) } if got := len(st.List()); got != 1 { t.Errorf("stored requests = %d, want 1", got) } } func TestTerminationMessagesPrefersSuccessfulAttempt(t *testing.T) { labels := map[string]string{jobs.LabelRequest: "abc123", jobs.LabelType: string(jobs.TypePR)} pods := []corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{Labels: labels}, Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ LastTerminationState: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Message: `{"error":"first try"}`}, }, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ExitCode: 0, Message: `{"pr_number":7}`}, }, }}}, }} got := terminationMessages(pods) if string(got[resultKey{"abc123", jobs.TypePR}]) != `{"pr_number":7}` { t.Errorf("message = %q", got[resultKey{"abc123", jobs.TypePR}]) } }