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

900 lines
27 KiB
Go

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