crossmate

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

push-worker.test.mjs (22934B)


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