feat: replace generated nicknames with filtered user input

This commit is contained in:
2026-10-26 18:04:09 +09:00
parent 5cef1c6f1e
commit fc45534c7d
11 changed files with 147 additions and 40 deletions
+35 -1
View File
@@ -26,7 +26,9 @@ function documentSurface() {
return this.nodes.get(selector)!;
}
addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); }
prepend() {} after() {} append() {} showModal() {} close() {}
open = false;
prepend() {} after() {} append() {} setAttribute() {} focus() {}
showModal() { this.open = true; } close() { this.open = false; }
}
const document = {
body: new Element(), createElement: () => new Element(), querySelector: () => new Element(),
@@ -38,6 +40,38 @@ function documentSurface() {
return document;
}
test('first visit waits for a saved nickname and keeps the form open on rejection', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
const profile: PlayerProfile = { playerId: 'a', displayName: '', appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let writes = 0, initialized = false, rejectSave = true;
const api = { async request(_path: string, method?: string, body?: { displayName: string }) {
if (method === 'POST') {
writes++;
if (rejectSave) throw new Error('저장 실패');
return { ...profile, displayName: body!.displayName };
}
return profile;
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
const loading = lobby.initialize(false).then(() => { initialized = true; });
await Promise.resolve();
const dialog = document.getElementById('nickname-dialog');
assert.equal(dialog.open, true); assert.equal(initialized, false);
let prevented = false;
dialog.listeners.get('cancel')!({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
const submit = () => dialog.querySelector('form').listeners.get('submit')!({ preventDefault() {} });
dialog.querySelector('input').value = '씨 발'; await submit();
assert.equal(writes, 0); assert.equal(dialog.open, true);
dialog.querySelector('input').value = '손오공'; await submit();
assert.equal(initialized, false); assert.equal(dialog.querySelector('p').textContent, '저장 실패');
rejectSave = false; await submit(); await loading;
assert.equal(initialized, true); assert.equal(dialog.open, false);
});
test('ranked confirmation shows accept, waits for opponent and cancels by offer id', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { profileNameError } from '../src/shared/profile-name.js';
test('nickname validation accepts ordinary names and rejects profanity and separated variants', () => {
for (const name of ['손오공', '무도가', '구름-1234', 'ClassMaster', '별빛 전사']) assert.equal(profileNameError(name), undefined, name);
for (const name of ['시발', '씨 발', '씨-발', 'ㅅㅂ', '병_신', 'FUCK', 'fuck', '씨\u200b발', '시1발']) {
assert.equal(profileNameError(name), 'PROFILE_NAME_PROFANITY', name);
}
for (const name of ['', '가', '가'.repeat(17), '<img>', null, undefined, {}]) assert.equal(profileNameError(name), 'INVALID_PROFILE_NAME');
});
+16 -10
View File
@@ -156,16 +156,17 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
} finally { await f.close(); }
});
test('profiles keep generated names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => {
test('profiles save filtered names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => {
const f = await fixture();
try {
const s = f.service;
const original = await s.profile('player-a');
assert.equal((await s.profile('player-a')).displayName, '');
const original = await s.updateProfile('player-a', { displayName: '손오공' });
assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' }));
assert.deepEqual(original.unlockedEquipment, []);
assert.match(original.displayName, /^[가-힣]+-[0-9]{4}$/);
for (const displayName of ['손오공', '광고사이트', '<img>', null, undefined]) {
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_READONLY/);
assert.equal(original.displayName, '손오공');
for (const displayName of ['씨 발', '병_신', '<img>', null, undefined]) {
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
}
for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) {
await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/);
@@ -186,7 +187,7 @@ test('profiles keep generated names and validate unlocked appearances, persisten
} finally { await f.close(); }
});
test('startup migrates old custom and default names including nonvisiting leaderboard players exactly once', integration, async () => {
test('startup preserves old names and hides invalid leaderboard names without rewriting profiles', integration, async () => {
const f = await fixture();
try {
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
@@ -206,13 +207,18 @@ test('startup migrates old custom and default names including nonvisiting leader
const board = await restarted.leaderboard('new-viewer');
names = board.entries.map(entry => entry.displayName);
assert.equal(names.length, 2);
for (const name of names) assert.match(name, /^[가-힣]+-[0-9]{4}$/);
assert.deepEqual(names, ['플레이어', '무도가']);
assert.equal(board.entries[0].wins, 1);
const profile = await restarted.profile('player-a');
assert.deepEqual(profile.stats, expectedStats);
assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }));
assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records);
assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 1);
assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 0);
assert.equal((await profiles.findOne({ _id: 'player-a' })).displayName, '광고.example');
assert.equal(profile.displayName, '');
await restarted.updateProfile('player-a', { displayName: '새이름' });
names = (await restarted.leaderboard('new-viewer')).entries.map(entry => entry.displayName);
assert.deepEqual(names, ['새이름', '무도가']);
} finally { await restarted.close(); }
const again = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); }
@@ -556,11 +562,11 @@ test('ranked HTTP authenticates identity and ignores client results for ranked a
appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 },
} });
assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED');
for (const displayName of ['광고사이트', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) {
for (const displayName of ['씨발', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) {
const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: {
displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' },
} });
assert.equal(rejected.statusCode, 403); assert.equal(rejected.json().code, 'PROFILE_NAME_READONLY');
assert.equal(rejected.statusCode, 400); assert.match(rejected.json().code, /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
}
assert.deepEqual(await f.service.profile('player-a'), original);
const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } });