mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
3126 lines
128 KiB
TypeScript
3126 lines
128 KiB
TypeScript
import {
|
||
adminSecretsStore,
|
||
createExecutionContext,
|
||
createScheduledController,
|
||
env,
|
||
SELF,
|
||
waitOnExecutionContext,
|
||
} from 'cloudflare:test'
|
||
import { beforeAll, expect, it } from 'vitest'
|
||
|
||
import {
|
||
SCHEMA_DDL as ACCOUNT_SCHEMA_DDL,
|
||
getAccount,
|
||
updateAccount,
|
||
} from '@repo/domain/src/accounts-db'
|
||
import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
|
||
import { PlatformType } from '@repo/domain/src/enums'
|
||
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
|
||
import {
|
||
PRESENCE_SCHEMA_DDL,
|
||
PRESENCE_TTL_SECONDS,
|
||
setPresence,
|
||
} from '@repo/domain/src/presence-db'
|
||
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
|
||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||
// Banning a player moves them into their own dorm, which is a room (created on demand) with
|
||
// a subroom — so those tables have to be here, as they are on the shared database.
|
||
import { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
|
||
import { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
|
||
import { generateToken } from '@repo/jwt'
|
||
|
||
import {
|
||
createCustomAvatarItem,
|
||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||
} from '../../../../api/src/custom-avatar-items-db'
|
||
import {
|
||
createReport,
|
||
getReportById,
|
||
SCHEMA_DDL as REPORT_SCHEMA_DDL,
|
||
} from '../../../../api/src/reports-db'
|
||
import { createWarning, SCHEMA_DDL as WARNING_SCHEMA_DDL } from '../../../../api/src/warnings-db'
|
||
import {
|
||
CACHED_LOGIN_PLATFORMS,
|
||
countAccountsForPlatformIdentity,
|
||
getLinksForAccount,
|
||
getLinksForPlatform,
|
||
isPlatformIdentityLinked,
|
||
linkPlatformIdentity,
|
||
PLATFORM_SCHEMA_DDL,
|
||
} from '../../../../auth/src/platform-db'
|
||
import {
|
||
BALANCE_SCHEMA_DDL,
|
||
CurrencyType,
|
||
DEFAULT_STARTING_TOKENS,
|
||
ensureStartingBalances,
|
||
getBalance,
|
||
spendCurrency,
|
||
} from '../../../../econ/src/balance-db'
|
||
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
|
||
import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-db'
|
||
import {
|
||
DISCORD_ROLE_GIFT_MESSAGE,
|
||
grantDiscordRoleGift,
|
||
} from '../../../../econ/src/discord-role-gift'
|
||
import { EQUIPMENT_SCHEMA_DDL, getEquipment } from '../../../../econ/src/equipment-db'
|
||
import {
|
||
grantCustomAvatarItem,
|
||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||
ownedCustomAvatarItemIds,
|
||
} from '../../../../econ/src/inventory-custom-db'
|
||
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
|
||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||
import { turnstileKeys } from '../../turnstile'
|
||
import { postAuthForm, readAuthError } from '../../upstream'
|
||
import { scheduled } from '../../www.app'
|
||
|
||
import type { Env } from '../../context'
|
||
import type { MemberRead } from '../../discord-roles'
|
||
|
||
declare module 'cloudflare:test' {
|
||
interface ProvidedEnv extends Env {}
|
||
}
|
||
|
||
// Turnstile's documented always-passes test keypair, seeded into the LOCAL Secrets Store
|
||
// so the bindings resolve — the same way every other worker's tests seed JWT_SECRET. It
|
||
// stands in for the two account-level secrets a deployed www reads, and it's what OPENS
|
||
// signup (see src/turnstile.ts): without it every signup test would test the closed door.
|
||
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
||
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
||
|
||
// The shared HS256 key. www verifies tokens itself for exactly one route (the benefits
|
||
// claim), so the tests have to be able to MINT one — hence a known value here rather than
|
||
// whatever a deployed store holds.
|
||
const TEST_JWT_SECRET = 'test-jwt-secret'
|
||
|
||
/** A bearer token for `accountId`, signed the way `auth` signs one. */
|
||
const tokenFor = (accountId: number, roles: string[] = []): Promise<string> =>
|
||
generateToken(String(accountId), '', 4, TEST_JWT_SECRET, roles)
|
||
|
||
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
|
||
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
|
||
// dangerous half — an operator who registers an app and stops has something that can sign
|
||
// a player in and no question left to ask about them — so it is the state the route-level
|
||
// tests pin: the claim must still be CLOSED. The fully-configured path is covered by
|
||
// unit-testing `discordConfig`, since exercising it end to end would call discord.com.
|
||
const TEST_DISCORD_CLIENT_ID = 'test-discord-client-id'
|
||
const TEST_DISCORD_CLIENT_SECRET = 'test-discord-client-secret'
|
||
|
||
beforeAll(async () => {
|
||
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
||
await adminSecretsStore(env.JWT_SECRET).create(TEST_JWT_SECRET)
|
||
await adminSecretsStore(env.DISCORD_CLIENT_ID).create(TEST_DISCORD_CLIENT_ID)
|
||
await adminSecretsStore(env.DISCORD_CLIENT_SECRET).create(TEST_DISCORD_CLIENT_SECRET)
|
||
// `presence` is owned (and migrated) by other workers — www only reads it — so the
|
||
// table has to be created here for the head-count behind /server-status.
|
||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
|
||
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// And `platform_account`, where a claimed Discord identity is linked.
|
||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// `report` and `warning` are owned (and migrated) by `api`; www serves the staff panel
|
||
// over them, so the tables have to exist here too. `room_instance` comes with them
|
||
// because a ban ejects the banned player from the instance they're standing in.
|
||
for (const stmt of REPORT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of WARNING_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// `stat` is written by the `match` presence cron; www only reads it, for `/stats`.
|
||
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// `audit_log` is owned by `api`; www files a row there for every ban and lift.
|
||
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// `balance` and `received_gift` are owned by `econ`; a staff token gift writes both.
|
||
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// A custom item gift reads `custom_avatar_item` (owned by `api`) and writes
|
||
// `inventory_custom` (owned by `econ`).
|
||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// A skin or consumable gift resolves its item in `catalog` and writes `equipment` or
|
||
// `consumable` — all three `econ`'s.
|
||
for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
// And an XP gift writes `progression`.
|
||
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||
})
|
||
|
||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
|
||
//
|
||
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify/rooms
|
||
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
|
||
// A build with them missing can't sign anyone in.
|
||
it('advertises signup and where the other workers live', async () => {
|
||
const res = await SELF.fetch('https://example.com/api/config')
|
||
expect(res.status).toBe(200)
|
||
// Read through the Secrets Store binding, from the value seeded above.
|
||
expect(await res.json()).toEqual({
|
||
signupEnabled: true,
|
||
turnstileSiteKey: TEST_SITE_KEY,
|
||
// Closed, because the Discord app here has no guild/role to check against — and with
|
||
// it closed the SPA is given no authorize URL to send anyone to, so the claim can't
|
||
// even be started. Note the client id is NOT leaked by a closed config.
|
||
benefitsEnabled: false,
|
||
discordAuthorizeUrl: null,
|
||
hosts: {
|
||
auth: 'https://auth.rec.example.com',
|
||
accounts: 'https://accounts.rec.example.com',
|
||
api: 'https://api.rec.example.com',
|
||
img: 'https://img.rec.example.com',
|
||
notify: 'https://notify.rec.example.com',
|
||
rooms: 'https://rooms.rec.example.com',
|
||
cdn: 'https://cdn.rec.example.com',
|
||
storage: 'https://storage.rec.example.com',
|
||
},
|
||
})
|
||
})
|
||
|
||
// The BFF proxies are gone: the browser calls those workers itself. Pinned because
|
||
// nothing else would fail if one were left behind — a stale proxy keeps working, it just
|
||
// re-creates the maintenance burden (and the shared-IP bug) this removed. `/api/signup`
|
||
// is the deliberate exception, and it's covered below.
|
||
it('no longer proxies the endpoints the game already serves', async () => {
|
||
for (const path of [
|
||
'/api/me',
|
||
'/api/login',
|
||
'/api/logout',
|
||
'/api/username',
|
||
'/api/email',
|
||
'/api/password',
|
||
'/api/maintenance',
|
||
'/api/coach-message',
|
||
'/api/slideshow',
|
||
]) {
|
||
const res = await SELF.fetch(`https://example.com${path}`, { method: 'POST' })
|
||
// Falls through to the SPA catch-all, which has no ASSETS binding under test.
|
||
expect(res.status, path).toBe(404)
|
||
}
|
||
})
|
||
|
||
// The keypair is the on/off switch for signup, so a www whose keys don't resolve must
|
||
// report it closed — that's the state a fresh deploy starts in, before the operator
|
||
// creates the two secrets. Checked directly because the real bindings are seeded for the
|
||
// fetch tests above.
|
||
//
|
||
// A store read that THROWS (secret absent, store unreachable) has to close the door the
|
||
// same way rather than surface as an error: /api/config is on the homepage's critical
|
||
// path, and a 500 there costs the whole page, not just the signup form.
|
||
it('treats an unresolvable or half-configured keypair as signup being off', async () => {
|
||
const stub = (value: string | null): SecretsStoreSecret =>
|
||
({ get: async () => value ?? '' }) as SecretsStoreSecret
|
||
const throws = (): SecretsStoreSecret =>
|
||
({
|
||
get: async () => {
|
||
throw new Error('secret not found')
|
||
},
|
||
}) as unknown as SecretsStoreSecret
|
||
|
||
const withKeys = (site: SecretsStoreSecret, secret: SecretsStoreSecret) =>
|
||
({
|
||
ENVIRONMENT: 'development',
|
||
TURNSTILE_SITE_KEY: site,
|
||
TURNSTILE_SECRET_KEY: secret,
|
||
}) as Env
|
||
|
||
await expect(turnstileKeys(withKeys(throws(), throws()))).resolves.toBeNull()
|
||
await expect(turnstileKeys(withKeys(stub('0xsite'), throws()))).resolves.toBeNull()
|
||
await expect(turnstileKeys(withKeys(throws(), stub('0xsecret')))).resolves.toBeNull()
|
||
await expect(turnstileKeys(withKeys(stub(''), stub('0xsecret')))).resolves.toBeNull()
|
||
await expect(turnstileKeys(withKeys(stub('0xsite'), stub('0xsecret')))).resolves.toEqual({
|
||
siteKey: '0xsite',
|
||
secretKey: '0xsecret',
|
||
})
|
||
})
|
||
|
||
// The operator's switch, off by default. `auth` is what enforces it (see its tests); www
|
||
// reads the same knob so the SPA hides the form and the endpoint answers in the sentence
|
||
// the form would show, rather than every visitor learning it from auth's refusal.
|
||
it('PASSWORD_SIGNUP=off reports signup closed and refuses the endpoint', async () => {
|
||
const original = env.PASSWORD_SIGNUP
|
||
try {
|
||
env.PASSWORD_SIGNUP = 'off'
|
||
const config = (await (await SELF.fetch('https://example.com/api/config')).json()) as {
|
||
signupEnabled: boolean
|
||
turnstileSiteKey: string | null
|
||
}
|
||
// Closed, and the site key withheld with it: no widget to mount for a form that
|
||
// isn't offered.
|
||
expect(config.signupEnabled).toBe(false)
|
||
expect(config.turnstileSiteKey).toBeNull()
|
||
|
||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ password: 'hunter2', turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX' }),
|
||
})
|
||
expect(res.status).toBe(403)
|
||
expect(await res.json()).toEqual({
|
||
error: 'Account creation from the website is disabled. Launch the game to create an account.',
|
||
})
|
||
|
||
// Unset is off as well.
|
||
delete env.PASSWORD_SIGNUP
|
||
const unset = await SELF.fetch('https://example.com/api/signup', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ password: 'hunter2', turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX' }),
|
||
})
|
||
expect(unset.status).toBe(403)
|
||
} finally {
|
||
env.PASSWORD_SIGNUP = original
|
||
}
|
||
})
|
||
|
||
// auth's own refusal of the grant, should a request reach it anyway, reads the same.
|
||
it('translates auth’s password-signup refusal into the same sentence', async () => {
|
||
const failure = await readAuthError(
|
||
new Response(
|
||
JSON.stringify({ error: 'invalid_grant', error_description: 'password signup is disabled' }),
|
||
{ status: 400, headers: { 'content-type': 'application/json' } }
|
||
),
|
||
'signup'
|
||
)
|
||
expect(failure.message).toBe(
|
||
'Account creation from the website is disabled. Launch the game to create an account.'
|
||
)
|
||
expect(failure.status).toBe(400)
|
||
})
|
||
|
||
it('refuses a signup with no Turnstile token', async () => {
|
||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ password: 'whatever' }),
|
||
})
|
||
// Rejected before any upstream call, so a bot can't reach create_account by omitting it.
|
||
expect(res.status).toBe(400)
|
||
expect(await res.json()).toEqual({ error: 'Please complete the bot check.' })
|
||
})
|
||
|
||
it('refuses a signup with no password', async () => {
|
||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ turnstileToken: 'dummy' }),
|
||
})
|
||
expect(res.status).toBe(400)
|
||
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
||
})
|
||
|
||
// A refused grant reaches the form as a sentence, never as the OAuth code. auth answers
|
||
// `{ error: 'invalid_grant', error_description: <the actual reason> }`, and www used to
|
||
// relay that untouched — so every failed signup, including one the player could act on
|
||
// (the per-network cap), read simply "invalid_grant". Checked directly because the pass
|
||
// path can't be reached from here (it would call the real auth worker).
|
||
it('explains a refused signup instead of relaying invalid_grant', async () => {
|
||
const refused = (description: string, status = 400) =>
|
||
new Response(JSON.stringify({ error: 'invalid_grant', error_description: description }), {
|
||
status,
|
||
headers: { 'content-type': 'application/json' },
|
||
})
|
||
|
||
const capped = await readAuthError(
|
||
refused('too many accounts created from this network'),
|
||
'signup'
|
||
)
|
||
expect(capped.status).toBe(400)
|
||
expect(capped.message).toContain('Too many accounts have already been created from your network')
|
||
// The raw pair still reaches the operator's log line.
|
||
expect(capped.upstream).toBe('invalid_grant: too many accounts created from this network')
|
||
|
||
const badPassword = await readAuthError(refused('invalid account_id or password'), 'login')
|
||
expect(badPassword.message).toBe('That username or password is incorrect.')
|
||
|
||
// A description auth grew since this table was written must not leak through as-is:
|
||
// it's written for an operator, so an unmapped one falls back to the generic sentence.
|
||
const unmapped = await readAuthError(refused('some new internal reason'), 'signup')
|
||
expect(unmapped.message).not.toContain('some new internal reason')
|
||
expect(unmapped.message).toContain('could not be created')
|
||
|
||
// Nothing about the form was wrong — auth couldn't proceed (an unset JWT_SECRET). Don't
|
||
// send them back to re-check their details, and don't answer 400 for our own fault.
|
||
const broken = await readAuthError(
|
||
new Response(
|
||
JSON.stringify({
|
||
error: 'server_error',
|
||
error_description: 'token signing is not configured',
|
||
}),
|
||
{ status: 500, headers: { 'content-type': 'application/json' } }
|
||
),
|
||
'signup'
|
||
)
|
||
expect(broken.status).toBe(502)
|
||
expect(broken.message).toContain('problem on our end')
|
||
|
||
// A body from something in front of auth (an edge error page) is not JSON at all.
|
||
const html = await readAuthError(new Response('<html>502</html>', { status: 502 }), 'signup')
|
||
expect(html.status).toBe(502)
|
||
expect(html.message).toContain('problem on our end')
|
||
expect(html.upstream).toBe('HTTP 502')
|
||
})
|
||
|
||
// The signup cap counts auth's `CF-Connecting-IP` as the account's immutable `signupIp`,
|
||
// and www used to reach auth over https://auth.<DOMAIN> — a Worker subrequest, which
|
||
// re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
|
||
// address. Every browser signup therefore shared one IP, and the cap (3, never decaying)
|
||
// refused the fourth web account ever created, for everyone. The service binding skips
|
||
// the edge, so the header set here is the one auth reads.
|
||
//
|
||
// Checked directly rather than through /api/signup: the pass path would call Cloudflare's
|
||
// siteverify for real (see the Turnstile tests above).
|
||
it('carries the browser IP across to auth instead of losing it to the edge', async () => {
|
||
const seen: Request[] = []
|
||
const withAuth = (fetcher?: Fetcher) =>
|
||
({
|
||
DOMAIN: 'rec.example.com',
|
||
AUTH: fetcher,
|
||
}) as unknown as Env
|
||
const capture = {
|
||
fetch: async (request: Request) => {
|
||
seen.push(request)
|
||
return new Response('{}', { headers: { 'content-type': 'application/json' } })
|
||
},
|
||
} as unknown as Fetcher
|
||
|
||
await postAuthForm(
|
||
withAuth(capture),
|
||
'/connect/token',
|
||
{ grant_type: 'create_account', password: 'hunter2' },
|
||
{ clientIp: '203.0.113.7' }
|
||
)
|
||
|
||
// The binding is used in preference to the hostname, and the real IP rides along.
|
||
expect(seen).toHaveLength(1)
|
||
expect(seen[0]!.headers.get('cf-connecting-ip')).toBe('203.0.113.7')
|
||
// Still the same host/path/body auth already answers — only the transport changed.
|
||
expect(seen[0]!.url).toBe('https://auth.rec.example.com/connect/token')
|
||
const body = await seen[0]!.formData()
|
||
expect(body.get('grant_type')).toBe('create_account')
|
||
expect(body.get('password')).toBe('hunter2')
|
||
|
||
// A call with no IP to forward must not invent one: an absent header leaves auth's
|
||
// own `clientIp` empty, which SKIPS the cap, rather than counting everyone together.
|
||
// Reachable in local dev, where the edge sets no `cf-connecting-ip` to pass on.
|
||
await postAuthForm(withAuth(capture), '/connect/token', { grant_type: 'create_account' })
|
||
expect(seen[1]!.headers.get('cf-connecting-ip')).toBeNull()
|
||
})
|
||
|
||
// The public status snapshot. Two things are pinned: it needs no auth and no origin (a
|
||
// status page or Discord bot fetches it from anywhere), and its player count is LIVE
|
||
// presence — a row whose TTL has run out is a player who crashed or hard-quit, and
|
||
// counting them would leave the number permanently inflated between sweeps.
|
||
it('serves a public head-count of the players actually online', async () => {
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const write = (accountId: number, expiresAt: number) =>
|
||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||
.bind(JSON.stringify({ accountId, roomInstance: null, expiresAt }))
|
||
.run()
|
||
|
||
// Empty table: online, nobody playing.
|
||
let res = await SELF.fetch('https://example.com/server-status')
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ status: 'online', players: 0 })
|
||
|
||
await write(1, now + PRESENCE_TTL_SECONDS) // in a lobby — still online
|
||
await write(2, now + PRESENCE_TTL_SECONDS)
|
||
await write(3, now - 1) // stopped heartbeating, not yet swept
|
||
|
||
res = await SELF.fetch('https://example.com/server-status', {
|
||
headers: { origin: 'https://s.example' },
|
||
})
|
||
expect(res.status).toBe(200)
|
||
// Readable from any origin — it's meant to be embedded elsewhere.
|
||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||
expect(await res.json()).toEqual({ status: 'online', players: 2 })
|
||
})
|
||
|
||
// The series behind `/stats`. What's pinned: samples fold into the range's bucket as its
|
||
// PEAK, the average is over the raw samples (not over the buckets), samples outside the
|
||
// window and other stat types stay out, and the range is a closed list.
|
||
it('serves the online series bucketed by range, peak per bucket', async () => {
|
||
await env.DB.prepare('DELETE FROM stat').run()
|
||
// Anchored to the start of the previous hour so the three samples provably share one
|
||
// hourly bucket, whenever the test runs.
|
||
const hour = Math.floor(Date.now() / 3_600_000) * 3_600_000 - 3_600_000
|
||
await recordStat(env.DB, 'online', 4, new Date(hour))
|
||
await recordStat(env.DB, 'online', 9, new Date(hour + 5 * 60_000))
|
||
await recordStat(env.DB, 'online', 2, new Date(hour + 10 * 60_000))
|
||
await recordStat(env.DB, 'online', 50, new Date(hour - 8 * 86_400_000)) // before the 7d window
|
||
await recordStat(env.DB, 'rooms', 99, new Date(hour)) // another series entirely
|
||
|
||
let res = await SELF.fetch('https://example.com/api/stats/online?range=7d')
|
||
expect(res.status).toBe(200)
|
||
const week = await res.json<{
|
||
bucketSeconds: number
|
||
from: number
|
||
to: number
|
||
peak: number
|
||
average: number
|
||
points: Array<{ t: number; players: number }>
|
||
}>()
|
||
expect(week.bucketSeconds).toBe(3600)
|
||
expect(week.to - week.from).toBe(7 * 86_400)
|
||
expect(week.points).toEqual([{ t: hour / 1000, players: 9 }])
|
||
expect(week.peak).toBe(9)
|
||
expect(week.average).toBe(5)
|
||
|
||
// The default range is the raw five-minute series: one point per sample.
|
||
res = await SELF.fetch('https://example.com/api/stats/online')
|
||
const day = await res.json<{ range: string; points: Array<{ players: number }> }>()
|
||
expect(day.range).toBe('24h')
|
||
expect(day.points.map((p) => p.players)).toEqual([4, 9, 2])
|
||
|
||
// The 30-day window reaches the old sample the week didn't.
|
||
res = await SELF.fetch('https://example.com/api/stats/online?range=30d')
|
||
expect((await res.json<{ peak: number }>()).peak).toBe(50)
|
||
|
||
res = await SELF.fetch('https://example.com/api/stats/online?range=forever')
|
||
expect(res.status).toBe(400)
|
||
})
|
||
|
||
it('serves the aggregated docs page with a source per documented service', async () => {
|
||
const res = await SELF.fetch('https://example.com/docs')
|
||
expect(res.status).toBe(200)
|
||
expect(res.headers.get('content-type')).toContain('text/html')
|
||
const html = await res.text()
|
||
// Mounts the self-hosted Scalar bundle (not a CDN) and lists every service's spec.
|
||
expect(html).toContain('/docs/scalar.standalone.js')
|
||
// Driven off the constant so adding a service can't leave the page (or this test)
|
||
// behind.
|
||
for (const { slug } of DOCUMENTED_SERVICES) {
|
||
expect(html).toContain(`/docs/openapi/${slug}.json`)
|
||
}
|
||
})
|
||
|
||
it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
|
||
// An un-allowlisted service is rejected before any upstream fetch, so this can't be
|
||
// turned into a proxy to `https://<anything>.<DOMAIN>`.
|
||
const res = await SELF.fetch('https://example.com/docs/openapi/evil.json')
|
||
expect(res.status).toBe(404)
|
||
})
|
||
|
||
// ---- Discord benefits claim ------------------------------------------------
|
||
|
||
// All four settings are the switch, exactly as the Turnstile keypair is for signup: a
|
||
// half-configured app must read as OFF. The dangerous half is a client id and secret with
|
||
// no guild/role — that authenticates a player and then has no question left to ask about
|
||
// them, so treating it as configured would hand Rec Room Plus to anyone with a Discord
|
||
// account. Checked directly because the configured path can't be reached from here (it
|
||
// would call discord.com for real).
|
||
it('treats a half-configured discord app as benefit claims being off', async () => {
|
||
const stub = (value: string | null): SecretsStoreSecret =>
|
||
({ get: async () => value ?? '' }) as SecretsStoreSecret
|
||
const throws = (): SecretsStoreSecret =>
|
||
({
|
||
get: async () => {
|
||
throw new Error('secret not found')
|
||
},
|
||
}) as unknown as SecretsStoreSecret
|
||
|
||
const withDiscord = (
|
||
id: SecretsStoreSecret,
|
||
secret: SecretsStoreSecret,
|
||
guildId?: string,
|
||
roleIds?: string
|
||
) =>
|
||
({
|
||
ENVIRONMENT: 'development',
|
||
DISCORD_CLIENT_ID: id,
|
||
DISCORD_CLIENT_SECRET: secret,
|
||
DISCORD_GUILD_ID: guildId,
|
||
DISCORD_BENEFITS_ROLE_IDS: roleIds,
|
||
}) as Env
|
||
|
||
const id = stub('client-id')
|
||
const secret = stub('client-secret')
|
||
// Snowflakes, as the real vars hold: ids are all digits, never a role's display name.
|
||
const guild = '1077000000000000000'
|
||
const role = '1077000000000000001'
|
||
|
||
// Nothing at all, and a store this worker can't read: both closed, never a 500.
|
||
await expect(discordConfig(withDiscord(throws(), throws()))).resolves.toBeNull()
|
||
await expect(discordConfig(withDiscord(stub(''), stub(''), '', ''))).resolves.toBeNull()
|
||
// Each single missing piece, including the two that would otherwise grant Plus for a
|
||
// bare Discord login.
|
||
await expect(discordConfig(withDiscord(throws(), secret, guild, role))).resolves.toBeNull()
|
||
await expect(discordConfig(withDiscord(id, throws(), guild, role))).resolves.toBeNull()
|
||
await expect(discordConfig(withDiscord(id, secret, '', role))).resolves.toBeNull()
|
||
await expect(discordConfig(withDiscord(id, secret, guild, ''))).resolves.toBeNull()
|
||
await expect(discordConfig(withDiscord(id, secret))).resolves.toBeNull()
|
||
// A role list that parses to NO ids is unset, not configured — otherwise a stray comma
|
||
// left in the var would open the claim with nothing to check against.
|
||
await expect(discordConfig(withDiscord(id, secret, guild, ' , , '))).resolves.toBeNull()
|
||
// All four present is the only configured state.
|
||
await expect(discordConfig(withDiscord(id, secret, guild, role))).resolves.toEqual({
|
||
clientId: 'client-id',
|
||
clientSecret: 'client-secret',
|
||
guildId: guild,
|
||
roleIds: [role],
|
||
})
|
||
// Several qualifying roles is the ordinary case, not a special one.
|
||
const second = '1077000000000000002'
|
||
await expect(discordConfig(withDiscord(id, secret, guild, `${role},${second}`))).resolves.toEqual(
|
||
{
|
||
clientId: 'client-id',
|
||
clientSecret: 'client-secret',
|
||
guildId: guild,
|
||
roleIds: [role, second],
|
||
}
|
||
)
|
||
})
|
||
|
||
// Several roles can qualify for the same benefit (a supporter role, a booster role,
|
||
// staff…), so the list is parsed leniently: an operator pasting ids out of Discord gets
|
||
// one per line, and a trailing comma is a typo rather than a role of '' that nothing
|
||
// could ever match. Every id is a snowflake — all digits, kept as a string.
|
||
it('parses a qualifying-role list however an operator writes it', () => {
|
||
expect(parseRoleIds('1077000000000000001')).toEqual(['1077000000000000001'])
|
||
expect(parseRoleIds('1077000000000000001,1077000000000000002')).toEqual([
|
||
'1077000000000000001',
|
||
'1077000000000000002',
|
||
])
|
||
expect(parseRoleIds(' 1077000000000000001 , 1077000000000000002 ')).toEqual([
|
||
'1077000000000000001',
|
||
'1077000000000000002',
|
||
])
|
||
// Pasted a line at a time, straight out of Discord.
|
||
expect(parseRoleIds('1077000000000000001\n1077000000000000002\n')).toEqual([
|
||
'1077000000000000001',
|
||
'1077000000000000002',
|
||
])
|
||
// Kept as STRINGS, never parsed to numbers: a snowflake exceeds 2^53, so
|
||
// Number('1077000000000000001') would round and stop matching the real role.
|
||
expect(parseRoleIds('1077000000000000001')[0]).toBe('1077000000000000001')
|
||
// Nothing to match on — these are the values that must close the claim.
|
||
expect(parseRoleIds('')).toEqual([])
|
||
expect(parseRoleIds(' ')).toEqual([])
|
||
expect(parseRoleIds(',,')).toEqual([])
|
||
// A trailing separator adds no empty id, which would match no role and never qualify.
|
||
expect(parseRoleIds('1077000000000000001,')).toEqual(['1077000000000000001'])
|
||
})
|
||
|
||
// ANY one of the configured roles qualifies — they are alternatives, not requirements.
|
||
// Testing for a subset instead would mean a player had to hold every tier at once, i.e.
|
||
// nobody would ever claim.
|
||
it('qualifies a member holding any one of the roles', () => {
|
||
// Ids on both sides — Discord reports a member's roles as snowflakes, never as names.
|
||
const supporter = '1077000000000000001'
|
||
const booster = '1077000000000000002'
|
||
const qualifying = [supporter, booster]
|
||
|
||
expect(qualifies([supporter], qualifying)).toBe(true)
|
||
expect(qualifies([booster], qualifying)).toBe(true)
|
||
expect(qualifies([booster, supporter], qualifying)).toBe(true)
|
||
// Holding some other role in the server is not enough.
|
||
expect(qualifies(['1077000000000000009'], qualifying)).toBe(false)
|
||
expect(qualifies([], qualifying)).toBe(false)
|
||
})
|
||
|
||
// The closed door, from the outside. This must be refused BEFORE the token is looked at,
|
||
// so an unconfigured server can't be talked into a claim by a valid session.
|
||
it('refuses a benefits claim when discord is only half configured', async () => {
|
||
const res = await SELF.fetch('https://example.com/api/benefits/claim', {
|
||
method: 'POST',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
authorization: `Bearer ${await tokenFor(4001)}`,
|
||
},
|
||
body: JSON.stringify({ code: 'whatever' }),
|
||
})
|
||
expect(res.status).toBe(403)
|
||
expect(await res.json()).toEqual({ error: 'Benefit claims are currently disabled.' })
|
||
})
|
||
|
||
// The benefits routes act on ONE account — the claim writes `hasPlus` onto its row — so
|
||
// which account it is has to come from a verified token and never from the request. Both
|
||
// halves of "verified" are pinned: no token, and a token signed with a key this server
|
||
// doesn't use (i.e. one it never issued).
|
||
//
|
||
// Asserted on `/api/benefits/status` because it's the benefits route whose auth gate is
|
||
// reachable here: the claim refuses on the config gate FIRST (covered above), which is
|
||
// the right order — an unconfigured server shouldn't be examining credentials for a
|
||
// feature it doesn't run — but it means an unconfigured project can't observe its 401.
|
||
it('requires a valid session to read benefits', async () => {
|
||
const path = 'https://example.com/api/benefits/status'
|
||
|
||
// No token at all.
|
||
expect((await SELF.fetch(path)).status).toBe(401)
|
||
|
||
// A token that is well-formed but signed with the wrong key.
|
||
const forged = await generateToken('4002', '', 4, 'not-the-real-secret')
|
||
const res = await SELF.fetch(path, { headers: { authorization: `Bearer ${forged}` } })
|
||
expect(res.status).toBe(401)
|
||
})
|
||
|
||
// What the claim page renders before anyone presses anything. `hasPlus` is read off the
|
||
// account ROW rather than a token claim, because it's set after the browser's token was
|
||
// issued — a freshly-claimed player's token says nothing about it.
|
||
it('reports where an account stands on benefits', async () => {
|
||
const token = await tokenFor(4003)
|
||
|
||
// An account with no row at all reads as "nothing claimed" rather than 404ing: every
|
||
// account has a benefits status, whether or not it has been written to yet.
|
||
let res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
|
||
|
||
await updateAccount(env.DB, 4003, { hasPlus: true })
|
||
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
|
||
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||
headers: { authorization: `Bearer ${token}` },
|
||
})
|
||
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
|
||
// use to the page, and an account's linked identities have no business on the wire.
|
||
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
|
||
})
|
||
|
||
// The once-only guard the claim is built on. Without it, one Discord member holding the
|
||
// role could walk it around every RecFlare account they own; with it, the second claim is
|
||
// refused and the first account keeps the benefit. Exercised at the two lookups the route
|
||
// asks, since the route's own path to them runs through discord.com.
|
||
it('tells a repeat claim from a second account claiming the same discord identity', async () => {
|
||
await updateAccount(env.DB, 4004, { hasPlus: true })
|
||
await linkPlatformIdentity(env.DB, 4004, PlatformType.Discord, '99002')
|
||
|
||
// The identity is taken, so a DIFFERENT account claiming it is the 409 case…
|
||
await expect(
|
||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99002')
|
||
).resolves.toBe(1)
|
||
await expect(isPlatformIdentityLinked(env.DB, 4005, PlatformType.Discord, '99002')).resolves.toBe(
|
||
false
|
||
)
|
||
|
||
// …while the account that already holds it re-claims idempotently, which is what makes
|
||
// the page safe to reload and a lapsed-then-restored role re-claimable.
|
||
await expect(isPlatformIdentityLinked(env.DB, 4004, PlatformType.Discord, '99002')).resolves.toBe(
|
||
true
|
||
)
|
||
|
||
// A Discord member who has claimed nowhere yet.
|
||
await expect(
|
||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99003')
|
||
).resolves.toBe(0)
|
||
})
|
||
|
||
// A Discord link must never become a way INTO an account. The login picker is public and
|
||
// unauthenticated, so listing one would both offer the client an account it can't redeem
|
||
// (the grant refuses platform 101) and tell anyone which RecFlare account a Discord user
|
||
// owns — a snowflake is readable by anyone sharing a server with them. `auth` owns that
|
||
// gate; this pins that the platform www writes to is one the gate actually excludes.
|
||
it('stores the discord identity on a platform the login picker will not list', () => {
|
||
expect(CACHED_LOGIN_PLATFORMS).not.toContain(PlatformType.Discord)
|
||
})
|
||
|
||
// The one-time supporter gift the claim pays on an account's FIRST Discord link: econ's
|
||
// cron grant, called on demand with the roles Discord just served, against www's own
|
||
// bindings. Exercised at the grant, since the route's path to it runs through discord.com;
|
||
// the claim decides "first" from the link table before it writes (see www.app.ts).
|
||
it('pays the discord role gift once, on demand, from www’s bindings', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
const supporter = '1077000000000000001'
|
||
const booster = '1077000000000000002'
|
||
const giftEnv = { ...env, DISCORD_ROLE_TOKENS: `${supporter}=2500,${booster}=10000` } as Env
|
||
|
||
// No map, or a member holding no mapped role: nothing is paid and nothing is written.
|
||
await expect(
|
||
grantDiscordRoleGift({ ...env, DISCORD_ROLE_TOKENS: undefined } as Env, 4010, [supporter], 100)
|
||
).resolves.toBeNull()
|
||
await expect(
|
||
grantDiscordRoleGift(giftEnv, 4010, ['1077000000000000009'], 100)
|
||
).resolves.toBeNull()
|
||
expect(await getPendingGifts(env.DB, 4010)).toEqual([])
|
||
expect(await (await hub().fetch('http://do/all')).json()).toEqual([])
|
||
|
||
// The best of the member's mapped roles pays — one box, on top of the signup grant, which
|
||
// is seeded first so a never-touched balance doesn't start from the gift alone.
|
||
await expect(
|
||
grantDiscordRoleGift(giftEnv, 4010, [supporter, booster], DEFAULT_STARTING_TOKENS)
|
||
).resolves.toEqual({ roleId: booster, tokens: 10000 })
|
||
await expect(
|
||
getBalance(env.DB, 4010, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 10000)
|
||
const gifts = await getPendingGifts(env.DB, 4010)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
FromPlayerId: 1,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Currency: 10000,
|
||
AvatarItemType: null,
|
||
Message: DISCORD_ROLE_GIFT_MESSAGE,
|
||
})
|
||
|
||
// Announced through www's hub binding exactly as econ's cron announces it: the resulting
|
||
// total into the -2 bucket, then the box.
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||
playerId: number
|
||
notificationType: number
|
||
data: Record<string, unknown>
|
||
}>
|
||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([
|
||
[4010, 61],
|
||
[4010, 31],
|
||
])
|
||
expect(frames[0].data).toEqual({
|
||
Balance: DEFAULT_STARTING_TOKENS + 10000,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Platform: -2,
|
||
})
|
||
expect(frames[1].data).toMatchObject({ Id: gifts[0]!.Id, Currency: 10000, BalanceType: -2 })
|
||
})
|
||
|
||
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.1–4 checks are run
|
||
// against, and a reviewer only sees the rendered page — so the four things they look
|
||
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
|
||
// assertion: these strings are the requirement, not incidental copy.
|
||
it('serves the privacy policy as real server-rendered HTML', async () => {
|
||
const res = await SELF.fetch('https://example.com/privacy')
|
||
// VRC.Privacy.1 — live, public, no sign-in, and text without JavaScript.
|
||
expect(res.status).toBe(200)
|
||
expect(res.headers.get('content-type')).toContain('text/html')
|
||
const html = await res.text()
|
||
expect(html).toContain('Privacy Policy')
|
||
|
||
// VRC.Privacy.2 — what is collected, VRC.Privacy.3 — what it is used for.
|
||
expect(html).toContain('What we collect')
|
||
expect(html).toContain('Why we use it')
|
||
|
||
// VRC.Privacy.4 — deletion is explained, free, and open to every region.
|
||
expect(html).toContain('Deleting your data')
|
||
expect(html).toMatch(/delete your account[^.]*at any\s+time, from anywhere in the world/)
|
||
expect(html).toContain('There is no charge for this')
|
||
|
||
// A deletion route a reader can actually follow. Discord and GitHub are always
|
||
// listed; the mailbox only when one is configured (see PRIVACY_EMAIL).
|
||
expect(html).toContain(DISCORD_INVITE)
|
||
expect(html).toContain(ISSUES_URL)
|
||
if (PRIVACY_EMAIL) expect(html).toContain(`mailto:${PRIVACY_EMAIL}`)
|
||
})
|
||
|
||
// ---- Staff moderation panel -------------------------------------------------
|
||
//
|
||
// The `/api/staff/*` endpoints behind the `/moderation` page (see src/staff.ts). These
|
||
// are recflare's own surface — no Rec Room client calls them — which is why they live on
|
||
// `www` rather than on the workers that reimplement the game's API.
|
||
//
|
||
// The gate is the whole security model here: every route reads and writes moderation
|
||
// state for any account, so the tests below pin BOTH refusals (no token, and a valid
|
||
// token without a staff role) as carefully as they pin the happy paths.
|
||
|
||
/** GET a staff endpoint as `accountId`, with or without the staff role. */
|
||
async function staffGet(path: string, accountId: number, roles: string[] = ['moderator']) {
|
||
return SELF.fetch(`https://example.com${path}`, {
|
||
headers: { authorization: `Bearer ${await tokenFor(accountId, roles)}` },
|
||
})
|
||
}
|
||
|
||
/** DELETE a staff endpoint as a moderator (or whatever `roles` names). */
|
||
async function staffDelete(path: string, accountId: number, roles: string[] = ['moderator']) {
|
||
return SELF.fetch(`https://example.com${path}`, {
|
||
method: 'DELETE',
|
||
headers: { authorization: `Bearer ${await tokenFor(accountId, roles)}` },
|
||
})
|
||
}
|
||
|
||
/** POST a JSON body to a staff endpoint as a moderator (or whatever `roles` names). */
|
||
async function staffPost(
|
||
path: string,
|
||
accountId: number,
|
||
body: unknown,
|
||
roles: string[] = ['moderator']
|
||
) {
|
||
return SELF.fetch(`https://example.com${path}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
authorization: `Bearer ${await tokenFor(accountId, roles)}`,
|
||
'content-type': 'application/json',
|
||
},
|
||
body: JSON.stringify(body),
|
||
})
|
||
}
|
||
|
||
/** POST a JSON body to a developer-only staff endpoint (the gifts) as a developer. */
|
||
const devPost = (path: string, accountId: number, body: unknown) =>
|
||
staffPost(path, accountId, body, ['developer'])
|
||
|
||
// Two refusals, not one. A 401 is an expired session — the SPA drops the token and sends
|
||
// the player to sign in — while a 403 is a signed-in player who simply isn't staff, and
|
||
// looping THEM through a sign-in would change nothing. Every route is checked, because
|
||
// the gate is middleware and a route registered outside it would be wide open.
|
||
it('refuses every staff endpoint without a token, and without a staff role', async () => {
|
||
const paths = [
|
||
'/api/staff/reports',
|
||
'/api/staff/reports/top-reported',
|
||
'/api/staff/reports/1',
|
||
'/api/staff/bans',
|
||
'/api/staff/players/1',
|
||
'/api/staff/players/1/linked',
|
||
'/api/staff/studio-access',
|
||
'/api/staff/sandbox',
|
||
]
|
||
const writes = [
|
||
'/api/staff/players/1/gift-tokens',
|
||
'/api/staff/players/1/gift-item',
|
||
'/api/staff/players/1/gift-xp',
|
||
'/api/staff/players/1/username-changes',
|
||
'/api/staff/players/1/clear-password',
|
||
'/api/staff/players/1/clear-dorm',
|
||
'/api/staff/studio-access',
|
||
'/api/staff/players/1/grant-plus',
|
||
]
|
||
writes.push(
|
||
'/api/staff/rooms/1/gift-tokens',
|
||
'/api/staff/online/gift-tokens',
|
||
'/api/staff/discord-roles/1/gift-tokens',
|
||
'/api/staff/discord-roles/sync',
|
||
'/api/staff/discord-roles/supporter-gift'
|
||
)
|
||
for (const path of writes) {
|
||
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
|
||
const res = await SELF.fetch(`https://example.com${path}`, {
|
||
method: 'POST',
|
||
headers: { authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}` },
|
||
})
|
||
expect(res.status).toBe(403)
|
||
}
|
||
|
||
// The gifts, the Plus grant, the role sweep and the supporter gift are narrower: a
|
||
// moderator is staff, but not a developer.
|
||
for (const path of writes.filter(
|
||
(p) =>
|
||
p.includes('/gift-') ||
|
||
p.includes('/grant-plus') ||
|
||
p.endsWith('/sync') ||
|
||
p.endsWith('/supporter-gift')
|
||
)) {
|
||
expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403)
|
||
}
|
||
|
||
for (const path of paths) {
|
||
expect((await SELF.fetch(`https://example.com${path}`)).status).toBe(401)
|
||
// A valid token whose `role` claim is a plain player's.
|
||
expect((await staffGet(path, 8101, ['gameClient'])).status).toBe(403)
|
||
}
|
||
|
||
// The writes too, on the same terms.
|
||
const unauthed = await SELF.fetch('https://example.com/api/staff/reports', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ reportedPlayerId: 8102 }),
|
||
})
|
||
expect(unauthed.status).toBe(401)
|
||
|
||
const player = await SELF.fetch('https://example.com/api/staff/reports/1/ban', {
|
||
method: 'POST',
|
||
headers: {
|
||
authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}`,
|
||
'content-type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ banned: true, days: 1 }),
|
||
})
|
||
expect(player.status).toBe(403)
|
||
|
||
const remove = await SELF.fetch('https://example.com/api/staff/studio-access/1', {
|
||
method: 'DELETE',
|
||
})
|
||
expect(remove.status).toBe(401)
|
||
const playerRemove = await SELF.fetch('https://example.com/api/staff/studio-access/1', {
|
||
method: 'DELETE',
|
||
headers: { authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}` },
|
||
})
|
||
expect(playerRemove.status).toBe(403)
|
||
// The sandbox toggle is a PUT, so the loop over POSTs above doesn't cover it.
|
||
const sandboxUnauthed = await SELF.fetch('https://example.com/api/staff/sandbox', {
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ sandbox: true }),
|
||
})
|
||
expect(sandboxUnauthed.status).toBe(401)
|
||
const sandboxPlayer = await SELF.fetch('https://example.com/api/staff/sandbox', {
|
||
method: 'PUT',
|
||
headers: {
|
||
authorization: `Bearer ${await tokenFor(8101, ['gameClient'])}`,
|
||
'content-type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ sandbox: true }),
|
||
})
|
||
expect(sandboxPlayer.status).toBe(403)
|
||
})
|
||
|
||
// The reporter is the CALLER, never a body field — that is the record of who raised a
|
||
// hand-written report, and it is why nothing marks these rows as staff-created.
|
||
it('files a minimal report as the acting moderator', async () => {
|
||
const res = await staffPost('/api/staff/reports', 8110, {
|
||
reportedPlayerId: 8111,
|
||
reportCategory: 102,
|
||
details: 'Seen in a log, in the Rec Center',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
const report = (await res.json()) as Record<string, unknown>
|
||
expect(report.reporter_player_id).toBe(8110)
|
||
expect(report.reported_player_id).toBe(8111)
|
||
expect(report.report_category).toBe(102)
|
||
expect(report.details).toBe('Seen in a log, in the Rec Center')
|
||
// Filed unbanned: a report is not a ban, and the ban is a separate decision.
|
||
expect(report.banned).toBe(0)
|
||
expect(report.banned_at).toBeNull()
|
||
expect(report.banned_by_player_id).toBeNull()
|
||
})
|
||
|
||
// Who, what kind and why — and nothing else. A moderator does not know a room's numeric
|
||
// id and would have to go and look it up, so WHERE goes in `details` with the rest of the
|
||
// account of what happened. `room_id` is a player report's column: the client that filed
|
||
// one knows the id it was standing in, and a hand-written row must not pretend to.
|
||
it('ignores a room id on a hand-written report', async () => {
|
||
const res = await staffPost('/api/staff/reports', 8110, {
|
||
reportedPlayerId: 8112,
|
||
details: 'In someone’s dorm',
|
||
roomId: 991,
|
||
})
|
||
expect(res.status).toBe(200)
|
||
expect(((await res.json()) as Record<string, unknown>).room_id).toBeNull()
|
||
})
|
||
|
||
it('refuses a report with no player, and one a moderator files against themselves', async () => {
|
||
expect((await staffPost('/api/staff/reports', 8110, {})).status).toBe(400)
|
||
// The self-report is a mistake every time, and the ban it would justify locks the
|
||
// panel's own operator out of the game.
|
||
expect((await staffPost('/api/staff/reports', 8110, { reportedPlayerId: 8110 })).status).toBe(400)
|
||
})
|
||
|
||
// The search is the panel's way into the log, so each filter is pinned separately: a
|
||
// filter that quietly doesn't apply looks like "no reports of that kind" rather than an
|
||
// error, which is the failure a moderator would act on.
|
||
it('searches reports by player, by reporter and by ban state', async () => {
|
||
const target = 8120
|
||
await createReport(env.DB, {
|
||
reporterPlayerId: 8121,
|
||
reportedPlayerId: target,
|
||
reportCategory: 101,
|
||
details: 'first',
|
||
})
|
||
await createReport(env.DB, {
|
||
reporterPlayerId: 8122,
|
||
reportedPlayerId: target,
|
||
reportCategory: 102,
|
||
details: 'second',
|
||
})
|
||
await createReport(env.DB, { reporterPlayerId: 8121, reportedPlayerId: 8123, details: 'other' })
|
||
|
||
const byPlayer = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}`, 8110)
|
||
).json()) as { reports: Array<{ details: string }>; total: number }
|
||
// Newest first, and only the two against this player.
|
||
expect(byPlayer.total).toBe(2)
|
||
expect(byPlayer.reports.map((r) => r.details)).toEqual(['second', 'first'])
|
||
|
||
const byReporter = (await (
|
||
await staffGet('/api/staff/reports?reporterPlayerId=8122', 8110)
|
||
).json()) as { total: number }
|
||
expect(byReporter.total).toBe(1)
|
||
|
||
const byCategory = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&reportCategory=101`, 8110)
|
||
).json()) as { reports: Array<{ details: string }> }
|
||
expect(byCategory.reports.map((r) => r.details)).toEqual(['first'])
|
||
|
||
// An ABSENT `banned` param must not read as `false` — that would hide every actioned
|
||
// report from an unfiltered search, which is most of what a moderator looks for.
|
||
const unfiltered = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}`, 8110)
|
||
).json()) as { total: number }
|
||
expect(unfiltered.total).toBe(2)
|
||
const unbanned = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&banned=false`, 8110)
|
||
).json()) as { total: number }
|
||
expect(unbanned.total).toBe(2)
|
||
const banned = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&banned=true`, 8110)
|
||
).json()) as { total: number }
|
||
expect(banned.total).toBe(0)
|
||
})
|
||
|
||
// `total` is the unpaged count of the same predicate, so the pager can say "26–50 of 120"
|
||
// without re-deriving it — and a page smaller than the count is not the end of the list.
|
||
it('pages the search, reporting the full match count alongside the page', async () => {
|
||
const target = 8130
|
||
for (let i = 0; i < 5; i++) {
|
||
await createReport(env.DB, {
|
||
reporterPlayerId: 8131,
|
||
reportedPlayerId: target,
|
||
details: `r${i}`,
|
||
})
|
||
}
|
||
|
||
const first = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&take=2`, 8110)
|
||
).json()) as { reports: Array<{ details: string }>; total: number }
|
||
expect(first.total).toBe(5)
|
||
expect(first.reports.map((r) => r.details)).toEqual(['r4', 'r3'])
|
||
|
||
const second = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${target}&take=2&skip=2`, 8110)
|
||
).json()) as { reports: Array<{ details: string }>; total: number }
|
||
expect(second.total).toBe(5)
|
||
expect(second.reports.map((r) => r.details)).toEqual(['r2', 'r1'])
|
||
})
|
||
|
||
// Ranked by DISTINCT reporters ahead of raw count. One player filing twenty reports
|
||
// against someone they're feuding with is a different thing from twenty players filing
|
||
// one each, and a queue that can't tell them apart puts the feud at the top.
|
||
it('ranks the report queue by distinct reporters, not by raw count', async () => {
|
||
const feud = 8140
|
||
const real = 8141
|
||
// Four reports, all from the same person.
|
||
for (let i = 0; i < 4; i++) {
|
||
await createReport(env.DB, { reporterPlayerId: 8142, reportedPlayerId: feud })
|
||
}
|
||
// Three reports, three different people.
|
||
for (const reporter of [8143, 8144, 8145]) {
|
||
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: real })
|
||
}
|
||
|
||
const rows = (await (
|
||
await staffGet('/api/staff/reports/top-reported?minReports=3', 8110)
|
||
).json()) as Array<{ playerId: number; reports: number; distinctReporters: number }>
|
||
|
||
const feudRow = rows.find((r) => r.playerId === feud)
|
||
const realRow = rows.find((r) => r.playerId === real)
|
||
expect(feudRow).toMatchObject({ reports: 4, distinctReporters: 1 })
|
||
expect(realRow).toMatchObject({ reports: 3, distinctReporters: 3 })
|
||
// Fewer reports, more reporters — and so ahead of the feud in the list.
|
||
expect(rows.indexOf(realRow!)).toBeLessThan(rows.indexOf(feudRow!))
|
||
})
|
||
|
||
it('keeps the long tail of single reports out of the queue', async () => {
|
||
await createReport(env.DB, { reporterPlayerId: 8151, reportedPlayerId: 8150 })
|
||
const rows = (await (
|
||
await staffGet('/api/staff/reports/top-reported?minReports=3', 8110)
|
||
).json()) as Array<{ playerId: number }>
|
||
expect(rows.map((r) => r.playerId)).not.toContain(8150)
|
||
|
||
// …but it is a threshold, not a rule: a moderator can lower it.
|
||
const all = (await (
|
||
await staffGet('/api/staff/reports/top-reported?minReports=1', 8110)
|
||
).json()) as Array<{ playerId: number }>
|
||
expect(all.map((r) => r.playerId)).toContain(8150)
|
||
})
|
||
|
||
// The ban: a duration in, an expiry stored, and the audit trail that 0020 added. The
|
||
// duration matters because `banned_at` is what the client's block screen counts the ban
|
||
// from — see `banBlockDetails`.
|
||
it('bans from a report, recording who did it and when', async () => {
|
||
const report = await createReport(env.DB, {
|
||
reporterPlayerId: 8161,
|
||
reportedPlayerId: 8160,
|
||
reportCategory: 102,
|
||
})
|
||
const before = Date.now()
|
||
|
||
const res = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||
banned: true,
|
||
days: 7,
|
||
})
|
||
expect(res.status).toBe(200)
|
||
const banned = (await res.json()) as Record<string, unknown>
|
||
|
||
expect(banned.banned).toBe(1)
|
||
expect(banned.banned_by_player_id).toBe(8110)
|
||
expect(Date.parse(banned.banned_at as string)).toBeGreaterThanOrEqual(before)
|
||
// Seven days out, give or take the test's own runtime.
|
||
const expires = Date.parse(banned.ban_expires as string)
|
||
expect(expires - Date.parse(banned.banned_at as string)).toBeCloseTo(7 * 86_400_000, -4)
|
||
|
||
// And it shows up as a ban in force, which is a different question from `banned = 1`.
|
||
const bans = (await (await staffGet('/api/staff/bans', 8110)).json()) as Array<{ id: number }>
|
||
expect(bans.map((b) => b.id)).toContain(report.id)
|
||
})
|
||
|
||
it('bans permanently when asked, and refuses a duration in the past', async () => {
|
||
const report = await createReport(env.DB, { reporterPlayerId: 8171, reportedPlayerId: 8170 })
|
||
|
||
const permanent = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||
banned: true,
|
||
permanent: true,
|
||
})
|
||
expect(permanent.status).toBe(200)
|
||
// NULL is what records "never lifts" — not a far-future date.
|
||
expect(((await permanent.json()) as Record<string, unknown>).ban_expires).toBeNull()
|
||
|
||
// An expiry that was ASKED for but can't be honoured is refused rather than quietly
|
||
// becoming a permanent ban — the one mistake here that waiting cannot undo.
|
||
const past = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||
banned: true,
|
||
expires: '2001-01-01T00:00:00.000Z',
|
||
})
|
||
expect(past.status).toBe(400)
|
||
const nonsense = await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, {
|
||
banned: true,
|
||
expires: 'next tuesday',
|
||
})
|
||
expect(nonsense.status).toBe(400)
|
||
})
|
||
|
||
// A lift has to leave the row distinguishable from an EXPIRED ban (banned = 0 versus a
|
||
// past ban_expires), and must not leave an audit trail saying a ban runs from somewhere.
|
||
it('lifts a ban, clearing the expiry and the audit columns but keeping the report', async () => {
|
||
const report = await createReport(env.DB, {
|
||
reporterPlayerId: 8181,
|
||
reportedPlayerId: 8180,
|
||
details: 'still on file',
|
||
})
|
||
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||
200
|
||
)
|
||
|
||
const lifted = await staffPost(`/api/staff/reports/${report.id}/ban`, 8111, { banned: false })
|
||
expect(lifted.status).toBe(200)
|
||
const row = (await lifted.json()) as Record<string, unknown>
|
||
expect(row.banned).toBe(0)
|
||
expect(row.ban_expires).toBeNull()
|
||
expect(row.banned_at).toBeNull()
|
||
expect(row.banned_by_player_id).toBeNull()
|
||
// The report itself survives — it is the record of what was reported, not of the ban.
|
||
expect(row.details).toBe('still on file')
|
||
|
||
const bans = (await (await staffGet('/api/staff/bans', 8110)).json()) as Array<{ id: number }>
|
||
expect(bans.map((b) => b.id)).not.toContain(report.id)
|
||
})
|
||
|
||
// A lift wipes the ban columns, so without its own signature the row reads exactly like a
|
||
// report nobody acted on — and the next moderator to search the player saw "Ban…" and
|
||
// re-banned someone a colleague had deliberately let back in. The lift signs the row with
|
||
// who and when; the search serves it; a re-ban clears it, since a standing ban is not lifted.
|
||
it('signs a lifted ban with who lifted it, and clears that on a re-ban', async () => {
|
||
const player = 8240
|
||
const report = await createReport(env.DB, { reporterPlayerId: 8241, reportedPlayerId: player })
|
||
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||
200
|
||
)
|
||
// Untouched until a lift — a standing ban has not been lifted.
|
||
const banned = (await (await staffGet(`/api/staff/reports/${report.id}`, 8110)).json()) as Record<
|
||
string,
|
||
unknown
|
||
>
|
||
expect(banned).toMatchObject({ banned: 1, unbanned_by_player_id: null, unbanned_at: null })
|
||
|
||
const lifted = (await (
|
||
await staffPost(`/api/staff/reports/${report.id}/ban`, 8111, { banned: false })
|
||
).json()) as Record<string, unknown>
|
||
// Signed by the LIFTING moderator, not the banning one.
|
||
expect(lifted).toMatchObject({ banned: 0, unbanned_by_player_id: 8111 })
|
||
expect(lifted.unbanned_at).toEqual(expect.any(String))
|
||
|
||
// The search — `/moderation/search?reportedPlayerId=…` — is where the next moderator
|
||
// meets the row, so it has to carry the signature too.
|
||
const search = (await (
|
||
await staffGet(`/api/staff/reports?reportedPlayerId=${player}`, 8110)
|
||
).json()) as { reports: Array<Record<string, unknown>> }
|
||
expect(search.reports).toHaveLength(1)
|
||
expect(search.reports[0]).toMatchObject({
|
||
id: report.id,
|
||
banned: 0,
|
||
unbanned_by_player_id: 8111,
|
||
unbanned_at: lifted.unbanned_at,
|
||
})
|
||
// And the player's own page, which the search links to.
|
||
const history = (await (await staffGet(`/api/staff/players/${player}`, 8110)).json()) as {
|
||
activeBan: unknown
|
||
reports: Array<Record<string, unknown>>
|
||
}
|
||
expect(history.activeBan).toBeNull()
|
||
expect(history.reports[0]).toMatchObject({ unbanned_by_player_id: 8111 })
|
||
|
||
// Banning again — knowingly — is a standing ban once more, with no lift on it.
|
||
const rebanned = (await (
|
||
await staffPost(`/api/staff/reports/${report.id}/ban`, 8112, { days: 7 })
|
||
).json()) as Record<string, unknown>
|
||
expect(rebanned).toMatchObject({
|
||
banned: 1,
|
||
banned_by_player_id: 8112,
|
||
unbanned_by_player_id: null,
|
||
unbanned_at: null,
|
||
})
|
||
})
|
||
|
||
// The report row keeps only the ban's CURRENT state — a lift wipes who banned and when —
|
||
// so the audit log is the one place a ban and its lift are both still on record.
|
||
it('records a ban and its lift on the audit log, against the moderator', async () => {
|
||
const report = await createReport(env.DB, {
|
||
reporterPlayerId: 8186,
|
||
reportedPlayerId: 8185,
|
||
reportCategory: 102,
|
||
})
|
||
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 2 })).status).toBe(
|
||
200
|
||
)
|
||
expect(
|
||
(await staffPost(`/api/staff/reports/${report.id}/ban`, 8111, { banned: false })).status
|
||
).toBe(200)
|
||
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT player_id, action, data FROM audit_log
|
||
WHERE action IN ('ban', 'unban') AND json_extract(data, '$.reportId') = ?1
|
||
ORDER BY audit_log_id`
|
||
)
|
||
.bind(report.id)
|
||
.all<{ player_id: number; action: string; data: string }>()
|
||
expect(results.map((r) => [r.player_id, r.action])).toEqual([
|
||
[8110, 'ban'],
|
||
[8111, 'unban'],
|
||
])
|
||
|
||
const ban = JSON.parse(results[0].data)
|
||
expect(ban).toMatchObject({ reportId: report.id, playerId: 8185, reportCategory: 102 })
|
||
expect(ban.banExpires).toEqual(expect.any(String))
|
||
expect(ban.previous).toMatchObject({ banned: false, bannedBy: null })
|
||
|
||
// The lift names who it undid: the report row itself no longer can.
|
||
const unban = JSON.parse(results[1].data)
|
||
expect(unban).toMatchObject({ playerId: 8185, banExpires: null })
|
||
expect(unban.previous).toMatchObject({
|
||
banned: true,
|
||
bannedBy: 8110,
|
||
banExpires: ban.banExpires,
|
||
})
|
||
})
|
||
|
||
it('answers a ban on a report that does not exist with a 404', async () => {
|
||
expect((await staffPost('/api/staff/reports/99999/ban', 8110, { days: 1 })).status).toBe(404)
|
||
expect((await staffGet('/api/staff/reports/99999', 8110)).status).toBe(404)
|
||
})
|
||
|
||
// Without this a ban only bites on the player's NEXT matchmake: `match` refuses a banned
|
||
// player, but nothing revisits a session already in progress, so someone banned
|
||
// mid-session keeps playing until they leave on their own.
|
||
it('throws a banned player out of the instance they are standing in', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
|
||
await setPresence(env.DB, {
|
||
accountId: 8190,
|
||
roomInstance: { roomInstanceId: 77001 },
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
const report = await createReport(env.DB, { reporterPlayerId: 8191, reportedPlayerId: 8190 })
|
||
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 3 })).status).toBe(
|
||
200
|
||
)
|
||
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||
playerIds?: number[]
|
||
ephemeral?: boolean
|
||
notificationType: number | string
|
||
data: Record<string, unknown>
|
||
}>
|
||
expect(frames).toHaveLength(1)
|
||
expect(frames[0].playerIds).toEqual([8190])
|
||
// EPHEMERAL: a kick is true of the moment it happened. Queued and delivered on a later
|
||
// connect it would eject them from an unrelated session.
|
||
expect(frames[0].ephemeral).toBe(true)
|
||
// `IsBan` is what makes the client's screen name this a ban rather than a kick.
|
||
expect(frames[0].data.IsBan).toBe(true)
|
||
expect(frames[0].data.GameSessionId).toBe(77001)
|
||
|
||
// The SAME ban the sign-in screen describes, not a generic one: its category, message and
|
||
// `Duration` — the seconds LEFT, which at the instant the ban is handed down is the whole
|
||
// three days. `TimeoutStartedAt` is not paired with it by the client and stays null.
|
||
const banned = await getReportById(env.DB, report.id)
|
||
expect(frames[0].data).toMatchObject({
|
||
ReportCategory: banned!.report_category,
|
||
Message: 'Rule violation',
|
||
TimeoutStartedAt: null,
|
||
})
|
||
expect(frames[0].data.Duration).toBeGreaterThan(3 * 86_400 - 5)
|
||
expect(frames[0].data.Duration).toBeLessThanOrEqual(3 * 86_400)
|
||
|
||
// And they are out of that instance — moved into their own DORM, not deleted: `match`
|
||
// lets a banned player matchmake there and nowhere else, so it is where they read the
|
||
// block screen, and with no presence at all the client loads the dorm a second time.
|
||
const presence = await env.DB.prepare(
|
||
"SELECT json_extract(data, '$.roomInstance.roomInstanceId') AS instance FROM presence WHERE json_extract(data, '$.accountId') = 8190"
|
||
).first<{ instance: number | null }>()
|
||
expect(presence).not.toBeNull()
|
||
// The OFFLINE dorm sentinel — a room the client loads locally, with no session behind it.
|
||
expect(presence?.instance).toBe(-2)
|
||
})
|
||
|
||
// Online but not standing in any room — in a menu — is still banned from the game, and is
|
||
// told so now rather than at their next sign-in.
|
||
it('tells a banned player who is online but in no instance', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
|
||
await setPresence(env.DB, {
|
||
accountId: 8195,
|
||
roomInstance: null,
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
const report = await createReport(env.DB, { reporterPlayerId: 8196, reportedPlayerId: 8195 })
|
||
expect(
|
||
(await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { permanent: true })).status
|
||
).toBe(200)
|
||
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||
playerIds?: number[]
|
||
ephemeral?: boolean
|
||
data: Record<string, unknown>
|
||
}>
|
||
expect(frames).toHaveLength(1)
|
||
expect(frames[0]).toMatchObject({ playerIds: [8195], ephemeral: true })
|
||
// No session to name, and a permanent ban carries the largest duration the field holds.
|
||
expect(frames[0].data).toMatchObject({ IsBan: true, GameSessionId: 0, Duration: 2_147_483_647 })
|
||
})
|
||
|
||
// The ban row is committed before the kick is attempted, so a player who is offline (or
|
||
// a hub that can't be reached) must not fail a ban that has been handed down.
|
||
it('bans an offline player without a kick to push', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
|
||
const report = await createReport(env.DB, { reporterPlayerId: 8201, reportedPlayerId: 8200 })
|
||
expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||
200
|
||
)
|
||
await expect(getReportById(env.DB, report.id)).resolves.toMatchObject({ banned: 1 })
|
||
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as unknown[]
|
||
expect(frames).toHaveLength(0)
|
||
})
|
||
|
||
// One screen, one call: a moderator deciding what to do about an account reads the
|
||
// reports, the warnings and the ban together, and three round trips would let the page
|
||
// render a decision out of a half-loaded history.
|
||
it('serves a player’s reports, warnings and active ban together', async () => {
|
||
const player = 8210
|
||
await createReport(env.DB, {
|
||
reporterPlayerId: 8211,
|
||
reportedPlayerId: player,
|
||
details: 'reported',
|
||
})
|
||
const actioned = await createReport(env.DB, {
|
||
reporterPlayerId: 8212,
|
||
reportedPlayerId: player,
|
||
reportCategory: 101,
|
||
})
|
||
await createWarning(env.DB, {
|
||
moderatorPlayerId: 8110,
|
||
warnedPlayerId: player,
|
||
reportCategory: 101,
|
||
displayReason: 'Told once',
|
||
moderatorNote: 'internal',
|
||
})
|
||
expect((await staffPost(`/api/staff/reports/${actioned.id}/ban`, 8110, { days: 2 })).status).toBe(
|
||
200
|
||
)
|
||
|
||
const history = (await (await staffGet(`/api/staff/players/${player}`, 8110)).json()) as {
|
||
playerId: number
|
||
reports: Array<{ id: number }>
|
||
warnings: Array<{ display_reason: string; moderator_note: string }>
|
||
activeBan: { id: number } | null
|
||
}
|
||
expect(history.playerId).toBe(player)
|
||
expect(history.reports).toHaveLength(2)
|
||
// The ban in force is the one the panel headlines, and it names the report behind it.
|
||
expect(history.activeBan?.id).toBe(actioned.id)
|
||
expect(history.warnings).toHaveLength(1)
|
||
// Both halves of a warning are here: the panel is the internal view, so the note a
|
||
// player never sees is shown to staff.
|
||
expect(history.warnings[0]).toMatchObject({
|
||
display_reason: 'Told once',
|
||
moderator_note: 'internal',
|
||
})
|
||
})
|
||
|
||
// The evasion preview. The IP arm is coarse by design — households and shared networks
|
||
// look identical to it — so the panel shows the blast radius BEFORE a ban lands, and
|
||
// echoes which arms the operator has enabled so an empty list can be read correctly.
|
||
it('previews which other accounts a ban would reach', async () => {
|
||
// `account_id` is GENERATED from the blob, so only `data` is written — the same way
|
||
// `auth` writes an account row.
|
||
const seedAccount = (data: Record<string, unknown>) =>
|
||
env.DB.prepare('INSERT OR REPLACE INTO account (data) VALUES (?1)')
|
||
.bind(JSON.stringify(data))
|
||
.run()
|
||
await seedAccount({ accountId: 8220, username: 'Evader', lastLoginIp: '203.0.113.9' })
|
||
await seedAccount({ accountId: 8221, username: 'Housemate', signupIp: '203.0.113.9' })
|
||
await linkPlatformIdentity(env.DB, 8220, PlatformType.Steam, 'steam-8220')
|
||
await linkPlatformIdentity(env.DB, 8222, PlatformType.Steam, 'steam-8220')
|
||
|
||
const preview = (await (await staffGet('/api/staff/players/8220/linked', 8110)).json()) as {
|
||
arms: { ip: boolean; platform: boolean }
|
||
linked: Array<{ accountId: number; username: string | null; via: string }>
|
||
}
|
||
// BAN_EVASION_MATCH is unset in the test config, so both arms are live — the default
|
||
// an operator who sets nothing gets.
|
||
expect(preview.arms).toEqual({ ip: true, platform: true })
|
||
|
||
// The platform match is the sharp one (a proven identity) and sorts first; the IP one
|
||
// is the coarse arm that would catch the housemate.
|
||
expect(preview.linked.find((l) => l.accountId === 8222)?.via).toBe('platform')
|
||
expect(preview.linked.find((l) => l.accountId === 8221)).toMatchObject({
|
||
via: 'ip',
|
||
username: 'Housemate',
|
||
})
|
||
// Never the account asked about: the question is who ELSE.
|
||
expect(preview.linked.map((l) => l.accountId)).not.toContain(8220)
|
||
})
|
||
|
||
// The panel's own URLs must reach the SPA, not the worker's API surface. `requireStaff`
|
||
// is mounted on `/api/staff/*`, and `run_worker_first` lists `/api/*` — so every
|
||
// `/moderation/…` path falls through assets-first to the SPA shell, which is what lets a
|
||
// moderator reload on a player's history or paste a link to one.
|
||
//
|
||
// What is asserted is the NEGATIVE: not a 401 or 403. A test run has no ASSETS binding
|
||
// (there is no client build behind it), so the catch-all answers 404 here and every other
|
||
// SPA route — `/account`, `/login`, `/` — does too. A 401 would mean the staff middleware
|
||
// had grown to cover these paths, and a cold load of the panel would start refusing
|
||
// instead of rendering.
|
||
it('leaves the panel’s own routes to the SPA rather than the staff endpoints', async () => {
|
||
for (const path of [
|
||
'/moderation',
|
||
'/moderation/search',
|
||
'/moderation/bans',
|
||
'/moderation/players/5',
|
||
'/moderation/reports/5/ban',
|
||
]) {
|
||
const res = await SELF.fetch(`https://example.com${path}`)
|
||
expect(res.status).not.toBe(401)
|
||
expect(res.status).not.toBe(403)
|
||
// Same answer the other SPA routes give in a test run, for the same reason.
|
||
expect(res.status).toBe((await SELF.fetch('https://example.com/account')).status)
|
||
}
|
||
})
|
||
|
||
// Ordered by when the ban LANDED, newest first: the list's job is catching mistakes, and
|
||
// the ban most likely to be wrong is the one just handed down. See `getBansInForce`.
|
||
it('serves standing bans most recently handed down first', async () => {
|
||
const older = await createReport(env.DB, { reporterPlayerId: 8231, reportedPlayerId: 8230 })
|
||
const newer = await createReport(env.DB, { reporterPlayerId: 8233, reportedPlayerId: 8232 })
|
||
// The older ban is the PERMANENT one, so an order by severity would float it to the top.
|
||
expect(
|
||
(await staffPost(`/api/staff/reports/${older.id}/ban`, 8110, { permanent: true })).status
|
||
).toBe(200)
|
||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||
.bind(older.id, '2024-01-01T00:00:00.000Z')
|
||
.run()
|
||
expect((await staffPost(`/api/staff/reports/${newer.id}/ban`, 8110, { days: 1 })).status).toBe(
|
||
200
|
||
)
|
||
|
||
const bans = (await (await staffGet('/api/staff/bans', 8110)).json()) as Array<{ id: number }>
|
||
const ordered = bans.map((b) => b.id).filter((id) => [older.id, newer.id].includes(id))
|
||
expect(ordered).toEqual([newer.id, older.id])
|
||
})
|
||
|
||
// ---- Staff player actions ---------------------------------------------------
|
||
//
|
||
// The staff card on a player's profile page. Each action is recorded on `audit_log` against
|
||
// the staffer who took it, with the target in `data`.
|
||
|
||
/** The audit rows for one action against one target player, oldest first. */
|
||
async function auditRows(action: string, playerId: number) {
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT player_id, data FROM audit_log
|
||
WHERE action = ?1 AND json_extract(data, '$.playerId') = ?2 ORDER BY audit_log_id`
|
||
)
|
||
.bind(action, playerId)
|
||
.all<{ player_id: number; data: string }>()
|
||
return results.map((r) => ({ actor: r.player_id, data: JSON.parse(r.data) }))
|
||
}
|
||
|
||
// Credited on top of the signup grant (a never-touched balance is seeded first, as econ
|
||
// does), boxed, and announced: the balance frame carries the resulting TOTAL in the one
|
||
// ALL_PLATFORMS bucket, and the box frame is what shows the player it arrived.
|
||
it('gifts a player tokens in a gift box', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
await updateAccount(env.DB, 8300, { username: 'Giftee' })
|
||
|
||
const res = await devPost('/api/staff/players/8300/gift-tokens', 8110, { amount: 500 })
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { balance: number; giftId: number }
|
||
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS + 500)
|
||
await expect(
|
||
getBalance(env.DB, 8300, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 500)
|
||
|
||
const gifts = await getPendingGifts(env.DB, 8300)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
Id: body.giftId,
|
||
FromPlayerId: 1,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Currency: 500,
|
||
// No staff box holds an avatar item; a 0 here sends the client after one.
|
||
AvatarItemType: null,
|
||
})
|
||
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||
playerId: number
|
||
notificationType: number
|
||
data: Record<string, unknown>
|
||
}>
|
||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([
|
||
[8300, 61],
|
||
[8300, 31],
|
||
])
|
||
expect(frames[0].data).toEqual({
|
||
Balance: DEFAULT_STARTING_TOKENS + 500,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Platform: -2,
|
||
})
|
||
expect(frames[1].data).toMatchObject({ Id: body.giftId, Currency: 500, BalanceType: -2 })
|
||
|
||
expect(await auditRows('gift_tokens', 8300)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: {
|
||
playerId: 8300,
|
||
amount: 500,
|
||
message: 'A gift from the staff!',
|
||
balance: DEFAULT_STARTING_TOKENS + 500,
|
||
giftId: body.giftId,
|
||
},
|
||
},
|
||
])
|
||
})
|
||
|
||
it('refuses a token gift that is not a whole number, too large, or to nobody', async () => {
|
||
await updateAccount(env.DB, 8301, { username: 'NotGifted' })
|
||
// A fraction and a word are not amounts, and the cap binds in both directions, since a
|
||
// negative gift takes tokens away. Zero is allowed — see the empty box below.
|
||
for (const amount of [1.5, 'lots', 10_001, -10_001]) {
|
||
const res = await devPost('/api/staff/players/8301/gift-tokens', 8110, { amount })
|
||
expect(res.status).toBe(400)
|
||
}
|
||
expect((await devPost('/api/staff/players/8399/gift-tokens', 8110, { amount: 5 })).status).toBe(
|
||
404
|
||
)
|
||
expect(await getPendingGifts(env.DB, 8301)).toEqual([])
|
||
expect(await auditRows('gift_tokens', 8301)).toEqual([])
|
||
})
|
||
|
||
// A negative gift takes tokens back — through the same guarded debit a purchase spends with,
|
||
// so it cannot overdraw. It still mints a box, carrying the negative amount: what the client
|
||
// makes of one is the thing being found out.
|
||
it('takes tokens back on a negative gift, and cannot overdraw', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
await updateAccount(env.DB, 8360, { username: 'Indebted' })
|
||
|
||
const res = await devPost('/api/staff/players/8360/gift-tokens', 8110, { amount: -400 })
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { balance: number; giftId: number }
|
||
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS - 400)
|
||
await expect(
|
||
getBalance(env.DB, 8360, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS - 400)
|
||
|
||
const gifts = await getPendingGifts(env.DB, 8360)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({ Id: body.giftId, Currency: -400 })
|
||
|
||
// The balance frame carries the resulting total, as it does for a credit.
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||
notificationType: number
|
||
data: Record<string, unknown>
|
||
}>
|
||
expect(frames[0].data).toMatchObject({ Balance: DEFAULT_STARTING_TOKENS - 400 })
|
||
|
||
expect(await auditRows('gift_tokens', 8360)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: {
|
||
playerId: 8360,
|
||
amount: -400,
|
||
message: 'A gift from the staff!',
|
||
balance: DEFAULT_STARTING_TOKENS - 400,
|
||
giftId: body.giftId,
|
||
},
|
||
},
|
||
])
|
||
|
||
// More than they hold changes nothing: no debit, no box, no audit row beyond the first.
|
||
// Under the cap, so this is the BALANCE refusing it and not the typo guard (they hold
|
||
// DEFAULT_STARTING_TOKENS - 400 by now).
|
||
const tooMuch = await devPost('/api/staff/players/8360/gift-tokens', 8110, {
|
||
amount: -(DEFAULT_STARTING_TOKENS - 100),
|
||
})
|
||
expect(tooMuch.status).toBe(400)
|
||
await expect(
|
||
getBalance(env.DB, 8360, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS - 400)
|
||
expect(await getPendingGifts(env.DB, 8360)).toHaveLength(1)
|
||
expect(await auditRows('gift_tokens', 8360)).toHaveLength(1)
|
||
})
|
||
|
||
// The room is the audience, not one session: everyone standing in any instance of it, read
|
||
// at the moment the button is pressed.
|
||
it('sends tokens to everyone in a room, across its instances', async () => {
|
||
const inRoom = async (accountId: number, roomId: number, roomInstanceId: number) =>
|
||
setPresence(env.DB, {
|
||
accountId,
|
||
roomInstance: { roomId, roomInstanceId },
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
// Two instances of room 7700, plus a player in a different room who must not be paid.
|
||
await inRoom(8370, 7700, 77101)
|
||
await inRoom(8371, 7700, 77101)
|
||
await inRoom(8372, 7700, 77102)
|
||
await inRoom(8373, 7701, 77103)
|
||
|
||
const res = await devPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 250 })
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({
|
||
roomId: 7700,
|
||
amount: 250,
|
||
paid: [8370, 8371, 8372],
|
||
skipped: [],
|
||
})
|
||
|
||
for (const playerId of [8370, 8371, 8372]) {
|
||
await expect(
|
||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 250)
|
||
expect(await getPendingGifts(env.DB, playerId)).toHaveLength(1)
|
||
}
|
||
// The player in the other room is untouched — no box, and no balance row touched.
|
||
expect(await getPendingGifts(env.DB, 8373)).toEqual([])
|
||
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT player_id, data FROM audit_log
|
||
WHERE action = 'gift_tokens_room' AND json_extract(data, '$.roomId') = 7700`
|
||
).all<{ player_id: number; data: string }>()
|
||
expect(results).toHaveLength(1)
|
||
expect(results[0].player_id).toBe(8110)
|
||
expect(JSON.parse(results[0].data)).toEqual({
|
||
roomId: 7700,
|
||
amount: 250,
|
||
message: 'A gift from the staff!',
|
||
paid: [8370, 8371, 8372],
|
||
skipped: [],
|
||
})
|
||
})
|
||
|
||
// An empty room is a 404 rather than a silent success: a staffer who pressed the button
|
||
// expects somebody to have been paid.
|
||
it('refuses a room gift with nobody in the room, and a bad amount', async () => {
|
||
expect((await devPost('/api/staff/rooms/7799/gift-tokens', 8110, { amount: 10 })).status).toBe(
|
||
404
|
||
)
|
||
expect(
|
||
(await devPost('/api/staff/rooms/7700/gift-tokens', 8110, { amount: 10_001 })).status
|
||
).toBe(400)
|
||
})
|
||
|
||
// The box says what the staffer wrote, when they wrote something: the one-player and room
|
||
// gifts take the same optional `message` the drop requires. Blank keeps the staff default,
|
||
// and one too long to send is refused rather than cut.
|
||
it('puts a custom message on a one-player and a room token gift', async () => {
|
||
await updateAccount(env.DB, 8362, { username: 'Messaged' })
|
||
let res = await devPost('/api/staff/players/8362/gift-tokens', 8110, {
|
||
amount: 50,
|
||
message: ' Well played! ',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
expect(await getPendingGifts(env.DB, 8362)).toMatchObject([
|
||
{ Currency: 50, Message: 'Well played!' },
|
||
])
|
||
expect(await auditRows('gift_tokens', 8362)).toMatchObject([
|
||
{ data: { message: 'Well played!' } },
|
||
])
|
||
|
||
res = await devPost('/api/staff/players/8362/gift-tokens', 8110, { amount: 5, message: '' })
|
||
expect(res.status).toBe(200)
|
||
expect((await getPendingGifts(env.DB, 8362)).map((g) => g.Message)).toEqual([
|
||
'Well played!',
|
||
'A gift from the staff!',
|
||
])
|
||
expect(
|
||
(
|
||
await devPost('/api/staff/players/8362/gift-tokens', 8110, {
|
||
amount: 5,
|
||
message: 'x'.repeat(257),
|
||
})
|
||
).status
|
||
).toBe(400)
|
||
|
||
await setPresence(env.DB, {
|
||
accountId: 8363,
|
||
roomInstance: { roomId: 7720, roomInstanceId: 77301 },
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
res = await devPost('/api/staff/rooms/7720/gift-tokens', 8110, {
|
||
amount: 25,
|
||
message: 'Thanks for coming!',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
expect(await getPendingGifts(env.DB, 8363)).toMatchObject([
|
||
{ Currency: 25, Message: 'Thanks for coming!' },
|
||
])
|
||
expect(
|
||
(
|
||
await devPost('/api/staff/rooms/7720/gift-tokens', 8110, {
|
||
amount: 25,
|
||
message: 'x'.repeat(257),
|
||
})
|
||
).status
|
||
).toBe(400)
|
||
})
|
||
|
||
// Everyone online is the audience — a player in a room, a player in another room and a
|
||
// player sat in the lobby with no instance at all — and the box carries the operator's message
|
||
// rather than the staff default.
|
||
it('drops tokens on everyone online, lobby included, with the message on the box', async () => {
|
||
const online = async (
|
||
accountId: number,
|
||
roomInstance: { roomId: number; roomInstanceId: number } | null
|
||
) =>
|
||
setPresence(env.DB, {
|
||
accountId,
|
||
roomInstance,
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
await online(8380, { roomId: 7710, roomInstanceId: 77201 })
|
||
await online(8381, { roomId: 7711, roomInstanceId: 77202 })
|
||
await online(8382, null)
|
||
// Expired presence is offline: the row is still there until the sweep, but they left.
|
||
await online(8383, { roomId: 7710, roomInstanceId: 77201 })
|
||
await env.DB.prepare(
|
||
`UPDATE presence SET data = json_set(data, '$.expiresAt', 1) WHERE account_id = 8383`
|
||
).run()
|
||
|
||
await clearHub()
|
||
const res = await devPost('/api/staff/online/gift-tokens', 8110, {
|
||
amount: 100,
|
||
message: 'Thanks for playing!',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { amount: number; message: string; paid: number[] }
|
||
expect(body.amount).toBe(100)
|
||
expect(body.message).toBe('Thanks for playing!')
|
||
expect(body.paid).toEqual(expect.arrayContaining([8380, 8381, 8382]))
|
||
expect(body.paid).not.toContain(8383)
|
||
|
||
const frames = await hubFrames()
|
||
for (const playerId of [8380, 8381, 8382]) {
|
||
await expect(
|
||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 100)
|
||
const gifts = await getPendingGifts(env.DB, playerId)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({ Currency: 100, Message: 'Thanks for playing!' })
|
||
// The same two frames the one-player gift sends, in the same order: the resulting
|
||
// total into the -2 bucket, then the box it came in — every player's own.
|
||
const own = frames.filter((f) => f.playerId === playerId)
|
||
expect(own.map((f) => f.notificationType)).toEqual([61, 31])
|
||
expect(own[0].data).toEqual({
|
||
Balance: DEFAULT_STARTING_TOKENS + 100,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
Platform: -2,
|
||
})
|
||
expect(own[1].data).toMatchObject({
|
||
Id: gifts[0]!.Id,
|
||
Currency: 100,
|
||
Message: 'Thanks for playing!',
|
||
BalanceType: -2,
|
||
})
|
||
}
|
||
expect(frames.filter((f) => f.playerId === 8383)).toEqual([])
|
||
expect(await getPendingGifts(env.DB, 8383)).toEqual([])
|
||
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT player_id, data FROM audit_log WHERE action = 'gift_tokens_online'`
|
||
).all<{ player_id: number; data: string }>()
|
||
expect(results).toHaveLength(1)
|
||
expect(results[0].player_id).toBe(8110)
|
||
expect(JSON.parse(results[0].data)).toMatchObject({ amount: 100, message: 'Thanks for playing!' })
|
||
})
|
||
|
||
// The drop is narrower than the other gifts: positive only, capped at 1,000 each whatever
|
||
// MAX_TOKEN_GIFT allows, and the box needs a message — that is what the players see of it.
|
||
it('refuses a token drop over the cap, without a message, or with nobody online', async () => {
|
||
const drop = (body: unknown) => devPost('/api/staff/online/gift-tokens', 8110, body)
|
||
expect((await drop({ amount: 1_001, message: 'hi' })).status).toBe(400)
|
||
expect((await drop({ amount: 0, message: 'hi' })).status).toBe(400)
|
||
expect((await drop({ amount: -5, message: 'hi' })).status).toBe(400)
|
||
expect((await drop({ amount: 10, message: ' ' })).status).toBe(400)
|
||
expect((await drop({ amount: 10 })).status).toBe(400)
|
||
// An empty server is a 404 rather than a silent success, as an empty room is.
|
||
await env.DB.prepare('DELETE FROM presence').run()
|
||
expect((await drop({ amount: 1_000, message: 'hi' })).status).toBe(404)
|
||
})
|
||
|
||
// The drop pays in rounds of 30 (three bound values a player, 100 a statement) and announces
|
||
// the lot in one hub call. More players than one round holds, with a balance row already
|
||
// present for some and none for the rest: everyone is paid once, in presence order, each
|
||
// box has its own id, and the hub gets exactly two frames a player — nobody doubled by a
|
||
// round boundary and nobody dropped past it.
|
||
it('drops tokens on more players than one round pays, each exactly once', async () => {
|
||
await env.DB.prepare('DELETE FROM presence').run()
|
||
const players = Array.from({ length: 70 }, (_, i) => 8600 + i)
|
||
for (const accountId of players) {
|
||
await setPresence(env.DB, {
|
||
accountId,
|
||
roomInstance: null,
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
}
|
||
// Half already hold a balance row (one touched by a prior read); the rest get the signup
|
||
// grant seeded by the drop itself. Both must land on grant + drop.
|
||
for (const accountId of players.filter((id) => id % 2 === 0)) {
|
||
await getBalance(env.DB, accountId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
}
|
||
|
||
await clearHub()
|
||
const res = await devPost('/api/staff/online/gift-tokens', 8110, {
|
||
amount: 7,
|
||
message: 'Round and round',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { paid: number[] }
|
||
expect(body.paid).toEqual(players)
|
||
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT account_id AS accountId, amount FROM balance
|
||
WHERE currency_type = ?1 AND account_id BETWEEN 8600 AND 8669 ORDER BY account_id`
|
||
)
|
||
.bind(CurrencyType.RecCenterTokens)
|
||
.all<{ accountId: number; amount: number }>()
|
||
expect(results.map((r) => r.accountId)).toEqual(players)
|
||
expect(new Set(results.map((r) => r.amount))).toEqual(new Set([DEFAULT_STARTING_TOKENS + 7]))
|
||
|
||
const frames = await hubFrames()
|
||
expect(frames).toHaveLength(players.length * 2)
|
||
const boxIds = new Set<number>()
|
||
for (const playerId of players) {
|
||
const own = frames.filter((f) => f.playerId === playerId)
|
||
expect(own.map((f) => f.notificationType)).toEqual([61, 31])
|
||
expect(own[0].data.Balance).toBe(DEFAULT_STARTING_TOKENS + 7)
|
||
const gifts = await getPendingGifts(env.DB, playerId)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(own[1].data.Id).toBe(gifts[0]!.Id)
|
||
boxIds.add(gifts[0]!.Id)
|
||
}
|
||
expect(boxIds.size).toBe(players.length)
|
||
})
|
||
|
||
// A negative room gift is a debit guarded per player: whoever can't cover it is skipped —
|
||
// no debit, no box, no frame — and the ones who can are still charged.
|
||
it('skips the players a room debit would overdraw, and charges the rest', async () => {
|
||
const inRoom = async (accountId: number) =>
|
||
setPresence(env.DB, {
|
||
accountId,
|
||
roomInstance: { roomId: 7730, roomInstanceId: 77301 },
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
await inRoom(8480)
|
||
await inRoom(8481)
|
||
await inRoom(8482)
|
||
// 8481 has already spent most of their grant.
|
||
await ensureStartingBalances(env.DB, 8481, DEFAULT_STARTING_TOKENS)
|
||
expect(
|
||
await spendCurrency(
|
||
env.DB,
|
||
8481,
|
||
CurrencyType.RecCenterTokens,
|
||
DEFAULT_STARTING_TOKENS - 10,
|
||
DEFAULT_STARTING_TOKENS
|
||
)
|
||
).toBe(true)
|
||
|
||
await clearHub()
|
||
const res = await devPost('/api/staff/rooms/7730/gift-tokens', 8110, { amount: -100 })
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({
|
||
roomId: 7730,
|
||
amount: -100,
|
||
paid: [8480, 8482],
|
||
skipped: [8481],
|
||
})
|
||
for (const playerId of [8480, 8482]) {
|
||
await expect(
|
||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS - 100)
|
||
expect(await getPendingGifts(env.DB, playerId)).toHaveLength(1)
|
||
}
|
||
await expect(
|
||
getBalance(env.DB, 8481, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(10)
|
||
expect(await getPendingGifts(env.DB, 8481)).toEqual([])
|
||
const frames = await hubFrames()
|
||
expect(frames.map((f) => f.playerId)).toEqual([8480, 8480, 8482, 8482])
|
||
})
|
||
|
||
// The role drop's audience is the Discord links that record the role — offline included,
|
||
// since that is the point — and nobody else: a link holding other roles, a link holding none,
|
||
// a player with no link at all. The same Discord identity on two accounts pays both.
|
||
it('drops tokens on every account whose Discord link holds the role, online or not', async () => {
|
||
const ROLE = '1077000000000000777'
|
||
const OTHER = '1077000000000000778'
|
||
await linkPlatformIdentity(env.DB, 8390, PlatformType.Discord, '900000000000008390', [ROLE])
|
||
await linkPlatformIdentity(env.DB, 8391, PlatformType.Discord, '900000000000008391', [
|
||
OTHER,
|
||
ROLE,
|
||
])
|
||
await linkPlatformIdentity(env.DB, 8392, PlatformType.Discord, '900000000000008392', [OTHER])
|
||
await linkPlatformIdentity(env.DB, 8393, PlatformType.Discord, '900000000000008393', [])
|
||
// One Discord user, two accounts: both links hold the role.
|
||
await linkPlatformIdentity(env.DB, 8394, PlatformType.Discord, '900000000000008394', [ROLE])
|
||
await linkPlatformIdentity(env.DB, 8395, PlatformType.Discord, '900000000000008394', [ROLE])
|
||
// Only one of them is online; the rest are paid anyway.
|
||
await env.DB.prepare('DELETE FROM presence').run()
|
||
await setPresence(env.DB, {
|
||
accountId: 8390,
|
||
roomInstance: null,
|
||
statusVisibility: 0,
|
||
deviceClass: 0,
|
||
vrMovementMode: 0,
|
||
platform: 4,
|
||
appVersion: 'test',
|
||
})
|
||
|
||
const res = await devPost(`/api/staff/discord-roles/${ROLE}/gift-tokens`, 8110, {
|
||
amount: 250,
|
||
message: 'Thanks for supporting the server!',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as {
|
||
roleId: string
|
||
amount: number
|
||
message: string
|
||
paid: number[]
|
||
}
|
||
expect(body.roleId).toBe(ROLE)
|
||
expect(body.amount).toBe(250)
|
||
expect(body.message).toBe('Thanks for supporting the server!')
|
||
expect([...body.paid].sort((a, b) => a - b)).toEqual([8390, 8391, 8394, 8395])
|
||
|
||
for (const playerId of [8390, 8391, 8394, 8395]) {
|
||
await expect(
|
||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 250)
|
||
const gifts = await getPendingGifts(env.DB, playerId)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
Currency: 250,
|
||
CurrencyType: CurrencyType.RecCenterTokens,
|
||
AvatarItemType: null,
|
||
Message: 'Thanks for supporting the server!',
|
||
})
|
||
}
|
||
for (const playerId of [8392, 8393]) {
|
||
expect(await getPendingGifts(env.DB, playerId)).toEqual([])
|
||
await expect(
|
||
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).resolves.toBe(DEFAULT_STARTING_TOKENS)
|
||
}
|
||
|
||
const { results } = await env.DB.prepare(
|
||
`SELECT player_id, data FROM audit_log WHERE action = 'gift_tokens_role'`
|
||
).all<{ player_id: number; data: string }>()
|
||
expect(results).toHaveLength(1)
|
||
expect(results[0].player_id).toBe(8110)
|
||
expect(JSON.parse(results[0].data)).toMatchObject({
|
||
roleId: ROLE,
|
||
amount: 250,
|
||
message: 'Thanks for supporting the server!',
|
||
})
|
||
})
|
||
|
||
// Same bounds as the online drop but with a higher cap (10,000), plus the role has to be a
|
||
// snowflake, and a role nobody's link records is a 404 rather than a silent success.
|
||
it('refuses a role drop over the cap, without a message, with a bad role, or with no holders', async () => {
|
||
const drop = (role: string, body: unknown) =>
|
||
devPost(`/api/staff/discord-roles/${role}/gift-tokens`, 8110, body)
|
||
await linkPlatformIdentity(env.DB, 8396, PlatformType.Discord, '900000000000008396', [
|
||
'1077000000000000779',
|
||
])
|
||
expect((await drop('1077000000000000779', { amount: 10_001, message: 'hi' })).status).toBe(400)
|
||
expect((await drop('1077000000000000779', { amount: 0, message: 'hi' })).status).toBe(400)
|
||
expect((await drop('1077000000000000779', { amount: -5, message: 'hi' })).status).toBe(400)
|
||
expect((await drop('1077000000000000779', { amount: 10, message: ' ' })).status).toBe(400)
|
||
expect((await drop('1077000000000000779', { amount: 10 })).status).toBe(400)
|
||
expect((await drop('supporters', { amount: 10, message: 'hi' })).status).toBe(400)
|
||
expect((await drop('1077000000000000780', { amount: 10, message: 'hi' })).status).toBe(404)
|
||
// Nothing above paid anyone.
|
||
expect(await getPendingGifts(env.DB, 8396)).toEqual([])
|
||
})
|
||
|
||
// Zero is a real gift here: nothing moves, and the player gets an empty box.
|
||
it('sends an empty box on a zero token gift', async () => {
|
||
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
||
const res = await devPost('/api/staff/players/8361/gift-tokens', 8110, { amount: 0 })
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { balance: number; giftId: number }
|
||
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS)
|
||
|
||
const gifts = await getPendingGifts(env.DB, 8361)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({ Id: body.giftId, Currency: 0 })
|
||
expect(await auditRows('gift_tokens', 8361)).toMatchObject([{ data: { amount: 0 } }])
|
||
})
|
||
|
||
// An account that has never renamed itself stores no count and is read as the default 3, so
|
||
// the first grant makes it 4 rather than 1.
|
||
it('adds a username change on top of the default', async () => {
|
||
await updateAccount(env.DB, 8310, { username: 'Renamer' })
|
||
|
||
let res = await staffPost('/api/staff/players/8310/username-changes', 8110, {})
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ playerId: 8310, availableUsernameChanges: 4 })
|
||
|
||
await updateAccount(env.DB, 8310, { availableUsernameChanges: 0 })
|
||
res = await staffPost('/api/staff/players/8310/username-changes', 8110, {})
|
||
expect(await res.json()).toEqual({ playerId: 8310, availableUsernameChanges: 1 })
|
||
|
||
expect((await auditRows('add_username_change', 8310)).map((r) => r.data)).toEqual([
|
||
{ playerId: 8310, availableUsernameChanges: 4 },
|
||
{ playerId: 8310, availableUsernameChanges: 1 },
|
||
])
|
||
expect((await staffPost('/api/staff/players/8398/username-changes', 8110, {})).status).toBe(404)
|
||
})
|
||
|
||
it('clears a player’s password so they can set a new one in game', async () => {
|
||
await updateAccount(env.DB, 8320, { username: 'Forgetful', passwordHash: 'salt:hash' })
|
||
|
||
let res = await staffPost('/api/staff/players/8320/clear-password', 8110, {})
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ playerId: 8320, hadPassword: true })
|
||
const row = await env.DB.prepare(
|
||
"SELECT json_extract(data, '$.passwordHash') AS hash, json_extract(data, '$.username') AS username FROM account WHERE account_id = 8320"
|
||
).first<{ hash: string | null; username: string }>()
|
||
// Only the hash goes: the rest of the account is untouched.
|
||
expect(row).toEqual({ hash: null, username: 'Forgetful' })
|
||
|
||
// Clearing it again is harmless, and says there was nothing to clear.
|
||
res = await staffPost('/api/staff/players/8320/clear-password', 8110, {})
|
||
expect(await res.json()).toEqual({ playerId: 8320, hadPassword: false })
|
||
|
||
expect(await auditRows('clear_password', 8320)).toEqual([
|
||
{ actor: 8110, data: { playerId: 8320, hadPassword: true } },
|
||
{ actor: 8110, data: { playerId: 8320, hadPassword: false } },
|
||
])
|
||
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
|
||
})
|
||
|
||
it('clears a player’s dorm by publishing an empty save over the newest one', async () => {
|
||
await updateAccount(env.DB, 8340, { username: 'Timetraveler' })
|
||
expect((await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})).status).toBe(404)
|
||
|
||
const dorm = await getOrCreateDormRoom(env.DB, 8340)
|
||
const roomId = Number(dorm.RoomId)
|
||
const { sub_room_id: subRoomId } = (await env.DB.prepare(
|
||
'SELECT sub_room_id FROM subroom WHERE room_id = ?1 LIMIT 1'
|
||
)
|
||
.bind(roomId)
|
||
.first<{ sub_room_id: number }>())!
|
||
|
||
// A dorm that has never been saved: nothing to clear, still a success.
|
||
let res = await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})
|
||
expect(await res.json()).toEqual({ playerId: 8340, roomId, previousSaveId: null, saveId: null })
|
||
|
||
const insertSave = (blob: string) =>
|
||
env.DB.prepare(
|
||
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id AS id'
|
||
)
|
||
.bind(
|
||
subRoomId,
|
||
JSON.stringify({
|
||
DataBlob: blob,
|
||
DataBlobHash: 'hash',
|
||
Description: blob,
|
||
PersistenceVersion: 7,
|
||
CreatedAt: '2025-08-01T00:00:00.000Z',
|
||
})
|
||
)
|
||
.first<{ id: number }>()
|
||
const older = (await insertSave('old.blob'))!.id
|
||
const newer = (await insertSave('new.blob'))!.id
|
||
await env.DB.prepare(
|
||
'UPDATE subroom SET current_save_id = ?2, staged_save_id = ?2 WHERE sub_room_id = ?1'
|
||
)
|
||
.bind(subRoomId, newer)
|
||
.run()
|
||
const saveData = async (id: number) =>
|
||
JSON.parse(
|
||
(await env.DB.prepare('SELECT data FROM subroom_save WHERE sub_room_data_save_id = ?1')
|
||
.bind(id)
|
||
.first<{ data: string }>())!.data
|
||
)
|
||
const pointers = () =>
|
||
env.DB.prepare('SELECT current_save_id, staged_save_id FROM subroom WHERE sub_room_id = ?1')
|
||
.bind(subRoomId)
|
||
.first<{ current_save_id: number; staged_save_id: number | null }>()
|
||
|
||
res = await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})
|
||
expect(res.status).toBe(200)
|
||
const { saveId } = (await res.json()) as { saveId: number }
|
||
expect(saveId).toBeGreaterThan(newer)
|
||
|
||
// The blank save copies the newest one but for the blob, and goes live.
|
||
const blank = await saveData(saveId)
|
||
expect(blank).toEqual({
|
||
DataBlob: '',
|
||
DataBlobHash: null,
|
||
Description: 'Cleared by staff',
|
||
PersistenceVersion: 7,
|
||
CreatedAt: expect.any(String),
|
||
})
|
||
expect(blank.CreatedAt).not.toBe('2025-08-01T00:00:00.000Z')
|
||
expect(await pointers()).toEqual({ current_save_id: saveId, staged_save_id: null })
|
||
// Nothing is erased: both earlier saves are as they were.
|
||
expect((await saveData(newer)).DataBlob).toBe('new.blob')
|
||
expect((await saveData(older)).DataBlob).toBe('old.blob')
|
||
|
||
// Clearing an already-empty dorm writes nothing more.
|
||
res = await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})
|
||
expect(await res.json()).toEqual({ playerId: 8340, roomId, previousSaveId: saveId, saveId: null })
|
||
expect(await pointers()).toEqual({ current_save_id: saveId, staged_save_id: null })
|
||
|
||
expect((await auditRows('clear_dorm', 8340)).map((r) => r.data)).toEqual([
|
||
{ playerId: 8340, roomId, previousSaveId: null, saveId: null },
|
||
{ playerId: 8340, roomId, previousSaveId: newer, saveId },
|
||
{ playerId: 8340, roomId, previousSaveId: saveId, saveId: null },
|
||
])
|
||
})
|
||
|
||
it('grants a player Plus, developers only', async () => {
|
||
await updateAccount(env.DB, 8330, { username: 'Plusless' })
|
||
const hasPlus = async () =>
|
||
await env.DB.prepare(
|
||
"SELECT json_extract(data, '$.hasPlus') AS plus, json_extract(data, '$.username') AS username FROM account WHERE account_id = 8330"
|
||
).first<{ plus: number | null; username: string }>()
|
||
|
||
// A moderator is staff but not a developer: refused, and nothing is written.
|
||
expect((await staffPost('/api/staff/players/8330/grant-plus', 8101, {})).status).toBe(403)
|
||
expect(await hasPlus()).toEqual({ plus: null, username: 'Plusless' })
|
||
|
||
let res = await devPost('/api/staff/players/8330/grant-plus', 8110, {})
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ playerId: 8330, hasPlus: true, hadPlus: false })
|
||
// Only the flag is set: the rest of the account is untouched.
|
||
expect(await hasPlus()).toEqual({ plus: 1, username: 'Plusless' })
|
||
|
||
// Granting it again is harmless, and says they already had it.
|
||
res = await devPost('/api/staff/players/8330/grant-plus', 8110, {})
|
||
expect(await res.json()).toEqual({ playerId: 8330, hasPlus: true, hadPlus: true })
|
||
|
||
expect(await auditRows('grant_plus', 8330)).toEqual([
|
||
{ actor: 8110, data: { playerId: 8330, hadPlus: false } },
|
||
{ actor: 8110, data: { playerId: 8330, hadPlus: true } },
|
||
])
|
||
// An unknown player is a 404, and no account is conjured for them.
|
||
expect((await devPost('/api/staff/players/8396/grant-plus', 8110, {})).status).toBe(404)
|
||
expect(
|
||
await env.DB.prepare('SELECT 1 AS hit FROM account WHERE account_id = 8396').first()
|
||
).toBeNull()
|
||
})
|
||
|
||
/** A custom avatar item made by `creatorAccountId`; published unless `accessibility` says. */
|
||
const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) =>
|
||
createCustomAvatarItem(env.DB, {
|
||
customAvatarItemId: id,
|
||
creatorAccountId,
|
||
name: `Shirt ${id.slice(0, 4)}`,
|
||
description: '',
|
||
price: 300,
|
||
baseAvatarItemId: 1,
|
||
baseAvatarItemColor: '',
|
||
accessibility,
|
||
designFilename: 'design.png',
|
||
thumbnailImageFilename: 'thumb.png',
|
||
})
|
||
|
||
/** A catalog row — a skin (with `prefabName`), a consumable or an avatar item — as `runx catalog load` writes one. */
|
||
const seedCatalogRow = (
|
||
itemKey: string,
|
||
catalogId: number,
|
||
kind: 'skin' | 'consumable' | 'avatar_item',
|
||
friendlyName: string,
|
||
prefabName: string | null = null
|
||
) =>
|
||
env.DB.prepare(
|
||
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name)
|
||
VALUES (?1, ?2, ?3, ?4, NULL, 3, -1, ?5)`
|
||
)
|
||
.bind(itemKey, catalogId, kind, friendlyName, prefabName)
|
||
.run()
|
||
|
||
/** Every frame the hub was handed, after clearing it. */
|
||
const hubFrames = async () =>
|
||
(await (
|
||
await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/all')
|
||
).json()) as Array<{
|
||
playerId: number
|
||
notificationType: number
|
||
data: Record<string, unknown>
|
||
}>
|
||
const clearHub = () =>
|
||
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/all', { method: 'DELETE' })
|
||
|
||
/** The one item gift, by whichever id. */
|
||
const giftItem = (playerId: number, itemId: unknown, message?: string) =>
|
||
devPost(`/api/staff/players/${playerId}/gift-item`, 8110, { itemId, message })
|
||
|
||
// Delivered as a purchase delivers one — owned, and boxed by `CustomAvatarItemId` — but nobody
|
||
// is charged and the creator is not paid: a grant, not a sale.
|
||
it('gifts a player a custom item in a gift box', async () => {
|
||
await clearHub()
|
||
await updateAccount(env.DB, 8330, { username: 'Wearer' })
|
||
const item = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000001', 8331)
|
||
|
||
// Upper-cased on purpose: a GUID's case is not part of its identity.
|
||
const res = await giftItem(8330, item.CustomAvatarItemId.toUpperCase())
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { kind: string; giftId: number; name: string }
|
||
expect(body).toMatchObject({ kind: 'custom_item', name: item.Name })
|
||
|
||
await expect(ownedCustomAvatarItemIds(env.DB, 8330, [item.CustomAvatarItemId])).resolves.toEqual(
|
||
new Set([item.CustomAvatarItemId])
|
||
)
|
||
const gifts = await getPendingGifts(env.DB, 8330)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
Id: body.giftId,
|
||
FromPlayerId: 1,
|
||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||
AvatarItemDesc: '',
|
||
// As econ boxes a bought custom item — an avatar item, so not the null the others carry.
|
||
AvatarItemType: 0,
|
||
Currency: 0,
|
||
Message: 'A gift from the staff!',
|
||
// Purchased_Gift_A: one of the contexts the client's box classifier tests. Default (0)
|
||
// is not, and a box it can't classify renders as "cannot display".
|
||
GiftContext: 500,
|
||
})
|
||
|
||
// Only the box is announced: no balance moved, the creator's included.
|
||
const frames = await hubFrames()
|
||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8330, 31]])
|
||
expect(frames[0].data).toMatchObject({
|
||
Id: body.giftId,
|
||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||
GiftContext: 500,
|
||
})
|
||
|
||
expect(await auditRows('gift_custom_item', 8330)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: {
|
||
playerId: 8330,
|
||
customAvatarItemId: item.CustomAvatarItemId,
|
||
name: item.Name,
|
||
message: 'A gift from the staff!',
|
||
giftId: body.giftId,
|
||
},
|
||
},
|
||
])
|
||
})
|
||
|
||
it('refuses a custom item gift the store would refuse', async () => {
|
||
await updateAccount(env.DB, 8340, { username: 'Picky' })
|
||
const published = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000002', 8341)
|
||
const draft = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000003', 8341, 0)
|
||
const own = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000004', 8340)
|
||
await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId)
|
||
|
||
expect((await giftItem(8340, '')).status).toBe(400)
|
||
expect((await giftItem(8340, 7)).status).toBe(400)
|
||
expect((await giftItem(8340, published.CustomAvatarItemId, 'x'.repeat(257))).status).toBe(400)
|
||
expect((await giftItem(8340, 'no-such-item')).status).toBe(404)
|
||
// A draft is visible to its creator alone; handing it out would publish it for them.
|
||
expect((await giftItem(8340, draft.CustomAvatarItemId)).status).toBe(404)
|
||
expect((await giftItem(8340, own.CustomAvatarItemId)).status).toBe(409)
|
||
expect((await giftItem(8340, published.CustomAvatarItemId)).status).toBe(409)
|
||
expect((await giftItem(8396, published.CustomAvatarItemId)).status).toBe(404)
|
||
|
||
expect(await getPendingGifts(env.DB, 8340)).toEqual([])
|
||
expect(await auditRows('gift_custom_item', 8340)).toEqual([])
|
||
})
|
||
|
||
// Delivered as a purchase delivers one — an `equipment` row, and a box keyed by the prefab and
|
||
// guid with `AvatarItemType` NULL (a 0 sends the client after an avatar item that isn't there).
|
||
it('gifts a player a skin in a gift box', async () => {
|
||
await clearHub()
|
||
await updateAccount(env.DB, 8420, { username: 'Armed' })
|
||
await seedCatalogRow('0dM2SfqGR0SmtO5ufTWfUQ', 91001, 'skin', 'Bow Skin (Dryad)', '[Bow]')
|
||
await seedCatalogRow(
|
||
'a1b2c3d4-0000-4000-8000-000000000001',
|
||
91002,
|
||
'skin',
|
||
'Pen Skin',
|
||
'[MakerPen]'
|
||
)
|
||
|
||
// By key, as the export spells it — the short ids are case-sensitive, so nothing is folded.
|
||
let res = await giftItem(8420, '0dM2SfqGR0SmtO5ufTWfUQ', 'Nice shot')
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as {
|
||
kind: string
|
||
giftId: number
|
||
name: string
|
||
prefabName: string
|
||
}
|
||
expect(body).toMatchObject({ kind: 'skin', name: 'Bow Skin (Dryad)', prefabName: '[Bow]' })
|
||
|
||
expect(await getEquipment(env.DB, 8420)).toEqual([
|
||
{
|
||
ModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||
PrefabName: '[Bow]',
|
||
FriendlyName: 'Bow Skin (Dryad)',
|
||
Tooltip: '',
|
||
Rarity: 3,
|
||
PlatformMask: -1,
|
||
Favorited: false,
|
||
},
|
||
])
|
||
const gifts = await getPendingGifts(env.DB, 8420)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
Id: body.giftId,
|
||
FromPlayerId: 1,
|
||
EquipmentPrefabName: '[Bow]',
|
||
EquipmentModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||
AvatarItemType: null,
|
||
AvatarItemDesc: '',
|
||
GiftRarity: 3,
|
||
GiftContext: 500,
|
||
Message: 'Nice shot',
|
||
})
|
||
const frames = await hubFrames()
|
||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8420, 31]])
|
||
expect(frames[0].data).toMatchObject({
|
||
Id: body.giftId,
|
||
EquipmentModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||
AvatarItemType: null,
|
||
GiftContext: 500,
|
||
})
|
||
expect(await auditRows('gift_skin', 8420)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: {
|
||
playerId: 8420,
|
||
modificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||
prefabName: '[Bow]',
|
||
name: 'Bow Skin (Dryad)',
|
||
message: 'Nice shot',
|
||
giftId: body.giftId,
|
||
},
|
||
},
|
||
])
|
||
|
||
// Or by the store's numeric id.
|
||
res = await giftItem(8420, '91002')
|
||
expect(res.status).toBe(200)
|
||
expect((await getEquipment(env.DB, 8420)).map((eq) => eq.ModificationGuid)).toEqual([
|
||
'0dM2SfqGR0SmtO5ufTWfUQ',
|
||
'a1b2c3d4-0000-4000-8000-000000000001',
|
||
])
|
||
|
||
// Owning a skin is boolean: a second gift would be a box with nothing new in it.
|
||
expect((await giftItem(8420, 'skin-owned-8420', 'x'.repeat(257))).status).toBe(400)
|
||
expect((await giftItem(8420, '0dM2SfqGR0SmtO5ufTWfUQ')).status).toBe(409)
|
||
expect((await giftItem(8420, '99999999')).status).toBe(404)
|
||
expect((await giftItem(8394, '0dM2SfqGR0SmtO5ufTWfUQ')).status).toBe(404)
|
||
expect(await getEquipment(env.DB, 8420)).toHaveLength(2)
|
||
expect(await getPendingGifts(env.DB, 8420)).toHaveLength(2)
|
||
})
|
||
|
||
// A consumable stacks: each gift is a fresh row, and the box carries that row's id and the
|
||
// count the player had before it, which is what opening the box reports.
|
||
it('gifts a player a consumable in a gift box, and again', async () => {
|
||
await clearHub()
|
||
await updateAccount(env.DB, 8430, { username: 'Hungry' })
|
||
await seedCatalogRow('0DsHcPhR_Eybn53UxKJhJw', 91010, 'consumable', 'Supreme Pizza')
|
||
|
||
let res = await giftItem(8430, '0DsHcPhR_Eybn53UxKJhJw')
|
||
expect(res.status).toBe(200)
|
||
const first = (await res.json()) as {
|
||
kind: string
|
||
giftId: number
|
||
name: string
|
||
count: number
|
||
owned: number
|
||
}
|
||
expect(first).toMatchObject({ kind: 'consumable', name: 'Supreme Pizza', count: 1, owned: 1 })
|
||
|
||
let owned = await getConsumables(env.DB, 8430)
|
||
expect(owned).toHaveLength(1)
|
||
expect(owned[0]).toMatchObject({ ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw', Count: 1 })
|
||
let gifts = await getPendingGifts(env.DB, 8430)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
Id: first.giftId,
|
||
FromPlayerId: 1,
|
||
ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw',
|
||
ConsumableCount: 1,
|
||
ConsumableMappingId: owned[0].Ids[0],
|
||
ConsumablePreExistingCount: 0,
|
||
AvatarItemType: null,
|
||
EquipmentModificationGuid: '',
|
||
GiftRarity: 3,
|
||
GiftContext: 500,
|
||
Message: 'A gift from the staff!',
|
||
})
|
||
const frames = await hubFrames()
|
||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8430, 31]])
|
||
expect(frames[0].data).toMatchObject({
|
||
Id: first.giftId,
|
||
ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw',
|
||
AvatarItemType: null,
|
||
})
|
||
expect(await auditRows('gift_consumable', 8430)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: {
|
||
playerId: 8430,
|
||
consumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw',
|
||
name: 'Supreme Pizza',
|
||
count: 1,
|
||
message: 'A gift from the staff!',
|
||
giftId: first.giftId,
|
||
},
|
||
},
|
||
])
|
||
|
||
// Again, by the store's id this time: a second instance, and the box knows they had one.
|
||
res = await giftItem(8430, '91010', 'Seconds?')
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toMatchObject({ kind: 'consumable', owned: 2 })
|
||
owned = await getConsumables(env.DB, 8430)
|
||
expect(owned).toHaveLength(1)
|
||
expect(owned[0]).toMatchObject({ Count: 2 })
|
||
expect(owned[0].Ids).toHaveLength(2)
|
||
gifts = await getPendingGifts(env.DB, 8430)
|
||
expect(gifts).toHaveLength(2)
|
||
expect(gifts.map((g) => g.ConsumablePreExistingCount ?? -1).sort((a, b) => a - b)).toEqual([0, 1])
|
||
expect(gifts.map((g) => g.Message).sort()).toEqual(['A gift from the staff!', 'Seconds?'])
|
||
|
||
expect((await giftItem(8430, '0DsHcPhR_Eybn53UxKJhJw', 'x'.repeat(257))).status).toBe(400)
|
||
expect((await giftItem(8393, '0DsHcPhR_Eybn53UxKJhJw')).status).toBe(404)
|
||
expect(await getConsumables(env.DB, 8430)).toEqual(owned)
|
||
})
|
||
|
||
// One id field, so the id decides the kind — and a UUID could be anything, so BOTH tables are
|
||
// asked. A baked avatar item is a real row nothing here can grant, and an id in both tables
|
||
// is nobody's to pick between.
|
||
it('refuses an item gift for a baked avatar item, or an id that names two things', async () => {
|
||
await updateAccount(env.DB, 8440, { username: 'Ambiguous' })
|
||
await seedCatalogRow('Hat,0,0,', 91020, 'avatar_item', 'A Hat')
|
||
const twin = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000005', 8441)
|
||
await seedCatalogRow(twin.CustomAvatarItemId, 91021, 'skin', 'Twin Skin', '[Bow]')
|
||
|
||
let res = await giftItem(8440, 'Hat,0,0,')
|
||
expect(res.status).toBe(400)
|
||
expect(((await res.json()) as { error: string }).error).toMatch(/baked avatar item/)
|
||
res = await giftItem(8440, '91020')
|
||
expect(res.status).toBe(400)
|
||
|
||
res = await giftItem(8440, twin.CustomAvatarItemId)
|
||
expect(res.status).toBe(409)
|
||
expect(((await res.json()) as { error: string }).error).toMatch(/both/)
|
||
// The catalog id names only the skin, so that way round it goes through.
|
||
res = await giftItem(8440, '91021')
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toMatchObject({ kind: 'skin', name: 'Twin Skin' })
|
||
|
||
expect(await getPendingGifts(env.DB, 8440)).toHaveLength(1)
|
||
await expect(ownedCustomAvatarItemIds(env.DB, 8440, [twin.CustomAvatarItemId])).resolves.toEqual(
|
||
new Set()
|
||
)
|
||
})
|
||
|
||
// Banked and levelled like a game reward's XP: 25 takes a fresh player from level 1 through
|
||
// 2 (10) and 3 (10) with 5 into level 3. The bar moves, and the box shows what arrived.
|
||
it('gifts a player XP in a gift box', async () => {
|
||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||
await updateAccount(env.DB, 8350, { username: 'Climber' })
|
||
|
||
const res = await devPost('/api/staff/players/8350/gift-xp', 8110, {
|
||
amount: 25,
|
||
message: 'Keep climbing!',
|
||
})
|
||
expect(res.status).toBe(200)
|
||
const body = (await res.json()) as { giftId: number }
|
||
expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 })
|
||
await expect(getProgression(env.DB, 8350)).resolves.toEqual({ PlayerId: 8350, Level: 3, XP: 5 })
|
||
|
||
const gifts = await getPendingGifts(env.DB, 8350)
|
||
expect(gifts).toHaveLength(1)
|
||
expect(gifts[0]).toMatchObject({
|
||
Id: body.giftId,
|
||
FromPlayerId: 1,
|
||
Xp: 25,
|
||
Currency: 0,
|
||
AvatarItemType: null,
|
||
GiftContext: 500,
|
||
Message: 'Keep climbing!',
|
||
})
|
||
|
||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||
playerId: number
|
||
notificationType: number | string
|
||
data: Record<string, unknown>
|
||
}>
|
||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([
|
||
[8350, 'PlayerProgressionLevelUpdate'],
|
||
[8350, 31],
|
||
])
|
||
expect(frames[0].data).toEqual({ PlayerId: 8350, Level: 3, XP: 5 })
|
||
expect(frames[1].data).toMatchObject({
|
||
Id: body.giftId,
|
||
Xp: 25,
|
||
GiftContext: 500,
|
||
Message: 'Keep climbing!',
|
||
})
|
||
|
||
expect(await auditRows('gift_xp', 8350)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: {
|
||
playerId: 8350,
|
||
amount: 25,
|
||
message: 'Keep climbing!',
|
||
level: 3,
|
||
levelsGained: 2,
|
||
giftId: body.giftId,
|
||
},
|
||
},
|
||
])
|
||
})
|
||
|
||
it('refuses an XP gift that is not a positive whole number, too large, or to nobody', async () => {
|
||
await updateAccount(env.DB, 8351, { username: 'Stuck' })
|
||
for (const amount of [0, -5, 1.5, 'lots', 101]) {
|
||
expect((await devPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400)
|
||
}
|
||
expect(
|
||
(
|
||
await devPost('/api/staff/players/8351/gift-xp', 8110, {
|
||
amount: 5,
|
||
message: 'x'.repeat(257),
|
||
})
|
||
).status
|
||
).toBe(400)
|
||
expect((await devPost('/api/staff/players/8395/gift-xp', 8110, { amount: 5 })).status).toBe(404)
|
||
await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 })
|
||
expect(await auditRows('gift_xp', 8351)).toEqual([])
|
||
})
|
||
|
||
// ---- Discord role sweep ------------------------------------------------------
|
||
|
||
// The daily cron behind `platform_account.role`: with a bot token configured it re-reads
|
||
// every Discord link's roles and writes them back. Driven through `refreshDiscordRoles`
|
||
// with the Discord read replaced, because the real one calls discord.com; the HTTP
|
||
// decoding is pinned separately below with a fake fetch.
|
||
|
||
const SWEEP_GUILD = '1077000000000000000'
|
||
const ROLE_A = '1077000000000000001'
|
||
const ROLE_B = '1077000000000000002'
|
||
|
||
/** A configured sweep env over the test bindings. */
|
||
const sweepEnv = (overrides: Partial<Env> = {}): Env =>
|
||
({
|
||
...env,
|
||
DISCORD_GUILD_ID: SWEEP_GUILD,
|
||
DISCORD_BOT_TOKEN: { get: async () => 'test-bot-token' } as SecretsStoreSecret,
|
||
...overrides,
|
||
}) as Env
|
||
|
||
/** A `fetchMember` that answers per Discord user id and records what it was asked. */
|
||
const memberTable = (answers: Record<string, MemberRead>) => {
|
||
const asked: string[] = []
|
||
const fetchMember = async (token: string, guild: string, userId: string): Promise<MemberRead> => {
|
||
expect(token).toBe('test-bot-token')
|
||
expect(guild).toBe(SWEEP_GUILD)
|
||
asked.push(userId)
|
||
return answers[userId] ?? { kind: 'error', status: 500 }
|
||
}
|
||
return { asked, fetchMember }
|
||
}
|
||
|
||
/** The one Discord link on an account, as the sweep left it. */
|
||
const discordLink = async (accountId: number) =>
|
||
(await getLinksForAccount(env.DB, accountId)).find((l) => l.platform === PlatformType.Discord)!
|
||
|
||
/**
|
||
* The sweep reads EVERY Discord link, and the table is shared across this file, so each
|
||
* test starts from only its own rows.
|
||
*/
|
||
const onlyDiscordLinks = async (
|
||
links: Array<[accountId: number, userId: string, roles: string[]]>
|
||
) => {
|
||
await env.DB.prepare('DELETE FROM platform_account WHERE platform = ?1')
|
||
.bind(PlatformType.Discord)
|
||
.run()
|
||
for (const [accountId, userId, roles] of links) {
|
||
await linkPlatformIdentity(env.DB, accountId, PlatformType.Discord, userId, roles)
|
||
}
|
||
}
|
||
|
||
it('does nothing until a guild and a bot token are configured', async () => {
|
||
await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]])
|
||
const { asked, fetchMember } = memberTable({})
|
||
await expect(
|
||
refreshDiscordRoles(sweepEnv({ DISCORD_GUILD_ID: '' }), { fetchMember })
|
||
).resolves.toMatchObject({ skipped: true, refreshed: 0 })
|
||
await expect(
|
||
refreshDiscordRoles(
|
||
sweepEnv({ DISCORD_BOT_TOKEN: { get: async () => '' } as SecretsStoreSecret }),
|
||
{ fetchMember }
|
||
)
|
||
).resolves.toMatchObject({ skipped: true })
|
||
// A store that can't be read closes it the same way, rather than throwing out of a cron.
|
||
await expect(
|
||
refreshDiscordRoles(
|
||
sweepEnv({
|
||
DISCORD_BOT_TOKEN: {
|
||
get: async () => {
|
||
throw new Error('secret not found')
|
||
},
|
||
} as unknown as SecretsStoreSecret,
|
||
}),
|
||
{ fetchMember }
|
||
)
|
||
).resolves.toMatchObject({ skipped: true })
|
||
expect(asked).toEqual([])
|
||
expect((await discordLink(9100)).roles).toEqual([ROLE_A])
|
||
})
|
||
|
||
// The deployed test config has no guild, so the real handler takes the "off" path: pinned
|
||
// so a misconfigured deploy is a logged no-op and not a daily error.
|
||
it('runs the sweep from the scheduled handler and stays off without a guild', async () => {
|
||
await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]])
|
||
const before = await discordLink(9100)
|
||
const ctx = createExecutionContext()
|
||
await scheduled(createScheduledController(), env, ctx)
|
||
await waitOnExecutionContext(ctx)
|
||
expect(await discordLink(9100)).toEqual(before)
|
||
})
|
||
|
||
// The same sweep on a developer's request, answered with its summary. The deployed test
|
||
// config has no guild, so this pins the shape of the answer and the audit row, not a
|
||
// Discord round trip: the sweep's own behaviour is covered below through its seams.
|
||
it('runs the sweep on demand for a developer and answers with the summary', async () => {
|
||
await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]])
|
||
const before = await discordLink(9100)
|
||
|
||
const res = await devPost('/api/staff/discord-roles/sync', 8110, {})
|
||
expect(res.status).toBe(200)
|
||
const summary = (await res.json()) as Record<string, unknown>
|
||
expect(summary).toEqual({
|
||
skipped: true,
|
||
refreshed: 0,
|
||
changed: 0,
|
||
gone: 0,
|
||
failed: 0,
|
||
halted: null,
|
||
})
|
||
expect(await discordLink(9100)).toEqual(before)
|
||
|
||
const row = await env.DB.prepare(
|
||
`SELECT player_id, data FROM audit_log WHERE action = 'sync_discord_roles'
|
||
ORDER BY audit_log_id DESC LIMIT 1`
|
||
).first<{ player_id: number; data: string }>()
|
||
expect(row?.player_id).toBe(8110)
|
||
expect(JSON.parse(row?.data ?? '{}')).toMatchObject({ skipped: true })
|
||
})
|
||
|
||
// The supporter gift on a button: econ's cron run (`grantDiscordRoleGifts`, covered in econ's
|
||
// own tests) with www's bindings, answered with its summary and written to the audit log.
|
||
// The test bindings map no role, so the run is a `skipped` one and pays nothing — which is
|
||
// also what the panel must be able to tell the developer.
|
||
it('runs the supporter gift on demand for a developer and answers with the summary', async () => {
|
||
await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]])
|
||
const before = await getBalance(
|
||
env.DB,
|
||
9100,
|
||
CurrencyType.RecCenterTokens,
|
||
DEFAULT_STARTING_TOKENS
|
||
)
|
||
|
||
const res = await devPost('/api/staff/discord-roles/supporter-gift', 8110, {})
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({
|
||
skipped: true,
|
||
roles: 0,
|
||
links: 0,
|
||
granted: 0,
|
||
tokens: 0,
|
||
failed: 0,
|
||
})
|
||
expect(
|
||
await getBalance(env.DB, 9100, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||
).toBe(before)
|
||
|
||
const row = await env.DB.prepare(
|
||
`SELECT player_id, data FROM audit_log WHERE action = 'run_discord_role_gift'
|
||
ORDER BY audit_log_id DESC LIMIT 1`
|
||
).first<{ player_id: number; data: string }>()
|
||
expect(row?.player_id).toBe(8110)
|
||
expect(JSON.parse(row?.data ?? '{}')).toMatchObject({ skipped: true, granted: 0 })
|
||
})
|
||
|
||
it('re-reads every discord link and writes the roles back', async () => {
|
||
await onlyDiscordLinks([
|
||
[9111, '900000000000000111', [ROLE_A]],
|
||
[9112, '900000000000000112', [ROLE_A]],
|
||
[9113, '900000000000000113', []],
|
||
])
|
||
// A Steam link on one of them is not a Discord link and is never asked about.
|
||
await linkPlatformIdentity(env.DB, 9111, PlatformType.Steam, '76561190000009111')
|
||
expect((await getLinksForPlatform(env.DB, PlatformType.Discord)).map((l) => l.accountId)).toEqual(
|
||
[9111, 9112, 9113]
|
||
)
|
||
|
||
const { asked, fetchMember } = memberTable({
|
||
'900000000000000111': { kind: 'member', roles: [ROLE_A] },
|
||
'900000000000000112': { kind: 'member', roles: [ROLE_B, ROLE_A] },
|
||
'900000000000000113': { kind: 'member', roles: [ROLE_A] },
|
||
})
|
||
const summary = await refreshDiscordRoles(sweepEnv(), { fetchMember })
|
||
expect(asked).toEqual(['900000000000000111', '900000000000000112', '900000000000000113'])
|
||
expect(summary).toEqual({
|
||
skipped: false,
|
||
refreshed: 3,
|
||
changed: 2,
|
||
gone: 0,
|
||
failed: 0,
|
||
halted: null,
|
||
})
|
||
expect((await discordLink(9111)).roles).toEqual([ROLE_A])
|
||
expect((await discordLink(9112)).roles).toEqual([ROLE_B, ROLE_A])
|
||
expect((await discordLink(9113)).roles).toEqual([ROLE_A])
|
||
// The Steam link is as it was: no roles, and nothing wrote to it.
|
||
const steam = (await getLinksForAccount(env.DB, 9111)).find(
|
||
(l) => l.platform === PlatformType.Steam
|
||
)!
|
||
expect(steam.roles).toEqual([])
|
||
})
|
||
|
||
it('records a member who left the guild as holding no roles, and leaves their Plus alone', async () => {
|
||
await updateAccount(env.DB, 9121, { hasPlus: true })
|
||
await onlyDiscordLinks([[9121, '900000000000000121', [ROLE_A]]])
|
||
|
||
const { fetchMember } = memberTable({ '900000000000000121': { kind: 'gone' } })
|
||
const summary = await refreshDiscordRoles(sweepEnv(), { fetchMember })
|
||
expect(summary).toMatchObject({ refreshed: 1, changed: 1, gone: 1 })
|
||
expect((await discordLink(9121)).roles).toEqual([])
|
||
// "Held a qualifying role once" is still true, and nothing here revokes it.
|
||
expect((await getAccount(env.DB, 9121))?.hasPlus).toBe(true)
|
||
// The link itself stays: it's what keeps the claim once-only per Discord user.
|
||
expect(
|
||
await isPlatformIdentityLinked(env.DB, 9121, PlatformType.Discord, '900000000000000121')
|
||
).toBe(true)
|
||
})
|
||
|
||
it('stops the run without writing when the token or the guild is refused', async () => {
|
||
await onlyDiscordLinks([
|
||
[9131, '900000000000000131', [ROLE_A]],
|
||
[9132, '900000000000000132', [ROLE_A]],
|
||
])
|
||
const { asked, fetchMember } = memberTable({
|
||
'900000000000000131': { kind: 'halt', reason: 'the bot is not in the guild (Unknown Guild)' },
|
||
'900000000000000132': { kind: 'gone' },
|
||
})
|
||
const summary = await refreshDiscordRoles(sweepEnv(), { fetchMember })
|
||
expect(asked).toEqual(['900000000000000131'])
|
||
expect(summary).toMatchObject({
|
||
refreshed: 0,
|
||
halted: 'the bot is not in the guild (Unknown Guild)',
|
||
})
|
||
// Neither row moved — in particular the second was NOT blanked as "gone".
|
||
for (const id of [9131, 9132]) expect((await discordLink(id)).roles).toEqual([ROLE_A])
|
||
})
|
||
|
||
it('skips a link that errored, keeps going, and honors a rate-limit pause', async () => {
|
||
await onlyDiscordLinks([
|
||
[9141, '900000000000000141', [ROLE_A]],
|
||
[9142, '900000000000000142', [ROLE_A]],
|
||
])
|
||
const slept: number[] = []
|
||
const { asked, fetchMember } = memberTable({
|
||
'900000000000000141': { kind: 'error', status: 502, pauseMs: 1_500 },
|
||
'900000000000000142': { kind: 'member', roles: [ROLE_B], pauseMs: 60_000 },
|
||
})
|
||
const summary = await refreshDiscordRoles(sweepEnv(), {
|
||
fetchMember,
|
||
sleep: async (ms) => {
|
||
slept.push(ms)
|
||
},
|
||
})
|
||
expect(asked).toEqual(['900000000000000141', '900000000000000142'])
|
||
expect(summary).toMatchObject({ refreshed: 1, changed: 1, failed: 1, halted: null })
|
||
// The pause is honored as asked, but capped: an hour-long retry_after is not slept.
|
||
expect(slept).toEqual([1_500, 10_000])
|
||
// The failed one keeps its snapshot.
|
||
expect((await discordLink(9141)).roles).toEqual([ROLE_A])
|
||
expect((await discordLink(9142)).roles).toEqual([ROLE_B])
|
||
})
|
||
|
||
// The HTTP half, against a fake discord.com. A 404 is three different things here, and
|
||
// telling them apart is what keeps a bot that isn't in the guild yet from emptying every
|
||
// snapshot in the table.
|
||
it('decodes a bot member read: roles, gone, halt and error', async () => {
|
||
const answer =
|
||
(status: number, body: unknown, headers: Record<string, string> = {}) =>
|
||
async (url: string, init?: RequestInit) => {
|
||
expect(url).toBe(
|
||
`https://discord.com/api/v10/guilds/${SWEEP_GUILD}/members/900000000000000001`
|
||
)
|
||
expect(new Headers(init?.headers).get('authorization')).toBe('Bot test-bot-token')
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'content-type': 'application/json', ...headers },
|
||
})
|
||
}
|
||
const read = (status: number, body: unknown, headers?: Record<string, string>) =>
|
||
fetchMemberRoles(
|
||
'test-bot-token',
|
||
SWEEP_GUILD,
|
||
'900000000000000001',
|
||
answer(status, body, headers)
|
||
)
|
||
|
||
// A member record: roles as snowflake strings, anything else in the array dropped.
|
||
await expect(
|
||
read(200, { user: { id: '900000000000000001' }, roles: [ROLE_A, 5, ROLE_B] })
|
||
).resolves.toEqual({
|
||
kind: 'member',
|
||
roles: [ROLE_A, ROLE_B],
|
||
pauseMs: 0,
|
||
})
|
||
// A spent bucket asks for a pause before the next call, on any answer.
|
||
await expect(
|
||
read(200, { roles: [] }, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset-after': '2.5' })
|
||
).resolves.toEqual({ kind: 'member', roles: [], pauseMs: 2_500 })
|
||
await expect(read(200, { user: {} })).resolves.toMatchObject({ kind: 'error', status: 200 })
|
||
|
||
// The three 404s.
|
||
await expect(read(404, { code: 10007, message: 'Unknown Member' })).resolves.toMatchObject({
|
||
kind: 'gone',
|
||
})
|
||
await expect(read(404, { code: 10013, message: 'Unknown User' })).resolves.toMatchObject({
|
||
kind: 'gone',
|
||
})
|
||
await expect(read(404, { code: 10004, message: 'Unknown Guild' })).resolves.toMatchObject({
|
||
kind: 'halt',
|
||
reason: expect.stringContaining('Unknown Guild'),
|
||
})
|
||
await expect(read(404, { code: 0, message: '404: Not Found' })).resolves.toMatchObject({
|
||
kind: 'error',
|
||
status: 404,
|
||
})
|
||
|
||
// The token and the bot's access: every further call would fail the same way.
|
||
await expect(read(401, { code: 0, message: '401: Unauthorized' })).resolves.toMatchObject({
|
||
kind: 'halt',
|
||
})
|
||
await expect(read(403, { code: 50001, message: 'Missing Access' })).resolves.toMatchObject({
|
||
kind: 'halt',
|
||
})
|
||
|
||
// Rate limited: transient, with the body's retry_after (seconds) as the pause.
|
||
await expect(read(429, { retry_after: 1.25, global: false })).resolves.toEqual({
|
||
kind: 'error',
|
||
status: 429,
|
||
pauseMs: 1_250,
|
||
})
|
||
await expect(read(500, {})).resolves.toMatchObject({ kind: 'error', status: 500 })
|
||
// Unreachable: an error with no status, never a throw out of the cron.
|
||
await expect(
|
||
fetchMemberRoles('test-bot-token', SWEEP_GUILD, '900000000000000001', async () => {
|
||
throw new TypeError('fetch failed')
|
||
})
|
||
).resolves.toEqual({ kind: 'error', status: null })
|
||
})
|
||
|
||
// ---- Studio upload access ---------------------------------------------------
|
||
//
|
||
// RecFlare Studio treats the JWT role `betastudio` as permission to upload. Staff
|
||
// set the account's `hasStudio` flag here; auth stamps it onto the next login and
|
||
// the next refresh. A signed-in player can ask about themselves. They cannot see or
|
||
// edit the list.
|
||
|
||
it('tells a signed-in player whether they can upload, and nobody else', async () => {
|
||
expect((await SELF.fetch('https://example.com/api/studio-access')).status).toBe(401)
|
||
const res = await SELF.fetch('https://example.com/api/studio-access', {
|
||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||
})
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ granted: false })
|
||
})
|
||
|
||
it('lets staff add, list, and remove studio upload access', async () => {
|
||
await updateAccount(env.DB, 8601, { username: 'StudioFan' })
|
||
await updateAccount(env.DB, 8602, { username: 'OtherFan' })
|
||
await updateAccount(env.DB, 8110, { username: 'Moderator' })
|
||
|
||
expect((await staffPost('/api/staff/studio-access', 8110, {})).status).toBe(400)
|
||
expect((await staffPost('/api/staff/studio-access', 8110, { username: 'Nobody' })).status).toBe(
|
||
404
|
||
)
|
||
expect(
|
||
(
|
||
await staffPost('/api/staff/studio-access', 8110, {
|
||
username: 'StudioFan',
|
||
accountId: 8602,
|
||
})
|
||
).status
|
||
).toBe(400)
|
||
|
||
const added = await staffPost('/api/staff/studio-access', 8110, { username: '@StudioFan' })
|
||
expect(added.status).toBe(200)
|
||
expect(await added.json()).toEqual({
|
||
accountId: 8601,
|
||
username: 'StudioFan',
|
||
granted: true,
|
||
alreadyGranted: false,
|
||
})
|
||
|
||
const again = await staffPost('/api/staff/studio-access', 8110, { accountId: 8601 })
|
||
expect(again.status).toBe(200)
|
||
expect(await again.json()).toMatchObject({ accountId: 8601, alreadyGranted: true })
|
||
|
||
const mine = await SELF.fetch('https://example.com/api/studio-access', {
|
||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||
})
|
||
expect(await mine.json()).toEqual({ granted: true })
|
||
|
||
const list = await staffGet('/api/staff/studio-access', 8110)
|
||
expect(list.status).toBe(200)
|
||
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
||
expect(body.accounts).toEqual([
|
||
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String) },
|
||
])
|
||
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(true)
|
||
|
||
expect((await staffDelete('/api/staff/studio-access/nope', 8110)).status).toBe(400)
|
||
expect((await staffDelete('/api/staff/studio-access/8609', 8110)).status).toBe(404)
|
||
|
||
const removed = await staffDelete('/api/staff/studio-access/8601', 8110)
|
||
expect(removed.status).toBe(200)
|
||
expect(await removed.json()).toEqual({ accountId: 8601, granted: false, removed: true })
|
||
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(false)
|
||
const repeat = await staffDelete('/api/staff/studio-access/8601', 8110)
|
||
expect(await repeat.json()).toEqual({ accountId: 8601, granted: false, removed: false })
|
||
|
||
const after = await SELF.fetch('https://example.com/api/studio-access', {
|
||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||
})
|
||
expect(await after.json()).toEqual({ granted: false })
|
||
|
||
expect(await auditRows('grant_studio_access', 8601)).toEqual([
|
||
{
|
||
actor: 8110,
|
||
data: { playerId: 8601, username: 'StudioFan', alreadyGranted: false },
|
||
},
|
||
{
|
||
actor: 8110,
|
||
data: { playerId: 8601, username: 'StudioFan', alreadyGranted: true },
|
||
},
|
||
])
|
||
expect(await auditRows('revoke_studio_access', 8601)).toEqual([
|
||
{ actor: 8110, data: { playerId: 8601, username: 'StudioFan', removed: true } },
|
||
{ actor: 8110, data: { playerId: 8601, username: 'StudioFan', removed: false } },
|
||
])
|
||
})
|
||
|
||
// ---- Sandbox opt-in ---------------------------------------------------------
|
||
// `match` picks the Tachyon pool off the account's `isSandbox` flag. Staff flip their OWN
|
||
// flag from the account page; the route never takes a player id, so nobody can be moved by
|
||
// someone else here.
|
||
|
||
it('lets staff opt themselves in and out of the sandbox pool, and only themselves', async () => {
|
||
await updateAccount(env.DB, 8120, { username: 'SandboxMod' })
|
||
|
||
const staffPut = (accountId: number, body: unknown, roles = ['moderator']) =>
|
||
tokenFor(accountId, roles).then((token) =>
|
||
SELF.fetch('https://example.com/api/staff/sandbox', {
|
||
method: 'PUT',
|
||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
)
|
||
|
||
// Off until opted in, for a moderator and a developer alike.
|
||
let res = await staffGet('/api/staff/sandbox', 8120)
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ sandbox: false })
|
||
expect((await staffGet('/api/staff/sandbox', 8120, ['developer'])).status).toBe(200)
|
||
|
||
// Not a boolean, not a body.
|
||
expect((await staffPut(8120, { sandbox: 'yes' })).status).toBe(400)
|
||
expect((await staffPut(8120, {})).status).toBe(400)
|
||
|
||
res = await staffPut(8120, { sandbox: true })
|
||
expect(res.status).toBe(200)
|
||
expect(await res.json()).toEqual({ sandbox: true, changed: true })
|
||
expect((await getAccount(env.DB, 8120))?.isSandbox).toBe(true)
|
||
expect(await (await staffGet('/api/staff/sandbox', 8120)).json()).toEqual({ sandbox: true })
|
||
|
||
// Idempotent: opting in again changes nothing and says so.
|
||
res = await staffPut(8120, { sandbox: true })
|
||
expect(await res.json()).toEqual({ sandbox: true, changed: false })
|
||
|
||
// A developer's token works the same, and the flag is theirs, not the moderator's.
|
||
res = await staffPut(8121, { sandbox: true }, ['developer'])
|
||
expect(await res.json()).toEqual({ sandbox: true, changed: true })
|
||
expect((await getAccount(env.DB, 8121))?.isSandbox).toBe(true)
|
||
|
||
res = await staffPut(8120, { sandbox: false })
|
||
expect(await res.json()).toEqual({ sandbox: false, changed: true })
|
||
expect((await getAccount(env.DB, 8120))?.isSandbox).toBe(false)
|
||
expect((await getAccount(env.DB, 8121))?.isSandbox).toBe(true)
|
||
|
||
// Each flip is on the audit log against the staff member who made it.
|
||
const rows = await env.DB.prepare(
|
||
"SELECT player_id, data FROM audit_log WHERE action = 'set_sandbox' ORDER BY audit_log_id"
|
||
).all<{ player_id: number; data: string }>()
|
||
expect(rows.results.map((r) => [r.player_id, JSON.parse(r.data).sandbox])).toEqual([
|
||
[8120, true],
|
||
[8120, true],
|
||
[8121, true],
|
||
[8120, false],
|
||
])
|
||
})
|