From 7b04c8836a60071810f2a692d6a6dd6473fb3c49 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sat, 16 May 2026 10:46:57 +0100 Subject: [PATCH] profanity filter for props --- src/entities/game.ts | 3 + src/lib/props/filterProfaneProps.ts | 54 ++++++++++ src/lib/props/sanitiseProps.ts | 2 +- src/migrations/.snapshot-gs_dev.json | 16 +++ ...60515181220AddBlockPropsProfanityColumn.ts | 13 +++ src/migrations/index.ts | 5 + src/routes/api/game-channel/docs.ts | 6 +- src/routes/api/game-channel/put-storage.ts | 13 ++- src/routes/api/leaderboard/docs.ts | 1 + src/routes/api/leaderboard/post.ts | 17 ++- src/routes/api/player/docs.ts | 1 + src/routes/protected/game-channel/update.ts | 18 +++- src/routes/protected/game/settings.ts | 3 +- src/routes/protected/game/update.ts | 9 +- src/routes/protected/player/update.ts | 33 ++++-- tests/lib/props/filterProfaneProps.test.ts | 102 ++++++++++++++++++ .../api/game-channel/put-storage.test.ts | 53 +++++++++ tests/routes/api/game-channel/put.test.ts | 71 ++++++++++++ tests/routes/api/leaderboard/post.test.ts | 57 ++++++++++ tests/routes/api/player/patch.test.ts | 51 +++++++++ tests/routes/protected/game/settings.test.ts | 3 +- tests/routes/protected/game/update.test.ts | 18 ++++ 22 files changed, 525 insertions(+), 24 deletions(-) create mode 100644 src/lib/props/filterProfaneProps.ts create mode 100644 src/migrations/20260515181220AddBlockPropsProfanityColumn.ts create mode 100644 tests/lib/props/filterProfaneProps.test.ts diff --git a/src/entities/game.ts b/src/entities/game.ts index a5f777010..f44ef2f7a 100644 --- a/src/entities/game.ts +++ b/src/entities/game.ts @@ -54,6 +54,9 @@ export default class Game { @Property() blockAliasIdentifierProfanity: boolean = false + @Property() + blockPropsProfanity: boolean = false + @Property() createdAt: Date = new Date() diff --git a/src/lib/props/filterProfaneProps.ts b/src/lib/props/filterProfaneProps.ts new file mode 100644 index 000000000..1a929eb51 --- /dev/null +++ b/src/lib/props/filterProfaneProps.ts @@ -0,0 +1,54 @@ +import { hasProfanity } from '../filters/profanity.js' +import { isArrayKey, type UnsanitisedProp } from './sanitiseProps.js' + +export type RejectedProp = { key: string; error: string } + +function isProfaneValue(value: string | null) { + return value !== null && hasProfanity(value) +} + +export function filterProfaneProps( + props: T[], + enabled: boolean, +): { accepted: T[]; rejected: RejectedProp[] } { + if (!enabled) { + return { accepted: props, rejected: [] } + } + + const accepted: T[] = [] + const rejectedMap = new Map() + + const arrayGroups = new Map() + for (const prop of props) { + if (isArrayKey(prop.key)) { + const group = arrayGroups.get(prop.key) ?? [] + group.push(prop) + arrayGroups.set(prop.key, group) + } + } + + for (const [key, group] of arrayGroups) { + if (group.some((p) => isProfaneValue(p.value))) { + rejectedMap.set(key, { key, error: 'Prop value contains profanity' }) + } + } + + for (const prop of props) { + if (rejectedMap.has(prop.key)) { + continue + } + + if (isArrayKey(prop.key)) { + accepted.push(prop) + continue + } + + if (isProfaneValue(prop.value)) { + rejectedMap.set(prop.key, { key: prop.key, error: 'Prop value contains profanity' }) + } else { + accepted.push(prop) + } + } + + return { accepted, rejected: Array.from(rejectedMap.values()) } +} diff --git a/src/lib/props/sanitiseProps.ts b/src/lib/props/sanitiseProps.ts index ea3242da5..72ce29e4e 100644 --- a/src/lib/props/sanitiseProps.ts +++ b/src/lib/props/sanitiseProps.ts @@ -4,7 +4,7 @@ import { PropSizeError } from '../errors/propSizeError.js' export const MAX_ARRAY_LENGTH = 1000 -type UnsanitisedProp = { key: string; value: string | null } +export type UnsanitisedProp = { key: string; value: string | null } export function isArrayKey(key: string): boolean { return key.endsWith('[]') diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index 4caf59331..9226beb37 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -512,6 +512,22 @@ "enumItems": [], "mappedType": "boolean" }, + "block_props_profanity": { + "name": "block_props_profanity", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": 3, + "scale": 0, + "default": "false", + "comment": null, + "enumItems": [], + "mappedType": "boolean" + }, "created_at": { "name": "created_at", "type": "datetime", diff --git a/src/migrations/20260515181220AddBlockPropsProfanityColumn.ts b/src/migrations/20260515181220AddBlockPropsProfanityColumn.ts new file mode 100644 index 000000000..d3dc70cf9 --- /dev/null +++ b/src/migrations/20260515181220AddBlockPropsProfanityColumn.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations' + +export class AddBlockPropsProfanityColumn extends Migration { + override up(): void | Promise { + this.addSql( + `alter table \`game\` add \`block_props_profanity\` tinyint(1) not null default false;`, + ) + } + + override down(): void | Promise { + this.addSql(`alter table \`game\` drop column \`block_props_profanity\`;`) + } +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 35173d275..5fdf27ce8 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -70,6 +70,7 @@ import { AddFailedJobFailedAtIndex } from './20260327225535AddFailedJobFailedAtI import { CreateGameCenterIntegrationEventTable } from './20260404213749CreateGameCenterIntegrationEventTable.js' import { MikroORMV7FKDecouple } from './20260509215853MikroORMV7FKDecouple.js' import { AddBlockAliasIdentifierProfanityColumn } from './20260514072111AddBlockAliasIdentifierProfanityColumn.js' +import { AddBlockPropsProfanityColumn } from './20260515181220AddBlockPropsProfanityColumn.js' export default [ { @@ -360,4 +361,8 @@ export default [ name: 'AddBlockAliasIdentifierProfanityColumn', class: AddBlockAliasIdentifierProfanityColumn, }, + { + name: 'AddBlockPropsProfanityColumn', + class: AddBlockPropsProfanityColumn, + }, ] diff --git a/src/routes/api/game-channel/docs.ts b/src/routes/api/game-channel/docs.ts index 992144a02..be428093e 100644 --- a/src/routes/api/game-channel/docs.ts +++ b/src/routes/api/game-channel/docs.ts @@ -283,6 +283,7 @@ export const putDocs = { createdAt: '2024-12-09T12:00:00.000Z', updatedAt: '2024-12-09T12:01:00.000Z', }, + rejectedProps: [{ key: 'messageOfTheDay', error: 'Prop value contains profanity' }], }, }, ], @@ -482,7 +483,8 @@ export const listStorageDocs = { } satisfies RouteDocs export const putStorageDocs = { - description: 'Create or update storage properties in a game channel', + description: + 'Create or update storage properties in a game channel. The failedProps array contains props that failed due to size constraints or because their values contain profanity (when profanity blocking is enabled).', samples: [ { title: 'Sample request to create/update properties', @@ -574,7 +576,7 @@ export const putStorageDocs = { }, ], deletedProps: [], - failedProps: [], + failedProps: [{ key: 'guildMotto', error: 'Prop value contains profanity' }], }, }, ], diff --git a/src/routes/api/game-channel/put-storage.ts b/src/routes/api/game-channel/put-storage.ts index 39a48c891..5620786c8 100644 --- a/src/routes/api/game-channel/put-storage.ts +++ b/src/routes/api/game-channel/put-storage.ts @@ -5,6 +5,7 @@ import GameChannelStorageProp from '../../../entities/game-channel-storage-prop. import GameChannel from '../../../entities/game-channel.js' import PlayerAlias from '../../../entities/player-alias.js' import { PropSizeError } from '../../../lib/errors/propSizeError.js' +import { filterProfaneProps } from '../../../lib/props/filterProfaneProps.js' import { isArrayKey, MAX_ARRAY_LENGTH, @@ -200,13 +201,17 @@ export const putStorageRoute = apiRoute({ return ctx.throw(403, 'This player is not a member of the channel') } + const sanitised = sanitiseProps({ props }) + const { accepted, rejected: profanityRejected } = filterProfaneProps( + sanitised, + ctx.state.game.blockPropsProfanity, + ) + const { upsertedProps, deletedProps, failedProps } = await em.transactional( async (trx): Promise => { - const sanitised = sanitiseProps({ props }) - const newScalarMap = new Map() const newArrayMap = new Map() - for (const { key, value } of sanitised) { + for (const { key, value } of accepted) { if (isArrayKey(key)) { const existing = newArrayMap.get(key) ?? [] existing.push(value) @@ -274,7 +279,7 @@ export const putStorageRoute = apiRoute({ channel, upsertedProps, deletedProps, - failedProps, + failedProps: [...profanityRejected, ...failedProps], }, } }, diff --git a/src/routes/api/leaderboard/docs.ts b/src/routes/api/leaderboard/docs.ts index 4ce2f3a9c..cc585fa1b 100644 --- a/src/routes/api/leaderboard/docs.ts +++ b/src/routes/api/leaderboard/docs.ts @@ -153,6 +153,7 @@ export const postDocs = { updatedAt: '2022-02-16T16:03:53.123Z', }, updated: true, + rejectedProps: [{ key: 'nickname', error: 'Prop value contains profanity' }], }, }, ], diff --git a/src/routes/api/leaderboard/post.ts b/src/routes/api/leaderboard/post.ts index cd8bffce9..efa24d579 100644 --- a/src/routes/api/leaderboard/post.ts +++ b/src/routes/api/leaderboard/post.ts @@ -7,6 +7,7 @@ import buildErrorResponse from '../../../lib/errors/buildErrorResponse.js' import { PropSizeError } from '../../../lib/errors/propSizeError.js' import { UniqueLeaderboardEntryPropsDigestError } from '../../../lib/errors/uniqueLeaderboardEntryPropsDigestError.js' import triggerIntegrations from '../../../lib/integrations/triggerIntegrations.js' +import { filterProfaneProps } from '../../../lib/props/filterProfaneProps.js' import { hardSanitiseProps, mergeAndSanitiseProps } from '../../../lib/props/sanitiseProps.js' import { apiRoute, withMiddleware } from '../../../lib/routing/router.js' import { playerAliasHeaderSchema } from '../../../lib/validation/playerAliasHeaderSchema.js' @@ -71,6 +72,10 @@ export const postRoute = apiRoute({ ), handler: async (ctx) => { const { score, props = [] } = ctx.state.validated.body + const { accepted: acceptedProps, rejected: rejectedProps } = filterProfaneProps( + props, + ctx.state.game.blockPropsProfanity, + ) const em = ctx.em const leaderboard = ctx.state.leaderboard @@ -85,7 +90,9 @@ export const postRoute = apiRoute({ let updated = false // filter out props with null values for createEntry (only used for merging in updates) - const createProps = props.filter((p): p is { key: string; value: string } => p.value !== null) + const createProps = acceptedProps.filter( + (p): p is { key: string; value: string } => p.value !== null, + ) try { if (leaderboard.unique) { @@ -115,9 +122,12 @@ export const postRoute = apiRoute({ if (shouldUpdate) { entry.score = score entry.createdAt = ctx.state.continuityDate ?? new Date() - if (props.length > 0) { + if (acceptedProps.length > 0) { entry.setProps( - mergeAndSanitiseProps({ prevProps: entry.props.getItems(), newProps: props }), + mergeAndSanitiseProps({ + prevProps: entry.props.getItems(), + newProps: acceptedProps, + }), ) } updated = true @@ -215,6 +225,7 @@ export const postRoute = apiRoute({ body: { entry: { position, ...entry.toJSON() }, updated, + rejectedProps, }, } }, diff --git a/src/routes/api/player/docs.ts b/src/routes/api/player/docs.ts index 7e323d242..b29efc73a 100644 --- a/src/routes/api/player/docs.ts +++ b/src/routes/api/player/docs.ts @@ -187,6 +187,7 @@ export const patchDocs = { lastSeenAt: '2022-04-12T15:09:43.066Z', groups: [{ id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' }], }, + rejectedProps: [{ key: 'nickname', error: 'Prop value contains profanity' }], }, }, ], diff --git a/src/routes/protected/game-channel/update.ts b/src/routes/protected/game-channel/update.ts index e20ba388a..592d47dab 100644 --- a/src/routes/protected/game-channel/update.ts +++ b/src/routes/protected/game-channel/update.ts @@ -6,6 +6,7 @@ import User from '../../../entities/user.js' import buildErrorResponse from '../../../lib/errors/buildErrorResponse.js' import { PropSizeError } from '../../../lib/errors/propSizeError.js' import createGameActivity from '../../../lib/logging/createGameActivity.js' +import { filterProfaneProps, type RejectedProp } from '../../../lib/props/filterProfaneProps.js' import { mergeAndSanitiseProps } from '../../../lib/props/sanitiseProps.js' import { protectedRoute, withMiddleware } from '../../../lib/routing/router.js' import { updatePropsSchema } from '../../../lib/validation/propsSchema.js' @@ -43,6 +44,7 @@ export async function updateChannelHandler({ temporaryMembership, }: UpdateChannelParams) { const changedProperties: string[] = [] + let rejectedProps: RejectedProp[] = [] if (typeof name === 'string' && name.trim().length > 0) { channel.name = name.trim() @@ -51,9 +53,18 @@ export async function updateChannelHandler({ if (props) { try { - channel.setProps( - mergeAndSanitiseProps({ prevProps: channel.props.getItems(), newProps: props }), - ) + const mergedProps = mergeAndSanitiseProps({ + prevProps: channel.props.getItems(), + newProps: props, + }) + + if (forwarded && channel.game.blockPropsProfanity) { + const { accepted, rejected } = filterProfaneProps(mergedProps, true) + channel.setProps(accepted) + rejectedProps = rejected + } else { + channel.setProps(mergedProps) + } } catch (err) { if (err instanceof PropSizeError) { return buildErrorResponse({ props: [err.message] }) @@ -158,6 +169,7 @@ export async function updateChannelHandler({ status: 200, body: { channel: channel.toJSONWithCount(counts), + rejectedProps, }, } } diff --git a/src/routes/protected/game/settings.ts b/src/routes/protected/game/settings.ts index fb4180b46..36a92f946 100644 --- a/src/routes/protected/game/settings.ts +++ b/src/routes/protected/game/settings.ts @@ -17,8 +17,9 @@ export const settingsRoute = protectedRoute({ purgeLivePlayers: game.purgeLivePlayers, purgeDevPlayersRetention: game.purgeDevPlayersRetention, purgeLivePlayersRetention: game.purgeLivePlayersRetention, - website: game.website, blockAliasIdentifierProfanity: game.blockAliasIdentifierProfanity, + blockPropsProfanity: game.blockPropsProfanity, + website: game.website, gameToken: game.getToken(), }, }, diff --git a/src/routes/protected/game/update.ts b/src/routes/protected/game/update.ts index c3049c7d2..f30de982b 100644 --- a/src/routes/protected/game/update.ts +++ b/src/routes/protected/game/update.ts @@ -43,6 +43,7 @@ export const updateRoute = protectedRoute({ purgeLivePlayersRetention: z.number().optional(), website: z.string().nullable().optional(), blockAliasIdentifierProfanity: z.boolean().optional(), + blockPropsProfanity: z.boolean().optional(), }), }), middleware: withMiddleware(userTypeGate([UserType.ADMIN], 'update games'), loadGame), @@ -56,6 +57,7 @@ export const updateRoute = protectedRoute({ purgeLivePlayersRetention, website, blockAliasIdentifierProfanity, + blockPropsProfanity, } = ctx.state.validated.body const em = ctx.em @@ -144,14 +146,19 @@ export const updateRoute = protectedRoute({ throwUnlessOwner(ctx) settingsToUpdate.blockAliasIdentifierProfanity = blockAliasIdentifierProfanity } + if (typeof blockPropsProfanity === 'boolean') { + throwUnlessOwner(ctx) + settingsToUpdate.blockPropsProfanity = blockPropsProfanity + } const [, changedProperties] = updateAllowedKeys(game, settingsToUpdate, [ 'purgeDevPlayers', 'purgeLivePlayers', 'purgeDevPlayersRetention', 'purgeLivePlayersRetention', - 'website', 'blockAliasIdentifierProfanity', + 'blockPropsProfanity', + 'website', ]) if (changedProperties.length > 0) { diff --git a/src/routes/protected/player/update.ts b/src/routes/protected/player/update.ts index 5c727462d..0e0233f15 100644 --- a/src/routes/protected/player/update.ts +++ b/src/routes/protected/player/update.ts @@ -5,6 +5,7 @@ import User, { UserType } from '../../../entities/user.js' import buildErrorResponse from '../../../lib/errors/buildErrorResponse.js' import { PropSizeError } from '../../../lib/errors/propSizeError.js' import createGameActivity from '../../../lib/logging/createGameActivity.js' +import { filterProfaneProps, type RejectedProp } from '../../../lib/props/filterProfaneProps.js' import { sanitiseProps, mergeAndSanitiseProps } from '../../../lib/props/sanitiseProps.js' import { protectedRoute, withMiddleware } from '../../../lib/routing/router.js' import { updatePropsSchema } from '../../../lib/validation/propsSchema.js' @@ -30,31 +31,45 @@ export async function updatePlayerHandler({ forwarded, user, }: UpdatePlayerParams) { - const { player: updatedPlayer, errorMessage } = await em.transactional(async (trx) => { + const { + player: updatedPlayer, + errorMessage, + rejectedProps, + } = await em.transactional(async (trx) => { const lockedPlayer = await trx.refreshOrFail(player, { lockMode: LockMode.PESSIMISTIC_WRITE }) + let rejectedProps: RejectedProp[] = [] + if (props) { if (!forwarded && props.some((prop) => prop.key.startsWith('META_'))) { return { player: null, errorMessage: "Prop keys starting with 'META_' are reserved for internal systems, please use another key name", + rejectedProps: [], } } try { - lockedPlayer.setProps( - mergeAndSanitiseProps({ - prevProps: lockedPlayer.props.getItems(), - newProps: props, - extraFilter: (prop) => !prop.key.startsWith('META_'), - }), - ) + const mergedProps = mergeAndSanitiseProps({ + prevProps: lockedPlayer.props.getItems(), + newProps: props, + extraFilter: (prop) => !prop.key.startsWith('META_'), + }) + + if (forwarded && lockedPlayer.game.blockPropsProfanity) { + const { accepted, rejected } = filterProfaneProps(mergedProps, true) + lockedPlayer.setProps(accepted) + rejectedProps = rejected + } else { + lockedPlayer.setProps(mergedProps) + } } catch (err) { if (err instanceof PropSizeError) { return { player: null, errorMessage: err.message, + rejectedProps: [], } } throw err @@ -81,6 +96,7 @@ export async function updatePlayerHandler({ return { player: lockedPlayer, errorMessage: null, + rejectedProps, } }) @@ -98,6 +114,7 @@ export async function updatePlayerHandler({ status: 200, body: { player: updatedPlayer, + rejectedProps, }, } } diff --git a/tests/lib/props/filterProfaneProps.test.ts b/tests/lib/props/filterProfaneProps.test.ts new file mode 100644 index 000000000..d53bfb186 --- /dev/null +++ b/tests/lib/props/filterProfaneProps.test.ts @@ -0,0 +1,102 @@ +import Prop from '../../../src/entities/prop.js' +import { filterProfaneProps } from '../../../src/lib/props/filterProfaneProps.js' + +describe('filterProfaneProps', () => { + it('should return all props as accepted when disabled', () => { + const props = [new Prop('name', 'fuck'), new Prop('level', '5')] + const { accepted, rejected } = filterProfaneProps(props, false) + expect(accepted).toEqual(props) + expect(rejected).toEqual([]) + }) + + it('should reject props with profane values when enabled', () => { + const props = [new Prop('name', 'fuck'), new Prop('level', '5')] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual([props[1]]) + expect(rejected).toEqual([{ key: 'name', error: 'Prop value contains profanity' }]) + }) + + it('should accept all props with clean values when enabled', () => { + const props = [new Prop('name', 'Alice'), new Prop('level', '5')] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual(props) + expect(rejected).toEqual([]) + }) + + it('should handle empty props array', () => { + const { accepted, rejected } = filterProfaneProps([], true) + expect(accepted).toEqual([]) + expect(rejected).toEqual([]) + }) + + it('should reject entire array key if any value is profane', () => { + const props = [ + new Prop('tags[]', 'good'), + new Prop('tags[]', 'fuck'), + new Prop('name', 'Alice'), + ] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual([props[2]]) + expect(rejected).toEqual([{ key: 'tags[]', error: 'Prop value contains profanity' }]) + }) + + it('should deduplicate rejections for the same key', () => { + const props = [new Prop('name', 'fuck')] + const { rejected } = filterProfaneProps(props, true) + expect(rejected).toHaveLength(1) + expect(rejected[0].key).toBe('name') + }) + + it('should work with unsanitised props (nullable values)', () => { + const props = [ + { key: 'bio', value: 'fuck' }, + { key: 'name', value: 'Alice' }, + ] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual([{ key: 'name', value: 'Alice' }]) + expect(rejected).toEqual([{ key: 'bio', error: 'Prop value contains profanity' }]) + }) + + it('should reject entire array key for unsanitised props if any value is profane', () => { + const props = [ + { key: 'tags[]', value: 'good' }, + { key: 'tags[]', value: 'fuck' }, + { key: 'tags[]', value: 'ok' }, + { key: 'name', value: 'Alice' }, + ] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual([{ key: 'name', value: 'Alice' }]) + expect(rejected).toEqual([{ key: 'tags[]', error: 'Prop value contains profanity' }]) + }) + + it('should accept array keys with all clean values for unsanitised props', () => { + const props = [ + { key: 'tags[]', value: 'good' }, + { key: 'tags[]', value: 'ok' }, + ] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual(props) + expect(rejected).toEqual([]) + }) + + it('should preserve null values in accepted unsanitised props', () => { + const props = [ + { key: 'bio', value: null }, + { key: 'name', value: 'Alice' }, + ] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual(props) + expect(rejected).toEqual([]) + }) + + it('should reject array key with mixed null and profane values', () => { + const props = [ + { key: 'tags[]', value: null }, + { key: 'tags[]', value: 'fuck' }, + { key: 'name', value: 'Alice' }, + ] + const { accepted, rejected } = filterProfaneProps(props, true) + expect(accepted).toEqual([{ key: 'name', value: 'Alice' }]) + expect(rejected).toEqual([{ key: 'tags[]', error: 'Prop value contains profanity' }]) + }) +}) diff --git a/tests/routes/api/game-channel/put-storage.test.ts b/tests/routes/api/game-channel/put-storage.test.ts index b33e14e96..9443d3fe0 100644 --- a/tests/routes/api/game-channel/put-storage.test.ts +++ b/tests/routes/api/game-channel/put-storage.test.ts @@ -708,6 +708,59 @@ describe('Game channel API - update storage', () => { expect(res.body.failedProps).toHaveLength(0) }) + it('should reject profane storage props when blockPropsProfanity is enabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + apiKey.game.blockPropsProfanity = true + await em.flush() + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(1) + expect(res.body.upsertedProps[0].key).toBe('level') + expect(res.body.failedProps).toEqual([ + { key: 'nickname', error: 'Prop value contains profanity' }, + ]) + }) + + it('should allow profane storage props when blockPropsProfanity is disabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(2) + expect(res.body.failedProps).toHaveLength(0) + }) + it('should handle mixed successes and failures when updating props', async () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) diff --git a/tests/routes/api/game-channel/put.test.ts b/tests/routes/api/game-channel/put.test.ts index b7bcf293a..623877f40 100644 --- a/tests/routes/api/game-channel/put.test.ts +++ b/tests/routes/api/game-channel/put.test.ts @@ -379,6 +379,77 @@ describe('Game channel API - update', () => { }) }) + it('should reject profane props when blockPropsProfanity is enabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + apiKey.game.blockPropsProfanity = true + await em.flush() + + const channel = await new GameChannelFactory(apiKey.game) + .state((channel) => ({ + props: new Collection(channel, [ + new GameChannelProp(channel, 'guildId', '1234'), + ]), + })) + .one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}`) + .send({ + props: [ + { key: 'guildId', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.props).toEqual([{ key: 'level', value: '5' }]) + expect(res.body.rejectedProps).toEqual([ + { key: 'guildId', error: 'Prop value contains profanity' }, + ]) + }) + + it('should allow profane props when blockPropsProfanity is disabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game) + .state((channel) => ({ + props: new Collection(channel, [ + new GameChannelProp(channel, 'guildId', '1234'), + ]), + })) + .one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}`) + .send({ + props: [ + { key: 'guildId', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.props).toEqual( + expect.arrayContaining([ + { key: 'guildId', value: 'fuck' }, + { key: 'level', value: '5' }, + ]), + ) + expect(res.body.rejectedProps).toEqual([]) + }) + it('should not notify players in the channel if a channel attempt is made but nothing changes', async () => { const { identifyMessage, ticket, player, token } = await createSocketIdentifyMessage([ APIKeyScope.READ_PLAYERS, diff --git a/tests/routes/api/leaderboard/post.test.ts b/tests/routes/api/leaderboard/post.test.ts index 7b2099602..17f599916 100644 --- a/tests/routes/api/leaderboard/post.test.ts +++ b/tests/routes/api/leaderboard/post.test.ts @@ -1230,4 +1230,61 @@ describe('Leaderboard API - create', () => { expect(res.body.updated).toBe(false) expect(res.body.entry.position).toBe(0) }) + + it('should reject profane props when blockPropsProfanity is enabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_LEADERBOARDS]) + apiKey.game.blockPropsProfanity = true + await em.flush() + + const player = await new PlayerFactory([apiKey.game]).one() + const leaderboard = await new LeaderboardFactory([apiKey.game]).one() + await em.persist([player, leaderboard]).flush() + + const res = await request(app) + .post(`/v1/leaderboards/${leaderboard.internalName}/entries`) + .send({ + score: 300, + props: [ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.entry.props).toEqual([{ key: 'level', value: '5' }]) + expect(res.body.rejectedProps).toEqual([ + { key: 'nickname', error: 'Prop value contains profanity' }, + ]) + }) + + it('should allow profane props when blockPropsProfanity is disabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_LEADERBOARDS]) + + const player = await new PlayerFactory([apiKey.game]).one() + const leaderboard = await new LeaderboardFactory([apiKey.game]).one() + await em.persist([player, leaderboard]).flush() + + const res = await request(app) + .post(`/v1/leaderboards/${leaderboard.internalName}/entries`) + .send({ + score: 300, + props: [ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.entry.props).toEqual( + expect.arrayContaining([ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ]), + ) + expect(res.body.rejectedProps).toEqual([]) + }) }) diff --git a/tests/routes/api/player/patch.test.ts b/tests/routes/api/player/patch.test.ts index 36149984a..ab7673c0a 100644 --- a/tests/routes/api/player/patch.test.ts +++ b/tests/routes/api/player/patch.test.ts @@ -387,6 +387,57 @@ describe('Player API - update', () => { consoleSpy.mockRestore() }) + it('should reject profane props when blockPropsProfanity is enabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_PLAYERS]) + apiKey.game.blockPropsProfanity = true + await em.flush() + + const player = await new PlayerFactory([apiKey.game]).one() + await em.persist(player).flush() + + const res = await request(app) + .patch(`/v1/players/${player.id}`) + .send({ + props: [ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.player.props).toEqual(expect.arrayContaining([{ key: 'level', value: '5' }])) + expect(res.body.rejectedProps).toEqual([ + { key: 'nickname', error: 'Prop value contains profanity' }, + ]) + }) + + it('should allow profane props when blockPropsProfanity is disabled', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).one() + await em.persist(player).flush() + + const res = await request(app) + .patch(`/v1/players/${player.id}`) + .send({ + props: [ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ], + }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.player.props).toEqual( + expect.arrayContaining([ + { key: 'nickname', value: 'fuck' }, + { key: 'level', value: '5' }, + ]), + ) + expect(res.body.rejectedProps).toEqual([]) + }) + // this is more likely to happen with event/stat flushing, but easier to test it here it('should handle unique constraint failures for groups', async () => { const redisSetSpy = vi.spyOn(Redis.prototype, 'set').mockResolvedValue('OK') diff --git a/tests/routes/protected/game/settings.test.ts b/tests/routes/protected/game/settings.test.ts index ffec071d4..d13ecdd9a 100644 --- a/tests/routes/protected/game/settings.test.ts +++ b/tests/routes/protected/game/settings.test.ts @@ -29,8 +29,9 @@ describe('Game - settings', () => { purgeLivePlayers: false, purgeDevPlayersRetention: 30, purgeLivePlayersRetention: 60, - website: 'https://example.com', blockAliasIdentifierProfanity: false, + blockPropsProfanity: false, + website: 'https://example.com', gameToken: expect.any(String), }) } diff --git a/tests/routes/protected/game/update.test.ts b/tests/routes/protected/game/update.test.ts index 22375698d..fc65c88a5 100644 --- a/tests/routes/protected/game/update.test.ts +++ b/tests/routes/protected/game/update.test.ts @@ -420,6 +420,24 @@ describe('Game - update', () => { }, ) + it.each(userPermissionProvider([]))( + 'should update blockPropsProfanity for a %s user', + async (statusCode, _, type) => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type }, organisation) + + await request(app) + .patch(`/games/${game.id}`) + .send({ blockPropsProfanity: true }) + .auth(token, { type: 'bearer' }) + .expect(statusCode) + + if (statusCode === 200) { + expect((await em.refreshOrFail(game)).blockPropsProfanity).toBe(true) + } + }, + ) + it('should not update game names if an empty string is sent', async () => { const [organisation, game] = await createOrganisationAndGame() const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation)