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