rdev/internal/handlers/keys_test.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:05:28 -07:00

352 lines
9.0 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/adapter/postgres"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"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) })
apiKeyRepo := postgres.NewAPIKeyRepository(db)
apiKeySvc := service.NewAPIKeyService(apiKeyRepo, "test-admin-key")
authService := auth.NewService(apiKeySvc, "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/"+string(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/"+string(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 !errors.Is(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: domain.APIKeyID("test-id"),
Name: "test-name",
KeyPrefix: "rdev_sk_abc",
Scopes: []auth.Scope{auth.ScopeProjectsRead, auth.ScopeProjectsExecute},
ProjectIDs: []domain.ProjectID{"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")
}
}