mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 支持自托管的 remote-control-server (#214)
* feat: 支持自托管的 remote-control-server (#214) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
162
packages/remote-control-server/src/__tests__/auth.test.ts
Normal file
162
packages/remote-control-server/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";
|
||||
|
||||
// Mock config before importing modules that depend on it
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-key-1", "test-key-2"],
|
||||
baseUrl: "",
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { validateApiKey, hashApiKey } from "../auth/api-key";
|
||||
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
|
||||
import { issueToken, resolveToken } from "../auth/token";
|
||||
import { storeReset, storeCreateUser } from "../store";
|
||||
|
||||
// ---------- api-key ----------
|
||||
|
||||
describe("validateApiKey", () => {
|
||||
test("validates a configured API key", () => {
|
||||
expect(validateApiKey("test-key-1")).toBe(true);
|
||||
expect(validateApiKey("test-key-2")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown key", () => {
|
||||
expect(validateApiKey("unknown-key")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects undefined", () => {
|
||||
expect(validateApiKey(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty string", () => {
|
||||
expect(validateApiKey("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("produces consistent SHA-256 hex", () => {
|
||||
const hash = hashApiKey("my-key");
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(hashApiKey("my-key")).toBe(hash);
|
||||
});
|
||||
|
||||
test("different keys produce different hashes", () => {
|
||||
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- jwt ----------
|
||||
|
||||
describe("JWT", () => {
|
||||
// JWT reads process.env.RCS_API_KEYS directly (not via config)
|
||||
const originalKeys = process.env.RCS_API_KEYS;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.RCS_API_KEYS = originalKeys;
|
||||
});
|
||||
|
||||
describe("generateWorkerJwt", () => {
|
||||
test("produces a three-part base64url token", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
const parts = token.split(".");
|
||||
expect(parts).toHaveLength(3);
|
||||
for (const part of parts) {
|
||||
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("contains correct header", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
|
||||
expect(header.alg).toBe("HS256");
|
||||
expect(header.typ).toBe("JWT");
|
||||
});
|
||||
|
||||
test("throws when no API key configured", () => {
|
||||
delete process.env.RCS_API_KEYS;
|
||||
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
|
||||
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyWorkerJwt", () => {
|
||||
test("verifies a valid token", () => {
|
||||
const token = generateWorkerJwt("ses_abc", 3600);
|
||||
const payload = verifyWorkerJwt(token);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload!.session_id).toBe("ses_abc");
|
||||
expect(payload!.role).toBe("worker");
|
||||
expect(payload!.iat).toBeGreaterThan(0);
|
||||
expect(payload!.exp).toBeGreaterThan(payload!.iat);
|
||||
});
|
||||
|
||||
test("returns null for expired token", () => {
|
||||
const token = generateWorkerJwt("ses_old", -10);
|
||||
expect(verifyWorkerJwt(token)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for malformed token (not 3 parts)", () => {
|
||||
expect(verifyWorkerJwt("a.b")).toBeNull();
|
||||
expect(verifyWorkerJwt("just-a-string")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for tampered signature", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
const parts = token.split(".");
|
||||
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
|
||||
expect(verifyWorkerJwt(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for wrong signing key", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
process.env.RCS_API_KEYS = "wrong-key";
|
||||
expect(verifyWorkerJwt(token)).toBeNull();
|
||||
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- token ----------
|
||||
|
||||
describe("issueToken / resolveToken", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
|
||||
test("issues and resolves a token", () => {
|
||||
storeCreateUser("alice");
|
||||
const { token, expires_in } = issueToken("alice");
|
||||
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
|
||||
expect(expires_in).toBe(86400);
|
||||
expect(resolveToken(token)).toBe("alice");
|
||||
});
|
||||
|
||||
test("returns null for unknown token", () => {
|
||||
expect(resolveToken("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for undefined token", () => {
|
||||
expect(resolveToken(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test("tokens are unique", () => {
|
||||
storeCreateUser("alice");
|
||||
const t1 = issueToken("alice").token;
|
||||
const t2 = issueToken("alice").token;
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock config with very short timeout for testing
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import {
|
||||
storeReset,
|
||||
storeCreateEnvironment,
|
||||
storeUpdateEnvironment,
|
||||
storeCreateSession,
|
||||
storeUpdateSession,
|
||||
storeGetEnvironment,
|
||||
storeGetSession,
|
||||
storeListActiveEnvironments,
|
||||
} from "../store";
|
||||
|
||||
describe("Disconnect Monitor Logic", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
|
||||
// Test the logic directly rather than the interval-based monitor
|
||||
// to avoid long-running tests with timers
|
||||
|
||||
test("environment times out when lastPollAt is too old", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const timeoutMs = 300 * 1000; // 5 minutes
|
||||
|
||||
// Simulate lastPollAt being 6 minutes ago
|
||||
const oldDate = new Date(Date.now() - timeoutMs - 60000);
|
||||
storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
|
||||
|
||||
// Check the timeout logic (same as in disconnect-monitor.ts)
|
||||
const now = Date.now();
|
||||
const envs = storeListActiveEnvironments();
|
||||
for (const e of envs) {
|
||||
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
|
||||
storeUpdateEnvironment(e.id, { status: "disconnected" });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = storeGetEnvironment(env.id);
|
||||
expect(updated?.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
test("environment stays active when lastPollAt is recent", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const timeoutMs = 300 * 1000;
|
||||
|
||||
// lastPollAt is recent (just created)
|
||||
const now = Date.now();
|
||||
const envs = storeListActiveEnvironments();
|
||||
for (const e of envs) {
|
||||
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
|
||||
storeUpdateEnvironment(e.id, { status: "disconnected" });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = storeGetEnvironment(env.id);
|
||||
expect(updated?.status).toBe("active");
|
||||
});
|
||||
|
||||
test("session becomes inactive when updatedAt is too old", () => {
|
||||
const session = storeCreateSession({ status: "idle" });
|
||||
storeUpdateSession(session.id, { status: "running" });
|
||||
const timeoutMs = 300 * 1000 * 2; // 2x disconnect timeout
|
||||
|
||||
// Simulate updatedAt being older than 2x timeout
|
||||
// We can't directly set updatedAt, but we can verify the logic
|
||||
// by checking that recently updated sessions are not marked inactive
|
||||
const now = Date.now();
|
||||
const rec = storeGetSession(session.id);
|
||||
// Session was just updated, should not be inactive
|
||||
expect(rec?.status).toBe("running");
|
||||
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
|
||||
});
|
||||
|
||||
test("session stays running when recently updated", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { status: "running" });
|
||||
|
||||
const timeoutMs = 300 * 1000 * 2;
|
||||
const rec = storeGetSession(session.id);
|
||||
expect(rec?.status).toBe("running");
|
||||
expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
|
||||
});
|
||||
});
|
||||
176
packages/remote-control-server/src/__tests__/event-bus.test.ts
Normal file
176
packages/remote-control-server/src/__tests__/event-bus.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { EventBus, getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||
|
||||
describe("EventBus", () => {
|
||||
let bus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
describe("publish", () => {
|
||||
test("publishes event with seqNum starting at 1", () => {
|
||||
const event = bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "s1",
|
||||
type: "user",
|
||||
payload: { content: "hello" },
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(event.seqNum).toBe(1);
|
||||
expect(event.createdAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("increments seqNum on each publish", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||
const event = bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
|
||||
expect(event.seqNum).toBe(3);
|
||||
});
|
||||
|
||||
test("throws when publishing to a closed bus", () => {
|
||||
bus.close();
|
||||
expect(() =>
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" }),
|
||||
).toThrow("EventBus is closed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
test("receives published events", () => {
|
||||
const received: unknown[] = [];
|
||||
bus.subscribe((event) => received.push(event));
|
||||
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hi" }, direction: "outbound" });
|
||||
expect(received).toHaveLength(1);
|
||||
expect((received[0] as any).payload).toEqual({ content: "hi" });
|
||||
});
|
||||
|
||||
test("unsubscribe stops receiving events", () => {
|
||||
const received: unknown[] = [];
|
||||
const unsub = bus.subscribe((event) => received.push(event));
|
||||
unsub();
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("multiple subscribers all receive events", () => {
|
||||
const r1: unknown[] = [];
|
||||
const r2: unknown[] = [];
|
||||
bus.subscribe((e) => r1.push(e));
|
||||
bus.subscribe((e) => r2.push(e));
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(r1).toHaveLength(1);
|
||||
expect(r2).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("subscriber error does not affect other subscribers", () => {
|
||||
const received: unknown[] = [];
|
||||
bus.subscribe(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
bus.subscribe((e) => received.push(e));
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("subscriberCount", () => {
|
||||
expect(bus.subscriberCount()).toBe(0);
|
||||
const unsub1 = bus.subscribe(() => {});
|
||||
expect(bus.subscriberCount()).toBe(1);
|
||||
const unsub2 = bus.subscribe(() => {});
|
||||
expect(bus.subscriberCount()).toBe(2);
|
||||
unsub1();
|
||||
expect(bus.subscriberCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventsSince", () => {
|
||||
test("returns events after given seqNum", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||
bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
|
||||
|
||||
const events = bus.getEventsSince(1);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].seqNum).toBe(2);
|
||||
expect(events[1].seqNum).toBe(3);
|
||||
});
|
||||
|
||||
test("returns empty for seqNum beyond last", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(bus.getEventsSince(1)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns all events when seqNum is 0", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||
expect(bus.getEventsSince(0)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastSeqNum", () => {
|
||||
test("returns 0 for empty bus", () => {
|
||||
expect(bus.getLastSeqNum()).toBe(0);
|
||||
});
|
||||
|
||||
test("returns last seqNum after publishes", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(bus.getLastSeqNum()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
test("clears subscribers and prevents publishing", () => {
|
||||
bus.subscribe(() => {});
|
||||
bus.close();
|
||||
expect(bus.subscriberCount()).toBe(0);
|
||||
expect(() => bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventBus registry", () => {
|
||||
beforeEach(() => {
|
||||
// Clean up global registry
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
});
|
||||
|
||||
describe("getEventBus", () => {
|
||||
test("creates new bus for unknown session", () => {
|
||||
const bus = getEventBus("s1");
|
||||
expect(bus).toBeInstanceOf(EventBus);
|
||||
expect(getAllEventBuses().has("s1")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns same bus for same session", () => {
|
||||
const bus1 = getEventBus("s1");
|
||||
const bus2 = getEventBus("s1");
|
||||
expect(bus1).toBe(bus2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEventBus", () => {
|
||||
test("removes and closes bus", () => {
|
||||
const bus = getEventBus("s2");
|
||||
removeEventBus("s2");
|
||||
expect(getAllEventBuses().has("s2")).toBe(false);
|
||||
expect(() => bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: {}, direction: "outbound" })).toThrow();
|
||||
});
|
||||
|
||||
test("no-op for non-existent bus", () => {
|
||||
expect(() => removeEventBus("nonexistent")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllEventBuses", () => {
|
||||
test("returns all registered buses", () => {
|
||||
getEventBus("a");
|
||||
getEventBus("b");
|
||||
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
208
packages/remote-control-server/src/__tests__/middleware.test.ts
Normal file
208
packages/remote-control-server/src/__tests__/middleware.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
|
||||
|
||||
// Mock config before imports
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { storeReset, storeCreateUser } from "../store";
|
||||
import { apiKeyAuth, sessionIngressAuth, uuidAuth, getUuidFromRequest } from "../auth/middleware";
|
||||
import { issueToken } from "../auth/token";
|
||||
import { generateWorkerJwt } from "../auth/jwt";
|
||||
|
||||
// Helper: create a test app with middleware and a simple handler
|
||||
function createTestApp() {
|
||||
const app = new Hono();
|
||||
|
||||
// Test route for apiKeyAuth
|
||||
app.get("/api-key-test", apiKeyAuth, (c) => {
|
||||
return c.json({ username: c.get("username") || null });
|
||||
});
|
||||
|
||||
// Test route for sessionIngressAuth
|
||||
app.get("/ingress/:id", sessionIngressAuth, (c) => {
|
||||
return c.json({ ok: true, jwtPayload: c.get("jwtPayload") || null });
|
||||
});
|
||||
|
||||
// Test route for uuidAuth
|
||||
app.get("/uuid-test", uuidAuth, (c) => {
|
||||
return c.json({ uuid: c.get("uuid") });
|
||||
});
|
||||
|
||||
// Test route for getUuidFromRequest
|
||||
app.get("/uuid-extract", (c) => {
|
||||
return c.json({ uuid: getUuidFromRequest(c) });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("Auth Middleware", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
describe("apiKeyAuth", () => {
|
||||
test("accepts valid API key with username header", async () => {
|
||||
const res = await app.request("/api-key-test", {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"X-Username": "alice",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("alice");
|
||||
});
|
||||
|
||||
test("accepts valid API key with username query param", async () => {
|
||||
const res = await app.request("/api-key-test?username=bob", {
|
||||
headers: { Authorization: "Bearer test-api-key" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("bob");
|
||||
});
|
||||
|
||||
test("accepts valid session token", async () => {
|
||||
storeCreateUser("charlie");
|
||||
const { token } = issueToken("charlie");
|
||||
const res = await app.request("/api-key-test", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("charlie");
|
||||
});
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const res = await app.request("/api-key-test", {
|
||||
headers: { Authorization: "Bearer wrong-key" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects missing token", async () => {
|
||||
const res = await app.request("/api-key-test");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("accepts token from query param", async () => {
|
||||
storeCreateUser("dave");
|
||||
const { token } = issueToken("dave");
|
||||
const res = await app.request(`/api-key-test?token=${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("dave");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionIngressAuth", () => {
|
||||
const originalKeys = process.env.RCS_API_KEYS;
|
||||
beforeEach(() => {
|
||||
process.env.RCS_API_KEYS = "test-api-key";
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env.RCS_API_KEYS = originalKeys;
|
||||
});
|
||||
|
||||
test("accepts valid API key", async () => {
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: { Authorization: "Bearer test-api-key" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("accepts valid JWT with matching session_id", async () => {
|
||||
const jwt = generateWorkerJwt("ses_123", 3600);
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.jwtPayload).not.toBeNull();
|
||||
expect(body.jwtPayload.session_id).toBe("ses_123");
|
||||
});
|
||||
|
||||
test("rejects JWT with mismatched session_id", async () => {
|
||||
const jwt = generateWorkerJwt("ses_456", 3600);
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("rejects missing token", async () => {
|
||||
const res = await app.request("/ingress/ses_123");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: { Authorization: "Bearer invalid" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uuidAuth", () => {
|
||||
test("accepts UUID from query param", async () => {
|
||||
const res = await app.request("/uuid-test?uuid=test-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("test-uuid-1");
|
||||
});
|
||||
|
||||
test("accepts UUID from header", async () => {
|
||||
const res = await app.request("/uuid-test", {
|
||||
headers: { "X-UUID": "test-uuid-2" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("test-uuid-2");
|
||||
});
|
||||
|
||||
test("rejects missing UUID", async () => {
|
||||
const res = await app.request("/uuid-test");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUuidFromRequest", () => {
|
||||
test("extracts from query param", async () => {
|
||||
const res = await app.request("/uuid-extract?uuid=from-query");
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("from-query");
|
||||
});
|
||||
|
||||
test("extracts from header", async () => {
|
||||
const res = await app.request("/uuid-extract", {
|
||||
headers: { "X-UUID": "from-header" },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("from-header");
|
||||
});
|
||||
|
||||
test("returns undefined when no UUID", async () => {
|
||||
const res = await app.request("/uuid-extract");
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
906
packages/remote-control-server/src/__tests__/routes.test.ts
Normal file
906
packages/remote-control-server/src/__tests__/routes.test.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock config
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 1,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store";
|
||||
import { removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||
import { issueToken } from "../auth/token";
|
||||
|
||||
// Import route modules
|
||||
import v1Sessions from "../routes/v1/sessions";
|
||||
import v1Environments from "../routes/v1/environments";
|
||||
import v1EnvironmentsWork from "../routes/v1/environments.work";
|
||||
import v1SessionIngress from "../routes/v1/session-ingress";
|
||||
import v2CodeSessions from "../routes/v2/code-sessions";
|
||||
import v2Worker from "../routes/v2/worker";
|
||||
import v2WorkerEvents from "../routes/v2/worker-events";
|
||||
import webAuth from "../routes/web/auth";
|
||||
import webSessions from "../routes/web/sessions";
|
||||
import webControl from "../routes/web/control";
|
||||
import webEnvironments from "../routes/web/environments";
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono();
|
||||
app.route("/v1/sessions", v1Sessions);
|
||||
app.route("/v1/environments", v1Environments);
|
||||
app.route("/v1/environments", v1EnvironmentsWork);
|
||||
app.route("/v2/session_ingress", v1SessionIngress);
|
||||
app.route("/v1/code/sessions", v2CodeSessions);
|
||||
app.route("/v1/code/sessions", v2Worker);
|
||||
app.route("/v1/code/sessions", v2WorkerEvents);
|
||||
app.route("/web", webAuth);
|
||||
app.route("/web", webSessions);
|
||||
app.route("/web", webControl);
|
||||
app.route("/web", webEnvironments);
|
||||
return app;
|
||||
}
|
||||
|
||||
const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" };
|
||||
|
||||
describe("V1 Session Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /v1/sessions — creates a session", async () => {
|
||||
const res = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Test Session" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toMatch(/^session_/);
|
||||
expect(body.title).toBe("Test Session");
|
||||
expect(body.status).toBe("idle");
|
||||
});
|
||||
|
||||
test("POST /v1/sessions — requires auth", async () => {
|
||||
const res = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /v1/sessions/:id — returns created session", async () => {
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const getRes = await app.request(`/v1/sessions/${id}`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = await getRes.json();
|
||||
expect(body.id).toBe(id);
|
||||
});
|
||||
|
||||
test("GET /v1/sessions/:id — 404 for unknown session", async () => {
|
||||
const res = await app.request("/v1/sessions/nope", {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("PATCH /v1/sessions/:id — updates title", async () => {
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const patchRes = await app.request(`/v1/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Updated Title" }),
|
||||
});
|
||||
expect(patchRes.status).toBe(200);
|
||||
const body = await patchRes.json();
|
||||
expect(body.title).toBe("Updated Title");
|
||||
});
|
||||
|
||||
test("POST /v1/sessions/:id/archive — archives session", async () => {
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const archiveRes = await app.request(`/v1/sessions/${id}/archive`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(archiveRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("POST /v1/sessions/:id/events — publishes events", async () => {
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const eventsRes = await app.request(`/v1/sessions/${id}/events`, {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: [{ type: "user", content: "hello" }] }),
|
||||
});
|
||||
expect(eventsRes.status).toBe(200);
|
||||
const body = await eventsRes.json();
|
||||
expect(body.events).toBe(1);
|
||||
});
|
||||
|
||||
test("POST /v1/sessions with environment_id creates work item", async () => {
|
||||
// First register an environment
|
||||
const envRes = await app.request("/v1/environments/bridge", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machine_name: "test" }),
|
||||
});
|
||||
const { environment_id } = await envRes.json();
|
||||
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ environment_id }),
|
||||
});
|
||||
expect(sessRes.status).toBe(200);
|
||||
const body = await sessRes.json();
|
||||
expect(body.environment_id).toBe(environment_id);
|
||||
});
|
||||
|
||||
test("POST /v1/sessions with invalid environment_id — session created, work item fails silently", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ environment_id: "env_nonexistent" }),
|
||||
});
|
||||
expect(sessRes.status).toBe(200);
|
||||
const body = await sessRes.json();
|
||||
expect(body.id).toMatch(/^session_/);
|
||||
});
|
||||
|
||||
test("POST /v1/sessions with events — publishes initial events", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: [{ type: "init", data: "starting" }] }),
|
||||
});
|
||||
expect(sessRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 Environment Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /v1/environments/bridge — registers environment", async () => {
|
||||
const res = await app.request("/v1/environments/bridge", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machine_name: "mac1", directory: "/home" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.environment_id).toMatch(/^env_/);
|
||||
expect(body.status).toBe("active");
|
||||
});
|
||||
|
||||
test("DELETE /v1/environments/bridge/:id — deregisters environment", async () => {
|
||||
const envRes = await app.request("/v1/environments/bridge", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { environment_id } = await envRes.json();
|
||||
|
||||
const delRes = await app.request(`/v1/environments/bridge/${environment_id}`, {
|
||||
method: "DELETE",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("POST /v1/environments/:id/bridge/reconnect — reconnects environment", async () => {
|
||||
const envRes = await app.request("/v1/environments/bridge", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { environment_id } = await envRes.json();
|
||||
|
||||
const reconnectRes = await app.request(`/v1/environments/${environment_id}/bridge/reconnect`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(reconnectRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 Work Routes", () => {
|
||||
let app: Hono;
|
||||
let envId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storeReset();
|
||||
app = createApp();
|
||||
|
||||
const envRes = await app.request("/v1/environments/bridge", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
envId = (await envRes.json()).environment_id;
|
||||
});
|
||||
|
||||
test("GET /v1/environments/:id/work/poll — returns 204 when no work", async () => {
|
||||
const res = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
test("work lifecycle: create → poll → ack → stop", async () => {
|
||||
// Create session with environment (creates work item)
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ environment_id: envId }),
|
||||
});
|
||||
const sessionId = (await sessRes.json()).id;
|
||||
|
||||
// Poll for work
|
||||
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(pollRes.status).toBe(200);
|
||||
const work = await pollRes.json();
|
||||
expect(work.id).toMatch(/^work_/);
|
||||
expect(work.data.id).toBe(sessionId);
|
||||
|
||||
// Ack work
|
||||
const ackRes = await app.request(`/v1/environments/${envId}/work/${work.id}/ack`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(ackRes.status).toBe(200);
|
||||
|
||||
// Stop work
|
||||
const stopRes = await app.request(`/v1/environments/${envId}/work/${work.id}/stop`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(stopRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("POST work heartbeat", async () => {
|
||||
// Create session + work
|
||||
await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ environment_id: envId }),
|
||||
});
|
||||
const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, {
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
const work = await pollRes.json();
|
||||
|
||||
const hbRes = await app.request(`/v1/environments/${envId}/work/${work.id}/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(hbRes.status).toBe(200);
|
||||
const body = await hbRes.json();
|
||||
expect(body.lease_extended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 Code Session Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
process.env.RCS_API_KEYS = "test-api-key";
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions — creates code session", async () => {
|
||||
const res = await app.request("/v1/code/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Code Session" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.session.id).toMatch(/^cse_/);
|
||||
expect(body.session.title).toBe("Code Session");
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/bridge — returns bridge info with JWT", async () => {
|
||||
// Create code session
|
||||
const createRes = await app.request("/v1/code/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = (await createRes.json()).session;
|
||||
|
||||
const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(bridgeRes.status).toBe(200);
|
||||
const body = await bridgeRes.json();
|
||||
expect(body.api_base_url).toBe("http://localhost:3000");
|
||||
expect(body.worker_epoch).toBe(1);
|
||||
expect(body.worker_jwt).toBeTruthy();
|
||||
expect(body.expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/bridge — 404 for unknown session", async () => {
|
||||
const res = await app.request("/v1/code/sessions/nope/bridge", {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 Worker Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
process.env.RCS_API_KEYS = "test-api-key";
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/register — increments epoch", async () => {
|
||||
// Create session
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const regRes = await app.request(`/v1/code/sessions/${id}/worker/register`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(regRes.status).toBe(200);
|
||||
const body = await regRes.json();
|
||||
expect(body.worker_epoch).toBe(1);
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/register — 404 for unknown", async () => {
|
||||
const res = await app.request("/v1/code/sessions/nope/worker/register", {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Auth Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /web/bind — binds session to UUID", async () => {
|
||||
// Create session first
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const bindRes = await app.request("/web/bind?uuid=test-uuid", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: id }),
|
||||
});
|
||||
expect(bindRes.status).toBe(200);
|
||||
const body = await bindRes.json();
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("POST /web/bind — 404 for unknown session", async () => {
|
||||
const res = await app.request("/web/bind?uuid=test-uuid", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "nope" }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("POST /web/bind — 400 when missing params", async () => {
|
||||
const res = await app.request("/web/bind", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Session Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /web/sessions — creates and auto-binds session", async () => {
|
||||
const res = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Web Session" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toMatch(/^session_/);
|
||||
expect(body.source).toBe("web");
|
||||
});
|
||||
|
||||
test("GET /web/sessions — returns sessions owned by UUID", async () => {
|
||||
// Create and bind
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const listRes = await app.request("/web/sessions?uuid=user-1");
|
||||
expect(listRes.status).toBe(200);
|
||||
const sessions = await listRes.json();
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test("GET /web/sessions — requires UUID", async () => {
|
||||
const res = await app.request("/web/sessions");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/all — lists only sessions owned by requesting UUID", async () => {
|
||||
// Create 2 sessions via different users
|
||||
await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
await app.request("/web/sessions?uuid=user-2", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const allRes = await app.request("/web/sessions/all?uuid=user-1");
|
||||
expect(allRes.status).toBe(200);
|
||||
const sessions = await allRes.json();
|
||||
expect(sessions).toHaveLength(1); // only user-1's session, not user-2's
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — returns owned session", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`);
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`);
|
||||
expect(getRes.status).toBe(403);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — returns events", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||
expect(histRes.status).toBe(200);
|
||||
const body = await histRes.json();
|
||||
expect(body.events).toEqual([]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`);
|
||||
expect(histRes.status).toBe(403);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — 404 after session deleted", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
// Archive/delete the session via v1
|
||||
await app.request(`/v1/sessions/${id}/archive`, {
|
||||
method: "POST",
|
||||
headers: AUTH_HEADERS,
|
||||
});
|
||||
|
||||
// Session still exists (archived), so we can still get it
|
||||
const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`);
|
||||
// After archive, session status is "archived" but still exists
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — 404 for non-existent session", async () => {
|
||||
// Bind to a non-existent session won't work, but if ownership was set
|
||||
// and session deleted, we need to test the 404 path
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
// Delete the session from store directly
|
||||
const { storeDeleteSession } = await import("../store");
|
||||
storeDeleteSession(id);
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||
expect(histRes.status).toBe(404);
|
||||
});
|
||||
|
||||
test("POST /web/sessions with invalid environment_id — handles work item error", async () => {
|
||||
const res = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ environment_id: "env_nonexistent" }),
|
||||
});
|
||||
// Session is still created even if work item fails
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toMatch(/^session_/);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/events — returns SSE stream", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-1`);
|
||||
expect(eventsRes.status).toBe(200);
|
||||
expect(eventsRes.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
|
||||
// Read initial keepalive and cancel
|
||||
const reader = eventsRes.body?.getReader();
|
||||
if (reader) {
|
||||
const { value } = await reader.read();
|
||||
const text = new TextDecoder().decode(value!);
|
||||
expect(text).toContain(": keepalive");
|
||||
reader.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/events — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await createRes.json();
|
||||
|
||||
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`);
|
||||
expect(eventsRes.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Control Routes", () => {
|
||||
let app: Hono;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
app = createApp();
|
||||
|
||||
// Create and bind session
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
sessionId = (await createRes.json()).id;
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/events — sends user message", async () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "user", content: "hello" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.event).toBeTruthy();
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/events — 403 for non-owner", async () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "user", content: "hello" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/control — sends control request", async () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/interrupt — interrupts session", async () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/interrupt — 403 for non-owner", async () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-2`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/control — 403 for non-owner", async () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/control?uuid=user-2`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "permission_response", approved: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("POST /web/sessions/:id/events — 403 for non-existent session with no ownership", async () => {
|
||||
const res = await app.request("/web/sessions/nonexistent/events?uuid=user-1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "user", content: "hello" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Environment Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("GET /web/environments — lists active environments", async () => {
|
||||
// Register an env via v1
|
||||
await app.request("/v1/environments/bridge", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machine_name: "mac1" }),
|
||||
});
|
||||
|
||||
const res = await app.request("/web/environments?uuid=user-1");
|
||||
expect(res.status).toBe(200);
|
||||
const envs = await res.json();
|
||||
expect(envs).toHaveLength(1);
|
||||
expect(envs[0].machine_name).toBe("mac1");
|
||||
});
|
||||
|
||||
test("GET /web/environments — requires UUID", async () => {
|
||||
const res = await app.request("/web/environments");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
process.env.RCS_API_KEYS = "test-api-key";
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /v2/session_ingress/session/:sessionId/events — ingests events with API key", async () => {
|
||||
// Create session first
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const res = await app.request(`/v2/session_ingress/session/${id}/events`, {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: [{ type: "assistant", content: "response" }] }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("ok");
|
||||
});
|
||||
|
||||
test("POST /v2/session_ingress/session/:sessionId/events — rejects without auth", async () => {
|
||||
const res = await app.request("/v2/session_ingress/session/nope/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: [] }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("POST /v2/session_ingress/session/:sessionId/events — 404 for unknown session", async () => {
|
||||
const res = await app.request("/v2/session_ingress/session/nope/events", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events: [{ type: "user", content: "hi" }] }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 Worker Events Routes", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
process.env.RCS_API_KEYS = "test-api-key";
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/events — publishes worker events", async () => {
|
||||
// Create session
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify([{ type: "assistant", content: "response" }]),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
|
||||
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/state`, {
|
||||
method: "PUT",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "running" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("PUT /v1/code/sessions/:id/worker/external_metadata — no-op", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/external_metadata`, {
|
||||
method: "PUT",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ meta: "data" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/events/:eventId/delivery — no-op", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { id } = await sessRes.json();
|
||||
|
||||
const res = await app.request(`/v1/code/sessions/${id}/worker/events/evt123/delivery`, {
|
||||
method: "POST",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "received" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
386
packages/remote-control-server/src/__tests__/services.test.ts
Normal file
386
packages/remote-control-server/src/__tests__/services.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock config before importing modules
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { storeReset, storeCreateEnvironment } from "../store";
|
||||
import {
|
||||
createSession,
|
||||
createCodeSession,
|
||||
getSession,
|
||||
updateSessionTitle,
|
||||
updateSessionStatus,
|
||||
archiveSession,
|
||||
incrementEpoch,
|
||||
listSessions,
|
||||
listSessionSummaries,
|
||||
listSessionSummariesByUsername,
|
||||
listSessionsByEnvironment,
|
||||
} from "../services/session";
|
||||
import {
|
||||
registerEnvironment,
|
||||
deregisterEnvironment,
|
||||
getEnvironment,
|
||||
updatePollTime,
|
||||
listActiveEnvironments,
|
||||
listActiveEnvironmentsResponse,
|
||||
listActiveEnvironmentsByUsername,
|
||||
reconnectEnvironment,
|
||||
} from "../services/environment";
|
||||
import { normalizePayload, publishSessionEvent } from "../services/transport";
|
||||
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||
|
||||
// ---------- Session Service ----------
|
||||
|
||||
describe("Session Service", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
test("creates a session with defaults", () => {
|
||||
const resp = createSession({});
|
||||
expect(resp.id).toMatch(/^session_/);
|
||||
expect(resp.status).toBe("idle");
|
||||
expect(resp.source).toBe("remote-control");
|
||||
expect(resp.environment_id).toBeNull();
|
||||
expect(resp.worker_epoch).toBe(0);
|
||||
expect(resp.created_at).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("creates a session with all options", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const resp = createSession({
|
||||
environment_id: env.id,
|
||||
title: "My Session",
|
||||
source: "cli",
|
||||
permission_mode: "auto",
|
||||
});
|
||||
expect(resp.environment_id).toBe(env.id);
|
||||
expect(resp.title).toBe("My Session");
|
||||
expect(resp.source).toBe("cli");
|
||||
expect(resp.permission_mode).toBe("auto");
|
||||
});
|
||||
|
||||
test("creates session with username", () => {
|
||||
const resp = createSession({ username: "alice" });
|
||||
expect(resp.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCodeSession", () => {
|
||||
test("creates a code session with cse_ prefix", () => {
|
||||
const resp = createCodeSession({});
|
||||
expect(resp.id).toMatch(/^cse_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSession", () => {
|
||||
test("returns null for non-existent session", () => {
|
||||
expect(getSession("nope")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns created session", () => {
|
||||
const created = createSession({});
|
||||
const fetched = getSession(created.id);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched!.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSessionTitle", () => {
|
||||
test("updates title", () => {
|
||||
const s = createSession({});
|
||||
updateSessionTitle(s.id, "New Title");
|
||||
expect(getSession(s.id)?.title).toBe("New Title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSessionStatus", () => {
|
||||
test("updates status", () => {
|
||||
const s = createSession({});
|
||||
updateSessionStatus(s.id, "active");
|
||||
expect(getSession(s.id)?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveSession", () => {
|
||||
test("sets status to archived and removes event bus", () => {
|
||||
const s = createSession({});
|
||||
// Create event bus for this session
|
||||
getEventBus(s.id);
|
||||
archiveSession(s.id);
|
||||
expect(getSession(s.id)?.status).toBe("archived");
|
||||
expect(getAllEventBuses().has(s.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementEpoch", () => {
|
||||
test("increments epoch by 1", () => {
|
||||
const s = createSession({});
|
||||
expect(incrementEpoch(s.id)).toBe(1);
|
||||
expect(incrementEpoch(s.id)).toBe(2);
|
||||
expect(getSession(s.id)?.worker_epoch).toBe(2);
|
||||
});
|
||||
|
||||
test("throws for non-existent session", () => {
|
||||
expect(() => incrementEpoch("nope")).toThrow("Session not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessions", () => {
|
||||
test("returns all sessions", () => {
|
||||
createSession({});
|
||||
createSession({});
|
||||
expect(listSessions()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessionSummaries", () => {
|
||||
test("returns summaries with correct fields", () => {
|
||||
createSession({ title: "Test" });
|
||||
const summaries = listSessionSummaries();
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0].title).toBe("Test");
|
||||
expect(summaries[0].updated_at).toBeGreaterThan(0);
|
||||
// Summary should not have environment_id
|
||||
expect("environment_id" in summaries[0]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessionSummariesByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
createSession({ username: "alice" });
|
||||
createSession({ username: "bob" });
|
||||
expect(listSessionSummariesByUsername("alice")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessionsByEnvironment", () => {
|
||||
test("filters by environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
createSession({ environment_id: env.id });
|
||||
createSession({});
|
||||
expect(listSessionsByEnvironment(env.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Environment Service ----------
|
||||
|
||||
describe("Environment Service", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
|
||||
describe("registerEnvironment", () => {
|
||||
test("registers environment with defaults", () => {
|
||||
const result = registerEnvironment({});
|
||||
expect(result.environment_id).toMatch(/^env_/);
|
||||
expect(result.environment_secret).toBe("test-api-key");
|
||||
expect(result.status).toBe("active");
|
||||
});
|
||||
|
||||
test("registers with options", () => {
|
||||
const result = registerEnvironment({
|
||||
machine_name: "mac1",
|
||||
directory: "/home/user",
|
||||
branch: "main",
|
||||
git_repo_url: "https://github.com/test/repo",
|
||||
max_sessions: 5,
|
||||
worker_type: "custom",
|
||||
});
|
||||
const env = getEnvironment(result.environment_id);
|
||||
expect(env?.machineName).toBe("mac1");
|
||||
expect(env?.directory).toBe("/home/user");
|
||||
expect(env?.maxSessions).toBe(5);
|
||||
});
|
||||
|
||||
test("registers with username", () => {
|
||||
const result = registerEnvironment({ username: "alice" });
|
||||
const env = getEnvironment(result.environment_id);
|
||||
expect(env?.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deregisterEnvironment", () => {
|
||||
test("sets status to deregistered", () => {
|
||||
const result = registerEnvironment({});
|
||||
deregisterEnvironment(result.environment_id);
|
||||
const env = getEnvironment(result.environment_id);
|
||||
expect(env?.status).toBe("deregistered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePollTime", () => {
|
||||
test("updates lastPollAt", () => {
|
||||
const result = registerEnvironment({});
|
||||
const before = getEnvironment(result.environment_id)?.lastPollAt;
|
||||
// Small delay to ensure time difference
|
||||
updatePollTime(result.environment_id);
|
||||
const after = getEnvironment(result.environment_id)?.lastPollAt;
|
||||
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("listActiveEnvironments", () => {
|
||||
test("returns active environments", () => {
|
||||
registerEnvironment({});
|
||||
registerEnvironment({});
|
||||
expect(listActiveEnvironments()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listActiveEnvironmentsResponse", () => {
|
||||
test("returns response format", () => {
|
||||
registerEnvironment({ machine_name: "mac1" });
|
||||
const envs = listActiveEnvironmentsResponse();
|
||||
expect(envs).toHaveLength(1);
|
||||
expect(envs[0].machine_name).toBe("mac1");
|
||||
expect(envs[0].last_poll_at).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listActiveEnvironmentsByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
registerEnvironment({ username: "alice" });
|
||||
registerEnvironment({ username: "bob" });
|
||||
expect(listActiveEnvironmentsByUsername("alice")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnectEnvironment", () => {
|
||||
test("sets status back to active", () => {
|
||||
const result = registerEnvironment({});
|
||||
deregisterEnvironment(result.environment_id);
|
||||
expect(getEnvironment(result.environment_id)?.status).toBe("deregistered");
|
||||
reconnectEnvironment(result.environment_id);
|
||||
expect(getEnvironment(result.environment_id)?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Transport Service ----------
|
||||
|
||||
describe("Transport Service", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
});
|
||||
|
||||
describe("normalizePayload", () => {
|
||||
test("handles string payload", () => {
|
||||
const result = normalizePayload("user", "hello world");
|
||||
expect(result.content).toBe("hello world");
|
||||
expect(result.raw).toBe("hello world");
|
||||
});
|
||||
|
||||
test("handles null payload", () => {
|
||||
const result = normalizePayload("user", null);
|
||||
expect(result.content).toBe("");
|
||||
expect(result.raw).toBeNull();
|
||||
});
|
||||
|
||||
test("handles object with direct content", () => {
|
||||
const result = normalizePayload("user", { content: "direct text" });
|
||||
expect(result.content).toBe("direct text");
|
||||
});
|
||||
|
||||
test("handles object with message.content string", () => {
|
||||
const result = normalizePayload("assistant", { message: { role: "assistant", content: "reply" } });
|
||||
expect(result.content).toBe("reply");
|
||||
});
|
||||
|
||||
test("handles object with message.content array", () => {
|
||||
const result = normalizePayload("assistant", {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "hello " },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.content).toBe("hello world");
|
||||
});
|
||||
|
||||
test("preserves tool fields", () => {
|
||||
const result = normalizePayload("tool_use", { tool_name: "Bash", tool_input: { cmd: "ls" } });
|
||||
expect(result.tool_name).toBe("Bash");
|
||||
expect(result.tool_input).toEqual({ cmd: "ls" });
|
||||
});
|
||||
|
||||
test("preserves permission fields", () => {
|
||||
const result = normalizePayload("permission", {
|
||||
request_id: "req_1",
|
||||
approved: true,
|
||||
updated_input: { cmd: "ls -la" },
|
||||
});
|
||||
expect(result.request_id).toBe("req_1");
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.updated_input).toEqual({ cmd: "ls -la" });
|
||||
});
|
||||
|
||||
test("preserves message field", () => {
|
||||
const msg = { role: "user", content: "hi" };
|
||||
const result = normalizePayload("user", { message: msg });
|
||||
expect(result.message).toEqual(msg);
|
||||
});
|
||||
|
||||
test("uses name as tool_name fallback", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
});
|
||||
|
||||
test("uses input as tool_input fallback", () => {
|
||||
const result = normalizePayload("tool", { input: { path: "/tmp" } });
|
||||
expect(result.tool_input).toEqual({ path: "/tmp" });
|
||||
});
|
||||
|
||||
test("handles empty content array", () => {
|
||||
const result = normalizePayload("assistant", {
|
||||
message: { content: [] },
|
||||
});
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("handles undefined payload", () => {
|
||||
const result = normalizePayload("user", undefined);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishSessionEvent", () => {
|
||||
test("publishes event to session bus", () => {
|
||||
const event = publishSessionEvent("s1", "user", { content: "hello" }, "outbound");
|
||||
expect(event.type).toBe("user");
|
||||
expect(event.direction).toBe("outbound");
|
||||
expect(event.sessionId).toBe("s1");
|
||||
expect(event.seqNum).toBe(1);
|
||||
});
|
||||
|
||||
test("normalizes payload before publishing", () => {
|
||||
const event = publishSessionEvent("s1", "assistant", { message: { content: "reply" } }, "inbound");
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
expect(payload.content).toBe("reply");
|
||||
});
|
||||
});
|
||||
});
|
||||
179
packages/remote-control-server/src/__tests__/sse-writer.test.ts
Normal file
179
packages/remote-control-server/src/__tests__/sse-writer.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock config
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { storeReset } from "../store";
|
||||
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
|
||||
import { createSSEWriter, createSSEStream } from "../transport/sse-writer";
|
||||
|
||||
/** Read up to N bytes from a Response stream, then cancel */
|
||||
async function readPartialStream(res: Response, maxBytes = 4096): Promise<string> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return "";
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (totalBytes < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalBytes += value.length;
|
||||
// Cancel after we have some data (first keepalive + any initial events)
|
||||
if (totalBytes > 0) break;
|
||||
}
|
||||
} finally {
|
||||
reader.cancel();
|
||||
}
|
||||
const combined = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(combined);
|
||||
}
|
||||
|
||||
describe("SSE Writer", () => {
|
||||
describe("createSSEWriter", () => {
|
||||
test("creates SSEWriter with send and close methods", () => {
|
||||
const app = new Hono();
|
||||
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null;
|
||||
|
||||
app.get("/test", (c) => {
|
||||
capturedWriter = createSSEWriter(c);
|
||||
return c.text("ok");
|
||||
});
|
||||
|
||||
app.request("/test");
|
||||
expect(capturedWriter).not.toBeNull();
|
||||
expect(typeof capturedWriter!.send).toBe("function");
|
||||
expect(typeof capturedWriter!.close).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSSEStream", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns Response with correct SSE headers", async () => {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
|
||||
const res = await app.request("/stream/s1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-cache");
|
||||
expect(res.headers.get("Connection")).toBe("keep-alive");
|
||||
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
|
||||
|
||||
// Cancel the stream
|
||||
res.body?.cancel();
|
||||
});
|
||||
|
||||
test("sends initial keepalive", async () => {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
|
||||
const res = await app.request("/stream/s2");
|
||||
const text = await readPartialStream(res);
|
||||
expect(text).toContain(": keepalive");
|
||||
});
|
||||
|
||||
test("sends historical events when fromSeqNum > 0", async () => {
|
||||
// Pre-populate event bus with events
|
||||
const bus = getEventBus("s3");
|
||||
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: { content: "hello" }, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s3", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const fromSeq = parseInt(c.req.query("fromSeq") || "0");
|
||||
return createSSEStream(c, sessionId, fromSeq);
|
||||
});
|
||||
|
||||
const res = await app.request("/stream/s3?fromSeq=1");
|
||||
const text = await readPartialStream(res);
|
||||
// Should replay events since seq 1 (i.e., event 2)
|
||||
expect(text).toContain('"seqNum":2');
|
||||
expect(text).toContain("assistant");
|
||||
});
|
||||
|
||||
test("no historical events when fromSeqNum is 0", async () => {
|
||||
const bus = getEventBus("s5");
|
||||
bus.publish({ id: "e1", sessionId: "s5", type: "user", payload: {}, direction: "outbound" });
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
|
||||
const res = await app.request("/stream/s5");
|
||||
const text = await readPartialStream(res);
|
||||
// With fromSeqNum=0, no historical replay, just keepalive
|
||||
expect(text).toContain(": keepalive");
|
||||
// Should NOT contain event data (only keepalive)
|
||||
expect(text).not.toContain("event: message");
|
||||
});
|
||||
|
||||
test("subscribes to new events and delivers them", async () => {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
|
||||
const res = await app.request("/stream/s6");
|
||||
|
||||
// Read initial keepalive first
|
||||
const reader = res.body!.getReader();
|
||||
const { value: firstChunk } = await reader.read();
|
||||
const initialText = new TextDecoder().decode(firstChunk!);
|
||||
expect(initialText).toContain(": keepalive");
|
||||
|
||||
// Now publish an event
|
||||
const bus = getEventBus("s6");
|
||||
bus.publish({ id: "e1", sessionId: "s6", type: "user", payload: { content: "real-time" }, direction: "outbound" });
|
||||
|
||||
// Read the event
|
||||
const { value: secondChunk } = await reader.read();
|
||||
const eventText = new TextDecoder().decode(secondChunk!);
|
||||
expect(eventText).toContain("event: message");
|
||||
expect(eventText).toContain("real-time");
|
||||
|
||||
reader.cancel();
|
||||
});
|
||||
});
|
||||
});
|
||||
396
packages/remote-control-server/src/__tests__/store.test.ts
Normal file
396
packages/remote-control-server/src/__tests__/store.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import {
|
||||
storeReset,
|
||||
storeCreateUser,
|
||||
storeGetUser,
|
||||
storeCreateToken,
|
||||
storeGetUserByToken,
|
||||
storeDeleteToken,
|
||||
storeCreateEnvironment,
|
||||
storeGetEnvironment,
|
||||
storeUpdateEnvironment,
|
||||
storeListActiveEnvironments,
|
||||
storeListActiveEnvironmentsByUsername,
|
||||
storeCreateSession,
|
||||
storeGetSession,
|
||||
storeUpdateSession,
|
||||
storeListSessions,
|
||||
storeListSessionsByUsername,
|
||||
storeListSessionsByEnvironment,
|
||||
storeDeleteSession,
|
||||
storeBindSession,
|
||||
storeIsSessionOwner,
|
||||
storeListSessionsByOwnerUuid,
|
||||
storeCreateWorkItem,
|
||||
storeGetWorkItem,
|
||||
storeGetPendingWorkItem,
|
||||
storeUpdateWorkItem,
|
||||
} from "../store";
|
||||
|
||||
describe("store", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
|
||||
// ---------- User ----------
|
||||
|
||||
describe("storeCreateUser", () => {
|
||||
test("creates a new user", () => {
|
||||
const user = storeCreateUser("alice");
|
||||
expect(user.username).toBe("alice");
|
||||
expect(user.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("returns existing user on duplicate create", () => {
|
||||
const first = storeCreateUser("bob");
|
||||
const second = storeCreateUser("bob");
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeGetUser", () => {
|
||||
test("returns undefined for non-existent user", () => {
|
||||
expect(storeGetUser("nobody")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns created user", () => {
|
||||
storeCreateUser("charlie");
|
||||
const user = storeGetUser("charlie");
|
||||
expect(user?.username).toBe("charlie");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Token ----------
|
||||
|
||||
describe("storeCreateToken / storeGetUserByToken", () => {
|
||||
test("creates and resolves token", () => {
|
||||
storeCreateUser("dave");
|
||||
storeCreateToken("dave", "tk_123");
|
||||
const entry = storeGetUserByToken("tk_123");
|
||||
expect(entry?.username).toBe("dave");
|
||||
expect(entry?.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("returns undefined for unknown token", () => {
|
||||
expect(storeGetUserByToken("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeDeleteToken", () => {
|
||||
test("deletes an existing token", () => {
|
||||
storeCreateUser("eve");
|
||||
storeCreateToken("eve", "tk_del");
|
||||
expect(storeDeleteToken("tk_del")).toBe(true);
|
||||
expect(storeGetUserByToken("tk_del")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns false for non-existent token", () => {
|
||||
expect(storeDeleteToken("nope")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Environment ----------
|
||||
|
||||
describe("storeCreateEnvironment", () => {
|
||||
test("creates environment with defaults", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s1" });
|
||||
expect(env.id).toMatch(/^env_/);
|
||||
expect(env.secret).toBe("s1");
|
||||
expect(env.status).toBe("active");
|
||||
expect(env.machineName).toBeNull();
|
||||
expect(env.maxSessions).toBe(1);
|
||||
expect(env.workerType).toBe("claude_code");
|
||||
expect(env.lastPollAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("creates environment with all options", () => {
|
||||
const env = storeCreateEnvironment({
|
||||
secret: "s2",
|
||||
machineName: "mac1",
|
||||
directory: "/home/user",
|
||||
branch: "main",
|
||||
gitRepoUrl: "https://github.com/test/repo",
|
||||
maxSessions: 5,
|
||||
workerType: "custom",
|
||||
bridgeId: "bridge1",
|
||||
username: "alice",
|
||||
});
|
||||
expect(env.machineName).toBe("mac1");
|
||||
expect(env.directory).toBe("/home/user");
|
||||
expect(env.branch).toBe("main");
|
||||
expect(env.gitRepoUrl).toBe("https://github.com/test/repo");
|
||||
expect(env.maxSessions).toBe(5);
|
||||
expect(env.workerType).toBe("custom");
|
||||
expect(env.bridgeId).toBe("bridge1");
|
||||
expect(env.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeGetEnvironment", () => {
|
||||
test("returns undefined for non-existent env", () => {
|
||||
expect(storeGetEnvironment("env_no")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns created environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
expect(storeGetEnvironment(env.id)).toBe(env);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeUpdateEnvironment", () => {
|
||||
test("updates existing environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const result = storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||
expect(result).toBe(true);
|
||||
const updated = storeGetEnvironment(env.id);
|
||||
expect(updated?.status).toBe("disconnected");
|
||||
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(env.updatedAt.getTime());
|
||||
});
|
||||
|
||||
test("returns false for non-existent environment", () => {
|
||||
expect(storeUpdateEnvironment("env_no", { status: "active" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeListActiveEnvironments", () => {
|
||||
test("returns only active environments", () => {
|
||||
const env1 = storeCreateEnvironment({ secret: "s1" });
|
||||
const env2 = storeCreateEnvironment({ secret: "s2" });
|
||||
storeUpdateEnvironment(env1.id, { status: "deregistered" });
|
||||
const active = storeListActiveEnvironments();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].id).toBe(env2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeListActiveEnvironmentsByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
storeCreateEnvironment({ secret: "s1", username: "alice" });
|
||||
storeCreateEnvironment({ secret: "s2", username: "bob" });
|
||||
const aliceEnvs = storeListActiveEnvironmentsByUsername("alice");
|
||||
expect(aliceEnvs).toHaveLength(1);
|
||||
expect(aliceEnvs[0].username).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Session ----------
|
||||
|
||||
describe("storeCreateSession", () => {
|
||||
test("creates session with defaults", () => {
|
||||
const session = storeCreateSession({});
|
||||
expect(session.id).toMatch(/^session_/);
|
||||
expect(session.status).toBe("idle");
|
||||
expect(session.source).toBe("remote-control");
|
||||
expect(session.environmentId).toBeNull();
|
||||
expect(session.workerEpoch).toBe(0);
|
||||
});
|
||||
|
||||
test("creates session with options", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const session = storeCreateSession({
|
||||
environmentId: env.id,
|
||||
title: "Test Session",
|
||||
source: "cli",
|
||||
permissionMode: "auto",
|
||||
username: "alice",
|
||||
});
|
||||
expect(session.environmentId).toBe(env.id);
|
||||
expect(session.title).toBe("Test Session");
|
||||
expect(session.source).toBe("cli");
|
||||
expect(session.permissionMode).toBe("auto");
|
||||
expect(session.username).toBe("alice");
|
||||
});
|
||||
|
||||
test("creates session with custom idPrefix", () => {
|
||||
const session = storeCreateSession({ idPrefix: "cse_" });
|
||||
expect(session.id).toMatch(/^cse_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeGetSession", () => {
|
||||
test("returns undefined for non-existent session", () => {
|
||||
expect(storeGetSession("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeUpdateSession", () => {
|
||||
test("updates existing session", () => {
|
||||
const session = storeCreateSession({});
|
||||
const result = storeUpdateSession(session.id, { title: "Updated", status: "active" });
|
||||
expect(result).toBe(true);
|
||||
const updated = storeGetSession(session.id);
|
||||
expect(updated?.title).toBe("Updated");
|
||||
expect(updated?.status).toBe("active");
|
||||
});
|
||||
|
||||
test("returns false for non-existent session", () => {
|
||||
expect(storeUpdateSession("nope", { title: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
test("increments workerEpoch", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { workerEpoch: 1 });
|
||||
expect(storeGetSession(session.id)?.workerEpoch).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeListSessions", () => {
|
||||
test("returns all sessions", () => {
|
||||
storeCreateSession({});
|
||||
storeCreateSession({});
|
||||
expect(storeListSessions()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeListSessionsByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
storeCreateSession({ username: "alice" });
|
||||
storeCreateSession({ username: "bob" });
|
||||
expect(storeListSessionsByUsername("alice")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeListSessionsByEnvironment", () => {
|
||||
test("filters by environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
storeCreateSession({ environmentId: env.id });
|
||||
storeCreateSession({});
|
||||
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeDeleteSession", () => {
|
||||
test("deletes existing session", () => {
|
||||
const session = storeCreateSession({});
|
||||
expect(storeDeleteSession(session.id)).toBe(true);
|
||||
expect(storeGetSession(session.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns false for non-existent session", () => {
|
||||
expect(storeDeleteSession("nope")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Session Ownership ----------
|
||||
|
||||
describe("storeBindSession / storeIsSessionOwner", () => {
|
||||
test("binds and checks ownership", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeBindSession(session.id, "uuid-1");
|
||||
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
|
||||
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(false);
|
||||
});
|
||||
|
||||
test("unbound session has no owner", () => {
|
||||
const session = storeCreateSession({});
|
||||
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(false);
|
||||
});
|
||||
|
||||
test("multiple owners per session", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeBindSession(session.id, "uuid-1");
|
||||
storeBindSession(session.id, "uuid-2");
|
||||
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
|
||||
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeListSessionsByOwnerUuid", () => {
|
||||
test("returns sessions owned by uuid", () => {
|
||||
const s1 = storeCreateSession({});
|
||||
const s2 = storeCreateSession({});
|
||||
storeBindSession(s1.id, "uuid-1");
|
||||
storeBindSession(s2.id, "uuid-1");
|
||||
const owned = storeListSessionsByOwnerUuid("uuid-1");
|
||||
expect(owned).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("returns empty for unknown uuid", () => {
|
||||
expect(storeListSessionsByOwnerUuid("nope")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("excludes deleted sessions", () => {
|
||||
const s1 = storeCreateSession({});
|
||||
storeBindSession(s1.id, "uuid-1");
|
||||
storeDeleteSession(s1.id);
|
||||
expect(storeListSessionsByOwnerUuid("uuid-1")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Work Items ----------
|
||||
|
||||
describe("storeCreateWorkItem", () => {
|
||||
test("creates work item with defaults", () => {
|
||||
const item = storeCreateWorkItem({
|
||||
environmentId: "env1",
|
||||
sessionId: "ses1",
|
||||
secret: "sec1",
|
||||
});
|
||||
expect(item.id).toMatch(/^work_/);
|
||||
expect(item.environmentId).toBe("env1");
|
||||
expect(item.sessionId).toBe("ses1");
|
||||
expect(item.state).toBe("pending");
|
||||
expect(item.secret).toBe("sec1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeGetWorkItem", () => {
|
||||
test("returns undefined for non-existent", () => {
|
||||
expect(storeGetWorkItem("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns created work item", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
expect(storeGetWorkItem(item.id)).toBe(item);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeGetPendingWorkItem", () => {
|
||||
test("returns pending work for environment", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
const found = storeGetPendingWorkItem("env1");
|
||||
expect(found?.id).toBe(item.id);
|
||||
});
|
||||
|
||||
test("returns undefined when no pending work", () => {
|
||||
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
expect(storeGetPendingWorkItem("env2")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("skips non-pending items", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
storeUpdateWorkItem(item.id, { state: "dispatched" });
|
||||
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeUpdateWorkItem", () => {
|
||||
test("updates existing work item", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
expect(storeUpdateWorkItem(item.id, { state: "acked" })).toBe(true);
|
||||
expect(storeGetWorkItem(item.id)?.state).toBe("acked");
|
||||
});
|
||||
|
||||
test("returns false for non-existent", () => {
|
||||
expect(storeUpdateWorkItem("nope", { state: "acked" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- storeReset ----------
|
||||
|
||||
describe("storeReset", () => {
|
||||
test("clears all data", () => {
|
||||
storeCreateUser("alice");
|
||||
storeCreateEnvironment({ secret: "s" });
|
||||
storeCreateSession({});
|
||||
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
|
||||
storeReset();
|
||||
|
||||
expect(storeGetUser("alice")).toBeUndefined();
|
||||
expect(storeListActiveEnvironments()).toHaveLength(0);
|
||||
expect(storeListSessions()).toHaveLength(0);
|
||||
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock config before imports
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 1, // Short timeout for tests
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { storeReset, storeCreateEnvironment, storeCreateSession, storeGetWorkItem, storeGetPendingWorkItem } from "../store";
|
||||
import {
|
||||
createWorkItem,
|
||||
pollWork,
|
||||
ackWork,
|
||||
stopWork,
|
||||
heartbeatWork,
|
||||
reconnectWorkForEnvironment,
|
||||
} from "../services/work-dispatch";
|
||||
|
||||
describe("Work Dispatch", () => {
|
||||
let envId: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
envId = env.id;
|
||||
const session = storeCreateSession({ environmentId: envId });
|
||||
sessionId = session.id;
|
||||
});
|
||||
|
||||
describe("createWorkItem", () => {
|
||||
test("creates work item for active environment", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
expect(workId).toMatch(/^work_/);
|
||||
const item = storeGetWorkItem(workId);
|
||||
expect(item?.state).toBe("pending");
|
||||
expect(item?.sessionId).toBe(sessionId);
|
||||
});
|
||||
|
||||
test("throws for non-existent environment", async () => {
|
||||
await expect(createWorkItem("env_no", sessionId)).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
test("throws for inactive environment", async () => {
|
||||
const inactiveEnv = storeCreateEnvironment({ secret: "s2" });
|
||||
// Manually set status to deregistered
|
||||
const { storeUpdateEnvironment } = await import("../store");
|
||||
storeUpdateEnvironment(inactiveEnv.id, { status: "deregistered" });
|
||||
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow("not active");
|
||||
});
|
||||
|
||||
test("encodes work secret as base64 JSON", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
const item = storeGetWorkItem(workId);
|
||||
const decoded = JSON.parse(Buffer.from(item!.secret, "base64url").toString());
|
||||
expect(decoded.version).toBe(1);
|
||||
expect(decoded.session_ingress_token).toBe("test-api-key");
|
||||
expect(decoded.api_base_url).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pollWork", () => {
|
||||
test("returns null when no work available (timeout)", async () => {
|
||||
const result = await pollWork(envId, 0.1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns pending work and marks as dispatched", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
const result = await pollWork(envId, 1);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(workId);
|
||||
expect(result!.state).toBe("dispatched");
|
||||
expect(result!.data.type).toBe("session");
|
||||
expect(result!.data.id).toBe(sessionId);
|
||||
// Work should no longer be pending
|
||||
expect(storeGetPendingWorkItem(envId)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not return work for different environment", async () => {
|
||||
const env2 = storeCreateEnvironment({ secret: "s2" });
|
||||
await createWorkItem(envId, sessionId);
|
||||
const result = await pollWork(env2.id, 0.1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ackWork", () => {
|
||||
test("marks work as acked", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
ackWork(workId);
|
||||
expect(storeGetWorkItem(workId)?.state).toBe("acked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopWork", () => {
|
||||
test("marks work as completed", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
stopWork(workId);
|
||||
expect(storeGetWorkItem(workId)?.state).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeatWork", () => {
|
||||
test("extends lease and returns heartbeat info", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
const result = heartbeatWork(workId);
|
||||
expect(result.lease_extended).toBe(true);
|
||||
expect(result.ttl_seconds).toBe(40); // heartbeatInterval * 2
|
||||
expect(result.last_heartbeat).toBeTruthy();
|
||||
});
|
||||
|
||||
test("returns default state for non-existent work", async () => {
|
||||
const result = heartbeatWork("work_no");
|
||||
expect(result.state).toBe("acked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnectWorkForEnvironment", () => {
|
||||
test("creates work items for idle sessions in environment", async () => {
|
||||
// Create another idle session
|
||||
storeCreateSession({ environmentId: envId });
|
||||
const workIds = await reconnectWorkForEnvironment(envId);
|
||||
expect(workIds).toHaveLength(2);
|
||||
for (const id of workIds) {
|
||||
expect(storeGetWorkItem(id)?.state).toBe("pending");
|
||||
}
|
||||
});
|
||||
|
||||
test("skips non-idle sessions", async () => {
|
||||
const activeSession = storeCreateSession({ environmentId: envId });
|
||||
const { storeUpdateSession } = await import("../store");
|
||||
storeUpdateSession(activeSession.id, { status: "active" });
|
||||
const workIds = await reconnectWorkForEnvironment(envId);
|
||||
// Only the original idle session should get work
|
||||
expect(workIds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns empty for environment with no sessions", async () => {
|
||||
const emptyEnv = storeCreateEnvironment({ secret: "s_empty" });
|
||||
const workIds = await reconnectWorkForEnvironment(emptyEnv.id);
|
||||
expect(workIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
484
packages/remote-control-server/src/__tests__/ws-handler.test.ts
Normal file
484
packages/remote-control-server/src/__tests__/ws-handler.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock config before imports
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { storeReset } from "../store";
|
||||
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||
import {
|
||||
ingestBridgeMessage,
|
||||
handleWebSocketOpen,
|
||||
handleWebSocketMessage,
|
||||
handleWebSocketClose,
|
||||
closeAllConnections,
|
||||
} from "../transport/ws-handler";
|
||||
|
||||
// Minimal WSContext mock
|
||||
function createMockWs(readyState = 1) {
|
||||
const sent: string[] = [];
|
||||
return {
|
||||
readyState,
|
||||
send: (data: string) => sent.push(data),
|
||||
close: (_code?: number, _reason?: string) => {},
|
||||
getSentData: () => sent,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("ws-handler", () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
}
|
||||
closeAllConnections();
|
||||
});
|
||||
|
||||
describe("ingestBridgeMessage", () => {
|
||||
test("ignores keep_alive messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", { type: "keep_alive" });
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("derives type from message.role for user messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
message: { role: "user", content: "hello" },
|
||||
uuid: "u1",
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("user");
|
||||
expect((events[0] as any).direction).toBe("inbound");
|
||||
});
|
||||
|
||||
test("derives type from message.role for assistant messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
message: { role: "assistant", content: [{ type: "text", text: "response" }] },
|
||||
uuid: "u2",
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("assistant");
|
||||
const payload = (events[0] as any).payload as Record<string, unknown>;
|
||||
expect(payload.content).toBe("response");
|
||||
});
|
||||
|
||||
test("derives type from explicit type field", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", { type: "control_request", request_id: "r1", request: { subtype: "interrupt" } });
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("control_request");
|
||||
});
|
||||
|
||||
test("derives result type from subtype/result fields", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", { subtype: "success", uuid: "u3", result: "done" });
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("result");
|
||||
});
|
||||
|
||||
test("derives system type from session_id field", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", { session_id: "s1", init: true });
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("system");
|
||||
});
|
||||
|
||||
test("handles control_response type", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
type: "control_response",
|
||||
response: { subtype: "success" },
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("control_response");
|
||||
});
|
||||
|
||||
test("handles partial_assistant type", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
type: "partial_assistant",
|
||||
message: { content: "partial..." },
|
||||
uuid: "u4",
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("partial_assistant");
|
||||
});
|
||||
|
||||
test("falls back to unknown type", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", { data: "something" });
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).type).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleWebSocketOpen", () => {
|
||||
test("subscribes to event bus and replays missed events", () => {
|
||||
// Publish some events before WS connects
|
||||
const bus = getEventBus("s1");
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hello" }, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
|
||||
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "s1");
|
||||
|
||||
// Should have replayed the outbound event (only outbound events are forwarded to WS)
|
||||
const sent = ws.getSentData();
|
||||
expect(sent.length).toBeGreaterThanOrEqual(1);
|
||||
// First message should be the outbound user event
|
||||
const msg = JSON.parse(sent[0]);
|
||||
expect(msg.type).toBe("user");
|
||||
});
|
||||
|
||||
test("replaces existing connection for same session", () => {
|
||||
const ws1 = createMockWs();
|
||||
const ws2 = createMockWs();
|
||||
handleWebSocketOpen(ws1, "s2");
|
||||
handleWebSocketOpen(ws2, "s2");
|
||||
|
||||
// ws2 should be the active connection
|
||||
const bus = getEventBus("s2");
|
||||
bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: { content: "test" }, direction: "outbound" });
|
||||
expect(ws2.getSentData().length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleWebSocketMessage", () => {
|
||||
test("parses NDJSON and ingests each message", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
|
||||
const ws = createMockWs();
|
||||
const data = JSON.stringify({ type: "user", message: { role: "user", content: "hello" } }) + "\n" +
|
||||
JSON.stringify({ type: "assistant", message: { role: "assistant", content: "hi" } }) + "\n";
|
||||
handleWebSocketMessage(ws, "s1", data);
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("ignores malformed JSON lines", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
|
||||
const ws = createMockWs();
|
||||
handleWebSocketMessage(ws, "s1", "not json\n");
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleWebSocketClose", () => {
|
||||
test("cleans up on close", () => {
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "s3");
|
||||
handleWebSocketClose(ws, "s3", 1000, "done");
|
||||
|
||||
// After close, publishing events should not cause errors
|
||||
const bus = getEventBus("s3");
|
||||
expect(() =>
|
||||
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: {}, direction: "outbound" })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSDKMessage (via handleWebSocketOpen outbound delivery)", () => {
|
||||
test("converts permission_response with approved=true", () => {
|
||||
const bus = getEventBus("pr1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "pr1");
|
||||
|
||||
bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "pr1",
|
||||
type: "permission_response",
|
||||
payload: { approved: true, request_id: "req1" },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_response");
|
||||
expect(lastMsg.response.subtype).toBe("success");
|
||||
expect(lastMsg.response.request_id).toBe("req1");
|
||||
expect(lastMsg.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
test("converts permission_response with approved=false", () => {
|
||||
const bus = getEventBus("pr2");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "pr2");
|
||||
|
||||
bus.publish({
|
||||
id: "e2",
|
||||
sessionId: "pr2",
|
||||
type: "permission_response",
|
||||
payload: { approved: false, request_id: "req2" },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_response");
|
||||
expect(lastMsg.response.subtype).toBe("error");
|
||||
expect(lastMsg.response.error).toBe("Permission denied by user");
|
||||
expect(lastMsg.response.response.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
test("converts permission_response with existing response object", () => {
|
||||
const bus = getEventBus("pr3");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "pr3");
|
||||
|
||||
bus.publish({
|
||||
id: "e3",
|
||||
sessionId: "pr3",
|
||||
type: "control_response",
|
||||
payload: { response: { subtype: "success", data: "custom" } },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_response");
|
||||
expect(lastMsg.response.subtype).toBe("success");
|
||||
expect(lastMsg.response.data).toBe("custom");
|
||||
});
|
||||
|
||||
test("converts interrupt event", () => {
|
||||
const bus = getEventBus("int1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "int1");
|
||||
|
||||
bus.publish({
|
||||
id: "e4",
|
||||
sessionId: "int1",
|
||||
type: "interrupt",
|
||||
payload: { action: "interrupt" },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_request");
|
||||
expect(lastMsg.request_id).toBe("e4");
|
||||
expect(lastMsg.request.subtype).toBe("interrupt");
|
||||
});
|
||||
|
||||
test("converts control_request event", () => {
|
||||
const bus = getEventBus("cr1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "cr1");
|
||||
|
||||
bus.publish({
|
||||
id: "e5",
|
||||
sessionId: "cr1",
|
||||
type: "control_request",
|
||||
payload: { request_id: "req5", request: { subtype: "permission", tool_name: "Bash" } },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_request");
|
||||
expect(lastMsg.request_id).toBe("req5");
|
||||
expect(lastMsg.request.subtype).toBe("permission");
|
||||
});
|
||||
|
||||
test("converts user_message event type", () => {
|
||||
const bus = getEventBus("um1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "um1");
|
||||
|
||||
bus.publish({
|
||||
id: "e6",
|
||||
sessionId: "um1",
|
||||
type: "user_message",
|
||||
payload: { content: "hello world" },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("user");
|
||||
expect(lastMsg.message.content).toBe("hello world");
|
||||
});
|
||||
|
||||
test("converts generic event type", () => {
|
||||
const bus = getEventBus("gen1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "gen1");
|
||||
|
||||
bus.publish({
|
||||
id: "e7",
|
||||
sessionId: "gen1",
|
||||
type: "status",
|
||||
payload: { state: "running" },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("status");
|
||||
expect(lastMsg.message).toEqual({ state: "running" });
|
||||
});
|
||||
|
||||
test("permission_response with updated_input", () => {
|
||||
const bus = getEventBus("ui1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "ui1");
|
||||
|
||||
bus.publish({
|
||||
id: "e8",
|
||||
sessionId: "ui1",
|
||||
type: "permission_response",
|
||||
payload: { approved: true, request_id: "req8", updated_input: { cmd: "ls -la" } },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.response.response.behavior).toBe("allow");
|
||||
expect(lastMsg.response.response.updatedInput).toEqual({ cmd: "ls -la" });
|
||||
});
|
||||
|
||||
test("permission_response with updated_permissions", () => {
|
||||
const bus = getEventBus("up1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "up1");
|
||||
|
||||
const permissions = [{ type: "setMode", mode: "acceptEdits", destination: "session" }];
|
||||
bus.publish({
|
||||
id: "ep1",
|
||||
sessionId: "up1",
|
||||
type: "permission_response",
|
||||
payload: {
|
||||
approved: true,
|
||||
request_id: "req-ep1",
|
||||
updated_input: { plan: "my plan" },
|
||||
updated_permissions: permissions,
|
||||
},
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_response");
|
||||
expect(lastMsg.response.subtype).toBe("success");
|
||||
expect(lastMsg.response.response.behavior).toBe("allow");
|
||||
expect(lastMsg.response.response.updatedInput).toEqual({ plan: "my plan" });
|
||||
expect(lastMsg.response.response.updatedPermissions).toEqual(permissions);
|
||||
});
|
||||
|
||||
test("permission_response denied with feedback message", () => {
|
||||
const bus = getEventBus("dm1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "dm1");
|
||||
|
||||
bus.publish({
|
||||
id: "dm1",
|
||||
sessionId: "dm1",
|
||||
type: "permission_response",
|
||||
payload: {
|
||||
approved: false,
|
||||
request_id: "req-dm1",
|
||||
message: "Please add more tests",
|
||||
},
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_response");
|
||||
expect(lastMsg.response.subtype).toBe("error");
|
||||
expect(lastMsg.response.response.behavior).toBe("deny");
|
||||
expect(lastMsg.response.message).toBe("Please add more tests");
|
||||
});
|
||||
|
||||
test("does not forward inbound events to WS", () => {
|
||||
const bus = getEventBus("no_in");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "no_in");
|
||||
|
||||
bus.publish({
|
||||
id: "e9",
|
||||
sessionId: "no_in",
|
||||
type: "assistant",
|
||||
payload: { content: "reply" },
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
// Only replayed events, no new inbound delivery
|
||||
const sent = ws.getSentData();
|
||||
// No outbound events were published, so only replay (if any)
|
||||
// Since the bus was fresh, no replay
|
||||
expect(sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("control_request falls back to payload when no request field", () => {
|
||||
const bus = getEventBus("cf1");
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "cf1");
|
||||
|
||||
bus.publish({
|
||||
id: "e10",
|
||||
sessionId: "cf1",
|
||||
type: "control_request",
|
||||
payload: { request_id: "req10", subtype: "custom", data: "test" },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const sent = ws.getSentData();
|
||||
const lastMsg = JSON.parse(sent[sent.length - 1]);
|
||||
expect(lastMsg.type).toBe("control_request");
|
||||
expect(lastMsg.request_id).toBe("req10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeAllConnections", () => {
|
||||
test("closes all active connections", () => {
|
||||
const ws1 = createMockWs();
|
||||
const ws2 = createMockWs();
|
||||
handleWebSocketOpen(ws1, "s1");
|
||||
handleWebSocketOpen(ws2, "s2");
|
||||
closeAllConnections();
|
||||
// No errors thrown
|
||||
});
|
||||
|
||||
test("no-op when no connections", () => {
|
||||
expect(() => closeAllConnections()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user