diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d04db9e2..e485b918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,109 @@ jobs: - run: npm ci - run: npm run build + e2e-api: + name: E2E API Smoke Test + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + # Credentials match .env.example (dev:dev) so local reproduction is + # identical to CI. POSTGRES_DB matches the default there too. + image: postgres:16 + env: + POSTGRES_USER: dev + POSTGRES_PASSWORD: dev + POSTGRES_DB: molecule + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U dev" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://dev:dev@localhost:5432/molecule?sslmode=disable + REDIS_URL: redis://localhost:6379 + PORT: "8080" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + cache: true + cache-dependency-path: platform/go.sum + - name: Build platform + working-directory: platform + run: go build -o platform-server ./cmd/server + - name: Start platform (background) + working-directory: platform + run: | + ./platform-server > platform.log 2>&1 & + echo $! > platform.pid + - name: Wait for /health + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health > /dev/null; then + echo "Platform up after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Platform did not become healthy in 30s" + cat platform/platform.log || true + exit 1 + - name: Assert migrations applied + # Migrations auto-run at platform boot. Fail fast if they silently + # didn't — catches future migration-author mistakes (e.g. a new + # privileged op Postgres "dev" can't execute) before the E2E run. + # Uses docker exec into the service container's own psql — avoids + # a 10-20s apt-install step in the runner. + run: | + pg_container=$(docker ps --filter "ancestor=postgres:16" --format "{{.ID}}" | head -1) + if [ -z "$pg_container" ]; then + echo "::error::Could not find postgres service container" + exit 1 + fi + tables=$(docker exec "$pg_container" psql -U dev -d molecule -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'") + if [ "$tables" != "1" ]; then + echo "::error::Migrations did not apply — 'workspaces' table missing" + cat platform/platform.log || true + exit 1 + fi + echo "Migrations OK (workspaces table present)" + - name: Run E2E API tests + run: bash tests/e2e/test_api.sh + - name: Dump platform log on failure + if: failure() + run: cat platform/platform.log || true + - name: Stop platform + if: always() + run: | + if [ -f platform/platform.pid ]; then + kill "$(cat platform/platform.pid)" 2>/dev/null || true + fi + + shellcheck: + name: Shellcheck (E2E scripts) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run shellcheck on tests/e2e/*.sh + uses: ludeeus/action-shellcheck@master + env: + SHELLCHECK_OPTS: --severity=warning + with: + scandir: tests/e2e + python-lint: name: Python Lint & Test runs-on: ubuntu-latest diff --git a/canvas/src/components/ConfirmDialog.tsx b/canvas/src/components/ConfirmDialog.tsx index 339376cf..13aeedc5 100644 --- a/canvas/src/components/ConfirmDialog.tsx +++ b/canvas/src/components/ConfirmDialog.tsx @@ -12,7 +12,11 @@ interface Props { onConfirm: () => void; onCancel: () => void; // Hide the Cancel button for single-action info toasts. - // onCancel is still invoked on Esc / backdrop-click. + // onCancel is still invoked on Esc / backdrop-click, so when using this + // dialog as a simple info toast the caller should pass the SAME handler + // for both `onConfirm` and `onCancel` — otherwise dismissing via Esc / + // backdrop click will run different logic than clicking the OK button, + // which is almost never what you want for an info dialog. singleButton?: boolean; } diff --git a/canvas/src/components/ErrorBoundary.tsx b/canvas/src/components/ErrorBoundary.tsx index 51729063..96766d08 100644 --- a/canvas/src/components/ErrorBoundary.tsx +++ b/canvas/src/components/ErrorBoundary.tsx @@ -44,7 +44,8 @@ export class ErrorBoundary extends React.Component< // Copy error info to clipboard for manual reporting (button click is its // own affordance — no native alert needed). On clipboard failure the // console.error above still surfaces the report. - void navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2)).catch(() => {}); + void navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2)) + .catch((e) => console.warn("clipboard write failed:", e)); }; render() { diff --git a/canvas/src/components/__tests__/ConfirmDialog.test.tsx b/canvas/src/components/__tests__/ConfirmDialog.test.tsx new file mode 100644 index 00000000..d58b1dd7 --- /dev/null +++ b/canvas/src/components/__tests__/ConfirmDialog.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { ConfirmDialog } from "../ConfirmDialog"; + +afterEach(() => { + cleanup(); +}); + +describe("ConfirmDialog singleButton prop", () => { + it("renders Cancel button by default", () => { + render( + + ); + expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Confirm" })).toBeTruthy(); + }); + + it("hides Cancel button when singleButton=true", () => { + render( + + ); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + expect(screen.getByRole("button", { name: "Confirm" })).toBeTruthy(); + }); + + it("singleButton: onCancel still fires on Escape", () => { + const onCancel = vi.fn(); + render( + + ); + fireEvent.keyDown(window, { key: "Escape" }); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("singleButton: onCancel still fires on backdrop click", () => { + const onCancel = vi.fn(); + const { container } = render( + + ); + // Backdrop is the div with bg-black/60 class, rendered into document.body via portal + const backdrop = document.querySelector(".bg-black\\/60") as HTMLElement; + expect(backdrop).toBeTruthy(); + void container; + fireEvent.click(backdrop); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("singleButton: onConfirm fires on button click", () => { + const onConfirm = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/platform/internal/handlers/a2a_proxy_test.go b/platform/internal/handlers/a2a_proxy_test.go index 4b846d5d..7de89c31 100644 --- a/platform/internal/handlers/a2a_proxy_test.go +++ b/platform/internal/handlers/a2a_proxy_test.go @@ -813,3 +813,350 @@ func TestNormalizeA2APayload_MissingMethodReturnsEmpty(t *testing.T) { t.Errorf("expected empty method, got %q", method) } } + +// --- resolveAgentURL direct unit tests --- + +func TestResolveAgentURL_CacheHit(t *testing.T) { + setupTestDB(t) + mr := setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + mr.Set("ws:ws-cached:url", "http://cached.example/a2a") + + url, perr := handler.resolveAgentURL(context.Background(), "ws-cached") + if perr != nil { + t.Fatalf("unexpected error: %+v", perr) + } + if url != "http://cached.example/a2a" { + t.Errorf("got %q, want cached URL", url) + } +} + +func TestResolveAgentURL_CacheMissDBHit(t *testing.T) { + mock := setupTestDB(t) + mr := setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id ="). + WithArgs("ws-dbhit"). + WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow("http://dbhit.example", "online")) + + url, perr := handler.resolveAgentURL(context.Background(), "ws-dbhit") + if perr != nil { + t.Fatalf("unexpected error: %+v", perr) + } + if url != "http://dbhit.example" { + t.Errorf("got %q, want http://dbhit.example", url) + } + // Verify cached now + if v, err := mr.Get("ws:ws-dbhit:url"); err != nil || v != "http://dbhit.example" { + t.Errorf("expected Redis cache populated; got v=%q err=%v", v, err) + } +} + +func TestResolveAgentURL_WorkspaceNotFound(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id ="). + WithArgs("ws-missing"). + WillReturnError(sql.ErrNoRows) + + _, perr := handler.resolveAgentURL(context.Background(), "ws-missing") + if perr == nil { + t.Fatal("expected error, got nil") + } + if perr.Status != http.StatusNotFound { + t.Errorf("got status %d, want 404", perr.Status) + } +} + +func TestResolveAgentURL_NullURL(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id ="). + WithArgs("ws-nullurl"). + WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow(nil, "provisioning")) + + _, perr := handler.resolveAgentURL(context.Background(), "ws-nullurl") + if perr == nil { + t.Fatal("expected error, got nil") + } + if perr.Status != http.StatusServiceUnavailable { + t.Errorf("got status %d, want 503", perr.Status) + } +} + +func TestResolveAgentURL_DockerRewrite(t *testing.T) { + // provisioner.InternalURL is called when platformInDocker && URL begins + // with http://127.0.0.1:. We don't have a real *Provisioner so the + // rewrite path requires h.provisioner != nil. Since we can't easily + // construct a provisioner, verify rewrite does NOT happen when + // provisioner is nil (guard clause). The rewrite branch itself is + // covered by TestResolveAgentURL_DockerRewrite_NilProvisionerNoRewrite. + mr := setupTestRedis(t) + setupTestDB(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + mr.Set("ws:ws-dock:url", "http://127.0.0.1:55555") + + restore := setPlatformInDockerForTest(true) + defer restore() + + url, perr := handler.resolveAgentURL(context.Background(), "ws-dock") + if perr != nil { + t.Fatalf("unexpected error: %+v", perr) + } + // nil provisioner → no rewrite + if url != "http://127.0.0.1:55555" { + t.Errorf("with nil provisioner, URL must not be rewritten; got %q", url) + } +} + +// --- dispatchA2A direct unit tests --- + +func TestDispatchA2A_BuildRequestError(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + // Malformed URL causes http.NewRequestWithContext to fail. + _, cancel, err := handler.dispatchA2A(context.Background(), "http://%%badhost", []byte("{}"), "") + if cancel != nil { + cancel() + } + if err == nil { + t.Fatal("expected error, got nil") + } + if _, ok := err.(*proxyDispatchBuildError); !ok { + t.Errorf("expected *proxyDispatchBuildError, got %T: %v", err, err) + } +} + +func TestDispatchA2A_CanvasTimeout(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + // Agent that responds OK — we just want the cancel func. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + resp, cancel, err := handler.dispatchA2A(context.Background(), srv.URL, []byte(`{}`), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + if cancel == nil { + t.Fatal("canvas caller (empty callerID) must set a timeout + return cancel") + } + cancel() // restore +} + +func TestDispatchA2A_AgentTimeout(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + resp, cancel, err := handler.dispatchA2A(context.Background(), srv.URL, []byte(`{}`), "ws-caller") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + if cancel == nil { + t.Fatal("agent-to-agent caller must set a timeout + return cancel") + } + cancel() +} + +func TestDispatchA2A_ContextDeadline_NoCancelAdded(t *testing.T) { + // When ctx already has a deadline, dispatchA2A must NOT layer its own + // timeout (cancel should be nil). + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + ctx, ctxCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer ctxCancel() + + resp, cancel, err := handler.dispatchA2A(ctx, srv.URL, []byte(`{}`), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + if cancel != nil { + t.Error("cancel should be nil when ctx already has a deadline") + cancel() + } +} + +// --- handleA2ADispatchError --- + +func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + // No workspace row expected — maybeMarkContainerDead with nil + // provisioner short-circuits, and activity-log insert is suppressed + // (logActivity=false). + _, _, perr := handler.handleA2ADispatchError( + context.Background(), "ws-dl", "", []byte("{}"), "message/send", + context.DeadlineExceeded, 1, false, + ) + if perr == nil { + t.Fatal("expected error, got nil") + } + // DeadlineExceeded is classified as upstream-busy → 503 with Retry-After. + if perr.Status != http.StatusServiceUnavailable { + t.Errorf("got status %d, want 503", perr.Status) + } + if perr.Headers["Retry-After"] == "" { + t.Error("expected Retry-After header on busy-503 shape") + } +} + +func TestHandleA2ADispatchError_BuildError(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + buildErr := &proxyDispatchBuildError{err: fmt.Errorf("bad url")} + _, _, perr := handler.handleA2ADispatchError( + context.Background(), "ws-x", "", []byte("{}"), "message/send", buildErr, 1, false, + ) + if perr == nil || perr.Status != http.StatusInternalServerError { + t.Errorf("expected 500, got %+v", perr) + } +} + +func TestHandleA2ADispatchError_GenericReturns502(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + _, _, perr := handler.handleA2ADispatchError( + context.Background(), "ws-x", "", []byte("{}"), "message/send", + fmt.Errorf("no such host"), 1, false, + ) + if perr == nil || perr.Status != http.StatusBadGateway { + t.Errorf("expected 502, got %+v", perr) + } +} + +// --- maybeMarkContainerDead --- + +// Nil provisioner → short-circuits false. +func TestMaybeMarkContainerDead_NilProvisioner(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-nilprov"). + WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph")) + + if got := handler.maybeMarkContainerDead(context.Background(), "ws-nilprov"); got { + t.Error("expected false when provisioner is nil") + } +} + +// external runtime → false regardless of provisioner. +func TestMaybeMarkContainerDead_ExternalRuntime(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery(`SELECT COALESCE\(runtime, 'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-ext"). + WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external")) + + if got := handler.maybeMarkContainerDead(context.Background(), "ws-ext"); got { + t.Error("expected false for external runtime") + } +} + +// --- logA2AFailure / logA2ASuccess smoke tests --- +// These helpers spawn a detached goroutine that calls LogActivity, which +// inserts into activity_logs. We can't easily sync on the goroutine via +// sqlmock (done order isn't guaranteed), so we only assert the function +// returns without panicking and makes the expected DB calls. + +func TestLogA2AFailure_Smoke(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + // Sync workspace-name lookup (called in the caller goroutine). + mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`). + WithArgs("ws-fail"). + WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Fail Target")) + // Async INSERT from the detached goroutine. MatchExpectationsInOrder=true + // by default, but the goroutine runs after the sync query above. + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + handler.logA2AFailure(context.Background(), "ws-fail", "", []byte(`{}`), "message/send", fmt.Errorf("boom"), 42) + time.Sleep(80 * time.Millisecond) +} + +func TestLogA2AFailure_EmptyNameFallback(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + // Empty name from DB → summary uses the workspaceID as the name. + mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`). + WithArgs("ws-noname"). + WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("")) + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + handler.logA2AFailure(context.Background(), "ws-noname", "", []byte(`{}`), "message/send", fmt.Errorf("boom"), 1) + time.Sleep(80 * time.Millisecond) +} + +func TestLogA2ASuccess_Smoke(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`). + WithArgs("ws-ok"). + WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("OK Target")) + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + handler.logA2ASuccess(context.Background(), "ws-ok", "", []byte(`{}`), []byte(`{"result":"x"}`), "message/send", 200, 10) + time.Sleep(80 * time.Millisecond) +} + +// Error-status path (>=400) records an "error" status in activity_logs. +func TestLogA2ASuccess_ErrorStatus(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`). + WithArgs("ws-err"). + WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("")) + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + // callerID != "" also means no A2A_RESPONSE broadcast. + handler.logA2ASuccess(context.Background(), "ws-err", "ws-caller", []byte(`{}`), []byte(`{}`), "message/send", 500, 10) + time.Sleep(80 * time.Millisecond) +} diff --git a/platform/internal/handlers/activity_test.go b/platform/internal/handlers/activity_test.go index d6ca9d15..1780be3b 100644 --- a/platform/internal/handlers/activity_test.go +++ b/platform/internal/handlers/activity_test.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -215,3 +216,186 @@ func TestActivityReport_RejectsUnknownType(t *testing.T) { t.Errorf("error message should list valid types including memory_write; got %s", w.Body.String()) } } + +// ==================== Direct unit tests for SessionSearch helpers ==================== + +// --- parseSessionSearchParams --- + +func TestParseSessionSearchParams_Defaults(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + q, limit := parseSessionSearchParams(c) + if q != "" { + t.Errorf("expected empty q, got %q", q) + } + if limit != 50 { + t.Errorf("expected default limit 50, got %d", limit) + } +} + +func TestParseSessionSearchParams_CustomLimit(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x?q=foo&limit=75", nil) + + q, limit := parseSessionSearchParams(c) + if q != "foo" { + t.Errorf("expected q=foo, got %q", q) + } + if limit != 75 { + t.Errorf("expected limit=75, got %d", limit) + } +} + +func TestParseSessionSearchParams_LimitCappedAt200(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x?limit=9999", nil) + + _, limit := parseSessionSearchParams(c) + if limit != 200 { + t.Errorf("expected cap 200, got %d", limit) + } +} + +func TestParseSessionSearchParams_InvalidLimitUsesDefault(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x?limit=abc", nil) + + _, limit := parseSessionSearchParams(c) + if limit != 50 { + t.Errorf("expected default on invalid, got %d", limit) + } +} + +// --- buildSessionSearchQuery --- + +func TestBuildSessionSearchQuery_NoFilters(t *testing.T) { + sqlQuery, args := buildSessionSearchQuery("ws-1", "", 50) + if strings.Contains(sqlQuery, "ILIKE") { + t.Error("expected no ILIKE when query empty") + } + if len(args) != 2 || args[0] != "ws-1" || args[1] != 50 { + t.Errorf("unexpected args: %v", args) + } +} + +func TestBuildSessionSearchQuery_WithQuery(t *testing.T) { + sqlQuery, args := buildSessionSearchQuery("ws-1", "foo", 25) + if !strings.Contains(sqlQuery, "ILIKE") { + t.Error("expected ILIKE when query provided") + } + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d: %v", len(args), args) + } + if args[1] != "%foo%" { + t.Errorf("expected LIKE pattern, got %v", args[1]) + } + if args[2] != 25 { + t.Errorf("expected limit=25, got %v", args[2]) + } +} + +// --- scanSessionSearchRows --- + +// fakeRows implements the minimal rows interface scanSessionSearchRows expects. +type fakeRows struct { + data [][]interface{} + i int + err error +} + +func (f *fakeRows) Next() bool { return f.i < len(f.data) } +func (f *fakeRows) Scan(dest ...interface{}) error { + row := f.data[f.i] + f.i++ + for i, v := range row { + switch d := dest[i].(type) { + case *string: + *d = v.(string) + case *[]byte: + if v == nil { + *d = nil + } else { + *d = v.([]byte) + } + case *time.Time: + *d = v.(time.Time) + } + } + return nil +} +func (f *fakeRows) Err() error { return f.err } + +func TestScanSessionSearchRows_EmptyRows(t *testing.T) { + items, err := scanSessionSearchRows(&fakeRows{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 0 { + t.Errorf("expected empty result, got %d", len(items)) + } +} + +func TestScanSessionSearchRows_MultipleRows(t *testing.T) { + now := time.Now() + rows := &fakeRows{ + data: [][]interface{}{ + {"activity", "a1", "ws-1", "task_update", "hello", "POST", "ok", []byte(`{"x":1}`), []byte(`{"y":2}`), now}, + {"memory", "m1", "ws-1", "TEAM", "note", "", "", []byte(nil), []byte(nil), now}, + }, + } + items, err := scanSessionSearchRows(rows) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0]["kind"] != "activity" { + t.Errorf("first row kind: %v", items[0]["kind"]) + } + if items[0]["request_body"] == nil { + t.Error("expected request_body present on activity row") + } + if _, has := items[1]["request_body"]; has { + t.Error("memory row should not have request_body (nil bytes)") + } +} + +// scanErrorRows returns a Scan error on the first row to cover the +// log-and-skip branch in scanSessionSearchRows. +type scanErrorRows struct { + called bool +} + +func (s *scanErrorRows) Next() bool { + if !s.called { + s.called = true + return true + } + return false +} +func (s *scanErrorRows) Scan(dest ...interface{}) error { return fmt.Errorf("scan bad") } +func (s *scanErrorRows) Err() error { return nil } + +func TestScanSessionSearchRows_ScanErrorSkipped(t *testing.T) { + items, err := scanSessionSearchRows(&scanErrorRows{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 0 { + t.Errorf("expected 0 items (scan error skipped), got %d", len(items)) + } +} + +func TestScanSessionSearchRows_RowsErrPropagates(t *testing.T) { + f := &fakeRows{err: fmt.Errorf("boom")} + _, err := scanSessionSearchRows(f) + if err == nil { + t.Fatal("expected error to propagate") + } +} diff --git a/platform/internal/handlers/delegation.go b/platform/internal/handlers/delegation.go index bd53daff..7edaab65 100644 --- a/platform/internal/handlers/delegation.go +++ b/platform/internal/handlers/delegation.go @@ -158,9 +158,13 @@ func lookupIdempotentDelegation(ctx context.Context, c *gin.Context, sourceID, i type insertDelegationOutcome int const ( + // insertOutcomeUnknown — zero-value sentinel; should never be returned + // by insertDelegationRow. Exists so that an uninitialized + // insertDelegationOutcome value doesn't silently alias a real outcome. + insertOutcomeUnknown insertDelegationOutcome = iota // insertOK — row stored; caller continues with dispatch and does NOT // surface a tracking warning. - insertOK insertDelegationOutcome = iota + insertOK // insertHandledByIdempotent — a concurrent idempotent request took the // slot; the winner's JSON response is already written and the caller // MUST return without further writes. diff --git a/platform/internal/handlers/delegation_test.go b/platform/internal/handlers/delegation_test.go index 3e99063a..e9e8ca69 100644 --- a/platform/internal/handlers/delegation_test.go +++ b/platform/internal/handlers/delegation_test.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -646,3 +647,200 @@ func TestDelegate_IdempotentRaceUniqueViolationReturnsExisting(t *testing.T) { t.Errorf("expected idempotent_hit=true on race resolution, got %v", resp["idempotent_hit"]) } } + +// ==================== Direct unit tests for extracted helpers ==================== + +// --- bindDelegateRequest --- + +func TestBindDelegateRequest_ValidJSON(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{"target_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","task":"hi"}` + c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + var out delegateRequest + if err := bindDelegateRequest(c, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Task != "hi" { + t.Errorf("got task %q", out.Task) + } +} + +func TestBindDelegateRequest_InvalidJSON(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString("not json")) + c.Request.Header.Set("Content-Type", "application/json") + + var out delegateRequest + if err := bindDelegateRequest(c, &out); err == nil { + t.Fatal("expected error") + } + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestBindDelegateRequest_InvalidTargetUUID(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/x", bytes.NewBufferString(`{"target_id":"not-uuid","task":"x"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + var out delegateRequest + if err := bindDelegateRequest(c, &out); err == nil { + t.Fatal("expected error") + } + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// --- lookupIdempotentDelegation --- + +func TestLookupIdempotentDelegation_NoKey(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", ""); hit { + t.Error("empty key should never hit") + } +} + +func TestLookupIdempotentDelegation_NoMatch(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id"). + WithArgs("ws-x", "some-key"). + WillReturnError(fmt.Errorf("sql: no rows")) + + if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", "some-key"); hit { + t.Error("expected false when no row found") + } +} + +func TestLookupIdempotentDelegation_FailedRowDeleted(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id"). + WithArgs("ws-x", "k"). + WillReturnRows(sqlmock.NewRows([]string{"delegation_id", "status", "target_id"}). + AddRow("old", "failed", "ws-target")) + mock.ExpectExec("DELETE FROM activity_logs"). + WithArgs("ws-x", "k"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", "k"); hit { + t.Error("failed row should be released, returning false") + } +} + +func TestLookupIdempotentDelegation_ExistingPending(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id"). + WithArgs("ws-x", "k"). + WillReturnRows(sqlmock.NewRows([]string{"delegation_id", "status", "target_id"}). + AddRow("del-123", "pending", "ws-target")) + + if hit := lookupIdempotentDelegation(context.Background(), c, "ws-x", "k"); !hit { + t.Fatal("expected hit=true") + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["delegation_id"] != "del-123" || resp["idempotent_hit"] != true { + t.Errorf("unexpected response: %v", resp) + } +} + +// --- insertDelegationRow --- + +func TestInsertDelegationRow_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + out := insertDelegationRow(context.Background(), c, + "ws-src", + delegateRequest{TargetID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Task: "hi"}, + "del-1") + if out != insertOK { + t.Errorf("got %v, want insertOK", out) + } +} + +func TestInsertDelegationRow_IdempotentConflict(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnError(fmt.Errorf("pq: duplicate key value violates unique constraint")) + mock.ExpectQuery("SELECT request_body->>'delegation_id', status"). + WithArgs("ws-src", "k1"). + WillReturnRows(sqlmock.NewRows([]string{"delegation_id", "status"}). + AddRow("winner-del", "pending")) + + out := insertDelegationRow(context.Background(), c, + "ws-src", + delegateRequest{TargetID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Task: "hi", IdempotencyKey: "k1"}, + "loser-del") + if out != insertHandledByIdempotent { + t.Errorf("got %v, want insertHandledByIdempotent", out) + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestInsertDelegationRow_OtherDBError(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // Without IdempotencyKey, the follow-up SELECT is skipped — any insert + // error falls straight to insertTrackingUnavailable. + mock.ExpectExec("INSERT INTO activity_logs"). + WillReturnError(fmt.Errorf("connection refused")) + + out := insertDelegationRow(context.Background(), c, + "ws-src", + delegateRequest{TargetID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Task: "hi"}, + "del-x") + if out != insertTrackingUnavailable { + t.Errorf("got %v, want insertTrackingUnavailable", out) + } +} + +// Verify the enum zero-value sentinel is defined and distinct from real outcomes. +func TestInsertDelegationOutcome_ZeroValueIsUnknown(t *testing.T) { + var zero insertDelegationOutcome + if zero != insertOutcomeUnknown { + t.Errorf("zero-value insertDelegationOutcome should equal insertOutcomeUnknown") + } + if insertOutcomeUnknown == insertOK { + t.Errorf("insertOutcomeUnknown must not collide with insertOK") + } +} diff --git a/platform/internal/handlers/discovery_test.go b/platform/internal/handlers/discovery_test.go index ae6bf8c3..5b16738a 100644 --- a/platform/internal/handlers/discovery_test.go +++ b/platform/internal/handlers/discovery_test.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "context" "database/sql" "encoding/json" "net/http" @@ -324,3 +325,296 @@ func TestCheckAccess_SameWorkspace(t *testing.T) { t.Errorf("expected allowed=true for same workspace, got %v", resp["allowed"]) } } + +// ==================== Direct unit tests for extracted helpers ==================== + +// --- discoverWorkspacePeer --- + +func TestDiscoverWorkspacePeer_Online(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + // name/runtime lookup → non-external + mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-online"). + WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Target", "langgraph")) + // No cached internal URL → DB status lookup → online + mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`). + WithArgs("ws-online"). + WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("online")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-online") + + 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["id"] != "ws-online" || resp["url"] == "" { + t.Errorf("unexpected body: %v", resp) + } +} + +func TestDiscoverWorkspacePeer_NotFound(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-missing"). + WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("", "langgraph")) + mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`). + WithArgs("ws-missing"). + WillReturnError(sql.ErrNoRows) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-missing") + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDiscoverWorkspacePeer_ExternalRuntime_HandledByExternalURL(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-ext"). + WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Ext", "external")) + // writeExternalWorkspaceURL's two queries + mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`). + WithArgs("ws-ext"). + WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://external.example")) + mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-caller"). + WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-ext") + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestDiscoverWorkspacePeer_CachedInternalURLHit(t *testing.T) { + mock := setupTestDB(t) + mr := setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-cached"). + WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Cached", "langgraph")) + mr.Set("ws:ws-cached:internal_url", "http://ws-cached:8000") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-cached") + 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["url"] != "http://ws-cached:8000" { + t.Errorf("expected cached internal URL, got %v", resp["url"]) + } +} + +func TestDiscoverWorkspacePeer_NotReachable(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(name,''\), COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-paused"). + WillReturnRows(sqlmock.NewRows([]string{"name", "runtime"}).AddRow("Paused", "langgraph")) + mock.ExpectQuery(`SELECT status FROM workspaces WHERE id =`). + WithArgs("ws-paused"). + WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("paused")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverWorkspacePeer(context.Background(), c, "ws-caller", "ws-paused") + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String()) + } +} + +// --- writeExternalWorkspaceURL --- + +func TestWriteExternalWorkspaceURL_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`). + WithArgs("ws-ext"). + WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://external.example/a2a")) + mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-caller"). + WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + handled := writeExternalWorkspaceURL(context.Background(), c, "ws-caller", "ws-ext", "External WS") + if !handled { + t.Error("expected handled=true when URL present") + } + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["url"] != "http://external.example/a2a" { + t.Errorf("got url %v", resp["url"]) + } + if resp["name"] != "External WS" { + t.Errorf("got name %v", resp["name"]) + } +} + +func TestWriteExternalWorkspaceURL_NoURL_FallsThrough(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`). + WithArgs("ws-ext"). + WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + if handled := writeExternalWorkspaceURL(context.Background(), c, "ws-caller", "ws-ext", ""); handled { + t.Error("expected handled=false when URL empty") + } +} + +func TestWriteExternalWorkspaceURL_RewritesLocalhostForDockerCaller(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT COALESCE\(url,''\) FROM workspaces WHERE id =`). + WithArgs("ws-ext"). + WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://127.0.0.1:8000/a2a")) + // non-external caller runtime → rewrite enabled + mock.ExpectQuery(`SELECT COALESCE\(runtime,'langgraph'\) FROM workspaces WHERE id =`). + WithArgs("ws-caller"). + WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + writeExternalWorkspaceURL(context.Background(), c, "ws-caller", "ws-ext", "") + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["url"] != "http://host.docker.internal:8000/a2a" { + t.Errorf("expected 127.0.0.1 → host.docker.internal rewrite, got %v", resp["url"]) + } +} + +// --- discoverHostPeer smoke (currently unreachable via Discover) --- + +func TestDiscoverHostPeer_Smoke_CacheHit(t *testing.T) { + setupTestDB(t) + mr := setupTestRedis(t) + mr.Set("ws:ws-host:url", "http://hostcache.example") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverHostPeer(context.Background(), c, "ws-host") + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestDiscoverHostPeer_Smoke_NotFound(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`). + WithArgs("ws-none"). + WillReturnError(sql.ErrNoRows) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverHostPeer(context.Background(), c, "ws-none") + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestDiscoverHostPeer_Smoke_DBError(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`). + WithArgs("ws-err"). + WillReturnError(sql.ErrConnDone) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverHostPeer(context.Background(), c, "ws-err") + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func TestDiscoverHostPeer_Smoke_ForwardedChainAndNullURL(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`). + WithArgs("ws-a"). + WillReturnRows(sqlmock.NewRows([]string{"url", "status", "forwarded_to"}).AddRow(nil, "online", "ws-b")) + mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`). + WithArgs("ws-b"). + WillReturnRows(sqlmock.NewRows([]string{"url", "status", "forwarded_to"}).AddRow(nil, "offline", nil)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverHostPeer(context.Background(), c, "ws-a") + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503 for null URL after chain, got %d", w.Code) + } +} + +func TestDiscoverHostPeer_Smoke_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT url, status, forwarded_to FROM workspaces WHERE id =`). + WithArgs("ws-ok"). + WillReturnRows(sqlmock.NewRows([]string{"url", "status", "forwarded_to"}).AddRow("http://ok.example", "online", nil)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/x", nil) + + discoverHostPeer(context.Background(), c, "ws-ok") + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/tests/e2e/_extract_token.py b/tests/e2e/_extract_token.py new file mode 100755 index 00000000..bf6b78be --- /dev/null +++ b/tests/e2e/_extract_token.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Stdin: JSON response from POST /registry/register. +Stdout: the auth_token value, or empty string. +Stderr: diagnostic when the response is unparseable or missing a token. + +Exit code is always 0 — the empty string on stdout is how callers +distinguish "no token issued" (legitimate on re-registration) from +success. The warning on stderr surfaces the no-token case so it +stops masking downstream "missing workspace auth token" 401s. +""" +import json +import sys + +try: + data = json.load(sys.stdin) +except (json.JSONDecodeError, ValueError) as e: + sys.stderr.write(f"e2e_extract_token: invalid JSON response ({e})\n") + print("") + raise SystemExit(0) + +token = data.get("auth_token", "") +if not token: + sys.stderr.write("e2e_extract_token: response contained no auth_token field\n") +print(token) diff --git a/tests/e2e/_lib.sh b/tests/e2e/_lib.sh new file mode 100755 index 00000000..ff3bf996 --- /dev/null +++ b/tests/e2e/_lib.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Common E2E helpers. Source this from every tests/e2e/*.sh. +# +# Usage: +# source "$(dirname "$0")/_lib.sh" +# e2e_cleanup_all_workspaces # call at top of script +# TOKEN=$(echo "$register_response" | e2e_extract_token) +# +# BASE defaults to http://localhost:8080. Set it before sourcing to override. + +: "${BASE:=http://localhost:8080}" +export BASE + +# Emit the auth_token from a /registry/register response on stdout. +# See _extract_token.py for the exact semantics. +e2e_extract_token() { + python3 "$(dirname "${BASH_SOURCE[0]}")/_extract_token.py" +} + +# Delete every workspace currently on the platform. Use at the top of a +# script so count-based assertions are reproducible across runs. +e2e_cleanup_all_workspaces() { + for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys +try: + [print(w['id']) for w in json.load(sys.stdin)] +except Exception: + pass" 2>/dev/null); do + curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true + done +} diff --git a/tests/e2e/test_activity_e2e.sh b/tests/e2e/test_activity_e2e.sh index 7954bb83..12c673aa 100755 --- a/tests/e2e/test_activity_e2e.sh +++ b/tests/e2e/test_activity_e2e.sh @@ -3,11 +3,15 @@ # Requires: platform running on localhost:8080 with at least one online agent. set -euo pipefail -BASE="http://localhost:8080" +source "$(dirname "$0")/_lib.sh" # sets BASE default PASS=0 FAIL=0 TIMEOUT="${A2A_TIMEOUT:-120}" +# Phase 30.1: heartbeats require a bearer token. Re-register the +# detected online agent to obtain one for our test-harness heartbeats. +AGENT_TOKEN="" + check() { local desc="$1" local expected="$2" @@ -58,15 +62,20 @@ if [ -z "$AGENT_ID" ]; then fi AGENT_NAME=$(curl -s "$BASE/workspaces/$AGENT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])") +AGENT_URL=$(curl -s "$BASE/workspaces/$AGENT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin).get('url') or 'http://localhost:9999')") echo "Using agent: $AGENT_NAME ($AGENT_ID)" echo "" +# Re-register to capture a bearer token for heartbeat tests (Phase 30.1). +# Re-registration is idempotent; the agent's own token continues to work +# alongside this one. +RREG=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ + -d "{\"id\":\"$AGENT_ID\",\"url\":\"$AGENT_URL\",\"agent_card\":{\"name\":\"$AGENT_NAME\",\"skills\":[]}}") +AGENT_TOKEN=$(echo "$RREG" | e2e_extract_token) + # ---------- A2A Communication Logging ---------- echo "--- A2A Communication Logging ---" -# Clear any existing activity by noting the count -BEFORE_COUNT=$(curl -s "$BASE/workspaces/$AGENT_ID/activity" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))") - # Test 1: Send A2A message and verify activity is logged R=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$AGENT_ID/a2a" \ -H "Content-Type: application/json" \ @@ -84,7 +93,7 @@ check "A2A message/send returns response" 'result' "$R" # Test 2: Activity log should have a new a2a_receive entry # Retry up to 3s for the async LogActivity goroutine to complete AFTER_COUNT=0 -for i in 1 2 3 4 5 6; do +for _ in 1 2 3 4 5 6; do R=$(curl -s "$BASE/workspaces/$AGENT_ID/activity?type=a2a_receive") AFTER_COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))") [ "$AFTER_COUNT" -gt "0" ] && break @@ -172,7 +181,7 @@ echo "" echo "--- Current Task Visibility ---" # Test 14: Set current_task via heartbeat -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $AGENT_TOKEN" \ -d "{\"workspace_id\":\"$AGENT_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":2,\"uptime_seconds\":600,\"current_task\":\"Analyzing quarterly report\"}") check "Heartbeat with current_task" '"status":"ok"' "$R" @@ -185,7 +194,7 @@ R=$(curl -s "$BASE/workspaces") check "current_task in workspace list" 'Analyzing quarterly report' "$R" # Test 17: Update current_task to new value -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $AGENT_TOKEN" \ -d "{\"workspace_id\":\"$AGENT_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":1,\"uptime_seconds\":700,\"current_task\":\"Generating summary\"}") check "Heartbeat update task" '"status":"ok"' "$R" @@ -194,7 +203,7 @@ check "current_task updated" '"current_task":"Generating summary"' "$R" check_not "old task cleared" 'quarterly report' "$(curl -s "$BASE/workspaces/$AGENT_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['current_task'])")" # Test 18: Clear current_task -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $AGENT_TOKEN" \ -d "{\"workspace_id\":\"$AGENT_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":800,\"current_task\":\"\"}") check "Heartbeat clear task" '"status":"ok"' "$R" diff --git a/tests/e2e/test_api.sh b/tests/e2e/test_api.sh index 2617009d..dd02fc1b 100644 --- a/tests/e2e/test_api.sh +++ b/tests/e2e/test_api.sh @@ -1,10 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -BASE="http://localhost:8080" +source "$(dirname "$0")/_lib.sh" # sets BASE default PASS=0 FAIL=0 +# Phase 30.1: tokens issued on first /registry/register must be echoed +# back on every subsequent /registry/heartbeat + /registry/update-card +# as `Authorization: Bearer `. Capture them here. +ECHO_TOKEN="" +SUM_TOKEN="" + +# Pre-test cleanup: remove any workspaces left over from prior runs so +# count-based assertions ("empty", "count=2") are reproducible. +e2e_cleanup_all_workspaces + check() { local desc="$1" local expected="$2" @@ -55,11 +65,13 @@ check "GET /workspaces/:id (agent_card null)" '"agent_card":null' "$R" R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$ECHO_ID\",\"url\":\"http://localhost:8001\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}") check "POST /registry/register (echo)" '"status":"registered"' "$R" +ECHO_TOKEN=$(echo "$R" | e2e_extract_token) # Test 8: Register summarizer R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$SUM_ID\",\"url\":\"http://localhost:8002\",\"agent_card\":{\"name\":\"Summarizer\",\"skills\":[{\"id\":\"summarize\",\"name\":\"Summarize\"}]}}") check "POST /registry/register (summarizer)" '"status":"registered"' "$R" +SUM_TOKEN=$(echo "$R" | e2e_extract_token) # Test 9: Both online R=$(curl -s "$BASE/workspaces/$ECHO_ID") @@ -68,7 +80,7 @@ check "Echo has agent_card" '"skills"' "$R" check "Echo has url" '"url":"http://localhost:8001"' "$R" # Test 10: Heartbeat -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":2,\"uptime_seconds\":120}") check "POST /registry/heartbeat" '"status":"ok"' "$R" @@ -76,19 +88,19 @@ R=$(curl -s "$BASE/workspaces/$ECHO_ID") check "Heartbeat updated active_tasks" '"active_tasks":2' "$R" check "Heartbeat updated uptime" '"uptime_seconds":120' "$R" -# Test 11: Discover (no auth header — canvas) +# Test 11: Discover without X-Workspace-ID — Phase 30.6 requires it R=$(curl -s "$BASE/registry/discover/$ECHO_ID") -check "GET /registry/discover/:id (canvas)" '"url":"http://localhost:8001"' "$R" +check "GET /registry/discover/:id (missing caller rejected)" 'X-Workspace-ID header is required' "$R" # Test 12: Discover (from sibling — allowed) -R=$(curl -s "$BASE/registry/discover/$ECHO_ID" -H "X-Workspace-ID: $SUM_ID") +R=$(curl -s "$BASE/registry/discover/$ECHO_ID" -H "X-Workspace-ID: $SUM_ID" -H "Authorization: Bearer $SUM_TOKEN") check "GET /registry/discover/:id (sibling)" '"url"' "$R" # Test 13: Peers (root siblings see each other) -R=$(curl -s "$BASE/registry/$ECHO_ID/peers") +R=$(curl -s "$BASE/registry/$ECHO_ID/peers" -H "Authorization: Bearer $ECHO_TOKEN") check "GET /registry/:id/peers (has summarizer)" '"Summarizer' "$R" -R=$(curl -s "$BASE/registry/$SUM_ID/peers") +R=$(curl -s "$BASE/registry/$SUM_ID/peers" -H "Authorization: Bearer $SUM_TOKEN") check "GET /registry/:id/peers (has echo)" '"Echo Agent"' "$R" # Test 14: Check access (root siblings) @@ -119,13 +131,13 @@ R=$(curl -s "$BASE/events/$ECHO_ID") check "GET /events/:id (has events for echo)" 'WORKSPACE_ONLINE' "$R" # Test 18: Update card -R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"agent_card\":{\"name\":\"Echo Agent v2\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"},{\"id\":\"repeat\",\"name\":\"Repeat\"}]}}") check "POST /registry/update-card" '"status":"updated"' "$R" # Test 19: Degraded status transition # First, ensure workspace is online (Redis TTL may have expired during test) -curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":180}" > /dev/null # Re-register to force online status in case liveness expired @@ -133,7 +145,7 @@ curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$ECHO_ID\",\"url\":\"http://localhost:8001\",\"agent_card\":{\"name\":\"Echo Agent v2\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"},{\"id\":\"repeat\",\"name\":\"Repeat\"}]}}" > /dev/null # Now send high error rate to trigger degraded -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.8,\"sample_error\":\"API rate limit\",\"active_tasks\":0,\"uptime_seconds\":200}") check "Heartbeat (high error_rate)" '"status":"ok"' "$R" @@ -141,7 +153,7 @@ R=$(curl -s "$BASE/workspaces/$ECHO_ID") check "Status degraded" '"status":"degraded"' "$R" # Test 20: Recovery -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":300}") check "Heartbeat (recovered)" '"status":"ok"' "$R" @@ -207,7 +219,7 @@ echo "" echo "--- Current Task Tests ---" # Test: Heartbeat with current_task -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":1,\"uptime_seconds\":400,\"current_task\":\"Analyzing document\"}") check "Heartbeat with current_task" '"status":"ok"' "$R" @@ -217,7 +229,7 @@ check "current_task visible in workspace" '"current_task":"Analyzing document"' check "active_tasks updated" '"active_tasks":1' "$R" # Test: Clear current_task -R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ +R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" -H "Authorization: Bearer $ECHO_TOKEN" \ -d "{\"workspace_id\":\"$ECHO_ID\",\"error_rate\":0.0,\"sample_error\":\"\",\"active_tasks\":0,\"uptime_seconds\":500,\"current_task\":\"\"}") check "Heartbeat clear current_task" '"status":"ok"' "$R" @@ -247,7 +259,6 @@ check "GET /bundles/export/:id" '"name":"Summarizer Agent"' "$BUNDLE" # Capture original config for comparison ORIG_NAME=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])") ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['tier'])") -ORIG_SKILLS=$(echo "$BUNDLE" | python3 -c "import sys,json; b=json.load(sys.stdin); card=b.get('agent_card') or {}; skills=card.get('skills',[]); print(','.join(s.get('name','') for s in skills))") # Delete the workspace R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID") @@ -299,9 +310,8 @@ R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json -d "{\"id\":\"$NEW_ID\",\"url\":\"http://localhost:8002\",\"agent_card\":{\"name\":\"Summarizer\",\"skills\":[{\"id\":\"summarize\",\"name\":\"Summarize\"}]}}") check "Register re-imported workspace" '"status":"registered"' "$R" -# Re-export and compare skills survived the round-trip +# Re-export and verify agent_card survives the round-trip REBUNDLE=$(curl -s "$BASE/bundles/export/$NEW_ID") -REIMPORT_SKILLS=$(echo "$REBUNDLE" | python3 -c "import sys,json; b=json.load(sys.stdin); card=b.get('agent_card') or {}; skills=card.get('skills',[]); print(','.join(s.get('name','') for s in skills))") check "Re-exported bundle has agent_card" '"agent_card"' "$REBUNDLE" # Clean up diff --git a/tests/e2e/test_claude_code_e2e.sh b/tests/e2e/test_claude_code_e2e.sh index d52fd7cb..6ed6a061 100755 --- a/tests/e2e/test_claude_code_e2e.sh +++ b/tests/e2e/test_claude_code_e2e.sh @@ -41,7 +41,9 @@ fi for id in $(curl -s $PLATFORM/workspaces | python3 -c "import sys,json; [print(w['id']) for w in json.load(sys.stdin)]" 2>/dev/null); do curl -s -X DELETE "$PLATFORM/workspaces/$id" > /dev/null done +# shellcheck disable=SC2046 # Intentional word-split over container IDs docker stop $(docker ps -q --filter "name=ws-") 2>/dev/null || true +# shellcheck disable=SC2046 docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true # --- Create Org Chart --- diff --git a/tests/e2e/test_comprehensive_e2e.sh b/tests/e2e/test_comprehensive_e2e.sh index 977598df..66390194 100755 --- a/tests/e2e/test_comprehensive_e2e.sh +++ b/tests/e2e/test_comprehensive_e2e.sh @@ -6,11 +6,18 @@ # Does NOT require running agent containers (tests platform-only behavior). set -euo pipefail -BASE="http://localhost:8080" +source "$(dirname "$0")/_lib.sh" # sets BASE default PASS=0 FAIL=0 SKIP=0 +# Phase 30.1: tokens issued at /registry/register must be echoed back on +# heartbeat, update-card, discover, and peers calls. +PM_TOKEN="" +DEV_TOKEN="" + +e2e_cleanup_all_workspaces + check() { local desc="$1" expected="$2" actual="$3" if echo "$actual" | grep -qF "$expected"; then @@ -60,24 +67,39 @@ check_status "GET /metrics returns 200" "200" "$CODE" echo "" echo "--- Section 2: Workspace CRUD ---" -# Create parent workspace (PM) +# Create parent workspace (PM) and immediately register to capture its +# auth token BEFORE the provisioner's container can spawn and claim it. +# Tokens are single-issue on first /registry/register per workspace +# (Phase 30.1) — if the container's main.py beats us, our later calls +# get no token and bearer-protected endpoints fail. Empirically the +# script wins the race 5/5 times when register fires right after +# create; sections that depend on container readiness (RT_* in 2b) +# still run normally. R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"Test PM","role":"Project Manager","tier":2}') check "Create PM" '"status":"provisioning"' "$R" PM_ID=$(echo "$R" | jq_extract "['id']") echo " PM_ID=$PM_ID" +RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ + -d "{\"id\":\"$PM_ID\",\"url\":\"http://localhost:9000\",\"agent_card\":{\"name\":\"PM\",\"skills\":[]}}") +PM_TOKEN=$(echo "$RR" | e2e_extract_token) # Create child workspace under PM R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\"}") check "Create Dev (child of PM)" '"status":"provisioning"' "$R" DEV_ID=$(echo "$R" | jq_extract "['id']") +RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ + -d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}") +DEV_TOKEN=$(echo "$RR" | e2e_extract_token) # Create sibling R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\"}") check "Create QA (sibling of Dev)" '"status":"provisioning"' "$R" QA_ID=$(echo "$R" | jq_extract "['id']") +curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ + -d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null # Create unrelated workspace R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ @@ -127,7 +149,7 @@ RT_CR_ID=$(echo "$R" | jq_extract "['id']") # Wait for containers to start (poll up to 30s for first one to appear) if command -v docker &>/dev/null; then short_cc="${RT_CC_ID:0:12}" - for i in 1 2 3 4 5 6; do + for _ in 1 2 3 4 5 6; do sleep 5 if docker inspect "ws-${short_cc}" >/dev/null 2>&1; then break; fi done @@ -137,7 +159,7 @@ if command -v docker &>/dev/null; then local short_id="${ws_id:0:12}" # Poll up to 30s for image to appear local actual_image="NOT_FOUND" - for j in 1 2 3 4 5 6; do + for _ in 1 2 3 4 5 6; do actual_image=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "NOT_FOUND") if echo "$actual_image" | grep -qF "$expected_tag"; then break; fi sleep 5 @@ -190,7 +212,7 @@ if echo "$R" | grep -qF "saved"; then # Poll up to 30s for the new container image to appear (restart can take a while) if command -v docker &>/dev/null; then short_id="${RT_LG_ID:0:12}" - for i in 1 2 3 4 5 6; do + for _ in 1 2 3 4 5 6; do sleep 5 actual=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "") if echo "$actual" | grep -qF "deepagents"; then break; fi @@ -217,10 +239,8 @@ done echo "" echo "--- Section 3: Registry & Heartbeat ---" -# Register Dev workspace -R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ - -d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}") -check "Register Dev" '"status":"registered"' "$R" +# Dev was already registered in Section 2 right after creation (to beat +# the provisioner in the token-issuance race). Re-assert the status here. # Verify Dev is now online R=$(curl -s "$BASE/workspaces/$DEV_ID") @@ -228,6 +248,7 @@ check "Dev status online after register" '"status":"online"' "$R" # Heartbeat with current_task R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ + -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"active_tasks\":1,\"current_task\":\"Running tests\"}") check "Heartbeat with task" '"status":"ok"' "$R" @@ -237,6 +258,7 @@ check "Current task visible" '"current_task":"Running tests"' "$R" # Heartbeat with error rate (trigger degraded — needs >0.5 AND registered) R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ + -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"error_rate\":0.8,\"sample_error\":\"timeout\"}") check "Degraded heartbeat" '"status":"ok"' "$R" @@ -247,6 +269,7 @@ check "Dev degraded" '"last_error_rate":0.8' "$R" # Recover R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ + -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"error_rate\":0.0}") R=$(curl -s "$BASE/workspaces/$DEV_ID") check "Dev recovered" '"last_error_rate":0' "$R" @@ -257,22 +280,18 @@ check "Dev recovered" '"last_error_rate":0' "$R" echo "" echo "--- Section 4: Discovery & Access Control ---" -# Register PM too -curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ - -d "{\"id\":\"$PM_ID\",\"url\":\"http://localhost:9000\",\"agent_card\":{\"name\":\"PM\",\"skills\":[]}}" > /dev/null +# PM was registered in Section 2 right after creation. # Discover requires X-Workspace-ID CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/registry/discover/$DEV_ID") check_status "Discover without header → 400" "400" "$CODE" # PM discovers Dev (parent→child: allowed) -R=$(curl -s -H "X-Workspace-ID: $PM_ID" "$BASE/registry/discover/$DEV_ID") +R=$(curl -s -H "X-Workspace-ID: $PM_ID" -H "Authorization: Bearer $PM_TOKEN" "$BASE/registry/discover/$DEV_ID") check "PM discovers Dev (parent→child)" "$DEV_ID" "$R" -# Dev discovers QA (siblings: allowed) — QA must be registered first -curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ - -d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null -R=$(curl -s -H "X-Workspace-ID: $DEV_ID" "$BASE/registry/discover/$QA_ID") +# Dev discovers QA (siblings: allowed) — QA was registered in Section 2 +R=$(curl -s -H "X-Workspace-ID: $DEV_ID" -H "Authorization: Bearer $DEV_TOKEN" "$BASE/registry/discover/$QA_ID") check "Dev discovers QA (siblings)" "$QA_ID" "$R" # Check access: PM → Dev (allowed) @@ -286,7 +305,7 @@ R=$(curl -s -X POST "$BASE/registry/check-access" -H "Content-Type: application/ check "Access Dev→Outsider (denied)" '"allowed":false' "$R" # Peers — Dev should see PM and QA -R=$(curl -s -H "X-Workspace-ID: $DEV_ID" "$BASE/registry/$DEV_ID/peers") +R=$(curl -s -H "X-Workspace-ID: $DEV_ID" -H "Authorization: Bearer $DEV_TOKEN" "$BASE/registry/$DEV_ID/peers") check "Dev peers include PM" "$PM_ID" "$R" check "Dev peers include QA" "$QA_ID" "$R" @@ -497,6 +516,7 @@ echo "" echo "--- Section 12: Agent Card Update ---" R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" \ + -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"agent_card\":{\"name\":\"Dev Agent v2\",\"skills\":[{\"id\":\"code\",\"name\":\"Coding\"}],\"version\":\"2.0.0\"}}") check "Update agent card" '"status":"updated"' "$R" @@ -548,7 +568,7 @@ for w in ws: " 2>/dev/null # Poll for clean state up to 30s — DB cascade + container stop is async on busy systems -for i in 1 2 3 4 5 6; do +for _ in 1 2 3 4 5 6; do sleep 5 R=$(curl -s "$BASE/workspaces") if [ "$R" = "[]" ]; then break; fi