build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
442
internal/store/users_test.go
Normal file
442
internal/store/users_test.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue