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>
536 lines
12 KiB
Go
536 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// mockCommandQueue implements port.CommandQueue for testing.
|
|
type mockCommandQueue struct {
|
|
commands []*domain.QueuedCommand
|
|
err error
|
|
}
|
|
|
|
func (m *mockCommandQueue) Enqueue(ctx context.Context, cmd *domain.QueuedCommand) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
cmd.ID = domain.QueuedCommandID("queued-cmd-123")
|
|
m.commands = append(m.commands, cmd)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCommandQueue) Dequeue(ctx context.Context, projectID string) (*domain.QueuedCommand, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
for _, cmd := range m.commands {
|
|
if cmd.ProjectID == projectID && cmd.Status == domain.QueueStatusPending {
|
|
cmd.Status = domain.QueueStatusRunning
|
|
return cmd, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockCommandQueue) UpdateStatus(ctx context.Context, cmdID domain.QueuedCommandID, status domain.QueueStatus, result *domain.QueuedCommandResult) error {
|
|
return m.err
|
|
}
|
|
|
|
func (m *mockCommandQueue) Cancel(ctx context.Context, cmdID domain.QueuedCommandID) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
for _, cmd := range m.commands {
|
|
if cmd.ID == cmdID {
|
|
if cmd.Status != domain.QueueStatusPending {
|
|
return domain.ErrCommandNotFound
|
|
}
|
|
cmd.Status = domain.QueueStatusCancelled
|
|
return nil
|
|
}
|
|
}
|
|
return domain.ErrCommandNotFound
|
|
}
|
|
|
|
func (m *mockCommandQueue) GetByID(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
for _, cmd := range m.commands {
|
|
if cmd.ID == cmdID {
|
|
return cmd, nil
|
|
}
|
|
}
|
|
return nil, domain.ErrCommandNotFound
|
|
}
|
|
|
|
func (m *mockCommandQueue) List(ctx context.Context, projectID string, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
var result []*domain.QueuedCommand
|
|
for _, cmd := range m.commands {
|
|
if cmd.ProjectID == projectID {
|
|
result = append(result, cmd)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockCommandQueue) GetStats(ctx context.Context, projectID string) (*domain.QueueStats, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
return &domain.QueueStats{
|
|
TotalPending: 1,
|
|
TotalRunning: 0,
|
|
TotalCompleted: 0,
|
|
TotalFailed: 0,
|
|
TotalCancelled: 0,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockCommandQueue) CleanupOld(ctx context.Context, olderThanDays int) (int64, error) {
|
|
return 0, m.err
|
|
}
|
|
|
|
// mockProjectRepo implements port.ProjectRepository for queue handler testing.
|
|
type mockProjectRepo struct {
|
|
projects map[domain.ProjectID]*domain.Project
|
|
}
|
|
|
|
func newMockProjectRepo() *mockProjectRepo {
|
|
return &mockProjectRepo{
|
|
projects: make(map[domain.ProjectID]*domain.Project),
|
|
}
|
|
}
|
|
|
|
func (m *mockProjectRepo) List(ctx context.Context) ([]domain.Project, error) {
|
|
var result []domain.Project
|
|
for _, p := range m.projects {
|
|
result = append(result, *p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockProjectRepo) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) {
|
|
if p, ok := m.projects[id]; ok {
|
|
return p, nil
|
|
}
|
|
return nil, domain.ErrProjectNotFound
|
|
}
|
|
|
|
func (m *mockProjectRepo) Exists(ctx context.Context, id domain.ProjectID) (bool, error) {
|
|
_, ok := m.projects[id]
|
|
return ok, nil
|
|
}
|
|
|
|
func (m *mockProjectRepo) RefreshStatus(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProjectRepo) Register(ctx context.Context, p *domain.Project) error {
|
|
m.projects[p.ID] = p
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProjectRepo) Unregister(ctx context.Context, id domain.ProjectID) error {
|
|
delete(m.projects, id)
|
|
return nil
|
|
}
|
|
|
|
func TestQueueHandler_Enqueue(t *testing.T) {
|
|
projectRepo := newMockProjectRepo()
|
|
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
projectID string
|
|
body EnqueueRequest
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "valid claude command",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: "explain this code",
|
|
CommandType: "claude",
|
|
},
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "valid shell command",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: "ls -la",
|
|
CommandType: "shell",
|
|
},
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "valid git command",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: `["status"]`,
|
|
CommandType: "git",
|
|
},
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "invalid command type",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: "test",
|
|
CommandType: "invalid",
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "empty command",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: "",
|
|
CommandType: "claude",
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "project not found",
|
|
projectID: "unknown",
|
|
body: EnqueueRequest{
|
|
Command: "test",
|
|
CommandType: "claude",
|
|
},
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "dangerous shell command",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: "rm -rf /",
|
|
CommandType: "shell",
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "invalid git command format",
|
|
projectID: "proj-1",
|
|
body: EnqueueRequest{
|
|
Command: "not json array",
|
|
CommandType: "git",
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
queue := &mockCommandQueue{}
|
|
h := NewQueueHandler(queue, projectRepo)
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/projects/{id}/queue/", h.Enqueue)
|
|
|
|
body, _ := json.Marshal(tt.body)
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/"+tt.projectID+"/queue/", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("Enqueue() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueHandler_List(t *testing.T) {
|
|
projectRepo := newMockProjectRepo()
|
|
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
|
|
|
|
queue := &mockCommandQueue{
|
|
commands: []*domain.QueuedCommand{
|
|
{
|
|
ID: "cmd-1",
|
|
ProjectID: "proj-1",
|
|
Command: "test",
|
|
CommandType: domain.CommandTypeClaude,
|
|
Status: domain.QueueStatusPending,
|
|
CreatedAt: time.Now(),
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
projectID string
|
|
query string
|
|
wantStatus int
|
|
wantCount int
|
|
}{
|
|
{
|
|
name: "list all commands",
|
|
projectID: "proj-1",
|
|
query: "",
|
|
wantStatus: http.StatusOK,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "project not found",
|
|
projectID: "unknown",
|
|
query: "",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "with limit",
|
|
projectID: "proj-1",
|
|
query: "?limit=10",
|
|
wantStatus: http.StatusOK,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "with offset",
|
|
projectID: "proj-1",
|
|
query: "?offset=0",
|
|
wantStatus: http.StatusOK,
|
|
wantCount: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := NewQueueHandler(queue, projectRepo)
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/projects/{id}/queue/", h.List)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/queue/"+tt.query, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(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 ListResponse `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
if len(resp.Data.Commands) != tt.wantCount {
|
|
t.Errorf("List() count = %d, want %d", len(resp.Data.Commands), tt.wantCount)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueHandler_GetByID(t *testing.T) {
|
|
projectRepo := newMockProjectRepo()
|
|
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
|
|
|
|
queue := &mockCommandQueue{
|
|
commands: []*domain.QueuedCommand{
|
|
{
|
|
ID: "cmd-123",
|
|
ProjectID: "proj-1",
|
|
Command: "test",
|
|
CommandType: domain.CommandTypeClaude,
|
|
Status: domain.QueueStatusPending,
|
|
CreatedAt: time.Now(),
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
projectID string
|
|
cmdID string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "existing command",
|
|
projectID: "proj-1",
|
|
cmdID: "cmd-123",
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "non-existent command",
|
|
projectID: "proj-1",
|
|
cmdID: "cmd-unknown",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "project not found",
|
|
projectID: "unknown",
|
|
cmdID: "cmd-123",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := NewQueueHandler(queue, projectRepo)
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/projects/{id}/queue/{cmdId}", h.GetByID)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/queue/"+tt.cmdID, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("GetByID() status = %d, want %d", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueHandler_Cancel(t *testing.T) {
|
|
projectRepo := newMockProjectRepo()
|
|
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
projectID string
|
|
cmdID string
|
|
commands []*domain.QueuedCommand
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "cancel pending command",
|
|
projectID: "proj-1",
|
|
cmdID: "cmd-123",
|
|
commands: []*domain.QueuedCommand{
|
|
{
|
|
ID: "cmd-123",
|
|
ProjectID: "proj-1",
|
|
Status: domain.QueueStatusPending,
|
|
},
|
|
},
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "command not found",
|
|
projectID: "proj-1",
|
|
cmdID: "cmd-unknown",
|
|
commands: nil,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "project not found",
|
|
projectID: "unknown",
|
|
cmdID: "cmd-123",
|
|
commands: nil,
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
queue := &mockCommandQueue{commands: tt.commands}
|
|
h := NewQueueHandler(queue, projectRepo)
|
|
|
|
r := chi.NewRouter()
|
|
r.Delete("/projects/{id}/queue/{cmdId}", h.Cancel)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/projects/"+tt.projectID+"/queue/"+tt.cmdID, nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("Cancel() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueHandler_Stats(t *testing.T) {
|
|
projectRepo := newMockProjectRepo()
|
|
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
|
|
|
|
queue := &mockCommandQueue{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
projectID string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "get stats",
|
|
projectID: "proj-1",
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "project not found",
|
|
projectID: "unknown",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := NewQueueHandler(queue, projectRepo)
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/projects/{id}/queue/stats", h.Stats)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/queue/stats", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != tt.wantStatus {
|
|
t.Errorf("Stats() status = %d, want %d", w.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQueueHandler_Enqueue_CommandSizeLimit(t *testing.T) {
|
|
projectRepo := newMockProjectRepo()
|
|
projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"})
|
|
queue := &mockCommandQueue{}
|
|
h := NewQueueHandler(queue, projectRepo)
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/projects/{id}/queue/", h.Enqueue)
|
|
|
|
// Create a command that exceeds MaxCommandSize (10KB)
|
|
largeCommand := make([]byte, MaxCommandSize+1)
|
|
for i := range largeCommand {
|
|
largeCommand[i] = 'a'
|
|
}
|
|
|
|
body := EnqueueRequest{
|
|
Command: string(largeCommand),
|
|
CommandType: "claude",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/proj-1/queue/", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Enqueue() with large command status = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
}
|