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>
346 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|