mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater. Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests. Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles. Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
64 lines
2.8 KiB
JavaScript
64 lines
2.8 KiB
JavaScript
/**
|
|
* Reconnect-proof rate limiting tests (Phase 2.2).
|
|
*/
|
|
|
|
const RateLimiter = require('../src/network/rateLimiter');
|
|
const { clientIp, stableId } = require('../src/network/rateLimitContext');
|
|
|
|
describe('rateLimitContext.stableId', () => {
|
|
test('prefers the session stable user id over the socket id', () => {
|
|
const sm = { sessions: new Map([['sockA', { id: 42 }]]) };
|
|
expect(stableId({ id: 'sockA' }, sm)).toBe('u:42');
|
|
});
|
|
test('falls back to socket id when no session', () => {
|
|
const sm = { sessions: new Map() };
|
|
expect(stableId({ id: 'sockZ' }, sm)).toBe('s:sockZ');
|
|
expect(stableId({ id: 'sockZ' }, undefined)).toBe('s:sockZ');
|
|
});
|
|
});
|
|
|
|
describe('rateLimitContext.clientIp', () => {
|
|
afterEach(() => { delete process.env.TRUST_PROXY; });
|
|
|
|
test('uses handshake address by default (ignores XFF when proxy not trusted)', () => {
|
|
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9' }, address: '10.0.0.1' } })).toBe('10.0.0.1');
|
|
});
|
|
test('uses the rightmost (proxy-appended) X-Forwarded-For hop when TRUST_PROXY=true', () => {
|
|
process.env.TRUST_PROXY = 'true';
|
|
// A single trusted nginx APPENDS the real client IP as the last hop; the leftmost entries
|
|
// are client-spoofable, so the rightmost is the only trustworthy one for rate limiting.
|
|
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9, 10.0.0.1' }, address: '7.7.7.7' } })).toBe('10.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('reconnect cannot bypass the limit (stable-id keyed)', () => {
|
|
test('two sockets for the same user share the limit', async () => {
|
|
const rl = new RateLimiter({ limits: { 'game:start': { window: 60000, max: 2 } } });
|
|
const sm = { sessions: new Map([['sockA', { id: 7 }], ['sockB', { id: 7 }]]) };
|
|
const idA = stableId({ id: 'sockA' }, sm); // u:7
|
|
const idB = stableId({ id: 'sockB' }, sm); // u:7 (reconnected, new socket)
|
|
expect(idA).toBe(idB);
|
|
|
|
await rl.recordAttempt(idA, 'game:start', '1.2.3.4');
|
|
await rl.recordAttempt(idA, 'game:start', '1.2.3.4'); // at max
|
|
const afterReconnect = await rl.checkLimit(idB, 'game:start', '1.2.3.4');
|
|
expect(afterReconnect.allowed).toBe(false); // blocked despite the new socket id
|
|
rl.shutdown();
|
|
});
|
|
|
|
test('chat:message is now IP-limited', () => {
|
|
const rl = new RateLimiter();
|
|
expect(rl._shouldApplyIpLimit('chat:message')).toBe(true);
|
|
rl.shutdown();
|
|
});
|
|
|
|
test('IP limit blocks a fresh anonymous identity from the same IP', async () => {
|
|
const rl = new RateLimiter({ limits: { 'game:start': { window: 60000, max: 1 } } });
|
|
// Two different "users" (e.g. cleared token) but same IP.
|
|
await rl.recordAttempt('u:1', 'game:start', '5.5.5.5');
|
|
const other = await rl.checkLimit('u:2', 'game:start', '5.5.5.5');
|
|
expect(other.allowed).toBe(false); // IP bucket already exhausted
|
|
rl.shutdown();
|
|
});
|
|
});
|