crossmate

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

push-worker.test.mjs (18912B)


      1 import test from "node:test";
      2 import assert from "node:assert/strict";
      3 import { createHmac } from "node:crypto";
      4 import { PushRegistry } from "../../Workers/push-worker.js";
      5 import { StubStorage } from "./helpers.mjs";
      6 
      7 function makeRegistry(env = {}) {
      8   const storage = new StubStorage();
      9   return { registry: new PushRegistry({ storage }, env), storage };
     10 }
     11 
     12 function attestRequest(ip) {
     13   return new Request("https://push.example/attest/register", {
     14     method: "POST",
     15     headers: { "CF-Connecting-IP": ip }
     16   });
     17 }
     18 
     19 test("rate limit config uses table defaults", () => {
     20   const { registry } = makeRegistry();
     21   assert.deepEqual(registry.rateLimitConfig("PUBLISH_CRED"), { limit: 60, windowSeconds: 60 });
     22   assert.deepEqual(registry.rateLimitConfig("APP_ATTEST_REGISTER_IP"), { limit: 30, windowSeconds: 60 * 60 });
     23 });
     24 
     25 test("rate limit config honours env overrides and rejects junk", () => {
     26   const { registry } = makeRegistry({
     27     PUBLISH_CRED_LIMIT: "5",
     28     PUBLISH_CRED_WINDOW_SECONDS: "120",
     29     PUBLISH_ADDRESS_LIMIT: "not-a-number"
     30   });
     31   assert.deepEqual(registry.rateLimitConfig("PUBLISH_CRED"), { limit: 5, windowSeconds: 120 });
     32   // A malformed override falls back to the table default rather than NaN.
     33   assert.equal(registry.rateLimitConfig("PUBLISH_ADDRESS").limit, 30);
     34 });
     35 
     36 test("requests beyond the limit are rejected with a retry hint", async () => {
     37   const { registry } = makeRegistry();
     38   const config = registry.rateLimitConfig("PUBLISH_CRED");
     39   for (let i = 0; i < config.limit; i++) {
     40     const result = await registry.checkRateLimit("publish:cred", "cred-a", config);
     41     assert.equal(result.ok, true, `call ${i + 1} should be allowed`);
     42   }
     43   const over = await registry.checkRateLimit("publish:cred", "cred-a", config);
     44   assert.equal(over.ok, false);
     45   assert.ok(over.retryAfterSeconds >= 1);
     46 });
     47 
     48 test("the window slides: aged entries free quota again", async () => {
     49   const { registry, storage } = makeRegistry({ PUBLISH_CRED_LIMIT: "2" });
     50   const config = registry.rateLimitConfig("PUBLISH_CRED");
     51   await registry.checkRateLimit("publish:cred", "cred-a", config);
     52   await registry.checkRateLimit("publish:cred", "cred-a", config);
     53   assert.equal((await registry.checkRateLimit("publish:cred", "cred-a", config)).ok, false);
     54 
     55   storage.age(storage.keys("rate:")[0], config.windowSeconds * 1000 + 1000);
     56   assert.equal((await registry.checkRateLimit("publish:cred", "cred-a", config)).ok, true);
     57 });
     58 
     59 test("identities have independent buckets", async () => {
     60   const { registry } = makeRegistry({ PUBLISH_CRED_LIMIT: "1" });
     61   const config = registry.rateLimitConfig("PUBLISH_CRED");
     62   assert.equal((await registry.checkRateLimit("publish:cred", "cred-a", config)).ok, true);
     63   assert.equal((await registry.checkRateLimit("publish:cred", "cred-a", config)).ok, false);
     64   assert.equal((await registry.checkRateLimit("publish:cred", "cred-b", config)).ok, true);
     65 });
     66 
     67 test("attestation gate returns a 429 response once the device limit is hit", async () => {
     68   const { registry } = makeRegistry({ APP_ATTEST_REGISTER_DEVICE_LIMIT: "2" });
     69   const request = attestRequest("203.0.113.9");
     70   assert.equal(await registry.checkAttestationRateLimit(request, "register", "device-1"), null);
     71   assert.equal(await registry.checkAttestationRateLimit(request, "register", "device-1"), null);
     72   const limited = await registry.checkAttestationRateLimit(request, "register", "device-1");
     73   assert.equal(limited.status, 429);
     74   assert.ok(Number(limited.headers.get("Retry-After")) >= 1);
     75 });
     76 
     77 test("first write arms the sweep alarm at the horizon; later writes leave it", async () => {
     78   const { registry, storage } = makeRegistry();
     79   const config = registry.rateLimitConfig("PUBLISH_CRED");
     80   const before = Date.now();
     81   await registry.checkRateLimit("publish:cred", "cred-a", config);
     82   const horizon = registry.rateSweepHorizonMillis();
     83   // The default horizon is the largest table window: the 1h attest buckets.
     84   assert.equal(horizon, 60 * 60 * 1000);
     85   assert.ok(storage.alarmAt >= before + horizon);
     86 
     87   const armedAt = storage.alarmAt;
     88   await registry.checkRateLimit("publish:cred", "cred-b", config);
     89   assert.equal(storage.alarmAt, armedAt, "arm-if-unarmed must not reschedule");
     90 });
     91 
     92 test("an env window override raises the sweep horizon", () => {
     93   const { registry } = makeRegistry({ APP_ATTEST_REGISTER_IP_WINDOW_SECONDS: "7200" });
     94   assert.equal(registry.rateSweepHorizonMillis(), 7200 * 1000);
     95 });
     96 
     97 test("sweep prunes only stale rate keys and re-arms while live ones remain", async () => {
     98   const { registry, storage } = makeRegistry();
     99   const config = registry.rateLimitConfig("PUBLISH_CRED");
    100   await registry.checkRateLimit("publish:cred", "cred-a", config);
    101   await registry.checkRateLimit("publish:cred", "cred-b", config);
    102   const [staleKey, freshKey] = storage.keys("rate:");
    103   storage.age(staleKey, registry.rateSweepHorizonMillis() + 60 * 1000);
    104   await storage.put("gamecred:keep-me", { secret: "s" });
    105 
    106   await registry.alarm();
    107   assert.equal(storage.map.has(staleKey), false);
    108   assert.equal(storage.map.has(freshKey), true);
    109   assert.equal(storage.map.has("gamecred:keep-me"), true, "sweep must stay inside the rate: prefix");
    110   assert.ok(storage.alarmAt > Date.now(), "alarm re-arms while live keys remain");
    111 });
    112 
    113 test("sweep goes quiet once every rate key has expired", async () => {
    114   const { registry, storage } = makeRegistry();
    115   const config = registry.rateLimitConfig("PUBLISH_CRED");
    116   await registry.checkRateLimit("publish:cred", "cred-a", config);
    117   storage.age(storage.keys("rate:")[0], registry.rateSweepHorizonMillis() + 60 * 1000);
    118   storage.alarmAt = null;
    119 
    120   await registry.alarm();
    121   assert.deepEqual(storage.keys("rate:"), []);
    122   assert.equal(storage.alarmAt, null, "no live keys, no re-arm");
    123 });
    124 
    125 // --- Game-binding registration (H4): a credID-scoped address registration
    126 // must prove possession of that game's secret, exactly like a publish.
    127 
    128 const gameSecret = Buffer.alloc(32, 7).toString("base64url");
    129 const otherSecret = Buffer.alloc(32, 9).toString("base64url");
    130 
    131 function gameSignature(secret, credID, { bodyHash = "hash", timestamp = "1000", nonce = "nonce" } = {}) {
    132   const payload = ["crossmate-push-game-v1", credID, bodyHash, timestamp, nonce].join("\n");
    133   return createHmac("sha256", Buffer.from(secret, "base64url")).update(payload).digest("base64url");
    134 }
    135 
    136 function registerRequest(signature) {
    137   const headers = {
    138     "X-Crossmate-Body-SHA256": "hash",
    139     "X-Crossmate-Timestamp": "1000",
    140     "X-Crossmate-Nonce": "nonce"
    141   };
    142   if (signature) headers["X-Crossmate-Game-Signature"] = signature;
    143   return new Request("https://push.example/register", { method: "POST", headers });
    144 }
    145 
    146 function registerBody(addresses, deviceID = "device1") {
    147   return JSON.stringify({
    148     deviceID,
    149     token: "apns-token",
    150     environment: "production",
    151     addresses
    152   });
    153 }
    154 
    155 async function registerGameCred(registry, credID, secret = gameSecret) {
    156   const response = await registry.handleGameRegister(credID, JSON.stringify({ secret }));
    157   assert.ok(response.status === 201 || response.status === 204);
    158 }
    159 
    160 test("a correctly signed game binding registers under its credID", async () => {
    161   const { registry, storage } = makeRegistry();
    162   await registerGameCred(registry, "cred-1");
    163 
    164   const response = await registry.handleRegister(
    165     registerRequest(gameSignature(gameSecret, "cred-1")),
    166     registerBody([{ address: "addr-a", credID: "cred-1" }]),
    167     { deviceID: "device1" }
    168   );
    169 
    170   assert.equal(response.status, 204);
    171   assert.deepEqual(storage.keys("addr:"), ["addr:cred-1:addr-a:device1"]);
    172 });
    173 
    174 test("an App-Attest-only game binding (no game signature) is not stored", async () => {
    175   const { registry, storage } = makeRegistry();
    176   await registerGameCred(registry, "cred-1");
    177 
    178   // A departed participant (or anyone who learned the credID) has a valid
    179   // App Attest enrollment of their own but not the game secret.
    180   const response = await registry.handleRegister(
    181     registerRequest(null),
    182     registerBody([
    183       { address: "addr-account" },
    184       { address: "addr-a", credID: "cred-1" }
    185     ]),
    186     { deviceID: "device1" }
    187   );
    188 
    189   // The account-scoped binding still lands (legacy batched clients), but the
    190   // unproven game binding is dropped.
    191   assert.equal(response.status, 204);
    192   assert.deepEqual(storage.keys("addr:"), ["addr:addr-account:device1"]);
    193 });
    194 
    195 test("a game binding signed with the wrong secret is not stored", async () => {
    196   const { registry, storage } = makeRegistry();
    197   await registerGameCred(registry, "cred-1");
    198 
    199   const response = await registry.handleRegister(
    200     registerRequest(gameSignature(otherSecret, "cred-1")),
    201     registerBody([{ address: "addr-a", credID: "cred-1" }]),
    202     { deviceID: "device1" }
    203   );
    204 
    205   assert.equal(response.status, 204);
    206   assert.deepEqual(storage.keys("addr:"), []);
    207 });
    208 
    209 test("a game binding naming an unknown credID is not stored", async () => {
    210   const { registry, storage } = makeRegistry();
    211 
    212   const response = await registry.handleRegister(
    213     registerRequest(gameSignature(gameSecret, "cred-unregistered")),
    214     registerBody([{ address: "addr-a", credID: "cred-unregistered" }]),
    215     { deviceID: "device1" }
    216   );
    217 
    218   assert.equal(response.status, 204);
    219   assert.deepEqual(storage.keys("addr:"), []);
    220 });
    221 
    222 test("mixed credIDs in one request drop every game binding", async () => {
    223   const { registry, storage } = makeRegistry();
    224   await registerGameCred(registry, "cred-1");
    225   await registerGameCred(registry, "cred-2", otherSecret);
    226 
    227   // One request carries one signature, so it can prove at most one credID;
    228   // a batch that names two proves neither.
    229   const response = await registry.handleRegister(
    230     registerRequest(gameSignature(gameSecret, "cred-1")),
    231     registerBody([
    232       { address: "addr-a", credID: "cred-1" },
    233       { address: "addr-b", credID: "cred-2" }
    234     ]),
    235     { deviceID: "device1" }
    236   );
    237 
    238   assert.equal(response.status, 204);
    239   assert.deepEqual(storage.keys("addr:"), []);
    240 });
    241 
    242 test("after rotation, the old credential cannot bind under the new credID", async () => {
    243   const { registry, storage } = makeRegistry();
    244   // The room before rotation…
    245   await registerGameCred(registry, "cred-old");
    246   // …and the replacement a remaining participant registered after someone left.
    247   await registerGameCred(registry, "cred-new", otherSecret);
    248 
    249   // The departed participant holds the complete old credential but signs for
    250   // the new credID with the old secret — the only credential it has.
    251   const response = await registry.handleRegister(
    252     registerRequest(gameSignature(gameSecret, "cred-new")),
    253     registerBody([{ address: "addr-a", credID: "cred-new" }], "device-departed"),
    254     { deviceID: "device-departed" }
    255   );
    256   assert.equal(response.status, 204);
    257   assert.deepEqual(storage.keys("addr:"), [], "old secret must not subscribe to the rotated room");
    258 
    259   // A current participant holding the new secret registers fine.
    260   const ok = await registry.handleRegister(
    261     registerRequest(gameSignature(otherSecret, "cred-new")),
    262     registerBody([{ address: "addr-a", credID: "cred-new" }], "device-current"),
    263     { deviceID: "device-current" }
    264   );
    265   assert.equal(ok.status, 204);
    266   assert.deepEqual(storage.keys("addr:"), ["addr:cred-new:addr-a:device-current"]);
    267 });
    268 
    269 // --- Ingress and fanout bounds (M3): request bodies, lists, strings, and
    270 // APNs payloads are all refused before the work they would otherwise buy.
    271 
    272 test("an oversized request body is refused before auth", async () => {
    273   const { registry } = makeRegistry({ MAX_BODY_BYTES: "1024" });
    274   const response = await registry.fetch(new Request("https://push.example/publish", {
    275     method: "POST",
    276     body: "x".repeat(2048)
    277   }));
    278   assert.equal(response.status, 413);
    279 });
    280 
    281 test("a body exactly at the limit passes the size gate", async () => {
    282   const { registry } = makeRegistry({ MAX_BODY_BYTES: "1024" });
    283   const response = await registry.fetch(new Request("https://push.example/publish", {
    284     method: "POST",
    285     body: "x".repeat(1024)
    286   }));
    287   // Past the size gate; refused by auth, not by size.
    288   assert.equal(response.status, 401);
    289 });
    290 
    291 test("the body cap also covers the pre-auth attestation routes", async () => {
    292   const { registry } = makeRegistry({ MAX_BODY_BYTES: "1024" });
    293   const response = await registry.fetch(new Request("https://push.example/attest/challenge", {
    294     method: "POST",
    295     body: "x".repeat(2048)
    296   }));
    297   assert.equal(response.status, 413);
    298 });
    299 
    300 test("register refuses oversized address and mutedKinds lists", async () => {
    301   const { registry } = makeRegistry({ MAX_ADDRESS_COUNT: "2" });
    302   const tooMany = await registry.handleRegister(
    303     registerRequest(null),
    304     registerBody([{ address: "a1" }, { address: "a2" }, { address: "a3" }]),
    305     { deviceID: "device1" }
    306   );
    307   assert.equal(tooMany.status, 400);
    308 
    309   const mutedOverflow = await registry.handleRegister(
    310     registerRequest(null),
    311     JSON.stringify({
    312       deviceID: "device1",
    313       token: "apns-token",
    314       environment: "production",
    315       addresses: [{ address: "a1" }],
    316       mutedKinds: Array.from({ length: 33 }, (_, i) => `kind${i}`)
    317     }),
    318     { deviceID: "device1" }
    319   );
    320   assert.equal(mutedOverflow.status, 400);
    321 });
    322 
    323 test("register refuses a malformed token and skips malformed addresses", async () => {
    324   const { registry, storage } = makeRegistry();
    325   const badToken = await registry.handleRegister(
    326     registerRequest(null),
    327     JSON.stringify({
    328       deviceID: "device1",
    329       token: "x".repeat(200),
    330       environment: "production",
    331       addresses: [{ address: "a1" }]
    332     }),
    333     { deviceID: "device1" }
    334   );
    335   assert.equal(badToken.status, 400);
    336 
    337   // An address that could forge a `:`-separated storage-key segment is
    338   // dropped; the well-formed sibling in the same request still registers.
    339   const mixed = await registry.handleRegister(
    340     registerRequest(null),
    341     registerBody([{ address: "addr:forged" }, { address: "addr-ok" }]),
    342     { deviceID: "device1" }
    343   );
    344   assert.equal(mixed.status, 204);
    345   assert.deepEqual(storage.keys("addr:"), ["addr:addr-ok:device1"]);
    346 });
    347 
    348 test("a game's registration set is capped at write time", async () => {
    349   const { registry, storage } = makeRegistry({ MAX_REGISTRATIONS_PER_CRED: "2" });
    350   await registerGameCred(registry, "cred-1");
    351   for (const device of ["device1", "device2"]) {
    352     const response = await registry.handleRegister(
    353       registerRequest(gameSignature(gameSecret, "cred-1")),
    354       registerBody([{ address: `addr-${device}`, credID: "cred-1" }], device),
    355       { deviceID: device }
    356     );
    357     assert.equal(response.status, 204);
    358   }
    359 
    360   const over = await registry.handleRegister(
    361     registerRequest(gameSignature(gameSecret, "cred-1")),
    362     registerBody([{ address: "addr-device3", credID: "cred-1" }], "device3"),
    363     { deviceID: "device3" }
    364   );
    365   assert.equal(over.status, 400);
    366   assert.equal(storage.keys("addr:cred-1:").length, 2);
    367 
    368   // Re-registering an existing binding is an overwrite, not growth.
    369   const again = await registry.handleRegister(
    370     registerRequest(gameSignature(gameSecret, "cred-1")),
    371     registerBody([{ address: "addr-device1", credID: "cred-1" }], "device1"),
    372     { deviceID: "device1" }
    373   );
    374   assert.equal(again.status, 204);
    375 });
    376 
    377 test("game credential registration bounds credID and secret", async () => {
    378   const { registry } = makeRegistry();
    379   const badCredID = await registry.handleGameRegister(
    380     "cred:forged",
    381     JSON.stringify({ secret: gameSecret })
    382   );
    383   assert.equal(badCredID.status, 400);
    384 
    385   const hugeSecret = await registry.handleGameRegister(
    386     "cred-1",
    387     JSON.stringify({ secret: "A".repeat(1000) })
    388   );
    389   assert.equal(hugeSecret.status, 400);
    390 });
    391 
    392 function publishRequest() {
    393   return new Request("https://push.example/publish", { method: "POST" });
    394 }
    395 
    396 function publishBody(overrides = {}) {
    397   return JSON.stringify({
    398     kind: "win",
    399     addressees: [{ address: "addr-a" }],
    400     ...overrides
    401   });
    402 }
    403 
    404 test("a bounded publish with no registered targets succeeds cheaply", async () => {
    405   const { registry } = makeRegistry();
    406   const response = await registry.handlePublish(
    407     publishRequest(),
    408     publishBody(),
    409     { deviceID: "device1" }
    410   );
    411   assert.equal(response.status, 200);
    412   assert.deepEqual(await response.json(), { delivered: 0, removed: 0, muted: 0, failed: 0 });
    413 });
    414 
    415 test("publish refuses oversized addressee lists and fields", async () => {
    416   const { registry } = makeRegistry({ MAX_ADDRESS_COUNT: "2" });
    417   const tooMany = await registry.handlePublish(
    418     publishRequest(),
    419     publishBody({ addressees: [{ address: "a1" }, { address: "a2" }, { address: "a3" }] }),
    420     { deviceID: "device1" }
    421   );
    422   assert.equal(tooMany.status, 400);
    423 
    424   const hugeEnc = await registry.handlePublish(
    425     publishRequest(),
    426     publishBody({ addressees: [{ address: "a1", enc: "A".repeat(5000) }] }),
    427     { deviceID: "device1" }
    428   );
    429   assert.equal(hugeEnc.status, 400);
    430 
    431   const hugeBody = await registry.handlePublish(
    432     publishRequest(),
    433     publishBody({ alertBody: "x".repeat(1000) }),
    434     { deviceID: "device1" }
    435   );
    436   assert.equal(hugeBody.status, 400);
    437 
    438   const badCollapse = await registry.handlePublish(
    439     publishRequest(),
    440     publishBody({ collapseID: "c".repeat(65) }),
    441     { deviceID: "device1" }
    442   );
    443   assert.equal(badCollapse.status, 400);
    444 
    445   const badAddress = await registry.handlePublish(
    446     publishRequest(),
    447     publishBody({ addressees: [{ address: "addr:forged" }] }),
    448     { deviceID: "device1" }
    449   );
    450   assert.equal(badAddress.status, 400);
    451 });
    452 
    453 test("a publish whose APNs payload cannot fit is refused before any fanout", async () => {
    454   const { registry } = makeRegistry();
    455   // Each field is within its own cap, but the assembled APNs payload
    456   // (aps + kind + title + enc as JSON) exceeds the 4 KB APNs ceiling.
    457   const response = await registry.handlePublish(
    458     publishRequest(),
    459     publishBody({
    460       title: "t".repeat(400),
    461       alertBody: "b".repeat(500),
    462       addressees: [{ address: "a1", enc: "A".repeat(4000) }]
    463     }),
    464     { deviceID: "device1" }
    465   );
    466   assert.equal(response.status, 413);
    467 
    468   // The same shape with a small enc fits and proceeds.
    469   const fits = await registry.handlePublish(
    470     publishRequest(),
    471     publishBody({
    472       title: "t".repeat(400),
    473       alertBody: "b".repeat(500),
    474       addressees: [{ address: "a1", enc: "A".repeat(500) }]
    475     }),
    476     { deviceID: "device1" }
    477   );
    478   assert.equal(fits.status, 200);
    479 });
    480 
    481 test("broadcast fanout is capped even for an over-cap room", async () => {
    482   const { registry, storage } = makeRegistry({ MAX_BROADCAST_TARGETS: "2" });
    483   for (let index = 0; index < 4; index += 1) {
    484     await storage.put(`addr:cred-1:addr-${index}:device-${index}`, {
    485       token: "t",
    486       environment: "production"
    487     });
    488   }
    489   const targets = await registry.resolveBroadcastTargets(
    490     "cred-1", null, null, "body", undefined, undefined
    491   );
    492   assert.equal(targets.length, 2);
    493 });