room-worker.test.mjs (6310B)
1 import test from "node:test"; 2 import assert from "node:assert/strict"; 3 import { EngagementRegisterLimiter, EngagementRoom } from "../../Workers/room-worker.js"; 4 import { StubStorage } from "./helpers.mjs"; 5 6 // Workers-runtime global used by EngagementRoom's constructor for keepalive 7 // auto-responses; the pair itself is inert under test. 8 globalThis.WebSocketRequestResponsePair ??= class { 9 constructor(request, response) { 10 this.request = request; 11 this.response = response; 12 } 13 }; 14 15 function makeLimiter(env = {}) { 16 const storage = new StubStorage(); 17 return { limiter: new EngagementRegisterLimiter({ storage }, env), storage }; 18 } 19 20 function registerRequest(roomID, ip = "203.0.113.9") { 21 const url = new URL("https://room.example/"); 22 if (roomID) url.searchParams.set("roomID", roomID); 23 return new Request(url, { headers: { "CF-Connecting-IP": ip } }); 24 } 25 26 test("a register probe without a room ID is a bad request", async () => { 27 const { limiter } = makeLimiter(); 28 const response = await limiter.fetch(new Request("https://room.example/")); 29 assert.equal(response.status, 400); 30 }); 31 32 test("distinct rooms are limited per IP, with a retry hint", async () => { 33 const { limiter } = makeLimiter({ ROOM_REGISTER_IP_LIMIT: "2" }); 34 assert.equal((await limiter.fetch(registerRequest("room-1"))).status, 204); 35 assert.equal((await limiter.fetch(registerRequest("room-2"))).status, 204); 36 const limited = await limiter.fetch(registerRequest("room-3")); 37 assert.equal(limited.status, 429); 38 assert.ok(Number(limited.headers.get("Retry-After")) >= 1); 39 }); 40 41 test("re-registering the same room is idempotent and consumes no quota", async () => { 42 const { limiter } = makeLimiter({ ROOM_REGISTER_IP_LIMIT: "2" }); 43 for (let i = 0; i < 5; i++) { 44 assert.equal((await limiter.fetch(registerRequest("room-1"))).status, 204, `re-register ${i + 1}`); 45 } 46 // The window still has one fresh-room slot left. 47 assert.equal((await limiter.fetch(registerRequest("room-2"))).status, 204); 48 assert.equal((await limiter.fetch(registerRequest("room-3"))).status, 429); 49 }); 50 51 test("IPs have independent budgets", async () => { 52 const { limiter } = makeLimiter({ ROOM_REGISTER_IP_LIMIT: "1" }); 53 assert.equal((await limiter.fetch(registerRequest("room-1", "203.0.113.9"))).status, 204); 54 assert.equal((await limiter.fetch(registerRequest("room-2", "203.0.113.9"))).status, 429); 55 assert.equal((await limiter.fetch(registerRequest("room-2", "198.51.100.7"))).status, 204); 56 }); 57 58 test("the window slides: an aged room entry frees its slot", async () => { 59 const { limiter, storage } = makeLimiter({ 60 ROOM_REGISTER_IP_LIMIT: "1", 61 ROOM_REGISTER_IP_WINDOW_SECONDS: "60" 62 }); 63 assert.equal((await limiter.fetch(registerRequest("room-1"))).status, 204); 64 assert.equal((await limiter.fetch(registerRequest("room-2"))).status, 429); 65 66 storage.age(storage.keys("rate:")[0], 61 * 1000); 67 assert.equal((await limiter.fetch(registerRequest("room-2"))).status, 204); 68 }); 69 70 test("registering arms the sweep alarm once", async () => { 71 const { limiter, storage } = makeLimiter({ ROOM_REGISTER_IP_WINDOW_SECONDS: "60" }); 72 const before = Date.now(); 73 await limiter.fetch(registerRequest("room-1")); 74 assert.ok(storage.alarmAt >= before + 60 * 1000); 75 76 const armedAt = storage.alarmAt; 77 await limiter.fetch(registerRequest("room-2")); 78 assert.equal(storage.alarmAt, armedAt, "arm-if-unarmed must not reschedule"); 79 }); 80 81 class StubSocket { 82 constructor() { 83 this.sent = []; 84 this.closed = null; 85 } 86 87 send(data) { 88 this.sent.push(data); 89 } 90 91 close(code, reason) { 92 this.closed = { code, reason }; 93 } 94 } 95 96 function makeRoom(env = {}) { 97 const storage = new StubStorage(); 98 const sockets = []; 99 const state = { 100 storage, 101 getWebSockets: () => sockets, 102 setWebSocketAutoResponse: () => {} 103 }; 104 return { room: new EngagementRoom(state, env), sockets, storage }; 105 } 106 107 test("an oversized frame is not relayed and closes the sender", async () => { 108 const { room, sockets } = makeRoom({ ROOM_MAX_FRAME_BYTES: "1024" }); 109 const alice = new StubSocket(); 110 const bob = new StubSocket(); 111 sockets.push(alice, bob); 112 113 await room.webSocketMessage(alice, new ArrayBuffer(1025)); 114 115 assert.deepEqual(bob.sent, []); 116 assert.equal(alice.closed?.code, 1009); 117 }); 118 119 test("a boundary-size frame is relayed to every peer but the sender", async () => { 120 const { room, sockets } = makeRoom({ ROOM_MAX_FRAME_BYTES: "1024" }); 121 const alice = new StubSocket(); 122 const bob = new StubSocket(); 123 const carol = new StubSocket(); 124 sockets.push(alice, bob, carol); 125 126 await room.webSocketMessage(alice, new ArrayBuffer(1024)); 127 128 assert.equal(bob.sent.length, 1); 129 assert.equal(carol.sent.length, 1); 130 assert.deepEqual(alice.sent, []); 131 assert.equal(alice.closed, null); 132 }); 133 134 test("the frame limit defaults to 512 KiB when unset or malformed", async () => { 135 for (const env of [{}, { ROOM_MAX_FRAME_BYTES: "not-a-number" }, { ROOM_MAX_FRAME_BYTES: "-1" }]) { 136 const { room, sockets } = makeRoom(env); 137 const alice = new StubSocket(); 138 const bob = new StubSocket(); 139 sockets.push(alice, bob); 140 141 await room.webSocketMessage(alice, new ArrayBuffer(512 * 1024)); 142 assert.equal(bob.sent.length, 1, `boundary frame relays under ${JSON.stringify(env)}`); 143 144 await room.webSocketMessage(alice, new ArrayBuffer(512 * 1024 + 1)); 145 assert.equal(bob.sent.length, 1, `oversized frame drops under ${JSON.stringify(env)}`); 146 assert.equal(alice.closed?.code, 1009); 147 } 148 }); 149 150 test("sweep prunes stale IP keys, keeps live ones, and re-arms accordingly", async () => { 151 const { limiter, storage } = makeLimiter({ ROOM_REGISTER_IP_WINDOW_SECONDS: "60" }); 152 await limiter.fetch(registerRequest("room-1", "203.0.113.9")); 153 await limiter.fetch(registerRequest("room-2", "198.51.100.7")); 154 const [staleKey, liveKey] = storage.keys("rate:"); 155 storage.age(staleKey, 61 * 1000); 156 157 await limiter.alarm(); 158 assert.equal(storage.map.has(staleKey), false); 159 assert.equal(storage.map.has(liveKey), true); 160 assert.ok(storage.alarmAt > Date.now(), "alarm re-arms while live keys remain"); 161 162 storage.age(liveKey, 61 * 1000); 163 storage.alarmAt = null; 164 await limiter.alarm(); 165 assert.deepEqual(storage.keys("rate:"), []); 166 assert.equal(storage.alarmAt, null, "no live keys, no re-arm"); 167 });