mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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<Record<string, string>> {
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const claims: Record<string, unknown> = { 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<HubRecord> }> {
|
|
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<HubRecord> => {
|
|
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<string, string>) =>
|
|
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<string, unknown>
|
|
}
|
|
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<string, unknown> }
|
|
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()
|
|
})
|
|
})
|