package jobrun import ( "context" "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "git.unkin.net/unkin/repospawner/internal/config" "git.unkin.net/unkin/repospawner/internal/jobs" ) func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // forge is a stub Gitea plus Vault, since the jobs mint a token before every // forge call. type forge struct { branches atomic.Int32 files atomic.Int32 lastFile string prCalls atomic.Int32 pollCalls atomic.Int32 merged atomic.Bool closed atomic.Bool } func (f *forge) handler(t *testing.T) http.HandlerFunc { t.Helper() return func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/login"): _, _ = w.Write([]byte(`{"auth":{"client_token":"s.vault"}}`)) case strings.HasPrefix(r.URL.Path, "/v1/gitea/creds/"): _, _ = w.Write([]byte(`{"data":{"username":"u","token":"gitea-tok"}}`)) case strings.HasSuffix(r.URL.Path, "/branches"): f.branches.Add(1) _, _ = w.Write([]byte(`{"name":"repospawner/widget"}`)) case strings.Contains(r.URL.Path, "/contents/"): f.files.Add(1) var body map[string]string raw, _ := io.ReadAll(r.Body) _ = json.Unmarshal(raw, &body) decoded, err := base64.StdEncoding.DecodeString(body["content"]) if err != nil { t.Errorf("content is not base64: %v", err) } f.lastFile = string(decoded) _, _ = w.Write([]byte(`{}`)) case strings.HasSuffix(r.URL.Path, "/pulls"): f.prCalls.Add(1) _, _ = w.Write([]byte(`{"number":42,"html_url":"https://git.unkin.net/unkin/terraform-git/pulls/42","state":"open"}`)) case strings.Contains(r.URL.Path, "/pulls/"): f.pollCalls.Add(1) switch { case f.merged.Load(): _, _ = w.Write([]byte(`{"number":42,"state":"closed","merged":true}`)) case f.closed.Load(): _, _ = w.Write([]byte(`{"number":42,"state":"closed","merged":false}`)) default: _, _ = w.Write([]byte(`{"number":42,"state":"open","merged":false}`)) } case strings.HasPrefix(r.URL.Path, "/api/v1/repos/"): _, _ = w.Write([]byte(`{"id":91,"full_name":"unkin/widget"}`)) default: t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) } } } func testCfg(t *testing.T, base string) *config.Config { t.Helper() tokenPath := filepath.Join(t.TempDir(), "token") if err := os.WriteFile(tokenPath, []byte("sa-jwt"), 0o600); err != nil { t.Fatalf("write sa token: %v", err) } return &config.Config{ GiteaURL: base, TFGitRepo: "unkin/terraform-git", VaultAddr: base, VaultK8sMount: "k8s/au/syd1", VaultK8sRole: "repospawner", VaultSATokenPath: tokenPath, GiteaCredsPath: "gitea/creds/repospawner", WoodpeckerServer: base, } } func TestPRWritesConfigAndOpensPullRequest(t *testing.T) { f := &forge{} srv := httptest.NewServer(f.handler(t)) defer srv.Close() res, err := PR(context.Background(), quietLogger(), testCfg(t, srv.URL), PROptions{ RequestID: "abc123", Name: "widget", Description: "does widgets", StatusChecks: []string{"ci/woodpecker/pr/test"}, }) if err != nil { t.Fatalf("PR: %v", err) } if res.PRNumber != 42 || !strings.HasSuffix(res.PRURL, "/42") { t.Errorf("result = %+v", res) } if f.branches.Load() != 1 || f.files.Load() != 1 || f.prCalls.Load() != 1 { t.Errorf("calls: branches=%d files=%d pulls=%d", f.branches.Load(), f.files.Load(), f.prCalls.Load()) } if !strings.Contains(f.lastFile, `description: "does widgets"`) || !strings.Contains(f.lastFile, `- "ci/woodpecker/pr/test"`) { t.Errorf("committed file:\n%s", f.lastFile) } } func TestPRRejectsAnInvalidRequestBeforeTouchingTheForge(t *testing.T) { f := &forge{} srv := httptest.NewServer(f.handler(t)) defer srv.Close() res, err := PR(context.Background(), quietLogger(), testCfg(t, srv.URL), PROptions{ RequestID: "abc123", Name: "Widget", Description: "d", StatusChecks: []string{"x"}, }) if err == nil { t.Fatal("expected a validation error") } if res.Error == "" { t.Error("the result must carry the reason for the termination message") } if f.branches.Load() != 0 { t.Error("an invalid request must not branch terraform-git") } } func TestWatchReportsMerge(t *testing.T) { f := &forge{} f.merged.Store(true) srv := httptest.NewServer(f.handler(t)) defer srv.Close() res, err := Watch(context.Background(), quietLogger(), testCfg(t, srv.URL), WatchOptions{ Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond, }) if err != nil { t.Fatalf("Watch: %v", err) } if !res.Merged || res.Closed { t.Errorf("result = %+v", res) } } func TestWatchReportsClose(t *testing.T) { f := &forge{} f.closed.Store(true) srv := httptest.NewServer(f.handler(t)) defer srv.Close() res, err := Watch(context.Background(), quietLogger(), testCfg(t, srv.URL), WatchOptions{ Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond, }) if err != nil { t.Fatalf("Watch: %v", err) } if !res.Closed || res.Merged { t.Errorf("result = %+v", res) } } func TestWatchKeepsPollingWhileOpen(t *testing.T) { f := &forge{} srv := httptest.NewServer(f.handler(t)) defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) defer cancel() res, err := Watch(ctx, quietLogger(), testCfg(t, srv.URL), WatchOptions{ Repo: "unkin/terraform-git", Number: 42, Interval: time.Millisecond, }) if err == nil { t.Fatal("an open pull request must not resolve the watch") } if res.Error == "" { t.Error("the result must explain why the watch ended") } if f.pollCalls.Load() < 2 { t.Errorf("polls = %d, want repeated polling", f.pollCalls.Load()) } } func TestWoodpeckerEnable(t *testing.T) { var enabled atomic.Bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/login"): _, _ = w.Write([]byte(`{"auth":{"client_token":"s.vault"}}`)) case strings.HasPrefix(r.URL.Path, "/v1/gitea/creds/"): _, _ = w.Write([]byte(`{"data":{"username":"u","token":"gitea-tok"}}`)) case r.URL.Path == "/api/v1/repos/unkin/widget": _, _ = w.Write([]byte(`{"id":91,"full_name":"unkin/widget"}`)) case r.URL.Path == "/api/repos": if r.URL.Query().Get("forge_remote_id") != "91" { t.Errorf("forge_remote_id = %q", r.URL.Query().Get("forge_remote_id")) } enabled.Store(true) _, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":true}`)) case strings.HasPrefix(r.URL.Path, "/api/repos/lookup/"): _, _ = w.Write([]byte(`{"id":5,"full_name":"unkin/widget","active":` + boolStr(enabled.Load()) + `}`)) default: t.Errorf("unexpected request %s", r.URL.Path) } })) defer srv.Close() cfg := testCfg(t, srv.URL) cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "wp") if err := os.WriteFile(cfg.WoodpeckerTokenFile, []byte("wp-token\n"), 0o600); err != nil { t.Fatalf("write token: %v", err) } res, err := WoodpeckerEnable(context.Background(), quietLogger(), cfg, "widget") if err != nil { t.Fatalf("WoodpeckerEnable: %v", err) } if !res.Enabled || res.RepoID != 91 { t.Errorf("result = %+v", res) } } func TestWoodpeckerEnableWithoutToken(t *testing.T) { cfg := testCfg(t, "https://unused.invalid") cfg.WoodpeckerTokenFile = filepath.Join(t.TempDir(), "absent") res, err := WoodpeckerEnable(context.Background(), quietLogger(), cfg, "widget") if err == nil { t.Fatal("expected an error with no token file") } if res.Error == "" || res.Enabled { t.Errorf("result = %+v", res) } } func TestReportWritesTerminationMessage(t *testing.T) { path := filepath.Join(t.TempDir(), "termination-log") Report(quietLogger(), path, jobs.PRResult{PRNumber: 42, PRURL: "https://example/42"}) raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } var got jobs.PRResult if !jobs.DecodeResult(raw, &got) { t.Fatalf("termination message %q did not decode", raw) } if got.PRNumber != 42 || got.PRURL != "https://example/42" { t.Errorf("result = %+v", got) } } func TestReportSurvivesAnUnwritablePath(t *testing.T) { // The kubelet path is absent outside a pod; the job must still exit cleanly. Report(quietLogger(), filepath.Join(t.TempDir(), "missing-dir", "log"), jobs.WatchResult{Merged: true}) } func boolStr(b bool) string { if b { return "true" } return "false" }