Files
repospawner/internal/jobrun/jobrun_test.go
T
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the initial repospawner service
repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
2026-08-30 14:33:31 +10:00

279 lines
8.3 KiB
Go

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"
}