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>
586 lines
15 KiB
Go
586 lines
15 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/testutil"
|
|
)
|
|
|
|
func TestAPIKey_IsExpired(t *testing.T) {
|
|
now := time.Now()
|
|
past := now.Add(-1 * time.Hour)
|
|
future := now.Add(1 * time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
key *APIKey
|
|
want bool
|
|
}{
|
|
{"nil expiration", &APIKey{ExpiresAt: nil}, false},
|
|
{"expired", &APIKey{ExpiresAt: &past}, true},
|
|
{"not expired", &APIKey{ExpiresAt: &future}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.key.IsExpired(); got != tt.want {
|
|
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIKey_IsRevoked(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
key *APIKey
|
|
want bool
|
|
}{
|
|
{"not revoked", &APIKey{RevokedAt: nil}, false},
|
|
{"revoked", &APIKey{RevokedAt: &now}, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.key.IsRevoked(); got != tt.want {
|
|
t.Errorf("IsRevoked() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIKey_IsActive(t *testing.T) {
|
|
now := time.Now()
|
|
past := now.Add(-1 * time.Hour)
|
|
future := now.Add(1 * time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
key *APIKey
|
|
want bool
|
|
}{
|
|
{"active", &APIKey{ExpiresAt: &future, RevokedAt: nil}, true},
|
|
{"expired", &APIKey{ExpiresAt: &past, RevokedAt: nil}, false},
|
|
{"revoked", &APIKey{ExpiresAt: &future, RevokedAt: &now}, false},
|
|
{"never expires", &APIKey{ExpiresAt: nil, RevokedAt: nil}, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.key.IsActive(); got != tt.want {
|
|
t.Errorf("IsActive() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestService_IsAdminKey(t *testing.T) {
|
|
svc := NewService(nil, "admin-secret")
|
|
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
want bool
|
|
}{
|
|
{"matches admin key", "admin-secret", true},
|
|
{"wrong key", "wrong-key", false},
|
|
{"empty key", "", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := svc.IsAdminKey(tt.key); got != tt.want {
|
|
t.Errorf("IsAdminKey(%q) = %v, want %v", tt.key, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestService_IsAdminKey_NoAdminKey(t *testing.T) {
|
|
svc := NewService(nil, "")
|
|
|
|
if svc.IsAdminKey("anything") {
|
|
t.Error("IsAdminKey should return false when no admin key is set")
|
|
}
|
|
}
|
|
|
|
// Integration tests - require database
|
|
func TestService_Create(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
svc := NewService(db, "admin-key")
|
|
|
|
t.Run("creates key with valid scopes", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-key-1",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if resp.Secret == "" {
|
|
t.Error("Create() returned empty secret")
|
|
}
|
|
|
|
if !ValidateKeyFormat(resp.Secret) {
|
|
t.Errorf("Create() returned invalid key format: %q", resp.Secret)
|
|
}
|
|
|
|
if resp.Key.Name != "test-key-1" {
|
|
t.Errorf("Key.Name = %q, want %q", resp.Key.Name, "test-key-1")
|
|
}
|
|
|
|
if len(resp.Key.Scopes) != 1 || resp.Key.Scopes[0] != ScopeProjectsRead {
|
|
t.Errorf("Key.Scopes = %v, want [%v]", resp.Key.Scopes, ScopeProjectsRead)
|
|
}
|
|
|
|
if resp.Key.ExpiresAt == nil {
|
|
t.Error("Key.ExpiresAt should not be nil")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects invalid scopes", func(t *testing.T) {
|
|
_, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-key-invalid",
|
|
Scopes: []Scope{Scope("invalid:scope")},
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
if err == nil {
|
|
t.Error("Create() should reject invalid scopes")
|
|
}
|
|
})
|
|
|
|
t.Run("creates key with no expiration", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-key-no-expire",
|
|
Scopes: []Scope{ScopeAdmin},
|
|
ExpiresIn: 0,
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if resp.Key.ExpiresAt != nil {
|
|
t.Error("Key.ExpiresAt should be nil for no expiration")
|
|
}
|
|
})
|
|
|
|
t.Run("creates key with project restrictions", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-key-projects",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ProjectIDs: []string{"proj-a", "proj-b"},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if len(resp.Key.ProjectIDs) != 2 {
|
|
t.Errorf("Key.ProjectIDs length = %d, want 2", len(resp.Key.ProjectIDs))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestService_Validate(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
svc := NewService(db, "admin-key-test")
|
|
|
|
t.Run("validates admin key", func(t *testing.T) {
|
|
key, err := svc.Validate(context.Background(), "admin-key-test")
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
|
|
if key.ID != "admin" {
|
|
t.Errorf("Key.ID = %q, want %q", key.ID, "admin")
|
|
}
|
|
|
|
if !HasScope(key.Scopes, ScopeAdmin) {
|
|
t.Error("Admin key should have admin scope")
|
|
}
|
|
})
|
|
|
|
t.Run("validates created key", func(t *testing.T) {
|
|
// Create a key first
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-validate-key",
|
|
Scopes: []Scope{ScopeProjectsRead, ScopeKeysRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
// Validate it
|
|
key, err := svc.Validate(context.Background(), resp.Secret)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
|
|
if key.Name != "test-validate-key" {
|
|
t.Errorf("Key.Name = %q, want %q", key.Name, "test-validate-key")
|
|
}
|
|
|
|
if len(key.Scopes) != 2 {
|
|
t.Errorf("Key.Scopes length = %d, want 2", len(key.Scopes))
|
|
}
|
|
})
|
|
|
|
t.Run("rejects invalid format", func(t *testing.T) {
|
|
_, err := svc.Validate(context.Background(), "not-a-valid-key")
|
|
if err != ErrKeyNotFound {
|
|
t.Errorf("Validate() error = %v, want %v", err, ErrKeyNotFound)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects unknown key", func(t *testing.T) {
|
|
// Valid format but not in database
|
|
_, err := svc.Validate(context.Background(), "rdev_sk_abc12345_0123456789abcdef0123456789abcdef")
|
|
if err != ErrKeyNotFound {
|
|
t.Errorf("Validate() error = %v, want %v", err, ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestService_List(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
svc := NewService(db, "admin-key")
|
|
|
|
// Create some test keys
|
|
for i := 0; i < 3; i++ {
|
|
_, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: fmt.Sprintf("test-list-key-%d", i),
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
}
|
|
|
|
keys, err := svc.List(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("List() error = %v", err)
|
|
}
|
|
|
|
// Should have at least our 3 test keys
|
|
testKeyCount := 0
|
|
for _, k := range keys {
|
|
if k.Name[:10] == "test-list-" {
|
|
testKeyCount++
|
|
}
|
|
}
|
|
|
|
if testKeyCount != 3 {
|
|
t.Errorf("List() returned %d test keys, want 3", testKeyCount)
|
|
}
|
|
}
|
|
|
|
func TestService_Get(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
svc := NewService(db, "admin-key")
|
|
|
|
t.Run("gets existing key", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-get-key",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
key, err := svc.Get(context.Background(), resp.Key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
if key.Name != "test-get-key" {
|
|
t.Errorf("Key.Name = %q, want %q", key.Name, "test-get-key")
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for unknown key", func(t *testing.T) {
|
|
_, err := svc.Get(context.Background(), "00000000-0000-0000-0000-000000000000")
|
|
if err != ErrKeyNotFound {
|
|
t.Errorf("Get() error = %v, want %v", err, ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestService_Revoke(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
svc := NewService(db, "admin-key")
|
|
|
|
t.Run("revokes existing key", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-revoke-key",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
err = svc.Revoke(context.Background(), resp.Key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Revoke() error = %v", err)
|
|
}
|
|
|
|
// Validate should fail
|
|
_, err = svc.Validate(context.Background(), resp.Secret)
|
|
if err != ErrKeyRevoked {
|
|
t.Errorf("Validate() after revoke error = %v, want %v", err, ErrKeyRevoked)
|
|
}
|
|
})
|
|
|
|
t.Run("returns error for unknown key", func(t *testing.T) {
|
|
err := svc.Revoke(context.Background(), "00000000-0000-0000-0000-000000000000")
|
|
if err != ErrKeyNotFound {
|
|
t.Errorf("Revoke() error = %v, want %v", err, ErrKeyNotFound)
|
|
}
|
|
})
|
|
|
|
t.Run("idempotent for already revoked", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-revoke-twice",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
// Revoke once
|
|
if err := svc.Revoke(context.Background(), resp.Key.ID); err != nil {
|
|
t.Fatalf("First Revoke() error = %v", err)
|
|
}
|
|
|
|
// Revoke again - should return not found (no rows affected)
|
|
err = svc.Revoke(context.Background(), resp.Key.ID)
|
|
if err != ErrKeyNotFound {
|
|
t.Errorf("Second Revoke() error = %v, want %v", err, ErrKeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPIKey_IsIPAllowed(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
allowedIPs []string
|
|
clientIP string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no restrictions - any IP allowed",
|
|
allowedIPs: nil,
|
|
clientIP: "192.168.1.100",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "empty restrictions - any IP allowed",
|
|
allowedIPs: []string{},
|
|
clientIP: "10.0.0.5",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "single IP match",
|
|
allowedIPs: []string{"192.168.1.100"},
|
|
clientIP: "192.168.1.100",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "single IP no match",
|
|
allowedIPs: []string{"192.168.1.100"},
|
|
clientIP: "192.168.1.101",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "CIDR match",
|
|
allowedIPs: []string{"192.168.1.0/24"},
|
|
clientIP: "192.168.1.55",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "CIDR no match",
|
|
allowedIPs: []string{"192.168.1.0/24"},
|
|
clientIP: "192.168.2.1",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "multiple CIDRs - first matches",
|
|
allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"},
|
|
clientIP: "10.50.25.100",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "multiple CIDRs - second matches",
|
|
allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"},
|
|
clientIP: "192.168.50.1",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "multiple CIDRs - none match",
|
|
allowedIPs: []string{"10.0.0.0/8", "192.168.0.0/16"},
|
|
clientIP: "172.16.0.1",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "mixed IP and CIDR - IP matches",
|
|
allowedIPs: []string{"10.0.0.0/8", "172.16.0.1"},
|
|
clientIP: "172.16.0.1",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "mixed IP and CIDR - CIDR matches",
|
|
allowedIPs: []string{"10.0.0.0/8", "172.16.0.1"},
|
|
clientIP: "10.1.2.3",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "IPv6 CIDR",
|
|
allowedIPs: []string{"2001:db8::/32"},
|
|
clientIP: "2001:db8:1234:5678::1",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "IPv6 no match",
|
|
allowedIPs: []string{"2001:db8::/32"},
|
|
clientIP: "2001:db9::1",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid client IP",
|
|
allowedIPs: []string{"192.168.1.0/24"},
|
|
clientIP: "not-an-ip",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "invalid CIDR in allowlist (fallback to IP parse)",
|
|
allowedIPs: []string{"invalid/cidr", "192.168.1.100"},
|
|
clientIP: "192.168.1.100",
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key := &APIKey{AllowedIPs: tt.allowedIPs}
|
|
if got := key.IsIPAllowed(tt.clientIP); got != tt.want {
|
|
t.Errorf("IsIPAllowed(%q) = %v, want %v", tt.clientIP, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestService_CreateWithAllowedIPs(t *testing.T) {
|
|
db := testutil.TestDB(t)
|
|
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
|
|
|
|
svc := NewService(db, "admin-key")
|
|
|
|
t.Run("creates key with IP restrictions", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-ip-key",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
AllowedIPs: []string{"192.168.1.0/24", "10.0.0.1"},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if len(resp.Key.AllowedIPs) != 2 {
|
|
t.Errorf("Key.AllowedIPs length = %d, want 2", len(resp.Key.AllowedIPs))
|
|
}
|
|
|
|
// Verify via Get
|
|
key, err := svc.Get(context.Background(), resp.Key.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
if len(key.AllowedIPs) != 2 {
|
|
t.Errorf("Retrieved Key.AllowedIPs length = %d, want 2", len(key.AllowedIPs))
|
|
}
|
|
|
|
// Verify via Validate
|
|
validatedKey, err := svc.Validate(context.Background(), resp.Secret)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
|
|
if len(validatedKey.AllowedIPs) != 2 {
|
|
t.Errorf("Validated Key.AllowedIPs length = %d, want 2", len(validatedKey.AllowedIPs))
|
|
}
|
|
|
|
// Verify IP checking works
|
|
if !validatedKey.IsIPAllowed("192.168.1.50") {
|
|
t.Error("IsIPAllowed should return true for IP in allowed CIDR")
|
|
}
|
|
if !validatedKey.IsIPAllowed("10.0.0.1") {
|
|
t.Error("IsIPAllowed should return true for explicitly allowed IP")
|
|
}
|
|
if validatedKey.IsIPAllowed("172.16.0.1") {
|
|
t.Error("IsIPAllowed should return false for IP not in allowed list")
|
|
}
|
|
})
|
|
|
|
t.Run("creates key with no IP restrictions", func(t *testing.T) {
|
|
resp, err := svc.Create(context.Background(), CreateKeyRequest{
|
|
Name: "test-no-ip-key",
|
|
Scopes: []Scope{ScopeProjectsRead},
|
|
ExpiresIn: 24 * time.Hour,
|
|
CreatedBy: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
|
|
if len(resp.Key.AllowedIPs) != 0 {
|
|
t.Errorf("Key.AllowedIPs should be empty, got %v", resp.Key.AllowedIPs)
|
|
}
|
|
|
|
// Verify via Validate
|
|
validatedKey, err := svc.Validate(context.Background(), resp.Secret)
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
|
|
// Any IP should be allowed
|
|
if !validatedKey.IsIPAllowed("1.2.3.4") {
|
|
t.Error("IsIPAllowed should return true when no restrictions set")
|
|
}
|
|
})
|
|
}
|