900 lines
27 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|