import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' import { AUDIT_LOG_SCHEMA_DDL, NOTIFICATION_SCHEMA_DDL } from '@repo/domain' import '../../notify.app' import { MAX_PENDING_PER_PLAYER } from '../../notifications-hub' import type { Env } from '../../context' import type { HubState } from '../../notifications-hub' declare module 'cloudflare:test' { interface ProvidedEnv extends Env {} } const ORIGIN = 'https://example.com' const RS = '\u001e' // Mint a token the way the `auth` worker does, signing with the shared test key // seeded into the JWT_SECRET store. Accounts 1 and 2 are the internal-endpoint admins. const TEST_SECRET = 'test-signing-key' function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } async function bearer( sub: string, roles?: string[], expiresIn = 3600 ): Promise> { const now = Math.floor(Date.now() / 1000) const claims: Record = { sub, exp: now + expiresIn } if (roles) claims.role = roles const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( JSON.stringify(claims) )}` const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } } // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. beforeAll(async () => { await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET) // The notification store a targeted coach message writes to. Owned by the `api` worker's // migrations, which don't run here, so build it from the mirrored DDL. for (const ddl of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(ddl).run() // The audit trail every /internal/* call is filed on. Owned by `api` too, so likewise // built from the mirrored DDL. for (const ddl of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(ddl).run() }) /** Who `connect` is by default — kept clear of the player ids the tests notify. */ const DEFAULT_CONNECT_PLAYER = 9000 interface HubRecord { type?: number target?: string invocationId?: string result?: unknown arguments?: unknown[] } /** Open a hub WebSocket, accept it, and complete the SignalR handshake. */ async function connect( id: string, opts: { headers?: Record; query?: string } = {} ): Promise<{ ws: WebSocket waitFor: (pred: (r: HubRecord) => boolean) => Promise /** Everything this socket has received so far — for asserting something did NOT arrive. */ records: HubRecord[] }> { // The hub only accepts identified connections, so default to a token; pass explicit // `headers` (`{}` for none) where the test is about who is connecting. const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient'])) const res = await exports.default.fetch(`${ORIGIN}/hub/v1?id=${id}${opts.query ?? ''}`, { headers: { Upgrade: 'websocket', ...auth }, }) expect(res.status).toBe(101) const ws = res.webSocket! ws.accept() const records: HubRecord[] = [] const waiters: Array<{ pred: (r: HubRecord) => boolean; resolve: (r: HubRecord) => void }> = [] ws.addEventListener('message', (e: MessageEvent) => { const text = typeof e.data === 'string' ? e.data : new TextDecoder().decode(e.data as ArrayBuffer) for (const part of text.split(RS)) { if (!part) continue const rec = JSON.parse(part) as HubRecord records.push(rec) for (let i = waiters.length - 1; i >= 0; i--) { if (waiters[i].pred(rec)) { waiters[i].resolve(rec) waiters.splice(i, 1) } } } }) const waitFor = (pred: (r: HubRecord) => boolean): Promise => { const existing = records.find(pred) if (existing) return Promise.resolve(existing) return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('timed out waiting for hub message')), 2000) waiters.push({ pred, resolve: (r) => { clearTimeout(timer) resolve(r) }, }) }) } // Handshake, then the OnConnect callback. ws.send(`{"protocol":"json","version":1}${RS}`) await waitFor((r) => r.type === 1 && r.target === 'OnConnect') return { ws, waitFor, records } } const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS) // The /internal/* endpoints are admin-gated (a token carrying an admin role), so // default to a moderator token; pass `auth` to override (e.g. the 401/403 paths). const post = async (path: string, body: unknown, auth?: Record) => exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ?? (await bearer('1', ['gameClient', 'moderator']))), }, body: JSON.stringify(body), }) describe('negotiate', () => { test('POST /hub/v1/negotiate advertises the WebSocket transport', async () => { const res = await exports.default.fetch(`${ORIGIN}/hub/v1/negotiate?negotiateVersion=1`, { method: 'POST', }) expect(res.status).toBe(200) const body = (await res.json()) as { connectionId: string connectionToken: string availableTransports: Array<{ transport: string }> } expect(body.connectionId).toMatch(/^[0-9a-f-]{36}$/) expect(body.connectionToken).toBe(body.connectionId) expect(body.availableTransports[0].transport).toBe('WebSockets') }) test('GET /hub/v1 without an upgrade header is 426', async () => { const res = await exports.default.fetch(`${ORIGIN}/hub/v1`) expect(res.status).toBe(426) }) }) describe('internal endpoint auth', () => { const body = { playerId: 1, notificationType: 1, data: {} } test('401 without a Bearer token', async () => { const res = await exports.default.fetch(`${ORIGIN}/internal/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) expect(res.status).toBe(401) }) test('403 for a valid token without an admin role', async () => { const res = await post('/internal/notify', body, await bearer('3', ['gameClient'])) expect(res.status).toBe(403) }) test('tokens carrying an admin role are allowed', async () => { for (const role of ['developer', 'moderator']) { const res = await post( '/internal/broadcast', { notificationType: 1 }, await bearer('3', ['gameClient', role]) ) expect(res.status).toBe(200) } }) }) describe('hub protocol', () => { test('GetSubscriptions reflects SubscribeToPlayers', async () => { const { ws, waitFor } = await connect('conn-subs') send(ws, { type: 1, invocationId: '1', target: 'GetSubscriptions', arguments: [] }) const empty = await waitFor((r) => r.type === 3 && r.invocationId === '1') expect(empty.result).toEqual([]) send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [1, 2, 2] }] }) send(ws, { type: 1, invocationId: '2', target: 'GetSubscriptions', arguments: [] }) const subscribed = await waitFor((r) => r.type === 3 && r.invocationId === '2') expect((subscribed.result as number[]).sort((a, b) => a - b)).toEqual([1, 2]) ws.close() }) // The client's argument spelling isn't specified anywhere, and getting it wrong is // silent: subscribing replaces the connection's set, so a misread leaves it at zero // and every notification queues instead of being delivered. test.each([ ['an options object', [{ playerIds: [11, 12] }]], ['a single array argument', [[11, 12]]], ['varargs', [11, 12]], ['string ids', [{ playerIds: ['11', '12'] }]], ])('SubscribeToPlayers accepts %s', async (label, args) => { const { ws, waitFor } = await connect(`conn-args-${label.replace(/\s+/g, '-')}`) send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: args }) send(ws, { type: 1, invocationId: 'g', target: 'GetSubscriptions', arguments: [] }) const result = await waitFor((r) => r.type === 3 && r.invocationId === 'g') expect((result.result as number[]).sort((a, b) => a - b)).toEqual([11, 12]) ws.close() }) test('an unreadable argument leaves existing subscriptions alone', async () => { const { ws, waitFor } = await connect('conn-args-bad') send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [21] }] }) // Answered, but the connection keeps the players it already had rather than // being wiped to zero. send(ws, { type: 1, invocationId: 'b', target: 'SubscribeToPlayers', arguments: ['nonsense'] }) await waitFor((r) => r.type === 3 && r.invocationId === 'b') send(ws, { type: 1, invocationId: 'g', target: 'GetSubscriptions', arguments: [] }) const result = await waitFor((r) => r.type === 3 && r.invocationId === 'g') expect(result.result).toEqual([21]) ws.close() }) test('an explicitly empty list still clears the connection', async () => { const { ws, waitFor } = await connect('conn-args-empty') send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [31] }] }) send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [] }] }) send(ws, { type: 1, invocationId: 'g', target: 'GetSubscriptions', arguments: [] }) const result = await waitFor((r) => r.type === 3 && r.invocationId === 'g') expect(result.result).toEqual([]) ws.close() }) test('responds to ping', async () => { const { ws, waitFor } = await connect('conn-ping') send(ws, { type: 6 }) const pong = await waitFor((r) => r.type === 6) expect(pong.type).toBe(6) ws.close() }) }) describe('notification delivery', () => { test('queues for an offline player and flushes on subscribe', async () => { const playerId = 9001 const queued = await post('/internal/notify', { playerId, notificationType: 2, data: { messageId: 'm1' }, }) expect(await queued.json()).toMatchObject({ queued: true, delivered: 0 }) const { ws, waitFor } = await connect('conn-pending') send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }] }) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') expect(JSON.parse((note.arguments as string[])[0])).toEqual({ Id: '2', Msg: { messageId: 'm1' }, }) ws.close() }) test('delivers live to a subscribed player', async () => { const playerId = 9002 const { ws, waitFor } = await connect('conn-live') send(ws, { type: 1, invocationId: 's', target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }], }) await waitFor((r) => r.type === 3 && r.invocationId === 's') const res = await post('/internal/notify', { playerId, notificationType: 1, data: { accountId: 42 }, }) expect(await res.json()).toMatchObject({ delivered: 1, queued: false }) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') expect(JSON.parse((note.arguments as string[])[0])).toEqual({ Id: '1', Msg: { accountId: 42 }, }) ws.close() }) // The bulk send is notifyPlayer many times over in one RPC: each entry its own player and // payload, delivered live or queued per player, in the order given. What the token drop // sends — two frames a player, a few hundred players. test('delivers a batch to each player, live or queued, in order', async () => { const live = 9005 const offline = 9006 const { ws, waitFor, records } = await connect('conn-bulk') send(ws, { type: 1, invocationId: 's', target: 'SubscribeToPlayers', arguments: [{ playerIds: [live] }], }) await waitFor((r) => r.type === 3 && r.invocationId === 's') const stub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') const result = await runInDurableObject(stub, (instance) => instance.notifyPlayers([ { playerId: live, notificationType: 61, data: { Balance: 10 } }, { playerId: offline, notificationType: 61, data: { Balance: 20 } }, { playerId: live, notificationType: 31, data: { Id: 1 } }, { playerId: offline, notificationType: 31, data: { Id: 2 } }, ]) ) expect(result).toEqual({ delivered: 2, queued: 2 }) await waitFor( (r) => r.type === 1 && r.target === 'Notification' && (JSON.parse((r.arguments as string[])[0]) as { Id: string }).Id === '31' ) const notes = records .filter((r) => r.type === 1 && r.target === 'Notification') .map((r) => JSON.parse((r.arguments as string[])[0]) as unknown) expect(notes).toEqual([ { Id: '61', Msg: { Balance: 10 } }, { Id: '31', Msg: { Id: 1 } }, ]) ws.close() // The offline player's two are queued, and flush in order when they subscribe. const late = await connect('conn-bulk-late') send(late.ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [offline] }], }) await late.waitFor( (r) => r.type === 1 && r.target === 'Notification' && (JSON.parse((r.arguments as string[])[0]) as { Id: string }).Id === '31' ) const flushed = late.records .filter((r) => r.type === 1 && r.target === 'Notification') .map((r) => JSON.parse((r.arguments as string[])[0]) as unknown) expect(flushed).toEqual([ { Id: '61', Msg: { Balance: 20 } }, { Id: '31', Msg: { Id: 2 } }, ]) late.ws.close() }) test('broadcast reaches connected clients', async () => { const { ws, waitFor } = await connect('conn-broadcast') const res = await post('/internal/broadcast', { notificationType: 25, data: { message: 'maint' }, }) expect(((await res.json()) as { delivered: number }).delivered).toBeGreaterThanOrEqual(1) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') expect(JSON.parse((note.arguments as string[])[0])).toEqual({ Id: '25', Msg: { message: 'maint' }, }) ws.close() }) test('coach-message-all messages every connected client', async () => { const a = await connect('coach-a') const b = await connect('coach-b') const res = await post('/internal/coach-message-all', { messageContent: 'hello all' }) expect(res.status).toBe(200) expect(((await res.json()) as { sent: number }).sent).toBeGreaterThanOrEqual(2) const noteA = await a.waitFor((r) => r.type === 1 && r.target === 'Notification') const payloadA = JSON.parse((noteA.arguments as string[])[0]) as { Id: string Msg: Record } expect(payloadA.Id).toBe('2') // MessageReceived expect(payloadA.Msg).toMatchObject({ FromPlayerId: 1, Type: 100, Data: 'hello all' }) const noteB = await b.waitFor((r) => r.type === 1 && r.target === 'Notification') const payloadB = JSON.parse((noteB.arguments as string[])[0]) as { Msg: Record } expect(payloadB.Msg).toMatchObject({ Data: 'hello all' }) a.ws.close() b.ws.close() }) test('coach-message reaches only the named player, addressed to them', async () => { const playerId = 9010 const target = await connect('coach-one', { headers: await bearer(String(playerId), ['gameClient']), }) const bystander = await connect('coach-one-bystander') send(target.ws, { type: 1, invocationId: 's', target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }], }) await target.waitFor((r) => r.type === 3 && r.invocationId === 's') const res = await post('/internal/coach-message', { playerId, messageContent: 'just you' }) expect(res.status).toBe(200) expect(await res.json()).toMatchObject({ delivered: 1, queued: false }) const note = await target.waitFor((r) => r.type === 1 && r.target === 'Notification') const payload = JSON.parse((note.arguments as string[])[0]) as { Id: string Msg: Record } expect(payload.Id).toBe('2') // MessageReceived // Same Coach frame as the broadcast, plus the recipient the broadcast can't name. expect(payload.Msg).toMatchObject({ FromPlayerId: 1, ToPlayerId: playerId, Type: 100, Data: 'just you', }) // The point of the targeted send: nobody else's socket sees it. The bystander is // subscribed to nothing, and a broadcast would have reached it regardless. expect(bystander.records.some((r) => r.target === 'Notification')).toBe(false) target.ws.close() bystander.ws.close() }) test('coach-message queues for a player who is offline', async () => { const playerId = 9011 const res = await post('/internal/coach-message', { playerId, messageContent: 'catch you later', }) expect(await res.json()).toMatchObject({ delivered: 0, queued: true }) // Unlike the broadcast, it survives until they connect. const { ws, waitFor } = await connect('coach-one-late') send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }] }) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') expect( (JSON.parse((note.arguments as string[])[0]) as { Msg: Record }).Msg ).toMatchObject({ FromPlayerId: 1, ToPlayerId: playerId, Data: 'catch you later' }) ws.close() }) test('coach-message stores a notification; the broadcast does not', async () => { const stored = async (playerId: number) => ( await env.DB.prepare( 'SELECT notification_id, from_player_id, type, data FROM notification WHERE to_player_id = ?1' ) .bind(playerId) .all<{ notification_id: number; from_player_id: number; type: number; data: string }>() ).results // A message to ONE player is a real message: stored, so they read it from their // inbox on next login whether or not the hub reached them, and the frame carries // the stored row's id. const playerId = 9013 const res = await post('/internal/coach-message', { playerId, messageContent: 'kept' }) const { notificationId } = (await res.json()) as { notificationId: number } const rows = await stored(playerId) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ notification_id: notificationId, from_player_id: 1, type: 100, data: 'kept', }) // The BROADCAST stores nothing — it reaches everyone online at once, so a row per // player would be thousands of writes per maintenance notice. const listener = await connect('coach-nostore', { headers: await bearer('9014', ['gameClient']), }) await post('/internal/coach-message-all', { messageContent: 'everyone' }) await listener.waitFor((r) => r.type === 1 && r.target === 'Notification') expect(await stored(9014)).toEqual([]) listener.ws.close() }) test('coach-message 400s without a player or a message', async () => { expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400) expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400) expect( (await post('/internal/coach-message', { playerId: 9012, messageContent: ' ' })).status ).toBe(400) }) test('coach-message-all 400s on an empty message', async () => { const res = await post('/internal/coach-message-all', { messageContent: ' ' }) expect(res.status).toBe(400) }) test('emits a numeric notificationType as a string Id', async () => { // The client dispatches on a string Id, so numeric codes (e.g. econ's // NotificationType enum) must be serialized as strings or they're dropped. const playerId = 9003 const { ws, waitFor } = await connect('conn-numeric-id') send(ws, { type: 1, invocationId: 's', target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }], }) await waitFor((r) => r.type === 3 && r.invocationId === 's') await post('/internal/notify', { playerId, notificationType: 71, data: { itemId: 5 } }) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') const payload = JSON.parse((note.arguments as string[])[0]) as { Id: unknown } expect(payload.Id).toBe('71') expect(typeof payload.Id).toBe('string') ws.close() }) }) // The client never calls SubscribeToPlayers, so a player's own notifications reach them // only because the connect established who they are. describe('connection ownership', () => { const hubState = async () => (await ( await exports.default.fetch(`${ORIGIN}/internal/hub-state`, { headers: await bearer('1', ['gameClient', 'moderator']), }) ).json()) as HubState // The client never calls SubscribeToPlayers, so connecting is the only moment a // queue can drain — with the flush living only in there, anything that landed while // the player was reconnecting stayed queued forever. test('drains what queued while the player was away, on connect', async () => { const playerId = 9305 await post('/internal/notify', { playerId, notificationType: 90, data: { n: 1 } }) await post('/internal/notify', { playerId, notificationType: 90, data: { n: 2 } }) const { ws, waitFor } = await connect('conn-flush', { headers: await bearer(String(playerId), ['gameClient']), }) // Delivered in the order they queued, and cleared once sent. const first = await waitFor( (r) => r.target === 'Notification' && (r.arguments as string[])[0].includes('"n":1') ) expect(JSON.parse((first.arguments as string[])[0])).toEqual({ Id: '90', Msg: { n: 1 } }) await waitFor( (r) => r.target === 'Notification' && (r.arguments as string[])[0].includes('"n":2') ) expect((await hubState()).pending.find((p) => p.playerId === playerId)).toBeUndefined() ws.close() }) test('delivers to the connecting player without any subscription', async () => { const playerId = 9301 const { ws, waitFor } = await connect('conn-owned', { headers: await bearer(String(playerId), ['gameClient']), }) const res = await post('/internal/notify', { playerId, notificationType: 90, data: { chatThreadId: 22 }, }) expect(await res.json()).toMatchObject({ delivered: 1, queued: false }) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') expect(JSON.parse((note.arguments as string[])[0])).toEqual({ Id: '90', Msg: { chatThreadId: 22 }, }) const connection = (await hubState()).connections.find((c) => c.connectionId === 'conn-owned') expect(connection).toMatchObject({ playerId, playerIds: [] }) ws.close() }) // SignalR clients that can't set headers on the upgrade put the token here instead. test('accepts the token as an access_token query param', async () => { const playerId = 9302 const auth = await bearer(String(playerId), ['gameClient']) const token = auth.Authorization.slice('Bearer '.length) const { ws, waitFor } = await connect('conn-owned-query', { headers: {}, query: `&access_token=${token}`, }) await post('/internal/notify', { playerId, notificationType: 90, data: { a: 1 } }) const note = await waitFor((r) => r.type === 1 && r.target === 'Notification') expect(JSON.parse((note.arguments as string[])[0])).toMatchObject({ Id: '90' }) ws.close() }) // The header is the DO's proof of identity, so presenting one without a token must // not get you in — otherwise anyone could name themselves and receive that player's // notifications. test('a client-supplied owner header does not authenticate a connect', async () => { const res = await exports.default.fetch(`${ORIGIN}/hub/v1?id=conn-spoofed`, { headers: { Upgrade: 'websocket', 'x-recflare-connection-owner': '9303' }, }) expect(res.status).toBe(401) const notified = await post('/internal/notify', { playerId: 9303, notificationType: 90 }) expect(await notified.json()).toMatchObject({ delivered: 0, queued: true }) expect( (await hubState()).connections.find((c) => c.connectionId === 'conn-spoofed') ).toBeUndefined() }) test('an unidentified connect is refused', async () => { const res = await exports.default.fetch(`${ORIGIN}/hub/v1?id=conn-anon`, { headers: { Upgrade: 'websocket' }, }) expect(res.status).toBe(401) }) test('an expired token is refused', async () => { const expired = await bearer(String(9304), ['gameClient'], -60) const res = await exports.default.fetch(`${ORIGIN}/hub/v1?id=conn-expired`, { headers: { Upgrade: 'websocket', ...expired }, }) expect(res.status).toBe(401) }) }) describe('hub state', () => { const hubState = async (auth?: Record) => exports.default.fetch(`${ORIGIN}/internal/hub-state`, { headers: auth ?? (await bearer('1', ['gameClient', 'moderator'])), }) test('reports a connection, who it receives for, and what is queued', async () => { const subscribed = 9101 const offline = 9102 const { ws, waitFor } = await connect('conn-state') send(ws, { type: 1, invocationId: 's', target: 'SubscribeToPlayers', arguments: [{ playerIds: [subscribed] }], }) await waitFor((r) => r.type === 3 && r.invocationId === 's') // Nobody is subscribed to `offline`, so this one queues instead of delivering — // the case a missing push is usually hiding in. await post('/internal/notify', { playerId: offline, notificationType: 90, data: { a: 1 } }) // One DO is shared across the file, so other tests' connections are in here too. const state = (await (await hubState()).json()) as HubState expect(state.connections.find((c) => c.connectionId === 'conn-state')).toEqual({ connectionId: 'conn-state', live: true, handshakeDone: true, playerId: DEFAULT_CONNECT_PLAYER, playerIds: [subscribed], }) const queued = state.pending.find((p) => p.playerId === offline) expect(queued?.count).toBe(1) expect(JSON.parse(queued!.latest)).toEqual({ Id: '90', Msg: { a: 1 } }) expect(state.pending.find((p) => p.playerId === subscribed)).toBeUndefined() ws.close() }) test('is admin-gated like the other internal endpoints', async () => { expect((await exports.default.fetch(`${ORIGIN}/internal/hub-state`)).status).toBe(401) expect((await hubState(await bearer('3', ['gameClient']))).status).toBe(403) }) }) describe('clearing pending notifications', () => { const clear = async (query: string, auth?: Record) => exports.default.fetch(`${ORIGIN}/internal/hub-state/pending?${query}`, { method: 'DELETE', headers: auth ?? (await bearer('1', ['gameClient', 'moderator'])), }) const pendingFor = async (playerId: number) => { const state = (await ( await exports.default.fetch(`${ORIGIN}/internal/hub-state`, { headers: await bearer('1', ['gameClient', 'moderator']), }) ).json()) as HubState return state.pending.find((p) => p.playerId === playerId) } const queue = async (playerId: number, count: number) => { for (let i = 0; i < count; i++) { await post('/internal/notify', { playerId, notificationType: 90, data: { i } }) } } test('clears one player, leaving everyone else queued', async () => { await queue(9201, 2) await queue(9202, 1) const res = await clear('playerId=9201') expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true, cleared: 2 }) expect(await pendingFor(9201)).toBeUndefined() expect((await pendingFor(9202))?.count).toBe(1) // Clearing a queue that's already empty is a no-op, not an error. expect(await (await clear('playerId=9201')).json()).toEqual({ success: true, cleared: 0 }) }) test('clears every queue only when all=true is explicit', async () => { await queue(9203, 1) const guarded = await clear('') expect(guarded.status).toBe(400) expect((await pendingFor(9203))?.count).toBe(1) const res = await clear('all=true') expect(res.status).toBe(200) expect(((await res.json()) as { cleared: number }).cleared).toBeGreaterThanOrEqual(1) expect(await pendingFor(9203)).toBeUndefined() }) test('400s on a non-numeric playerId', async () => { expect((await clear('playerId=nope')).status).toBe(400) }) test('is admin-gated like the other internal endpoints', async () => { const res = await exports.default.fetch(`${ORIGIN}/internal/hub-state/pending?all=true`, { method: 'DELETE', }) expect(res.status).toBe(401) expect((await clear('all=true', await bearer('3', ['gameClient']))).status).toBe(403) }) }) // What a Durable Object RESET leaves behind, and how the hub recovers from it. A reset // kills every socket without running webSocketClose, so the connection rows outlive the // sockets they name — and a stale row makes deliverToPlayer report 0 delivered, which // queues notifications for players who are online. describe('recovering from lost sockets', () => { const hubState = async (): Promise => (await ( await exports.default.fetch(`${ORIGIN}/internal/hub-state`, { headers: await bearer('1', ['gameClient', 'moderator']), }) ).json()) as HubState test('forgets a connection whose socket is gone, instead of queueing to it forever', async () => { const playerId = 9301 const { ws } = await connect('conn-reset', { headers: await bearer(String(playerId), ['gameClient']), }) // Kill the socket the way a reset does — no close frame, so the DO never runs // webSocketClose and the row survives. await ws.close() // First send after the socket died: nothing to deliver to, so it queues... const first = await post('/internal/notify', { playerId, notificationType: 40, data: {} }) expect(await first.json()).toMatchObject({ queued: true, delivered: 0 }) // ...and the dead row is dropped on the way, so the player no longer looks // connected to anything. const state = await hubState() expect(state.connections.find((c) => c.connectionId === 'conn-reset')).toBeUndefined() }) }) describe('pending queue bound', () => { test('keeps the newest notifications and drops the oldest past the cap', async () => { // Unbounded, this is what a broken delivery path fills up — and what flushPending // then reads into memory in one go. Seeded straight into the object: the point is // the bound, not the 500 HTTP round trips it would take to reach it. const playerId = 9302 const stub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') await runInDurableObject(stub, (_instance, state) => { for (let i = 0; i < MAX_PENDING_PER_PLAYER + 100; i++) { state.storage.sql.exec( 'INSERT INTO pending (playerId, payload) VALUES (?, ?)', playerId, JSON.stringify({ Id: '90', Msg: { i } }) ) } }) // The next queued notification is what enforces the bound. await post('/internal/notify', { playerId, notificationType: 90, data: { last: true } }) const state = (await ( await exports.default.fetch(`${ORIGIN}/internal/hub-state`, { headers: await bearer('1', ['gameClient', 'moderator']), }) ).json()) as HubState const queued = state.pending.find((p) => p.playerId === playerId) expect(queued?.count).toBe(MAX_PENDING_PER_PLAYER) // Newest-wins: the one just sent survived, the oldest hundred did not. expect(JSON.parse(queued!.latest)).toEqual({ Id: '90', Msg: { last: true } }) }) }) // The website's admin controls (maintenance countdown, coach broadcast) are a browser // calling `/internal/*` directly rather than through a `www` proxy, so these need CORS. describe('CORS', () => { // The catch: `/internal/*` is behind `requireAdmin`, and a browser preflight carries // NO Authorization header — it can't, that's the header it's asking permission to // send. So the CORS middleware has to answer it before the admin gate sees it, // otherwise every admin action fails the preflight with a 401 and never gets sent. test('answers the preflight on an admin endpoint without a token', async () => { const res = await exports.default.fetch( new Request(`${ORIGIN}/internal/broadcast`, { method: 'OPTIONS', headers: { origin: 'https://www.example.com', 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization, content-type', }, }), env ) expect(res.status).toBe(204) expect(res.headers.get('access-control-allow-origin')).toBe('*') const allowed = res.headers.get('access-control-allow-headers')?.toLowerCase() ?? '' expect(allowed).toContain('authorization') expect(allowed).toContain('content-type') }) // The gate itself is untouched: the preflight passing is not the request passing. test('still rejects the actual call without an admin token', async () => { const res = await exports.default.fetch( new Request(`${ORIGIN}/internal/broadcast`, { method: 'POST', headers: { origin: 'https://www.example.com', 'content-type': 'application/json' }, body: JSON.stringify({ notificationType: 25, data: {} }), }), env ) expect(res.status).toBe(401) expect(res.headers.get('access-control-allow-origin')).toBe('*') }) }) // Every /internal/* call is recorded on the `audit_log` table, against the account that // made it. These endpoints are the operator's remote control over every connected client // and most of what they do leaves no other trace on the database — a maintenance broadcast // is a WebSocket frame and then nothing — so the row is the only lasting answer to "who // sent that". describe('audit log', () => { interface AuditRow { player_id: number action: string data: string | null date: string } /** Everything recorded against one actor, oldest first. */ const auditRows = async (playerId: number): Promise => { const { results } = await env.DB.prepare( 'SELECT player_id, action, data, date FROM audit_log WHERE player_id = ?1 ORDER BY audit_log_id' ) .bind(playerId) .all() return results } const auditData = (row: AuditRow): Record => JSON.parse(row.data ?? '{}') as Record // A distinct actor per test: the rest of the file drives these endpoints as account 1, // so filtering on the caller is what keeps each assertion about its own call. test('records a successful call against the caller, with what was asked for', async () => { const actor = 4242 const res = await post( '/internal/coach-message', { playerId: 9500, messageContent: 'audited' }, await bearer(String(actor), ['moderator']) ) expect(res.status).toBe(200) const rows = await auditRows(actor) expect(rows).toHaveLength(1) // The action is the path after `/internal/`, as a snake_case verb. expect(rows[0].action).toBe('coach_message') expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false) expect(auditData(rows[0])).toMatchObject({ method: 'POST', path: '/internal/coach-message', status: 200, // The message and its recipient exist nowhere else once the frame has gone out. body: { playerId: 9500, messageContent: 'audited' }, }) }) // A refused call is still a call someone made, and here — unlike the 401 below — we know // who made it. A signed-in account reaching for an admin endpoint is close to the whole // point of keeping the trail. test('records a call the role gate refused, with the status that refused it', async () => { const actor = 4243 const res = await post( '/internal/broadcast', { notificationType: 1 }, await bearer(String(actor), ['gameClient']) ) expect(res.status).toBe(403) const rows = await auditRows(actor) expect(rows).toHaveLength(1) expect(rows[0].action).toBe('broadcast') expect(auditData(rows[0]).status).toBe(403) }) test('records a rejected body as the attempt it was', async () => { const actor = 4244 const res = await post( '/internal/coach-message', { playerId: 9501 }, await bearer(String(actor), ['moderator']) ) expect(res.status).toBe(400) const rows = await auditRows(actor) expect(rows).toHaveLength(1) expect(auditData(rows[0])).toMatchObject({ status: 400, body: { playerId: 9501 } }) }) // The reads are recorded too, and a call that carries its arguments in the query string // rather than a body records those instead. test('records a read, with its query string', async () => { const actor = 4245 const res = await exports.default.fetch(`${ORIGIN}/internal/hub-state/pending?all=true`, { method: 'DELETE', headers: await bearer(String(actor), ['developer']), }) expect(res.status).toBe(200) const rows = await auditRows(actor) expect(rows).toHaveLength(1) expect(rows[0].action).toBe('hub_state_pending') expect(auditData(rows[0])).toMatchObject({ method: 'DELETE', query: { all: 'true' } }) }) // Nothing to file it under: `player_id` is the actor, and a request with no valid token // names nobody. The 401 is the record of that one, in the worker log. test('records nothing for a call with no valid token', async () => { const count = async () => (await env.DB.prepare('SELECT COUNT(*) AS n FROM audit_log').first<{ n: number }>())!.n const before = await count() const res = await exports.default.fetch(`${ORIGIN}/internal/broadcast`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notificationType: 1 }), }) expect(res.status).toBe(401) expect(await count()).toBe(before) }) })