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

486 lines
16 KiB
Go

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