mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
apps/notify some notify fixes
This commit is contained in:
@@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import '../../notify.app'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
import type { HubState } from '../../notifications-hub'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
@@ -22,9 +23,13 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
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>> {
|
||||
async function bearer(
|
||||
sub: string,
|
||||
roles?: string[],
|
||||
expiresIn = 3600
|
||||
): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
||||
const claims: Record<string, unknown> = { sub, exp: now + expiresIn }
|
||||
if (roles) claims.role = roles
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify(claims)
|
||||
@@ -45,6 +50,9 @@ beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
|
||||
})
|
||||
|
||||
/** 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
|
||||
@@ -55,10 +63,14 @@ interface HubRecord {
|
||||
|
||||
/** Open a hub WebSocket, accept it, and complete the SignalR handshake. */
|
||||
async function connect(
|
||||
id: string
|
||||
id: string,
|
||||
opts: { headers?: Record<string, string>; query?: 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' },
|
||||
// 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!
|
||||
@@ -185,6 +197,48 @@ describe('hub protocol', () => {
|
||||
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 })
|
||||
@@ -274,7 +328,9 @@ describe('notification delivery', () => {
|
||||
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> }
|
||||
const payloadB = JSON.parse((noteB.arguments as string[])[0]) as {
|
||||
Msg: Record<string, unknown>
|
||||
}
|
||||
expect(payloadB.Msg).toMatchObject({ Data: 'hello all' })
|
||||
|
||||
a.ws.close()
|
||||
@@ -309,3 +365,219 @@ describe('notification delivery', () => {
|
||||
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<string, string>) =>
|
||||
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<string, string>) =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user