Skip to content

Commit e508941

Browse files
test: add comprehensive server-side tests for remote-control-server
165 tests covering store, auth, event-bus, services, work-dispatch, ws-handler, and middleware modules. Also includes ws-handler fix to replay all events (inbound + outbound) for bridge reconnection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 855cc4b commit e508941

8 files changed

Lines changed: 1719 additions & 3 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";
2+
3+
// Mock config before importing modules that depend on it
4+
const mockConfig = {
5+
port: 3000,
6+
host: "0.0.0.0",
7+
apiKeys: ["test-key-1", "test-key-2"],
8+
baseUrl: "",
9+
pollTimeout: 8,
10+
heartbeatInterval: 20,
11+
jwtExpiresIn: 3600,
12+
disconnectTimeout: 300,
13+
};
14+
15+
mock.module("../config", () => ({
16+
config: mockConfig,
17+
getBaseUrl: () => "http://localhost:3000",
18+
}));
19+
20+
import { validateApiKey, hashApiKey } from "../auth/api-key";
21+
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
22+
import { issueToken, resolveToken } from "../auth/token";
23+
import { storeReset, storeCreateUser } from "../store";
24+
25+
// ---------- api-key ----------
26+
27+
describe("validateApiKey", () => {
28+
test("validates a configured API key", () => {
29+
expect(validateApiKey("test-key-1")).toBe(true);
30+
expect(validateApiKey("test-key-2")).toBe(true);
31+
});
32+
33+
test("rejects unknown key", () => {
34+
expect(validateApiKey("unknown-key")).toBe(false);
35+
});
36+
37+
test("rejects undefined", () => {
38+
expect(validateApiKey(undefined)).toBe(false);
39+
});
40+
41+
test("rejects empty string", () => {
42+
expect(validateApiKey("")).toBe(false);
43+
});
44+
});
45+
46+
describe("hashApiKey", () => {
47+
test("produces consistent SHA-256 hex", () => {
48+
const hash = hashApiKey("my-key");
49+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
50+
expect(hashApiKey("my-key")).toBe(hash);
51+
});
52+
53+
test("different keys produce different hashes", () => {
54+
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
55+
});
56+
});
57+
58+
// ---------- jwt ----------
59+
60+
describe("JWT", () => {
61+
// JWT reads process.env.RCS_API_KEYS directly (not via config)
62+
const originalKeys = process.env.RCS_API_KEYS;
63+
64+
beforeEach(() => {
65+
process.env.RCS_API_KEYS = "jwt-test-secret";
66+
});
67+
68+
afterAll(() => {
69+
process.env.RCS_API_KEYS = originalKeys;
70+
});
71+
72+
describe("generateWorkerJwt", () => {
73+
test("produces a three-part base64url token", () => {
74+
const token = generateWorkerJwt("ses_123", 3600);
75+
const parts = token.split(".");
76+
expect(parts).toHaveLength(3);
77+
for (const part of parts) {
78+
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
79+
}
80+
});
81+
82+
test("contains correct header", () => {
83+
const token = generateWorkerJwt("ses_123", 3600);
84+
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
85+
expect(header.alg).toBe("HS256");
86+
expect(header.typ).toBe("JWT");
87+
});
88+
89+
test("throws when no API key configured", () => {
90+
delete process.env.RCS_API_KEYS;
91+
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
92+
process.env.RCS_API_KEYS = "jwt-test-secret";
93+
});
94+
});
95+
96+
describe("verifyWorkerJwt", () => {
97+
test("verifies a valid token", () => {
98+
const token = generateWorkerJwt("ses_abc", 3600);
99+
const payload = verifyWorkerJwt(token);
100+
expect(payload).not.toBeNull();
101+
expect(payload!.session_id).toBe("ses_abc");
102+
expect(payload!.role).toBe("worker");
103+
expect(payload!.iat).toBeGreaterThan(0);
104+
expect(payload!.exp).toBeGreaterThan(payload!.iat);
105+
});
106+
107+
test("returns null for expired token", () => {
108+
const token = generateWorkerJwt("ses_old", -10);
109+
expect(verifyWorkerJwt(token)).toBeNull();
110+
});
111+
112+
test("returns null for malformed token (not 3 parts)", () => {
113+
expect(verifyWorkerJwt("a.b")).toBeNull();
114+
expect(verifyWorkerJwt("just-a-string")).toBeNull();
115+
});
116+
117+
test("returns null for tampered signature", () => {
118+
const token = generateWorkerJwt("ses_123", 3600);
119+
const parts = token.split(".");
120+
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
121+
expect(verifyWorkerJwt(tampered)).toBeNull();
122+
});
123+
124+
test("returns null for wrong signing key", () => {
125+
const token = generateWorkerJwt("ses_123", 3600);
126+
process.env.RCS_API_KEYS = "wrong-key";
127+
expect(verifyWorkerJwt(token)).toBeNull();
128+
process.env.RCS_API_KEYS = "jwt-test-secret";
129+
});
130+
});
131+
});
132+
133+
// ---------- token ----------
134+
135+
describe("issueToken / resolveToken", () => {
136+
beforeEach(() => {
137+
storeReset();
138+
});
139+
140+
test("issues and resolves a token", () => {
141+
storeCreateUser("alice");
142+
const { token, expires_in } = issueToken("alice");
143+
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
144+
expect(expires_in).toBe(86400);
145+
expect(resolveToken(token)).toBe("alice");
146+
});
147+
148+
test("returns null for unknown token", () => {
149+
expect(resolveToken("nonexistent")).toBeNull();
150+
});
151+
152+
test("returns null for undefined token", () => {
153+
expect(resolveToken(undefined)).toBeNull();
154+
});
155+
156+
test("tokens are unique", () => {
157+
storeCreateUser("alice");
158+
const t1 = issueToken("alice").token;
159+
const t2 = issueToken("alice").token;
160+
expect(t1).not.toBe(t2);
161+
});
162+
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, test, expect, beforeEach } from "bun:test";
2+
import { EventBus, getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
3+
4+
describe("EventBus", () => {
5+
let bus: EventBus;
6+
7+
beforeEach(() => {
8+
bus = new EventBus();
9+
});
10+
11+
describe("publish", () => {
12+
test("publishes event with seqNum starting at 1", () => {
13+
const event = bus.publish({
14+
id: "e1",
15+
sessionId: "s1",
16+
type: "user",
17+
payload: { content: "hello" },
18+
direction: "outbound",
19+
});
20+
expect(event.seqNum).toBe(1);
21+
expect(event.createdAt).toBeGreaterThan(0);
22+
});
23+
24+
test("increments seqNum on each publish", () => {
25+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
26+
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
27+
const event = bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
28+
expect(event.seqNum).toBe(3);
29+
});
30+
31+
test("throws when publishing to a closed bus", () => {
32+
bus.close();
33+
expect(() =>
34+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" }),
35+
).toThrow("EventBus is closed");
36+
});
37+
});
38+
39+
describe("subscribe", () => {
40+
test("receives published events", () => {
41+
const received: unknown[] = [];
42+
bus.subscribe((event) => received.push(event));
43+
44+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hi" }, direction: "outbound" });
45+
expect(received).toHaveLength(1);
46+
expect((received[0] as any).payload).toEqual({ content: "hi" });
47+
});
48+
49+
test("unsubscribe stops receiving events", () => {
50+
const received: unknown[] = [];
51+
const unsub = bus.subscribe((event) => received.push(event));
52+
unsub();
53+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
54+
expect(received).toHaveLength(0);
55+
});
56+
57+
test("multiple subscribers all receive events", () => {
58+
const r1: unknown[] = [];
59+
const r2: unknown[] = [];
60+
bus.subscribe((e) => r1.push(e));
61+
bus.subscribe((e) => r2.push(e));
62+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
63+
expect(r1).toHaveLength(1);
64+
expect(r2).toHaveLength(1);
65+
});
66+
67+
test("subscriber error does not affect other subscribers", () => {
68+
const received: unknown[] = [];
69+
bus.subscribe(() => {
70+
throw new Error("boom");
71+
});
72+
bus.subscribe((e) => received.push(e));
73+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
74+
expect(received).toHaveLength(1);
75+
});
76+
77+
test("subscriberCount", () => {
78+
expect(bus.subscriberCount()).toBe(0);
79+
const unsub1 = bus.subscribe(() => {});
80+
expect(bus.subscriberCount()).toBe(1);
81+
const unsub2 = bus.subscribe(() => {});
82+
expect(bus.subscriberCount()).toBe(2);
83+
unsub1();
84+
expect(bus.subscriberCount()).toBe(1);
85+
});
86+
});
87+
88+
describe("getEventsSince", () => {
89+
test("returns events after given seqNum", () => {
90+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
91+
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
92+
bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
93+
94+
const events = bus.getEventsSince(1);
95+
expect(events).toHaveLength(2);
96+
expect(events[0].seqNum).toBe(2);
97+
expect(events[1].seqNum).toBe(3);
98+
});
99+
100+
test("returns empty for seqNum beyond last", () => {
101+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
102+
expect(bus.getEventsSince(1)).toHaveLength(0);
103+
});
104+
105+
test("returns all events when seqNum is 0", () => {
106+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
107+
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
108+
expect(bus.getEventsSince(0)).toHaveLength(2);
109+
});
110+
});
111+
112+
describe("getLastSeqNum", () => {
113+
test("returns 0 for empty bus", () => {
114+
expect(bus.getLastSeqNum()).toBe(0);
115+
});
116+
117+
test("returns last seqNum after publishes", () => {
118+
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
119+
bus.publish({ id: "e2", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
120+
expect(bus.getLastSeqNum()).toBe(2);
121+
});
122+
});
123+
124+
describe("close", () => {
125+
test("clears subscribers and prevents publishing", () => {
126+
bus.subscribe(() => {});
127+
bus.close();
128+
expect(bus.subscriberCount()).toBe(0);
129+
expect(() => bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" })).toThrow();
130+
});
131+
});
132+
});
133+
134+
describe("EventBus registry", () => {
135+
beforeEach(() => {
136+
// Clean up global registry
137+
for (const [key] of getAllEventBuses()) {
138+
removeEventBus(key);
139+
}
140+
});
141+
142+
describe("getEventBus", () => {
143+
test("creates new bus for unknown session", () => {
144+
const bus = getEventBus("s1");
145+
expect(bus).toBeInstanceOf(EventBus);
146+
expect(getAllEventBuses().has("s1")).toBe(true);
147+
});
148+
149+
test("returns same bus for same session", () => {
150+
const bus1 = getEventBus("s1");
151+
const bus2 = getEventBus("s1");
152+
expect(bus1).toBe(bus2);
153+
});
154+
});
155+
156+
describe("removeEventBus", () => {
157+
test("removes and closes bus", () => {
158+
const bus = getEventBus("s2");
159+
removeEventBus("s2");
160+
expect(getAllEventBuses().has("s2")).toBe(false);
161+
expect(() => bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: {}, direction: "outbound" })).toThrow();
162+
});
163+
164+
test("no-op for non-existent bus", () => {
165+
expect(() => removeEventBus("nonexistent")).not.toThrow();
166+
});
167+
});
168+
169+
describe("getAllEventBuses", () => {
170+
test("returns all registered buses", () => {
171+
getEventBus("a");
172+
getEventBus("b");
173+
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2);
174+
});
175+
});
176+
});

0 commit comments

Comments
 (0)