442 lines
10 KiB
Go
442 lines
10 KiB
Go
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")
|
|
}
|
|
}
|