rdev/internal/service/apikey_service_test.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

372 lines
9.2 KiB
Go

package service
import (
"context"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// MockAPIKeyRepository implements port.APIKeyRepository for testing.
type MockAPIKeyRepository struct {
keys map[domain.APIKeyID]*domain.APIKey
keysByHash map[string]*domain.APIKey
createErr error
lastUsedCalls int
lastUsedErr error
}
func NewMockAPIKeyRepository() *MockAPIKeyRepository {
return &MockAPIKeyRepository{
keys: make(map[domain.APIKeyID]*domain.APIKey),
keysByHash: make(map[string]*domain.APIKey),
}
}
func (m *MockAPIKeyRepository) Create(ctx context.Context, key *domain.APIKey, keyHash string) error {
if m.createErr != nil {
return m.createErr
}
key.ID = domain.APIKeyID("key-" + key.Name)
key.CreatedAt = time.Now()
m.keys[key.ID] = key
m.keysByHash[keyHash] = key
return nil
}
func (m *MockAPIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*domain.APIKey, error) {
key, ok := m.keysByHash[keyHash]
if !ok {
return nil, domain.ErrKeyNotFound
}
return key, nil
}
func (m *MockAPIKeyRepository) Get(ctx context.Context, id domain.APIKeyID) (*domain.APIKey, error) {
key, ok := m.keys[id]
if !ok {
return nil, domain.ErrKeyNotFound
}
return key, nil
}
func (m *MockAPIKeyRepository) List(ctx context.Context) ([]*domain.APIKey, error) {
result := make([]*domain.APIKey, 0, len(m.keys))
for _, k := range m.keys {
result = append(result, k)
}
return result, nil
}
func (m *MockAPIKeyRepository) Revoke(ctx context.Context, id domain.APIKeyID) error {
key, ok := m.keys[id]
if !ok {
return domain.ErrKeyNotFound
}
now := time.Now()
key.RevokedAt = &now
return nil
}
func (m *MockAPIKeyRepository) UpdateLastUsed(ctx context.Context, id domain.APIKeyID) error {
m.lastUsedCalls++
if m.lastUsedErr != nil {
return m.lastUsedErr
}
key, ok := m.keys[id]
if !ok {
return domain.ErrKeyNotFound
}
now := time.Now()
key.LastUsedAt = &now
return nil
}
func TestAPIKeyService_Create(t *testing.T) {
repo := NewMockAPIKeyRepository()
svc := NewAPIKeyService(repo, "admin-secret")
t.Run("creates key successfully", func(t *testing.T) {
result, err := svc.Create(context.Background(), CreateKeyRequest{
Name: "test-key",
Scopes: []domain.Scope{domain.ScopeProjectsRead},
ExpiresIn: 24 * time.Hour,
CreatedBy: "test-user",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if result.Key.Name != "test-key" {
t.Errorf("Key.Name = %q, want %q", result.Key.Name, "test-key")
}
if result.Secret == "" {
t.Error("Secret should not be empty")
}
if len(result.Key.KeyPrefix) != 8 {
t.Errorf("KeyPrefix length = %d, want 8", len(result.Key.KeyPrefix))
}
if result.Key.ExpiresAt == nil {
t.Error("ExpiresAt should be set")
}
})
t.Run("creates key without expiration", func(t *testing.T) {
result, err := svc.Create(context.Background(), CreateKeyRequest{
Name: "never-expires",
Scopes: []domain.Scope{domain.ScopeAdmin},
CreatedBy: "admin",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if result.Key.ExpiresAt != nil {
t.Error("ExpiresAt should be nil for keys without expiration")
}
})
t.Run("creates key with project restrictions", func(t *testing.T) {
result, err := svc.Create(context.Background(), CreateKeyRequest{
Name: "restricted-key",
Scopes: []domain.Scope{domain.ScopeProjectsRead},
ProjectIDs: []domain.ProjectID{"proj-a", "proj-b"},
CreatedBy: "test",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if len(result.Key.ProjectIDs) != 2 {
t.Errorf("ProjectIDs length = %d, want 2", len(result.Key.ProjectIDs))
}
})
}
func TestAPIKeyService_Get(t *testing.T) {
repo := NewMockAPIKeyRepository()
svc := NewAPIKeyService(repo, "admin-secret")
// Create a key first
createResult, _ := svc.Create(context.Background(), CreateKeyRequest{
Name: "get-test",
Scopes: []domain.Scope{domain.ScopeProjectsRead},
CreatedBy: "test",
})
t.Run("gets existing key", func(t *testing.T) {
key, err := svc.Get(context.Background(), createResult.Key.ID)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if key.Name != "get-test" {
t.Errorf("Name = %q, want %q", key.Name, "get-test")
}
})
t.Run("returns error for nonexistent key", func(t *testing.T) {
_, err := svc.Get(context.Background(), "nonexistent")
if err != domain.ErrKeyNotFound {
t.Errorf("Get() error = %v, want %v", err, domain.ErrKeyNotFound)
}
})
}
func TestAPIKeyService_List(t *testing.T) {
repo := NewMockAPIKeyRepository()
svc := NewAPIKeyService(repo, "admin-secret")
// Create some keys
for i := 0; i < 3; i++ {
_, _ = svc.Create(context.Background(), CreateKeyRequest{
Name: "list-key-" + string(rune('a'+i)),
Scopes: []domain.Scope{domain.ScopeProjectsRead},
CreatedBy: "test",
})
}
keys, err := svc.List(context.Background())
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(keys) != 3 {
t.Errorf("List() returned %d keys, want 3", len(keys))
}
}
func TestAPIKeyService_Revoke(t *testing.T) {
repo := NewMockAPIKeyRepository()
svc := NewAPIKeyService(repo, "admin-secret")
// Create a key
createResult, _ := svc.Create(context.Background(), CreateKeyRequest{
Name: "revoke-test",
Scopes: []domain.Scope{domain.ScopeProjectsRead},
CreatedBy: "test",
})
t.Run("revokes existing key", func(t *testing.T) {
err := svc.Revoke(context.Background(), createResult.Key.ID)
if err != nil {
t.Fatalf("Revoke() error = %v", err)
}
// Verify revoked
key, _ := svc.Get(context.Background(), createResult.Key.ID)
if key.RevokedAt == nil {
t.Error("RevokedAt should be set after revoke")
}
})
t.Run("returns error for nonexistent key", func(t *testing.T) {
err := svc.Revoke(context.Background(), "nonexistent")
if err != domain.ErrKeyNotFound {
t.Errorf("Revoke() error = %v, want %v", err, domain.ErrKeyNotFound)
}
})
}
func TestAPIKeyService_UpdateLastUsed(t *testing.T) {
repo := NewMockAPIKeyRepository()
svc := NewAPIKeyService(repo, "admin-secret")
// Create a key
createResult, _ := svc.Create(context.Background(), CreateKeyRequest{
Name: "last-used-test",
Scopes: []domain.Scope{domain.ScopeProjectsRead},
CreatedBy: "test",
})
err := svc.UpdateLastUsed(context.Background(), createResult.Key.ID)
if err != nil {
t.Fatalf("UpdateLastUsed() error = %v", err)
}
// Verify updated
key, _ := svc.Get(context.Background(), createResult.Key.ID)
if key.LastUsedAt == nil {
t.Error("LastUsedAt should be set after update")
}
}
func TestAPIKeyService_ValidateAdminKey(t *testing.T) {
svc := NewAPIKeyService(nil, "super-secret-admin")
tests := []struct {
key string
want bool
}{
{"super-secret-admin", true},
{"wrong-key", false},
{"", false},
}
for _, tt := range tests {
if got := svc.ValidateAdminKey(tt.key); got != tt.want {
t.Errorf("ValidateAdminKey(%q) = %v, want %v", tt.key, got, tt.want)
}
}
}
func TestAPIKeyService_ValidateAdminKey_NoAdmin(t *testing.T) {
svc := NewAPIKeyService(nil, "")
// When no admin key is set, validation should always fail
if svc.ValidateAdminKey("anything") {
t.Error("ValidateAdminKey should return false when no admin key is set")
}
}
func TestAPIKeyService_AdminKey(t *testing.T) {
svc := NewAPIKeyService(nil, "my-admin-key")
if got := svc.AdminKey(); got != "my-admin-key" {
t.Errorf("AdminKey() = %q, want %q", got, "my-admin-key")
}
}
func TestParseExpiration(t *testing.T) {
tests := []struct {
input string
want time.Duration
wantErr bool
}{
{"", 0, false},
{"never", 0, false},
{"30d", 30 * 24 * time.Hour, false},
{"60d", 60 * 24 * time.Hour, false},
{"90d", 90 * 24 * time.Hour, false},
{"1y", 365 * 24 * time.Hour, false},
{"invalid", 0, true},
{"10d", 0, true}, // Not a supported format
}
for _, tt := range tests {
got, err := ParseExpiration(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseExpiration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
continue
}
if got != tt.want {
t.Errorf("ParseExpiration(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestGenerateSecret(t *testing.T) {
secrets := make(map[string]bool)
for i := 0; i < 100; i++ {
secret, err := generateSecret()
if err != nil {
t.Fatalf("generateSecret() error = %v", err)
}
// Should be 64 hex characters (32 bytes)
if len(secret) != 64 {
t.Errorf("Secret length = %d, want 64", len(secret))
}
// Should be unique
if secrets[secret] {
t.Errorf("Duplicate secret generated: %q", secret)
}
secrets[secret] = true
}
}
func TestHashKey(t *testing.T) {
// Same input should produce same hash
hash1 := hashKey("test-key")
hash2 := hashKey("test-key")
if hash1 != hash2 {
t.Error("Same input should produce same hash")
}
// Different input should produce different hash
hash3 := hashKey("different-key")
if hash1 == hash3 {
t.Error("Different input should produce different hash")
}
// Hash should be 64 hex characters (SHA-256)
if len(hash1) != 64 {
t.Errorf("Hash length = %d, want 64", len(hash1))
}
}
func TestFormatSecret(t *testing.T) {
result := formatSecret("abcd1234", "abcd12345678rest")
expected := "rdev_sk_abcd1234_5678rest"
if result != expected {
t.Errorf("formatSecret() = %q, want %q", result, expected)
}
}