/** * Realtime integration (TASK-0091, acceptance criterion 1): the handshake * matrix. * * The pre-join rejections (bad token / wrong workspace / no permission / * foreign control room / bad room) happen AT the HTTP upgrade — the server * answers 401/403/400/429 and destroys the socket before any WebSocket * close frame exists (R1: "no room join"). Post-join drops use the WS close * codes (4000–4010). This spec asserts both layers. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import * as Y from "yjs"; import { buildRealtimeFixture, connectClient, mintToken, probeUpgrade, type RealtimeFixture, } from "./rt-helpers"; import { WS_CLOSE, type HelloFrame } from "@factory/shared/realtime/frames"; let fx: RealtimeFixture; beforeAll(async () => { fx = await buildRealtimeFixture({ pages: new Set(["p-it", "p-view", "p-none", "p-seed"]), workspaces: { "p-it": "w1", "p-view": "w1", "p-none": "w1", "p-seed": "w1" }, levels: { "page:p-it": "edit", "page:p-view": "view", "page:p-none": "none", }, }); }); afterAll(async () => { await fx.stop(); }); const hello = async (c: Awaited>): Promise => (await c.nextFrame("hello")) as unknown as HelloFrame; describe("handshake: successful joins (R1)", () => { it("valid token + EDIT-able page → hello with canEdit=true + identity fields", async () => { const c = await connectClient(fx, { room: "page:p-it" }); try { const h = await hello(c); expect(h.connId).toMatch(/^[a-f0-9]{16}$/); expect(h.canEdit).toBe(true); expect(h.serverTime).toBeGreaterThan(0); expect(h.roomPeers).toEqual([]); } finally { c.close(); } }); it("VIEW-only → hello canEdit=false and a subsequent update is dropped with error FORBIDDEN (socket stays open)", async () => { const c = await connectClient(fx, { room: "page:p-view" }); try { const h = await hello(c); expect(h.canEdit).toBe(false); // A genuine Yjs update on a throwaway doc. const probeDoc = new Y.Doc(); const yText = probeDoc.getText("t"); yText.insert(0, "view-only write attempt"); const bytes = Y.encodeStateAsUpdate(probeDoc); probeDoc.destroy(); c.sendBinary(1, bytes); const err = await c.nextFrame("error"); expect(err.code).toBe("FORBIDDEN"); expect(err.message).toBeTruthy(); // The socket is NOT closed by the per-frame check (R1: drop, not // kill — the double-check is flag + per-frame, the close is for // the strike path). expect(c.isOpen()).toBe(true); } finally { c.close(); } }); it("the token via the Authorization: Bearer header (the proxy path) also joins", async () => { const token = mintToken({ sub: "u-header", workspaces: ["w1"] }); // No token in the query string; the header carries it. const c = await connectClient(fx, { room: "page:p-it", url: `${fx.wsUrl}/realtime?room=${encodeURIComponent("page:p-it")}`, headers: { authorization: `Bearer ${token}` }, }); try { const h = await hello(c); expect(h.canEdit).toBe(true); } finally { c.close(); } }); it("a user's own user:{id} control room joins (hello, canEdit=false)", async () => { const c = await connectClient(fx, { token: mintToken({ sub: "u-ctl", workspaces: ["w1"] }), room: "user:u-ctl", }); try { const h = await hello(c); expect(h.canEdit).toBe(false); } finally { c.close(); } }); it("a seeded page hydrates: sync.request receives the doc state (done frame)", async () => { const blocks = [ { id: "b1", type: "paragraph", content: { text: "hello realtime" }, parentId: null, position: "a0" }, { id: "b2", type: "paragraph", content: { text: "second block" }, parentId: null, position: "a1" }, ] as const; fx.store.seed("p-seed", blocks); const c = await connectClient(fx, { room: "page:p-seed" }); try { await hello(c); c.sendText({ type: "sync.request" }); const done = await c.nextFrameWhere((f) => f.type === "sync.state" && f.done === true); expect(done.chunk).toBeTypeOf("string"); // The chunk carries the Yjs state (apply on a fresh doc → the text // is present — the hydration actually delivered the seed). const chunk = done.chunk as string; if (chunk.length > 0) { const fresh = new Y.Doc(); Y.applyUpdate(fresh, Buffer.from(chunk, "base64"), "test"); const frag = fresh.getXmlFragment("blocks"); expect(frag.length).toBe(2); fresh.destroy(); } } finally { c.close(); } }); }); describe("handshake: pre-join rejections (HTTP, no room join)", () => { it("bad token (garbage) → HTTP 401, no upgrade", async () => { const r = await probeUpgrade(fx, { token: "not.a.jwt", room: "page:p-it" }); expect(r.status).toBe(401); }); it("bad token (wrong signature) → HTTP 401", async () => { const forged = mintToken({ sub: "u-forge", workspaces: ["w1"] }, "a-different-secret"); const r = await probeUpgrade(fx, { token: forged, room: "page:p-it" }); expect(r.status).toBe(401); }); it("expired token (beyond the 5 min grace) → HTTP 401", async () => { const expired = mintToken({ sub: "u-exp", workspaces: ["w1"], expSec: Math.floor(Date.now() / 1000) - 3_600 }); const r = await probeUpgrade(fx, { token: expired, room: "page:p-it" }); expect(r.status).toBe(401); }); it("token for workspace A on a workspace-B room → HTTP 403 (no-existence, 404-style)", async () => { // The page lives in w1; the token only carries w2. const token = mintToken({ sub: "u-w2", workspaces: ["w2"] }); const r = await probeUpgrade(fx, { token, room: "page:p-it" }); expect(r.status).toBe(403); }); it("user:{otherId} join (not the socket's own control room) → HTTP 403", async () => { const token = mintToken({ sub: "u-own", workspaces: ["w1"] }); const r = await probeUpgrade(fx, { token, room: "user:someone-else" }); expect(r.status).toBe(403); }); it("level=none resource → HTTP 403 (no VIEW)", async () => { const r = await probeUpgrade(fx, { room: "page:p-none" }); expect(r.status).toBe(403); }); it("unknown resource → HTTP 403 (meta null, fail-closed)", async () => { const r = await probeUpgrade(fx, { room: "page:does-not-exist" }); expect(r.status).toBe(403); }); it("malformed room key → HTTP 400 bad_room", async () => { const r = await probeUpgrade(fx, { room: "nonsense" }); expect(r.status).toBe(400); }); it("wrong path → connection destroyed (no 101)", async () => { const r = await probeUpgrade(fx, { path: "/wrong?token=x" }); expect(r.status).not.toBe(101); }); }); describe("handshake: rate-limit rejections (upgrade-time, R1/R6)", () => { it("31 rapid connects from one IP → the 31st is rejected 429 + audit", async () => { // One fixed client IP (X-Forwarded-For, trustProxy) exhausts the // 30/min connect budget; rejected attempts are NOT recorded, so the // 31st is the first rejection. const fxL = await buildRealtimeFixture({ pages: new Set(["p-it"]), workspaces: { "p-it": "w1" }, levels: { "page:p-it": "edit" }, trustProxy: true, }); try { const results: number[] = []; for (let i = 0; i < 31; i += 1) { const r = await probeUpgrade(fxL, { room: "page:p-it", headers: { "x-forwarded-for": "10.9.0.7" }, }); results.push(r.status); } const ok = results.filter((s) => s === 101).length; const limited = results.filter((s) => s === 429).length; expect(ok).toBe(30); expect(limited).toBe(1); expect(fxL.audit.abuse("connect_rate_limited")).toBe(1); } finally { await fxL.stop(); } }); it("11 live sockets for one user → the oldest is evicted (control cap 10, 4008 + control_evicted frame + audit)", async () => { // The user control room rides EVERY resource socket (note 16), and // the control cap (10 live sockets/user, the multi-tab abuse guard) // is what a same-user flood actually hits: the 11th join evicts the // OLDEST socket with an error frame + close 4008. (The outer // 100/user connect cap is the backstop behind it.) const fxU = await buildRealtimeFixture({ pages: new Set(["p-it"]), workspaces: { "p-it": "w1" }, levels: { "page:p-it": "edit" }, }); const floodToken = mintToken({ sub: "u-flood", workspaces: ["w1"] }); const clients = []; try { for (let i = 0; i < 10; i += 1) { const c = await connectClient(fxU, { room: "page:p-it", token: floodToken }); await c.nextFrame("hello"); clients.push(c); } // The 11th join for the same user TRIGGERS the eviction of the // oldest (clients[0]) — error frame first, then close 4008. const eleventh = await connectClient(fxU, { room: "page:p-it", token: floodToken }); await eleventh.nextFrame("hello"); const evicted = clients[0]!; const err = await evicted.nextFrameWhere((f) => f.type === "error" && f.code === "control_evicted", 5_000); expect(err.message).toBeTruthy(); const closed = await evicted.onClose(5_000); expect(closed.code).toBe(WS_CLOSE.RateOrPolicy); expect(fxU.audit.abuse("control_evicted")).toBe(1); // Exactly 10 live sockets for the user (the oldest is gone). expect(fxU.server.sockets().filter((s) => s.session.userId === "u-flood").length).toBe(10); clients.push(eleventh); } finally { for (const c of clients) c.terminate(); await fxU.stop(); } }); }); describe("handshake: the WS close codes exist on the shared contract", () => { it("4000/4001/4002/4008/4009/4010 are distinct and in the 4000 range", () => { const codes = [ WS_CLOSE.ProtocolOrHeartbeat, WS_CLOSE.Unauthorized, WS_CLOSE.Forbidden, WS_CLOSE.RateOrPolicy, WS_CLOSE.PayloadTooLarge, WS_CLOSE.ServerShutdown, ]; expect(new Set(codes).size).toBe(6); for (const c of codes) expect(c).toBeGreaterThanOrEqual(4000); }); });