molecule-core/platform/internal/handlers/schedules_test.go
Hongming Wang 35705274c9 fix(code-review): CanvasOrBearer fall-through, scheduler short(), activity spoof log + 6 new tests
Addresses self-review of the 10-PR batch merged earlier this session.
Splits the follow-ups into this Go-side PR and a later Python/docs PR.

## Fixes

1. wsauth_middleware.go CanvasOrBearer — invalid bearer now hard-rejects
   with 401 instead of falling through to the Origin check. Previous code
   let an attacker with an expired token + matching Origin bypass auth.
   Empty bearer still falls through to the Origin path (the intended
   canvas path).

2. scheduler.go short() helper — extracts safe UUID prefix truncation.
   Pre-existing unsafe [:12] and [:8] slices would panic on workspace IDs
   shorter than the bound. #115's new skip path had the bounds check;
   the happy-path log lines did not. One helper, three call sites.

3. activity.go security-event log on source_id spoof — #209 added the
   403 but the attempt was invisible to any auditor cron. Stable
   greppable log line with authed_workspace, body_source_id, client IP.

## New tests

- TestShort_helper — bounds-safety regression guard for the helper
- TestRecordSkipped_writesSkippedStatus — #115 coverage gap, exercises
  UPDATE + INSERT via sqlmock
- TestRecordSkipped_shortWorkspaceIDNoPanic — short-ID crash regression
- TestActivityHandler_Report_SourceIDSpoofRejected — #209 403 path
- TestActivityHandler_Report_MatchingSourceIDAccepted — non-spoof path
- TestHistory_IncludesErrorDetail — #152 problem B coverage

go test -race ./... green locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:48:25 -07:00

174 lines
6.6 KiB
Go

package handlers
import (
"bytes"
"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)
}
}