package api import ( "encoding/json" "fmt" "net/http" "testing" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) // --- POST /api/v1/users --- func TestCreateUser_Success(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"alice"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var resp createUserResponse decodeJSON(t, rr, &resp) if resp.User.ID != "alice" { t.Fatalf("expected user id 'alice', got %q", resp.User.ID) } if resp.APIKey == "" { t.Fatal("expected api_key to be set") } if resp.APIKey != "dpk_testgeneratedkey123456" { t.Fatalf("expected mock api key, got %q", resp.APIKey) } // Default quotas should be applied if resp.User.Quota.MaxConcurrentPods != 3 { t.Fatalf("expected default max_concurrent_pods 3, got %d", resp.User.Quota.MaxConcurrentPods) } } func TestCreateUser_WithCustomQuotas(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"bob","quotas":{"max_concurrent_pods":5,"max_cpu_per_pod":16,"max_ram_gb_per_pod":32,"monthly_pod_hours":1000,"monthly_ai_requests":50000}}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var resp createUserResponse decodeJSON(t, rr, &resp) if resp.User.Quota.MaxConcurrentPods != 5 { t.Fatalf("expected max_concurrent_pods 5, got %d", resp.User.Quota.MaxConcurrentPods) } if resp.User.Quota.MaxCPUPerPod != 16 { t.Fatalf("expected max_cpu_per_pod 16, got %d", resp.User.Quota.MaxCPUPerPod) } } func TestCreateUser_ForbiddenNonAdmin(t *testing.T) { kv, _, _, router := newPodTestRouter() user := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_test", user, model.RoleUser) body := `{"user":"newuser"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_test") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreateUser_InvalidBody(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodPost, "/api/v1/users", "not json", "dpk_admin") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } } func TestCreateUser_ValidationError(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) // Missing required field rr := doRequest(router, http.MethodPost, "/api/v1/users", `{}`, "dpk_admin") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreateUser_InvalidName(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodPost, "/api/v1/users", `{"user":"INVALID_NAME"}`, "dpk_admin") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreateUser_Duplicate(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := `{"user":"alice"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreateUser_PartialQuotasMergedWithDefaults(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) // Only specify max_cpu_per_pod; other fields should get defaults body := `{"user":"dave","quotas":{"max_cpu_per_pod":16}}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var resp createUserResponse decodeJSON(t, rr, &resp) if resp.User.Quota.MaxCPUPerPod != 16 { t.Fatalf("expected max_cpu_per_pod 16, got %d", resp.User.Quota.MaxCPUPerPod) } // Unset fields should be defaults, not zero if resp.User.Quota.MaxConcurrentPods != 3 { t.Fatalf("expected default max_concurrent_pods 3, got %d", resp.User.Quota.MaxConcurrentPods) } if resp.User.Quota.MaxRAMGBPerPod != 16 { t.Fatalf("expected default max_ram_gb_per_pod 16, got %d", resp.User.Quota.MaxRAMGBPerPod) } if resp.User.Quota.MonthlyPodHours != 500 { t.Fatalf("expected default monthly_pod_hours 500, got %d", resp.User.Quota.MonthlyPodHours) } if resp.User.Quota.MonthlyAIRequests != 10000 { t.Fatalf("expected default monthly_ai_requests 10000, got %d", resp.User.Quota.MonthlyAIRequests) } } func TestCreateUser_NegativeQuotaRejected(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"eve","quotas":{"max_cpu_per_pod":-1}}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } } func TestCreateUser_APIKeyFailureRollsBackUser(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) // Make CreateAPIKey fail us.createAPIKeyErr = fmt.Errorf("db connection lost") body := `{"user":"frank"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) } // User should have been cleaned up — not left orphaned if _, exists := us.users["frank"]; exists { t.Fatal("expected user 'frank' to be cleaned up after API key failure") } } func TestDeleteUser_FailsWhenPodDeletionFails(t *testing.T) { kv, pm, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) pm.addPod(&model.Pod{User: "alice", Name: "main"}) // Make pod deletion fail pm.deleteAllPodsErr = fmt.Errorf("k8s API unreachable") rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_admin") if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500 when pod deletion fails, got %d: %s", rr.Code, rr.Body.String()) } // User record should still exist (not deleted) if _, exists := us.users["alice"]; !exists { t.Fatal("expected user 'alice' to still exist after pod deletion failure") } } // --- GET /api/v1/users --- func TestListUsers_AdminSeesAll(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()}) rr := doRequest(router, http.MethodGet, "/api/v1/users", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var users []model.User decodeJSON(t, rr, &users) if len(users) != 2 { t.Fatalf("expected 2 users, got %d", len(users)) } } func TestListUsers_UserSeesSelf(t *testing.T) { kv, _, us, router := newPodTestRouter() alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_alice", alice, model.RoleUser) us.addUser(alice) us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()}) rr := doRequest(router, http.MethodGet, "/api/v1/users", "", "dpk_alice") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var users []model.User decodeJSON(t, rr, &users) if len(users) != 1 { t.Fatalf("expected 1 user (self), got %d", len(users)) } if users[0].ID != "alice" { t.Fatalf("expected user 'alice', got %q", users[0].ID) } } // --- GET /api/v1/users/{user} --- func TestGetUser_Self(t *testing.T) { kv, _, us, router := newPodTestRouter() alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_alice", alice, model.RoleUser) us.addUser(alice) rr := doRequest(router, http.MethodGet, "/api/v1/users/alice", "", "dpk_alice") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var user model.User decodeJSON(t, rr, &user) if user.ID != "alice" { t.Fatalf("expected user 'alice', got %q", user.ID) } } func TestGetUser_AdminCanViewOthers(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) rr := doRequest(router, http.MethodGet, "/api/v1/users/alice", "", "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } } func TestGetUser_ForbiddenOtherUser(t *testing.T) { kv, _, us, router := newPodTestRouter() alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_alice", alice, model.RoleUser) us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()}) rr := doRequest(router, http.MethodGet, "/api/v1/users/bob", "", "dpk_alice") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestGetUser_NotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodGet, "/api/v1/users/nonexistent", "", "dpk_admin") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } // --- DELETE /api/v1/users/{user} --- func TestDeleteUser_Success(t *testing.T) { kv, pm, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) pm.addPod(&model.Pod{User: "alice", Name: "main"}) rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_admin") if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) } // Verify user is gone getRR := doRequest(router, http.MethodGet, "/api/v1/users/alice", "", "dpk_admin") if getRR.Code != http.StatusNotFound { t.Fatalf("expected user to be deleted, got status %d", getRR.Code) } // Verify pods are gone pods, _ := pm.ListPods(nil, "alice") if len(pods) != 0 { t.Fatalf("expected pods to be deleted, got %d", len(pods)) } } func TestDeleteUser_ForbiddenNonAdmin(t *testing.T) { kv, _, us, router := newPodTestRouter() alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_alice", alice, model.RoleUser) us.addUser(alice) rr := doRequest(router, http.MethodDelete, "/api/v1/users/alice", "", "dpk_alice") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestDeleteUser_NotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodDelete, "/api/v1/users/nonexistent", "", "dpk_admin") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } // --- PATCH /api/v1/users/{user}/quotas --- func TestUpdateQuotas_Success(t *testing.T) { kv, _, us, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := `{"max_concurrent_pods":10,"max_cpu_per_pod":16}` rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", body, "dpk_admin") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var user model.User decodeJSON(t, rr, &user) if user.Quota.MaxConcurrentPods != 10 { t.Fatalf("expected max_concurrent_pods 10, got %d", user.Quota.MaxConcurrentPods) } if user.Quota.MaxCPUPerPod != 16 { t.Fatalf("expected max_cpu_per_pod 16, got %d", user.Quota.MaxCPUPerPod) } // Unchanged fields should be preserved if user.Quota.MonthlyPodHours != 500 { t.Fatalf("expected monthly_pod_hours 500, got %d", user.Quota.MonthlyPodHours) } } func TestUpdateQuotas_ForbiddenNonAdmin(t *testing.T) { kv, _, us, router := newPodTestRouter() alice := &model.User{ID: "alice", Quota: model.DefaultQuota()} kv.addKey("dpk_alice", alice, model.RoleUser) us.addUser(alice) body := `{"max_concurrent_pods":10}` rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", body, "dpk_alice") if rr.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) } } func TestUpdateQuotas_InvalidBody(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", "not json", "dpk_admin") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } } func TestUpdateQuotas_ValidationError(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) // max_concurrent_pods must be at least 1 body := `{"max_concurrent_pods":0}` rr := doRequest(router, http.MethodPatch, "/api/v1/users/alice/quotas", body, "dpk_admin") if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } } func TestUpdateQuotas_UserNotFound(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"max_concurrent_pods":10}` rr := doRequest(router, http.MethodPatch, "/api/v1/users/nonexistent/quotas", body, "dpk_admin") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } // --- Auth integration for user routes --- func TestUserRoutes_NoAuth(t *testing.T) { _, _, _, router := newPodTestRouter() routes := []struct { method string path string }{ {http.MethodPost, "/api/v1/users"}, {http.MethodGet, "/api/v1/users"}, {http.MethodGet, "/api/v1/users/alice"}, {http.MethodDelete, "/api/v1/users/alice"}, {http.MethodPatch, "/api/v1/users/alice/quotas"}, } 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) } }) } } // --- verify response structure --- func TestCreateUser_ResponseStructure(t *testing.T) { kv, _, _, router := newPodTestRouter() admin := &model.User{ID: "admin-user", Quota: model.DefaultQuota()} kv.addKey("dpk_admin", admin, model.RoleAdmin) body := `{"user":"charlie"}` rr := doRequest(router, http.MethodPost, "/api/v1/users", body, "dpk_admin") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } // Verify response has both user and api_key fields var raw map[string]json.RawMessage if err := json.NewDecoder(rr.Body).Decode(&raw); err != nil { t.Fatalf("decode response: %v", err) } if _, ok := raw["user"]; !ok { t.Fatal("response missing 'user' field") } if _, ok := raw["api_key"]; !ok { t.Fatal("response missing 'api_key' field") } }