dev-pod-api-build/internal/api/runners_test.go
2026-04-16 04:16:36 +00:00

548 lines
16 KiB
Go

package api
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"testing"
"time"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store"
)
type mockRunnerStore struct {
mu sync.Mutex
runners map[string]*model.Runner
}
func newMockRunnerStore() *mockRunnerStore {
return &mockRunnerStore{runners: make(map[string]*model.Runner)}
}
func (m *mockRunnerStore) CreateRunner(_ context.Context, r *model.Runner) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.runners[r.ID]; exists {
return fmt.Errorf("runner %q: %w", r.ID, store.ErrDuplicate)
}
for _, existing := range m.runners {
if r.WebhookDeliveryID != "" && existing.WebhookDeliveryID == r.WebhookDeliveryID {
return fmt.Errorf("runner %q: %w", r.ID, store.ErrDuplicate)
}
}
copy := *r
m.runners[r.ID] = &copy
return nil
}
func (m *mockRunnerStore) GetRunner(_ context.Context, id string) (*model.Runner, error) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.runners[id]
if !ok {
return nil, fmt.Errorf("runner %q: %w", id, store.ErrNotFound)
}
copy := *r
return &copy, nil
}
func (m *mockRunnerStore) ListRunners(_ context.Context, userFilter, statusFilter string) ([]model.Runner, error) {
m.mu.Lock()
defer m.mu.Unlock()
var result []model.Runner
for _, r := range m.runners {
if userFilter != "" && r.User != userFilter {
continue
}
if statusFilter != "" && string(r.Status) != statusFilter {
continue
}
result = append(result, *r)
}
return result, nil
}
func (m *mockRunnerStore) UpdateRunnerStatus(_ context.Context, id string, newStatus model.RunnerStatus, forgejoRunnerID string) error {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.runners[id]
if !ok {
return fmt.Errorf("runner %q: %w", id, store.ErrNotFound)
}
if !r.Status.CanTransitionTo(newStatus) {
return fmt.Errorf("invalid transition from %s to %s", r.Status, newStatus)
}
r.Status = newStatus
if forgejoRunnerID != "" {
r.ForgejoRunnerID = forgejoRunnerID
}
now := time.Now().UTC()
if newStatus == model.RunnerStatusJobClaimed {
r.ClaimedAt = &now
}
if newStatus.IsTerminal() {
r.CompletedAt = &now
}
return nil
}
func (m *mockRunnerStore) DeleteRunner(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.runners[id]; !ok {
return fmt.Errorf("runner %q: %w", id, store.ErrNotFound)
}
delete(m.runners, id)
return nil
}
func (m *mockRunnerStore) IsDeliveryProcessed(_ context.Context, deliveryID string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if deliveryID == "" {
return false, nil
}
for _, r := range m.runners {
if r.WebhookDeliveryID == deliveryID {
return true, nil
}
}
return false, nil
}
func (m *mockRunnerStore) GetStaleRunners(_ context.Context, ttl time.Duration) ([]model.Runner, error) {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().UTC().Add(-ttl)
var result []model.Runner
for _, r := range m.runners {
if r.CreatedAt.Before(cutoff) && !r.Status.IsTerminal() && r.Status != model.RunnerStatusCleanupPending {
result = append(result, *r)
}
}
return result, nil
}
type mockRunnerPodManager struct {
mu sync.Mutex
pods map[string]string // runnerID -> podName
createErr error
}
func newMockRunnerPodManager() *mockRunnerPodManager {
return &mockRunnerPodManager{pods: make(map[string]string)}
}
func (m *mockRunnerPodManager) CreateRunnerPod(_ context.Context, opts k8s.CreateRunnerPodOpts) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.createErr != nil {
return "", m.createErr
}
podName := model.RunnerPodName(opts.RunnerID)
m.pods[opts.RunnerID] = podName
return podName, nil
}
func (m *mockRunnerPodManager) DeleteRunnerPod(_ context.Context, _, podName string) error {
m.mu.Lock()
defer m.mu.Unlock()
for k, v := range m.pods {
if v == podName {
delete(m.pods, k)
return nil
}
}
return nil
}
func newRunnerTestRouter() (*mockKeyValidator, *mockUserStore, *mockRunnerStore, *mockRunnerPodManager, http.Handler) {
kv := newMockValidator()
us := newMockUserStore()
rs := newMockRunnerStore()
rp := newMockRunnerPodManager()
fm := newMockForgejoManager()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := &Server{
Store: kv,
K8s: newMockPodManager(),
Users: us,
Usage: newMockUsageStore(),
Runners: rs,
RunnerPods: rp,
Forgejo: fm,
Logger: logger,
GenerateKey: mockGenerateKey,
}
return kv, us, rs, rp, NewRouter(srv)
}
func TestCreateRunner_Success(t *testing.T) {
kv, us, rs, rp, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
body := `{"user":"alice","repo":"alice/myrepo","branch":"main","tools":"go","task":"build it"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var runner model.Runner
decodeJSON(t, rr, &runner)
if runner.User != "alice" {
t.Errorf("expected user alice, got %s", runner.User)
}
if runner.RepoURL != "alice/myrepo" {
t.Errorf("expected repo alice/myrepo, got %s", runner.RepoURL)
}
if runner.Status != model.RunnerStatusPodCreating {
t.Errorf("expected status pod_creating, got %s", runner.Status)
}
rs.mu.Lock()
count := len(rs.runners)
rs.mu.Unlock()
if count != 1 {
t.Errorf("expected 1 runner in store, got %d", count)
}
rp.mu.Lock()
podCount := len(rp.pods)
rp.mu.Unlock()
if podCount != 1 {
t.Errorf("expected 1 pod created, got %d", podCount)
}
}
func TestCreateRunner_DedupeWebhook(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
body := `{"user":"alice","repo":"alice/myrepo","webhook_delivery_id":"delivery-xyz"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
if rr.Code != http.StatusCreated {
t.Fatalf("first create: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
rr2 := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
if rr2.Code != http.StatusConflict {
t.Fatalf("dupe: expected 409, got %d: %s", rr2.Code, rr2.Body.String())
}
rs.mu.Lock()
count := len(rs.runners)
rs.mu.Unlock()
if count != 1 {
t.Errorf("expected exactly 1 runner after dedupe, got %d", count)
}
}
func TestCreateRunner_ForbiddenOtherUser(t *testing.T) {
kv, us, _, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
body := `{"user":"bob","repo":"bob/repo"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestCreateRunner_AdminCanCreateForOthers(t *testing.T) {
kv, us, _, _, router := newRunnerTestRouter()
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
bob := &model.User{ID: "bob", Quota: model.DefaultQuota()}
kv.addKey("dpk_admin", admin, model.RoleAdmin)
us.addUser(admin)
us.addUser(bob)
body := `{"user":"bob","repo":"bob/repo"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_admin")
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestCreateRunner_UserNotFound(t *testing.T) {
kv, _, _, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
body := `{"user":"alice","repo":"alice/repo"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestCreateRunner_InvalidBody(t *testing.T) {
kv, _, _, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
rr := doRequest(router, http.MethodPost, "/api/v1/runners", "not json", "dpk_test")
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
}
func TestCreateRunner_ValidationError(t *testing.T) {
kv, _, _, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
rr := doRequest(router, http.MethodPost, "/api/v1/runners", `{"user":"alice"}`, "dpk_test")
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestCreateRunner_PodCreationFails(t *testing.T) {
kv := newMockValidator()
us := newMockUserStore()
rs := newMockRunnerStore()
rp := newMockRunnerPodManager()
rp.createErr = fmt.Errorf("k8s error")
fm := newMockForgejoManager()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := &Server{
Store: kv,
K8s: newMockPodManager(),
Users: us,
Usage: newMockUsageStore(),
Runners: rs,
RunnerPods: rp,
Forgejo: fm,
Logger: logger,
GenerateKey: mockGenerateKey,
}
router := NewRouter(srv)
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
body := `{"user":"alice","repo":"alice/repo"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners", body, "dpk_test")
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String())
}
rs.mu.Lock()
for _, r := range rs.runners {
if r.Status != model.RunnerStatusFailed {
t.Errorf("expected runner status failed after pod creation failure, got %s", r.Status)
}
}
rs.mu.Unlock()
}
func TestListRunners_UserSeesOwn(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
alice := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_alice", alice, model.RoleUser)
us.addUser(alice)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
rs.runners["r2"] = &model.Runner{ID: "r2", User: "bob", Status: model.RunnerStatusReceived}
rs.mu.Unlock()
rr := doRequest(router, http.MethodGet, "/api/v1/runners", "", "dpk_alice")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var runners []model.Runner
decodeJSON(t, rr, &runners)
if len(runners) != 1 {
t.Fatalf("expected 1 runner, got %d", len(runners))
}
if runners[0].User != "alice" {
t.Errorf("expected alice's runner, got %s", runners[0].User)
}
}
func TestListRunners_AdminSeesAll(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()}
kv.addKey("dpk_admin", admin, model.RoleAdmin)
us.addUser(admin)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
rs.runners["r2"] = &model.Runner{ID: "r2", User: "bob", Status: model.RunnerStatusReceived}
rs.mu.Unlock()
rr := doRequest(router, http.MethodGet, "/api/v1/runners", "", "dpk_admin")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var runners []model.Runner
decodeJSON(t, rr, &runners)
if len(runners) != 2 {
t.Fatalf("expected 2 runners, got %d", len(runners))
}
}
func TestDeleteRunner_Success(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", PodName: "dev-pod-r1", Status: model.RunnerStatusReceived}
rs.mu.Unlock()
rr := doRequest(router, http.MethodDelete, "/api/v1/runners/r1", "", "dpk_test")
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String())
}
rs.mu.Lock()
count := len(rs.runners)
rs.mu.Unlock()
if count != 0 {
t.Errorf("expected 0 runners after delete, got %d", count)
}
}
func TestDeleteRunner_NotFound(t *testing.T) {
kv, _, _, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
rr := doRequest(router, http.MethodDelete, "/api/v1/runners/nonexistent", "", "dpk_test")
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestDeleteRunner_ForbiddenOtherUser(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "bob", Status: model.RunnerStatusReceived}
rs.mu.Unlock()
rr := doRequest(router, http.MethodDelete, "/api/v1/runners/r1", "", "dpk_test")
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestUpdateRunnerStatus_Success(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
rs.mu.Unlock()
body := `{"status":"pod_creating"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners/r1/status", body, "dpk_test")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var runner model.Runner
decodeJSON(t, rr, &runner)
if runner.Status != model.RunnerStatusPodCreating {
t.Errorf("expected status pod_creating, got %s", runner.Status)
}
}
func TestUpdateRunnerStatus_InvalidTransition(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusReceived}
rs.mu.Unlock()
body := `{"status":"completed"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners/r1/status", body, "dpk_test")
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestUpdateRunnerStatus_NotFound(t *testing.T) {
kv, _, _, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
body := `{"status":"pod_creating"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners/nonexistent/status", body, "dpk_test")
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestUpdateRunnerStatus_WithForgejoRunnerID(t *testing.T) {
kv, us, rs, _, router := newRunnerTestRouter()
user := &model.User{ID: "alice", Quota: model.DefaultQuota()}
kv.addKey("dpk_test", user, model.RoleUser)
us.addUser(user)
rs.mu.Lock()
rs.runners["r1"] = &model.Runner{ID: "r1", User: "alice", Status: model.RunnerStatusPodCreating}
rs.mu.Unlock()
body := `{"status":"runner_registered","forgejo_runner_id":"forgejo-99"}`
rr := doRequest(router, http.MethodPost, "/api/v1/runners/r1/status", body, "dpk_test")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var runner model.Runner
decodeJSON(t, rr, &runner)
if runner.ForgejoRunnerID != "forgejo-99" {
t.Errorf("expected forgejo_runner_id forgejo-99, got %s", runner.ForgejoRunnerID)
}
}
func TestRunnerRoutes_NoAuth(t *testing.T) {
_, _, _, _, router := newRunnerTestRouter()
routes := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/runners"},
{http.MethodGet, "/api/v1/runners"},
{http.MethodDelete, "/api/v1/runners/r1"},
{http.MethodPost, "/api/v1/runners/r1/status"},
}
for _, rt := range routes {
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
rr := doRequest(router, rt.method, rt.path, "", "")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
})
}
}