Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ---------- ApprovalsHandler: Create ----------
|
|
|
|
func TestApprovals_Create_Success(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
// Insert approval
|
|
mock.ExpectQuery("INSERT INTO approval_requests").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("appr-1"))
|
|
|
|
// RecordAndBroadcast for APPROVAL_REQUESTED
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
// Parent lookup (no parent — nil)
|
|
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
body := `{"action":"run_script","reason":"need to execute","task_id":"task-99"}`
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(c)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["approval_id"] != "appr-1" {
|
|
t.Errorf("expected approval_id appr-1, got %v", resp["approval_id"])
|
|
}
|
|
if resp["status"] != "pending" {
|
|
t.Errorf("expected status 'pending', got %v", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Create_WithParentEscalation(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
// Insert approval
|
|
mock.ExpectQuery("INSERT INTO approval_requests").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("appr-2"))
|
|
|
|
// APPROVAL_REQUESTED broadcast
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
// Parent lookup — returns parent-ws
|
|
parentID := "parent-ws"
|
|
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
|
WithArgs("child-ws").
|
|
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(&parentID))
|
|
|
|
// APPROVAL_ESCALATED broadcast to parent
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "child-ws"}}
|
|
body := `{"action":"delete_file","reason":"cleanup"}`
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(c)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Create_MissingAction(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"reason":"no action"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---------- ApprovalsHandler: ListAll ----------
|
|
|
|
func TestApprovals_ListAll_Empty(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
// Auto-expire stale approvals
|
|
mock.ExpectExec("UPDATE approval_requests SET status = 'denied'").
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
|
|
// Query all pending
|
|
mock.ExpectQuery("SELECT a.id, a.workspace_id, w.name, a.action, a.reason, a.status, a.created_at FROM approval_requests a").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "name", "action", "reason", "status", "created_at"}))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("GET", "/approvals/pending", nil)
|
|
|
|
handler.ListAll(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
var result []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty list, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestApprovals_ListAll_WithResults(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status = 'denied'").
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
|
|
reason := "needs review"
|
|
rows := sqlmock.NewRows([]string{"id", "workspace_id", "name", "action", "reason", "status", "created_at"}).
|
|
AddRow("appr-1", "ws-1", "Test WS", "run_script", &reason, "pending", "2024-01-01T00:00:00Z").
|
|
AddRow("appr-2", "ws-2", "Test WS 2", "delete_file", nil, "pending", "2024-01-02T00:00:00Z")
|
|
|
|
mock.ExpectQuery("SELECT a.id, a.workspace_id, w.name").
|
|
WillReturnRows(rows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("GET", "/approvals/pending", nil)
|
|
|
|
handler.ListAll(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var result []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if len(result) != 2 {
|
|
t.Errorf("expected 2 approvals, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// ---------- ApprovalsHandler: List ----------
|
|
|
|
func TestApprovals_List_ForWorkspace(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
decidedBy := "admin"
|
|
decidedAt := "2024-01-01T00:00:00Z"
|
|
taskID := "task-1"
|
|
reason := "safety check"
|
|
rows := sqlmock.NewRows([]string{"id", "task_id", "action", "reason", "status", "decided_by", "decided_at", "created_at"}).
|
|
AddRow("appr-5", &taskID, "run", &reason, "approved", &decidedBy, &decidedAt, "2024-01-01T00:00:00Z")
|
|
|
|
mock.ExpectQuery("SELECT id, task_id, action, reason, status, decided_by, decided_at, created_at FROM approval_requests WHERE workspace_id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(rows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/approvals", nil)
|
|
|
|
handler.List(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var result []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if len(result) != 1 {
|
|
t.Errorf("expected 1 approval, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// ---------- ApprovalsHandler: Decide ----------
|
|
|
|
func TestApprovals_Decide_Approved(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status").
|
|
WithArgs("approved", "human", "appr-1", "ws-1").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"approved"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["status"] != "approved" {
|
|
t.Errorf("expected status 'approved', got %v", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_Denied(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status").
|
|
WithArgs("denied", "supervisor", "appr-2", "ws-1").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-2"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"denied","decided_by":"supervisor"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_NotFound(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status").
|
|
WithArgs("approved", "human", "appr-none", "ws-1").
|
|
WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-none"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"approved"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_InvalidDecision(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"maybe"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_MissingDecision(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|