rdev/internal/handlers/keys_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

346 lines
8.6 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/testutil"
)
// TestKeysHandler requires a database connection.
// Tests are skipped if the database is not available.
func setupKeysHandler(t *testing.T) (*KeysHandler, chi.Router, *auth.Service) {
db := testutil.TestDB(t)
t.Cleanup(func() { testutil.CleanupTestKeys(t, db) })
authService := auth.NewService(db, "test-admin-key")
handler := NewKeysHandler(authService)
router := chi.NewRouter()
// For tests, we'll mount without the auth middleware
// since we're testing the handler logic, not auth
router.Route("/keys", func(r chi.Router) {
r.Get("/", handler.List)
r.Post("/", handler.Create)
r.Get("/{id}", handler.Get)
r.Delete("/{id}", handler.Revoke)
})
return handler, router, authService
}
func TestKeysHandler_List(t *testing.T) {
_, router, authService := setupKeysHandler(t)
// Create some test keys
for i := 0; i < 3; i++ {
_, err := authService.Create(context.Background(), auth.CreateKeyRequest{
Name: "test-handler-list-" + string(rune('a'+i)),
Scopes: []auth.Scope{auth.ScopeProjectsRead},
ExpiresIn: 24 * time.Hour,
CreatedBy: "test",
})
if err != nil {
t.Fatalf("Failed to create test key: %v", err)
}
}
req := httptest.NewRequest("GET", "/keys", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200. Body: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
data, ok := resp["data"].([]any)
if !ok {
t.Fatal("Response data is not an array")
}
// Should have at least 3 keys
if len(data) < 3 {
t.Errorf("Expected at least 3 keys, got %d", len(data))
}
}
func TestKeysHandler_Create(t *testing.T) {
_, router, _ := setupKeysHandler(t)
tests := []struct {
name string
body CreateKeyRequest
wantStatus int
wantErr string
}{
{
name: "valid key",
body: CreateKeyRequest{
Name: "test-create-key",
Scopes: []string{"projects:read"},
ExpiresIn: "30d",
},
wantStatus: http.StatusCreated,
},
{
name: "missing name",
body: CreateKeyRequest{
Scopes: []string{"projects:read"},
},
wantStatus: http.StatusBadRequest,
wantErr: "name: is required",
},
{
name: "missing scopes",
body: CreateKeyRequest{
Name: "test-no-scopes",
},
wantStatus: http.StatusBadRequest,
wantErr: "scopes: is required",
},
{
name: "invalid scope",
body: CreateKeyRequest{
Name: "test-invalid-scope",
Scopes: []string{"invalid:scope"},
},
wantStatus: http.StatusBadRequest,
wantErr: "invalid scope",
},
{
name: "invalid expiration",
body: CreateKeyRequest{
Name: "test-invalid-exp",
Scopes: []string{"projects:read"},
ExpiresIn: "invalid",
},
wantStatus: http.StatusBadRequest,
wantErr: "expiration",
},
{
name: "with project restrictions",
body: CreateKeyRequest{
Name: "test-with-projects",
Scopes: []string{"projects:read"},
ProjectIDs: []string{"proj-a", "proj-b"},
ExpiresIn: "90d",
},
wantStatus: http.StatusCreated,
},
{
name: "never expires",
body: CreateKeyRequest{
Name: "test-never-expires",
Scopes: []string{"admin"},
ExpiresIn: "never",
},
wantStatus: http.StatusCreated,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/keys", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("Status = %d, want %d. Body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
if tt.wantErr != "" {
if !strings.Contains(rec.Body.String(), tt.wantErr) {
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr)
}
}
// For successful creates, verify the response structure
if tt.wantStatus == http.StatusCreated {
var resp map[string]any
json.NewDecoder(bytes.NewReader(rec.Body.Bytes())).Decode(&resp)
data, _ := resp["data"].(map[string]any)
if data["secret"] == nil || data["secret"] == "" {
t.Error("Response should include secret")
}
if data["key"] == nil {
t.Error("Response should include key object")
}
}
})
}
}
func TestKeysHandler_Get(t *testing.T) {
_, router, authService := setupKeysHandler(t)
// Create a key to get
result, err := authService.Create(context.Background(), auth.CreateKeyRequest{
Name: "test-handler-get",
Scopes: []auth.Scope{auth.ScopeProjectsRead},
ExpiresIn: 24 * time.Hour,
CreatedBy: "test",
})
if err != nil {
t.Fatalf("Failed to create test key: %v", err)
}
t.Run("existing key", func(t *testing.T) {
req := httptest.NewRequest("GET", "/keys/"+result.Key.ID, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200. Body: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
json.NewDecoder(rec.Body).Decode(&resp)
data, _ := resp["data"].(map[string]any)
if data["name"] != "test-handler-get" {
t.Errorf("Name = %v, want test-handler-get", data["name"])
}
})
t.Run("non-existent key", func(t *testing.T) {
req := httptest.NewRequest("GET", "/keys/00000000-0000-0000-0000-000000000000", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("Status = %d, want 404", rec.Code)
}
})
}
func TestKeysHandler_Revoke(t *testing.T) {
_, router, authService := setupKeysHandler(t)
// Create a key to revoke
result, err := authService.Create(context.Background(), auth.CreateKeyRequest{
Name: "test-handler-revoke",
Scopes: []auth.Scope{auth.ScopeProjectsRead},
ExpiresIn: 24 * time.Hour,
CreatedBy: "test",
})
if err != nil {
t.Fatalf("Failed to create test key: %v", err)
}
t.Run("revoke existing key", func(t *testing.T) {
req := httptest.NewRequest("DELETE", "/keys/"+result.Key.ID, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200. Body: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
json.NewDecoder(rec.Body).Decode(&resp)
data, _ := resp["data"].(map[string]any)
if data["status"] != "revoked" {
t.Errorf("Status = %v, want revoked", data["status"])
}
// Verify the key is actually revoked
_, err := authService.Validate(context.Background(), result.Secret)
if err != auth.ErrKeyRevoked {
t.Errorf("Key should be revoked, got err = %v", err)
}
})
t.Run("revoke non-existent key", func(t *testing.T) {
req := httptest.NewRequest("DELETE", "/keys/00000000-0000-0000-0000-000000000000", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("Status = %d, want 404", rec.Code)
}
})
}
func TestKeysHandler_InvalidJSON(t *testing.T) {
_, router, _ := setupKeysHandler(t)
req := httptest.NewRequest("POST", "/keys", strings.NewReader("invalid json{"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("Status = %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid JSON") {
t.Errorf("Body = %q, want to contain 'Invalid JSON'", rec.Body.String())
}
}
func TestApiKeyToResponse(t *testing.T) {
now := time.Now()
future := now.Add(24 * time.Hour)
key := &auth.APIKey{
ID: "test-id",
Name: "test-name",
KeyPrefix: "rdev_sk_abc",
Scopes: []auth.Scope{auth.ScopeProjectsRead, auth.ScopeProjectsExecute},
ProjectIDs: []string{"proj-a"},
CreatedAt: now,
ExpiresAt: &future,
LastUsedAt: &now,
CreatedBy: "test-user",
}
resp := apiKeyToResponse(key)
if resp.ID != "test-id" {
t.Errorf("ID = %q, want test-id", resp.ID)
}
if resp.Name != "test-name" {
t.Errorf("Name = %q, want test-name", resp.Name)
}
if len(resp.Scopes) != 2 {
t.Errorf("Scopes length = %d, want 2", len(resp.Scopes))
}
if len(resp.ProjectIDs) != 1 {
t.Errorf("ProjectIDs length = %d, want 1", len(resp.ProjectIDs))
}
if resp.ExpiresAt == nil {
t.Error("ExpiresAt should not be nil")
}
if resp.LastUsedAt == nil {
t.Error("LastUsedAt should not be nil")
}
if !resp.Active {
t.Error("Active should be true")
}
}