import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' import '../../notify.app' import type { Env } from '../../context' 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[]): Promise> { const now = Math.floor(Date.now() / 1000) const claims: Record = { sub, exp: now + 3600 } 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) }) 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 ): Promise<{ ws: WebSocket; waitFor: (pred: (r: HubRecord) => boolean) => Promise }> { const res = await exports.default.fetch(`${ORIGIN}/hub/v1?id=${id}`, { headers: { Upgrade: 'websocket' }, }) 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 } } 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() }) 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() }) 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-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() }) })