package store import ( "context" "errors" "strings" "testing" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) // --- User CRUD tests --- func TestCreateUser(t *testing.T) { s := newTestStore(t) ctx := context.Background() quota := model.DefaultQuota() u, err := s.CreateUser(ctx, "alice", quota) if err != nil { t.Fatalf("create user: %v", err) } if u.ID != "alice" { t.Errorf("got id %q, want %q", u.ID, "alice") } if u.CreatedAt.IsZero() { t.Error("created_at should not be zero") } if u.Quota != quota { t.Errorf("got quota %+v, want %+v", u.Quota, quota) } } func TestCreateUserDuplicate(t *testing.T) { s := newTestStore(t) ctx := context.Background() quota := model.DefaultQuota() if _, err := s.CreateUser(ctx, "alice", quota); err != nil { t.Fatalf("first create: %v", err) } _, err := s.CreateUser(ctx, "alice", quota) if err == nil { t.Fatal("expected error for duplicate user") } if !errors.Is(err, ErrDuplicate) { t.Errorf("got %v, want ErrDuplicate", err) } } func TestGetUser(t *testing.T) { s := newTestStore(t) ctx := context.Background() quota := model.DefaultQuota() created, err := s.CreateUser(ctx, "bob", quota) if err != nil { t.Fatalf("create: %v", err) } got, err := s.GetUser(ctx, "bob") if err != nil { t.Fatalf("get: %v", err) } if got.ID != created.ID { t.Errorf("got id %q, want %q", got.ID, created.ID) } if got.Quota != quota { t.Errorf("got quota %+v, want %+v", got.Quota, quota) } } func TestGetUserNotFound(t *testing.T) { s := newTestStore(t) ctx := context.Background() _, err := s.GetUser(ctx, "nonexistent") if err == nil { t.Fatal("expected error") } if !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } } func TestListUsers(t *testing.T) { s := newTestStore(t) ctx := context.Background() quota := model.DefaultQuota() // Empty list users, err := s.ListUsers(ctx) if err != nil { t.Fatalf("list empty: %v", err) } if len(users) != 0 { t.Errorf("got %d users, want 0", len(users)) } // Create users if _, err := s.CreateUser(ctx, "alice", quota); err != nil { t.Fatalf("create alice: %v", err) } if _, err := s.CreateUser(ctx, "bob", quota); err != nil { t.Fatalf("create bob: %v", err) } users, err = s.ListUsers(ctx) if err != nil { t.Fatalf("list: %v", err) } if len(users) != 2 { t.Fatalf("got %d users, want 2", len(users)) } // Ordered by created_at if users[0].ID != "alice" { t.Errorf("first user: got %q, want %q", users[0].ID, "alice") } if users[1].ID != "bob" { t.Errorf("second user: got %q, want %q", users[1].ID, "bob") } } func TestDeleteUser(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create: %v", err) } if err := s.DeleteUser(ctx, "alice"); err != nil { t.Fatalf("delete: %v", err) } // Should be gone _, err := s.GetUser(ctx, "alice") if !errors.Is(err, ErrNotFound) { t.Errorf("got %v after delete, want ErrNotFound", err) } } func TestDeleteUserNotFound(t *testing.T) { s := newTestStore(t) ctx := context.Background() err := s.DeleteUser(ctx, "ghost") if !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } } func TestDeleteUserCascadesAPIKeys(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create user: %v", err) } plain, hash, err := GenerateAPIKey() if err != nil { t.Fatalf("generate key: %v", err) } if err := s.CreateAPIKey(ctx, "alice", model.RoleUser, hash); err != nil { t.Fatalf("create key: %v", err) } // Delete user should cascade to api_keys if err := s.DeleteUser(ctx, "alice"); err != nil { t.Fatalf("delete: %v", err) } // Key should no longer validate _, _, err = s.ValidateKey(ctx, plain) if !errors.Is(err, ErrNotFound) { t.Errorf("got %v after cascade delete, want ErrNotFound", err) } } // --- Quota tests --- func TestUpdateQuotas(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create: %v", err) } newPods := 5 newCPU := 16 updated, err := s.UpdateQuotas(ctx, "alice", model.UpdateQuotasRequest{ MaxConcurrentPods: &newPods, MaxCPUPerPod: &newCPU, }) if err != nil { t.Fatalf("update quotas: %v", err) } if updated.Quota.MaxConcurrentPods != 5 { t.Errorf("max_concurrent_pods: got %d, want 5", updated.Quota.MaxConcurrentPods) } if updated.Quota.MaxCPUPerPod != 16 { t.Errorf("max_cpu_per_pod: got %d, want 16", updated.Quota.MaxCPUPerPod) } // Unchanged fields should keep defaults if updated.Quota.MaxRAMGBPerPod != 16 { t.Errorf("max_ram_gb_per_pod: got %d, want 16", updated.Quota.MaxRAMGBPerPod) } } func TestUpdateQuotasNoChanges(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create: %v", err) } // Empty update should return current user u, err := s.UpdateQuotas(ctx, "alice", model.UpdateQuotasRequest{}) if err != nil { t.Fatalf("update no changes: %v", err) } if u.Quota != model.DefaultQuota() { t.Errorf("quota changed unexpectedly: %+v", u.Quota) } } func TestUpdateQuotasNotFound(t *testing.T) { s := newTestStore(t) ctx := context.Background() newPods := 5 _, err := s.UpdateQuotas(ctx, "ghost", model.UpdateQuotasRequest{ MaxConcurrentPods: &newPods, }) if !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } } func TestQuotaStorageAndRetrieval(t *testing.T) { s := newTestStore(t) ctx := context.Background() customQuota := model.Quota{ MaxConcurrentPods: 10, MaxCPUPerPod: 32, MaxRAMGBPerPod: 64, MonthlyPodHours: 1000, MonthlyAIRequests: 50000, } if _, err := s.CreateUser(ctx, "power-user", customQuota); err != nil { t.Fatalf("create: %v", err) } got, err := s.GetUser(ctx, "power-user") if err != nil { t.Fatalf("get: %v", err) } if got.Quota != customQuota { t.Errorf("quota mismatch:\ngot: %+v\nwant: %+v", got.Quota, customQuota) } } // --- API key tests --- func TestGenerateAPIKey(t *testing.T) { plain, hash, err := GenerateAPIKey() if err != nil { t.Fatalf("generate: %v", err) } // Check prefix if !strings.HasPrefix(plain, model.APIKeyPrefix) { t.Errorf("key %q missing prefix %q", plain, model.APIKeyPrefix) } // Check length: dpk_ (4) + 24 hex chars = 28 if len(plain) != 28 { t.Errorf("key length: got %d, want 28", len(plain)) } // Hash should be consistent if HashKey(plain) != hash { t.Error("hash mismatch") } // Two keys should be different plain2, _, err := GenerateAPIKey() if err != nil { t.Fatalf("generate second: %v", err) } if plain == plain2 { t.Error("two generated keys should not be equal") } } func TestHashKeyConsistency(t *testing.T) { key := "dpk_abcdef1234567890abcdef12" h1 := HashKey(key) h2 := HashKey(key) if h1 != h2 { t.Error("hash should be deterministic") } // SHA-256 produces 64 hex chars if len(h1) != 64 { t.Errorf("hash length: got %d, want 64", len(h1)) } } func TestCreateAndValidateAPIKey(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create user: %v", err) } plain, hash, err := GenerateAPIKey() if err != nil { t.Fatalf("generate key: %v", err) } if err := s.CreateAPIKey(ctx, "alice", model.RoleUser, hash); err != nil { t.Fatalf("create key: %v", err) } // Validate returns correct user and role u, role, err := s.ValidateKey(ctx, plain) if err != nil { t.Fatalf("validate: %v", err) } if u.ID != "alice" { t.Errorf("user: got %q, want %q", u.ID, "alice") } if role != model.RoleUser { t.Errorf("role: got %q, want %q", role, model.RoleUser) } } func TestValidateAdminKey(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "admin1", model.DefaultQuota()); err != nil { t.Fatalf("create user: %v", err) } plain, hash, err := GenerateAPIKey() if err != nil { t.Fatalf("generate key: %v", err) } if err := s.CreateAPIKey(ctx, "admin1", model.RoleAdmin, hash); err != nil { t.Fatalf("create key: %v", err) } _, role, err := s.ValidateKey(ctx, plain) if err != nil { t.Fatalf("validate: %v", err) } if role != model.RoleAdmin { t.Errorf("role: got %q, want %q", role, model.RoleAdmin) } } func TestValidateInvalidKey(t *testing.T) { s := newTestStore(t) ctx := context.Background() _, _, err := s.ValidateKey(ctx, "dpk_invalid_key_here_nope") if err == nil { t.Fatal("expected error for invalid key") } if !errors.Is(err, ErrNotFound) { t.Errorf("got %v, want ErrNotFound", err) } } func TestMultipleKeysPerUser(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create user: %v", err) } // Create two keys plain1, hash1, _ := GenerateAPIKey() plain2, hash2, _ := GenerateAPIKey() if err := s.CreateAPIKey(ctx, "alice", model.RoleUser, hash1); err != nil { t.Fatalf("create key 1: %v", err) } if err := s.CreateAPIKey(ctx, "alice", model.RoleAdmin, hash2); err != nil { t.Fatalf("create key 2: %v", err) } // Both should validate u1, role1, err := s.ValidateKey(ctx, plain1) if err != nil { t.Fatalf("validate key 1: %v", err) } if u1.ID != "alice" || role1 != model.RoleUser { t.Errorf("key1: got user=%q role=%q, want alice/user", u1.ID, role1) } u2, role2, err := s.ValidateKey(ctx, plain2) if err != nil { t.Fatalf("validate key 2: %v", err) } if u2.ID != "alice" || role2 != model.RoleAdmin { t.Errorf("key2: got user=%q role=%q, want alice/admin", u2.ID, role2) } } func TestValidateKeyUpdatesLastUsed(t *testing.T) { s := newTestStore(t) ctx := context.Background() if _, err := s.CreateUser(ctx, "alice", model.DefaultQuota()); err != nil { t.Fatalf("create user: %v", err) } plain, hash, _ := GenerateAPIKey() if err := s.CreateAPIKey(ctx, "alice", model.RoleUser, hash); err != nil { t.Fatalf("create key: %v", err) } // Validate should update last_used_at if _, _, err := s.ValidateKey(ctx, plain); err != nil { t.Fatalf("validate: %v", err) } // Check last_used_at is set var lastUsed *string err := testPool.QueryRow(ctx, `SELECT last_used_at::text FROM api_keys WHERE key_hash = $1`, hash).Scan(&lastUsed) if err != nil { t.Fatalf("query last_used_at: %v", err) } if lastUsed == nil { t.Error("last_used_at should be set after validation") } }