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] = © 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 ©, 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) } }) } }