diff --git a/lib/src/remote/test-rendezvous.test.ts b/lib/src/remote/test-rendezvous.test.ts new file mode 100644 index 000000000..39d86c18e --- /dev/null +++ b/lib/src/remote/test-rendezvous.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_ONE_TIME_FORWARDED, + ONE_TIME_WS_ROUTES, + WS_CLOSE_ONE_TIME_EXPIRED, + WS_CLOSE_ONE_TIME_TAKEN, + WS_CLOSE_ONE_TIME_UNAVAILABLE, + WS_CLOSE_ONE_TIME_VIOLATION, +} from 'remote-lib-common'; +import { createTestRendezvous } from './test-rendezvous'; +import { createTestClock } from './test-timers'; + +// The fake must close as Hosted's `OneTimeRoom` does (`hosted/server/one-time-room.ts`; +// `docs/specs/one-time.md` -> "Hosted rendezvous"): the phone reads its copy off the code. +describe('test rendezvous parity with the one-time room', () => { + const START = 1_700_000_000_000; + async function mint() { + const clock = createTestClock(START); + const rendezvous = createTestRendezvous({ now: clock.now, setTimer: clock.setTimer }); + rendezvous.createBurrowSocket(`wss://hosted.invalid${ONE_TIME_WS_ROUTES.burrow}`); + await Promise.resolve(); + const room = rendezvous.room(); + const join = async () => { + const socket = rendezvous.createClientSocket(rendezvous.clientUrl(room.roomId)); + await Promise.resolve(); + return socket; + }; + return { clock, rendezvous, room, join }; + } + + it('refuses an unknown room 4012, a joined one 4011, and a join past expiresAt 4010', async () => { + const { clock, rendezvous, room, join } = await mint(); + const unknown = rendezvous.createClientSocket(rendezvous.clientUrl('nope')); + await Promise.resolve(); + expect(unknown.closeCode).toBe(WS_CLOSE_ONE_TIME_UNAVAILABLE); + + clock.jump(room.expiresAt + 1 - clock.now()); + expect((await join()).closeCode).toBe(WS_CLOSE_ONE_TIME_EXPIRED); + + const late = await mint(); + expect((await late.join()).closeCode).toBeNull(); + late.clock.jump(late.room.expiresAt + 1 - late.clock.now()); + expect((await late.join()).closeCode).toBe(WS_CLOSE_ONE_TIME_TAKEN); + }); + + it('counts a frame sent before any phone joins toward the cap', async () => { + const { room } = await mint(); + for (let i = 0; i < MAX_ONE_TIME_FORWARDED; i += 1) room.burrow.send(`frame ${i}`); + expect(room.forwarded).toBe(MAX_ONE_TIME_FORWARDED); + expect(room.burrow.closeCode).toBeNull(); + room.burrow.send('one past the cap'); + expect(room.burrow.closeCode).toBe(WS_CLOSE_ONE_TIME_VIOLATION); + }); +}); diff --git a/lib/src/remote/test-rendezvous.ts b/lib/src/remote/test-rendezvous.ts index dbda66e73..6b4850dd8 100644 --- a/lib/src/remote/test-rendezvous.ts +++ b/lib/src/remote/test-rendezvous.ts @@ -13,11 +13,12 @@ * * - **A room per Burrow socket**, announced by exactly one `one-time-room` frame * the moment the socket opens. - * - **One join, ever.** A second join is accepted and then closed `4011`; an - * unknown or deleted room, or one past its `expiresAt`, `4012`. + * - **One join, ever.** A refused join is accepted and then closed: an + * unknown or deleted room `4012`, then a joined one `4011`, then one past its + * `expiresAt` `4010`. * - **Strings forwarded verbatim, never parsed**, each counted toward * `MAX_ONE_TIME_FORWARDED` across both directions; a message with nobody on - * the other end yet is dropped uncounted. + * the other end yet is counted, then dropped. * - **`RELAY_PING` answered with `RELAY_PONG`**, never forwarded or * counted. * - **A non-string, an oversize string, or one past the cap closes both @@ -254,14 +255,12 @@ export function createTestRendezvous(options: TestRendezvousOptions = {}): TestR violate(room); return; } - const to = from === room.burrow ? room.client : room.burrow; - if (!to) return; room.forwarded += 1; if (room.forwarded > MAX_ONE_TIME_FORWARDED) { violate(room); return; } - to.deliver(data); + (from === room.burrow ? room.client : room.burrow)?.deliver(data); }; const closed = (room: Room, from: RendezvousSocket): void => { @@ -337,8 +336,9 @@ export function createTestRendezvous(options: TestRendezvousOptions = {}): TestR // Decided now, so a second join made in the same turn is refused; told // after the open, as accept-then-close does. let refusal: number | null = null; - if (!room || room.deleted || now() > room.expiresAt) refusal = WS_CLOSE_ONE_TIME_UNAVAILABLE; + if (!room || room.deleted) refusal = WS_CLOSE_ONE_TIME_UNAVAILABLE; else if (room.client) refusal = WS_CLOSE_ONE_TIME_TAKEN; + else if (now() > room.expiresAt) refusal = WS_CLOSE_ONE_TIME_EXPIRED; else { room.client = socket; socket.onSend = (data) => forward(room, socket, data);