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

275 lines
6.5 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
)
// mockAuditLogger implements port.AuditLogger for testing.
type mockAuditLogger struct {
entries []domain.AuditLogEntry
err error
}
func (m *mockAuditLogger) LogCommandStart(ctx context.Context, entry *domain.AuditLogEntry) error {
return m.err
}
func (m *mockAuditLogger) LogCommandEnd(ctx context.Context, commandID string, result *domain.AuditResult) error {
return m.err
}
func (m *mockAuditLogger) List(ctx context.Context, filters domain.AuditFilters) ([]domain.AuditLogEntry, error) {
if m.err != nil {
return nil, m.err
}
return m.entries, nil
}
func (m *mockAuditLogger) Get(ctx context.Context, commandID string) (*domain.AuditLogEntry, error) {
if m.err != nil {
return nil, m.err
}
for _, e := range m.entries {
if e.CommandID == commandID {
return &e, nil
}
}
return nil, domain.ErrAuditNotFound
}
func TestAuditHandler_List(t *testing.T) {
now := time.Now()
entries := []domain.AuditLogEntry{
{
ID: "audit-1",
CommandID: "cmd-1",
ProjectID: "proj-1",
CommandType: domain.CommandTypeClaude,
Status: domain.AuditStatusSuccess,
StartedAt: now,
CreatedAt: now,
},
{
ID: "audit-2",
CommandID: "cmd-2",
ProjectID: "proj-1",
CommandType: domain.CommandTypeShell,
Status: domain.AuditStatusRunning,
StartedAt: now,
CreatedAt: now,
},
}
tests := []struct {
name string
query string
mock *mockAuditLogger
wantStatus int
wantCount int
}{
{
name: "list all entries",
query: "",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusOK,
wantCount: 2,
},
{
name: "filter by project",
query: "?project=proj-1",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusOK,
wantCount: 2,
},
{
name: "invalid command_type",
query: "?command_type=invalid",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid status",
query: "?status=invalid",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid start time",
query: "?start=invalid",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid limit",
query: "?limit=-1",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid offset",
query: "?offset=-1",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusBadRequest,
},
{
name: "valid limit and offset",
query: "?limit=10&offset=0",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusOK,
wantCount: 2,
},
{
name: "empty result",
query: "",
mock: &mockAuditLogger{entries: nil},
wantStatus: http.StatusOK,
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewAuditHandler(tt.mock)
req := httptest.NewRequest(http.MethodGet, "/audit-log"+tt.query, nil)
w := httptest.NewRecorder()
h.List(w, req)
if w.Code != tt.wantStatus {
t.Errorf("List() status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK {
var resp struct {
Data ListAuditLogResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Data.Entries) != tt.wantCount {
t.Errorf("List() count = %d, want %d", len(resp.Data.Entries), tt.wantCount)
}
}
})
}
}
func TestAuditHandler_Get(t *testing.T) {
now := time.Now()
entries := []domain.AuditLogEntry{
{
ID: "audit-1",
CommandID: "cmd-123",
ProjectID: "proj-1",
CommandType: domain.CommandTypeClaude,
Status: domain.AuditStatusSuccess,
StartedAt: now,
CreatedAt: now,
},
}
tests := []struct {
name string
commandID string
mock *mockAuditLogger
wantStatus int
}{
{
name: "existing entry",
commandID: "cmd-123",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusOK,
},
{
name: "non-existent entry",
commandID: "cmd-unknown",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusNotFound,
},
{
name: "empty command_id",
commandID: "",
mock: &mockAuditLogger{entries: entries},
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewAuditHandler(tt.mock)
r := chi.NewRouter()
r.Get("/audit-log/{command_id}", h.Get)
path := "/audit-log/" + tt.commandID
if tt.commandID == "" {
// Test with empty path param
r.Get("/audit-log/", h.Get)
path = "/audit-log/"
}
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Get() status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
func TestAuditLogToResponse(t *testing.T) {
now := time.Now()
completedAt := now.Add(time.Second)
exitCode := 0
durationMs := int64(1000)
entry := &domain.AuditLogEntry{
ID: "audit-1",
APIKeyID: "key-1",
CommandID: "cmd-1",
ProjectID: "proj-1",
CommandType: domain.CommandTypeClaude,
Args: "some args",
ClientIP: "127.0.0.1",
UserAgent: "test-agent",
StartedAt: now,
CompletedAt: &completedAt,
ExitCode: &exitCode,
DurationMs: &durationMs,
Status: domain.AuditStatusSuccess,
ErrorMessage: "",
OutputSizeBytes: 1024,
CreatedAt: now,
}
resp := auditLogToResponse(entry)
if resp.ID != entry.ID {
t.Errorf("ID = %s, want %s", resp.ID, entry.ID)
}
if resp.CommandID != entry.CommandID {
t.Errorf("CommandID = %s, want %s", resp.CommandID, entry.CommandID)
}
if resp.ExitCode == nil || *resp.ExitCode != exitCode {
t.Errorf("ExitCode = %v, want %d", resp.ExitCode, exitCode)
}
if resp.DurationMs == nil || *resp.DurationMs != durationMs {
t.Errorf("DurationMs = %v, want %d", resp.DurationMs, durationMs)
}
if resp.CompletedAt == nil {
t.Error("CompletedAt should not be nil")
}
}