rdev/internal/auth/service_test.go
jordan 538ea57ed4 feat: Add claude-config API, security hardening, and testing infrastructure
Claude Config API (v0.6):
- Add CRUD endpoints for commands, skills, and agents
- Commands/skills/agents stored in /workspace/.claude/ (per-project, in git)
- Credentials shared via PVC at /root/.claude/ (shared across pods)
- Use base64 encoding for file writes (prevents shell injection)
- Add content size limits (1MB max)

Security Hardening:
- Add sanitize package for command/prompt validation
- Add rate limiting middleware (token bucket algorithm)
- Add concurrent command limiting
- Add input sanitization to all command handlers
- Gitignore secrets.yaml and credentials.yaml
- Add *.example templates for secrets

Testing Infrastructure:
- Add testutil package with mocks and fixtures
- Add unit tests for auth package (63% coverage)
- Add unit tests for executor (47% coverage)
- Add handler integration tests (40% coverage)
- Add 100% coverage for sanitize, cmdlimit packages
- Add 96% coverage for ratelimit package

Infrastructure:
- Shared Claude credentials PVC (ReadWriteMany)
- Reduced workspace PVC size from 20Gi to 5Gi
- Add init container cleanup before git clone
- Document Longhorn RWX requirements

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

395 lines
9.7 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)
}
})
}