mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Better username censoring (#3122)
## Description: Many inapropriate names bypass the current filter. This PR does the following: 1. Moves name censoring to server side so inappropriate names are scrubbed before being sent to the client 2. Requests a list of profane words from the api, this allows us to quickly add new profane words in the admin panel without having to redeploy. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -227,6 +227,7 @@ async function createClientGame(
|
||||
config,
|
||||
gameMap,
|
||||
clientID,
|
||||
lobbyConfig.playerName,
|
||||
lobbyConfig.gameStartInfo.gameID,
|
||||
lobbyConfig.gameStartInfo.players,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,6 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
import { simpleHash } from "./Util";
|
||||
import { censorNameWithClanTag } from "./validations/username";
|
||||
|
||||
export async function createGameRunner(
|
||||
gameStart: GameStartInfo,
|
||||
@@ -48,7 +47,7 @@ export async function createGameRunner(
|
||||
|
||||
const humans = gameStart.players.map((p) => {
|
||||
return new PlayerInfo(
|
||||
p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
|
||||
p.username,
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
|
||||
@@ -603,12 +603,20 @@ export class GameView implements GameMap {
|
||||
private _config: Config,
|
||||
private _mapData: TerrainMapData,
|
||||
private _myClientID: ClientID,
|
||||
private _myUsername: string,
|
||||
private _gameID: GameID,
|
||||
private humans: Player[],
|
||||
) {
|
||||
this._map = this._mapData.gameMap;
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
// Replace the local player's username with their own stored username.
|
||||
// This way the user does not know they are being censored.
|
||||
for (const h of this.humans) {
|
||||
if (h.clientID === this._myClientID) {
|
||||
h.username = this._myUsername;
|
||||
}
|
||||
}
|
||||
this._cosmetics = new Map(
|
||||
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
|
||||
);
|
||||
|
||||
@@ -1,92 +1,9 @@
|
||||
import {
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
englishRecommendedTransformers,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
} from "obscenity";
|
||||
import { translateText } from "../../client/Utils";
|
||||
import { UsernameSchema } from "../Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../Util";
|
||||
|
||||
const matcher = new RegExpMatcher({
|
||||
...englishDataset.build(),
|
||||
...englishRecommendedTransformers,
|
||||
...resolveConfusablesTransformer(),
|
||||
...skipNonAlphabeticTransformer(),
|
||||
...collapseDuplicatesTransformer(),
|
||||
...resolveLeetSpeakTransformer(),
|
||||
});
|
||||
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 27;
|
||||
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
export function fixProfaneUsername(username: string): string {
|
||||
if (isProfaneUsername(username)) {
|
||||
return shadowNames[simpleHash(username) % shadowNames.length];
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function isProfaneUsername(username: string): boolean {
|
||||
return matcher.hasMatch(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Preserves non-profane clan tag:
|
||||
* prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLaN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
export function censorNameWithClanTag(username: string): string {
|
||||
// Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false;
|
||||
const usernameIsProfane = isProfaneUsername(nameWithoutClan);
|
||||
|
||||
const censoredNameWithoutClan = usernameIsProfane
|
||||
? fixProfaneUsername(nameWithoutClan)
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag if it existed and is not profane
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
|
||||
// Don't restore profane or nonexistent clan tag
|
||||
return censoredNameWithoutClan;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ export class Client {
|
||||
public readonly flares: string[] | undefined,
|
||||
public readonly ip: string,
|
||||
public readonly username: string,
|
||||
public readonly uncensoredUsername: string,
|
||||
public ws: WebSocket,
|
||||
public readonly cosmetics: PlayerCosmetics | undefined,
|
||||
) {}
|
||||
|
||||
+119
-1
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
DataSet,
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
pattern,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import {
|
||||
@@ -7,6 +18,95 @@ import {
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
|
||||
|
||||
export const shadowNames = [
|
||||
"UnhuggedToday",
|
||||
"DaddysLilChamp",
|
||||
"BunnyKisses67",
|
||||
"SnugglePuppy",
|
||||
"CuddleMonster67",
|
||||
"DaddysLilStar",
|
||||
"SnuggleMuffin",
|
||||
"PeesALittle",
|
||||
"PleaseFullSendMe",
|
||||
"NanasLilMan",
|
||||
"NoAlliances",
|
||||
"TryingTooHard67",
|
||||
"MommysLilStinker",
|
||||
"NeedHugs",
|
||||
"MommysLilPeanut",
|
||||
"IWillBetrayU",
|
||||
"DaddysLilTater",
|
||||
"PreciousBubbles",
|
||||
"67 Cringelord",
|
||||
"Peace And Love",
|
||||
"AlmostPottyTrained",
|
||||
];
|
||||
|
||||
export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
const customDataset = new DataSet<{ originalWord: string }>().addAll(
|
||||
englishDataset,
|
||||
);
|
||||
|
||||
for (const word of bannedWords) {
|
||||
customDataset.addPhrase((phrase) =>
|
||||
phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`),
|
||||
);
|
||||
}
|
||||
|
||||
return new RegExpMatcher({
|
||||
...customDataset.build(),
|
||||
blacklistMatcherTransformers: [
|
||||
toAsciiLowerCaseTransformer(),
|
||||
resolveConfusablesTransformer(),
|
||||
resolveLeetSpeakTransformer(),
|
||||
collapseDuplicatesTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLAN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
function censorUsernameWithMatcher(
|
||||
username: string,
|
||||
matcher: RegExpMatcher,
|
||||
): string {
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
|
||||
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
|
||||
|
||||
const censoredName = usernameIsProfane
|
||||
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag only if it's clean, otherwise remove it entirely
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredName}`;
|
||||
}
|
||||
|
||||
return censoredName;
|
||||
}
|
||||
|
||||
type CosmeticResult =
|
||||
| { type: "allowed"; cosmetics: PlayerCosmetics }
|
||||
@@ -14,13 +114,19 @@ type CosmeticResult =
|
||||
|
||||
export interface PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
|
||||
censorUsername(username: string): string;
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private matcher: RegExpMatcher;
|
||||
|
||||
constructor(
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
) {}
|
||||
bannedWords: string[],
|
||||
) {
|
||||
this.matcher = createMatcher(bannedWords);
|
||||
}
|
||||
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
const cosmetics: PlayerCosmetics = {};
|
||||
@@ -106,10 +212,22 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
return { color };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
return censorUsernameWithMatcher(username, this.matcher);
|
||||
}
|
||||
}
|
||||
|
||||
// Default matcher with no custom banned words (just englishDataset)
|
||||
const defaultMatcher = createMatcher([]);
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
|
||||
return { type: "allowed", cosmetics: {} };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
// Fail open: use matcher with just the built-in English profanity dataset
|
||||
return censorUsernameWithMatcher(username, defaultMatcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PrivilegeCheckerImpl,
|
||||
} from "./Privilege";
|
||||
|
||||
// Refreshes the privilege checker every 5 minutes.
|
||||
// Refreshes the privilege checker every 3 minutes.
|
||||
// WARNING: This fails open if cosmetics.json is not available.
|
||||
export class PrivilegeRefresher {
|
||||
private privilegeChecker: PrivilegeChecker | null = null;
|
||||
@@ -18,7 +18,9 @@ export class PrivilegeRefresher {
|
||||
private log: Logger;
|
||||
|
||||
constructor(
|
||||
private endpoint: string,
|
||||
private cosmeticsEndpoint: string,
|
||||
private profaneWordsEndpoint: string,
|
||||
private apiKey: string,
|
||||
parentLog: Logger,
|
||||
private refreshInterval: number = 1000 * 60 * 3,
|
||||
) {
|
||||
@@ -37,27 +39,62 @@ export class PrivilegeRefresher {
|
||||
}
|
||||
|
||||
private async loadPrivilegeChecker(): Promise<void> {
|
||||
this.log.info(`Loading privilege checker from ${this.endpoint}`);
|
||||
this.log.info(`Loading privilege checker`);
|
||||
try {
|
||||
const response = await fetch(this.endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const fetchWithTimeout = async (url: string) => {
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { "x-api-key": this.apiKey },
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.warn(`Failed to fetch ${url}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([
|
||||
fetchWithTimeout(this.cosmeticsEndpoint),
|
||||
fetchWithTimeout(this.profaneWordsEndpoint),
|
||||
]);
|
||||
|
||||
if (!cosmeticsResponse || !cosmeticsResponse.ok) {
|
||||
throw new Error(
|
||||
`Cosmetics HTTP error! status: ${cosmeticsResponse?.status ?? "network error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const cosmeticsData = await response.json();
|
||||
const cosmeticsData = await cosmeticsResponse.json();
|
||||
const result = CosmeticsSchema.safeParse(cosmeticsData);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
|
||||
}
|
||||
|
||||
let bannedWords: string[] = [];
|
||||
if (profaneWordsResponse && profaneWordsResponse.ok) {
|
||||
try {
|
||||
bannedWords = await profaneWordsResponse.json();
|
||||
this.log.info(
|
||||
`Loaded ${bannedWords.length} profane words from ${this.profaneWordsEndpoint}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.warn(`Failed to parse profane words JSON, using empty list`);
|
||||
}
|
||||
} else {
|
||||
this.log.warn(
|
||||
`Failed to fetch profane words (status ${profaneWordsResponse?.status ?? "network error"}), using empty list`,
|
||||
);
|
||||
}
|
||||
|
||||
this.privilegeChecker = new PrivilegeCheckerImpl(
|
||||
result.data,
|
||||
base64url.decode,
|
||||
bannedWords,
|
||||
);
|
||||
this.log.info(`Privilege checker loaded successfully`);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error);
|
||||
this.log.error(`Failed to load privilege checker:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function startWorker() {
|
||||
|
||||
const privilegeRefresher = new PrivilegeRefresher(
|
||||
config.jwtIssuer() + "/cosmetics.json",
|
||||
config.jwtIssuer() + "/profane_words_game_server",
|
||||
config.apiKey(),
|
||||
log,
|
||||
);
|
||||
privilegeRefresher.start();
|
||||
@@ -436,6 +438,11 @@ export async function startWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
// Censor profane usernames server-side (don't reject, just rename)
|
||||
const censoredUsername = privilegeRefresher
|
||||
.get()
|
||||
.censorUsername(clientMsg.username);
|
||||
|
||||
// Create client and add to game
|
||||
const client = new Client(
|
||||
generateID(),
|
||||
@@ -444,6 +451,7 @@ export async function startWorker() {
|
||||
roles,
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
clientMsg.username,
|
||||
ws,
|
||||
cosmeticResult.cosmetics,
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
// Mocking the obscenity library to control its behavior in tests.
|
||||
vi.mock("obscenity", () => {
|
||||
return {
|
||||
RegExpMatcher: class {
|
||||
private dummy: string[] = ["foo", "bar", "leet", "code"];
|
||||
constructor(_opts: any) {}
|
||||
hasMatch(input: string): boolean {
|
||||
const lower = input.toLowerCase();
|
||||
const decoded = lower
|
||||
.replace(/4/g, "a")
|
||||
.replace(/3/g, "e")
|
||||
.replace(/1/g, "i")
|
||||
.replace(/0/g, "o")
|
||||
.replace(/5/g, "s")
|
||||
.replace(/7/g, "t");
|
||||
return this.dummy.some((token) => decoded.includes(token));
|
||||
}
|
||||
},
|
||||
collapseDuplicatesTransformer: () => ({}),
|
||||
englishRecommendedTransformers: {},
|
||||
englishDataset: { build: () => ({}) },
|
||||
resolveConfusablesTransformer: () => ({}),
|
||||
resolveLeetSpeakTransformer: () => ({}),
|
||||
skipNonAlphabeticTransformer: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mocks the output of translation functions to return predictable values.
|
||||
vi.mock("../src/client/Utils", () => ({
|
||||
translateText: (key: string, vars?: any) =>
|
||||
@@ -32,53 +5,11 @@ vi.mock("../src/client/Utils", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
fixProfaneUsername,
|
||||
isProfaneUsername,
|
||||
MAX_USERNAME_LENGTH,
|
||||
validateUsername,
|
||||
} from "../src/core/validations/username";
|
||||
|
||||
describe("username.ts functions", () => {
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => {
|
||||
test.each([
|
||||
{ username: "l33t", profane: true }, // decodes to "leet"
|
||||
{ username: "L33T", profane: true },
|
||||
{ username: "l33tc0de", profane: true }, // decodes to "leetcode", contains "leet" and "code"
|
||||
{ username: "L33TC0DE", profane: true },
|
||||
{ username: "foo123", profane: true }, // contains "foo"
|
||||
{ username: "b4r", profane: true }, // decodes to "bar"
|
||||
{ username: "safeName", profane: false },
|
||||
{ username: "s4f3", profane: false }, // decodes to "safe" but "safe" not in dummy list
|
||||
])('isProfaneUsername("%s") → %s', ({ username, profane }) => {
|
||||
expect(isProfaneUsername(username)).toBe(profane);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ username: "safeName" },
|
||||
{ username: "l33t" },
|
||||
{ username: "b4rUser" },
|
||||
])('fixProfaneUsername("%s") behavior', ({ username }) => {
|
||||
const profane = isProfaneUsername(username);
|
||||
const fixed = fixProfaneUsername(username);
|
||||
if (!profane) {
|
||||
expect(fixed).toBe(username);
|
||||
} else {
|
||||
// When profane: result should be one of shadowNames
|
||||
expect(shadowNames).toContain(fixed);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUsername", () => {
|
||||
test("rejects non-string", () => {
|
||||
// @ts-expect-error: Testing non-string input to validateUsername on purpose
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
createMatcher,
|
||||
PrivilegeCheckerImpl,
|
||||
shadowNames,
|
||||
} from "../src/server/Privilege";
|
||||
|
||||
const bannedWords = [
|
||||
"hitler",
|
||||
"adolf",
|
||||
"nazi",
|
||||
"jew",
|
||||
"auschwitz",
|
||||
"whitepower",
|
||||
"heil",
|
||||
"chair", // Test word to verify custom banned words work
|
||||
];
|
||||
|
||||
const matcher = createMatcher(bannedWords);
|
||||
|
||||
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {} };
|
||||
const mockDecoder = () => new Uint8Array();
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
|
||||
|
||||
describe("UsernameCensor", () => {
|
||||
describe("isProfane (via matcher.hasMatch)", () => {
|
||||
test("detects exact banned words", () => {
|
||||
expect(matcher.hasMatch("hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("nazi")).toBe(true);
|
||||
expect(matcher.hasMatch("auschwitz")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects custom banned words like 'chair'", () => {
|
||||
expect(matcher.hasMatch("chair")).toBe(true);
|
||||
expect(matcher.hasMatch("Chair")).toBe(true);
|
||||
expect(matcher.hasMatch("CHAIR")).toBe(true);
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words case-insensitively", () => {
|
||||
expect(matcher.hasMatch("Hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("NAZI")).toBe(true);
|
||||
expect(matcher.hasMatch("Adolf")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with leet speak", () => {
|
||||
expect(matcher.hasMatch("h1tl3r")).toBe(true);
|
||||
expect(matcher.hasMatch("4d0lf")).toBe(true);
|
||||
expect(matcher.hasMatch("n4z1")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with duplicated characters", () => {
|
||||
expect(matcher.hasMatch("hiiitler")).toBe(true);
|
||||
expect(matcher.hasMatch("naazzii")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with accented characters", () => {
|
||||
expect(matcher.hasMatch("Adölf")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words as substrings", () => {
|
||||
expect(matcher.hasMatch("xhitlerx")).toBe(true);
|
||||
expect(matcher.hasMatch("IloveNazi")).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clean usernames", () => {
|
||||
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
|
||||
expect(matcher.hasMatch("GameMaster")).toBe(false);
|
||||
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("censorUsername", () => {
|
||||
test("returns clean usernames unchanged", () => {
|
||||
expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("GameMaster")).toBe("GameMaster");
|
||||
});
|
||||
|
||||
test("replaces profane usernames with a shadow name", () => {
|
||||
const result = checker.censorUsername("hitler");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("replaces leet speak profane usernames with a shadow name", () => {
|
||||
const result = checker.censorUsername("h1tl3r");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("preserves clean clan tag when username is profane", () => {
|
||||
const result = checker.censorUsername("[COOL]hitler");
|
||||
expect(result).toMatch(/^\[COOL\] /);
|
||||
const nameAfterTag = result.replace("[COOL] ", "");
|
||||
expect(shadowNames).toContain(nameAfterTag);
|
||||
});
|
||||
|
||||
test("removes profane clan tag but keeps clean username", () => {
|
||||
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with leet speak profanity", () => {
|
||||
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with uppercased banned word", () => {
|
||||
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag containing banned word substring", () => {
|
||||
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes profane clan tag and censors profane username", () => {
|
||||
const result = checker.censorUsername("[NAZI]hitler");
|
||||
// No clan tag prefix, just a shadow name
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("removes leet speak profane clan tag and censors leet speak username", () => {
|
||||
const result = checker.censorUsername("[N4Z1]h1tl3r");
|
||||
// No clan tag prefix, just a shadow name
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("returns deterministic shadow name for same input", () => {
|
||||
const a = checker.censorUsername("hitler");
|
||||
const b = checker.censorUsername("hitler");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("handles username with no clan tag", () => {
|
||||
expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer");
|
||||
});
|
||||
|
||||
test("empty banned words list still catches englishDataset profanity", () => {
|
||||
// The emptyChecker still uses englishDataset, so common profanity is caught
|
||||
expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
|
||||
// Verify a known english profanity gets censored even without custom banned words
|
||||
const result = emptyChecker.censorUsername("fuck");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user