rdev/internal/handlers/sessions_test.go
jordan 9226454b85
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
- Add UndeployAll() using label selectors to clean up monorepo components
  on project deletion (replaces name-based Undeploy in DeleteProject and
  the direct undeploy handler)
- Add ResourceGC background worker that periodically finds K8s resources
  whose project label has no matching DB record, deletes after 1h safety
  window
- Widen deployer client type from *kubernetes.Clientset to
  kubernetes.Interface for testability
- UndeployAll accumulates errors via errors.Join instead of failing fast
- Add checkout/checkin sidecar dev flow: temporary git tokens, branch
  checkout, review on checkin with cleanup workers
- Add interactive sessions: pod binding, command execution, SSE streaming,
  ephemeral preview URLs with session cleanup workers
- Add GET /workers/pool endpoint for aggregate capacity and queue depth
- Add sessions:read and sessions:execute auth scopes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:11:28 -07:00

506 lines
14 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
)
// mockSessionRepository implements port.SessionRepository for testing.
type mockSessionRepository struct {
sessions map[string]*domain.Session
err error
}
func newMockSessionRepository() *mockSessionRepository {
return &mockSessionRepository{sessions: make(map[string]*domain.Session)}
}
func (m *mockSessionRepository) Create(_ context.Context, session *domain.Session) error {
if m.err != nil {
return m.err
}
// Check unique constraint: only one active per project.
for _, s := range m.sessions {
if s.ProjectID == session.ProjectID && s.Status == domain.SessionStatusActive {
return domain.ErrSessionExists
}
}
session.ID = domain.SessionID("test-session-id")
m.sessions[string(session.ID)] = session
return nil
}
func (m *mockSessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
s, ok := m.sessions[string(id)]
if !ok {
return nil, domain.ErrSessionNotFound
}
return s, nil
}
func (m *mockSessionRepository) GetActiveByProject(_ context.Context, projectID domain.ProjectID) (*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
for _, s := range m.sessions {
if s.ProjectID == projectID && s.Status == domain.SessionStatusActive {
return s, nil
}
}
return nil, domain.ErrSessionNotFound
}
func (m *mockSessionRepository) ListByProject(_ context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.Session
for _, s := range m.sessions {
if s.ProjectID == projectID {
result = append(result, s)
}
}
return result, nil
}
func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error {
if m.err != nil {
return m.err
}
s, ok := m.sessions[string(id)]
if !ok || s.Status != domain.SessionStatusActive {
return domain.ErrSessionNotActive
}
s.Status = domain.SessionStatusEnded
now := time.Now()
s.EndedAt = &now
return nil
}
func (m *mockSessionRepository) CleanupExpired(_ context.Context) ([]*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
var expired []*domain.Session
for _, s := range m.sessions {
if s.Status == domain.SessionStatusActive && time.Now().After(s.ExpiresAt) {
s.Status = domain.SessionStatusExpired
now := time.Now()
s.EndedAt = &now
expired = append(expired, s)
}
}
return expired, nil
}
// mockCheckoutRepository implements port.CheckoutRepository for testing sessions.
type mockCheckoutRepository struct {
checkouts map[string]*domain.Checkout
err error
}
func newMockCheckoutRepository() *mockCheckoutRepository {
return &mockCheckoutRepository{checkouts: make(map[string]*domain.Checkout)}
}
func (m *mockCheckoutRepository) Create(_ context.Context, c *domain.Checkout) error {
if m.err != nil {
return m.err
}
c.ID = domain.CheckoutID("test-checkout-id")
m.checkouts[string(c.ID)] = c
return nil
}
func (m *mockCheckoutRepository) Get(_ context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
if m.err != nil {
return nil, m.err
}
c, ok := m.checkouts[string(id)]
if !ok {
return nil, domain.ErrCheckoutNotFound
}
return c, nil
}
func (m *mockCheckoutRepository) GetByProjectBranch(_ context.Context, _ domain.ProjectID, _ string) (*domain.Checkout, error) {
return nil, domain.ErrCheckoutNotFound
}
func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOptions) ([]*domain.Checkout, error) {
return nil, nil
}
func (m *mockCheckoutRepository) ListByProject(_ context.Context, _ domain.ProjectID) ([]*domain.Checkout, error) {
return nil, nil
}
func (m *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
if c, ok := m.checkouts[string(id)]; ok {
c.Status = status
}
return nil
}
func (m *mockCheckoutRepository) SetCheckedIn(_ context.Context, id domain.CheckoutID, _ string) error {
if c, ok := m.checkouts[string(id)]; ok {
c.Status = domain.CheckoutStatusCheckedIn
now := time.Now()
c.CheckedInAt = &now
}
return nil
}
func (m *mockCheckoutRepository) SetReviewTask(_ context.Context, _ domain.CheckoutID, _ string) error {
return nil
}
func (m *mockCheckoutRepository) CleanupExpired(_ context.Context) ([]int64, error) {
return nil, nil
}
// setupSessionTest creates a sessions handler with mock dependencies.
// It reuses mockProjectRepo from queue_test.go.
func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo) {
sessionRepo := newMockSessionRepository()
checkoutRepo := newMockCheckoutRepository()
projectRepo := newMockProjectRepo()
gitRepo := newMockGitRepository()
previewMgr := newMockPreviewManager()
// Add a test project.
projectRepo.projects["test-project"] = &domain.Project{
ID: "test-project",
Name: "test-project",
PodName: "test-project-0",
Status: domain.ProjectStatusRunning,
}
checkoutService := service.NewCheckoutService(
checkoutRepo,
gitRepo,
projectRepo,
service.CheckoutServiceConfig{
GiteaURL: "https://git.threesix.ai",
DefaultOwner: "threesix",
DefaultExpiry: 24 * time.Hour,
},
)
sessionService := service.NewSessionService(
sessionRepo,
checkoutService,
projectRepo,
previewMgr,
service.SessionServiceConfig{
PreviewDomain: "preview.threesix.ai",
DefaultExpiry: 24 * time.Hour,
},
)
handler := NewSessionsHandler(sessionService)
return handler, sessionRepo, projectRepo
}
func TestSessionsHandler_Create(t *testing.T) {
handler, _, _ := setupSessionTest()
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("create_session", func(t *testing.T) {
body := `{"branch": "develop"}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data map, got %T", resp["data"])
}
if data["id"] == "" {
t.Error("expected non-empty session id")
}
if data["preview_url"] == "" {
t.Error("expected non-empty preview_url")
}
if data["auth_clone_url"] == "" {
t.Error("expected auth_clone_url on creation")
}
if data["instructions"] == "" {
t.Error("expected instructions on creation")
}
if data["pod_name"] != "test-project-0" {
t.Errorf("expected pod_name=test-project-0, got %v", data["pod_name"])
}
})
t.Run("create_session_no_branch", func(t *testing.T) {
body := `{}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("create_session_project_not_found", func(t *testing.T) {
body := `{"branch": "develop"}`
req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/sessions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
})
}
func TestSessionsHandler_Get(t *testing.T) {
handler, sessionRepo, _ := setupSessionTest()
// Seed a session.
session := &domain.Session{
ID: "session-abc",
ProjectID: "test-project",
CheckoutID: "checkout-abc",
PodName: "test-project-0",
PreviewURL: "https://abc.preview.threesix.ai",
PreviewHost: "abc.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Status: domain.SessionStatusActive,
}
sessionRepo.sessions["session-abc"] = session
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("get_existing", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/session-abc", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
})
t.Run("get_wrong_project", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/other-project/sessions/session-abc", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
}
})
t.Run("get_not_found", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/nonexistent", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
}
})
}
func TestSessionsHandler_List(t *testing.T) {
handler, sessionRepo, _ := setupSessionTest()
// Seed sessions.
sessionRepo.sessions["session-1"] = &domain.Session{
ID: "session-1",
ProjectID: "test-project",
CheckoutID: "checkout-1",
PodName: "test-project-0",
PreviewURL: "https://s1.preview.threesix.ai",
PreviewHost: "s1.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Status: domain.SessionStatusActive,
}
sessionRepo.sessions["session-2"] = &domain.Session{
ID: "session-2",
ProjectID: "test-project",
CheckoutID: "checkout-2",
PodName: "test-project-0",
PreviewURL: "https://s2.preview.threesix.ai",
PreviewHost: "s2.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now().Add(-1 * time.Hour),
ExpiresAt: time.Now().Add(23 * time.Hour),
Status: domain.SessionStatusEnded,
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data map, got %T", resp["data"])
}
sessions, ok := data["sessions"].([]any)
if !ok {
t.Fatalf("expected sessions array, got %T", data["sessions"])
}
if len(sessions) != 2 {
t.Errorf("got %d sessions, want 2", len(sessions))
}
}
func TestSessionsHandler_Checkin(t *testing.T) {
handler, sessionRepo, _ := setupSessionTest()
// Seed an active session with a matching checkout.
sessionRepo.sessions["session-end"] = &domain.Session{
ID: "session-end",
ProjectID: "test-project",
CheckoutID: "checkout-end",
PodName: "test-project-0",
PreviewURL: "https://end.preview.threesix.ai",
PreviewHost: "end.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Status: domain.SessionStatusActive,
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("checkin_session", func(t *testing.T) {
// Checkout doesn't exist in the mock, but session service continues on checkout errors.
body := `{"skip_review": true}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
})
t.Run("checkin_already_ended", func(t *testing.T) {
body := `{}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
})
}
func TestWorkersHandler_PoolStatus(t *testing.T) {
registry := newMockWorkerRegistry()
queue := newMockWorkQueue()
workerService := service.NewWorkerService(registry, queue)
handler := NewWorkersHandler(workerService).WithWorkQueue(queue)
registry.workers["w1"] = &domain.Worker{
ID: "w1", Hostname: "h1", Status: domain.WorkerStatusIdle,
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
}
registry.workers["w2"] = &domain.Worker{
ID: "w2", Hostname: "h2", Status: domain.WorkerStatusBusy,
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
}
// Add a pending task to the queue.
queue.tasks["t1"] = &domain.WorkTask{ID: "t1", Status: domain.WorkTaskStatusPending}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
req := httptest.NewRequest(http.MethodGet, "/workers/pool", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data map, got %T", resp["data"])
}
if int(data["total"].(float64)) != 2 {
t.Errorf("total: got %v, want 2", data["total"])
}
if int(data["idle"].(float64)) != 1 {
t.Errorf("idle: got %v, want 1", data["idle"])
}
if int(data["busy"].(float64)) != 1 {
t.Errorf("busy: got %v, want 1", data["busy"])
}
if int(data["available"].(float64)) != 1 {
t.Errorf("available: got %v, want 1", data["available"])
}
if int(data["queue_depth"].(float64)) != 1 {
t.Errorf("queue_depth: got %v, want 1", data["queue_depth"])
}
}
// Verify port.PreviewManager is implemented by the mock.
var _ port.PreviewManager = (*mockPreviewManager)(nil)