mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 23:51:28 -07:00
1536 lines
63 KiB
TypeScript
1536 lines
63 KiB
TypeScript
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 account’s 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 account’s 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 account’s 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 request’s 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
|
||
}
|
||
})
|
||
})
|