Files
recflare/apps/auth/src/test/integration/api.test.ts
T
2026-08-25 11:42:29 -04:00

1536 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import {
GAME_VERSION,
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import {
banFromReport,
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
// The Orientation room (RoomId 13) new accounts are placed into on signup.
const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// Credential login requires the account's password; seed a known one for the
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
const META_APP_SECRET = 'test-meta-app-secret'
const META_APP_ID = '1232175103309633'
const META_USER_ID = '27061366730207360'
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
let metaSecretId: string
// Apply the accounts schema so create_account can persist (mirrors the migration),
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The Meta app secret, likewise — a Meta login is refused outright without one.
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
const hash = await hashPassword(LOGIN_PASSWORD)
for (const id of [42, 77]) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await seedRoomWithSubRooms(env.DB, {
RoomId: 13,
Name: 'Orientation',
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
* Ban an account the way a moderator would: file a report against it and convert that
* report into a ban. `banExpires` null is a permanent ban.
*/
async function banAccount(accountId: number, banExpires: string | null = null): Promise<void> {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId })
await banFromReport(env.DB, row.id, { banExpires })
}
/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */
async function seedAccount(accountId: number, username: string): Promise<void> {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) }))
.run()
}
/** Decode a JWT payload (no verification) for asserting claims. */
function decodePayload(token: string): Record<string, unknown> {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(
new TextDecoder().decode(Uint8Array.from(atob(part), (ch) => ch.charCodeAt(0)))
) as Record<string, unknown>
}
async function accessTokenFor(body: string, ip?: string): Promise<string> {
return (await postToken(body, ip)).json.access_token as string
}
async function tokenFor(body: string, ip?: string): Promise<Record<string, unknown>> {
return decodePayload(await accessTokenFor(body, ip))
}
/**
* POST a form-urlencoded body to /connect/token, returning status + parsed JSON.
* `ip` sets CF-Connecting-IP (what Cloudflare's edge sets in production); omit it and
* the request looks IP-less, which is how the other tests dodge the per-IP signup cap.
*/
async function postToken(
body: string,
ip?: string
): Promise<{ status: number; json: Record<string, unknown> }> {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(ip ? { 'CF-Connecting-IP': ip } : {}),
},
body,
})
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
}
/**
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
* exactly this reason. Returns the graph requests the worker made alongside the
* response, so a test can assert WHICH user id the nonce was validated against.
*/
async function metaLogin(
body: string,
isValid: boolean
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
const graphCalls: URLSearchParams[] = []
const realFetch = globalThis.fetch
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
if (url.startsWith('https://graph.oculus.com/')) {
graphCalls.push(new URLSearchParams(init?.body ?? ''))
return Response.json({ is_valid: isValid })
}
return realFetch(url, init)
}) as unknown as typeof fetch
try {
return { ...(await postToken(body)), graphCalls }
} finally {
globalThis.fetch = realFetch
}
}
/** GET a JSON route on the worker and parse the body as `T`. */
async function getJson<T>(path: string): Promise<T> {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
return (await res.json()) as T
}
/** The picker entries a platform identity yields, as the client sees them. */
function cachedLogins(platform: number, id: string) {
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
`/cachedlogin/forplatformid/${platform}/${id}`
)
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
describe('auth worker routes', () => {
test('GET /eac/challenge returns the EAC challenge as text/plain', async () => {
const res = await exports.default.fetch(`${ORIGIN}/eac/challenge`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/plain')
// EAC challenge content (BOM is stripped on read).
expect(await res.text()).toBe('"AA=="')
})
test.each([
['0 (Steam)', 0],
['1 (Meta)', 1],
])(
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
async (_label, platform) => {
const res = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
// The one stubbed identity: `1/1` consults nothing and always answers the canned
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
// the platform login screen and onto username/password.
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
platform: 1,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
},
])
})
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
// can't prove.
test.each([2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
`grant_type=create_account&platform=${platform}&platform_id=whoever`
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test.each([2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
`grant_type=cached_login&account_id=42&platform=${platform}&platform_id=whoever`
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test('Steam create_account requires a valid platform_auth ticket', async () => {
// platform=0 (Steam) with no verifiable ticket must not bind the spoofable
// platform_id field — it's rejected outright.
const res = await postToken(
'grant_type=create_account&platform=0&platform_id=76561197962463211'
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Steam cached_login requires a valid platform_auth ticket', async () => {
const res = await postToken(
'grant_type=cached_login&account_id=42&platform=0&platform_id=76561197962463211'
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account requires a platform_auth nonce', async () => {
// platform=1 with no nonce must not bind the spoofable platform_id field.
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account binds the id Meta validated the nonce against', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
true
)
expect(res.status).toBe(200)
// The nonce was validated against the posted user id, authenticated as the app.
expect(res.graphCalls).toHaveLength(1)
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
// The account is bound to platform 1 with that id — which is what makes the
// cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub)
const linked = await cachedLogins(1, META_USER_ID)
expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
)
// Platform ownership is the credential, so the client is not asked for a password.
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
})
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta cached_login logs into the linked account with no password', async () => {
const userId = '27061366730209999'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5150,
username: 'MetaPlayer',
platform: 1,
platformId: userId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5150, 1, userId)
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(200)
expect(res.graphCalls[0].get('user_id')).toBe(userId)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('5150')
})
test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
// account is actually linked to.
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(400)
expect(res.json.error_description).toContain('no linked account')
})
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
// An operator misconfiguration, not a bad credential: without the secret no nonce
// can be validated, and the alternative — trusting the posted platform_id — would
// let anyone log into any Meta-linked account by naming its user id.
const admin = adminSecretsStore(env.META_APP_SECRET)
await admin.update('', metaSecretId)
try {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(500)
expect(res.json.error).toBe('server_error')
// Nothing was asked of Meta, and nothing was trusted.
expect(res.graphCalls).toHaveLength(0)
} finally {
await admin.update(META_APP_SECRET, metaSecretId)
}
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
const steamId = '76561197962463299'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 31380,
username: 'SteamPlayer',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-07-09T21:20:31.419Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
platform: 0,
platformId: steamId,
accountId: 31380,
lastLoginTime: '2026-07-09T21:20:31.419Z',
requirePassword: false,
},
])
})
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
// same list — otherwise the newer client's login screen comes up empty.
test('POST /cachedlogin/forplatformid answers exactly what the GET answers', async () => {
const steamId = '76561197962463211'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 31381,
username: 'SteamPlayer2025',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-13T04:21:34.768Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 31381, 0, steamId)
// The body as the live client sends it: device id, the platform session ticket, a
// timestamp. All ignored for now.
const body = new URLSearchParams({
deviceId: '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88',
platformAuth: JSON.stringify({ Ticket: '140000009C5F501B447424FF', AppId: '471710' }),
time: '2026-08-13T04:21:34.7684754Z',
}).toString()
const posted = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
expect(posted.status).toBe(200)
const expected = [
{
platform: 0,
platformId: steamId,
accountId: 31381,
lastLoginTime: '2026-08-13T04:21:34.768Z',
requirePassword: false,
},
]
expect(await posted.json()).toEqual(expected)
expect(await cachedLogins(0, steamId)).toEqual(expected)
})
// A POST with no body at all still resolves — the client's body is never consulted.
test('POST /cachedlogin/forplatformid/1/1 still returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`, {
method: 'POST',
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject([{ accountId: 1, requirePassword: true }])
})
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
// The point of the link table. The same account is reachable from the PC and from
// the headset, and each picker reports the identity IT was asked about — that's
// what the client posts back on the cached_login grant.
const steamId = '76561197962463777'
const metaId = '27061366730207777'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6200,
username: 'CrossPlatform',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-01T10:00:00.000Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
const onSteam = await cachedLogins(0, steamId)
const onMeta = await cachedLogins(1, metaId)
expect(onSteam).toEqual([
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
])
expect(onMeta).toEqual([
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
])
// And the grant accepts both, without a password.
const viaMeta = await metaLogin(
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(viaMeta.status).toBe(200)
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
})
test('the picker and the cached_login grant read the same table', async () => {
// Regression: the picker used to derive links from the account blob (treating a
// missing `platform` as Steam) while the grant ran its own check, so the client
// could be handed an account_id that answered "no linked account" forever. Both
// now read platform_account, which is why an account with a stale blob identity
// is NOT offered — and, since it isn't offered, never rejected either.
const steamId = '76561197962463211'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
.run()
// No link row yet: not offered.
const before = await cachedLogins(0, steamId)
expect(before.map((a) => a.accountId)).not.toContain(8)
// The 0007 backfill is what gives accounts like this one — bound before the link
// table existed, and carrying no `platform` field at all — their link.
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
const after = await cachedLogins(0, steamId)
expect(after.map((a) => a.accountId)).toContain(8)
// COALESCEd to Steam, which is what an unset platform meant.
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `account_id=42&platform_id=steam-123&password=${LOGIN_PASSWORD}`,
})
expect(res.status).toBe(200)
const json = (await res.json()) as {
access_token: string
token_type: string
expires_in: number
}
expect(json.token_type).toBe('Bearer')
expect(json.expires_in).toBe(TOKEN_TTL_SECONDS)
// header.payload.signature
const parts = json.access_token.split('.')
expect(parts).toHaveLength(3)
// The client reads these claims to authorize itself; decode and assert them.
const payload = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')), (ch) =>
ch.charCodeAt(0)
)
)
) as Record<string, unknown>
expect(payload.sub).toBe('42') // account_id from the body is honored
expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient')
// screenshare is a feature gate, not a grant — every token carries it.
expect(payload.role).toContain('screenshare')
// A plain adult account carries nothing beyond those — no elevated roles.
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
expect(payload.scope).toContain('rn.api')
})
// `rn.ver` is the CLIENT's build, from the `ver` it posts here — presence in `match`
// reads it back off the token, so this is what a player is reported as running.
test('POST /connect/token stamps the posted ver into rn.ver', async () => {
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}&ver=20250718.01`)
expect(payload['rn.ver']).toBe('20250718.01')
})
// A grant that names no build — a refresh, or a caller that isn't the game — falls
// back to the server's GAME_VERSION rather than stamping an empty claim, which would
// leave presence carrying an empty version.
test('POST /connect/token falls back to GAME_VERSION with no ver', async () => {
expect((await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`))['rn.ver']).toBe(
GAME_VERSION
)
expect((await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}&ver=`))['rn.ver']).toBe(
GAME_VERSION
)
})
test('POST /connect/token stamps developer/moderator roles into the token', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 91,
username: 'StaffPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isDeveloper: true,
isModerator: true,
})
)
.run()
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 92,
username: 'JuniorPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isJunior: true,
})
)
.run()
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
expect(payload.role).not.toContain('developer')
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)
expect((await res.json()) as { error: string }).toMatchObject({ error: 'invalid_request' })
})
test('POST /connect/token 400s on a non-numeric account_id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'account_id=notanumber',
})
expect(res.status).toBe(400)
})
test('POST /connect/token rejects a credential login with the wrong password', async () => {
const res = await postToken('account_id=42&password=wrong-password')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token rejects a credential login with no password', async () => {
const res = await postToken('account_id=42')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token refuses login to an account with no password set', async () => {
// Account 999 exists but never set a password — it has no credential to verify,
// so login by id alone is refused (this is the closed takeover hole).
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 999, username: 'NoPass' }))
.run()
const res = await postToken('account_id=999&password=anything')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token create_account can set a password used for later login', async () => {
const created = await postToken(
'grant_type=create_account&platform_id=steam-pw2&password=hunter2'
)
expect(created.status).toBe(200)
const sub = decodePayload(created.json.access_token as string).sub as string
// The password set at creation authenticates a subsequent credential login.
const ok = await postToken(`account_id=${sub}&password=hunter2`)
expect(ok.status).toBe(200)
// A wrong password for that same account is rejected.
const bad = await postToken(`account_id=${sub}&password=nope`)
expect(bad.status).toBe(400)
})
test('POST /connect/token logs in by username (RecRoom password grant)', async () => {
// The RecRoom client posts the username, not the account_id — case-insensitively
// and with a trailing space, both of which must still resolve account 42.
const res = await postToken(
`grant_type=password&username=player42%20&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('42')
})
test('POST /connect/token rejects a username login with the wrong password', async () => {
const res = await postToken(`grant_type=password&username=Player42&password=wrong`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token 400s on an unknown username', async () => {
const res = await postToken(`grant_type=password&username=NoSuchUser&password=whatever`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_request')
})
test('POST /connect/token grant_type=create_account persists a new account', async () => {
const payload = await tokenFor('grant_type=create_account&platform_id=steam-123')
// The token's sub is the new account id, allocated above the system accounts.
const sub = Number.parseInt(payload.sub as string, 10)
expect(sub).toBeGreaterThanOrEqual(2)
// The account exists in the DB with an auto-assigned (non-default) username.
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(sub)
.first<{ data: string }>()
expect(row).not.toBeNull()
const account = JSON.parse(row!.data) as { username: string }
expect(account.username).not.toMatch(/^Player\d+$/)
})
test('POST /connect/token create_account stores the login device on the account', async () => {
const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88'
const payload = await tokenFor(
`grant_type=create_account&platform_id=steam-dev1&device_id=${deviceId}&device_class=2`
)
const sub = Number.parseInt(payload.sub as string, 10)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(sub)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
expect(account.deviceId).toBe(deviceId)
expect(account.deviceClass).toBe(2)
// Accounts sharing a device can be found later (account linkup).
const shared = await getAccountsByDeviceId(env.DB, deviceId)
expect(shared.map((a) => a.accountId)).toContain(sub)
})
test('POST /connect/token stores deviceClass as an integer, not a REAL', async () => {
// D1 binds a JS number as a SQLite REAL, so a naive json_set writes `"deviceClass":2.0`
// into the blob. JSON.parse tolerates that, but the raw JSON is what other readers
// (and any strict int parser) see, so assert on the stored TEXT, not the parsed value.
await postToken(
`grant_type=password&username=Player77&password=${LOGIN_PASSWORD}&device_id=dev-int&device_class=2`
)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(77)
.first<{ data: string }>()
expect(row!.data).toContain('"deviceClass":2')
expect(row!.data).not.toContain('2.0')
})
test('POST /connect/token refreshes the stored device on a credential login', async () => {
// Account 42 was seeded with no device; a later login records the one it came from.
const res = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=dev-42-new&device_class=3`
)
expect(res.status).toBe(200)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(42)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
expect(account.deviceId).toBe('dev-42-new')
expect(account.deviceClass).toBe(3)
})
test('POST /connect/token create_account records the client IP', async () => {
const payload = await tokenFor('grant_type=create_account&platform_id=steam-ip1', '203.0.113.7')
const sub = Number.parseInt(payload.sub as string, 10)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(sub)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { signupIp: string; lastLoginIp: string }
expect(account.signupIp).toBe('203.0.113.7')
expect(account.lastLoginIp).toBe('203.0.113.7')
})
test('POST /connect/token caps the accounts created from one IP', async () => {
const ip = '198.51.100.22'
for (let i = 0; i < 3; i++) {
const ok = await postToken(`grant_type=create_account&platform_id=steam-cap${i}`, ip)
expect(ok.status).toBe(200)
}
// The 4th signup from that IP is refused — the cap is 3.
const capped = await postToken('grant_type=create_account&platform_id=steam-cap3', ip)
expect(capped.status).toBe(400)
expect(capped.json.error).toBe('invalid_grant')
expect(capped.json.error_description).toMatch(/network/)
// A different IP is unaffected, and the capped IP can still LOG IN to what it has.
const other = await postToken(
'grant_type=create_account&platform_id=steam-cap4',
'198.51.100.23'
)
expect(other.status).toBe(200)
})
test('the signup caps come from vars, and 0 disables an arm', async () => {
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
// override is restored in `finally` rather than leaking a cap of 0 into the tests above.
const original = env.MAX_ACCOUNTS_PER_IP
try {
env.MAX_ACCOUNTS_PER_IP = 1
const ip = '198.51.100.30'
const first = await postToken('grant_type=create_account&platform_id=steam-var0', ip)
expect(first.status).toBe(200)
const capped = await postToken('grant_type=create_account&platform_id=steam-var1', ip)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/network/)
// 0 disables the arm entirely: the IP that was just capped can sign up again.
env.MAX_ACCOUNTS_PER_IP = 0
const uncapped = await postToken('grant_type=create_account&platform_id=steam-var2', ip)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
// Account 42's owner may be over the signup cap; that must never lock them out of
// an account they already have.
const res = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
'198.51.100.22'
)
expect(res.status).toBe(200)
})
test('POST /connect/token create_account seeds the new player into Orientation', async () => {
const payload = await tokenFor('grant_type=create_account&platform_id=steam-456')
const sub = payload.sub as string
// Presence is written to the shared `presence` D1 table (account_id keyed).
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(Number(sub))
.first<{ data: string }>()
expect(row).not.toBeNull()
const presence = JSON.parse(row!.data) as {
roomInstance: { roomInstanceId: number; roomId: number; location: string; name: string }
}
expect(presence.roomInstance).toMatchObject({
roomInstanceId: -2,
roomId: 13,
location: ORIENTATION_SCENE,
name: '^Orientation',
})
})
test('POST /connect/token carries the platform int on the token', async () => {
const payload = await tokenFor(`account_id=42&platform=5&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe(5)
// `rn.plat` is the same int, not a pinned 0.
expect(payload['rn.plat']).toBe(5)
})
test('POST /connect/token defaults the platform claim when none is posted', async () => {
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe(0)
expect(payload['rn.plat']).toBe(0)
})
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
const login = await postToken(
`account_id=42&platform=0&platform_id=steam-123&password=${LOGIN_PASSWORD}`
)
expect(login.status).toBe(200)
const refreshToken = login.json.refresh_token as string
expect(typeof refreshToken).toBe('string')
expect(refreshToken.length).toBeGreaterThan(0)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(200)
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('42')
// The platform identity comes off the account, not the refresh token. Account 42
// has none bound (the posted `platform_id` above was never Steam-verified, so it
// was never written), so the refreshed token carries no identity either.
expect(payload.platform).toBe(0)
expect(payload.platform_id).toBe('')
// The refresh token is rotated (single-use), so a new one is returned.
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
})
test('a refreshed token carries the identity bound to the account', async () => {
// A Steam-bound account: only a verified ticket writes `platformId`, so seed it
// directly rather than posting an (unverified) platform_id on the login.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 43,
username: 'Player43',
passwordHash: await hashPassword(LOGIN_PASSWORD),
platform: 0,
platformId: 'steam-123',
})
)
.run()
const login = await postToken(`account_id=43&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('43')
expect(payload.platform).toBe(0)
expect(payload.platform_id).toBe('steam-123')
})
// A password login is how a player who already has an account signs in on a NEW
// device. The client posts its platform proof alongside the password, and linking
// the two is what turns the next launch on that device into a cached login.
describe('password grant links the platform identity it proves', () => {
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
async function seedPasswordAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
// Exactly the client's flow: an account made elsewhere, signed into on a headset
// with username + password, with the Meta nonce riding along.
await seedPasswordAccount(7100, 'djdevin')
const metaId = '27061366730201234'
const login = await metaLogin(
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
// The nonce was validated against the id being linked — an unproven id is never
// linked, since a link is a password-free way into the account.
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
// The headset now gets a cached login: offered by the picker…
const offered = await cachedLogins(1, metaId)
expect(offered.map((a) => a.accountId)).toContain(7100)
// …and accepted by the grant, with no password.
const cached = await metaLogin(
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(cached.status).toBe(200)
})
test('the first identity linked becomes the account primary; later ones just link', async () => {
await seedPasswordAccount(7101, 'multiplatform')
const metaId = '27061366730205678'
await metaLogin(
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The blob's primary identity was empty, so the first link fills it in — this is
// what the account DTO and the refresh grant's claims report.
const account = (await env.DB.prepare(
'SELECT data FROM account WHERE account_id = 7101'
).first<{ data: string }>())!
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
// A second identity on another platform links without disturbing the primary.
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
const links = await getLinksForAccount(env.DB, 7101)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[1, metaId],
[0, '76561197962465678'],
])
})
test('an unverified platform_auth logs in but links nothing', async () => {
// The password already proved who this is, so the login stands — but a link is a
// password-free way in, and this identity was never proven, so none is written.
await seedPasswordAccount(7102, 'unproven')
const metaId = '27061366730209876'
const login = await metaLogin(
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false // Meta rejects the nonce
)
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
})
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
await seedPasswordAccount(7103, 'noproof')
const login = await metaLogin(
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=27061366730204321`,
true
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
})
test('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
// The sideload placeholder identifies nobody — every sideloaded headset reports
// `1`, so a link on it would be a password-free way into this account from any of
// them. The password login still stands; Meta is never even asked, since there is
// nothing there to validate.
await seedPasswordAccount(7105, 'sideloader')
const login = await metaLogin(
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=1` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true // even with Meta answering yes to everything
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
// And so the picker never offers this account off the placeholder — only the
// canned stub entry is there.
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
})
test('linking obeys the per-identity account cap, without failing the login', async () => {
// Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them.
const metaId = '27061366730203333'
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
await seedPasswordAccount(8100, 'overcap')
const login = await metaLogin(
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The password was valid, so the player is logged in — they just don't get a
// cached login on this account.
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
})
test('re-logging in on the same device does not duplicate the link', async () => {
await seedPasswordAccount(7104, 'repeatlogin')
const metaId = '27061366730207654'
const body =
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
await metaLogin(body, true)
await metaLogin(body, true)
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
})
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string
const first = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(first.status).toBe(200)
// Redeeming the same token again fails — it was consumed (rotated) above.
const reuse = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(reuse.status).toBe(400)
expect(reuse.json.error).toBe('invalid_grant')
})
/**
* A D1 that throws the storage-side `internal error` on its first `n` reads and then
* behaves. Only what `consumeRefreshToken` touches is wrapped — prepare/bind/first.
*/
const flakyDb = (failures: number): D1Database => {
let remaining = failures
const wrap = (stmt: D1PreparedStatement): D1PreparedStatement =>
({
bind: (...values: unknown[]) => wrap(stmt.bind(...values)),
first: async <T>() => {
if (remaining > 0) {
remaining--
throw new Error('D1_ERROR: internal error; reference = testref0000')
}
return stmt.first<T>()
},
}) as unknown as D1PreparedStatement
return { prepare: (sql: string) => wrap(env.DB.prepare(sql)) } as unknown as D1Database
}
test('consumeRefreshToken rides out a transient D1 error rather than logging the player out', async () => {
const token = await issueRefreshToken(env.DB, 77)
// Two hiccups, then the real thing — the redemption still succeeds, so the player
// keeps their session instead of being bounced to the login screen by a 500.
expect(await consumeRefreshToken(flakyDb(2), token)).toBe(77)
// And it was genuinely consumed: the retry didn't leave the row behind.
expect(await consumeRefreshToken(env.DB, token)).toBeNull()
})
test('consumeRefreshToken rethrows once the retries are spent, never a silent null', async () => {
const token = await issueRefreshToken(env.DB, 77)
// A real D1 outage has to surface as a 500. Answering null would tell the player
// their token was bad and make them log in again over something that isn't theirs.
await expect(consumeRefreshToken(flakyDb(99), token)).rejects.toThrow('internal error')
// The token survived, so a later attempt still works.
expect(await consumeRefreshToken(env.DB, token)).toBe(77)
})
test('POST /connect/token 400s on an unknown refresh_token', async () => {
const res = await postToken('grant_type=refresh_token&refresh_token=NOPE-1')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /cachedlogin/forplatformids returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'id=76561197971551621&id=76561197976728738',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /account/me/changepassword 401s without a token', async () => {
const res = await changePassword('oldPassword=&newPassword=secret123')
expect(res.status).toBe(401)
})
test('POST /account/me/changepassword 400s without a new password', async () => {
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-pw0')
const res = await changePassword('oldPassword=&newPassword=', token)
expect(res.status).toBe(400)
})
test('POST /account/me/changepassword sets then rotates the password', async () => {
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-pw1')
// First set — oldPassword is empty (as the client sends it).
const set = await changePassword('oldPassword=&newPassword=first-password', token)
expect(set.status).toBe(200)
expect(await set.json()).toEqual({ success: true })
// A wrong old password is now rejected.
const wrong = await changePassword('oldPassword=nope&newPassword=second-password', token)
expect(wrong.status).toBe(400)
// The correct old password rotates it.
const rotate = await changePassword(
'oldPassword=first-password&newPassword=second-password',
token
)
expect(rotate.status).toBe(200)
expect(await rotate.json()).toEqual({ success: true })
})
test('GET /privileges/me/restrictions is an empty array for an unrestricted caller', async () => {
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-restrict1')
const res = await exports.default.fetch(`${ORIGIN}/privileges/me/restrictions`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
// [] is the normal unrestricted answer — not null, not a 404: the client refills its
// list from this and acts on a record being present.
expect(await res.text()).toBe('[]')
})
test('GET /privileges/me/restrictions 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/privileges/me/restrictions`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(false)
})
test('GET /role/developer/:id returns a bare true when the account is flagged', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
const res = await exports.default.fetch(`${ORIGIN}/role/developer/4242`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
})
test('GET /role/developer/:id 404s for an unknown player', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/99999`)
expect(res.status).toBe(404)
})
test('GET /role/moderator/:id reflects the isModerator flag as a bare boolean', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
.run()
const granted = await exports.default.fetch(`${ORIGIN}/role/moderator/4343`)
expect(await granted.json()).toBe(true)
// An account without the flag (42) is not a moderator.
const plain = await exports.default.fetch(`${ORIGIN}/role/moderator/42`)
expect(await plain.json()).toBe(false)
// Unknown player → 404.
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /cachedlogin/forplatformid/{platform}/{id}',
'GET /eac/challenge',
'GET /oculus/nonce',
'GET /privileges/me/restrictions',
'GET /role/developer/{id}',
'GET /role/moderator/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids',
'POST /connect/token',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
// The website is a browser origin calling these endpoints directly — the same ones the
// game calls — instead of proxying them through `www`. That only works if the responses
// carry CORS headers: without them the browser discards a perfectly good token response
// and sign-in fails with nothing in any server log to explain it.
describe('CORS', () => {
test('answers the preflight the browser sends before a token grant', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain('content-type')
})
// The header has to be on the REAL response too, not just the preflight — and on a
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
// network error rather than "that password is incorrect".
test('allows the origin on the response itself, refusals included', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: {
origin: 'https://www.example.com',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
}),
env
)
expect(res.status).toBe(400)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
// The bearer header is what the SPA authenticates with, so it must be allowed by name
// — a preflight that omits it makes every signed-in call fail.
test('allows the Authorization header the SPA signs its calls with', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/account/me/changepassword`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
})
})
// A banned account is refused a token at all — the outer wall of a ban, since with no
// token every other worker is shut to it. The ban is a `report` row with `banned` set
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
// before it was handed down.
describe('banned accounts', () => {
test('POST /connect/token refuses a password grant from a banned account', async () => {
await seedAccount(6101, 'BannedPlayer')
await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
// The exact sentence www's shared auth-messages table keys on to put a real
// message in front of the player — changing it silently downgrades that to the
// generic "you could not be signed in".
expect(res.json.error_description).toBe('this account is banned')
})
test('POST /connect/token refuses a username login from a banned account', async () => {
await seedAccount(6102, 'BannedByName')
await banAccount(6102)
const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A client that was already signed in when the ban landed still holds a valid refresh
// token; redeeming it must not renew the session.
test('POST /connect/token refuses to refresh a banned accounts session', async () => {
await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
const refreshToken = login.json.refresh_token as string
await banAccount(6103)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(400)
expect(refreshed.json.error_description).toBe('this account is banned')
})
// The ban check runs AFTER the credential check, so a wrong password on a banned
// account still answers the ordinary bad-credential refusal — it can't be used to
// find out whether an account exists or is banned without knowing its password.
test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104)
const res = await postToken('account_id=6104&password=not-the-password')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid account_id or password')
})
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
test('an expired ban lets the account sign in again', async () => {
await seedAccount(6105, 'ServedTime')
await banAccount(6105, '2020-01-01T00:00:00.000Z')
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
})
test('a ban that has not expired yet still refuses the login', async () => {
await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A report is not a ban until a moderator converts it.
test('an unbanned report does not refuse the login', async () => {
await seedAccount(6107, 'MerelyReported')
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6107 })
const res = await postToken(`account_id=6107&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
})
// The ban is the ACCOUNT's: nothing here stops the player signing up again, which is
// the signup caps' job, not this check's.
test('a banned player can still create a new account', async () => {
await seedAccount(6108, 'BannedButNew')
await banAccount(6108)
const created = await postToken('grant_type=create_account&platform_id=steam-after-ban')
expect(created.status).toBe(200)
})
})
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username: name,
passwordHash: await hashPassword(LOGIN_PASSWORD),
...ips,
})
)
.run()
}
const login = (id: number, ip?: string) =>
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
test('an account sharing a banned accounts platform identity cannot log in', async () => {
await account(6301, 'EvaderOne')
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
await banAccount(6301)
await account(6302, 'EvaderTwo')
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
const res = await login(6302)
expect(res.status).toBe(400)
// A vaguer sentence than a direct ban: this account may belong to somebody else.
expect(res.json.error_description).toBe('this device or network is blocked')
})
test('an account sharing a banned accounts IP cannot log in', async () => {
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
await banAccount(6303)
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
const res = await login(6304)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this device or network is blocked')
})
// The address the request arrives from counts, so an account that never logged in
// from the banned network before is caught on the first attempt rather than the second.
test('the requests own IP is matched even when the account has none stored', async () => {
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
await banAccount(6305)
await account(6306, 'CleanElsewhere')
expect((await login(6306, '203.0.113.31')).status).toBe(400)
// The same account from any other network signs in normally.
expect((await login(6306, '198.51.100.31')).status).toBe(200)
})
test('an unrelated account signs in normally', async () => {
await account(6307, 'Unrelated', { signupIp: '198.51.100.7' })
await banAccount(6307 + 1000) // a ban on somebody else entirely
expect((await login(6307)).status).toBe(200)
})
// The point of checking before minting: a refused signup must leave nothing behind,
// or the evader keeps the account (and burns a slot off the signup caps) anyway.
test('create_account from a banned IP is refused and creates no account', async () => {
await account(6310, 'BannedSignupSource', { signupIp: '203.0.113.40' })
await banAccount(6310)
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>()
const res = await postToken('grant_type=create_account', '203.0.113.40')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this device or network is blocked')
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>()
expect(after?.n).toBe(before?.n)
})
test('create_account from an unrelated IP still works', async () => {
const res = await postToken('grant_type=create_account', '198.51.100.99')
expect(res.status).toBe(200)
})
// The knob an operator reaches for when the IP arm locks out real players.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
await banAccount(6320)
await account(6321, 'KnobHousemate', { signupIp: '203.0.113.50' })
await account(6322, 'KnobEvader')
await linkPlatformIdentity(env.DB, 6322, 0, 'steam-knobevader')
try {
env.BAN_EVASION_MATCH = 'platform'
expect((await login(6321)).status).toBe(200)
expect((await login(6322)).status).toBe(400)
// And signup from that network is open again.
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself is refused whatever the knob says.
const banned = await login(6320)
expect(banned.status).toBe(400)
expect(banned.json.error_description).toBe('this account is banned')
} finally {
env.BAN_EVASION_MATCH = original
}
})
})