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>
435 lines
16 KiB
Go
435 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Issue #24 — DB is the source of truth; org/import is additive on
|
|
// template-source rows only. Runtime-added schedules survive re-imports.
|
|
|
|
// TestRuntimeSchedule_HasSourceRuntime asserts that POST /workspaces/:id/schedules
|
|
// writes source='runtime' so that re-imports of the org template never touch
|
|
// these user-created rows (preserved across re-imports).
|
|
func TestRuntimeSchedule_HasSourceRuntime(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
// Match the literal 'runtime' source baked into the INSERT and capture
|
|
// the workspace id arg. The inserted row id is returned via RETURNING.
|
|
mock.ExpectQuery("INSERT INTO workspace_schedules .* VALUES .* 'runtime'").
|
|
WithArgs("550e8400-e29b-41d4-a716-446655440000", "test", "*/5 * * * *", "UTC", "do thing", true, sqlmock.AnyArg()).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11111111-1111-1111-1111-111111111111"))
|
|
|
|
body := []byte(`{"name":"test","cron_expr":"*/5 * * * *","prompt":"do thing"}`)
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/550e8400-e29b-41d4-a716-446655440000/schedules", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(c)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestImport_OrgScheduleSQLShape verifies the SQL emitted by the org/import
|
|
// path for schedules. It MUST be an INSERT ... ON CONFLICT (workspace_id, name)
|
|
// DO UPDATE ... WHERE source='template' with VALUES ... 'template'. Together
|
|
// these guarantee that re-import is:
|
|
// - additive (new template rows are inserted),
|
|
// - idempotent (existing template rows are refreshed),
|
|
// - non-destructive of runtime rows (the WHERE filter skips them),
|
|
// - never DELETE-based (additive only).
|
|
//
|
|
// This is a structural assertion against the source — cheap and catches a
|
|
// regression that would silently break user-created schedules across
|
|
// re-imports without needing a full provisioner harness.
|
|
func TestImport_OrgScheduleSQLShape(t *testing.T) {
|
|
got := orgImportScheduleSQL
|
|
|
|
// Single test covers four CEO requirements at once: additive seed
|
|
// (template marker), idempotent refresh (ON CONFLICT DO UPDATE),
|
|
// runtime-row preservation (WHERE source='template'), and never-DELETE.
|
|
mustContain := []string{
|
|
"INSERT INTO workspace_schedules",
|
|
"source",
|
|
"'template'",
|
|
"ON CONFLICT (workspace_id, name) DO UPDATE",
|
|
"WHERE workspace_schedules.source = 'template'",
|
|
}
|
|
for _, s := range mustContain {
|
|
if !strings.Contains(got, s) {
|
|
t.Errorf("org/import schedule SQL missing fragment %q\n--- SQL ---\n%s", s, got)
|
|
}
|
|
}
|
|
if regexp.MustCompile(`(?i)\bDELETE\b\s+FROM\s+workspace_schedules`).MatchString(got) {
|
|
t.Error("org/import schedule SQL must never DELETE — additive only")
|
|
}
|
|
}
|
|
|
|
// TestList_IncludesSourceColumn asserts GET /workspaces/:id/schedules
|
|
// returns the source field so Canvas can render template/runtime badges.
|
|
func TestList_IncludesSourceColumn(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
cols := []string{
|
|
"id", "workspace_id", "name", "cron_expr", "timezone", "prompt", "enabled",
|
|
"last_run_at", "next_run_at", "run_count", "last_status", "last_error",
|
|
"source", "created_at", "updated_at",
|
|
}
|
|
now := time.Now()
|
|
mock.ExpectQuery("SELECT .* source, created_at, updated_at\\s+FROM workspace_schedules").
|
|
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
|
WillReturnRows(sqlmock.NewRows(cols).
|
|
AddRow("id1", "550e8400-e29b-41d4-a716-446655440000", "tmpl-sched", "0 * * * *", "UTC", "p", true,
|
|
nil, nil, 0, "", "", "template", now, now).
|
|
AddRow("id2", "550e8400-e29b-41d4-a716-446655440000", "user-sched", "*/5 * * * *", "UTC", "p2", true,
|
|
nil, nil, 0, "", "", "runtime", now, now))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
|
c.Request = httptest.NewRequest("GET", "/workspaces/550e8400-e29b-41d4-a716-446655440000/schedules", nil)
|
|
|
|
handler.List(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `"source":"template"`) {
|
|
t.Errorf(`response missing "source":"template": %s`, body)
|
|
}
|
|
if !strings.Contains(body, `"source":"runtime"`) {
|
|
t.Errorf(`response missing "source":"runtime": %s`, body)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHistory_IncludesErrorDetail — #152 problem B coverage. The history
|
|
// endpoint must surface error_detail from activity_logs so clients know
|
|
// why a cron run failed (not just that it failed). Writes a fake cron_run
|
|
// row via sqlmock with a non-empty error_detail and asserts it reaches
|
|
// the JSON response.
|
|
func TestHistory_IncludesErrorDetail(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
workspaceID := "550e8400-e29b-41d4-a716-446655440000"
|
|
scheduleID := "11111111-1111-1111-1111-111111111111"
|
|
now := time.Now()
|
|
|
|
cols := []string{"created_at", "duration_ms", "status", "error_detail", "request_body"}
|
|
mock.ExpectQuery("SELECT created_at, duration_ms, status").
|
|
WithArgs(workspaceID, scheduleID).
|
|
WillReturnRows(sqlmock.NewRows(cols).
|
|
AddRow(now, 4200, "error", "HTTP 500 — workspace agent OOM", `{"schedule_id":"`+scheduleID+`"}`).
|
|
AddRow(now, 1500, "ok", "", `{"schedule_id":"`+scheduleID+`"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{
|
|
{Key: "id", Value: workspaceID},
|
|
{Key: "scheduleId", Value: scheduleID},
|
|
}
|
|
c.Request = httptest.NewRequest("GET",
|
|
"/workspaces/"+workspaceID+"/schedules/"+scheduleID+"/history", nil)
|
|
|
|
handler.History(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `"error_detail":"HTTP 500 — workspace agent OOM"`) {
|
|
t.Errorf("history response missing populated error_detail: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"error_detail":""`) {
|
|
t.Errorf("history response missing empty error_detail on ok row: %s", body)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Errorf("sqlmock: %v", err)
|
|
}
|
|
}
|
|
|
|
// ==================== Health — issue #249 ====================
|
|
//
|
|
// GET /workspaces/:id/schedules/health is accessible to CanCommunicate peers
|
|
// without workspace bearer auth. The handler mirrors the A2A proxy's auth
|
|
// pattern: X-Workspace-ID + caller token + CanCommunicate gate.
|
|
|
|
const healthWorkspaceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
const healthCallerID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
|
|
// healthCols is the column set returned by the Health SELECT.
|
|
var healthCols = []string{"id", "name", "enabled", "last_run_at", "next_run_at", "run_count", "last_status", "last_error"}
|
|
|
|
// TestScheduleHealth_MissingCallerID_Rejected verifies that requests without
|
|
// X-Workspace-ID are rejected with 401 — anonymous peer reads are not allowed.
|
|
func TestScheduleHealth_MissingCallerID_Rejected(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
c.Request = httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for missing caller, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestScheduleHealth_SelfCall_Allowed verifies that when callerID == workspaceID
|
|
// (self-call) the request is allowed and health fields are returned without any
|
|
// CanCommunicate DB lookups.
|
|
func TestScheduleHealth_SelfCall_Allowed(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
// Self-call: no token check, no CanCommunicate queries.
|
|
// Expect only the health SELECT.
|
|
mock.ExpectQuery(`SELECT id, name, enabled, last_run_at, next_run_at, run_count, last_status, last_error\s+FROM workspace_schedules`).
|
|
WithArgs(healthWorkspaceID).
|
|
WillReturnRows(sqlmock.NewRows(healthCols).
|
|
AddRow("sched-1", "nightly", true, &now, &now, 42, "ok", ""))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
req := httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
req.Header.Set("X-Workspace-ID", healthWorkspaceID) // self-call
|
|
c.Request = req
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp []scheduleHealthResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if len(resp) != 1 || resp[0].ID != "sched-1" || resp[0].RunCount != 42 {
|
|
t.Errorf("unexpected health response: %+v", resp)
|
|
}
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestScheduleHealth_CanCommunicatePeer_LegacyNoToken verifies that a legacy
|
|
// peer (no live tokens on file for the caller) is grandfathered through the
|
|
// token check and can read health when CanCommunicate is satisfied.
|
|
func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
|
|
// 1. validateCallerToken: caller has zero live tokens → grandfather through.
|
|
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
|
WithArgs(healthCallerID).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
|
|
|
// 2. CanCommunicate: caller and target share the same parent (siblings → allowed).
|
|
mockCanCommunicate(mock, healthCallerID, healthWorkspaceID, true)
|
|
|
|
// 3. Health SELECT.
|
|
mock.ExpectQuery(`SELECT id, name, enabled, last_run_at, next_run_at, run_count, last_status, last_error\s+FROM workspace_schedules`).
|
|
WithArgs(healthWorkspaceID).
|
|
WillReturnRows(sqlmock.NewRows(healthCols).
|
|
AddRow("sched-2", "hourly", true, &now, &now, 7, "ok", ""))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
req := httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
req.Header.Set("X-Workspace-ID", healthCallerID)
|
|
c.Request = req
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp []scheduleHealthResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if len(resp) != 1 || resp[0].RunCount != 7 {
|
|
t.Errorf("unexpected response: %+v", resp)
|
|
}
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestScheduleHealth_AccessDenied_NonPeer verifies that a workspace which fails
|
|
// CanCommunicate (different org branch) receives 403 — not 401 or 500.
|
|
func TestScheduleHealth_AccessDenied_NonPeer(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
// 1. validateCallerToken: no live tokens → grandfather.
|
|
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
|
WithArgs(healthCallerID).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
|
|
|
// 2. CanCommunicate: different parents → denied.
|
|
mockCanCommunicate(mock, healthCallerID, healthWorkspaceID, false)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
req := httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
req.Header.Set("X-Workspace-ID", healthCallerID)
|
|
c.Request = req
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-peer, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestScheduleHealth_SystemCaller_Allowed verifies that system callers
|
|
// (webhook:*, system:*, test:*) bypass token + CanCommunicate checks.
|
|
func TestScheduleHealth_SystemCaller_Allowed(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
|
|
// No token check, no CanCommunicate queries — just the health SELECT.
|
|
mock.ExpectQuery(`SELECT id, name, enabled, last_run_at, next_run_at, run_count, last_status, last_error\s+FROM workspace_schedules`).
|
|
WithArgs(healthWorkspaceID).
|
|
WillReturnRows(sqlmock.NewRows(healthCols).
|
|
AddRow("sched-3", "weekly", false, nil, &now, 0, "", ""))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
req := httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
req.Header.Set("X-Workspace-ID", "system:monitor")
|
|
c.Request = req
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for system caller, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestScheduleHealth_NoPromptExposed verifies that the health response never
|
|
// includes prompt or cron_expr — only execution-state fields are returned.
|
|
func TestScheduleHealth_NoPromptExposed(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
|
|
// No token check, no CanCommunicate queries for system caller.
|
|
mock.ExpectQuery(`SELECT id, name, enabled, last_run_at, next_run_at, run_count, last_status, last_error\s+FROM workspace_schedules`).
|
|
WithArgs(healthWorkspaceID).
|
|
WillReturnRows(sqlmock.NewRows(healthCols).
|
|
AddRow("sched-4", "daily", true, &now, &now, 3, "ok", ""))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
req := httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
req.Header.Set("X-Workspace-ID", "system:test")
|
|
c.Request = req
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
rawBody := w.Body.String()
|
|
for _, forbidden := range []string{"prompt", "cron_expr", "timezone"} {
|
|
if strings.Contains(rawBody, forbidden) {
|
|
t.Errorf("health response must not contain %q field: %s", forbidden, rawBody)
|
|
}
|
|
}
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestScheduleHealth_DBError_Returns500 verifies that a DB failure on the health
|
|
// SELECT produces a 500, not a panic.
|
|
func TestScheduleHealth_DBError_Returns500(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewScheduleHandler()
|
|
|
|
// No token check, no CanCommunicate queries for system caller.
|
|
mock.ExpectQuery(`SELECT id, name, enabled, last_run_at, next_run_at, run_count, last_status, last_error\s+FROM workspace_schedules`).
|
|
WithArgs(healthWorkspaceID).
|
|
WillReturnError(sql.ErrConnDone)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: healthWorkspaceID}}
|
|
req := httptest.NewRequest("GET", "/workspaces/"+healthWorkspaceID+"/schedules/health", nil)
|
|
req.Header.Set("X-Workspace-ID", "system:test")
|
|
c.Request = req
|
|
|
|
handler.Health(c)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 on DB error, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("unmet expectations: %v", err)
|
|
}
|
|
}
|