crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 });