Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ceccfeafa8 |
@@ -1,213 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for canvas/src/lib/hydrate.ts — exponential-backoff canvas store hydration.
|
||||
*
|
||||
* 7 cases:
|
||||
* 1. Success on first attempt → { error: null }
|
||||
* 2. Viewport fetch fails (non-fatal) → store still hydrates, returns { error: null }
|
||||
* 3. Success after 1 retry → onRetrying(1) called once, final result { error: null }
|
||||
* 4. Success after 2 retries → onRetrying called for each failed attempt
|
||||
* 5. All attempts fail → returns the error message after MAX_RETRIES
|
||||
* 6. onRetrying called with correct attempt number on each retry
|
||||
* 7. Exponential backoff delays: 1s, 2s, 4s for attempts 1, 2, 3
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
|
||||
|
||||
// ─── Mock api ──────────────────────────────────────────────────────────────────
|
||||
// PLATFORM_URL must be a named export — hydrate.ts imports it directly, not via api.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn<(path: string) => Promise<unknown>>(),
|
||||
},
|
||||
PLATFORM_URL: "http://localhost:8080",
|
||||
}));
|
||||
|
||||
// ─── Mock store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockHydrate = vi.fn();
|
||||
const mockSetViewport = vi.fn();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: {
|
||||
getState: () => ({
|
||||
hydrate: mockHydrate,
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.mocked(api.get);
|
||||
|
||||
function makeWorkspace(id = "ws-1") {
|
||||
return {
|
||||
id,
|
||||
name: "Test WS",
|
||||
role: "assistant",
|
||||
tier: 1,
|
||||
status: "online" as const,
|
||||
agent_card: null,
|
||||
url: "http://localhost:9000",
|
||||
parent_id: null,
|
||||
active_tasks: 0,
|
||||
last_error_rate: 0,
|
||||
last_sample_error: "",
|
||||
uptime_seconds: 60,
|
||||
current_task: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("hydrateCanvas — success paths", () => {
|
||||
it("returns { error: null } on first-attempt success", async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // /canvas/viewport
|
||||
|
||||
const result = await hydrateCanvas();
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 1 });
|
||||
});
|
||||
|
||||
it("viewport fetch failure is non-fatal — store still hydrates", async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces OK
|
||||
.mockRejectedValueOnce(new Error("viewport down")); // /canvas/viewport fails
|
||||
|
||||
const result = await hydrateCanvas();
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { error: null } after 1 retry", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
// Each attempt makes 2 parallel api.get calls (workspaces + viewport).
|
||||
// Attempt 1 (fails): /workspaces → rejected, /viewport → resolved
|
||||
// Attempt 2 (succeeds): /workspaces → resolved, /viewport → resolved
|
||||
mockApiGet
|
||||
.mockRejectedValueOnce(new Error("network down")) // attempt 1: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // attempt 1: /viewport
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // attempt 2: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // attempt 2: /viewport
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
|
||||
// Advance past the first backoff delay (1000 * 2^0 = 1000 ms)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(onRetrying).toHaveBeenCalledTimes(1);
|
||||
expect(onRetrying).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("onRetrying called once per failed attempt before next retry", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
// Attempt 1: both calls fail
|
||||
// Attempt 2: both calls fail
|
||||
// Attempt 3: both calls succeed → hydrate succeeds
|
||||
mockApiGet
|
||||
.mockRejectedValueOnce(new Error("attempt 1")) // a1: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a1: /viewport (resolved even though workspaces failed)
|
||||
.mockRejectedValueOnce(new Error("attempt 2")) // a2: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a2: /viewport
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // a3: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // a3: /viewport
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(onRetrying).toHaveBeenCalledTimes(2);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — failure paths", () => {
|
||||
it("returns error message after all MAX_RETRIES attempts exhausted", async () => {
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1} failed`));
|
||||
}
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(result.error).toContain("Unable to connect to platform");
|
||||
expect(mockHydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onRetrying called MAX_RETRIES-1 times before final exhausted attempt", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
|
||||
}
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
// onRetrying is called after each failed attempt, before the next attempt.
|
||||
// With MAX_RETRIES=3: called after attempt 1 (→2) and after attempt 2 (→3).
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — exponential backoff timing", () => {
|
||||
it("total elapsed time equals sum of exponential delays 1s + 2s + 4s", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
|
||||
// Advance all timers at once and let fake timers resolve everything
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Total expected: 1000 (delay1) + 2000 (delay2) = 3000 ms
|
||||
// (no delay after the final attempt 3 — function returns immediately)
|
||||
expect(elapsed).toBeGreaterThanOrEqual(2999);
|
||||
expect(elapsed).toBeLessThan(5000); // sanity cap
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ─── Setup helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// socketTestDB wraps sqlmock setup with the redis setup needed for wsauth.
|
||||
func socketTestDB(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
// Start a miniredis for the wsauth token subsystem.
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
mockDB.Close()
|
||||
t.Fatalf("failed to start miniredis: %v", err)
|
||||
}
|
||||
db.DB = mockDB
|
||||
db.RDB = redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
wsauth.ResetInboundSecretCacheForTesting()
|
||||
|
||||
cleanup := func() {
|
||||
mockDB.Close()
|
||||
mr.Close()
|
||||
wsauth.ResetInboundSecretCacheForTesting()
|
||||
}
|
||||
return mock, cleanup
|
||||
}
|
||||
|
||||
// ─── Test cases ────────────────────────────────────────────────────────────────
|
||||
// Phase 30.1/30.2 bearer-token auth gate on WebSocket upgrade.
|
||||
// SocketHandler.HandleConnect enforces:
|
||||
// - Canvas clients (no X-Workspace-ID header) → bypass auth, upgrade proceeds
|
||||
// - Workspace agents (X-Workspace-ID present) → HasAnyLiveToken probe → bearer validation
|
||||
|
||||
func TestSocketHandler_HandleConnect_CanvasClient_NoAuthRequired(t *testing.T) {
|
||||
mock, cleanup := socketTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create hub and drain the Register channel via Run.
|
||||
hub := ws.NewHub(func(_, _ string) bool { return true })
|
||||
go hub.Run()
|
||||
|
||||
h := NewSocketHandler(hub)
|
||||
c, w := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/ws", nil)
|
||||
// No X-Workspace-ID → canvas client path.
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
// Canvas path has no DB expectations — HasAnyLiveToken not called.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
_ = w.Code // upgrade fails in test env (httptest doesn't do WS) — handler returns.
|
||||
}
|
||||
|
||||
// TestSocketHandler_HandleConnect_AgentNoLiveToken_BypassesBearerCheck verifies
|
||||
// that agents with no live tokens (legacy pre-token workspaces) are grandfathered
|
||||
// through without being asked for a bearer token.
|
||||
func TestSocketHandler_HandleConnect_AgentNoLiveToken_BypassesBearerCheck(t *testing.T) {
|
||||
mock, cleanup := socketTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// HasAnyLiveToken → no rows (no live tokens → n=0).
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens WHERE workspace_id = \$1 AND revoked_at IS NULL`).
|
||||
WithArgs("ws-agent").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
hub := ws.NewHub(func(_, _ string) bool { return true })
|
||||
go hub.Run()
|
||||
|
||||
h := NewSocketHandler(hub)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/ws", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-agent")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketHandler_HandleConnect_DBErrorOnHasAnyLiveToken returns 500.
|
||||
func TestSocketHandler_HandleConnect_DBErrorOnHasAnyLiveToken(t *testing.T) {
|
||||
mock, cleanup := socketTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens WHERE workspace_id = \$1 AND revoked_at IS NULL`).
|
||||
WithArgs("ws-agent").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
hub := ws.NewHub(func(_, _ string) bool { return true })
|
||||
go hub.Run()
|
||||
|
||||
h := NewSocketHandler(hub)
|
||||
c, w := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/ws", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-agent")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB error, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketHandler_HandleConnect_MissingBearerToken returns 401.
|
||||
func TestSocketHandler_HandleConnect_MissingBearerToken(t *testing.T) {
|
||||
mock, cleanup := socketTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// hasLive=true but no Authorization header.
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens WHERE workspace_id = \$1 AND revoked_at IS NULL`).
|
||||
WithArgs("ws-agent").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
hub := ws.NewHub(func(_, _ string) bool { return true })
|
||||
go hub.Run()
|
||||
|
||||
h := NewSocketHandler(hub)
|
||||
c, w := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/ws", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-agent")
|
||||
// No Authorization header.
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 on missing bearer token, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketHandler_HandleConnect_InvalidBearerToken returns 401.
|
||||
func TestSocketHandler_HandleConnect_InvalidBearerToken(t *testing.T) {
|
||||
mock, cleanup := socketTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// hasLive=true.
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens WHERE workspace_id = \$1 AND revoked_at IS NULL`).
|
||||
WithArgs("ws-agent").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
// ValidateToken → lookupTokenByHash: no matching hash.
|
||||
mock.ExpectQuery(`SELECT t\.id, t\.workspace_id FROM workspace_auth_tokens t JOIN workspaces w`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
hub := ws.NewHub(func(_, _ string) bool { return true })
|
||||
go hub.Run()
|
||||
|
||||
h := NewSocketHandler(hub)
|
||||
c, w := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/ws", nil)
|
||||
c.Request.Header.Set("X-Workspace-ID", "ws-agent")
|
||||
c.Request.Header.Set("Authorization", "Bearer invalid-token-xyz")
|
||||
|
||||
h.HandleConnect(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 on invalid bearer token, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user