1
11import { afterAll, beforeAll, describe, expect, it } from "vitest";
12import * as Y from "yjs";
13import {
14 buildRealtimeFixture,
15 connectClient,
16 mintToken,
17 probeUpgrade,
18 type RealtimeFixture,
19} from "./rt-helpers";
20import { WS_CLOSE, type HelloFrame } from "@factory/shared/realtime/frames";
21
22let fx: RealtimeFixture;
23
24beforeAll(async () => {
25 fx = await buildRealtimeFixture({
26 pages: new Set(["p-it", "p-view", "p-none", "p-seed"]),
27 workspaces: { "p-it": "w1", "p-view": "w1", "p-none": "w1", "p-seed": "w1" },
28 levels: {
29 "page:p-it": "edit",
30 "page:p-view": "view",
31 "page:p-none": "none",
32 },
33 });
34});
35afterAll(async () => {
36 await fx.stop();
37});
38
39const hello = async (c: Awaited<ReturnType<typeof connectClient>>): Promise<HelloFrame> =>
40 (await c.nextFrame("hello")) as unknown as HelloFrame;
41
42describe("handshake: successful joins (R1)", () => {
43 it("valid token + EDIT-able page → hello with canEdit=true + identity fields", async () => {
44 const c = await connectClient(fx, { room: "page:p-it" });
45 try {
46 const h = await hello(c);
47 expect(h.connId).toMatch(/^[a-f0-9]{16}$/);
48 expect(h.canEdit).toBe(true);
49 expect(h.serverTime).toBeGreaterThan(0);
50 expect(h.roomPeers).toEqual([]);
51 } finally {
52 c.close();
53 }
54 });
55
56 it("VIEW-only → hello canEdit=false and a subsequent update is dropped with error FORBIDDEN (socket stays open)", async () => {
57 const c = await connectClient(fx, { room: "page:p-view" });
58 try {
59 const h = await hello(c);
60 expect(h.canEdit).toBe(false);
61
62
63 const probeDoc = new Y.Doc();
64 const yText = probeDoc.getText("t");
65 yText.insert(0, "view-only write attempt");
66 const bytes = Y.encodeStateAsUpdate(probeDoc);
67 probeDoc.destroy();
68
69 c.sendBinary(1, bytes);
70 const err = await c.nextFrame("error");
71 expect(err.code).toBe("FORBIDDEN");
72 expect(err.message).toBeTruthy();
73
74
75
76 expect(c.isOpen()).toBe(true);
77 } finally {
78 c.close();
79 }
80 });
81
82 it("the token via the Authorization: Bearer header (the proxy path) also joins", async () => {
83 const token = mintToken({ sub: "u-header", workspaces: ["w1"] });
84
85 const c = await connectClient(fx, {
86 room: "page:p-it",
87 url: `${fx.wsUrl}/realtime?room=${encodeURIComponent("page:p-it")}`,
88 headers: { authorization: `Bearer ${token}` },
89 });
90 try {
91 const h = await hello(c);
92 expect(h.canEdit).toBe(true);
93 } finally {
94 c.close();
95 }
96 });
97
98 it("a user's own user:{id} control room joins (hello, canEdit=false)", async () => {
99 const c = await connectClient(fx, {
100 token: mintToken({ sub: "u-ctl", workspaces: ["w1"] }),
101 room: "user:u-ctl",
102 });
103 try {
104 const h = await hello(c);
105 expect(h.canEdit).toBe(false);
106 } finally {
107 c.close();
108 }
109 });
110
111 it("a seeded page hydrates: sync.request receives the doc state (done frame)", async () => {
112 const blocks = [
113 { id: "b1", type: "paragraph", content: { text: "hello realtime" }, parentId: null, position: "a0" },
114 { id: "b2", type: "paragraph", content: { text: "second block" }, parentId: null, position: "a1" },
115 ] as const;
116 fx.store.seed("p-seed", blocks);
117 const c = await connectClient(fx, { room: "page:p-seed" });
118 try {
119 await hello(c);
120 c.sendText({ type: "sync.request" });
121 const done = await c.nextFrameWhere((f) => f.type === "sync.state" && f.done === true);
122 expect(done.chunk).toBeTypeOf("string");
123
124
125 const chunk = done.chunk as string;
126 if (chunk.length > 0) {
127 const fresh = new Y.Doc();
128 Y.applyUpdate(fresh, Buffer.from(chunk, "base64"), "test");
129 const frag = fresh.getXmlFragment("blocks");
130 expect(frag.length).toBe(2);
131 fresh.destroy();
132 }
133 } finally {
134 c.close();
135 }
136 });
137});
138
139describe("handshake: pre-join rejections (HTTP, no room join)", () => {
140 it("bad token (garbage) → HTTP 401, no upgrade", async () => {
141 const r = await probeUpgrade(fx, { token: "not.a.jwt", room: "page:p-it" });
142 expect(r.status).toBe(401);
143 });
144
145 it("bad token (wrong signature) → HTTP 401", async () => {
146 const forged = mintToken({ sub: "u-forge", workspaces: ["w1"] }, "a-different-secret");
147 const r = await probeUpgrade(fx, { token: forged, room: "page:p-it" });
148 expect(r.status).toBe(401);
149 });
150
151 it("expired token (beyond the 5 min grace) → HTTP 401", async () => {
152 const expired = mintToken({ sub: "u-exp", workspaces: ["w1"], expSec: Math.floor(Date.now() / 1000) - 3_600 });
153 const r = await probeUpgrade(fx, { token: expired, room: "page:p-it" });
154 expect(r.status).toBe(401);
155 });
156
157 it("token for workspace A on a workspace-B room → HTTP 403 (no-existence, 404-style)", async () => {
158
159 const token = mintToken({ sub: "u-w2", workspaces: ["w2"] });
160 const r = await probeUpgrade(fx, { token, room: "page:p-it" });
161 expect(r.status).toBe(403);
162 });
163
164 it("user:{otherId} join (not the socket's own control room) → HTTP 403", async () => {
165 const token = mintToken({ sub: "u-own", workspaces: ["w1"] });
166 const r = await probeUpgrade(fx, { token, room: "user:someone-else" });
167 expect(r.status).toBe(403);
168 });
169
170 it("level=none resource → HTTP 403 (no VIEW)", async () => {
171 const r = await probeUpgrade(fx, { room: "page:p-none" });
172 expect(r.status).toBe(403);
173 });
174
175 it("unknown resource → HTTP 403 (meta null, fail-closed)", async () => {
176 const r = await probeUpgrade(fx, { room: "page:does-not-exist" });
177 expect(r.status).toBe(403);
178 });
179
180 it("malformed room key → HTTP 400 bad_room", async () => {
181 const r = await probeUpgrade(fx, { room: "nonsense" });
182 expect(r.status).toBe(400);
183 });
184
185 it("wrong path → connection destroyed (no 101)", async () => {
186 const r = await probeUpgrade(fx, { path: "/wrong?token=x" });
187 expect(r.status).not.toBe(101);
188 });
189});
190
191describe("handshake: rate-limit rejections (upgrade-time, R1/R6)", () => {
192 it("31 rapid connects from one IP → the 31st is rejected 429 + audit", async () => {
193
194
195
196 const fxL = await buildRealtimeFixture({
197 pages: new Set(["p-it"]),
198 workspaces: { "p-it": "w1" },
199 levels: { "page:p-it": "edit" },
200 trustProxy: true,
201 });
202 try {
203 const results: number[] = [];
204 for (let i = 0; i < 31; i += 1) {
205 const r = await probeUpgrade(fxL, {
206 room: "page:p-it",
207 headers: { "x-forwarded-for": "10.9.0.7" },
208 });
209 results.push(r.status);
210 }
211 const ok = results.filter((s) => s === 101).length;
212 const limited = results.filter((s) => s === 429).length;
213 expect(ok).toBe(30);
214 expect(limited).toBe(1);
215 expect(fxL.audit.abuse("connect_rate_limited")).toBe(1);
216 } finally {
217 await fxL.stop();
218 }
219 });
220
221 it("11 live sockets for one user → the oldest is evicted (control cap 10, 4008 + control_evicted frame + audit)", async () => {
222
223
224
225
226
227 const fxU = await buildRealtimeFixture({
228 pages: new Set(["p-it"]),
229 workspaces: { "p-it": "w1" },
230 levels: { "page:p-it": "edit" },
231 });
232 const floodToken = mintToken({ sub: "u-flood", workspaces: ["w1"] });
233 const clients = [];
234 try {
235 for (let i = 0; i < 10; i += 1) {
236 const c = await connectClient(fxU, { room: "page:p-it", token: floodToken });
237 await c.nextFrame("hello");
238 clients.push(c);
239 }
240
241
242 const eleventh = await connectClient(fxU, { room: "page:p-it", token: floodToken });
243 await eleventh.nextFrame("hello");
244 const evicted = clients[0]!;
245 const err = await evicted.nextFrameWhere((f) => f.type === "error" && f.code === "control_evicted", 5_000);
246 expect(err.message).toBeTruthy();
247 const closed = await evicted.onClose(5_000);
248 expect(closed.code).toBe(WS_CLOSE.RateOrPolicy);
249 expect(fxU.audit.abuse("control_evicted")).toBe(1);
250
251 expect(fxU.server.sockets().filter((s) => s.session.userId === "u-flood").length).toBe(10);
252 clients.push(eleventh);
253 } finally {
254 for (const c of clients) c.terminate();
255 await fxU.stop();
256 }
257 });
258});
259
260describe("handshake: the WS close codes exist on the shared contract", () => {
261 it("4000/4001/4002/4008/4009/4010 are distinct and in the 4000 range", () => {
262 const codes = [
263 WS_CLOSE.ProtocolOrHeartbeat,
264 WS_CLOSE.Unauthorized,
265 WS_CLOSE.Forbidden,
266 WS_CLOSE.RateOrPolicy,
267 WS_CLOSE.PayloadTooLarge,
268 WS_CLOSE.ServerShutdown,
269 ];
270 expect(new Set(codes).size).toBe(6);
271 for (const c of codes) expect(c).toBeGreaterThanOrEqual(4000);
272 });
273});
274