fix: resolve security/correctness review + AAA graphics pass

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.
This commit is contained in:
jwinterm
2026-07-09 17:46:58 -04:00
parent 0f68eec26b
commit a62eefb47b
48 changed files with 3964 additions and 623 deletions
+4 -2
View File
@@ -18,7 +18,8 @@ describe('SocketChatProvider', () => {
const broadcast = [];
const history = { saveMessage: (m) => { saved.push(m); return Promise.resolve(); } };
const broadcastManager = {
broadcastChatMessage: (username, text, ts, socketId) => broadcast.push({ username, text, ts, socketId })
// The 4th arg is the sanitized public id (never the raw socket.id — S1 defense in depth).
broadcastChatMessage: (username, text, ts, publicId) => broadcast.push({ username, text, ts, publicId })
};
const provider = new SocketChatProvider({ io, broadcastManager, historyManager: history });
@@ -27,7 +28,8 @@ describe('SocketChatProvider', () => {
expect(saved).toHaveLength(1);
expect(saved[0]).toMatchObject({ socketId: 'sock', username: 'abc123', message: 'hi', userId: 7 });
expect(broadcast).toHaveLength(1);
expect(broadcast[0]).toEqual({ username: 'abc123', text: 'hi', ts: 42, socketId: 'sock' });
// userId is present, so the broadcast is attributed by the public user id ('7'), not the socket id.
expect(broadcast[0]).toEqual({ username: 'abc123', text: 'hi', ts: 42, publicId: '7' });
});
test('global publish falls back to io.emit when no broadcastManager is given', async () => {
+356
View File
@@ -0,0 +1,356 @@
/**
* matchRework_ultra — regression coverage for the match-mode production hardening pass.
*
* Exercises the confirmed defects the rework fixes: the move() crash (MP-C2), the real
* countdown (MP-H6), reconnect re-keying + ownership (MP-H1/H2), winner consistency across
* end paths, BigInt/capped/no-address payouts (MP-C4/H5), refund-on-every-cancellation
* (MP-H3/H4), and payout-first isolated finalize (MP-C5). All timers created here are cleared
* in afterEach so the suite leaves no open handles.
*/
const MatchManager = require('../src/network/matchManager');
const MatchPayoutService = require('../src/network/matchPayoutService');
const MatchQueue = require('../src/network/matchQueue');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const MatchEngine = require('../src/multiplayer/MatchEngine');
const SEED = '0'.repeat(64);
const NEVER = 10 ** 9; // effectively-never timer duration for countdown/floor/ceiling
function makeIo() {
const broadcasts = [];
const sockets = new Map();
return {
broadcasts,
sockets: { sockets },
to(channel) {
return { emit: (event, payload) => broadcasts.push({ channel, event, payload }) };
}
};
}
function makeSocket(id) {
return { id, joined: [], emitted: [], join(ch) { this.joined.push(ch); }, emit(ev, p) { this.emitted.push({ ev, p }); } };
}
function makeCryptoFinishedRoom({ winner = 'a' } = {}) {
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
room.start();
room.playerStates.get(winner).escaped = true;
room.winnerId = winner;
room.status = 'finished';
room.finalize();
return room;
}
describe('match rework — crash + countdown (MatchManager)', () => {
const managers = [];
function mgr(opts = {}) {
const m = new MatchManager({ io: makeIo(), db: null, debugManager: null, ...opts });
m.enabled = true;
managers.push(m);
return m;
}
afterEach(() => {
while (managers.length) managers.pop().shutdown();
jest.useRealTimers();
delete process.env.MATCH_ENABLED;
});
test('MP-C2: move() no longer references undefined db/gameModeManager and never throws', () => {
const m = mgr();
// No match for this socket -> benign no-op (previously threw ReferenceError: db).
expect(() => m.move({ id: 'ghost' }, { dx: 1, dy: 0 })).not.toThrow();
// Garbage / missing payload is null-safe.
expect(() => m.move({ id: 'ghost' }, null)).not.toThrow();
expect(() => m.move({ id: 'ghost' }, { dx: 'x' })).not.toThrow();
// Disabled manager returns benignly.
m.enabled = false;
expect(() => m.move({ id: 'ghost' }, { dx: 1, dy: 0 })).not.toThrow();
});
test('MP-H6: moves are rejected until the countdown elapses, then accepted', () => {
const m = mgr();
m.moveCooldownMs = 0; // isolate the status gate from flood control
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: NEVER, hardCeilingMs: NEVER });
expect(room.status).toBe('starting');
m.move({ id: 's1' }, { dx: 1, dy: 0 });
expect(room.moveQueue.size).toBe(0); // rejected during 'starting'
room.start(); // simulate the countdown elapsing
m.move({ id: 's1' }, { dx: 1, dy: 0 });
expect(room.moveQueue.size).toBe(1); // accepted once 'active'
});
test('MP-H6: the countdown (not the scheduler) starts the engine + activates the room', () => {
jest.useFakeTimers();
const m = mgr();
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
const engine = new MatchEngine({ room, tickMs: NEVER, onFinish: (r) => m.onFinish(r) });
m.setEngine(room.id, engine);
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: 3000, minDurationMs: NEVER, hardCeilingMs: NEVER, tickMs: NEVER });
expect(room.status).toBe('starting');
jest.advanceTimersByTime(3000);
expect(room.status).toBe('active');
expect(engine.running).toBe(true);
});
test('leaked-timer safety: expire() is idempotent and clears the hard-ceiling watchdog', () => {
const m = mgr();
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
const engine = new MatchEngine({ room, tickMs: NEVER, onFinish: (r) => m.onFinish(r) });
m.setEngine(room.id, engine);
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: NEVER, hardCeilingMs: NEVER });
room.start();
m.expire(room.id, 'admin');
expect(room._finalized).toBe(true);
expect(room._watchdogs).toBeNull(); // ceiling/floor/countdown timers cleared
expect(engine.running).toBe(false); // engine stopped
// Second call must be a benign no-op (no double finalize, no throw).
expect(() => m.expire(room.id, 'admin')).not.toThrow();
});
});
describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => {
const managers = [];
afterEach(() => { while (managers.length) managers.pop().shutdown(); delete process.env.MATCH_ENABLED; });
function activeMatch() {
const m = new MatchManager({ io: makeIo(), db: null, debugManager: null });
m.enabled = true;
managers.push(m);
const room = new MatchRoom({ economy: 'free', entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED });
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
{ countdownMs: NEVER, minDurationMs: NEVER, hardCeilingMs: NEVER });
room.start();
return { m, room };
}
test('reconnect re-maps old socket id to new across occupants, playerStates + manager maps', () => {
const { m, room } = activeMatch();
// Disconnect s1 -> arms the AFK grace timer keyed by the OLD id.
m.handleDisconnect({ id: 's1' });
expect(m.disconnectTimeouts.has(`${room.id}:s1`)).toBe(true);
const sock = makeSocket('s1new');
const ok = m.handleReconnect(sock, { userId: 1 });
expect(ok).toBe(true);
// Occupant + state re-keyed to the new socket id, old key gone.
expect(room.occupants.has('s1new')).toBe(true);
expect(room.occupants.has('s1')).toBe(false);
expect(room.occupants.get('s1new').id).toBe('s1new'); // occupant.id updated too
expect(room.playerStates.has('s1new')).toBe(true);
expect(room.playerStates.get('s1new').userId).toBe(1);
expect(room.playerStates.has('s1')).toBe(false);
// Manager maps re-keyed; AFK timer for the OLD id cleared (never AFK-kill a reconnect).
expect(m.socketToMatch.get('s1new')).toBe(room.id);
expect(m.socketToMatch.has('s1')).toBe(false);
expect(m.disconnectTimeouts.has(`${room.id}:s1`)).toBe(false);
expect(sock.emitted.some(e => e.ev === 'match_rejoined')).toBe(true);
});
test('a session that does not own a racer cannot attach to the match', () => {
const { m, room } = activeMatch();
const evil = makeSocket('evil');
const ok = m.handleReconnect(evil, { userId: 999 }); // no racer with userId 999
expect(ok).toBe(false);
expect(room.occupants.has('evil')).toBe(false);
expect(m.socketToMatch.has('evil')).toBe(false);
});
});
describe('match rework — winner consistency across end paths', () => {
test('all-dead: winnerId equals the placement #1 player (never a stranded null)', () => {
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
room.start();
room._killPlayer('a', 'monster');
room._killPlayer('b', 'monster');
expect(room.status).toBe('finished');
expect(room.endReason).toBe('all_dead');
room.finalize();
const placeOne = [...room.playerStates.entries()].find(([, s]) => s.placement === 1);
expect(placeOne).toBeTruthy();
expect(room.winnerId).toBe(placeOne[0]); // payout target == leaderboard #1
expect(room.winnerId).not.toBeNull();
});
test('escape: the first player out stays the winner and is placement #1', () => {
const room = makeCryptoFinishedRoom({ winner: 'a' });
expect(room.winnerId).toBe('a');
expect(room.playerStates.get('a').placement).toBe(1);
});
});
describe('match rework — payout economics (MP-C4/H5)', () => {
function capturingDb({ address = 'WALLET', pot = '20000', fee = '1000' } = {}) {
const captured = [];
return {
captured,
async query(sql) {
if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: pot, house_fee_atomic: fee }] };
if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: address }] };
return { rows: [], rowCount: 0 };
},
async withTransaction(fn) {
const client = {
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('SELECT id FROM payouts')) return { rows: [] };
return { rows: [{ id: 1 }], rowCount: 1 };
}
};
return fn(client);
}
};
}
test('winner with an address gets a pending payout capped at pot minus house fee', async () => {
const db = capturingDb({ address: 'WALLET', pot: '20000', fee: '1000' });
const svc = new MatchPayoutService({ db });
await svc.payoutWinner(makeCryptoFinishedRoom());
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(ins.params[3]).toBe('19000'); // amount = 20000 - 1000
expect(ins.params[5]).toBe('match_winner');
expect(ins.params[6]).toBe('pending');
});
test('MP-H5: a winner with NO address still records a claimable payout (never forfeited)', async () => {
const db = capturingDb({ address: null, pot: '20000', fee: '1000' });
const svc = new MatchPayoutService({ db });
await svc.payoutWinner(makeCryptoFinishedRoom());
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(ins).toBeTruthy();
expect(ins.params[3]).toBe('19000'); // full owed amount recorded
expect(ins.params[6]).toBe('needs_review'); // deferred, batcher won't send it
expect(ins.params[5]).toBe('match_winner_no_address');
expect(ins.params[2]).toBe('PENDING_NO_ADDRESS'); // sentinel, non-null, claimable
});
test('BigInt exactness: a pot beyond 2^53 loses no precision', async () => {
const db = capturingDb({ address: 'WALLET', pot: '90071992547409920001', fee: '1' });
const svc = new MatchPayoutService({ db });
await svc.payoutWinner(makeCryptoFinishedRoom());
const ins = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
expect(ins.params[3]).toBe('90071992547409920000'); // exact; a float would round this
});
test('collectEntryTickets uses integer basis-point math for a fractional house fee', async () => {
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000000000';
process.env.MATCH_HOUSE_FEE_PERCENT = '5.5';
const captured = [];
const db = { async withTransaction(fn) { return fn({ async query(sql, params) { captured.push({ sql, params }); return { rows: [{ race_entries: 1 }], rowCount: 1 }; } }); } };
const svc = new MatchPayoutService({ db });
const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED });
await svc.collectEntryTickets(room, [{ userId: 1 }, { userId: 2 }]);
const upd = captured.find(q => q.sql.includes('UPDATE matches'));
expect(upd.params[0]).toBe('10000000000'); // entry fee
expect(upd.params[1]).toBe('20000000000'); // pot = fee * 2
expect(upd.params[2]).toBe('1100000000'); // house fee = pot * 550bp / 10000 (floor)
expect(room.potAtomic).toBeGreaterThan(0);
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
delete process.env.MATCH_HOUSE_FEE_PERCENT;
});
});
describe('match rework — refunds on every cancellation path (MP-H3/H4)', () => {
afterEach(() => { delete process.env.MATCH_ENABLED; delete process.env.MATCH_CREDITS_COST; });
test('refundEntries cancels the queue row AND writes a ticket refund ledger row', async () => {
process.env.MATCH_ENABLED = 'true';
const captured = [];
const db = {
async query() { return { rows: [], rowCount: 0 }; },
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('UPDATE match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
return { rows: [{ race_entries: 5 }], rowCount: 1 };
}
});
}
};
const q = new MatchQueue({ db });
await q.refundEntries([{ userId: 1 }], 'crypto_race', 'match_cancel');
expect(captured.some(c => c.sql.includes("status = 'cancelled'"))).toBe(true);
expect(captured.some(c => c.sql.includes('race_entries = race_entries + 1'))).toBe(true);
expect(captured.some(c => c.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
});
test('MP-H4: a credits leave after restart refunds the DB-derived amount, not memory', async () => {
process.env.MATCH_ENABLED = 'true';
// No MATCH_CREDITS_COST set -> configured default is 1; the ledger says 7 was deducted.
const captured = [];
const db = {
async query() { return { rows: [], rowCount: 0 }; },
async withTransaction(fn) {
return fn({
async query(sql, params) {
captured.push({ sql, params });
if (sql.includes('DELETE FROM match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
if (sql.includes("reason = 'match_queue_join'")) return { rows: [{ amount: '-7' }], rowCount: 1 };
if (sql.includes('UPDATE users SET credits')) return { rows: [{ credits: 107 }], rowCount: 1 };
return { rows: [{}], rowCount: 1 };
}
});
}
};
const q = new MatchQueue({ db }); // in-memory queue is EMPTY (simulates a restart)
const res = await q.leave(1, 'credits_prestige');
expect(res.success).toBe(true);
const upd = captured.find(c => c.sql.includes('UPDATE users SET credits'));
expect(upd.params[0]).toBe(7); // exact deducted amount from the ledger, not config (1)
});
test('leave accepts a resolved session object (socketHandlers form)', async () => {
process.env.MATCH_ENABLED = 'true';
const db = {
async query() { return { rows: [], rowCount: 0 }; },
async withTransaction(fn) {
return fn({
async query(sql) {
if (sql.includes('DELETE FROM match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
return { rows: [{ race_entries: 3 }], rowCount: 1 };
}
});
}
};
const q = new MatchQueue({ db });
const res = await q.leave({ userId: 1, economy: 'crypto_race' });
expect(res.success).toBe(true);
});
});
describe('match rework — payout-first isolated finalize (MP-C5)', () => {
afterEach(() => { delete process.env.MATCH_ENABLED; });
test('the winner payout runs FIRST and the match_end broadcast still fires when later steps throw', async () => {
const io = makeIo();
const throwingDb = { async withTransaction() { throw new Error('persist boom'); } };
const m = new MatchManager({ io, db: throwingDb, debugManager: null });
m.enabled = true;
const order = [];
m.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); throw new Error('lb boom'); };
const room = makeCryptoFinishedRoom();
m.rooms.set(room.id, room);
await m._finalize(room);
expect(order[0]).toBe('payout'); // payout is attempted before persist/leaderboard
// Persist threw and leaderboard threw, yet the result was still broadcast to clients.
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(true);
m.shutdown();
});
});
+34 -17
View File
@@ -2,6 +2,21 @@ const MatchScheduler = require('../src/network/matchScheduler');
const MatchQueue = require('../src/network/matchQueue');
describe('MatchScheduler', () => {
let scheduler = null;
let manager = null;
afterEach(() => {
// The scheduler starts long-lived timers when it drains a queue: a hard-ceiling
// setTimeout (owned by the scheduler) and, per match, an engine tick interval (owned
// by the manager via setEngine). Stop both so Jest can exit instead of leaking handles
// (an unstopped ceiling timer later fires manager.expire() and crashes the process).
if (scheduler) scheduler.shutdown();
if (manager) for (const engine of manager.engines.values()) engine.stop();
scheduler = null;
manager = null;
delete process.env.MATCH_ENABLED;
});
function makeQueue() {
const entries = [];
let nextId = 1;
@@ -26,45 +41,46 @@ describe('MatchScheduler', () => {
function makeManager() {
const attached = [];
const engines = new Map();
return {
attached,
engines,
db: null,
attach(room, entrants, opts) {
this.attached.push({ room, entrants, opts });
},
setEngine(id, engine) {},
setEngine(id, engine) { engines.set(id, engine); },
onTick() {},
onFinish() {}
onFinish() {},
expire() {}
};
}
test('does nothing when disabled', async () => {
process.env.MATCH_ENABLED = 'false';
const queue = makeQueue();
const manager = makeManager();
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
await scheduler.onBlock(100);
expect(manager.attached.length).toBe(0);
delete process.env.MATCH_ENABLED;
});
test('does nothing when fewer than 2 players are queued', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
const manager = makeManager();
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
await scheduler.onBlock(100);
expect(manager.attached.length).toBe(0);
expect(queue.length('free')).toBe(1);
delete process.env.MATCH_ENABLED;
});
test('drains free queue into a match when 2+ players queued', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
const manager = makeManager();
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
await scheduler.onBlock(100);
@@ -73,20 +89,21 @@ describe('MatchScheduler', () => {
expect(room.economy).toBe('free');
expect(entrants.length).toBe(2);
expect(queue.length('free')).toBe(0);
delete process.env.MATCH_ENABLED;
});
test('starts the match engine after attach', async () => {
test('hands off the engine without starting it (real countdown, no head-start)', async () => {
process.env.MATCH_ENABLED = 'true';
const queue = makeQueue();
const manager = makeManager();
const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, tickMs: 100 });
manager = makeManager();
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, tickMs: 100 });
await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
await scheduler.onBlock(100);
const { room } = manager.attached[0];
// Engine exists and was started via attach (the actual engine lives in scheduler internals).
expect(room.status).toBe('active');
delete process.env.MATCH_ENABLED;
// MP-H6: the scheduler creates the engine and hands it to the manager (setEngine) but
// does NOT start it. The room stays in 'starting' — moves are rejected — until the
// manager's countdown elapses, so honest and modified clients always start together.
expect(manager.engines.size).toBe(1);
expect(room.status).toBe('starting');
});
});
+208
View File
@@ -0,0 +1,208 @@
/**
* Payment-safety fixes (M1, M3, M4, M5, C1).
*
* Covers the gameModeManager hardening:
* - M1: an ambiguous SINGLE payout failure is marked 'needs_review' (not 'failed') + alerts.
* - M3: a concurrent single_game payment claim aborts cleanly (no duplicate game / throw).
* - M4: completeGame refuses to insert a payout it can't attach to a resolved game + user.
* - M5: the payout amount is clamped to the configured max (cap+alert) / skipped below min.
* - C1: getOrCreateUser(socketId, { create: false }) returns null without inserting a user.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockDb = () => {
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
return {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
_mockClient: mockClient
};
};
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: false, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: {
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1000000000n, maxPayout: 10000000000000n },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n, minPayout: 1000000000n, maxPayout: 5000000000000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm(walletService = { processPayout: jest.fn(), processBatchPayout: jest.fn() }) {
const db = createMockDb();
const gmm = new GameModeManager(db, walletService, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig());
gmm._scheduleBatchPayout = jest.fn(); // never actually schedule
gmm.alertService = { sendAlert: jest.fn().mockResolvedValue() };
return { gmm, db };
}
const ONE_PENDING = [
{ id: 42, user_id: 1, game_id: 7, payout_address: 'addrA', amount: '100000000000', multiplier: 2, reason: 'escape' }
];
describe('M1 — single payout ambiguous failure', () => {
test('is marked needs_review (never failed) and alerts', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '10000000000000' }),
processPayout: jest.fn().mockRejectedValue(new Error('RPC timeout after broadcast?')),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: ONE_PENDING }) // SELECT ... FOR UPDATE SKIP LOCKED
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
expect(reviewCall).toBeDefined();
expect(reviewCall[1][2]).toBe(42); // payout id
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
expect(failedCall).toBeUndefined();
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('single_payout_failed', expect.any(Object));
});
test('a pre-broadcast insufficient-funds error stays pending (safe retry), no alert', async () => {
const walletService = {
getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '10000000000000' }),
processPayout: jest.fn().mockRejectedValue(new Error('not enough unlocked money to transfer')),
processBatchPayout: jest.fn()
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: ONE_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$3/i.test(c[0]));
expect(pendingCall).toBeDefined();
expect(gmm.alertService.sendAlert).not.toHaveBeenCalled();
});
});
describe('M3 — atomic single_game payment claim', () => {
test('aborts cleanly when the payment was already consumed (claim returns no row)', async () => {
const { gmm } = buildGmm();
// withTransaction runs cb with a client whose claim SELECT returns no rows.
gmm.db.withTransaction = jest.fn(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }));
const res = await gmm._processGameStartWithPayment({ id: 1, payout_address: 'addr' }, { id: 55 }, 'seed-1');
expect(res.success).toBe(false);
expect(res.alreadyConsumed).toBe(true);
});
test('treats a games.payment_id unique violation (23505) as already consumed, not a throw', async () => {
const { gmm } = buildGmm();
gmm.db.withTransaction = jest.fn(async (cb) => {
const client = {
query: jest.fn()
.mockResolvedValueOnce({ rows: [{ id: 55 }] }) // claim SELECT ... FOR UPDATE finds the payment
.mockImplementationOnce(() => { const e = new Error('duplicate key'); e.code = '23505'; throw e; })
};
return cb(client); // the throw propagates like the real withTransaction (rollback + rethrow)
});
const res = await gmm._processGameStartWithPayment({ id: 1, payout_address: 'addr' }, { id: 55 }, 'seed-1');
expect(res.success).toBe(false);
expect(res.alreadyConsumed).toBe(true);
});
});
describe('M4 — completeGame never inserts an unattached payout', () => {
test('refuses payout when the paying user cannot be resolved from the game row', async () => {
const { gmm, db } = buildGmm();
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, /* no user_id */ game_mode: 'PAID_SINGLE', payout_address: 'addr' }] }) // game record
.mockResolvedValue({ rows: [] }); // completion UPDATE + existing-payout check
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
expect(result.reason).toBe('Paying user not resolved');
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(inserted).toBeUndefined();
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_unresolved_user', expect.any(Object));
});
});
describe('M5 — payout amount is clamped to configured bounds', () => {
test('caps an over-max payout and alerts', async () => {
const { gmm, db } = buildGmm();
db.query
.mockResolvedValueOnce({ rows: [{
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
payout_escape_amount: '99999999999999', payout_treasure_amount: '99999999999999',
payout_escape_mult: '2', payout_treasure_mult: '3'
}] }) // game record
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'addr' }] }) // user lookup by id
.mockResolvedValueOnce({ rows: [{ id: 999 }] }); // INSERT payout
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
expect(result.payout.amount).toBe('10000000000000'); // capped to maxPayout
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_over_max', expect.any(Object));
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(inserted[1][3]).toBe('10000000000000'); // amount param is the capped value
});
test('skips a below-min payout entirely (no insert)', async () => {
const { gmm, db } = buildGmm();
db.query
.mockResolvedValueOnce({ rows: [{
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
payout_escape_amount: '1', payout_treasure_amount: '1',
payout_escape_mult: '2', payout_treasure_mult: '3'
}] })
.mockResolvedValue({ rows: [] });
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
expect(result.reason).toBe('Below minimum payout');
expect(result.payout).toBeNull();
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(inserted).toBeUndefined();
});
});
describe('C1 — getOrCreateUser create:false', () => {
test('returns null and never inserts when no user row exists', async () => {
const { gmm, db } = buildGmm();
db.query.mockResolvedValue({ rows: [] }); // socket lookup finds nothing
const user = await gmm.getOrCreateUser('ghost-socket', { create: false });
expect(user).toBeNull();
const insert = db.query.mock.calls.find(c => /INSERT INTO users/i.test(c[0]));
expect(insert).toBeUndefined();
});
test('still creates by default for existing callers', async () => {
const { gmm, db } = buildGmm();
db.query
.mockResolvedValueOnce({ rows: [] }) // socket lookup: none
.mockResolvedValueOnce({ rows: [{ id: 7, socket_id: 's' }] }); // INSERT ... RETURNING *
const user = await gmm.getOrCreateUser('s');
expect(user).toEqual({ id: 7, socket_id: 's' });
const insert = db.query.mock.calls.find(c => /INSERT INTO users/i.test(c[0]));
expect(insert).toBeDefined();
});
});
+7 -7
View File
@@ -300,7 +300,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('processes winning game with payout correctly', async () => {
// Game record lookup
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
// Game update
mockDb.query.mockResolvedValueOnce({ rows: [] });
@@ -329,7 +329,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('treasure bonus applied correctly to payout', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] });
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
@@ -351,7 +351,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('no payout for losing game', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
@@ -364,7 +364,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('no payout when user has no payout address', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
@@ -382,7 +382,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('prevents duplicate payout for same game', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, payment_mode: 'direct' }]
rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }]
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
// Existing payout found
@@ -407,7 +407,7 @@ describe('Payment & Payout Comprehensive Tests', () => {
gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 };
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1, game_mode: 'PAID_CREDITS' }] // Game was started with credits
rows: [{ id: 1, user_id: 1, game_mode: 'PAID_CREDITS' }] // Game was started with credits
});
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
@@ -657,7 +657,7 @@ describe('Edge Cases and Error Handling', () => {
test('handles payout insert failure gracefully', async () => {
gmm.gameMode = 'PAID_SINGLE';
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, payment_mode: 'direct' }] });
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }] });
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout
mockDb.query.mockResolvedValueOnce({
+2 -2
View File
@@ -57,7 +57,7 @@ describe('Payout uses the snapshot recorded at game start', () => {
// live 2x = 200000000000), treasure pays 999999.
db.query
.mockResolvedValueOnce({ rows: [{
id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr',
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr',
payout_escape_amount: '123456', payout_treasure_amount: '999999',
payout_escape_mult: '2.000', payout_treasure_mult: '3.000'
}] }) // game record lookup
@@ -79,7 +79,7 @@ describe('Payout uses the snapshot recorded at game start', () => {
const { gmm, db } = buildGmm();
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // no snapshot cols
.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // no snapshot cols
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] })
+1
View File
@@ -36,6 +36,7 @@ describe('product grants', () => {
expect(summary).toEqual({
credits: 0,
raceEntries: 0,
packs: ['iso-dungeon'],
premiumLevel: null
});
+52 -1
View File
@@ -9,7 +9,7 @@
const DungeonGenerator = require('../src/game/dungeon');
const Monster = require('../src/game/monster');
const { createSeededRNG, hashSeed, verifyGame } = require('../src/game/provablyFair');
const { createSeededRNG, hashSeed, verifyGame, deriveSeed } = require('../src/game/provablyFair');
const SEED_A = 'a'.repeat(64);
const SEED_B = 'b'.repeat(64);
@@ -48,6 +48,57 @@ describe('Provably fair: deterministic dungeon generation', () => {
});
});
describe('Provably fair: seed derivation (server + client seed)', () => {
test('deriveSeed is HMAC-SHA256(key=serverSeed, msg=clientSeed) hex', () => {
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', SEED_A).update('my-client-seed').digest('hex');
expect(deriveSeed(SEED_A, 'my-client-seed')).toBe(expected);
// 64-char hex output
expect(deriveSeed(SEED_A, 'my-client-seed')).toMatch(/^[0-9a-f]{64}$/);
});
test('clientSeed defaults to empty string (non-breaking) and is deterministic', () => {
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', SEED_A).update('').digest('hex');
expect(deriveSeed(SEED_A)).toBe(expected);
expect(deriveSeed(SEED_A)).toBe(deriveSeed(SEED_A, ''));
});
test('different client seeds yield different derived seeds (client perturbs outcome)', () => {
expect(deriveSeed(SEED_A, 'x')).not.toBe(deriveSeed(SEED_A, 'y'));
// and a different server seed changes the result too
expect(deriveSeed(SEED_A, 'x')).not.toBe(deriveSeed(SEED_B, 'x'));
});
test('a verifier can reproduce the dungeon from serverSeed + clientSeed', () => {
const serverSeed = SEED_A;
const clientSeed = 'player-chosen';
// Commitment is over the serverSeed only (locked before clientSeed is known).
const commitment = hashSeed(serverSeed);
expect(verifyGame(serverSeed, commitment).valid).toBe(true);
// The effective seed drives generation; the verifier re-derives it and regenerates.
const effective = deriveSeed(serverSeed, clientSeed);
const fp1 = DungeonGenerator.layoutFingerprint(DungeonGenerator.regenerateFromSeed(effective, 'WOW'));
const fp2 = DungeonGenerator.layoutFingerprint(
DungeonGenerator.regenerateFromSeed(deriveSeed(serverSeed, clientSeed), 'WOW')
);
expect(fp1).toBe(fp2);
});
});
describe('Provably fair: seeded RNG is bounded to [0, 1)', () => {
test('every value stays within [0, 1) across many draws and seeds', () => {
for (let s = 0; s < 8; s++) {
const rng = createSeededRNG((s.toString(16)).repeat(64).slice(0, 64));
for (let i = 0; i < 500; i++) {
const v = rng();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
}
});
});
describe('Dungeon reachability (Phase 3.2)', () => {
test('generated dungeons always have a reachable exit (many seeds)', () => {
const cfg = { primaryFloor: "'1", secondaryFloor: "'2" };
+4 -2
View File
@@ -23,9 +23,11 @@ describe('rateLimitContext.clientIp', () => {
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 first X-Forwarded-For hop when TRUST_PROXY=true', () => {
test('uses the rightmost (proxy-appended) X-Forwarded-For hop when TRUST_PROXY=true', () => {
process.env.TRUST_PROXY = 'true';
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9, 10.0.0.1' }, address: '10.0.0.1' } })).toBe('9.9.9.9');
// 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');
});
});
+18 -8
View File
@@ -134,12 +134,20 @@ describe('SQL Injection Prevention', () => {
let dbAvailable = false;
beforeAll(async () => {
// Try to connect to real DB for DatabaseManager tests
realDb = new DatabaseManager();
await realDb.initialize();
// Check if connection was successful (initialize doesn't throw, it returns silently)
dbAvailable = realDb.connected === true;
// Only attempt a real DB connection when a usable password is configured. Without one,
// node-postgres throws inside the SASL handshake *after* opening the TCP socket and
// orphans that socket (it survives pool.end()), which would keep Jest from exiting. A
// missing password simply means "DB not available" here — and these tests already fall
// back to mocks in that case.
const hasDbPassword = typeof process.env.DB_PASSWORD === 'string' && process.env.DB_PASSWORD.length > 0;
if (hasDbPassword) {
// Try to connect to real DB for DatabaseManager tests
realDb = new DatabaseManager();
await realDb.initialize();
// Check if connection was successful (initialize doesn't throw, it returns silently)
dbAvailable = realDb.connected === true;
}
if (!dbAvailable) {
console.log('⚠️ Database not available, DatabaseManager tests will use mocks');
}
@@ -164,8 +172,10 @@ describe('SQL Injection Prevention', () => {
if (sessionManager) {
sessionManager.dispose();
}
if (realDb && dbAvailable) {
await realDb.close();
// Always close the pool: initialize() creates it even when the connection fails, so
// gating on dbAvailable would leak the pg pool handle and keep Jest from exiting.
if (realDb) {
try { await realDb.close(); } catch (_) {}
}
});