Public: anyone can read this; the crew can change it.
forked from trevato/cs
The room's two doors are tested where they are opened: POST /api/room and the socket upgrade answer to src/server.test.ts #352
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/335-the-room-s-two-doors-are-tested-where-th → main 1 commit fork
2 files
changed +536
−3
src/server.test.ts +528 −2
| @@ -20,6 +20,15 @@ import { parseSituation, type Situation } from "./plan/situation.ts"; | ||
| 20 | 20 | import { sign } from "./who.ts"; |
| 21 | 21 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| 22 | 22 | import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; |
| 23 | // The rooms, for the two doors at the end of this file: what a code is, what | |
| 24 | // the hub holds, and the frames that cross a socket once one is open. | |
| 25 | import { isRoomCode } from "./rooms/code.ts"; | |
| 26 | import { CLOSE_TOO_BIG, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts"; | |
| 27 | import { levelOf, SNAPSHOT_HZ, TEAM_SIZE } from "./rooms/room.ts"; | |
| 28 | import { applySnapshot, readDown, writeInput, INPUT_BYTES, SNAPSHOT_FRAME, type Hello } from "./rooms/wire.ts"; | |
| 29 | import { COMPETITIVE, SHORT } from "./client/match.ts"; | |
| 30 | import type { PlayedMap } from "./mapname.ts"; | |
| 31 | import { createWorld, NO_INPUT } from "./client/world.ts"; | |
| 23 | 32 | |
| 24 | 33 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 25 | 34 | process.env.PORT = "0"; |
| @@ -29,8 +38,8 @@ const BUNDLES = bundlesDir(); | ||
| 29 | 38 | // The test is the forge: it holds the same key and signs identities with it. |
| 30 | 39 | const KEY = "the-key-the-forge-signs-with"; |
| 31 | 40 | process.env.FORGE_APP_IDENTITY_KEY = KEY; |
| 32 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, onError, findScripts, holdStatic } = | |
| 33 | await import("./server.ts"); | |
| 41 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, ROOM_LIMIT, SOCKET_PATH, hub, onError, findScripts, | |
| 42 | holdStatic } = await import("./server.ts"); | |
| 34 | 43 | const base = `http://${server.hostname}:${server.port}`; |
| 35 | 44 | |
| 36 | 45 | /** The two headers the forge puts on a request it proxies for a signed-in person. */ |
| @@ -1236,3 +1245,520 @@ test("the endpoint meta is how a page names where a plan comes from", () => { | ||
| 1236 | 1245 | // Escaped like everything else the shell is handed. |
| 1237 | 1246 | expect(endpoints([{ id: 'x"y', href: '/a"b' }])).toContain('id="x"y" content="/a"b"'); |
| 1238 | 1247 | }); |
| 1248 | ||
| 1249 | // --- the room's two doors --------------------------------------------------- | |
| 1250 | ||
| 1251 | /** | |
| 1252 | * `POST /api/room` and the socket upgrade are the only two endpoints here that | |
| 1253 | * hold a connection open, and the only two a stranger reaches with no result to | |
| 1254 | * post. The four test files under `src/rooms/` are the hub, the room and the | |
| 1255 | * codec through plain objects and never see a request; what follows is the | |
| 1256 | * doors themselves — the statuses, the headers, the cookie, the cut on a name | |
| 1257 | * and every refusal — against the server this file has been fetching all along. | |
| 1258 | * | |
| 1259 | * Two habits every test below keeps, because a room is a world this process | |
| 1260 | * steps 64 times a second for as long as it is held: | |
| 1261 | * | |
| 1262 | * - **It gives its room back.** The hub frees an empty room after `IDLE_MS`, | |
| 1263 | * which is ninety seconds and outlives this suite, so a room left behind | |
| 1264 | * would be stepped for the rest of the run and would push the next test | |
| 1265 | * towards `ROOM_CAP`. `finish` closes the sockets and deletes the room, and | |
| 1266 | * the last test here reads the hub to prove nothing was forgotten. | |
| 1267 | * - **It is its own caller.** `ROOM_LIMIT` is three rooms per caller keyed by | |
| 1268 | * address and session, and every test here comes from this one address — so | |
| 1269 | * each takes a session cookie nobody else uses and spends its own three | |
| 1270 | * tokens. Nothing below depends on where in the file it runs, the flood test | |
| 1271 | * included. | |
| 1272 | * | |
| 1273 | * It is in this already long file rather than in a sibling of its own for one | |
| 1274 | * mechanical reason: `bun test` runs every file in one process with one module | |
| 1275 | * registry, so a second file that imported `./server.ts` would share *this* | |
| 1276 | * file's server — and, running before it, would set `FORGE_APP_DATA` after the | |
| 1277 | * bundles had been built into somewhere else. One file imports the server, and | |
| 1278 | * `src/rooms/room.test.ts` already says the socket's own half is here. | |
| 1279 | */ | |
| 1280 | ||
| 1281 | /** A caller nobody else here is: the session cookie derived from a test's label. */ | |
| 1282 | const caller = (label: string): string => | |
| 1283 | `cs_session=${new Bun.CryptoHasher("sha256").update(label).digest("hex").slice(0, 32)}`; | |
| 1284 | ||
| 1285 | /** The room door, with the bytes a caller actually wrote. */ | |
| 1286 | const postRoom = (body: string, headers: Record<string, string> = {}): Promise<Response> => | |
| 1287 | fetch(base + "/api/room", { method: "POST", headers: { "content-type": "application/json", ...headers }, body }); | |
| 1288 | ||
| 1289 | /** …and the usual case: a JSON object, from a caller of this test's own. */ | |
| 1290 | const askRoom = (label: string, asked: unknown = {}): Promise<Response> => | |
| 1291 | postRoom(JSON.stringify(asked), { cookie: caller(label) }); | |
| 1292 | ||
| 1293 | /** What the room door answers with, once it has answered. */ | |
| 1294 | type Opened = { code: string; map: string; length: string }; | |
| 1295 | ||
| 1296 | const openedRoom = async (label: string, asked: unknown = {}): Promise<Opened> => { | |
| 1297 | const response = await askRoom(label, asked); | |
| 1298 | expect(response.status).toBe(200); | |
| 1299 | return (await response.json()) as Opened; | |
| 1300 | }; | |
| 1301 | ||
| 1302 | /** Poll until this is true or the deadline passes; the answer is what it was. */ | |
| 1303 | const until = async (ready: () => boolean, ms = 2000): Promise<boolean> => { | |
| 1304 | const deadline = Date.now() + ms; | |
| 1305 | while (!ready() && Date.now() < deadline) await Bun.sleep(5); | |
| 1306 | return ready(); | |
| 1307 | }; | |
| 1308 | ||
| 1309 | /** What a page holds: the socket, every frame it was handed, and the close. */ | |
| 1310 | type Client = { | |
| 1311 | ws: WebSocket; | |
| 1312 | frames: (string | Uint8Array)[]; | |
| 1313 | /** The code the connection was closed with, or null while it is open. */ | |
| 1314 | closeCode: () => number | null; | |
| 1315 | /** Closes it and waits for the socket to be shut, so no test leaks one. */ | |
| 1316 | close: () => Promise<void>; | |
| 1317 | }; | |
| 1318 | ||
| 1319 | /** A real browser's end of a room, connected and open. */ | |
| 1320 | async function connect(query: string): Promise<Client> { | |
| 1321 | const ws = new WebSocket(`${base.replace("http://", "ws://")}${SOCKET_PATH}?${query}`); | |
| 1322 | ws.binaryType = "arraybuffer"; | |
| 1323 | const frames: (string | Uint8Array)[] = []; | |
| 1324 | let closed: number | null = null; | |
| 1325 | ws.addEventListener("message", (event: MessageEvent) => { | |
| 1326 | frames.push(typeof event.data === "string" ? event.data : new Uint8Array(event.data as ArrayBuffer)); | |
| 1327 | }); | |
| 1328 | ws.addEventListener("close", (event: CloseEvent) => { closed = event.code; }); | |
| 1329 | const client: Client = { | |
| 1330 | ws, frames, | |
| 1331 | closeCode: () => closed, | |
| 1332 | close: async () => { | |
| 1333 | ws.close(); | |
| 1334 | await until(() => ws.readyState === WebSocket.CLOSED); | |
| 1335 | }, | |
| 1336 | }; | |
| 1337 | expect(await until(() => ws.readyState === WebSocket.OPEN || closed !== null)).toBe(true); | |
| 1338 | expect(ws.readyState).toBe(WebSocket.OPEN); | |
| 1339 | return client; | |
| 1340 | } | |
| 1341 | ||
| 1342 | /** The end of a test: close what it opened, and hand the room back to the hub. */ | |
| 1343 | const finish = async (code: string, ...clients: Client[]): Promise<void> => { | |
| 1344 | for (const client of clients) await client.close(); | |
| 1345 | hub.rooms.delete(code); | |
| 1346 | }; | |
| 1347 | ||
| 1348 | /** | |
| 1349 | * The 101 itself, read off the wire. A `WebSocket` hands a page the socket and | |
| 1350 | * never the response it came out of, so the handshake is written here by hand | |
| 1351 | * and the status line and headers are read back before the connection is | |
| 1352 | * dropped: it is the only way to see what a browser's upgrade was answered | |
| 1353 | * with. The bytes after the blank line are the room's first frames and are not | |
| 1354 | * read — this is about the head. | |
| 1355 | */ | |
| 1356 | async function handshake(query: string, extra: Record<string, string> = {}): Promise<{ | |
| 1357 | status: number; headers: Headers; | |
| 1358 | }> { | |
| 1359 | const key = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))); | |
| 1360 | const request = [ | |
| 1361 | `GET ${SOCKET_PATH}?${query} HTTP/1.1`, | |
| 1362 | `Host: ${new URL(base).host}`, | |
| 1363 | "Upgrade: websocket", | |
| 1364 | "Connection: Upgrade", | |
| 1365 | `Sec-WebSocket-Key: ${key}`, | |
| 1366 | "Sec-WebSocket-Version: 13", | |
| 1367 | ...Object.entries(extra).map(([name, value]) => `${name}: ${value}`), | |
| 1368 | ].join("\r\n") + "\r\n\r\n"; | |
| 1369 | ||
| 1370 | let head = ""; | |
| 1371 | const where = new URL(base); | |
| 1372 | const socket = await Bun.connect({ | |
| 1373 | hostname: where.hostname, | |
| 1374 | port: Number(where.port), | |
| 1375 | socket: { | |
| 1376 | open(connection) { connection.write(request); }, | |
| 1377 | data(_connection, chunk) { head += chunk.toString("latin1"); }, | |
| 1378 | }, | |
| 1379 | }); | |
| 1380 | expect(await until(() => head.includes("\r\n\r\n"))).toBe(true); | |
| 1381 | socket.end(); | |
| 1382 | ||
| 1383 | const [status, ...rest] = head.slice(0, head.indexOf("\r\n\r\n")).split("\r\n"); | |
| 1384 | const headers = new Headers(); | |
| 1385 | for (const line of rest) { | |
| 1386 | const at = line.indexOf(":"); | |
| 1387 | if (at > 0) headers.append(line.slice(0, at).trim(), line.slice(at + 1).trim()); | |
| 1388 | } | |
| 1389 | return { status: Number((status ?? "").split(" ")[1]), headers }; | |
| 1390 | } | |
| 1391 | ||
| 1392 | test("`POST /api/room` answers a code as no-store JSON, on the floor, with a session", async () => { | |
| 1393 | const response = await askRoom("opens one"); | |
| 1394 | expect(response.status).toBe(200); | |
| 1395 | expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); | |
| 1396 | expect(response.headers.get("cache-control")).toBe("no-store"); | |
| 1397 | // The floor a plan comes back on, header for header: this is data and not a | |
| 1398 | // document, so it carries the policy with no style hash in it. | |
| 1399 | const plan = await askPlan(JSON.stringify(situation())); | |
| 1400 | expect(response.headers.get("content-security-policy")).toBe(plan.headers.get("content-security-policy")); | |
| 1401 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1402 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 1403 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); | |
| 1404 | expect(response.headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()"); | |
| 1405 | ||
| 1406 | // The session it arrived with, back in the cookie every page here sets. | |
| 1407 | const cookie = response.headers.get("set-cookie") ?? ""; | |
| 1408 | expect(cookie).toContain(caller("opens one")); | |
| 1409 | expect(cookie).toContain("HttpOnly"); | |
| 1410 | expect(cookie).toContain("SameSite=Lax"); | |
| 1411 | expect(cookie).toContain("Path=/"); | |
| 1412 | ||
| 1413 | // And the answer is a code, a map and a length, and nothing else at all. | |
| 1414 | const room = (await response.json()) as Opened; | |
| 1415 | expect(isRoomCode(room.code)).toBe(true); | |
| 1416 | expect(room).toEqual({ code: room.code, map: "de_forge", length: "short" }); | |
| 1417 | // The code names a room this process is holding, with nobody in it yet. | |
| 1418 | expect(hub.find(room.code)?.map).toBe("de_forge"); | |
| 1419 | expect(hub.find(room.code)?.members.size).toBe(0); | |
| 1420 | await finish(room.code); | |
| 1421 | }); | |
| 1422 | ||
| 1423 | test("a browser with no session is given one on the way into its first room", async () => { | |
| 1424 | // The one test here with no cookie of its own, because that is the thing it | |
| 1425 | // is about: the caller keyed `room:<address>|-` is this one and nobody else. | |
| 1426 | const response = await postRoom("{}"); | |
| 1427 | expect(response.status).toBe(200); | |
| 1428 | expect(response.headers.get("set-cookie")).toMatch(/^cs_session=[0-9a-f]{32}; Path=\/; HttpOnly; SameSite=Lax/); | |
| 1429 | await finish(((await response.json()) as Opened).code); | |
| 1430 | }); | |
| 1431 | ||
| 1432 | test("the map and the length are settled at the door: an unknown map is de_forge, and only `long` is long", async () => { | |
| 1433 | const cases: [unknown, PlayedMap, "short" | "long"][] = [ | |
| 1434 | [{}, "de_forge", "short"], | |
| 1435 | [{ map: "de_dust2", length: "epic" }, "de_forge", "short"], | |
| 1436 | [{ map: 7, length: 1 }, "de_forge", "short"], | |
| 1437 | [{ map: null, length: "Long" }, "de_forge", "short"], | |
| 1438 | [{ map: "<script>alert(1)</script>", length: ["long"] }, "de_forge", "short"], | |
| 1439 | [{ map: "generated" }, "de_forge", "short"], | |
| 1440 | [{ map: "de_forge", length: "long" }, "de_forge", "long"], | |
| 1441 | [{ map: "gen_14u5j6g", length: "long" }, "gen_14u5j6g", "long"], | |
| 1442 | ]; | |
| 1443 | for (const [asked, map, length] of cases) { | |
| 1444 | const room = await openedRoom(`map ${JSON.stringify(asked)}`, asked); | |
| 1445 | expect([room.map, room.length]).toEqual([map, length]); | |
| 1446 | // The room this process is holding is the one the answer described, and | |
| 1447 | // the rules under it are the long match's only when the answer said long. | |
| 1448 | const held = hub.find(room.code); | |
| 1449 | expect(held?.map).toBe(map); | |
| 1450 | expect(held?.length).toBe(length); | |
| 1451 | expect(held?.rules).toBe(length === "long" ? COMPETITIVE : SHORT); | |
| 1452 | await finish(room.code); | |
| 1453 | } | |
| 1454 | // A body that is not an object at all is not an error: it asked for nothing, | |
| 1455 | // so it gets what asking for nothing gets. | |
| 1456 | for (const body of ["[]", "null", '"de_dust2"', "7", "{}"]) { | |
| 1457 | const response = await postRoom(body, { cookie: caller(`not an object ${body}`) }); | |
| 1458 | expect(response.status).toBe(200); | |
| 1459 | const room = (await response.json()) as Opened; | |
| 1460 | expect([room.map, room.length]).toEqual(["de_forge", "short"]); | |
| 1461 | await finish(room.code); | |
| 1462 | } | |
| 1463 | }); | |
| 1464 | ||
| 1465 | test("a room is opened by this page and by nobody else, and never by a read", async () => { | |
| 1466 | const before = hub.rooms.size; | |
| 1467 | const elsewhere: Record<string, string>[] = [ | |
| 1468 | { "sec-fetch-site": "cross-site" }, | |
| 1469 | { "sec-fetch-site": "same-site" }, | |
| 1470 | { origin: "https://evil.example" }, | |
| 1471 | ]; | |
| 1472 | for (const headers of elsewhere) { | |
| 1473 | const response = await postRoom(JSON.stringify({ map: "de_forge" }), { ...headers, cookie: caller("cross-site") }); | |
| 1474 | expect(response.status).toBe(403); | |
| 1475 | expect(await response.text()).toBe("forbidden"); | |
| 1476 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1477 | expect(response.headers.get("set-cookie")).toBeNull(); | |
| 1478 | } | |
| 1479 | // And it is not a read: nothing opens a room for a GET or a HEAD. | |
| 1480 | expect((await fetch(base + "/api/room")).status).toBe(404); | |
| 1481 | expect((await server.fetch(new Request(base + "/api/room", { method: "HEAD" }))).status).toBe(404); | |
| 1482 | // Not one of them left a world behind for this process to step. | |
| 1483 | expect(hub.rooms.size).toBe(before); | |
| 1484 | }); | |
| 1485 | ||
| 1486 | test("a body that is not a room to open is refused by the bytes read, and opens nothing", async () => { | |
| 1487 | const before = hub.rooms.size; | |
| 1488 | const bad = await postRoom("{", { cookie: caller("bad json") }); | |
| 1489 | expect(bad.status).toBe(400); | |
| 1490 | expect(await bad.text()).toBe("bad json"); | |
| 1491 | // A kilobyte is the cap, counted as it arrives rather than believed off a | |
| 1492 | // header: this body declares nothing and is cut off anyway. | |
| 1493 | const big = await postRoom(`{"map":"de_forge","pad":"${"x".repeat(4096)}"}`, { cookie: caller("too large") }); | |
| 1494 | expect(big.status).toBe(413); | |
| 1495 | expect(await big.text()).toBe("too large"); | |
| 1496 | for (const response of [bad, big]) | |
| 1497 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1498 | expect(hub.rooms.size).toBe(before); | |
| 1499 | }); | |
| 1500 | ||
| 1501 | test("one caller cannot open rooms as fast as it likes", async () => { | |
| 1502 | // This spends one caller's bucket to the last token. Every other test here | |
| 1503 | // has a caller of its own, so this one poisons nothing and needs no place in | |
| 1504 | // the file — which is why it is here and not last. | |
| 1505 | const mine = { cookie: caller("flooder") }; | |
| 1506 | const codes: string[] = []; | |
| 1507 | let refused: Response | undefined; | |
| 1508 | for (let i = 0; i <= ROOM_LIMIT.burst; i++) { | |
| 1509 | const response = await postRoom("{}", mine); | |
| 1510 | if (response.status === 429) { refused = response; break; } | |
| 1511 | expect(response.status).toBe(200); | |
| 1512 | codes.push(((await response.json()) as Opened).code); | |
| 1513 | } | |
| 1514 | expect(codes.length).toBe(ROOM_LIMIT.burst); | |
| 1515 | expect(refused?.status).toBe(429); | |
| 1516 | expect(await refused?.text()).toBe("slow down"); | |
| 1517 | expect(refused?.headers.get("retry-after")).toBe(String(ROOM_LIMIT.per / 1000)); | |
| 1518 | expect(refused?.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1519 | // Somebody else's bucket is their own: one flood does not close the door. | |
| 1520 | const other = await postRoom("{}", { cookie: caller("bystander") }); | |
| 1521 | expect(other.status).toBe(200); | |
| 1522 | codes.push(((await other.json()) as Opened).code); | |
| 1523 | for (const code of codes) await finish(code); | |
| 1524 | }); | |
| 1525 | ||
| 1526 | test("past ROOM_CAP the answer is 503 and the sentence names the cap", async () => { | |
| 1527 | // Nineteen rooms through the hub and only the twentieth through the route: | |
| 1528 | // nineteen POSTs would want seven callers to get past ROOM_LIMIT, and the | |
| 1529 | // room this test is about is the one the route refuses rather than the ones | |
| 1530 | // underneath it. It is cheap either way — nineteen de_forge rooms are 6.5 ms | |
| 1531 | // on this tree — so what picked the hub was the limiter, not the clock. | |
| 1532 | const filled: string[] = []; | |
| 1533 | while (hub.rooms.size < ROOM_CAP - 1) { | |
| 1534 | const room = hub.open({ map: "de_forge", seed: hub.rooms.size, length: "short", now: Date.now() }); | |
| 1535 | expect(room).not.toBeNull(); | |
| 1536 | filled.push((room as { code: string }).code); | |
| 1537 | } | |
| 1538 | try { | |
| 1539 | const last = await postRoom("{}", { cookie: caller("cap") }); | |
| 1540 | expect(last.status).toBe(200); | |
| 1541 | filled.push(((await last.json()) as Opened).code); | |
| 1542 | expect(hub.rooms.size).toBe(ROOM_CAP); | |
| 1543 | ||
| 1544 | const refused = await postRoom("{}", { cookie: caller("cap") }); | |
| 1545 | expect(refused.status).toBe(503); | |
| 1546 | expect(await refused.text()).toBe(`every room is taken — this deployment holds ${ROOM_CAP}`); | |
| 1547 | expect(refused.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 1548 | expect(refused.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1549 | // A refusal is not a room: the cap is still the cap. | |
| 1550 | expect(hub.rooms.size).toBe(ROOM_CAP); | |
| 1551 | } finally { | |
| 1552 | for (const code of filled) hub.rooms.delete(code); | |
| 1553 | } | |
| 1554 | }); | |
| 1555 | ||
| 1556 | test("the upgrade refuses at the door: no code is 400, an unknown code is 404, another site is 403", async () => { | |
| 1557 | const room = await openedRoom("upgrade refusals"); | |
| 1558 | try { | |
| 1559 | // Nothing asked for, and things asked for that are not codes: the wrong | |
| 1560 | // length, a space, and the letters the alphabet leaves out (O, 0, I, L, 1, S). | |
| 1561 | for (const query of ["", "?room=", "?room=ABCDE", "?room=ABCDEFG", `?room=${encodeURIComponent("ABC DEF")}`, | |
| 1562 | "?room=O0IL1S", "?room=../../etc", `?room=${encodeURIComponent("<script>")}`]) { | |
| 1563 | const response = await fetch(base + SOCKET_PATH + query); | |
| 1564 | expect(response.status).toBe(400); | |
| 1565 | expect(await response.text()).toBe("not a room code"); | |
| 1566 | expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 1567 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1568 | } | |
| 1569 | ||
| 1570 | // A code shaped like one, for a room nobody is holding. The one in a | |
| 1571 | // billion where the hub drew it a moment ago is made impossible here. | |
| 1572 | hub.rooms.delete("ABCDEF"); | |
| 1573 | const gone = await fetch(`${base}${SOCKET_PATH}?room=ABCDEF`); | |
| 1574 | expect(gone.status).toBe(404); | |
| 1575 | expect(await gone.text()).toBe("no room with that code — it may have been idle too long"); | |
| 1576 | expect(gone.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 1577 | ||
| 1578 | // And another site cannot upgrade, with a code that is real and current: | |
| 1579 | // the refusal comes before the room is even looked up. | |
| 1580 | const elsewhere: Record<string, string>[] = [{ "sec-fetch-site": "cross-site" }, { origin: "https://evil.example" }]; | |
| 1581 | for (const headers of elsewhere) { | |
| 1582 | const response = await fetch(`${base}${SOCKET_PATH}?room=${room.code}`, { headers }); | |
| 1583 | expect(response.status).toBe(403); | |
| 1584 | expect(await response.text()).toBe("forbidden"); | |
| 1585 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1586 | } | |
| 1587 | // Not one of those refusals seated anybody. | |
| 1588 | expect(hub.find(room.code)?.members.size).toBe(0); | |
| 1589 | } finally { | |
| 1590 | await finish(room.code); | |
| 1591 | } | |
| 1592 | }); | |
| 1593 | ||
| 1594 | test("a plain `GET /ws` with no upgrade header is the app's own 404 page, not an error", async () => { | |
| 1595 | const room = await openedRoom("plain get"); | |
| 1596 | try { | |
| 1597 | // Lower case on purpose: the code is upper-cased at the door, so this one | |
| 1598 | // resolves — a code that did not would be the plain-text 404 above, and | |
| 1599 | // what comes back instead is the document every wrong path gets. | |
| 1600 | const miss = await fetch(`${base}${SOCKET_PATH}?room=${room.code.toLowerCase()}`); | |
| 1601 | expect(miss.status).toBe(404); | |
| 1602 | expect(miss.headers.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 1603 | const body = await miss.text(); | |
| 1604 | expect(body.startsWith("<!doctype html>")).toBe(true); | |
| 1605 | expect(body).toContain("<h1>not found</h1>"); | |
| 1606 | expect(floorOf(miss)).toEqual(floorOf(await fetch(base + "/nope"))); | |
| 1607 | ||
| 1608 | // HEAD is that 404 with nothing to read, like every other path here. It | |
| 1609 | // goes over the wire rather than through `server.fetch`, because this route | |
| 1610 | // asks the listener to upgrade and `server.fetch` hands it no listener. | |
| 1611 | const head = await fetch(`${base}${SOCKET_PATH}?room=${room.code}`, { method: "HEAD" }); | |
| 1612 | expect(head.status).toBe(404); | |
| 1613 | expect(await head.text()).toBe(""); | |
| 1614 | ||
| 1615 | // Reading a room is not joining one. | |
| 1616 | expect(hub.find(room.code)?.members.size).toBe(0); | |
| 1617 | } finally { | |
| 1618 | await finish(room.code); | |
| 1619 | } | |
| 1620 | }); | |
| 1621 | ||
| 1622 | test("the upgrade is a 101 carrying the session cookie and the security floor", async () => { | |
| 1623 | const room = await openedRoom("handshake"); | |
| 1624 | try { | |
| 1625 | const session = "d".repeat(32); | |
| 1626 | const { status, headers } = await handshake(`room=${room.code}&name=handshake`, { | |
| 1627 | Cookie: `cs_session=${session}`, | |
| 1628 | }); | |
| 1629 | expect(status).toBe(101); | |
| 1630 | expect(headers.get("upgrade")?.toLowerCase()).toBe("websocket"); | |
| 1631 | expect(headers.get("sec-websocket-accept")).not.toBeNull(); | |
| 1632 | // The session it arrived with, not a new one: a browser that has a cookie | |
| 1633 | // keeps it, and the cookie is the same one every page here sets. | |
| 1634 | expect(headers.get("set-cookie")).toContain(`cs_session=${session}`); | |
| 1635 | expect(headers.get("set-cookie")).toContain("HttpOnly"); | |
| 1636 | expect(headers.get("set-cookie")).toContain("SameSite=Lax"); | |
| 1637 | // The floor rides on the 101 like it does on every other answer. | |
| 1638 | expect(headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 1639 | expect(headers.get("x-content-type-options")).toBe("nosniff"); | |
| 1640 | expect(headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); | |
| 1641 | expect(headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()"); | |
| 1642 | ||
| 1643 | // A browser with no cookie is given one on the 101 as well. | |
| 1644 | const fresh = await handshake(`room=${room.code}&name=handshake`); | |
| 1645 | expect(fresh.status).toBe(101); | |
| 1646 | expect(fresh.headers.get("set-cookie")).toMatch(/cs_session=[0-9a-f]{32}; Path=\/; HttpOnly; SameSite=Lax/); | |
| 1647 | ||
| 1648 | // Both connections were dropped by `handshake`; the seats go with them. | |
| 1649 | expect(await until(() => hub.find(room.code)?.members.size === 0)).toBe(true); | |
| 1650 | } finally { | |
| 1651 | await finish(room.code); | |
| 1652 | } | |
| 1653 | }); | |
| 1654 | ||
| 1655 | test("a real socket into a real room is handed hello and then the round, sixteen times a second", async () => { | |
| 1656 | const room = await openedRoom("hello", { map: "de_forge" }); | |
| 1657 | const client = await connect(`room=${room.code}&name=torque`); | |
| 1658 | try { | |
| 1659 | expect(await until(() => client.frames.some((frame) => typeof frame !== "string"))).toBe(true); | |
| 1660 | ||
| 1661 | // The first thing down the wire is the hello, and it is about this room. | |
| 1662 | const hello = readDown(client.frames[0] as string) as Hello; | |
| 1663 | expect(hello?.k).toBe("hello"); | |
| 1664 | expect(hello.code).toBe(room.code); | |
| 1665 | expect(hello.map).toBe("de_forge"); | |
| 1666 | expect(hello.rules).toBe("short"); | |
| 1667 | expect(hello.snapshotHz).toBe(SNAPSHOT_HZ); | |
| 1668 | expect(hello.roster.length).toBe(TEAM_SIZE * 2); | |
| 1669 | expect(hello.roster[hello.seat]?.name).toBe("torque"); | |
| 1670 | expect(hello.roster[hello.seat]?.bot).toBe(false); | |
| 1671 | // The room agrees: one member, holding that seat. | |
| 1672 | const held = hub.find(room.code); | |
| 1673 | expect(held?.members.size).toBe(1); | |
| 1674 | expect([...(held?.members.values() ?? [])][0]?.seat).toBe(hello.seat); | |
| 1675 | ||
| 1676 | // Then the round itself, as bytes a page reads: the same snapshot the | |
| 1677 | // shooter's own client applies, against a world carved from the two | |
| 1678 | // numbers the hello handed over. | |
| 1679 | const snapshot = client.frames.find((frame) => typeof frame !== "string") as Uint8Array; | |
| 1680 | expect(snapshot[0]).toBe(SNAPSHOT_FRAME); | |
| 1681 | const watching = createWorld(levelOf("de_forge"), SHORT, "watching", "ct", TEAM_SIZE, hello.seed); | |
| 1682 | const read = applySnapshot(watching, snapshot, hello.seat); | |
| 1683 | expect(read).not.toBeNull(); | |
| 1684 | expect(read?.tick).toBeGreaterThan(0); | |
| 1685 | } finally { | |
| 1686 | await client.close(); | |
| 1687 | // The close is what frees the seat, and the bot takes its body back. | |
| 1688 | expect(await until(() => hub.find(room.code)?.members.size === 0)).toBe(true); | |
| 1689 | expect(hub.find(room.code)?.world.players.every((p) => p.bot)).toBe(true); | |
| 1690 | hub.rooms.delete(room.code); | |
| 1691 | } | |
| 1692 | }); | |
| 1693 | ||
| 1694 | test("a name longer than 24 characters is cut at the door, before the room ever sees it", async () => { | |
| 1695 | // The door cuts at 24; the room then strips what is not printable and cuts at | |
| 1696 | // 20 (`uniqueName`). A name whose first ten characters are control codes | |
| 1697 | // tells the two apart: cut at 24 first and fourteen letters survive, cut only | |
| 1698 | // at 20 in the room and twenty would. | |
| 1699 | const wanted = "\u0001".repeat(10) + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; | |
| 1700 | const room = await openedRoom("long name"); | |
| 1701 | const client = await connect(`room=${room.code}&name=${encodeURIComponent(wanted)}`); | |
| 1702 | try { | |
| 1703 | expect(await until(() => client.frames.length > 0)).toBe(true); | |
| 1704 | const hello = readDown(client.frames[0] as string) as Hello; | |
| 1705 | expect(hello.roster[hello.seat]?.name).toBe("ABCDEFGHIJKLMN"); | |
| 1706 | } finally { | |
| 1707 | await finish(room.code, client); | |
| 1708 | } | |
| 1709 | }); | |
| 1710 | ||
| 1711 | test("a text frame on the socket is dropped, and the same bytes as binary are an input", async () => { | |
| 1712 | const room = await openedRoom("text frame"); | |
| 1713 | const client = await connect(`room=${room.code}&name=typist`); | |
| 1714 | try { | |
| 1715 | expect(await until(() => client.frames.length > 0)).toBe(true); | |
| 1716 | const member = [...(hub.find(room.code)?.members.values() ?? [])][0]; | |
| 1717 | expect(member).toBeDefined(); | |
| 1718 | ||
| 1719 | // Fourteen characters, the exact width of an input frame, as text. Text is | |
| 1720 | // not part of this protocol in this direction, so it is dropped before the | |
| 1721 | // room: not driven, and not even counted as a frame the room refused. | |
| 1722 | client.ws.send("x".repeat(INPUT_BYTES)); | |
| 1723 | // The binary frame behind it is the clock: once its tick has landed, the | |
| 1724 | // text frame in front of it has been through the same handler. | |
| 1725 | client.ws.send(new Uint8Array(writeInput({ | |
| 1726 | tick: 5, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null, | |
| 1727 | }))); | |
| 1728 | expect(await until(() => member?.lastTick === 5)).toBe(true); | |
| 1729 | expect(member?.dropped).toBe(0); | |
| 1730 | expect(client.closeCode()).toBeNull(); | |
| 1731 | } finally { | |
| 1732 | await finish(room.code, client); | |
| 1733 | } | |
| 1734 | }); | |
| 1735 | ||
| 1736 | test("a binary frame over MESSAGE_MAX closes the connection before this process holds it", async () => { | |
| 1737 | const room = await openedRoom("huge frame"); | |
| 1738 | const client = await connect(`room=${room.code}&name=shouter`); | |
| 1739 | try { | |
| 1740 | expect(await until(() => client.frames.length > 0)).toBe(true); | |
| 1741 | const member = [...(hub.find(room.code)?.members.values() ?? [])][0]; | |
| 1742 | ||
| 1743 | client.ws.send(new Uint8Array(MESSAGE_MAX + 1)); | |
| 1744 | expect(await until(() => client.closeCode() !== null)).toBe(true); | |
| 1745 | // Bun refuses a frame past `maxPayloadLength` itself, so this close is the | |
| 1746 | // runtime's and never the hub's own 4001: the bytes were never handed to a | |
| 1747 | // room, which is why there is nothing here to parse and nothing counted. | |
| 1748 | expect(client.closeCode()).not.toBe(CLOSE_TOO_BIG); | |
| 1749 | expect(member?.dropped).toBe(0); | |
| 1750 | // And the seat goes back to the bot that had it. | |
| 1751 | expect(await until(() => hub.find(room.code)?.members.size === 0)).toBe(true); | |
| 1752 | expect(hub.find(room.code)?.world.players.every((p) => p.bot)).toBe(true); | |
| 1753 | } finally { | |
| 1754 | await finish(room.code, client); | |
| 1755 | } | |
| 1756 | }); | |
| 1757 | ||
| 1758 | test("every room these tests opened was given back, and every socket closed", async () => { | |
| 1759 | // The invariant the whole section is written to: nothing above leaves a world | |
| 1760 | // being stepped, and no seat is held by a connection that is gone. A failure | |
| 1761 | // here names a test above that forgot its `finish`. | |
| 1762 | expect(await until(() => hub.members() === 0)).toBe(true); | |
| 1763 | expect([...hub.rooms.keys()]).toEqual([]); | |
| 1764 | }); |
src/server.ts +8 −1
| @@ -302,9 +302,16 @@ const roomLimit = createLimiter(ROOM_LIMIT); | ||
| 302 | 302 | * session are two members, and a reload is a new one whose predecessor is taken |
| 303 | 303 | * out of the room by its own close. So a seat is never held by a tab that is |
| 304 | 304 | * gone, and a session cookie is not a claim on a seat. |
| 305 | * | |
| 306 | * The hub is exported for one reason, and it is the tests': `src/server.test.ts` | |
| 307 | * drives the two doors below over a real socket, and a room opened by a test is | |
| 308 | * a world this process steps until the hub's sweep takes it ninety seconds | |
| 309 | * later. So a test gives its room back when it is done — `hub.rooms.delete` — | |
| 310 | * and reads the member it just made to check what the room was told. Nothing in | |
| 311 | * this app imports it; a room is still opened only through the route below. | |
| 305 | 312 | */ |
| 306 | 313 | const sockets = new Map<string, Bun.ServerWebSocket<Joined>>(); |
| 307 | const hub = createHub({ | |
| 314 | export const hub = createHub({ | |
| 308 | 315 | deliver(id, frame) { |
| 309 | 316 | sockets.get(id)?.send(frame); |
| 310 | 317 | }, |
new above
offline