package api import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "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" ) // mockPodManager implements PodManager for testing. type mockPodManager struct { mu sync.Mutex pods map[string]map[string]*model.Pod // user -> pod name -> Pod deleteAllPodsErr error // if set, DeleteAllPods returns this } func newMockPodManager() *mockPodManager { return &mockPodManager{ pods: make(map[string]map[string]*model.Pod), } } func (m *mockPodManager) addPod(p *model.Pod) { m.mu.Lock() defer m.mu.Unlock() if m.pods[p.User] == nil { m.pods[p.User] = make(map[string]*model.Pod) } m.pods[p.User][p.Name] = p } func (m *mockPodManager) CreatePod(_ context.Context, opts k8s.CreatePodOpts) (*model.Pod, error) { m.mu.Lock() defer m.mu.Unlock() if opts.MaxConcurrentPods > 0 && len(m.pods[opts.User]) >= opts.MaxConcurrentPods { return nil, k8s.ErrQuotaExceeded } if m.pods[opts.User] != nil { if _, exists := m.pods[opts.User][opts.Pod]; exists { return nil, k8s.ErrPodAlreadyExists } } pod := &model.Pod{ User: opts.User, Name: opts.Pod, Tools: opts.Tools, CPUReq: opts.CPUReq, CPULimit: opts.CPULimit, MemReq: opts.MemReq, MemLimit: opts.MemLimit, Task: opts.Task, Status: "Pending", URL: fmt.Sprintf("https://test.dev/@%s/%s/", opts.User, opts.Pod), } if m.pods[opts.User] == nil { m.pods[opts.User] = make(map[string]*model.Pod) } m.pods[opts.User][opts.Pod] = pod return pod, nil } func (m *mockPodManager) DeletePod(_ context.Context, user, pod string) error { m.mu.Lock() defer m.mu.Unlock() if m.pods[user] == nil { return k8s.ErrPodNotFound } if _, exists := m.pods[user][pod]; !exists { return k8s.ErrPodNotFound } delete(m.pods[user], pod) return nil } func (m *mockPodManager) DeleteAllPods(_ context.Context, user string) error { m.mu.Lock() defer m.mu.Unlock() if m.deleteAllPodsErr != nil { return m.deleteAllPodsErr } delete(m.pods, user) return nil } func (m *mockPodManager) ListPods(_ context.Context, user string) ([]model.Pod, error) { m.mu.Lock() defer m.mu.Unlock() var result []model.Pod for _, p := range m.pods[user] { result = append(result, *p) } return result, nil } func (m *mockPodManager) GetPod(_ context.Context, user, pod string) (*model.Pod, error) { m.mu.Lock() defer m.mu.Unlock() if m.pods[user] == nil { return nil, k8s.ErrPodNotFound } p, exists := m.pods[user][pod] if !exists { return nil, k8s.ErrPodNotFound } return p, nil } // mockUserStore implements UserStore for testing. type mockUserStore struct { mu sync.Mutex users map[string]*model.User apiKeys map[string]string // keyHash -> userID forgejoTokens map[string]string // userID -> token tailscaleKeys map[string]string // userID -> key createAPIKeyErr error // if set, CreateAPIKey returns this } func newMockUserStore() *mockUserStore { return &mockUserStore{ users: make(map[string]*model.User), apiKeys: make(map[string]string), forgejoTokens: make(map[string]string), tailscaleKeys: make(map[string]string), } } func (m *mockUserStore) addUser(u *model.User) { m.mu.Lock() defer m.mu.Unlock() m.users[u.ID] = u } func (m *mockUserStore) GetUser(_ context.Context, id string) (*model.User, error) { m.mu.Lock() defer m.mu.Unlock() u, ok := m.users[id] if !ok { return nil, fmt.Errorf("user %q: %w", id, store.ErrNotFound) } return u, nil } func (m *mockUserStore) ListUsers(_ context.Context) ([]model.User, error) { m.mu.Lock() defer m.mu.Unlock() var result []model.User for _, u := range m.users { result = append(result, *u) } return result, nil } func (m *mockUserStore) CreateUser(_ context.Context, id string, quota model.Quota) (*model.User, error) { m.mu.Lock() defer m.mu.Unlock() if _, exists := m.users[id]; exists { return nil, fmt.Errorf("user %q: %w", id, store.ErrDuplicate) } u := &model.User{ID: id, Quota: quota} m.users[id] = u return u, nil } func (m *mockUserStore) DeleteUser(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() if _, exists := m.users[id]; !exists { return fmt.Errorf("user %q: %w", id, store.ErrNotFound) } delete(m.users, id) return nil } func (m *mockUserStore) UpdateQuotas(_ context.Context, id string, req model.UpdateQuotasRequest) (*model.User, error) { m.mu.Lock() defer m.mu.Unlock() u, exists := m.users[id] if !exists { return nil, fmt.Errorf("user %q: %w", id, store.ErrNotFound) } if req.MaxConcurrentPods != nil { u.Quota.MaxConcurrentPods = *req.MaxConcurrentPods } if req.MaxCPUPerPod != nil { u.Quota.MaxCPUPerPod = *req.MaxCPUPerPod } if req.MaxRAMGBPerPod != nil { u.Quota.MaxRAMGBPerPod = *req.MaxRAMGBPerPod } if req.MonthlyPodHours != nil { u.Quota.MonthlyPodHours = *req.MonthlyPodHours } if req.MonthlyAIRequests != nil { u.Quota.MonthlyAIRequests = *req.MonthlyAIRequests } return u, nil } func (m *mockUserStore) CreateAPIKey(_ context.Context, userID, _ string, keyHash string) error { m.mu.Lock() defer m.mu.Unlock() if m.createAPIKeyErr != nil { return m.createAPIKeyErr } m.apiKeys[keyHash] = userID return nil } func (m *mockUserStore) SaveForgejoToken(_ context.Context, userID, token string) error { m.mu.Lock() defer m.mu.Unlock() if _, exists := m.users[userID]; !exists { return fmt.Errorf("user %q: %w", userID, store.ErrNotFound) } m.forgejoTokens[userID] = token return nil } func (m *mockUserStore) GetForgejoToken(_ context.Context, userID string) (string, error) { m.mu.Lock() defer m.mu.Unlock() token, exists := m.forgejoTokens[userID] if !exists { return "", nil } return token, nil } func (m *mockUserStore) SaveTailscaleKey(_ context.Context, userID, key string) error { m.mu.Lock() defer m.mu.Unlock() if _, exists := m.users[userID]; !exists { return fmt.Errorf("user %q: %w", userID, store.ErrNotFound) } m.tailscaleKeys[userID] = key return nil } func (m *mockUserStore) GetTailscaleKey(_ context.Context, userID string) (string, error) { m.mu.Lock() defer m.mu.Unlock() key, exists := m.tailscaleKeys[userID] if !exists { return "", nil } return key, nil } // mockGenerateKey returns a predictable API key for testing. func mockGenerateKey() (string, string, error) { return "dpk_testgeneratedkey123456", "hash_testgeneratedkey123456", nil } // newPodTestRouter creates a fully-wired test router with all mock dependencies. func newPodTestRouter() (*mockKeyValidator, *mockPodManager, *mockUserStore, http.Handler) { kv := newMockValidator() pm := newMockPodManager() us := newMockUserStore() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := &Server{ Store: kv, K8s: pm, Users: us, Usage: newMockUsageStore(), Logger: logger, GenerateKey: mockGenerateKey, } return kv, pm, us, NewRouter(srv) } func doRequest(router http.Handler, method, path, body, token string) *httptest.ResponseRecorder { var bodyReader io.Reader if body != "" { bodyReader = strings.NewReader(body) } req := httptest.NewRequest(method, path, bodyReader) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rr := httptest.NewRecorder() router.ServeHTTP(rr, req) return rr } func decodeJSON(t *testing.T, rr *httptest.ResponseRecorder, v any) { t.Helper() if err := json.NewDecoder(rr.Body).Decode(v); err != nil { t.Fatalf("decode response: %v, body: %s", err, rr.Body.String()) } } // --- POST /api/v1/pods --- func TestCreatePod_Success(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) body := `{"user":"alice","pod":"main","tools":"go,rust"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var pod model.Pod decodeJSON(t, rr, &pod) if pod.Name != "main" { t.Fatalf("expected pod name 'main', got %q", pod.Name) } if pod.Tools != "speckit,go,rust" { t.Fatalf("expected tools 'speckit,go,rust', got %q", pod.Tools) } if pod.CPUReq != "2" { t.Fatalf("expected default cpu_req '2', got %q", pod.CPUReq) } if pod.MemReq != "4Gi" { t.Fatalf("expected default mem_req '4Gi', got %q", pod.MemReq) } } func TestCreatePod_WithResources(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) body := `{"user":"alice","pod":"heavy","tools":"go","cpu_req":"4","cpu_limit":"8","mem_req":"8Gi","mem_limit":"16Gi"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var pod model.Pod decodeJSON(t, rr, &pod) if pod.CPUReq != "4" || pod.CPULimit != "8" { t.Fatalf("expected cpu 4/8, got %s/%s", pod.CPUReq, pod.CPULimit) } if pod.MemReq != "8Gi" || pod.MemLimit != "16Gi" { t.Fatalf("expected mem 8Gi/16Gi, got %s/%s", pod.MemReq, pod.MemLimit) } } func TestCreatePod_InvalidBody(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodPost, "/api/v1/pods", "not json", "dpk_test") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } } func TestCreatePod_ValidationError(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) // Missing required field rr := doRequest(router, http.MethodPost, "/api/v1/pods", `{"user":"alice"}`, "dpk_test") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } } func TestCreatePod_ForbiddenOtherUser(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) body := `{"user":"bob","pod":"main","tools":"go"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreatePod_AdminCanCreateForOthers(t *testing.T) { kv, _, us, router := newPodTestRouter() 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","pod":"main","tools":"go"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreatePod_UserNotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) // Don't add alice to user store body := `{"user":"alice","pod":"main","tools":"go"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreatePod_QuotaExceeded(t *testing.T) { kv, pm, us, router := newPodTestRouter() quota := model.DefaultQuota() quota.MaxConcurrentPods = 1 user := &model.User{ID: "alice", Quota: quota} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) // Pre-add an existing pod pm.addPod(&model.Pod{User: "alice", Name: "existing"}) body := `{"user":"alice","pod":"second","tools":"go"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusTooManyRequests { t.Fatalf("expected 429, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreatePod_Duplicate(t *testing.T) { kv, pm, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) pm.addPod(&model.Pod{User: "alice", Name: "main"}) body := `{"user":"alice","pod":"main","tools":"go"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreatePod_BudgetExceeded(t *testing.T) { kv := newMockValidator() pm := newMockPodManager() us := newMockUserStore() usage := newMockUsageStore() usage.usageResult = &model.UsageSummary{ PodHours: 500, BudgetUsedPct: 100, // 100% = budget exhausted } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := &Server{ Store: kv, K8s: pm, Users: us, Usage: usage, 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","pod":"main","tools":"go"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusTooManyRequests { t.Fatalf("expected 429, got %d: %s", rr.Code, rr.Body.String()) } } // --- DELETE /api/v1/pods/{user}/{pod} --- func TestDeletePod_Success(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "alice", Name: "main"}) rr := doRequest(router, http.MethodDelete, "/api/v1/pods/alice/main", "", "dpk_test") if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) } } func TestDeletePod_NotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodDelete, "/api/v1/pods/alice/nonexistent", "", "dpk_test") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } func TestDeletePod_ForbiddenOtherUser(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "bob", Name: "main"}) rr := doRequest(router, http.MethodDelete, "/api/v1/pods/bob/main", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestDeletePod_AdminCanDeleteOthers(t *testing.T) { kv, pm, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) pm.addPod(&model.Pod{User: "bob", Name: "main"}) rr := doRequest(router, http.MethodDelete, "/api/v1/pods/bob/main", "", "dpk_admin") if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) } } // --- DELETE /api/v1/pods/{user} --- func TestDeleteAllPods_Success(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "alice", Name: "main"}) pm.addPod(&model.Pod{User: "alice", Name: "test"}) rr := doRequest(router, http.MethodDelete, "/api/v1/pods/alice", "", "dpk_test") if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) } // Verify pods are gone listRR := doRequest(router, http.MethodGet, "/api/v1/pods/alice", "", "dpk_test") var pods []model.Pod decodeJSON(t, listRR, &pods) if len(pods) != 0 { t.Fatalf("expected 0 pods after delete all, got %d", len(pods)) } } func TestDeleteAllPods_Forbidden(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodDelete, "/api/v1/pods/bob", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } // --- GET /api/v1/pods --- func TestListAllPods_UserSeesOwnOnly(t *testing.T) { kv, pm, _, router := newPodTestRouter() alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_alice", alice, model.RoleUser) pm.addPod(&model.Pod{User: "alice", Name: "main"}) pm.addPod(&model.Pod{User: "bob", Name: "work"}) rr := doRequest(router, http.MethodGet, "/api/v1/pods", "", "dpk_alice") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var pods []model.Pod decodeJSON(t, rr, &pods) if len(pods) != 1 { t.Fatalf("expected 1 pod, got %d", len(pods)) } if pods[0].Name != "main" { t.Fatalf("expected pod 'main', got %q", pods[0].Name) } } func TestListAllPods_AdminSeesAll(t *testing.T) { kv, pm, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} bob := &model.User{ID: "bob", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(admin) us.addUser(alice) us.addUser(bob) pm.addPod(&model.Pod{User: "alice", Name: "main"}) pm.addPod(&model.Pod{User: "bob", Name: "work"}) rr := doRequest(router, http.MethodGet, "/api/v1/pods", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var pods []model.Pod decodeJSON(t, rr, &pods) if len(pods) != 2 { t.Fatalf("expected 2 pods, got %d", len(pods)) } } func TestListAllPods_Empty(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/pods", "", "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var pods []model.Pod decodeJSON(t, rr, &pods) if len(pods) != 0 { t.Fatalf("expected 0 pods, got %d", len(pods)) } } // --- GET /api/v1/pods/{user} --- func TestListUserPods_Success(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go"}) pm.addPod(&model.Pod{User: "alice", Name: "test", Tools: "rust"}) rr := doRequest(router, http.MethodGet, "/api/v1/pods/alice", "", "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var pods []model.Pod decodeJSON(t, rr, &pods) if len(pods) != 2 { t.Fatalf("expected 2 pods, got %d", len(pods)) } } func TestListUserPods_Forbidden(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/pods/bob", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } // --- GET /api/v1/pods/{user}/{pod} --- func TestGetPod_Success(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go,rust", CPUReq: "4", MemReq: "8Gi"}) rr := doRequest(router, http.MethodGet, "/api/v1/pods/alice/main", "", "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var pod model.Pod decodeJSON(t, rr, &pod) if pod.Name != "main" { t.Fatalf("expected pod 'main', got %q", pod.Name) } if pod.Tools != "go,rust" { t.Fatalf("expected tools 'go,rust', got %q", pod.Tools) } } func TestGetPod_NotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) rr := doRequest(router, http.MethodGet, "/api/v1/pods/alice/nonexistent", "", "dpk_test") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } func TestGetPod_Forbidden(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "bob", Name: "main"}) rr := doRequest(router, http.MethodGet, "/api/v1/pods/bob/main", "", "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } // --- PATCH /api/v1/pods/{user}/{pod} --- func TestUpdatePod_Success(t *testing.T) { kv, pm, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go", CPUReq: "2", CPULimit: "4", MemReq: "4Gi", MemLimit: "8Gi"}) body := `{"cpu_limit":"8","mem_limit":"16Gi"}` rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", body, "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var pod model.Pod decodeJSON(t, rr, &pod) if pod.CPULimit != "8" { t.Fatalf("expected cpu_limit '8', got %q", pod.CPULimit) } if pod.MemLimit != "16Gi" { t.Fatalf("expected mem_limit '16Gi', got %q", pod.MemLimit) } // Unchanged fields should be preserved if pod.Tools != "go" { t.Fatalf("expected tools 'go' preserved, got %q", pod.Tools) } if pod.CPUReq != "2" { t.Fatalf("expected cpu_req '2' preserved, got %q", pod.CPUReq) } } func TestUpdatePod_NotFound(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) body := `{"cpu_limit":"8"}` rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/nonexistent", body, "dpk_test") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } func TestUpdatePod_Forbidden(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "bob", Name: "main"}) body := `{"cpu_limit":"8"}` rr := doRequest(router, http.MethodPatch, "/api/v1/pods/bob/main", body, "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestUpdatePod_InvalidBody(t *testing.T) { kv, pm, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) pm.addPod(&model.Pod{User: "alice", Name: "main"}) rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", "not json", "dpk_test") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } } func TestUpdatePod_ChangeTools(t *testing.T) { kv, pm, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go", CPUReq: "2", CPULimit: "4", MemReq: "4Gi", MemLimit: "8Gi"}) body := `{"tools":"go,rust,node"}` rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", body, "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var pod model.Pod decodeJSON(t, rr, &pod) if pod.Tools != "speckit,go,rust,node" { t.Fatalf("expected tools 'speckit,go,rust,node', got %q", pod.Tools) } } // --- Auth integration --- func TestPodRoutes_NoAuth(t *testing.T) { _, _, _, router := newPodTestRouter() routes := []struct { method string path string }{ {http.MethodPost, "/api/v1/pods"}, {http.MethodGet, "/api/v1/pods"}, {http.MethodGet, "/api/v1/pods/alice"}, {http.MethodGet, "/api/v1/pods/alice/main"}, {http.MethodDelete, "/api/v1/pods/alice"}, {http.MethodDelete, "/api/v1/pods/alice/main"}, {http.MethodPatch, "/api/v1/pods/alice/main"}, } 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) } }) } } func TestCreatePod_WithTailscaleKey(t *testing.T) { kv, _, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) body := `{"user":"alice","pod":"main","tools":"go","tailscale_key":"tskey-auth-abc123"}` rr := doRequest(router, http.MethodPost, "/api/v1/pods", body, "dpk_test") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } // Verify tailscale key was saved to user store us.mu.Lock() savedKey := us.tailscaleKeys["alice"] us.mu.Unlock() if savedKey != "tskey-auth-abc123" { t.Fatalf("expected tailscale key 'tskey-auth-abc123' saved, got %q", savedKey) } } func TestUpdatePod_PreservesTailscaleKey(t *testing.T) { kv, pm, us, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) us.addUser(user) us.tailscaleKeys["alice"] = "tskey-auth-existing" pm.addPod(&model.Pod{User: "alice", Name: "main", Tools: "go", CPUReq: "2", CPULimit: "4", MemReq: "4Gi", MemLimit: "8Gi"}) body := `{"cpu_limit":"8"}` rr := doRequest(router, http.MethodPatch, "/api/v1/pods/alice/main", body, "dpk_test") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } // Verify tailscale key is still saved us.mu.Lock() savedKey := us.tailscaleKeys["alice"] us.mu.Unlock() if savedKey != "tskey-auth-existing" { t.Fatalf("expected tailscale key preserved as 'tskey-auth-existing', got %q", savedKey) } } func TestNormalizeTools(t *testing.T) { tests := []struct { input string want string }{ {"", "speckit"}, {"none", ""}, {"go,rust", "speckit,go,rust"}, {"speckit,go", "speckit,go"}, {"go,speckit,rust", "go,speckit,rust"}, {"go,speckit@1.2", "go,speckit@1.2"}, {"speckit", "speckit"}, {"bun", "speckit,bun"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := normalizeTools(tt.input) if got != tt.want { t.Errorf("normalizeTools(%q) = %q, want %q", tt.input, got, tt.want) } }) } }