mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
157 lines
5.9 KiB
JavaScript
157 lines
5.9 KiB
JavaScript
/**
|
|
* Reconnect-proof rate limiting tests (Phase 2.2).
|
|
*/
|
|
|
|
const RateLimiter = require('../src/network/rateLimiter');
|
|
const { clientIp, stableId } = require('../src/network/rateLimitContext');
|
|
const SocketHandlers = require('../src/network/socketHandlers');
|
|
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
|
|
|
function legalAcknowledgement(gameModeManager = null) {
|
|
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
|
|
return {
|
|
policyVersion: disclosure.policyVersion,
|
|
ageEligible: true,
|
|
termsRead: true,
|
|
riskAccepted: true,
|
|
testnetUnderstood: disclosure.service.isTestNetwork === true
|
|
};
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('payment creation limit is enforced at the socket boundary', () => {
|
|
test('blocks the fourth request before wallet or database work', async () => {
|
|
const rateLimiter = new RateLimiter({
|
|
limits: { 'payment:create': { window: 60000, max: 3 } }
|
|
});
|
|
const downstream = jest.fn().mockResolvedValue(undefined);
|
|
const socket = {
|
|
id: 'payment-socket',
|
|
handshake: { address: '192.0.2.20', headers: {} },
|
|
emit: jest.fn()
|
|
};
|
|
const context = {
|
|
rateLimiter,
|
|
sessionManager: { sessions: new Map([[socket.id, { id: 91 }]]) },
|
|
paymentHandlers: { handlePaymentRequest: downstream }
|
|
};
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
await SocketHandlers.prototype.handlePaymentRequest.call(
|
|
context,
|
|
socket,
|
|
{
|
|
type: 'credits_package',
|
|
packageId: 'small',
|
|
legalAcknowledgement: legalAcknowledgement()
|
|
}
|
|
);
|
|
}
|
|
|
|
expect(downstream).toHaveBeenCalledTimes(3);
|
|
expect(socket.emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({
|
|
code: 'RATE_LIMITED'
|
|
}));
|
|
rateLimiter.shutdown();
|
|
});
|
|
});
|
|
|
|
describe('match queue join throttling', () => {
|
|
test('bounds joins but never blocks an escrow-releasing leave', async () => {
|
|
const rateLimiter = new RateLimiter({
|
|
limits: { 'game:queue': { window: 60000, max: 1 } }
|
|
});
|
|
const socket = {
|
|
id: 'match-socket',
|
|
handshake: { address: '192.0.2.30', headers: {} },
|
|
emit: jest.fn()
|
|
};
|
|
const enqueue = jest.fn().mockResolvedValue({ success: true });
|
|
const leave = jest.fn().mockResolvedValue({ success: true });
|
|
const context = {
|
|
rateLimiter,
|
|
sessionManager: { sessions: new Map([[socket.id, { id: 92 }]]) },
|
|
matchQueue: { isEnabled: () => true, enqueue, leave },
|
|
_resolveMatchSession: jest.fn().mockResolvedValue({
|
|
userId: 92,
|
|
socketId: socket.id,
|
|
sessionToken: 'test-token'
|
|
}),
|
|
debugManager: { CONSOLE_LOGGING: false }
|
|
};
|
|
|
|
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
|
|
action: 'join', economy: 'free'
|
|
});
|
|
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
|
|
action: 'join', economy: 'free'
|
|
});
|
|
await SocketHandlers.prototype._handleMatchQueue.call(context, socket, {
|
|
action: 'leave', economy: 'free'
|
|
});
|
|
|
|
expect(enqueue).toHaveBeenCalledTimes(1);
|
|
expect(leave).toHaveBeenCalledTimes(1);
|
|
expect(socket.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({
|
|
code: 'RATE_LIMITED'
|
|
}));
|
|
rateLimiter.shutdown();
|
|
});
|
|
});
|