mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 23:58:33 +02:00
feat: productionize paid modes and PvP capture
This commit is contained in:
@@ -4,14 +4,19 @@ const { AppError } = require('../src/utils/errors');
|
||||
describe('AddressManager', () => {
|
||||
const sampleAddress = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn';
|
||||
|
||||
function createManager({ existingAddress = null } = {}) {
|
||||
function createManager({ existingAddress = null, cryptoType = null, network = null, walletValidation = null } = {}) {
|
||||
const emitMock = jest.fn();
|
||||
const toMock = jest.fn().mockReturnValue({ emit: emitMock });
|
||||
const broadcastMock = { sendStatusUpdate: jest.fn() };
|
||||
const gameModeManager = {
|
||||
cryptoType,
|
||||
network,
|
||||
setUserPayoutAddress: jest.fn().mockResolvedValue(true),
|
||||
getOrCreateUser: jest.fn().mockResolvedValue({ payout_address: existingAddress })
|
||||
};
|
||||
if (walletValidation) {
|
||||
gameModeManager.validatePayoutAddress = jest.fn().mockResolvedValue(walletValidation);
|
||||
}
|
||||
|
||||
const manager = new AddressManager({
|
||||
gameModeManager,
|
||||
@@ -47,4 +52,31 @@ describe('AddressManager', () => {
|
||||
expect(manager.pending.has('socket-3')).toBe(false);
|
||||
expect(emitMock).toHaveBeenCalledWith('address_confirmed', expect.objectContaining({ message: expect.stringContaining('already set') }));
|
||||
});
|
||||
|
||||
test('accepts stagenet XMR prefixes and rejects mainnet prefixes on a stagenet server', async () => {
|
||||
const { manager, gameModeManager } = createManager({
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
walletValidation: { valid: true, nettype: 'stagenet' }
|
||||
});
|
||||
const stagenetAddress = `5${'1'.repeat(94)}`;
|
||||
const mainnetAddress = `4${'1'.repeat(94)}`;
|
||||
|
||||
expect(manager.isValidAddress(stagenetAddress)).toBe(true);
|
||||
expect(manager.isValidAddress(mainnetAddress)).toBe(false);
|
||||
expect(await manager.saveAddress('socket-stage', stagenetAddress)).toBe(stagenetAddress);
|
||||
expect(gameModeManager.setUserPayoutAddress).toHaveBeenCalledWith('socket-stage', stagenetAddress);
|
||||
});
|
||||
|
||||
test('wallet-RPC validation fails closed before an address is persisted', async () => {
|
||||
const { manager, gameModeManager } = createManager({
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
walletValidation: { valid: false, nettype: 'mainnet' }
|
||||
});
|
||||
const stagenetShapedAddress = `7${'1'.repeat(94)}`;
|
||||
|
||||
expect(await manager.saveAddress('socket-bad', stagenetShapedAddress)).toBe(false);
|
||||
expect(gameModeManager.setUserPayoutAddress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const createAdminRoutes = require('../src/routes/admin');
|
||||
const AlertService = require('../src/services/alertService');
|
||||
|
||||
function makeRouter(db) {
|
||||
return createAdminRoutes({
|
||||
db,
|
||||
gameModeManager: {},
|
||||
walletRPCService: {},
|
||||
socketHandlers: {},
|
||||
io: {},
|
||||
alertService: null
|
||||
});
|
||||
}
|
||||
|
||||
function handlerFor(router, method, routePath) {
|
||||
const layer = router.stack.find(item => item.route?.path === routePath
|
||||
&& item.route.methods[method]);
|
||||
if (!layer) throw new Error(`Missing ${method.toUpperCase()} ${routePath}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
|
||||
function invoke(handler, req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
resolve({ status: this.statusCode, payload });
|
||||
return this;
|
||||
}
|
||||
};
|
||||
handler(req, res, reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('admin financial operations visibility', () => {
|
||||
test.each(['processing', 'needs_review'])(
|
||||
'payout list accepts the %s filter and reports every operational total',
|
||||
async status => {
|
||||
const db = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 7,
|
||||
game_id: 8,
|
||||
user_id: 9,
|
||||
amount: '1200',
|
||||
payout_address: 'address',
|
||||
status,
|
||||
tx_hash: null,
|
||||
fee: null,
|
||||
retry_count: 1,
|
||||
last_error: 'ambiguous result',
|
||||
created_at: new Date('2026-07-21T00:00:00Z'),
|
||||
processed_at: null
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
pending_count: '1',
|
||||
processing_count: '2',
|
||||
failed_count: '3',
|
||||
review_count: '4',
|
||||
completed_count: '5',
|
||||
permanently_failed_count: '6',
|
||||
batched_count: '7',
|
||||
total_volume: '8000'
|
||||
}] })
|
||||
};
|
||||
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/payouts');
|
||||
|
||||
const response = await invoke(handler, { query: { status } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.query.mock.calls[0][1]).toEqual([50, 0, status]);
|
||||
expect(response.payload.payouts[0]).toMatchObject({ status, lastError: 'ambiguous result' });
|
||||
expect(response.payload.totals).toMatchObject({
|
||||
pending: 1,
|
||||
processing: 2,
|
||||
failed: 3,
|
||||
needsReview: 4,
|
||||
completed: 5,
|
||||
permanentlyFailed: 6,
|
||||
batched: 7
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
test('refund attention filter lists requested, processing, and needs_review rows', async () => {
|
||||
const db = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 11,
|
||||
payment_id: 12,
|
||||
user_id: 13,
|
||||
payment_status: 'refunded',
|
||||
status: 'needs_review',
|
||||
amount: '900',
|
||||
payout_address: 'refund-address',
|
||||
credits_deducted: '2',
|
||||
purchase_progress_deducted: '2',
|
||||
race_entries_deducted: 0,
|
||||
tx_hash: null,
|
||||
error_message: 'wallet outcome unknown',
|
||||
reason: 'operator request',
|
||||
created_at: new Date('2026-07-21T00:00:00Z'),
|
||||
updated_at: new Date('2026-07-21T00:01:00Z')
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
recorded_count: '1',
|
||||
requested_count: '2',
|
||||
processing_count: '3',
|
||||
completed_count: '4',
|
||||
review_count: '5',
|
||||
completed_volume: '600'
|
||||
}] })
|
||||
};
|
||||
const handler = handlerFor(makeRouter(db), 'get', '/api/admin/stats/refunds');
|
||||
|
||||
const response = await invoke(handler, { query: { status: 'attention' } });
|
||||
|
||||
expect(db.query.mock.calls[0][1]).toEqual([
|
||||
50,
|
||||
0,
|
||||
['requested', 'processing', 'needs_review']
|
||||
]);
|
||||
expect(response.payload.refunds[0]).toMatchObject({
|
||||
id: 11,
|
||||
status: 'needs_review',
|
||||
error: 'wallet outcome unknown'
|
||||
});
|
||||
expect(response.payload.totals).toMatchObject({
|
||||
recorded: 1,
|
||||
requested: 2,
|
||||
processing: 3,
|
||||
completed: 4,
|
||||
needsReview: 5
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard sends the retry endpoint its required explicit confirmation', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '../html/admin.html'), 'utf8');
|
||||
const retryFunction = html.match(/async function retryPayout[\s\S]*?async function loadRefunds/)[0];
|
||||
|
||||
expect(retryFunction).toContain("'Content-Type': 'application/json'");
|
||||
expect(retryFunction).toContain('body: JSON.stringify({ confirm: true })');
|
||||
expect(html).toContain('<option value="processing">Processing</option>');
|
||||
expect(html).toContain('<option value="needs_review">Needs Review</option>');
|
||||
expect(html).toContain('id="refundStatusFilter"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('financial review alerts', () => {
|
||||
let logSpy;
|
||||
let errorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('alerts for manual-review and stale nonterminal rows without mutating them', async () => {
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [{
|
||||
payout_review_count: '2',
|
||||
refund_review_count: '1',
|
||||
stale_payout_processing_count: '1',
|
||||
stale_refund_nonterminal_count: '2'
|
||||
}] }) };
|
||||
const service = new AlertService({ db, walletService: null, debugManager: {} });
|
||||
service.sendAlert = jest.fn().mockResolvedValue({ sent: true });
|
||||
service.resolveAlert = jest.fn().mockResolvedValue({ sent: false });
|
||||
|
||||
await service.checkFinancialReviewRows();
|
||||
|
||||
expect(db.query).toHaveBeenCalledTimes(1);
|
||||
expect(db.query.mock.calls[0][1]).toEqual([service.financialReviewStaleMs]);
|
||||
expect(service.sendAlert).toHaveBeenCalledWith(
|
||||
'financial_needs_review',
|
||||
expect.objectContaining({ level: 'crit' })
|
||||
);
|
||||
expect(service.sendAlert).toHaveBeenCalledWith(
|
||||
'financial_nonterminal_stale',
|
||||
expect.objectContaining({ level: 'crit' })
|
||||
);
|
||||
expect(service.resolveAlert).not.toHaveBeenCalled();
|
||||
expect(db.query.mock.calls[0][0]).not.toMatch(/\bUPDATE\b/i);
|
||||
});
|
||||
|
||||
test('resolves both aggregate alerts after all review queues clear', async () => {
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [{
|
||||
payout_review_count: '0',
|
||||
refund_review_count: '0',
|
||||
stale_payout_processing_count: '0',
|
||||
stale_refund_nonterminal_count: '0'
|
||||
}] }) };
|
||||
const service = new AlertService({ db, walletService: null, debugManager: {} });
|
||||
service.sendAlert = jest.fn();
|
||||
service.resolveAlert = jest.fn().mockResolvedValue({ sent: true });
|
||||
|
||||
await service.checkFinancialReviewRows();
|
||||
|
||||
expect(service.sendAlert).not.toHaveBeenCalled();
|
||||
expect(service.resolveAlert).toHaveBeenCalledWith('financial_needs_review');
|
||||
expect(service.resolveAlert).toHaveBeenCalledWith('financial_nonterminal_stale');
|
||||
});
|
||||
|
||||
test('periodic checks include the financial review query', async () => {
|
||||
const service = new AlertService({ db: {}, walletService: null, debugManager: {} });
|
||||
service.checkWalletBalance = jest.fn().mockResolvedValue();
|
||||
service.checkWalletConnection = jest.fn().mockResolvedValue();
|
||||
service.checkPendingPayouts = jest.fn().mockResolvedValue();
|
||||
service.checkFinancialReviewRows = jest.fn().mockResolvedValue();
|
||||
|
||||
await service.runChecks();
|
||||
|
||||
expect(service.checkFinancialReviewRows).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ describe('admin routes factory', () => {
|
||||
'/api/admin/users/search',
|
||||
'/api/admin/stats/overview',
|
||||
'/api/admin/stats/payouts',
|
||||
'/api/admin/stats/refunds',
|
||||
'/api/admin/stats/games',
|
||||
'/api/admin/users',
|
||||
'/api/admin/users/:id',
|
||||
@@ -52,7 +53,14 @@ describe('admin routes factory', () => {
|
||||
|
||||
describe('auth (smirk) routes factory', () => {
|
||||
test('builds a router and registers the smirk endpoints', () => {
|
||||
const router = createAuthRoutes({ db: { query: jest.fn() } });
|
||||
const query = jest.fn();
|
||||
const router = createAuthRoutes({
|
||||
db: { query, withTransaction: jest.fn() },
|
||||
sessionManager: {
|
||||
generateSecureToken: jest.fn(),
|
||||
disconnectUserSessions: jest.fn()
|
||||
}
|
||||
});
|
||||
const paths = router.stack.filter(l => l.route).map(l => l.route.path);
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'/api/auth/smirk/challenge',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const AlertService = require('../src/services/alertService');
|
||||
|
||||
describe('AlertService exact reserve gate', () => {
|
||||
const keys = ['BALANCE_CRITICAL', 'BALANCE_WARN', 'LOW_BALANCE_THRESHOLD'];
|
||||
let previous;
|
||||
let logSpy;
|
||||
let errorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
||||
process.env.BALANCE_CRITICAL = '10000000000000001';
|
||||
process.env.BALANCE_WARN = '10000000000000002';
|
||||
delete process.env.LOW_BALANCE_THRESHOLD;
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
for (const key of keys) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('compares balances above Number.MAX_SAFE_INTEGER without rounding', async () => {
|
||||
const service = new AlertService({
|
||||
walletService: {
|
||||
isHealthy: true,
|
||||
getBalance: jest.fn(async () => ({ unlocked_balance: '10000000000000000' }))
|
||||
},
|
||||
db: null,
|
||||
debugManager: { CONSOLE_LOGGING: false }
|
||||
});
|
||||
|
||||
expect(service.balanceCriticalThreshold).toBe(10000000000000001n);
|
||||
await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({ halted: true }));
|
||||
});
|
||||
|
||||
test('wallet balance read errors fail closed for new paid games', async () => {
|
||||
const service = new AlertService({
|
||||
walletService: {
|
||||
isHealthy: true,
|
||||
getBalance: jest.fn(async () => { throw new Error('rpc unavailable'); })
|
||||
},
|
||||
db: null,
|
||||
debugManager: { CONSOLE_LOGGING: false }
|
||||
});
|
||||
|
||||
await expect(service.checkBalanceForGameStart()).resolves.toEqual(expect.objectContaining({
|
||||
halted: true,
|
||||
reason: expect.stringContaining('could not be verified')
|
||||
}));
|
||||
expect(service.isBalanceCritical).toBe(true);
|
||||
});
|
||||
});
|
||||
+141
-2
@@ -52,12 +52,35 @@ const TWO_PENDING = [
|
||||
{ id: 10, user_id: 1, game_id: 'g1', payout_address: 'addrA', amount: '200000000000', multiplier: 2, reason: 'escape' },
|
||||
{ id: 11, user_id: 2, game_id: 'g2', payout_address: 'addrB', amount: '300000000000', multiplier: 3, reason: 'escape' }
|
||||
];
|
||||
const ONE_PENDING = [TWO_PENDING[0]];
|
||||
const SHARED_TX_HASH = 'e'.repeat(64);
|
||||
|
||||
describe('Batch payout dispatch', () => {
|
||||
test('shared runtime dispatch predicate stops internal batch paths before DB or wallet work', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn(),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
gmm.payoutDispatchAllowed = jest.fn(() => false);
|
||||
|
||||
gmm._scheduleBatchPayout();
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
expect(gmm.payoutDispatchAllowed).toHaveBeenCalled();
|
||||
expect(gmm._batchPayoutTimer).toBeNull();
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
expect(db.query).not.toHaveBeenCalled();
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('marks the whole batch completed with the shared tx_hash in one transaction', async () => {
|
||||
const walletService = {
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn().mockResolvedValue({ tx_hash_list: ['txSHARED'], totalFee: 200 })
|
||||
processBatchPayout: jest.fn().mockResolvedValue({ success: true, tx_hash_list: [SHARED_TX_HASH], totalFee: 200 }),
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' })
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
|
||||
@@ -79,7 +102,7 @@ describe('Batch payout dispatch', () => {
|
||||
expect(batchUpdate).toMatch(/status = 'completed'/);
|
||||
|
||||
const batchUpdateCall = db._mockClient.query.mock.calls.find(c => /UPDATE payouts SET tx_hash/i.test(c[0]) && /id = ANY/i.test(c[0]));
|
||||
expect(batchUpdateCall[1][0]).toBe('txSHARED'); // tx_hash param
|
||||
expect(batchUpdateCall[1][0]).toBe(SHARED_TX_HASH); // tx_hash param
|
||||
expect(batchUpdateCall[1][3]).toEqual([10, 11]); // ids array
|
||||
|
||||
// Per-user stats incremented for each winner inside the same transaction (BIGINT, no float).
|
||||
@@ -112,6 +135,78 @@ describe('Batch payout dispatch', () => {
|
||||
expect(failedCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('single payout retains a valid wallet hash when the completion DB write fails', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: SHARED_TX_HASH, fee: 20 }),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: ONE_PENDING })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockRejectedValueOnce(new Error('database write failed after broadcast'));
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
|
||||
expect(review).toBeDefined();
|
||||
expect(review[0]).toMatch(/tx_hash = COALESCE\(\$3, tx_hash\)/i);
|
||||
expect(review[1][2]).toBe(SHARED_TX_HASH);
|
||||
expect(review[1][3]).toBe(10);
|
||||
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false);
|
||||
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('batch retains its one valid wallet hash when the completion DB write fails', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tx_hash_list: [SHARED_TX_HASH],
|
||||
totalFee: 200
|
||||
})
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockRejectedValueOnce(new Error('database write failed after batch broadcast'));
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
|
||||
expect(review).toBeDefined();
|
||||
expect(review[0]).toMatch(/tx_hash = COALESCE\(\$4, tx_hash\)/i);
|
||||
expect(review[1][2]).toEqual([10, 11]);
|
||||
expect(review[1][3]).toBe(SHARED_TX_HASH);
|
||||
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'pending')).toBe(false);
|
||||
expect(walletService.processBatchPayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('multiple valid wallet hashes are not guessed onto every liability', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tx_hash_list: ['a'.repeat(64), 'b'.repeat(64)],
|
||||
totalFee: 200
|
||||
})
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
|
||||
expect(review).toBeDefined();
|
||||
expect(db._mockClient.query.mock.calls.some(call => /status = 'completed'/i.test(call[0]))).toBe(false);
|
||||
});
|
||||
|
||||
test('MONERO LOCKING: defers the batch to pending when unlocked balance is insufficient (no transfer attempted)', async () => {
|
||||
// TWO_PENDING needs 200000000000 + 300000000000 = 500000000000 atomic.
|
||||
// Unlocked balance is far below that (outputs locked ~10 blocks) -> defer, do not send.
|
||||
@@ -138,6 +233,50 @@ describe('Batch payout dispatch', () => {
|
||||
expect(deferSql[1][0]).toEqual([10, 11]); // ids array param
|
||||
});
|
||||
|
||||
test('defers without transfer when unlocked balance cannot be verified', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockRejectedValue(new Error('wallet unavailable')),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
|
||||
const deferred = db.query.mock.calls.find(call => /wallet unlocked balance could not be verified/.test(call[1]?.[1] || ''));
|
||||
expect(deferred).toBeDefined();
|
||||
expect(deferred[1][0]).toEqual([10, 11]);
|
||||
});
|
||||
|
||||
test('rechecks a gate that closes after claim and wallet probe before transfer', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({
|
||||
balance: '10000000000000',
|
||||
unlocked_balance: '10000000000000'
|
||||
}),
|
||||
processPayout: jest.fn(),
|
||||
processBatchPayout: jest.fn()
|
||||
};
|
||||
const { gmm, db } = buildGmm(walletService);
|
||||
let checks = 0;
|
||||
gmm.payoutDispatchAllowed = jest.fn(() => ++checks === 1);
|
||||
db._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: TWO_PENDING })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
|
||||
expect(walletService.getBalance).toHaveBeenCalledTimes(1);
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(walletService.processBatchPayout).not.toHaveBeenCalled();
|
||||
expect(db.query.mock.calls.some(call => call[1]?.[1] === 'Payout dispatch paused before wallet transfer')).toBe(true);
|
||||
});
|
||||
|
||||
test('a pre-broadcast insufficient-funds batch error is retried (pending), not needs_review', async () => {
|
||||
const walletService = {
|
||||
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
CANARY_HANDSHAKE_CONFIRM,
|
||||
CANARY_HANDSHAKE_PROTOCOL,
|
||||
computeProof,
|
||||
createCanaryDatabaseIdentityHandler,
|
||||
equalProof,
|
||||
readDatabaseIdentity
|
||||
} = require('../src/services/canaryDatabaseIdentity');
|
||||
|
||||
function protectedNonceFile(nonce = 'ab'.repeat(32)) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-canary-nonce-'));
|
||||
const fileName = path.join(directory, 'nonce');
|
||||
fs.writeFileSync(fileName, `${nonce}\n`, { mode: 0o600 });
|
||||
return { directory, fileName, nonce };
|
||||
}
|
||||
|
||||
function enabledEnv(fileName, overrides = {}) {
|
||||
return {
|
||||
NODE_ENV: 'production',
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'stagenet',
|
||||
PORT: '3102',
|
||||
DB_NAME: 'monerogue_canary_direct_e2e',
|
||||
CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e',
|
||||
CANARY_DATABASE_HANDSHAKE: CANARY_HANDSHAKE_CONFIRM,
|
||||
CANARY_DATABASE_NONCE_FILE: fileName,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function responseRecorder() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
}
|
||||
|
||||
describe('isolated canary database identity endpoint', () => {
|
||||
test('is absent by default and fails closed on partial or non-stagenet configuration', () => {
|
||||
expect(createCanaryDatabaseIdentityHandler({ db: {}, env: {} })).toBeNull();
|
||||
expect(() => createCanaryDatabaseIdentityHandler({
|
||||
db: {}, env: { CANARY_EXPECT_DATABASE: 'monerogue_canary_direct_e2e' }
|
||||
})).toThrow(/CANARY_DATABASE_HANDSHAKE/);
|
||||
|
||||
const fixture = protectedNonceFile();
|
||||
try {
|
||||
expect(() => createCanaryDatabaseIdentityHandler({
|
||||
db: {}, env: enabledEnv(fixture.fileName, { MONERO_NETWORK: 'mainnet' })
|
||||
})).toThrow(/XMR stagenet/);
|
||||
expect(() => createCanaryDatabaseIdentityHandler({
|
||||
db: {}, env: enabledEnv(fixture.fileName, { PORT: '3000' })
|
||||
})).toThrow(/3102/);
|
||||
expect(() => createCanaryDatabaseIdentityHandler({
|
||||
db: {}, env: enabledEnv(fixture.fileName, { DB_NAME: 'monerogue' })
|
||||
})).toThrow(/DB_NAME/);
|
||||
|
||||
fs.chmodSync(fixture.fileName, 0o644);
|
||||
expect(() => createCanaryDatabaseIdentityHandler({
|
||||
db: {}, env: enabledEnv(fixture.fileName)
|
||||
})).toThrow(/group\/world/);
|
||||
fs.chmodSync(fixture.fileName, 0o600);
|
||||
const link = path.join(fixture.directory, 'nonce-link');
|
||||
fs.symlinkSync(fixture.fileName, link);
|
||||
expect(() => createCanaryDatabaseIdentityHandler({
|
||||
db: {}, env: enabledEnv(link)
|
||||
})).toThrow();
|
||||
} finally {
|
||||
fs.rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('signs the application pool exact cluster, OID, database, and caller challenge', async () => {
|
||||
const fixture = protectedNonceFile();
|
||||
const identity = {
|
||||
cluster_id: '7612345678901234567',
|
||||
database_oid: '24591',
|
||||
database_name: 'monerogue_canary_direct_e2e'
|
||||
};
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [identity] }) };
|
||||
try {
|
||||
const handler = createCanaryDatabaseIdentityHandler({
|
||||
db, env: enabledEnv(fixture.fileName)
|
||||
});
|
||||
const challenge = '12'.repeat(32);
|
||||
const response = responseRecorder();
|
||||
await handler({
|
||||
get: () => challenge,
|
||||
socket: { remoteAddress: '::ffff:127.0.0.1' }
|
||||
}, response);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual(expect.objectContaining({
|
||||
protocol: CANARY_HANDSHAKE_PROTOCOL,
|
||||
challenge,
|
||||
database: {
|
||||
clusterId: identity.cluster_id,
|
||||
databaseOid: identity.database_oid,
|
||||
databaseName: identity.database_name
|
||||
}
|
||||
}));
|
||||
const expected = computeProof(fixture.nonce, response.body.database, challenge);
|
||||
expect(equalProof(response.body.proof, expected)).toBe(true);
|
||||
expect(JSON.stringify(response.body)).not.toContain(fixture.nonce);
|
||||
expect(db.query.mock.calls[0][0]).toContain('pg_control_system()');
|
||||
} finally {
|
||||
fs.rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed challenges and refuses an application pool on another database', async () => {
|
||||
const fixture = protectedNonceFile();
|
||||
try {
|
||||
const wrongDb = { query: jest.fn().mockResolvedValue({ rows: [{
|
||||
cluster_id: '7612345678901234567',
|
||||
database_oid: '24592',
|
||||
database_name: 'monerogue_canary_credits_e2e'
|
||||
}] }) };
|
||||
const handler = createCanaryDatabaseIdentityHandler({
|
||||
db: wrongDb, env: enabledEnv(fixture.fileName)
|
||||
});
|
||||
const malformed = responseRecorder();
|
||||
const remote = responseRecorder();
|
||||
await handler({
|
||||
get: () => '34'.repeat(32), socket: { remoteAddress: '192.0.2.10' }
|
||||
}, remote);
|
||||
expect(remote.statusCode).toBe(404);
|
||||
expect(wrongDb.query).not.toHaveBeenCalled();
|
||||
|
||||
await handler({
|
||||
get: () => 'not-a-challenge', socket: { remoteAddress: '127.0.0.1' }
|
||||
}, malformed);
|
||||
expect(malformed.statusCode).toBe(400);
|
||||
expect(wrongDb.query).not.toHaveBeenCalled();
|
||||
|
||||
const mismatch = responseRecorder();
|
||||
await handler({
|
||||
get: () => '34'.repeat(32), socket: { remoteAddress: '::1' }
|
||||
}, mismatch);
|
||||
expect(mismatch.statusCode).toBe(503);
|
||||
expect(mismatch.body).toEqual({ error: 'Canary database identity mismatch.' });
|
||||
} finally {
|
||||
fs.rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires one unambiguous row from PostgreSQL identity functions', async () => {
|
||||
await expect(readDatabaseIdentity({
|
||||
query: jest.fn().mockResolvedValue({ rows: [] })
|
||||
})).rejects.toThrow(/ambiguous/);
|
||||
await expect(readDatabaseIdentity({
|
||||
query: jest.fn().mockResolvedValue({ rows: [{
|
||||
cluster_id: '', database_oid: '1', database_name: 'db'
|
||||
}] })
|
||||
})).rejects.toThrow(/cluster identity/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
CloneMigrationVerificationError,
|
||||
MINIMUM_RELEASE_MIGRATION,
|
||||
cloneMigrationConfirmation,
|
||||
discoverMigrationManifest,
|
||||
isDisposableCloneDatabaseName,
|
||||
runCloneMigrations,
|
||||
validateCloneMigrationEnvironment,
|
||||
verifyExactMigrationLedger
|
||||
} = require('../src/scripts/migrate-disposable-clone');
|
||||
|
||||
function safeEnvironment(overrides = {}) {
|
||||
const database = overrides.DB_NAME || 'monerogue_restore_release_20260721';
|
||||
return {
|
||||
DB_HOST: '127.0.0.1',
|
||||
DB_PORT: '5432',
|
||||
DB_NAME: database,
|
||||
DB_USER: 'monerogue_migrator',
|
||||
CLONE_MIGRATION_EXPECT_DATABASE: database,
|
||||
CLONE_MIGRATION_CONFIRM: cloneMigrationConfirmation(database),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
class FakePool {
|
||||
constructor(config, { actualDatabase = config.database, hideLatest = false } = {}) {
|
||||
this.config = config;
|
||||
this.actualDatabase = actualDatabase;
|
||||
this.hideLatest = hideLatest;
|
||||
this.ledger = new Set();
|
||||
this.transactions = [];
|
||||
this.ended = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const pool = this;
|
||||
let pending = null;
|
||||
return {
|
||||
async query(sql, params = []) {
|
||||
if (/current_database\(\)/.test(sql)) {
|
||||
return { rows: [{ database_name: pool.actualDatabase }], rowCount: 1 };
|
||||
}
|
||||
if (sql === 'BEGIN') {
|
||||
pending = null;
|
||||
return { rows: [], rowCount: null };
|
||||
}
|
||||
if (/INSERT INTO schema_migrations/.test(sql)) {
|
||||
pending = params[0];
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (sql === 'COMMIT') {
|
||||
if (pending) pool.ledger.add(pending);
|
||||
pool.transactions.push(pending);
|
||||
pending = null;
|
||||
return { rows: [], rowCount: null };
|
||||
}
|
||||
if (sql === 'ROLLBACK') {
|
||||
pending = null;
|
||||
return { rows: [], rowCount: null };
|
||||
}
|
||||
return { rows: [], rowCount: null };
|
||||
},
|
||||
release() {}
|
||||
};
|
||||
}
|
||||
|
||||
async query(sql, params = []) {
|
||||
if (/CREATE TABLE IF NOT EXISTS schema_migrations/.test(sql)) {
|
||||
return { rows: [], rowCount: null };
|
||||
}
|
||||
if (/SELECT 1 FROM schema_migrations/.test(sql)) {
|
||||
return { rows: [], rowCount: this.ledger.has(params[0]) ? 1 : 0 };
|
||||
}
|
||||
if (/SELECT filename FROM schema_migrations/.test(sql)) {
|
||||
let filenames = [...this.ledger].sort();
|
||||
if (this.hideLatest) filenames = filenames.slice(0, -1);
|
||||
return { rows: filenames.map(filename => ({ filename })), rowCount: filenames.length };
|
||||
}
|
||||
throw new Error(`unexpected fake pool query: ${sql}`);
|
||||
}
|
||||
|
||||
async end() {
|
||||
this.ended = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('disposable clone migration target guard', () => {
|
||||
test.each([
|
||||
'monerogue_clone_release_a1',
|
||||
'wownerogue_restore_20260721',
|
||||
'wowngeon_canary_direct_e2e'
|
||||
])('accepts an explicitly disposable application database: %s', database => {
|
||||
expect(isDisposableCloneDatabaseName(database)).toBe(true);
|
||||
expect(validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), []))
|
||||
.toEqual(expect.objectContaining({ database, expectedDatabase: database }));
|
||||
});
|
||||
|
||||
test.each([
|
||||
'monerogue',
|
||||
'wownerogue',
|
||||
'wowngeon',
|
||||
'monerogue_prod_clone',
|
||||
'monerogue_clone_prd',
|
||||
'monerogue_clone_production2026',
|
||||
'monerogue_master_restore',
|
||||
'wownerogue_clone_live',
|
||||
'wownerogue_clone_live1',
|
||||
'monerogue_mainnet_restore',
|
||||
'postgres_restore_20260721',
|
||||
'monerogue_staging_20260721',
|
||||
'monerogue-clone-release'
|
||||
])('refuses a live-looking, unscoped, or malformed name: %s', database => {
|
||||
expect(isDisposableCloneDatabaseName(database)).toBe(false);
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment({ DB_NAME: database }), []))
|
||||
.toThrow(/clone, restore, or canary/);
|
||||
});
|
||||
|
||||
test('requires an exact expected database, database-bound confirmation, and local host', () => {
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
|
||||
CLONE_MIGRATION_EXPECT_DATABASE: 'monerogue_restore_other'
|
||||
}), [])).toThrow(/exactly equal/);
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
|
||||
CLONE_MIGRATION_CONFIRM: 'yes'
|
||||
}), [])).toThrow(/exactly bind/);
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
|
||||
DB_HOST: 'database.internal'
|
||||
}), [])).toThrow(/loopback/);
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
|
||||
DB_HOST: '/tmp/untrusted-postgresql-socket'
|
||||
}), [])).toThrow(/loopback/);
|
||||
expect(validateCloneMigrationEnvironment(safeEnvironment({
|
||||
DB_HOST: '/var/run/postgresql'
|
||||
}), [])).toEqual(expect.objectContaining({ host: '/var/run/postgresql' }));
|
||||
});
|
||||
|
||||
test('forbids argv and credential-bearing DATABASE_URL before constructing a pool', async () => {
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment(), ['--database', 'x']))
|
||||
.toThrow(/command-line/);
|
||||
expect(() => validateCloneMigrationEnvironment(safeEnvironment({
|
||||
DATABASE_URL: 'postgres://user:secret@127.0.0.1/db'
|
||||
}), [])).toThrow(/DATABASE_URL/);
|
||||
|
||||
const poolFactory = jest.fn();
|
||||
await expect(runCloneMigrations({
|
||||
env: safeEnvironment(), argv: ['--password', 'secret'], poolFactory
|
||||
})).rejects.toThrow(/command-line/);
|
||||
expect(poolFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposable clone migration execution', () => {
|
||||
let logSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('discovers the contiguous repository manifest through migration 40 dynamically', () => {
|
||||
const manifest = discoverMigrationManifest();
|
||||
expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION);
|
||||
expect(manifest.latestFilename).toBe('040_paid_match_entropy_precommit.sql');
|
||||
expect(manifest.files).toHaveLength(40);
|
||||
});
|
||||
|
||||
test('runs the normal ordered migration implementation and proves the exact ledger', async () => {
|
||||
const secret = 'do-not-print-this-database-password';
|
||||
const env = safeEnvironment({ DB_PASSWORD: secret });
|
||||
let pool;
|
||||
const output = [];
|
||||
|
||||
const result = await runCloneMigrations({
|
||||
env,
|
||||
argv: [],
|
||||
poolFactory: config => {
|
||||
pool = new FakePool(config);
|
||||
return pool;
|
||||
},
|
||||
logger: { info: line => output.push(line) }
|
||||
});
|
||||
|
||||
expect(pool.transactions).toEqual(result.files);
|
||||
expect(result.latestFilename).toBe('040_paid_match_entropy_precommit.sql');
|
||||
expect(output).toContain('clone_migration_status=ok');
|
||||
expect(output).toContain('latest_migration=040_paid_match_entropy_precommit.sql');
|
||||
expect(output.join('\n')).not.toContain(secret);
|
||||
expect(pool.config).toEqual(expect.objectContaining({
|
||||
database: env.DB_NAME,
|
||||
host: '127.0.0.1',
|
||||
max: 1,
|
||||
application_name: 'wowngeon-disposable-clone-migrations'
|
||||
}));
|
||||
expect(pool.ended).toBe(true);
|
||||
});
|
||||
|
||||
test('checks server database identity before applying a migration', async () => {
|
||||
let pool;
|
||||
await expect(runCloneMigrations({
|
||||
env: safeEnvironment(),
|
||||
argv: [],
|
||||
poolFactory: config => {
|
||||
pool = new FakePool(config, { actualDatabase: 'monerogue_restore_wrong' });
|
||||
return pool;
|
||||
},
|
||||
logger: { info: jest.fn() }
|
||||
})).rejects.toThrow(/exact expected clone name/);
|
||||
expect(pool.transactions).toEqual([]);
|
||||
expect(pool.ended).toBe(true);
|
||||
});
|
||||
|
||||
test('fails closed if the post-run ledger is not exactly the repository manifest', async () => {
|
||||
await expect(runCloneMigrations({
|
||||
env: safeEnvironment(),
|
||||
argv: [],
|
||||
poolFactory: config => new FakePool(config, { hideLatest: true }),
|
||||
logger: { info: jest.fn() }
|
||||
})).rejects.toBeInstanceOf(CloneMigrationVerificationError);
|
||||
});
|
||||
|
||||
test('rejects missing, extra, or reordered ledger entries', () => {
|
||||
const manifest = discoverMigrationManifest();
|
||||
const rows = manifest.files.map(filename => ({ filename }));
|
||||
expect(verifyExactMigrationLedger(rows, manifest)).toBe(true);
|
||||
expect(() => verifyExactMigrationLedger(rows.slice(1), manifest)).toThrow(/count/);
|
||||
expect(() => verifyExactMigrationLedger([...rows].reverse(), manifest)).toThrow(/differs/);
|
||||
expect(() => verifyExactMigrationLedger([...rows, { filename: '999_unknown.sql' }], manifest))
|
||||
.toThrow(/count/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const STORAGE_KEY = 'wowngeon_paid_ack';
|
||||
|
||||
function policy(version, isTestNetwork = false) {
|
||||
return {
|
||||
policyVersion: version,
|
||||
paidAcknowledgementRequired: true,
|
||||
minimumAge: 18,
|
||||
service: {
|
||||
isTestNetwork,
|
||||
currencyLabel: isTestNetwork ? 'sXMR' : 'WOW',
|
||||
cryptoType: isTestNetwork ? 'XMR' : 'WOW'
|
||||
},
|
||||
links: { terms: '/terms', privacy: '/privacy', responsiblePlay: '/responsible-play' }
|
||||
};
|
||||
}
|
||||
|
||||
function response(value) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(value) });
|
||||
}
|
||||
|
||||
describe('browser commerce acknowledgement cache', () => {
|
||||
let values;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
values = new Map();
|
||||
global.sessionStorage = {
|
||||
getItem: jest.fn(key => values.has(key) ? values.get(key) : null),
|
||||
setItem: jest.fn((key, value) => values.set(key, value)),
|
||||
removeItem: jest.fn(key => values.delete(key))
|
||||
};
|
||||
global.fetch = jest.fn();
|
||||
delete global.CommerceConsent;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
delete global.sessionStorage;
|
||||
delete global.CommerceConsent;
|
||||
});
|
||||
|
||||
test('accepts only the five-field canonical cache and clears extra data', async () => {
|
||||
global.fetch.mockReturnValueOnce(response(policy('browser-v1')));
|
||||
const consent = require('../html/js/legal/commerceConsent');
|
||||
await consent.loadPolicy();
|
||||
values.set(STORAGE_KEY, JSON.stringify({
|
||||
policyVersion: 'browser-v1',
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: false,
|
||||
acknowledgedAt: 'must-not-be-stored'
|
||||
}));
|
||||
|
||||
expect(consent.acknowledgement()).toBeNull();
|
||||
expect(values.has(STORAGE_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
test('server version rejection clears acceptance and refetches current policy', async () => {
|
||||
global.fetch
|
||||
.mockReturnValueOnce(response(policy('browser-v1')))
|
||||
.mockReturnValueOnce(response(policy('browser-v2')));
|
||||
const consent = require('../html/js/legal/commerceConsent');
|
||||
await consent.loadPolicy();
|
||||
values.set(STORAGE_KEY, JSON.stringify({
|
||||
policyVersion: 'browser-v1',
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: false
|
||||
}));
|
||||
expect(consent.acknowledgement()).toEqual(expect.objectContaining({ policyVersion: 'browser-v1' }));
|
||||
|
||||
const refreshed = await consent.reject({ code: 'PAID_ACK_VERSION', policyVersion: 'browser-v2' });
|
||||
|
||||
expect(refreshed.policyVersion).toBe('browser-v2');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(consent.acknowledgement()).toBeNull();
|
||||
expect(values.has(STORAGE_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
test('disconnect-facing clear removes both memory and session copies', async () => {
|
||||
global.fetch.mockReturnValueOnce(response(policy('browser-v1')));
|
||||
const consent = require('../html/js/legal/commerceConsent');
|
||||
await consent.loadPolicy();
|
||||
values.set(STORAGE_KEY, JSON.stringify({
|
||||
policyVersion: 'browser-v1',
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: false
|
||||
}));
|
||||
expect(consent.acknowledgement()).not.toBeNull();
|
||||
|
||||
consent.clear();
|
||||
|
||||
expect(consent.acknowledgement()).toBeNull();
|
||||
expect(values.has(STORAGE_KEY)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
const {
|
||||
buildCommerceDisclosure,
|
||||
safeContactUrl,
|
||||
validatePaidAcknowledgement
|
||||
} = require('../src/config/commerceDisclosurePolicy');
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
|
||||
function manager(overrides = {}) {
|
||||
return {
|
||||
cryptoType: 'XMR',
|
||||
currencyLabel: 'sXMR',
|
||||
network: 'stagenet',
|
||||
isTestNetwork: true,
|
||||
gameName: 'Monerogue',
|
||||
paymentsEnabled: true,
|
||||
freePlayEnabled: true,
|
||||
directModeEnabled: true,
|
||||
creditsModeEnabled: true,
|
||||
payoutsEnabled: true,
|
||||
isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function env(overrides = {}) {
|
||||
return {
|
||||
NODE_ENV: 'production',
|
||||
LEGAL_POLICY_VERSION: '2026-07-21-v1',
|
||||
TERMS_EFFECTIVE_DATE: '2026-07-21',
|
||||
MINIMUM_AGE: '21',
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
|
||||
OPERATOR_NAME: 'Test Operator',
|
||||
OPERATOR_CONTACT_URL: 'mailto:support@example.invalid',
|
||||
MATCH_ENABLED: 'false',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('commerce disclosure and paid-action policy', () => {
|
||||
test('describes active test-network payout mode without conflating it with mainnet', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager(), env());
|
||||
|
||||
expect(disclosure).toEqual(expect.objectContaining({
|
||||
policyVersion: '2026-07-21-v1',
|
||||
minimumAge: 21,
|
||||
paidAcknowledgementRequired: true
|
||||
}));
|
||||
expect(disclosure.service).toEqual(expect.objectContaining({
|
||||
network: 'stagenet',
|
||||
isTestNetwork: true,
|
||||
paidPrestigeOnly: false,
|
||||
soloPayoutsEnabled: true
|
||||
}));
|
||||
});
|
||||
|
||||
test('describes paid prestige separately when every payout path is off', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
|
||||
payoutsEnabled: false, isPayoutEnabledForMode: () => false
|
||||
}), env());
|
||||
|
||||
expect(disclosure.service.paidPrestigeOnly).toBe(true);
|
||||
expect(disclosure.service.anyPayoutsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('does not advertise a stale solo payout flag when that paid mode is disabled', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: true,
|
||||
isPayoutEnabledForMode: mode => mode === 'PAID_SINGLE'
|
||||
}), env());
|
||||
|
||||
expect(disclosure.service.directPaidEntryEnabled).toBe(false);
|
||||
expect(disclosure.service.paidCreditsEnabled).toBe(true);
|
||||
expect(disclosure.service.soloPayoutsEnabled).toBe(false);
|
||||
|
||||
const activeCreditsPayout = buildCommerceDisclosure(manager({
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: true,
|
||||
isPayoutEnabledForMode: mode => mode === 'PAID_CREDITS'
|
||||
}), env());
|
||||
expect(activeCreditsPayout.service.soloPayoutsEnabled).toBe(true);
|
||||
|
||||
const noPaidModes = buildCommerceDisclosure(manager({
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: false,
|
||||
isPayoutEnabledForMode: () => true
|
||||
}), env());
|
||||
expect(noPaidModes.service.soloPayoutsEnabled).toBe(false);
|
||||
expect(noPaidModes.service.anyPayoutsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('fails closed on missing, stale, incomplete, or testnet-unaware acknowledgement', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager(), env());
|
||||
const good = {
|
||||
policyVersion: disclosure.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: true
|
||||
};
|
||||
|
||||
expect(validatePaidAcknowledgement(null, disclosure).ok).toBe(false);
|
||||
expect(validatePaidAcknowledgement({ ...good, policyVersion: 'old' }, disclosure).code).toBe('PAID_ACK_VERSION');
|
||||
expect(validatePaidAcknowledgement({ ...good, riskAccepted: false }, disclosure).code).toBe('PAID_ACK_INCOMPLETE');
|
||||
expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: false }, disclosure).code).toBe('PAID_ACK_TESTNET');
|
||||
expect(validatePaidAcknowledgement({ ...good, extra: true }, disclosure).code).toBe('PAID_ACK_MALFORMED');
|
||||
expect(validatePaidAcknowledgement({
|
||||
policyVersion: disclosure.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true
|
||||
}, disclosure).code).toBe('PAID_ACK_MALFORMED');
|
||||
expect(validatePaidAcknowledgement(good, disclosure)).toEqual({
|
||||
ok: true,
|
||||
code: 'ACKNOWLEDGED',
|
||||
acknowledgement: Object.freeze(good)
|
||||
});
|
||||
});
|
||||
|
||||
test('mainnet acknowledgement is canonical and carries an explicit false testnet flag', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
cryptoType: 'WOW',
|
||||
currencyLabel: 'WOW',
|
||||
network: 'mainnet',
|
||||
isTestNetwork: false
|
||||
}), env());
|
||||
const good = {
|
||||
policyVersion: disclosure.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: false
|
||||
};
|
||||
|
||||
expect(validatePaidAcknowledgement(good, disclosure)).toEqual({
|
||||
ok: true,
|
||||
code: 'ACKNOWLEDGED',
|
||||
acknowledgement: Object.freeze(good)
|
||||
});
|
||||
expect(validatePaidAcknowledgement({ ...good, testnetUnderstood: true }, disclosure).code)
|
||||
.toBe('PAID_ACK_MALFORMED');
|
||||
});
|
||||
|
||||
test('keeps acknowledgement UI enabled for paid match entitlements while invoice intake is off', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
paymentsEnabled: false,
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: false,
|
||||
freePlayEnabled: true,
|
||||
_getMatchEconomies: () => ({ free: true, credits_prestige: true })
|
||||
}), env({ PAID_ACKNOWLEDGEMENT_REQUIRED: 'false', MATCH_ENABLED: 'true' }));
|
||||
|
||||
expect(disclosure.service.paymentsEnabled).toBe(false);
|
||||
expect(disclosure.paidAcknowledgementRequired).toBe(true);
|
||||
});
|
||||
|
||||
test('allows no acknowledgement only where the policy is actually disabled', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
paymentsEnabled: false,
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: false,
|
||||
freePlayEnabled: false
|
||||
}), {
|
||||
NODE_ENV: 'test', PAID_ACKNOWLEDGEMENT_REQUIRED: 'false'
|
||||
});
|
||||
expect(validatePaidAcknowledgement(null, disclosure)).toEqual({
|
||||
ok: true, code: 'NOT_REQUIRED', acknowledgement: null
|
||||
});
|
||||
});
|
||||
|
||||
test('contact URLs reject executable and unrelated schemes', () => {
|
||||
expect(safeContactUrl('javascript:alert(1)')).toBeNull();
|
||||
expect(safeContactUrl('data:text/html,boom')).toBeNull();
|
||||
expect(safeContactUrl('mailto:support@example.invalid')).toBe('mailto:support@example.invalid');
|
||||
expect(safeContactUrl('https://example.invalid/support')).toBe('https://example.invalid/support');
|
||||
});
|
||||
|
||||
test('payment handler refuses an invoice before wallet or database work when acknowledgement is absent', async () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV;
|
||||
const previousVersion = process.env.LEGAL_POLICY_VERSION;
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.LEGAL_POLICY_VERSION = 'server-enforcement-v1';
|
||||
const emit = jest.fn();
|
||||
const createPaymentRequest = jest.fn();
|
||||
const handler = new PaymentHandlers({
|
||||
io: { to: () => ({ emit }) },
|
||||
gameModeManager: {
|
||||
...manager({ isTestNetwork: false, network: 'mainnet' }),
|
||||
createPaymentRequest,
|
||||
isPayoutEnabledForMode: () => false
|
||||
},
|
||||
walletService: {},
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
queueManager: {},
|
||||
broadcastManager: { sendStatusUpdate: jest.fn() },
|
||||
sessionManager: null
|
||||
});
|
||||
try {
|
||||
await handler.handlePaymentRequest({ id: 'socket-1' }, { type: 'single_game' });
|
||||
expect(createPaymentRequest).not.toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
|
||||
code: 'PAID_ACK_REQUIRED', policyVersion: 'server-enforcement-v1'
|
||||
}));
|
||||
expect(emit).toHaveBeenCalledWith('payment_error', expect.objectContaining({
|
||||
code: 'PAID_ACK_REQUIRED'
|
||||
}));
|
||||
} finally {
|
||||
handler.dispose();
|
||||
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = previousNodeEnv;
|
||||
if (previousVersion === undefined) delete process.env.LEGAL_POLICY_VERSION;
|
||||
else process.env.LEGAL_POLICY_VERSION = previousVersion;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const DatabaseManager = require('../src/db/databaseManager');
|
||||
|
||||
describe('DatabaseManager readiness freshness', () => {
|
||||
test('requires both a connection and a recent successful query', () => {
|
||||
const db = new DatabaseManager();
|
||||
expect(db.isHealthy()).toBe(false);
|
||||
|
||||
db.connected = true;
|
||||
db.lastSuccessfulQueryAt = Date.now();
|
||||
expect(db.isHealthy(1000)).toBe(true);
|
||||
|
||||
db.lastSuccessfulQueryAt = Date.now() - 2000;
|
||||
expect(db.isHealthy(1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const DebugManager = require('../src/debug/debugManager');
|
||||
|
||||
describe('DebugManager daemon freshness', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
const originalSource = process.env.BLOCK_SOURCE;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.BLOCK_SOURCE = 'daemon';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalEnv;
|
||||
if (originalSource === undefined) delete process.env.BLOCK_SOURCE;
|
||||
else process.env.BLOCK_SOURCE = originalSource;
|
||||
});
|
||||
|
||||
test('rejects a stale cached height until a recent RPC poll succeeds', () => {
|
||||
const manager = new DebugManager({ broadcastBlockHeight: jest.fn() });
|
||||
manager.lastProductionBlockHeight = 123;
|
||||
manager.lastSuccessfulRpcAt = 0;
|
||||
|
||||
expect(manager.isChainHealthy()).toBe(false);
|
||||
|
||||
manager.lastSuccessfulRpcAt = Date.now();
|
||||
expect(manager.isChainHealthy(1000)).toBe(true);
|
||||
|
||||
manager.lastSuccessfulRpcAt = Date.now() - 2000;
|
||||
expect(manager.isChainHealthy(1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { DigestAuthClient, parseDigestChallenge } = require('../src/payments/digestAuth');
|
||||
|
||||
describe('wallet RPC Digest authentication', () => {
|
||||
test('parses quoted qop lists and required fields', () => {
|
||||
expect(parseDigestChallenge('Digest realm="wallet", nonce="n1", qop="auth,auth-int", algorithm=MD5'))
|
||||
.toMatchObject({ realm: 'wallet', nonce: 'n1', qop: 'auth,auth-int', algorithm: 'MD5' });
|
||||
});
|
||||
|
||||
test('caches the challenge and increments nonce-count without another unauthenticated probe', async () => {
|
||||
const unauthorized = new Error('unauthorized');
|
||||
unauthorized.response = {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Digest realm="wallet", nonce="nonce", qop="auth"' }
|
||||
};
|
||||
const http = { post: jest.fn()
|
||||
.mockRejectedValueOnce(unauthorized)
|
||||
.mockResolvedValue({ data: { result: {} } }) };
|
||||
const client = new DigestAuthClient(http, {
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
randomBytes: () => Buffer.alloc(16, 1)
|
||||
});
|
||||
|
||||
await client.post('http://127.0.0.1:38083/json_rpc', { id: '1' });
|
||||
await client.post('http://127.0.0.1:38083/json_rpc', { id: '2' });
|
||||
|
||||
expect(http.post).toHaveBeenCalledTimes(3);
|
||||
expect(http.post.mock.calls[1][2].headers.Authorization).toContain('nc=00000001');
|
||||
expect(http.post.mock.calls[2][2].headers.Authorization).toContain('nc=00000002');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('database economic identity immutability', () => {
|
||||
const readMigration = name => fs.readFileSync(
|
||||
path.join(__dirname, `../src/migrations/${name}`), 'utf8'
|
||||
);
|
||||
|
||||
test('committed solo owner is frozen on and after commitment', () => {
|
||||
const initial = readMigration('032_solo_liability_invariants.sql');
|
||||
const forward = readMigration('038_economic_identity_immutability.sql');
|
||||
for (const sql of [initial, forward]) {
|
||||
expect(sql).toMatch(/NEW\.user_id IS DISTINCT FROM OLD\.user_id/i);
|
||||
expect(sql).toMatch(/OLD\.payout_committed_at IS NULL[\s\S]*NEW\.payout_committed_at IS NOT NULL[\s\S]*NEW\.user_id IS DISTINCT FROM OLD\.user_id/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('receipt-backed or final invoice economics, destination, product, and provider are frozen', () => {
|
||||
const forward = readMigration('038_economic_identity_immutability.sql');
|
||||
for (const field of [
|
||||
'user_id', 'payment_type', 'expected_amount', 'subaddress', 'address_index',
|
||||
'provider_id', 'provider_invoice_id', 'payment_mode', 'credit_package_id',
|
||||
'product_id', 'product_grants', 'description', 'expires_at'
|
||||
]) {
|
||||
expect(forward).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
|
||||
}
|
||||
expect(forward).toMatch(/OLD\.status IN \('confirmed', 'refunded', 'expired'\)/i);
|
||||
expect(forward).toMatch(/EXISTS \(SELECT 1 FROM payment_receipts r WHERE r\.payment_id = OLD\.id\)/i);
|
||||
expect(forward).toMatch(/OLD\.fairness_bound_at IS NULL AND NEW\.fairness_bound_at IS NOT NULL/i);
|
||||
});
|
||||
|
||||
test('confirmed-payment refunds change operational status without rewriting immutable description', () => {
|
||||
const refundService = fs.readFileSync(
|
||||
path.join(__dirname, '../src/services/paymentRefundService.js'), 'utf8'
|
||||
);
|
||||
const update = refundService.match(/UPDATE payments[\s\S]*?WHERE id = \$1/gi) || [];
|
||||
expect(update).toHaveLength(1);
|
||||
expect(update[0]).toMatch(/SET status = 'refunded'/i);
|
||||
expect(update[0]).not.toMatch(/description/i);
|
||||
});
|
||||
|
||||
test('payment receipt authorization evidence is append-only', () => {
|
||||
const forward = readMigration('038_economic_identity_immutability.sql');
|
||||
expect(forward).toMatch(/CREATE TRIGGER trg_payment_receipts_append_only/i);
|
||||
expect(forward).toMatch(/BEFORE UPDATE OR DELETE ON payment_receipts/i);
|
||||
expect(forward).toMatch(/payment receipt evidence is append-only/i);
|
||||
});
|
||||
|
||||
test('payout economics are immutable with only the exact saved-address claim exception', () => {
|
||||
const sql = readMigration('038_economic_identity_immutability.sql');
|
||||
for (const field of ['user_id', 'game_id', 'match_id', 'amount', 'multiplier', 'reason']) {
|
||||
expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
|
||||
}
|
||||
expect(sql).toMatch(/OLD\.payout_address = 'PENDING_NO_ADDRESS'/i);
|
||||
expect(sql).toMatch(/OLD\.status = 'needs_review'/i);
|
||||
expect(sql).toMatch(/NEW\.status = 'pending'/i);
|
||||
expect(sql).toMatch(/OLD\.tx_hash IS NULL[\s\S]*NEW\.tx_hash IS NULL/i);
|
||||
expect(sql).toMatch(/u\.id = OLD\.user_id[\s\S]*u\.payout_address = NEW\.payout_address/i);
|
||||
expect(sql).toMatch(/CREATE TRIGGER trg_payouts_immutable_obligation/i);
|
||||
});
|
||||
|
||||
test('unresolved solo-win obligations are recordable but excluded from automatic address claim', () => {
|
||||
const initial = readMigration('038_economic_identity_immutability.sql');
|
||||
const forward = readMigration('039_unresolved_solo_payout_review.sql');
|
||||
const runtime = fs.readFileSync(
|
||||
path.join(__dirname, '../src/game/gameModeManager.js'), 'utf8'
|
||||
);
|
||||
|
||||
expect(runtime).toContain("'solo_winner_identity_review'");
|
||||
expect(forward).toContain("'solo_winner_identity_review'");
|
||||
expect(forward).toMatch(/status = 'needs_review'[\s\S]*tx_hash IS NULL/i);
|
||||
expect(initial).toMatch(
|
||||
/OLD\.reason IN \('match_winner_no_address', 'solo_winner_no_address'\)/i
|
||||
);
|
||||
expect(initial).not.toMatch(
|
||||
/OLD\.reason IN \([^)]*solo_winner_identity_review/i
|
||||
);
|
||||
});
|
||||
|
||||
test('paid entropy precommit witness is shaped, immutable, and locks active fairness identity', () => {
|
||||
const sql = readMigration('040_paid_match_entropy_precommit.sql');
|
||||
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_tip_height BIGINT/i);
|
||||
expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS entropy_precommit_verified_at TIMESTAMPTZ/i);
|
||||
expect(sql).toMatch(/entropy_precommit_tip_height < start_block_height/i);
|
||||
expect(sql).toMatch(/NEW\.entropy_precommit_tip_height IS DISTINCT FROM OLD\.entropy_precommit_tip_height/i);
|
||||
expect(sql).toMatch(/NEW\.entropy_precommit_verified_at IS DISTINCT FROM OLD\.entropy_precommit_verified_at/i);
|
||||
expect(sql).toMatch(/NOT \(OLD\.status = 'pending' AND NEW\.status = 'starting'\)/i);
|
||||
for (const field of [
|
||||
'economy', 'variant', 'ruleset_id', 'difficulty_preset', 'max_players',
|
||||
'seed_hash', 'dungeon', 'start_block_height'
|
||||
]) {
|
||||
expect(sql).toMatch(new RegExp(`NEW\\.${field} IS DISTINCT FROM OLD\\.${field}`, 'i'));
|
||||
}
|
||||
expect(sql).toMatch(/CREATE TRIGGER trg_matches_immutable_entropy_precommit/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
const EnvironmentValidator = require('../src/config/environmentValidator');
|
||||
|
||||
function config(overrides = {}) {
|
||||
const base = {
|
||||
paymentsEnabled: true,
|
||||
currency: { symbol: 'XMR' },
|
||||
modes: {
|
||||
direct: { enabled: true, price: 10000n },
|
||||
credits: {
|
||||
enabled: true,
|
||||
packages: [{ id: 'small', credits: 10, price: 90000000000n }]
|
||||
}
|
||||
},
|
||||
payouts: {
|
||||
enabled: false,
|
||||
rules: {
|
||||
direct: {
|
||||
enabled: false,
|
||||
multipliers: { escape: 2, escapeWithTreasure: 3 },
|
||||
minPayout: 1n,
|
||||
maxPayout: 50000n
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
multipliers: { escape: 2, escapeWithTreasure: 3 },
|
||||
baseValue: 10000n,
|
||||
minPayout: 1n,
|
||||
maxPayout: 50000n
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
modes: {
|
||||
...base.modes,
|
||||
...(overrides.modes || {})
|
||||
},
|
||||
payouts: {
|
||||
...base.payouts,
|
||||
...(overrides.payouts || {}),
|
||||
rules: {
|
||||
direct: {
|
||||
...base.payouts.rules.direct,
|
||||
...(overrides.payouts?.rules?.direct || {})
|
||||
},
|
||||
credits: {
|
||||
...base.payouts.rules.credits,
|
||||
...(overrides.payouts?.rules?.credits || {})
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function productionEnv(overrides = {}) {
|
||||
return {
|
||||
NODE_ENV: 'production',
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'stagenet',
|
||||
PAYMENTS_ENABLED: 'true',
|
||||
PAYOUTS_ENABLED: 'false',
|
||||
DIRECT_PAYOUTS_ENABLED: 'false',
|
||||
CREDITS_PAYOUTS_ENABLED: 'false',
|
||||
PRIMARY_WALLET_ENDPOINT: 'http://127.0.0.1:38083',
|
||||
WALLET_RPC_USER: 'monerogue',
|
||||
WALLET_RPC_PASSWORD: 'wallet-rpc-correct-horse-2026',
|
||||
PRIMARY_RPC_ENDPOINT: 'http://127.0.0.1:38081',
|
||||
DB_PASSWORD: 'correct-horse-battery-staple-2026',
|
||||
ADMIN_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
OPERATOR_NAME: 'Test Operator LLC',
|
||||
OPERATOR_CONTACT_URL: 'mailto:support@operator.invalid',
|
||||
LEGAL_POLICY_VERSION: '2026-07-21-v1',
|
||||
TERMS_EFFECTIVE_DATE: '2026-07-21',
|
||||
MINIMUM_AGE: '18',
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
|
||||
BLOCK_SOURCE: 'daemon',
|
||||
TRUST_PROXY: 'true',
|
||||
MATCH_PAID_ENTROPY_DELAY_BLOCKS: '2',
|
||||
MATCH_PAID_ENTROPY_CONFIRMATIONS: '2',
|
||||
PAYOUT_MAX_PER_GAME: '50000',
|
||||
BALANCE_CRITICAL: '50000',
|
||||
BALANCE_WARN: '100000',
|
||||
CREDITS_PACKAGES: '[{"id":"small","credits":10,"price":"90000000000"}]',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const silentLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
};
|
||||
|
||||
describe('EnvironmentValidator production launch gate', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('accepts explicit paid-credits plus free-play style config with payouts disabled', () => {
|
||||
const validator = new EnvironmentValidator({ env: productionEnv(), logger: silentLogger });
|
||||
const result = validator.assertValid(config());
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.money).toEqual(expect.objectContaining({
|
||||
paymentsEnabled: true,
|
||||
payoutsEnabled: false
|
||||
}));
|
||||
});
|
||||
|
||||
test('accepts explicitly capped stagenet crypto match payouts', () => {
|
||||
const env = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
MATCH_ENABLED: 'true',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
||||
MATCH_PAYOUTS_ENABLED: 'true',
|
||||
MATCH_PAYOUT_MAX: '50000',
|
||||
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
||||
MATCH_HOUSE_FEE_PERCENT: '5',
|
||||
MATCH_RULESET_ID: 'race',
|
||||
MATCH_MAX_PLAYERS: '4'
|
||||
});
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.assertValid(config({
|
||||
payouts: { enabled: true },
|
||||
products: {
|
||||
cosmetic: [{
|
||||
id: 'race_2',
|
||||
price: 20000n,
|
||||
grants: { race_entries: 2, race_entry_value_atomic: '10000' }
|
||||
}]
|
||||
}
|
||||
}));
|
||||
|
||||
expect(result.money.matchPayoutsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '1'],
|
||||
['MATCH_PAID_ENTROPY_CONFIRMATIONS', '1'],
|
||||
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '2.5'],
|
||||
['MATCH_PAID_ENTROPY_CONFIRMATIONS', '9007199254740992'],
|
||||
['MATCH_PAID_ENTROPY_DELAY_BLOCKS', '101']
|
||||
])('rejects unsafe production paid entropy setting %s=%s', (key, value) => {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ MATCH_ENABLED: 'true', [key]: value }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toContain(
|
||||
`${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.`
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects crypto co-op and a cap below the configured maximum pot liability', () => {
|
||||
const env = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
MATCH_ENABLED: 'true',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
||||
MATCH_PAYOUTS_ENABLED: 'true',
|
||||
MATCH_PAYOUT_MAX: '100',
|
||||
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
||||
MATCH_HOUSE_FEE_PERCENT: '5',
|
||||
MATCH_RULESET_ID: 'coop-escape',
|
||||
MATCH_MAX_PLAYERS: '4'
|
||||
});
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.validate(config({ payouts: { enabled: true } }));
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('single-winner crypto payout semantics'),
|
||||
expect.stringContaining('below the configured 4-player winner liability')
|
||||
]));
|
||||
});
|
||||
|
||||
test('rejects mismatched or underfunded race-ticket products', () => {
|
||||
const env = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
MATCH_ENABLED: 'true',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
||||
MATCH_PAYOUTS_ENABLED: 'true',
|
||||
MATCH_PAYOUT_MAX: '50000',
|
||||
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
||||
MATCH_HOUSE_FEE_PERCENT: '5',
|
||||
MATCH_RULESET_ID: 'race',
|
||||
MATCH_MAX_PLAYERS: '4'
|
||||
});
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
|
||||
payouts: { enabled: true },
|
||||
products: {
|
||||
cosmetic: [
|
||||
{
|
||||
id: 'wrong-value',
|
||||
price: 20000n,
|
||||
grants: { race_entries: 2, race_entry_value_atomic: '9000' }
|
||||
},
|
||||
{
|
||||
id: 'underfunded',
|
||||
price: 19999n,
|
||||
grants: { race_entries: 2, race_entry_value_atomic: '10000' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('wrong-value backing must equal MATCH_ENTRY_FEE_ATOMIC'),
|
||||
expect.stringContaining('underfunded price must fund at least 20000')
|
||||
]));
|
||||
});
|
||||
|
||||
test('rejects a 100% or over-precision match house fee', () => {
|
||||
for (const value of ['100', '5.555']) {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ MATCH_HOUSE_FEE_PERCENT: value }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
expect(result.errors).toContain(
|
||||
'MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing production secrets and RPC endpoints are fatal', () => {
|
||||
const env = productionEnv({
|
||||
ADMIN_API_KEY: 'change-me',
|
||||
DB_PASSWORD: '',
|
||||
PRIMARY_WALLET_ENDPOINT: '',
|
||||
PRIMARY_RPC_ENDPOINT: ''
|
||||
});
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('PRIMARY_WALLET_ENDPOINT'),
|
||||
expect.stringContaining('PRIMARY_RPC_ENDPOINT'),
|
||||
expect.stringContaining('ADMIN_API_KEY'),
|
||||
expect.stringContaining('DB_PASSWORD')
|
||||
]));
|
||||
});
|
||||
|
||||
test('wallet RPC authentication requires a complete credential pair', () => {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ WALLET_RPC_USER: 'rpc-user', WALLET_RPC_PASSWORD: '' }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toContain('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.');
|
||||
});
|
||||
|
||||
test('production payments reject unauthenticated or remotely exposed wallet RPC', () => {
|
||||
const missing = productionEnv({ WALLET_RPC_USER: '', WALLET_RPC_PASSWORD: '' });
|
||||
const missingResult = new EnvironmentValidator({ env: missing, logger: silentLogger }).validate(config());
|
||||
expect(missingResult.errors).toContain(
|
||||
'Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.'
|
||||
);
|
||||
|
||||
const remote = productionEnv({ PRIMARY_WALLET_ENDPOINT: 'http://10.42.1.20:38083' });
|
||||
const remoteResult = new EnvironmentValidator({ env: remote, logger: silentLogger }).validate(config());
|
||||
expect(remoteResult.errors).toContain(
|
||||
'Production wallet-rpc over plaintext HTTP must bind to a loopback address.'
|
||||
);
|
||||
});
|
||||
|
||||
test('payout-only recovery still requires an authenticated loopback wallet', () => {
|
||||
const payoutOnlyConfig = config({
|
||||
paymentsEnabled: false,
|
||||
modes: {
|
||||
direct: { enabled: false },
|
||||
credits: { enabled: false, packages: [] }
|
||||
},
|
||||
payouts: { enabled: true }
|
||||
});
|
||||
const env = productionEnv({
|
||||
PAYMENTS_ENABLED: 'false',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYMENT_ENABLED: 'false',
|
||||
CREDITS_ENABLED: 'false',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
WALLET_RPC_USER: '',
|
||||
WALLET_RPC_PASSWORD: '',
|
||||
PRIMARY_WALLET_ENDPOINT: 'http://10.42.1.20:38083'
|
||||
});
|
||||
|
||||
const errors = new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.validate(payoutOnlyConfig).errors;
|
||||
|
||||
expect(errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Production financial workers require WALLET_RPC_USER'),
|
||||
expect.stringContaining('plaintext HTTP must bind to a loopback'),
|
||||
expect.stringContaining('ALLOW_MAINNET_PAYOUTS=true')
|
||||
]));
|
||||
|
||||
env.PRIMARY_WALLET_ENDPOINT = '';
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.validate(payoutOnlyConfig).errors)
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Financial workers require a wallet'),
|
||||
expect.stringContaining('PRIMARY_WALLET_ENDPOINT')
|
||||
]));
|
||||
});
|
||||
|
||||
test('payout-only recovery cannot bypass the mainnet acknowledgement', () => {
|
||||
const payoutOnlyConfig = config({
|
||||
paymentsEnabled: false,
|
||||
modes: {
|
||||
direct: { enabled: false },
|
||||
credits: { enabled: false, packages: [] }
|
||||
},
|
||||
payouts: { enabled: true }
|
||||
});
|
||||
const env = productionEnv({
|
||||
PAYMENTS_ENABLED: 'false',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYMENT_ENABLED: 'false',
|
||||
CREDITS_ENABLED: 'false',
|
||||
MONERO_NETWORK: 'mainnet'
|
||||
});
|
||||
|
||||
const validator = new EnvironmentValidator({ env, logger: silentLogger });
|
||||
expect(validator.validate(payoutOnlyConfig).errors).toContain(
|
||||
'Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.'
|
||||
);
|
||||
env.ALLOW_MAINNET_PAYOUTS = 'true';
|
||||
expect(validator.validate(payoutOnlyConfig).errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('production requires explicit payout intent instead of dangerous defaults', () => {
|
||||
const env = productionEnv();
|
||||
delete env.PAYOUTS_ENABLED;
|
||||
delete env.DIRECT_PAYOUTS_ENABLED;
|
||||
delete env.CREDITS_PAYOUTS_ENABLED;
|
||||
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('explicit PAYOUTS_ENABLED'),
|
||||
expect.stringContaining('explicit DIRECT_PAYOUTS_ENABLED'),
|
||||
expect.stringContaining('explicit CREDITS_PAYOUTS_ENABLED')
|
||||
]));
|
||||
});
|
||||
|
||||
test('production paid play requires truthful operator metadata and acknowledgement policy', () => {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({
|
||||
OPERATOR_NAME: 'CHANGE_ME',
|
||||
OPERATOR_CONTACT_URL: 'javascript:alert(1)',
|
||||
LEGAL_POLICY_VERSION: '',
|
||||
TERMS_EFFECTIVE_DATE: '2026-02-30',
|
||||
MINIMUM_AGE: '0',
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED: 'false'
|
||||
}),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('PAID_ACKNOWLEDGEMENT_REQUIRED=true'),
|
||||
expect.stringContaining('LEGAL_POLICY_VERSION'),
|
||||
expect.stringContaining('TERMS_EFFECTIVE_DATE'),
|
||||
expect.stringContaining('MINIMUM_AGE'),
|
||||
expect.stringContaining('OPERATOR_NAME'),
|
||||
expect.stringContaining('OPERATOR_CONTACT_URL')
|
||||
]));
|
||||
});
|
||||
|
||||
test('rejects misspelled booleans and legacy modes instead of using defaults', () => {
|
||||
const env = productionEnv({
|
||||
GAME_MODE: 'FRE',
|
||||
PAYOUTS_ENABLED: 'flase',
|
||||
DIRECT_PAYOUTS_ENABLED: 'treu'
|
||||
});
|
||||
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('GAME_MODE must be'),
|
||||
expect.stringContaining('PAYOUTS_ENABLED must be an explicit boolean'),
|
||||
expect.stringContaining('DIRECT_PAYOUTS_ENABLED must be an explicit boolean')
|
||||
]));
|
||||
});
|
||||
|
||||
test('rejects malformed atomic amounts and payout multipliers', () => {
|
||||
const env = productionEnv({
|
||||
DIRECT_GAME_PRICE: '0.01 XMR',
|
||||
PAYOUT_MAX_PER_GAME: '-1',
|
||||
DIRECT_PAYOUT_ESCAPE: 'double'
|
||||
});
|
||||
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('DIRECT_GAME_PRICE must be a non-negative integer'),
|
||||
expect.stringContaining('PAYOUT_MAX_PER_GAME must be a non-negative integer'),
|
||||
expect.stringContaining('DIRECT_PAYOUT_ESCAPE must be a finite number')
|
||||
]));
|
||||
});
|
||||
|
||||
test('rejects unknown payment modes instead of silently disabling them', () => {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ PAYMENT_MODES: 'direct,magic' }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toContain('PAYMENT_MODES must contain only direct and/or credits.');
|
||||
});
|
||||
|
||||
test('rejects an invalid trusted-proxy hop count', () => {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ TRUST_PROXY_HOPS: 'all' }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toContain('TRUST_PROXY_HOPS must be an integer from 1 through 8.');
|
||||
});
|
||||
|
||||
test('mainnet payout dispatch needs a deliberate acknowledgement', () => {
|
||||
const payoutConfig = config({
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: { direct: { enabled: true }, credits: { enabled: true } }
|
||||
}
|
||||
});
|
||||
const env = productionEnv({
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'true'
|
||||
});
|
||||
const validator = new EnvironmentValidator({ env, logger: silentLogger });
|
||||
|
||||
expect(validator.validate(payoutConfig).errors)
|
||||
.toContain('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.');
|
||||
|
||||
env.ALLOW_MAINNET_PAYOUTS = 'true';
|
||||
expect(validator.validate(payoutConfig).errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('stagenet 2x/3x payouts do not require the mainnet acknowledgement', () => {
|
||||
const payoutConfig = config({
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: { direct: { enabled: true }, credits: { enabled: true } }
|
||||
}
|
||||
});
|
||||
const env = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'true'
|
||||
});
|
||||
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.money.payoutsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects direct 3x above cap and credits 2x below minimum', () => {
|
||||
const env = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'true'
|
||||
});
|
||||
const payoutConfig = config({
|
||||
modes: { direct: { enabled: true, price: 20000n } },
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
direct: { enabled: true, maxPayout: 50000n },
|
||||
credits: { enabled: true, baseValue: 1000n, minPayout: 3000n }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(payoutConfig);
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
'Direct escapeWithTreasure payout (60000 atomic units) exceeds its configured maximum (50000).',
|
||||
'Credits escape payout (2000 atomic units) is below its configured minimum (3000).'
|
||||
]));
|
||||
});
|
||||
|
||||
test('payout launch gate requires a reserve that covers the largest accepted liability', () => {
|
||||
const payoutConfig = config({
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: { direct: { enabled: true }, credits: { enabled: false } }
|
||||
}
|
||||
});
|
||||
const missing = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
PAYOUT_MAX_PER_GAME: '60000',
|
||||
BALANCE_CRITICAL: '',
|
||||
BALANCE_WARN: ''
|
||||
});
|
||||
expect(new EnvironmentValidator({ env: missing, logger: silentLogger })
|
||||
.validate(payoutConfig).errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('BALANCE_CRITICAL must be an explicit positive'),
|
||||
expect.stringContaining('BALANCE_WARN must be an explicit positive')
|
||||
]));
|
||||
|
||||
const underfunded = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
PAYOUT_MAX_PER_GAME: '60000',
|
||||
BALANCE_CRITICAL: '59999',
|
||||
BALANCE_WARN: '59998'
|
||||
});
|
||||
expect(new EnvironmentValidator({ env: underfunded, logger: silentLogger })
|
||||
.validate(payoutConfig).errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('BALANCE_CRITICAL must be at least PAYOUT_MAX_PER_GAME (60000'),
|
||||
expect.stringContaining('BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL')
|
||||
]));
|
||||
});
|
||||
|
||||
test.each([
|
||||
['BLOCK_SOURCE', 'simulated'],
|
||||
['SIMULATED_BLOCKS', 'true'],
|
||||
['FORCE_SIMULATED_BLOCKS', 'true']
|
||||
])('rejects production simulation via %s', (key, value) => {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ [key]: value }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toContain('Simulated blocks are forbidden when NODE_ENV=production.');
|
||||
});
|
||||
|
||||
test('assertValid throws a typed error for startup to fail closed', () => {
|
||||
const validator = new EnvironmentValidator({
|
||||
env: productionEnv({ ADMIN_API_KEY: 'weak' }),
|
||||
logger: silentLogger
|
||||
});
|
||||
|
||||
expect(() => validator.assertValid(config())).toThrow(expect.objectContaining({
|
||||
name: 'EnvironmentValidationError',
|
||||
code: 'INVALID_ENVIRONMENT'
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnvironmentValidator development ergonomics', () => {
|
||||
test('missing wallet/daemon endpoints warn without hard-stopping development', () => {
|
||||
const env = { NODE_ENV: 'development', CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'stagenet' };
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.warnings.some(message => message.includes('PRIMARY_WALLET_ENDPOINT'))).toBe(true);
|
||||
expect(result.warnings.some(message => message.includes('PRIMARY_RPC_ENDPOINT'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
const FairnessOfferManager = require('../src/game/fairnessOfferManager');
|
||||
const Game = require('../src/game/game');
|
||||
const DungeonGenerator = require('../src/game/dungeon');
|
||||
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
|
||||
const {
|
||||
hashSeed,
|
||||
deriveSeed,
|
||||
createGameProof,
|
||||
getPreGameCommitment,
|
||||
getPostGameReveal,
|
||||
verifyGameProof
|
||||
} = require('../src/game/provablyFair');
|
||||
|
||||
const SERVER_SEED = 'a'.repeat(64);
|
||||
const CLIENT_SEED = '12'.repeat(32);
|
||||
|
||||
describe('two-phase solo fairness offers', () => {
|
||||
test('publishes a commitment before client seed and consumes it exactly once', () => {
|
||||
let now = 1000;
|
||||
const offers = new FairnessOfferManager({
|
||||
now: () => now,
|
||||
seedFactory: () => SERVER_SEED,
|
||||
idFactory: () => 'offer-1',
|
||||
ttlMs: 60000
|
||||
});
|
||||
|
||||
const published = offers.ensureOffer('socket-a');
|
||||
expect(published).toEqual(expect.objectContaining({
|
||||
offerId: 'offer-1',
|
||||
commitment: hashSeed(SERVER_SEED),
|
||||
issuedAt: 1000,
|
||||
proofVersion: 2
|
||||
}));
|
||||
|
||||
now = 2000; // client contribution arrives strictly after publication
|
||||
const consumed = offers.consume('socket-a', {
|
||||
offerId: published.offerId,
|
||||
clientSeed: CLIENT_SEED.toUpperCase()
|
||||
});
|
||||
expect(consumed.success).toBe(true);
|
||||
expect(consumed.proofInput.offerIssuedAt).toBeLessThan(now);
|
||||
expect(consumed.proofInput.clientSeed).toBe(CLIENT_SEED);
|
||||
expect(consumed.proofInput.commitment).toBe(published.commitment);
|
||||
|
||||
expect(offers.consume('socket-a', {
|
||||
offerId: published.offerId,
|
||||
clientSeed: CLIENT_SEED
|
||||
})).toEqual(expect.objectContaining({ success: false, code: 'OFFER_REPLAYED' }));
|
||||
});
|
||||
|
||||
test('cannot steal, swap, or attach a client seed without echoing the published offer', () => {
|
||||
const offers = new FairnessOfferManager({
|
||||
seedFactory: () => SERVER_SEED,
|
||||
idFactory: () => 'offer-owner'
|
||||
});
|
||||
const published = offers.ensureOffer('owner');
|
||||
|
||||
expect(offers.consume('attacker', {
|
||||
offerId: published.offerId,
|
||||
clientSeed: CLIENT_SEED
|
||||
}).code).toBe('OFFER_OWNER_MISMATCH');
|
||||
expect(offers.consume('owner', { clientSeed: CLIENT_SEED }).code).toBe('OFFER_REQUIRED');
|
||||
|
||||
const consumed = offers.consume('owner', {
|
||||
offerId: published.offerId,
|
||||
clientSeed: CLIENT_SEED
|
||||
});
|
||||
expect(consumed.success).toBe(true);
|
||||
|
||||
expect(() => createGameProof('game', CLIENT_SEED, {
|
||||
...consumed.proofInput,
|
||||
commitment: 'b'.repeat(64)
|
||||
})).toThrow(/commitment does not match/i);
|
||||
});
|
||||
|
||||
test('legacy clients may consume the already-published current offer with an empty seed', () => {
|
||||
const offers = new FairnessOfferManager({
|
||||
seedFactory: () => SERVER_SEED,
|
||||
idFactory: () => 'legacy-offer'
|
||||
});
|
||||
offers.ensureOffer('legacy');
|
||||
const consumed = offers.consume('legacy');
|
||||
expect(consumed.success).toBe(true);
|
||||
expect(consumed.proofInput.clientSeed).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2 proof binds the accepted client seed to the played dungeon', () => {
|
||||
function precommit(clientSeed, offerId) {
|
||||
return {
|
||||
proofVersion: 2,
|
||||
offerId,
|
||||
offerIssuedAt: 123,
|
||||
serverSeed: SERVER_SEED,
|
||||
commitment: hashSeed(SERVER_SEED),
|
||||
clientSeed
|
||||
};
|
||||
}
|
||||
|
||||
test('Game derives its RNG before layout generation and persists reproducible context', () => {
|
||||
const game = new Game('socket', { id: 1 }, { fairnessProof: precommit(CLIENT_SEED, 'offer-a') });
|
||||
expect(game.gameProof.seed).toBe(deriveSeed(SERVER_SEED, CLIENT_SEED));
|
||||
expect(game.gameProof.clientSeed).toBe(CLIENT_SEED);
|
||||
expect(game.gameProof.offerId).toBe('offer-a');
|
||||
|
||||
const context = game.gameProof.context;
|
||||
const regenerated = DungeonGenerator.regenerateFromSeed(
|
||||
game.gameProof.seed,
|
||||
context.cryptoType,
|
||||
context.generationOptions
|
||||
);
|
||||
if (context.maxDepth > 1) regenerated.treasure = null;
|
||||
expect(DungeonGenerator.layoutFingerprint(regenerated)).toBe(game.gameProof.layoutFingerprint);
|
||||
});
|
||||
|
||||
test('changing the client seed changes both effective seed and dungeon', () => {
|
||||
const one = new Game('one', { id: 1 }, { fairnessProof: precommit('01', 'offer-1') });
|
||||
const two = new Game('two', { id: 2 }, { fairnessProof: precommit('02', 'offer-2') });
|
||||
expect(one.gameProof.seed).not.toBe(two.gameProof.seed);
|
||||
expect(one.gameProof.layoutFingerprint).not.toBe(two.gameProof.layoutFingerprint);
|
||||
});
|
||||
|
||||
test('pre-game data hides server seed, reveal links by game id, and tampering fails', () => {
|
||||
const proof = createGameProof('game-uuid', CLIENT_SEED, precommit(CLIENT_SEED, 'offer-z'));
|
||||
proof.layoutFingerprint = 'f'.repeat(64);
|
||||
const before = getPreGameCommitment(proof);
|
||||
expect(before.clientSeed).toBe(CLIENT_SEED);
|
||||
expect(before.offerId).toBe('offer-z');
|
||||
expect(before.serverSeed).toBeUndefined();
|
||||
expect(before.seed).toBeUndefined();
|
||||
|
||||
const reveal = getPostGameReveal(proof);
|
||||
expect(reveal.verificationUrl).toBe('/verify/game-uuid');
|
||||
expect(verifyGameProof(reveal).valid).toBe(true);
|
||||
expect(verifyGameProof({ ...reveal, clientSeed: '03' }).valid).toBe(false);
|
||||
expect(verifyGameProof({ ...reveal, effectiveSeed: 'b'.repeat(64), seed: 'b'.repeat(64) }).valid).toBe(false);
|
||||
expect(verifyGameProof({ ...reveal, serverSeed: 'b'.repeat(64) }).valid).toBe(false);
|
||||
});
|
||||
|
||||
test('post-game reveal preserves the terminal result after endGame changes runtime state', () => {
|
||||
const game = new Game('result-socket', { id: 3, endGame() {} }, {
|
||||
fairnessProof: precommit(CLIENT_SEED, 'result-offer')
|
||||
});
|
||||
game.endGame('won', { score: 10 });
|
||||
expect(game.gameState).toBe('ended');
|
||||
expect(game.getProofReveal().gameResult.won).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all-depth versioned verification', () => {
|
||||
let savedLevels;
|
||||
let savedCrypto;
|
||||
|
||||
beforeEach(() => {
|
||||
savedLevels = process.env.DUNGEON_LEVELS;
|
||||
savedCrypto = process.env.CRYPTO_TYPE;
|
||||
process.env.DUNGEON_LEVELS = '3';
|
||||
process.env.CRYPTO_TYPE = 'WOW';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedLevels === undefined) delete process.env.DUNGEON_LEVELS;
|
||||
else process.env.DUNGEON_LEVELS = savedLevels;
|
||||
if (savedCrypto === undefined) delete process.env.CRYPTO_TYPE;
|
||||
else process.env.CRYPTO_TYPE = savedCrypto;
|
||||
});
|
||||
|
||||
test('persists and deterministically verifies every advertised depth', () => {
|
||||
const fairnessProof = {
|
||||
proofVersion: 2,
|
||||
offerId: 'all-depth-offer',
|
||||
offerIssuedAt: 123,
|
||||
serverSeed: SERVER_SEED,
|
||||
commitment: hashSeed(SERVER_SEED),
|
||||
clientSeed: CLIENT_SEED
|
||||
};
|
||||
const game = new Game('depth-socket', { id: 7 }, { fairnessProof });
|
||||
const reveal = game.getProofReveal();
|
||||
|
||||
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
|
||||
expect(game.gameProof.layoutFingerprints).toHaveLength(3);
|
||||
expect(game.gameProof.layoutFingerprints.map(item => item.depth)).toEqual([1, 2, 3]);
|
||||
|
||||
const result = attachDungeonVerification(verifyGameProof(reveal), {
|
||||
effectiveSeed: reveal.effectiveSeed,
|
||||
proofContext: reveal.context,
|
||||
expectedFingerprint: reveal.layoutFingerprint,
|
||||
expectedFingerprints: reveal.layoutFingerprints,
|
||||
generatorVersion: reveal.generatorVersion
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.verificationScope).toBe('all_depths');
|
||||
expect(result.verifiedDepths).toEqual([1, 2, 3]);
|
||||
expect(result.levels.every(level => level.matches)).toBe(true);
|
||||
});
|
||||
|
||||
test('fails if any depth is tampered with or omitted', () => {
|
||||
const game = new Game('tamper-socket', { id: 8 }, {
|
||||
fairnessProof: {
|
||||
proofVersion: 2,
|
||||
offerId: 'tamper-offer',
|
||||
offerIssuedAt: 123,
|
||||
serverSeed: SERVER_SEED,
|
||||
commitment: hashSeed(SERVER_SEED),
|
||||
clientSeed: CLIENT_SEED
|
||||
}
|
||||
});
|
||||
const reveal = game.getProofReveal();
|
||||
const tampered = reveal.layoutFingerprints.map(item => ({ ...item }));
|
||||
tampered[1].fingerprint = '0'.repeat(64);
|
||||
const mismatch = attachDungeonVerification(verifyGameProof(reveal), {
|
||||
effectiveSeed: reveal.effectiveSeed,
|
||||
proofContext: reveal.context,
|
||||
expectedFingerprints: tampered,
|
||||
generatorVersion: reveal.generatorVersion
|
||||
});
|
||||
expect(mismatch.valid).toBe(false);
|
||||
expect(mismatch.levels[1].matches).toBe(false);
|
||||
|
||||
const incomplete = attachDungeonVerification(verifyGameProof(reveal), {
|
||||
effectiveSeed: reveal.effectiveSeed,
|
||||
proofContext: reveal.context,
|
||||
expectedFingerprints: reveal.layoutFingerprints.slice(0, 2),
|
||||
generatorVersion: reveal.generatorVersion
|
||||
});
|
||||
expect(incomplete.valid).toBe(false);
|
||||
expect(incomplete.regenerationError).toBe('incomplete_layout_manifest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser contribution helper', () => {
|
||||
test('generates a fresh 256-bit client seed after selecting the offer', () => {
|
||||
const oldWindow = global.window;
|
||||
const oldSocket = global.socket;
|
||||
global.window = {
|
||||
crypto: { getRandomValues: (bytes) => { for (let i = 0; i < bytes.length; i++) bytes[i] = i; return bytes; } }
|
||||
};
|
||||
global.socket = { emit: jest.fn() };
|
||||
jest.resetModules();
|
||||
const BrowserHandlers = require('../html/js/network/socketHandlers.js');
|
||||
BrowserHandlers._fairnessOffer = { offerId: 'browser-offer', commitment: hashSeed(SERVER_SEED) };
|
||||
const payload = BrowserHandlers.fairnessAttempt({ free: true });
|
||||
expect(payload.free).toBe(true);
|
||||
expect(payload.fairnessOfferId).toBe('browser-offer');
|
||||
expect(payload.clientSeed).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(BrowserHandlers._pendingFairnessAttempt.commitment).toBe(hashSeed(SERVER_SEED));
|
||||
global.window = oldWindow;
|
||||
global.socket = oldSocket;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const deploy = name => fs.readFileSync(path.join(__dirname, '..', 'scripts', 'deploy', name), 'utf8');
|
||||
|
||||
describe('deployment financial-audit contract', () => {
|
||||
const audit = deploy('financial-audit.sql');
|
||||
|
||||
test('retained consumed match anchors are not treated as live escrow', () => {
|
||||
expect(audit).toMatch(/match_queue_entries\s+WHERE status IS NULL OR status NOT IN \('consumed', 'cancelled'\)/);
|
||||
expect(audit).not.toMatch(/status NOT IN \('cancelled'\)/);
|
||||
});
|
||||
|
||||
test('nullable or unknown money/game states and a stale schema ledger fail closed', () => {
|
||||
expect(audit).toContain("payouts WHERE status IS DISTINCT FROM 'completed'");
|
||||
expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')");
|
||||
expect(audit).toContain("MAX(filename) = '040_paid_match_entropy_precommit.sql'");
|
||||
expect(audit).toContain('COUNT(*) = 40');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MIGRATIONS = path.join(__dirname, '../src/migrations');
|
||||
const AUDIT = path.join(__dirname, '../scripts/deploy/financial-constraint-audit.sql');
|
||||
const VALIDATE = path.join(__dirname, '../scripts/deploy/financial-constraint-validate-restore.sql');
|
||||
|
||||
const CONSTRAINTS = Object.freeze([
|
||||
['payments', 'payments_status_check'],
|
||||
['payments', 'payments_payment_type_check'],
|
||||
['payouts', 'payouts_status_check'],
|
||||
['games', 'games_payout_commitment_complete'],
|
||||
['games', 'games_layout_fingerprints_array_check'],
|
||||
['race_entry_lots', 'race_entry_lots_remaining_within_original'],
|
||||
['match_queue_entries', 'match_queue_entries_status_check'],
|
||||
['match_queue_entries', 'match_queue_entries_escrow_nonnegative'],
|
||||
['match_queue_entries', 'match_queue_committed_escrow_complete'],
|
||||
['matches', 'matches_payout_liability_amount_nonnegative'],
|
||||
['matches', 'matches_payout_liability_cap_positive'],
|
||||
['matches', 'matches_payout_liability_complete'],
|
||||
['matches', 'matches_payout_liability_economics_consistent'],
|
||||
['race_entry_lots', 'race_entry_lots_refund_shape'],
|
||||
['payment_refunds', 'payment_refunds_progress_nonnegative'],
|
||||
['games', 'games_entry_consumption_shape'],
|
||||
['payments', 'payments_fairness_binding_complete'],
|
||||
['payouts', 'payouts_no_address_review_shape']
|
||||
]);
|
||||
|
||||
function migrationNotValidConstraintNames() {
|
||||
const names = new Set();
|
||||
for (const fileName of fs.readdirSync(MIGRATIONS).filter(name => name.endsWith('.sql'))) {
|
||||
const sql = fs.readFileSync(path.join(MIGRATIONS, fileName), 'utf8');
|
||||
for (const marker of sql.matchAll(/NOT\s+VALID\s*;/gi)) {
|
||||
const prefix = sql.slice(0, marker.index);
|
||||
const declarations = [...prefix.matchAll(/ADD\s+CONSTRAINT\s+([a-z0-9_]+)/gi)];
|
||||
if (declarations.length) names.add(declarations.at(-1)[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
describe('historical financial constraint release gates', () => {
|
||||
const expectedNames = CONSTRAINTS.map(([, name]) => name).sort();
|
||||
|
||||
test('inventory stays synchronized with every migration NOT VALID constraint', () => {
|
||||
expect(migrationNotValidConstraintNames()).toEqual(expectedNames);
|
||||
expect(CONSTRAINTS).toHaveLength(18);
|
||||
});
|
||||
|
||||
test('read-only audit names every constraint and checks historical predicates', () => {
|
||||
const sql = fs.readFileSync(AUDIT, 'utf8');
|
||||
expect(sql).toMatch(/BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;/i);
|
||||
expect(sql).toContain("current_database() = :'expected_database'");
|
||||
expect(sql).toContain('historical_constraints_clean');
|
||||
expect(sql).toContain('IS FALSE');
|
||||
expect(sql).not.toMatch(/\bALTER\s+TABLE\b/i);
|
||||
expect(sql).not.toMatch(/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE)\b/i);
|
||||
for (const [, name] of CONSTRAINTS) {
|
||||
expect(sql).toContain(`'${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('disposable-restore gate explicitly validates every constraint then rolls back', () => {
|
||||
const sql = fs.readFileSync(VALIDATE, 'utf8');
|
||||
expect(sql).toContain("current_database() ~* '(restore|scratch)'");
|
||||
expect(sql).toContain('I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE');
|
||||
expect(sql).toContain('\\ir financial-constraint-audit.sql');
|
||||
expect(sql).toContain('SET LOCAL lock_timeout');
|
||||
expect(sql).toMatch(/ROLLBACK;[\s\S]*PASS: PostgreSQL validated all 18 constraints/i);
|
||||
for (const [table, name] of CONSTRAINTS) {
|
||||
expect(sql).toContain(
|
||||
`ALTER TABLE public.${table} VALIDATE CONSTRAINT ${name};`
|
||||
);
|
||||
}
|
||||
expect((sql.match(/\bVALIDATE CONSTRAINT\s+[a-z0-9_]+;/gi) || []))
|
||||
.toHaveLength(CONSTRAINTS.length);
|
||||
});
|
||||
});
|
||||
@@ -50,8 +50,8 @@ describe('Follow-up 1 — no-address match winner reconciliation', () => {
|
||||
expect(ok).toBe(true);
|
||||
const payoutUpdate = db.query.mock.calls.find(c => /UPDATE\s+payouts/i.test(c[0]));
|
||||
expect(payoutUpdate).toBeTruthy();
|
||||
// Narrow, safe filter — only the no-address match liability, never generic needs_review.
|
||||
expect(payoutUpdate[0]).toMatch(/reason\s*=\s*'match_winner_no_address'/);
|
||||
// Narrow, safe filter — only explicit no-address liabilities, never generic needs_review.
|
||||
expect(payoutUpdate[0]).toMatch(/reason\s+IN\s*\(\s*'match_winner_no_address'\s*,\s*'solo_winner_no_address'\s*\)/);
|
||||
expect(payoutUpdate[0]).toMatch(/status\s*=\s*'needs_review'/);
|
||||
expect(payoutUpdate[0]).toMatch(/PENDING_NO_ADDRESS/);
|
||||
expect(payoutUpdate[0]).toMatch(/SET payout_address = \$1, status = 'pending'/);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const GameManager = require('../src/game/gameManager');
|
||||
|
||||
function makeHarness(completion) {
|
||||
const targetedEmit = jest.fn();
|
||||
const io = {
|
||||
to: jest.fn(() => ({ emit: targetedEmit })),
|
||||
emit: jest.fn()
|
||||
};
|
||||
const game = {
|
||||
id: 'game-seed',
|
||||
player: { hasTreasure: false },
|
||||
moveCount: 12,
|
||||
startedAt: Date.now() - 1000,
|
||||
endGame: jest.fn(),
|
||||
getProofReveal: jest.fn(() => ({ commitment: 'proof' }))
|
||||
};
|
||||
const gameModeManager = {
|
||||
completeGame: jest.fn().mockResolvedValue(completion),
|
||||
db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) }
|
||||
};
|
||||
const manager = new GameManager({
|
||||
activeGames: new Map([['socket-1', game]]),
|
||||
io,
|
||||
broadcastManager: {},
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
gameModeManager
|
||||
});
|
||||
return { manager, game, io };
|
||||
}
|
||||
|
||||
async function finishWin(completion) {
|
||||
const harness = makeHarness(completion);
|
||||
await harness.manager.handleGameOver(
|
||||
{ id: 'socket-1' },
|
||||
harness.game,
|
||||
'won',
|
||||
'escaped',
|
||||
'Escaped',
|
||||
100
|
||||
);
|
||||
return harness.io.emit.mock.calls.find(([event]) => event === 'win_feed')?.[1];
|
||||
}
|
||||
|
||||
describe('solo win feed payout status', () => {
|
||||
test('marks a win paid when completion contains a nested payout amount', async () => {
|
||||
const feed = await finishWin({
|
||||
success: true,
|
||||
payout: { status: 'queued', amount: '200000000000', multiplier: 2 }
|
||||
});
|
||||
|
||||
expect(feed).toEqual(expect.objectContaining({ name: 'Alice', score: 100, paid: true }));
|
||||
});
|
||||
|
||||
test('does not trust an obsolete top-level amount when no payout was created', async () => {
|
||||
const feed = await finishWin({ success: true, amount: '200000000000', payout: null });
|
||||
|
||||
expect(feed).toEqual(expect.objectContaining({ paid: false }));
|
||||
});
|
||||
});
|
||||
+349
-19
@@ -110,7 +110,205 @@ describe('GameModeManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('production payout and free-play policy', () => {
|
||||
test('financial recovery gate blocks invoice intake and every paid start but preserves explicit free play', async () => {
|
||||
gmm.paymentsEnabled = true;
|
||||
gmm.directModeEnabled = true;
|
||||
gmm.creditsModeEnabled = true;
|
||||
gmm.financialAdmissionAllowed = () => false;
|
||||
|
||||
expect(gmm.isPaymentIntakeEnabled('single_game')).toBe(false);
|
||||
expect(gmm.isPaymentIntakeEnabled('credits_package')).toBe(false);
|
||||
expect(gmm.isPaymentIntakeEnabled('cosmetic_pack')).toBe(false);
|
||||
|
||||
const user = { id: 7, credits: 5, payout_address: 'wallet' };
|
||||
jest.spyOn(gmm, 'getOrCreateUser').mockResolvedValue(user);
|
||||
const paid = await gmm.processGameStart('socket-paid', 'game-paid');
|
||||
expect(paid).toMatchObject({
|
||||
success: false,
|
||||
code: 'FINANCIAL_RECOVERY_PENDING'
|
||||
});
|
||||
expect(mockDb.query).not.toHaveBeenCalled();
|
||||
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
||||
|
||||
expect(await gmm._processGameStartWithCredits(user, 'socket-paid', 'game-credits'))
|
||||
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
|
||||
expect(await gmm._processGameStartWithPayment(user, { id: 9 }, 'game-direct'))
|
||||
.toMatchObject({ success: false, code: 'FINANCIAL_RECOVERY_PENDING' });
|
||||
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
||||
|
||||
jest.spyOn(gmm, '_processGameStartFree').mockResolvedValue({
|
||||
success: true,
|
||||
effectiveMode: 'FREE'
|
||||
});
|
||||
await expect(gmm.processGameStart('socket-free', 'game-free', { forceFree: true }))
|
||||
.resolves.toEqual({ success: true, effectiveMode: 'FREE' });
|
||||
});
|
||||
|
||||
test('master payout switch overrides enabled per-mode rules and stops dispatch', async () => {
|
||||
gmm.payoutsEnabled = false;
|
||||
gmm.directPayoutEnabled = true;
|
||||
gmm.creditsPayoutEnabled = true;
|
||||
gmm.directRequiresAddress = true;
|
||||
|
||||
expect(gmm.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
|
||||
expect(gmm.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
|
||||
expect(gmm.requiresPayoutAddressForMode('PAID_SINGLE')).toBe(false);
|
||||
|
||||
await gmm._processPendingPayouts();
|
||||
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
||||
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shutdown cancels a queued payout debounce and rejects later scheduling', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const processPending = jest.spyOn(gmm, '_processPendingPayouts').mockResolvedValue();
|
||||
|
||||
gmm._scheduleBatchPayout();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
|
||||
gmm.shutdown();
|
||||
expect(gmm._batchPayoutTimer).toBeNull();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
gmm._scheduleBatchPayout();
|
||||
jest.advanceTimersByTime(5000);
|
||||
await Promise.resolve();
|
||||
expect(processPending).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('unified paid config does not accidentally inherit free mode when FREE_PLAY_ENABLED is false', () => {
|
||||
const oldGameMode = process.env.GAME_MODE;
|
||||
const oldFree = process.env.FREE_PLAY_ENABLED;
|
||||
try {
|
||||
delete process.env.GAME_MODE;
|
||||
process.env.FREE_PLAY_ENABLED = 'false';
|
||||
const manager = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager);
|
||||
expect(manager.paymentsEnabled).toBe(true);
|
||||
expect(manager.freePlayEnabled).toBe(false);
|
||||
} finally {
|
||||
if (oldGameMode === undefined) delete process.env.GAME_MODE; else process.env.GAME_MODE = oldGameMode;
|
||||
if (oldFree === undefined) delete process.env.FREE_PLAY_ENABLED; else process.env.FREE_PLAY_ENABLED = oldFree;
|
||||
}
|
||||
});
|
||||
|
||||
test('game mode info advertises the selected safe ruleset catalog', () => {
|
||||
const old = process.env.MATCH_RULESET_ID;
|
||||
try {
|
||||
process.env.MATCH_RULESET_ID = 'last-alive';
|
||||
const match = gmm.getGameModeInfo().modes.match;
|
||||
expect(match.activeRuleset).toEqual(expect.objectContaining({
|
||||
id: 'last-alive',
|
||||
label: 'Last Alive',
|
||||
winCondition: 'last-alive'
|
||||
}));
|
||||
expect(match.rulesets.map(r => r.id)).toEqual(expect.arrayContaining(['race', 'last-alive', 'score-attack', 'coop-escape']));
|
||||
expect(match.rulesets.map(r => r.id)).not.toContain('solo-classic');
|
||||
} finally {
|
||||
if (old === undefined) delete process.env.MATCH_RULESET_ID; else process.env.MATCH_RULESET_ID = old;
|
||||
}
|
||||
});
|
||||
|
||||
test('crypto race economy is hidden when the payout master switch is off', () => {
|
||||
const keys = [
|
||||
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
|
||||
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
|
||||
'MATCH_MAX_PLAYERS'
|
||||
];
|
||||
const old = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
||||
try {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUT_MAX = '1000000000000';
|
||||
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
||||
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
|
||||
process.env.MATCH_MAX_PLAYERS = '4';
|
||||
gmm.payoutsEnabled = false;
|
||||
expect(gmm._getMatchEconomies().crypto_race).toBeUndefined();
|
||||
gmm.payoutsEnabled = true;
|
||||
expect(gmm._getMatchEconomies().crypto_race).toBe(true);
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (old[key] === undefined) delete process.env[key]; else process.env[key] = old[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePayout', () => {
|
||||
test('publishes only the double/triple outcomes implemented by payout calculation', () => {
|
||||
gmm.directPayoutMultipliers = {
|
||||
escape: 2,
|
||||
escapeWithTreasure: 3,
|
||||
perfectRun: 5,
|
||||
experimentalJackpot: 20
|
||||
};
|
||||
gmm.creditPayoutMultipliers = {
|
||||
escape: 2,
|
||||
escapeWithTreasure: 3,
|
||||
perfectRun: 3
|
||||
};
|
||||
|
||||
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_SINGLE')).toStrictEqual({
|
||||
escape: 2,
|
||||
escapeWithTreasure: 3
|
||||
});
|
||||
expect(gmm.getImplementedPayoutMultipliersForMode('PAID_CREDITS')).toStrictEqual({
|
||||
escape: 2,
|
||||
escapeWithTreasure: 3
|
||||
});
|
||||
|
||||
const publicInfo = gmm.getGameModeInfo();
|
||||
expect(publicInfo.payoutMultipliers).toStrictEqual({
|
||||
direct: { escape: 2, escapeWithTreasure: 3 },
|
||||
credits: { escape: 2, escapeWithTreasure: 3 }
|
||||
});
|
||||
});
|
||||
|
||||
test('publishes exact effective outcomes after cap/min policy', () => {
|
||||
gmm.payoutsEnabled = true;
|
||||
gmm.directPayoutEnabled = true;
|
||||
gmm.creditsPayoutEnabled = true;
|
||||
gmm.singleGamePrice = 20000;
|
||||
gmm.creditsPayoutBaseValue = 1000;
|
||||
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
||||
gmm.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
||||
gmm.configSnapshot.payouts.rules.direct.minPayout = 1n;
|
||||
gmm.configSnapshot.payouts.rules.direct.maxPayout = 50000n;
|
||||
gmm.configSnapshot.payouts.rules.credits.minPayout = 3000n;
|
||||
gmm.configSnapshot.payouts.rules.credits.maxPayout = 50000n;
|
||||
|
||||
const outcomes = gmm.getGameModeInfo().payoutOutcomes;
|
||||
expect(outcomes.direct.escape).toEqual(expect.objectContaining({
|
||||
payable: true,
|
||||
amountAtomic: '40000',
|
||||
rawAmountAtomic: '40000',
|
||||
capApplied: false
|
||||
}));
|
||||
expect(outcomes.direct.escapeWithTreasure).toEqual(expect.objectContaining({
|
||||
payable: true,
|
||||
amountAtomic: '50000',
|
||||
rawAmountAtomic: '60000',
|
||||
capApplied: true
|
||||
}));
|
||||
expect(outcomes.credits.escape).toEqual(expect.objectContaining({
|
||||
payable: false,
|
||||
amountAtomic: '0',
|
||||
rawAmountAtomic: '2000',
|
||||
suppressedReason: 'below_minimum'
|
||||
}));
|
||||
|
||||
const snapshot = gmm._computePayoutSnapshot('PAID_SINGLE');
|
||||
expect(snapshot.treasureAmount).toBe('50000');
|
||||
expect(snapshot.terms.version).toBe(2);
|
||||
});
|
||||
|
||||
test('calculates direct mode payout correctly', () => {
|
||||
gmm.singleGamePrice = 100000000000; // 1 WOW
|
||||
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
||||
@@ -189,10 +387,14 @@ describe('GameModeManager', () => {
|
||||
});
|
||||
|
||||
// Step 2: withTransaction — client.query calls
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
|
||||
.mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15
|
||||
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
||||
mockClient.query.mockImplementation(async (sql) => {
|
||||
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
|
||||
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
|
||||
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
|
||||
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
|
||||
expect(result.success).toBe(true);
|
||||
@@ -204,12 +406,15 @@ describe('GameModeManager', () => {
|
||||
rows: [{ user_id: 1, description: '', status: 'pending', payment_type: 'credits_package' }]
|
||||
});
|
||||
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }] }) // UPDATE users
|
||||
.mockResolvedValueOnce({ rows: [] }) // INSERT credit_transactions
|
||||
.mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement
|
||||
.mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants
|
||||
mockClient.query.mockImplementation(async (sql) => {
|
||||
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
|
||||
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 15, total_credits_purchased: 15, premium_level: 'supporter' }], rowCount: 1 };
|
||||
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
|
||||
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
|
||||
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
|
||||
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, {
|
||||
id: 'bundle',
|
||||
@@ -243,11 +448,15 @@ describe('GameModeManager', () => {
|
||||
}]
|
||||
});
|
||||
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 456 }] }) // UPDATE payments
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }] }) // UPDATE users
|
||||
.mockResolvedValueOnce({ rows: [] }) // INSERT pack entitlement
|
||||
.mockResolvedValueOnce({ rows: [{ pack_id: 'kenney-3d-characters' }] }); // SELECT active grants
|
||||
mockClient.query.mockImplementation(async (sql) => {
|
||||
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 456 }], rowCount: 1 };
|
||||
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/INSERT INTO user_pack_entitlements/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }], rowCount: 1 };
|
||||
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 456 }], rowCount: 1 };
|
||||
if (/SELECT pack_id/i.test(sql)) return { rows: [{ pack_id: 'kenney-3d-characters' }] };
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
|
||||
const result = await gmm.processProductPaymentConfirmation('socket1', 456, null, 1000);
|
||||
|
||||
@@ -257,6 +466,105 @@ describe('GameModeManager', () => {
|
||||
expect(mockClient.query.mock.calls.some(([sql]) => /INSERT INTO credit_transactions/i.test(sql))).toBe(false);
|
||||
});
|
||||
|
||||
test('creates an exact funded lot from the durable confirmed-payment grant snapshot', async () => {
|
||||
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
|
||||
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
||||
try {
|
||||
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
||||
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
user_id: 1,
|
||||
description: 'Two race entries',
|
||||
status: 'pending',
|
||||
payment_type: 'cosmetic_pack',
|
||||
product_id: 'race_2',
|
||||
product_grants: {
|
||||
credits: 0,
|
||||
raceEntries: 2,
|
||||
raceEntryValueAtomic: '10000',
|
||||
packs: [],
|
||||
premiumLevel: null
|
||||
},
|
||||
received_amount: '20000',
|
||||
expected_amount: '20000'
|
||||
}]
|
||||
});
|
||||
mockClient.query.mockImplementation(async (sql) => {
|
||||
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 700 }], rowCount: 1 };
|
||||
if (/SELECT id, premium_level/i.test(sql)) {
|
||||
return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
||||
}
|
||||
if (/SET credits = credits/i.test(sql)) {
|
||||
return {
|
||||
rows: [{ id: 1, credits: 0, total_credits_purchased: 0, premium_level: 'free' }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (/SET race_entries = race_entries \+/i.test(sql)) {
|
||||
return { rows: [{ race_entries: 2 }], rowCount: 1 };
|
||||
}
|
||||
if (/INSERT INTO race_entry_lots/i.test(sql)) {
|
||||
return { rows: [{ id: 88 }], rowCount: 1 };
|
||||
}
|
||||
if (/SELECT pack_id/i.test(sql)) return { rows: [], rowCount: 0 };
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
|
||||
const result = await gmm.processProductPaymentConfirmation('new-socket', 700, {
|
||||
id: 'changed-live-catalog',
|
||||
grants: { race_entries: 99, race_entry_value_atomic: '1' }
|
||||
}, '20000');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const lot = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_lots/i.test(sql));
|
||||
expect(lot[1]).toEqual([1, 700, '10000', 2, 'race_2']);
|
||||
const ticketLedger = mockClient.query.mock.calls.find(([sql]) => /INSERT INTO race_entry_transactions/i.test(sql));
|
||||
expect(ticketLedger[1][3]).toBe(700);
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('does not confirm an underfunded race-ticket invoice', async () => {
|
||||
const keys = ['MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED', 'MATCH_ENTRY_FEE_ATOMIC'];
|
||||
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
||||
try {
|
||||
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
||||
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
user_id: 1,
|
||||
status: 'pending',
|
||||
payment_type: 'cosmetic_pack',
|
||||
product_id: 'race_2',
|
||||
product_grants: {
|
||||
credits: 0,
|
||||
raceEntries: 2,
|
||||
raceEntryValueAtomic: '10000'
|
||||
},
|
||||
received_amount: '19999',
|
||||
expected_amount: '20000'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await gmm.processProductPaymentConfirmation('new-socket', 701, null, '19999');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(mockDb.withTransaction).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to default credits when package info missing and no description', async () => {
|
||||
// Step 1: db.query — SELECT payment lookup with no credits in description
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
@@ -264,10 +572,14 @@ describe('GameModeManager', () => {
|
||||
});
|
||||
|
||||
// Step 2: withTransaction — client.query calls
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
|
||||
.mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users
|
||||
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
||||
mockClient.query.mockImplementation(async (sql) => {
|
||||
if (/UPDATE payments/i.test(sql)) return { rows: [{ id: 123 }], rowCount: 1 };
|
||||
if (/SELECT id, premium_level/i.test(sql)) return { rows: [{ id: 1, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/UPDATE users/i.test(sql)) return { rows: [{ id: 1, credits: 10, total_credits_purchased: 10, premium_level: 'free' }], rowCount: 1 };
|
||||
if (/INSERT INTO payment_entitlement_grants/i.test(sql)) return { rows: [{ payment_id: 123 }], rowCount: 1 };
|
||||
if (/SELECT pack_id/i.test(sql)) return { rows: [] };
|
||||
return { rows: [], rowCount: 1 };
|
||||
});
|
||||
|
||||
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
|
||||
expect(result.success).toBe(true);
|
||||
@@ -309,6 +621,24 @@ describe('GameModeManager', () => {
|
||||
expect(result.effectiveMode).toBe('PAID_SINGLE');
|
||||
});
|
||||
|
||||
test('when direct is preferred, a confirmed direct entry is not replaced by an existing credit', async () => {
|
||||
gmm.paymentsEnabled = true;
|
||||
gmm.directModeEnabled = true;
|
||||
gmm.creditsModeEnabled = true;
|
||||
gmm.preferCreditsFirst = false;
|
||||
|
||||
mockDb.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 999 }] });
|
||||
|
||||
const result = await gmm.canUserStartGame('socket1');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.paymentId).toBe(999);
|
||||
expect(result.effectiveMode).toBe('PAID_SINGLE');
|
||||
});
|
||||
|
||||
test('denies game when no credits and no payment in mixed mode', async () => {
|
||||
gmm.paymentsEnabled = true;
|
||||
gmm.directModeEnabled = true;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const Game = require('../src/game/game');
|
||||
const GameManager = require('../src/game/gameManager');
|
||||
|
||||
describe('paid game persistence gate', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
test('a failed durable INSERT aborts before the game becomes active', async () => {
|
||||
const game = { id: 'durable-seed', gameProof: {} };
|
||||
jest.spyOn(Game, 'createStandardGame').mockReturnValue(game);
|
||||
const user = { id: 'socket-1', joinGame: jest.fn() };
|
||||
const activeGames = new Map();
|
||||
const dbError = new Error('database unavailable');
|
||||
const manager = new GameManager({
|
||||
activeGames,
|
||||
io: {},
|
||||
broadcastManager: {},
|
||||
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 },
|
||||
gameModeManager: {
|
||||
paymentsEnabled: true,
|
||||
gameMode: 'PAID_SINGLE',
|
||||
db: { query: jest.fn().mockRejectedValue(dbError) }
|
||||
}
|
||||
});
|
||||
|
||||
await expect(manager.createGameForUser(user)).rejects.toThrow('database unavailable');
|
||||
|
||||
expect(user.joinGame).not.toHaveBeenCalled();
|
||||
expect(activeGames.has(user.id)).toBe(false);
|
||||
expect(game.dbId).toBeNull();
|
||||
});
|
||||
|
||||
test('a paid game also rejects an INSERT that returns no durable id', async () => {
|
||||
const game = { id: 'missing-id', gameProof: {} };
|
||||
jest.spyOn(Game, 'createStandardGame').mockReturnValue(game);
|
||||
const user = { id: 'socket-2', joinGame: jest.fn() };
|
||||
const manager = new GameManager({
|
||||
activeGames: new Map(),
|
||||
io: {},
|
||||
broadcastManager: {},
|
||||
debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 100 },
|
||||
gameModeManager: {
|
||||
paymentsEnabled: true,
|
||||
gameMode: 'PAID_SINGLE',
|
||||
db: { query: jest.fn().mockResolvedValue({ rows: [] }) }
|
||||
}
|
||||
});
|
||||
|
||||
await expect(manager.createGameForUser(user)).rejects.toThrow('Durable game record is required');
|
||||
expect(user.joinGame).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
const { buildPublicHealth } = require('../src/services/healthService');
|
||||
|
||||
describe('public health payload', () => {
|
||||
test('is ready only when required production dependencies are up', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
paymentsEnabled: true,
|
||||
walletHealthy: true,
|
||||
payoutsEnabled: false,
|
||||
now: 1,
|
||||
uptime: 10
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
status: 'ok',
|
||||
ready: true,
|
||||
checks: { database: 'up', chain: 'up', wallet: 'up' },
|
||||
money: { paymentsEnabled: true, payoutsEnabled: false }
|
||||
});
|
||||
});
|
||||
|
||||
test('free-only instances do not require a wallet', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
paymentsEnabled: false,
|
||||
walletHealthy: false
|
||||
});
|
||||
|
||||
expect(health.ready).toBe(true);
|
||||
expect(health.checks.wallet).toBe('not_required');
|
||||
});
|
||||
|
||||
test('payout-only settlement requires a wallet without claiming intake is enabled', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
paymentsEnabled: false,
|
||||
payoutsEnabled: true,
|
||||
walletHealthy: false
|
||||
});
|
||||
|
||||
expect(health.ready).toBe(false);
|
||||
expect(health.checks.wallet).toBe('down');
|
||||
expect(health.money).toEqual({ paymentsEnabled: false, payoutsEnabled: true });
|
||||
expect(health.paymentsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('reports degraded readiness when the daemon or required wallet is down', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 0,
|
||||
paymentsEnabled: true,
|
||||
walletHealthy: false
|
||||
});
|
||||
|
||||
expect(health.status).toBe('degraded');
|
||||
expect(health.ready).toBe(false);
|
||||
expect(health.checks).toMatchObject({ chain: 'down', wallet: 'down' });
|
||||
});
|
||||
|
||||
test('keeps readiness closed while startup financial recovery is pending', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
paymentsEnabled: true,
|
||||
walletHealthy: true,
|
||||
financialRecoveryReady: false
|
||||
});
|
||||
|
||||
expect(health.ready).toBe(false);
|
||||
expect(health.checks.financialRecovery).toBe('pending');
|
||||
});
|
||||
|
||||
test('does not treat a stale cached block height as a healthy daemon', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
chainHealthy: false,
|
||||
paymentsEnabled: false
|
||||
});
|
||||
|
||||
expect(health.ready).toBe(false);
|
||||
expect(health.checks.chain).toBe('down');
|
||||
});
|
||||
|
||||
test('never includes sensitive operational detail', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 1,
|
||||
paymentsEnabled: true,
|
||||
walletHealthy: true
|
||||
});
|
||||
|
||||
expect(JSON.stringify(health)).not.toMatch(/balance|endpoint|password|memory|rateLimiter/i);
|
||||
});
|
||||
|
||||
test('requires verified daemon and wallet identities for production money mode', () => {
|
||||
const identity = {
|
||||
verified: true,
|
||||
expected: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' },
|
||||
actual: { cryptoType: 'XMR', network: 'stagenet', genesisHash: 'secret-internal' },
|
||||
reason: 'internal-reason',
|
||||
endpoint: 'http://user:password@wallet.test'
|
||||
};
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
chainHealthy: true,
|
||||
paymentsEnabled: true,
|
||||
walletHealthy: true,
|
||||
identityRequired: true,
|
||||
daemonIdentity: identity,
|
||||
walletIdentity: identity
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
ready: true,
|
||||
checks: { daemonIdentity: 'verified', walletIdentity: 'verified' },
|
||||
chain: { network: 'stagenet' },
|
||||
identities: {
|
||||
daemon: { required: true, verified: true },
|
||||
wallet: { required: true, verified: true }
|
||||
}
|
||||
});
|
||||
expect(JSON.stringify(health)).not.toMatch(/secret-internal|internal-reason|endpoint|password/);
|
||||
});
|
||||
|
||||
test('fails readiness on a daemon identity mismatch', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
chainHealthy: true,
|
||||
paymentsEnabled: false,
|
||||
identityRequired: true,
|
||||
daemonIdentity: { verified: false }
|
||||
});
|
||||
|
||||
expect(health.ready).toBe(false);
|
||||
expect(health.checks).toMatchObject({ chain: 'down', daemonIdentity: 'unverified' });
|
||||
});
|
||||
|
||||
test('fails payout-only readiness on a wallet identity mismatch', () => {
|
||||
const health = buildPublicHealth({
|
||||
databaseReady: true,
|
||||
blockHeight: 123,
|
||||
chainHealthy: true,
|
||||
paymentsEnabled: false,
|
||||
payoutsEnabled: true,
|
||||
walletHealthy: true,
|
||||
identityRequired: true,
|
||||
daemonIdentity: { verified: true },
|
||||
walletIdentity: { verified: false }
|
||||
});
|
||||
|
||||
expect(health.ready).toBe(false);
|
||||
expect(health.checks).toMatchObject({ wallet: 'down', walletIdentity: 'unverified' });
|
||||
});
|
||||
});
|
||||
@@ -53,3 +53,18 @@ describe('handleRegisterClient validation', () => {
|
||||
expect(reg.data.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session resume credential transport', () => {
|
||||
let ch;
|
||||
afterEach(() => ch && ch.shutdown && ch.shutdown());
|
||||
|
||||
test('accepts auth payload token and ignores URL query token', () => {
|
||||
ch = buildHandler();
|
||||
expect(ch._getResumeToken({
|
||||
handshake: { auth: { resumeToken: 'auth-secret' }, query: { resumeToken: 'url-secret' } }
|
||||
})).toBe('auth-secret');
|
||||
expect(ch._getResumeToken({
|
||||
handshake: { auth: {}, query: { resumeToken: 'url-secret' } }
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
const LatePaymentReconciler = require('../src/services/latePaymentReconciler');
|
||||
|
||||
describe('late incoming payment reconciliation', () => {
|
||||
test('bounded scanner sends an expired native receipt only to manual review', async () => {
|
||||
const row = {
|
||||
id: 7,
|
||||
user_id: 3,
|
||||
status: 'expired',
|
||||
subaddress: 'late-address',
|
||||
address_index: 11,
|
||||
expected_amount: '100',
|
||||
provider_id: 'native-monero',
|
||||
provider_invoice_id: 'late-address'
|
||||
};
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) };
|
||||
const receipt = {
|
||||
evidenceType: 'chain_output', providerId: 'native-monero',
|
||||
txHash: 'a'.repeat(64), outputId: 'b'.repeat(64),
|
||||
evidenceId: `${'a'.repeat(64)}:${'b'.repeat(64)}`,
|
||||
addressIndex: 11, amount: '100', confirmed: true
|
||||
};
|
||||
const walletService = {
|
||||
accountIndex: 0,
|
||||
addressToUser: new Map(),
|
||||
addressToSocket: new Map(),
|
||||
checkPaymentStatus: jest.fn(async address => {
|
||||
expect(address).toBe('late-address');
|
||||
expect(walletService.addressToUser.get(address)).toEqual(expect.objectContaining({
|
||||
amount: 100,
|
||||
addressIndex: 11
|
||||
}));
|
||||
return { confirmed: true, complete: true, amount: 100, receipts: [receipt] };
|
||||
}),
|
||||
stopPaymentMonitoring: jest.fn()
|
||||
};
|
||||
const gameModeManager = {
|
||||
paymentProviders: { get: jest.fn(() => null) },
|
||||
reconcileLatePaymentForReview: jest.fn().mockResolvedValue({ checked: true, needsReview: true })
|
||||
};
|
||||
const service = new LatePaymentReconciler({
|
||||
db, gameModeManager, walletService, lookbackHours: 24, batchSize: 20,
|
||||
logger: { warn: jest.fn(), error: jest.fn() }
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result).toEqual({ skipped: false, checked: 1, reviews: 1, errors: 0 });
|
||||
expect(db.query.mock.calls[0][1]).toEqual([24, 20]);
|
||||
expect(gameModeManager.reconcileLatePaymentForReview).toHaveBeenCalledWith(7, [receipt]);
|
||||
expect(walletService.addressToUser.has('late-address')).toBe(false);
|
||||
expect(walletService.stopPaymentMonitoring).toHaveBeenCalledWith('late-address');
|
||||
});
|
||||
|
||||
test('durable late receipt stays expired and creates review without any entitlement write', async () => {
|
||||
const calls = [];
|
||||
const client = {
|
||||
query: jest.fn(async (sql, params = []) => {
|
||||
calls.push({ sql, params });
|
||||
if (/SELECT id, status, expected_amount/i.test(sql)) {
|
||||
return { rows: [{
|
||||
id: 8, status: 'expired', expected_amount: '100',
|
||||
provider_id: 'native-monero', address_index: 12
|
||||
}] };
|
||||
}
|
||||
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
|
||||
return { rows: [{ total: '100', primary_tx_hash: 'c'.repeat(64), receipt_count: '1' }] };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
})
|
||||
};
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
manager.db = { withTransaction: async callback => callback(client) };
|
||||
const outputId = 'd'.repeat(64);
|
||||
const receipt = {
|
||||
evidenceType: 'chain_output', providerId: 'native-monero',
|
||||
txHash: 'c'.repeat(64), outputId,
|
||||
evidenceId: `${'c'.repeat(64)}:${outputId}`,
|
||||
addressIndex: 12, amount: '100', confirmed: true
|
||||
};
|
||||
|
||||
const result = await manager.reconcileLatePaymentForReview(8, [receipt]);
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
checked: true,
|
||||
needsReview: true,
|
||||
coverageComplete: true
|
||||
}));
|
||||
expect(calls.some(call => /INSERT INTO payment_receipts/i.test(call.sql))).toBe(true);
|
||||
expect(calls.some(call => /INSERT INTO payment_late_reviews/i.test(call.sql))).toBe(true);
|
||||
expect(calls.some(call => /SET status = CASE WHEN status = 'pending' THEN 'expired'/i.test(call.sql))).toBe(true);
|
||||
expect(calls.some(call => /UPDATE users|payment_entitlement_grants|credit_transactions/i.test(call.sql))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
||||
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('../src/views/legalPages');
|
||||
|
||||
function disclosure(overrides = {}) {
|
||||
const manager = {
|
||||
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
|
||||
gameName: '<script>bad</script>', paymentsEnabled: true, freePlayEnabled: true,
|
||||
directModeEnabled: true, creditsModeEnabled: true, payoutsEnabled: false,
|
||||
isPayoutEnabledForMode: () => false
|
||||
};
|
||||
return buildCommerceDisclosure(manager, {
|
||||
NODE_ENV: 'production', LEGAL_POLICY_VERSION: 'v1', TERMS_EFFECTIVE_DATE: '2026-07-21',
|
||||
MINIMUM_AGE: '18', PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
|
||||
OPERATOR_NAME: '<img src=x onerror=alert(1)>',
|
||||
OPERATOR_CONTACT_URL: 'javascript:alert(1)',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
describe('production disclosure pages', () => {
|
||||
test.each([
|
||||
['terms', renderTerms, 'Paid prestige mode'],
|
||||
['privacy', renderPrivacy, 'random browser session token'],
|
||||
['responsible play', renderResponsiblePlay, 'automated self-exclusion system']
|
||||
])('%s page is mode-aware, complete, and HTML-escapes operator data', (_name, render, expected) => {
|
||||
const html = render(disclosure());
|
||||
expect(html).toContain(expected);
|
||||
expect(html).toContain('/responsible-play');
|
||||
expect(html).toContain('<script>bad</script>');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).not.toContain('<script>bad</script>');
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
});
|
||||
|
||||
test('terms state the exact solo and PvP leaderboard mappings', () => {
|
||||
const html = renderTerms(disclosure());
|
||||
expect(html).toContain('FREE runs use the Pleb board');
|
||||
expect(html).toContain('PAID_SINGLE and PAID_CREDITS runs use the Hall of Champions');
|
||||
expect(html).toContain('credits_prestige matches use the Prestige board');
|
||||
expect(html).toContain('crypto_race matches use the Hall of Champions');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('match client durable finish and fairness messaging', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8');
|
||||
|
||||
test('does not treat a settlement-pending event as a final result', () => {
|
||||
const pending = source.match(/socket\.on\('match_settlement_pending',[\s\S]*?\n \}\);/);
|
||||
expect(pending).toBeTruthy();
|
||||
expect(pending[0]).toContain('not final yet');
|
||||
expect(pending[0]).not.toContain('inRace = false');
|
||||
});
|
||||
|
||||
test('checks the revealed seed against the published SHA-256 commitment', () => {
|
||||
expect(source).toContain("subtle.digest('SHA-256'");
|
||||
expect(source).toContain("actual === String(fairness.seedHash).toLowerCase()");
|
||||
expect(source).toContain('seed proof ✓');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
const MatchScheduler = require('../src/network/matchScheduler');
|
||||
const MatchManager = require('../src/network/matchManager');
|
||||
|
||||
describe('match queue crash/restart invariants', () => {
|
||||
const priorMatchEnabled = process.env.MATCH_ENABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (priorMatchEnabled === undefined) delete process.env.MATCH_ENABLED;
|
||||
else process.env.MATCH_ENABLED = priorMatchEnabled;
|
||||
});
|
||||
|
||||
test('persisting a match links every exact queue row before inserting linked entrants', async () => {
|
||||
const captured = [];
|
||||
const entries = [
|
||||
{ userId: 7, socketId: 's7', queueEntryId: 101 },
|
||||
{ userId: 8, socketId: 's8', queueEntryId: 102 }
|
||||
];
|
||||
const db = {
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('UPDATE match_queue_entries') && sql.includes('SET match_id')) {
|
||||
return {
|
||||
rows: entries.map(entry => ({
|
||||
id: entry.queueEntryId,
|
||||
user_id: entry.userId
|
||||
})),
|
||||
rowCount: entries.length
|
||||
};
|
||||
}
|
||||
return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const room = new MatchRoom({
|
||||
economy: 'crypto_race',
|
||||
entrants: {
|
||||
s7: { userId: 7, socketId: 's7' },
|
||||
s8: { userId: 8, socketId: 's8' }
|
||||
},
|
||||
seed: 'a'.repeat(64)
|
||||
});
|
||||
const scheduler = new MatchScheduler({ matchQueue: {}, matchManager: { db } });
|
||||
|
||||
await scheduler._persistMatch(room, entries, 'crypto_race');
|
||||
|
||||
const link = captured.find(call => call.sql.includes('SET match_id'));
|
||||
expect(link.params).toEqual([room.id, [101, 102], 'crypto_race']);
|
||||
expect(link.sql).toContain("status = 'matched'");
|
||||
expect(link.sql).toContain('match_id IS NULL');
|
||||
const entrantInserts = captured.filter(call => call.sql.includes('INSERT INTO match_entrants'));
|
||||
expect(entrantInserts.map(call => call.params)).toEqual([
|
||||
[room.id, 7, 's7', 101],
|
||||
[room.id, 8, 's8', 102]
|
||||
]);
|
||||
scheduler.shutdown();
|
||||
});
|
||||
|
||||
test('a finished match with linked consumed tickets is never treated as stale escrow', async () => {
|
||||
let staleSql = '';
|
||||
const db = {
|
||||
query: jest.fn(async (sql) => {
|
||||
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
|
||||
if (sql.includes('FROM match_queue_entries') && sql.includes("status = 'matched'")) {
|
||||
staleSql = sql;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}),
|
||||
withTransaction: jest.fn()
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
await queue.initialize();
|
||||
await queue.shutdown();
|
||||
|
||||
expect(staleSql).toContain("status = 'matched' AND match_id IS NULL");
|
||||
expect(staleSql).not.toContain("status = 'consumed'");
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a crash after drain but before match persistence refunds the unlinked hold exactly once', async () => {
|
||||
const state = {
|
||||
queueStatus: 'matched',
|
||||
matchId: null,
|
||||
lotRemaining: 0,
|
||||
userTickets: 0,
|
||||
ledgerRows: 0
|
||||
};
|
||||
const db = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM match_queue_entries')
|
||||
&& state.queueStatus === 'matched'
|
||||
&& state.matchId === null) {
|
||||
return {
|
||||
rows: [{
|
||||
id: 41,
|
||||
user_id: 9,
|
||||
economy: 'crypto_race',
|
||||
escrow_amount: '1',
|
||||
race_entry_lot_id: 71
|
||||
}],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql) {
|
||||
if (sql.includes('UPDATE match_queue_entries')) {
|
||||
expect(sql).toContain("status = 'matched' AND match_id IS NULL");
|
||||
if (state.queueStatus !== 'matched' || state.matchId !== null) {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
state.queueStatus = 'cancelled';
|
||||
return {
|
||||
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (sql.includes('UPDATE race_entry_lots')) {
|
||||
expect(sql).toContain('refunded_at IS NULL');
|
||||
expect(sql).toContain('remaining_entries + $1 <= original_entries');
|
||||
state.lotRemaining += 1;
|
||||
return { rows: [{ id: 71 }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE users')) {
|
||||
state.userTickets += 1;
|
||||
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('INSERT INTO race_entry_transactions')) {
|
||||
state.ledgerRows += 1;
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
await queue._recoverStaleQueueEntries();
|
||||
await queue._recoverStaleQueueEntries();
|
||||
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
queueStatus: 'cancelled',
|
||||
lotRemaining: 1,
|
||||
userTickets: 1,
|
||||
ledgerRows: 1
|
||||
}));
|
||||
});
|
||||
|
||||
test('an abandoned starting match restores a consumed funded ticket only once', async () => {
|
||||
const state = {
|
||||
matchStatus: 'starting',
|
||||
queueStatus: 'consumed',
|
||||
lotRemaining: 0,
|
||||
userTickets: 0,
|
||||
entrantRefunded: false,
|
||||
ledgerRows: 0
|
||||
};
|
||||
const db = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM matches') && state.matchStatus === 'starting') {
|
||||
return { rows: [{ id: 'match-9', economy: 'crypto_race' }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT id FROM payouts')) return { rows: [], rowCount: 0 };
|
||||
if (sql.includes('UPDATE matches SET status')) {
|
||||
if (state.matchStatus !== 'starting') return { rows: [], rowCount: 0 };
|
||||
state.matchStatus = 'cancelled';
|
||||
return { rows: [{ id: 'match-9' }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('FROM match_entrants') && sql.includes('queue_entry_id')) {
|
||||
return {
|
||||
rows: [{ id: 91, user_id: 9, queue_entry_id: 41 }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (sql.includes('UPDATE match_queue_entries') && sql.includes('match_id = $2')) {
|
||||
if (state.queueStatus !== 'consumed') return { rows: [], rowCount: 0 };
|
||||
state.queueStatus = 'cancelled';
|
||||
return {
|
||||
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (sql.includes('UPDATE race_entry_lots')) {
|
||||
state.lotRemaining += 1;
|
||||
return { rows: [{ id: 71 }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE users')) {
|
||||
state.userTickets += 1;
|
||||
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('INSERT INTO race_entry_transactions')) {
|
||||
state.ledgerRows += 1;
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE match_entrants')) {
|
||||
state.entrantRefunded = true;
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
await queue._recoverAbandonedMatches();
|
||||
await queue._recoverAbandonedMatches();
|
||||
|
||||
expect(state).toEqual({
|
||||
matchStatus: 'cancelled',
|
||||
queueStatus: 'cancelled',
|
||||
lotRemaining: 1,
|
||||
userTickets: 1,
|
||||
entrantRefunded: true,
|
||||
ledgerRows: 1
|
||||
});
|
||||
});
|
||||
|
||||
test('queue initialization stays closed when the abandoned-match scan fails', async () => {
|
||||
const db = {
|
||||
query: jest.fn(async sql => {
|
||||
if (sql.includes('FROM matches')) throw new Error('scan unavailable');
|
||||
return { rows: [], rowCount: 0 };
|
||||
}),
|
||||
withTransaction: jest.fn()
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
await expect(queue.initialize()).rejects.toMatchObject({
|
||||
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
||||
recovery: {
|
||||
scope: 'abandoned_matches',
|
||||
scanFailed: true,
|
||||
scanned: 0,
|
||||
resolved: 0,
|
||||
unresolved: []
|
||||
}
|
||||
});
|
||||
expect(queue.initialized).toBe(false);
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('queue initialization rejects after processing peers when one match remains unresolved', async () => {
|
||||
let transactionCount = 0;
|
||||
const db = {
|
||||
query: jest.fn(async sql => {
|
||||
if (sql.includes('FROM matches')) {
|
||||
return {
|
||||
rows: [
|
||||
{ id: 'unresolved-match', economy: 'crypto_race' },
|
||||
{ id: 'resolved-match', economy: 'crypto_race' }
|
||||
],
|
||||
rowCount: 2
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}),
|
||||
withTransaction: jest.fn(async fn => {
|
||||
transactionCount += 1;
|
||||
if (transactionCount === 1) throw new Error('refund transaction failed');
|
||||
return fn({
|
||||
query: jest.fn(async sql => {
|
||||
if (sql.includes('SELECT id FROM payouts')) {
|
||||
return { rows: [{ id: 91 }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
})
|
||||
});
|
||||
})
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
await expect(queue.initialize()).rejects.toMatchObject({
|
||||
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
||||
recovery: {
|
||||
scope: 'abandoned_matches',
|
||||
scanFailed: false,
|
||||
scanned: 2,
|
||||
resolved: 1,
|
||||
unresolved: [{ type: 'match', id: 'unresolved-match' }]
|
||||
}
|
||||
});
|
||||
expect(queue.initialized).toBe(false);
|
||||
expect(db.withTransaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('paid queue admission is closed before durable recovery completes', async () => {
|
||||
const queue = new MatchQueue({ db: {} });
|
||||
|
||||
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
|
||||
success: false,
|
||||
reason: 'financial_recovery_pending'
|
||||
});
|
||||
});
|
||||
|
||||
test('a later liability-recovery failure closes paid joins, drains, and freezes dynamically', async () => {
|
||||
const queue = new MatchQueue({
|
||||
db: {},
|
||||
isFinancialRecoveryReady: () => false
|
||||
});
|
||||
queue.initialized = true;
|
||||
queue._queues.credits_prestige.push(
|
||||
{ queueEntryId: 1, userId: 1, socketId: 's1' },
|
||||
{ queueEntryId: 2, userId: 2, socketId: 's2' }
|
||||
);
|
||||
|
||||
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
|
||||
success: false,
|
||||
reason: 'financial_recovery_pending'
|
||||
});
|
||||
await expect(queue.drain('credits_prestige', 2, 2)).resolves.toBeNull();
|
||||
await expect(queue.freezePaidMatch({
|
||||
economy: 'credits_prestige',
|
||||
maxPlayers: 2,
|
||||
minPlayers: 2,
|
||||
freezeBlockHeight: 10,
|
||||
targetBlockHeight: 11,
|
||||
rulesetId: 'race'
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('MATCH_ENABLED=false still refunds durable queued escrow during boot', async () => {
|
||||
process.env.MATCH_ENABLED = 'false';
|
||||
let userTickets = 0;
|
||||
let lotTickets = 0;
|
||||
let ledgerRows = 0;
|
||||
const db = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
|
||||
if (sql.includes("status = 'matched'") && sql.includes('INTERVAL')) {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (sql.includes("WHERE status = 'queued'")) {
|
||||
return {
|
||||
rows: [{
|
||||
id: 55,
|
||||
user_id: 12,
|
||||
economy: 'crypto_race',
|
||||
socket_id: 'old-socket',
|
||||
session_token: 'session',
|
||||
created_at: new Date().toISOString()
|
||||
}],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql) {
|
||||
if (sql.includes('DELETE FROM match_queue_entries')) {
|
||||
return {
|
||||
rows: [{ id: 55, escrow_amount: '1', race_entry_lot_id: 77 }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (sql.includes('UPDATE users')) {
|
||||
userTickets += 1;
|
||||
return { rows: [{ race_entries: userTickets }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE race_entry_lots')) {
|
||||
lotTickets += 1;
|
||||
return { rows: [{ id: 77 }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('INSERT INTO race_entry_transactions')) {
|
||||
ledgerRows += 1;
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
await queue.initialize();
|
||||
await queue.shutdown();
|
||||
|
||||
expect(queue.isEnabled()).toBe(false);
|
||||
expect(queue.length('crypto_race')).toBe(0);
|
||||
expect({ userTickets, lotTickets, ledgerRows }).toEqual({
|
||||
userTickets: 1,
|
||||
lotTickets: 1,
|
||||
ledgerRows: 1
|
||||
});
|
||||
});
|
||||
|
||||
test('MATCH_ENABLED=false still reconciles an already-finished accepted liability', async () => {
|
||||
process.env.MATCH_ENABLED = 'false';
|
||||
const manager = new MatchManager({ db: {}, io: null });
|
||||
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
|
||||
scanned: 1,
|
||||
created: 1,
|
||||
failed: 0
|
||||
});
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.enabled).toBe(false);
|
||||
expect(manager.matchPayoutService.reconcileFinishedLiabilities).toHaveBeenCalledTimes(1);
|
||||
manager.shutdown();
|
||||
});
|
||||
|
||||
test('finished accepted-liability failures keep match recovery and startup closed', async () => {
|
||||
const manager = new MatchManager({ db: {}, io: null });
|
||||
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
scanned: 1,
|
||||
created: 0,
|
||||
failed: 1,
|
||||
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
|
||||
});
|
||||
|
||||
await expect(manager.initialize()).rejects.toMatchObject({
|
||||
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
||||
recovery: {
|
||||
scope: 'finished_match_liabilities',
|
||||
scanFailed: false,
|
||||
scanned: 1,
|
||||
resolved: 0,
|
||||
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
|
||||
}
|
||||
});
|
||||
expect(manager.financialRecoveryReady).toBe(false);
|
||||
expect(manager._liabilityReconcileTimer).toBeNull();
|
||||
manager.shutdown();
|
||||
});
|
||||
|
||||
test('finished-liability scan errors have explicit fail-closed startup semantics', async () => {
|
||||
const manager = new MatchManager({ db: {}, io: null });
|
||||
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn()
|
||||
.mockRejectedValue(new Error('liability scan unavailable'));
|
||||
|
||||
await expect(manager.initialize()).rejects.toMatchObject({
|
||||
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
||||
recovery: {
|
||||
scope: 'finished_match_liabilities',
|
||||
scanFailed: true,
|
||||
scanned: 0,
|
||||
resolved: 0,
|
||||
unresolved: []
|
||||
}
|
||||
});
|
||||
expect(manager.financialRecoveryReady).toBe(false);
|
||||
manager.shutdown();
|
||||
});
|
||||
|
||||
test('bounded reconciliation retries a later transient failure and reopens the dynamic gate', async () => {
|
||||
const manager = new MatchManager({ db: {}, io: null });
|
||||
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
|
||||
ok: true, scanned: 0, created: 0, failed: 0, unresolved: []
|
||||
});
|
||||
await manager.initialize();
|
||||
manager.matchPayoutService.reconcileFinishedLiabilities
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
scanned: 1,
|
||||
created: 0,
|
||||
failed: 1,
|
||||
unresolved: [{ type: 'match_liability', id: 'match-retry' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
scanned: 1,
|
||||
created: 1,
|
||||
failed: 0,
|
||||
unresolved: []
|
||||
});
|
||||
|
||||
const failed = await manager._reconcileFinishedLiabilities({ failClosed: false });
|
||||
expect(failed.ok).toBe(false);
|
||||
expect(manager.financialRecoveryReady).toBe(false);
|
||||
|
||||
const retried = await manager._reconcileFinishedLiabilities({ failClosed: false });
|
||||
expect(retried).toMatchObject({ ok: true, scanned: 1, created: 1, failed: 0 });
|
||||
expect(manager.financialRecoveryReady).toBe(true);
|
||||
manager.shutdown();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const MatchLeaderboard = require('../src/network/matchLeaderboard');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
const { WIN } = require('../src/game/rulesets');
|
||||
|
||||
describe('MatchLeaderboard', () => {
|
||||
function makeDb() {
|
||||
@@ -64,4 +65,87 @@ describe('MatchLeaderboard', () => {
|
||||
expect(gamesInserts.length).toBe(2);
|
||||
expect(gamesInserts[0].params[2]).toBe('PAID_CREDITS');
|
||||
});
|
||||
|
||||
test('last-alive win/loss follows winnerId + placement, never the escaped flag', async () => {
|
||||
const db = makeDb();
|
||||
const lb = new MatchLeaderboard({ db });
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
economy: 'free',
|
||||
ruleset: {
|
||||
id: 'test-last-alive', mode: 'pvp',
|
||||
entities: { monster: false, pvpCombat: true },
|
||||
players: { min: 2, max: 2 },
|
||||
winCondition: { type: WIN.LAST_ALIVE }
|
||||
},
|
||||
seed: '0'.repeat(64)
|
||||
});
|
||||
room.start();
|
||||
room._finishPlayer('a', true);
|
||||
room.winCondition.onExit(room, 'a');
|
||||
room.finalize();
|
||||
|
||||
expect(room.playerStates.get('a').escaped).toBe(true);
|
||||
expect(room.winnerId).toBe('b');
|
||||
expect(room.playerStates.get('b').placement).toBe(1);
|
||||
|
||||
await lb.postMatch(room);
|
||||
const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games'));
|
||||
const bySocket = new Map(inserts.map(q => [q.params[1], q.params]));
|
||||
expect(bySocket.get('a').slice(3, 5)).toEqual(['lost', 'match_loss']);
|
||||
expect(bySocket.get('b').slice(3, 5)).toEqual(['won', 'match_winner']);
|
||||
});
|
||||
|
||||
test('score-attack awards the authoritative score winner even when another entrant escaped', async () => {
|
||||
const db = makeDb();
|
||||
const lb = new MatchLeaderboard({ db });
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
economy: 'free',
|
||||
ruleset: {
|
||||
id: 'test-score', mode: 'race', entities: { monster: false },
|
||||
players: { min: 2, max: 2 }, winCondition: { type: WIN.HIGH_SCORE }
|
||||
},
|
||||
seed: '0'.repeat(64)
|
||||
});
|
||||
room.start();
|
||||
room.playerStates.get('a').escaped = true;
|
||||
room.playerStates.get('a').finished = true;
|
||||
room.playerStates.get('a').moves = 200;
|
||||
room.playerStates.get('b').hasTreasure = true;
|
||||
room.playerStates.get('b').moves = 1;
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
|
||||
expect(room.winnerId).toBe('b');
|
||||
await lb.postMatch(room);
|
||||
const inserts = db.captured.filter(q => q.sql.includes('INSERT INTO games'));
|
||||
const bySocket = new Map(inserts.map(q => [q.params[1], q.params]));
|
||||
expect(bySocket.get('a')[3]).toBe('lost');
|
||||
expect(bySocket.get('b')[3]).toBe('won');
|
||||
});
|
||||
|
||||
test('co-op results stay in match history and never enter individual boards', async () => {
|
||||
const emitted = [];
|
||||
const db = makeDb();
|
||||
const lb = new MatchLeaderboard({ db, io: { emit: (e, p) => emitted.push({ e, p }) } });
|
||||
const room = new MatchRoom({
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
economy: 'free',
|
||||
ruleset: {
|
||||
id: 'coop-escape', mode: 'coop',
|
||||
entities: { monster: false }, players: { min: 2, max: 2 },
|
||||
winCondition: { type: WIN.ALL_ESCAPE }
|
||||
},
|
||||
seed: '0'.repeat(64)
|
||||
});
|
||||
room.start();
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
|
||||
expect(room.winnerId).toBeNull();
|
||||
await lb.postMatch(room);
|
||||
expect(db.captured).toHaveLength(0);
|
||||
expect(emitted).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
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 MatchScheduler = require('../src/network/matchScheduler');
|
||||
const {
|
||||
buildPaidEntrantFreeze,
|
||||
deriveFutureBlockMatchSeed
|
||||
} = require('../src/network/matchFairness');
|
||||
|
||||
const MATCH_ENV_KEYS = [
|
||||
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
|
||||
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
|
||||
'MATCH_RULESET_ID', 'MATCH_MAX_PLAYERS', 'PAYOUTS_ENABLED', 'PAYMENTS_ENABLED'
|
||||
];
|
||||
|
||||
describe('match payout liability production invariants', () => {
|
||||
let priorEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
priorEnv = Object.fromEntries(MATCH_ENV_KEYS.map(key => [key, process.env[key]]));
|
||||
Object.assign(process.env, {
|
||||
MATCH_ENABLED: 'true',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
||||
MATCH_PAYOUTS_ENABLED: 'true',
|
||||
MATCH_PAYOUT_MAX: '1000000',
|
||||
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
||||
MATCH_HOUSE_FEE_PERCENT: '5',
|
||||
MATCH_RULESET_ID: 'race',
|
||||
MATCH_MAX_PLAYERS: '4',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
PAYMENTS_ENABLED: 'true'
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(priorEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const gameModeManager = (overrides = {}) => ({
|
||||
payoutsEnabled: true,
|
||||
directModeEnabled: true,
|
||||
creditsModeEnabled: false,
|
||||
_scheduleBatchPayout: jest.fn(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
function cryptoRoom(rulesetId = 'race') {
|
||||
return new MatchRoom({
|
||||
economy: 'crypto_race',
|
||||
rulesetId,
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: '0'.repeat(64)
|
||||
});
|
||||
}
|
||||
|
||||
test('MATCH_PAYOUT_MAX is checked before any ticket commitment transaction', async () => {
|
||||
process.env.MATCH_PAYOUT_MAX = '18000'; // two entries owe 19,000 after the 5% fee
|
||||
const db = { withTransaction: jest.fn() };
|
||||
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
|
||||
|
||||
await expect(service.collectEntryTickets(cryptoRoom(), [
|
||||
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
|
||||
]))
|
||||
.rejects.toMatchObject({ code: 'PAYOUT_CAP_EXCEEDED' });
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('crypto co-op is rejected before commitment because split semantics do not exist', async () => {
|
||||
process.env.MATCH_RULESET_ID = 'coop-escape';
|
||||
const db = { withTransaction: jest.fn() };
|
||||
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
|
||||
|
||||
await expect(service.collectEntryTickets(cryptoRoom('coop-escape'), [
|
||||
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
|
||||
]))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_CRYPTO_RULESET' });
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('scheduler clamps entrants to the selected ruleset player contract', () => {
|
||||
const scheduler = new MatchScheduler({
|
||||
matchQueue: {},
|
||||
matchManager: {},
|
||||
rulesetId: 'race',
|
||||
maxPlayers: 32
|
||||
});
|
||||
|
||||
expect(scheduler.minPlayers).toBe(2);
|
||||
expect(scheduler.maxPlayers).toBe(8);
|
||||
scheduler.shutdown();
|
||||
});
|
||||
|
||||
test('hot-disable refunds queued crypto escrow and removes memory only after commit', async () => {
|
||||
let available = true;
|
||||
const captured = [];
|
||||
const db = {
|
||||
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 };
|
||||
if (sql.includes('UPDATE users')) return { rows: [{ race_entries: 2 }], rowCount: 1 };
|
||||
return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const manager = gameModeManager({
|
||||
_getMatchEconomies: () => available ? { free: true, crypto_race: true } : { free: true }
|
||||
});
|
||||
const queue = new MatchQueue({ db, gameModeManager: manager });
|
||||
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
|
||||
|
||||
available = false;
|
||||
await queue.refundUnavailableQueues();
|
||||
|
||||
expect(queue.length('crypto_race')).toBe(0);
|
||||
expect(captured.some(q => q.sql.includes('race_entries = race_entries + $1'))).toBe(true);
|
||||
expect(captured.some(q => q.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
|
||||
});
|
||||
|
||||
test('failed hot-disable refund remains queued for the next reconciliation attempt', async () => {
|
||||
const db = { async withTransaction() { throw new Error('db down'); } };
|
||||
const manager = gameModeManager({ _getMatchEconomies: () => ({ free: true }) });
|
||||
const queue = new MatchQueue({ db, gameModeManager: manager });
|
||||
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
|
||||
|
||||
await queue.refundUnavailableQueues();
|
||||
|
||||
expect(queue.length('crypto_race')).toBe(1);
|
||||
});
|
||||
|
||||
test('finished liability is reconciled after a transient payout insertion failure', async () => {
|
||||
let insertFails = true;
|
||||
let payoutInserted = false;
|
||||
let successfulInserts = 0;
|
||||
const captured = [];
|
||||
const db = {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT m.id')) {
|
||||
return payoutInserted
|
||||
? { rows: [], rowCount: 0 }
|
||||
: { rows: [{ id: 'match-1' }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
|
||||
return { rows: [{
|
||||
id: 'match-1', economy: 'crypto_race', status: 'finished', winner_user_id: 7,
|
||||
payout_liability_amount_atomic: '19000',
|
||||
payout_liability_cap_atomic: '1000000',
|
||||
payout_liability_accepted_at: new Date().toISOString()
|
||||
}], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('FROM payouts')) {
|
||||
return payoutInserted
|
||||
? { rows: [{ id: 88 }], rowCount: 1 }
|
||||
: { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (sql.includes('FROM users')) return { rows: [{ id: 7, payout_address: 'wallet' }], rowCount: 1 };
|
||||
if (sql.includes('INSERT INTO payouts') && insertFails) throw new Error('insert unavailable');
|
||||
if (sql.includes('INSERT INTO payouts')) {
|
||||
payoutInserted = true;
|
||||
successfulInserts += 1;
|
||||
}
|
||||
return { rows: [{ id: 88 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const manager = gameModeManager();
|
||||
const service = new MatchPayoutService({ db, gameModeManager: manager });
|
||||
|
||||
expect(await service.reconcileFinishedLiabilities()).toEqual({
|
||||
ok: false,
|
||||
scanned: 1,
|
||||
created: 0,
|
||||
failed: 1,
|
||||
unresolved: [{ type: 'match_liability', id: 'match-1' }]
|
||||
});
|
||||
insertFails = false;
|
||||
expect(await service.reconcileFinishedLiabilities()).toEqual({
|
||||
ok: true,
|
||||
scanned: 1,
|
||||
created: 1,
|
||||
failed: 0,
|
||||
unresolved: []
|
||||
});
|
||||
expect(await service.reconcileFinishedLiabilities()).toEqual({
|
||||
ok: true,
|
||||
scanned: 0,
|
||||
created: 0,
|
||||
failed: 0,
|
||||
unresolved: []
|
||||
});
|
||||
expect(successfulInserts).toBe(1);
|
||||
expect(manager._scheduleBatchPayout).toHaveBeenCalledTimes(1);
|
||||
expect(captured.filter(q => q.sql.includes('INSERT INTO payouts'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('manager durably persists completion before attempting liability insertion', async () => {
|
||||
const manager = new MatchManager({ io: null, db: {}, debugManager: null });
|
||||
manager.enabled = true;
|
||||
const order = [];
|
||||
manager._persistFinish = async () => { order.push('persist'); };
|
||||
manager.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
|
||||
manager.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); };
|
||||
const room = cryptoRoom();
|
||||
room.winnerId = 'a';
|
||||
room.status = 'finished';
|
||||
|
||||
await manager._finalize(room);
|
||||
|
||||
expect(order.slice(0, 2)).toEqual(['persist', 'payout']);
|
||||
manager.shutdown();
|
||||
});
|
||||
|
||||
test('a future-block-frozen match with an ambiguous collect acknowledgement continues only when the accepted snapshot exists', async () => {
|
||||
const entries = [
|
||||
{ userId: 1, socketId: 'a', sessionToken: 'ta', queueEntryId: 11 },
|
||||
{ userId: 2, socketId: 'b', sessionToken: 'tb', queueEntryId: 12 }
|
||||
];
|
||||
const matchQueue = {
|
||||
refundEntries: jest.fn()
|
||||
};
|
||||
const payoutService = {
|
||||
collectEntryTickets: jest.fn().mockRejectedValue(new Error('COMMIT acknowledgement lost')),
|
||||
getAcceptedLiability: jest.fn().mockResolvedValue({
|
||||
entry_fee_atomic: '10000',
|
||||
pot_atomic: '20000',
|
||||
house_fee_atomic: '1000',
|
||||
house_fee_percent: '5',
|
||||
payout_liability_amount_atomic: '19000',
|
||||
payout_liability_cap_atomic: '1000000',
|
||||
payout_liability_terms: { version: 2 }
|
||||
})
|
||||
};
|
||||
const matchManager = {
|
||||
db: null,
|
||||
gameModeManager: gameModeManager(),
|
||||
matchPayoutService: payoutService,
|
||||
setEngine: jest.fn(),
|
||||
attach: jest.fn(),
|
||||
onTick: jest.fn(),
|
||||
onFinish: jest.fn()
|
||||
};
|
||||
const scheduler = new MatchScheduler({
|
||||
matchQueue,
|
||||
matchManager,
|
||||
blockCountProvider: async () => 902,
|
||||
blockEntropyProvider: async height => ({
|
||||
block_header: { height, hash: 'ab'.repeat(32) }
|
||||
})
|
||||
});
|
||||
const freeze = buildPaidEntrantFreeze({
|
||||
freezeBlockHeight: 899,
|
||||
targetBlockHeight: 900,
|
||||
economy: 'crypto_race',
|
||||
rulesetId: 'race',
|
||||
queueEntryIds: entries.map(entry => entry.queueEntryId)
|
||||
});
|
||||
const seedProof = deriveFutureBlockMatchSeed({
|
||||
blockHash: 'ab'.repeat(32),
|
||||
blockHeight: 900,
|
||||
freeze
|
||||
});
|
||||
|
||||
await scheduler._startEntries({
|
||||
entries,
|
||||
economy: 'crypto_race',
|
||||
blockHeight: 900,
|
||||
seedProof,
|
||||
frozen: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
freeze,
|
||||
freezeCommitment: freeze.freezeCommitment,
|
||||
entries
|
||||
}
|
||||
});
|
||||
|
||||
expect(payoutService.collectEntryTickets).toHaveBeenCalledTimes(1);
|
||||
expect(payoutService.getAcceptedLiability).toHaveBeenCalledTimes(1);
|
||||
expect(matchQueue.refundEntries).not.toHaveBeenCalled();
|
||||
expect(matchManager.attach).toHaveBeenCalledTimes(1);
|
||||
const room = matchManager.attach.mock.calls[0][0];
|
||||
expect(room.payoutLiabilityAmountAtomic).toBe('19000');
|
||||
scheduler.shutdown();
|
||||
});
|
||||
});
|
||||
+120
-22
@@ -2,33 +2,84 @@ const MatchPayoutService = require('../src/network/matchPayoutService');
|
||||
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
||||
|
||||
describe('MatchPayoutService', () => {
|
||||
function makeDb() {
|
||||
const enabledEnv = (overrides = {}) => ({
|
||||
MATCH_ENABLED: 'true',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
||||
MATCH_PAYOUTS_ENABLED: 'true',
|
||||
MATCH_PAYOUT_MAX: '100000000000000000',
|
||||
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
||||
MATCH_HOUSE_FEE_PERCENT: '5',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
PAYMENTS_ENABLED: 'true',
|
||||
MATCH_RULESET_ID: 'race',
|
||||
MATCH_MAX_PLAYERS: '4',
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'stagenet',
|
||||
...overrides
|
||||
});
|
||||
|
||||
function makeDb({ address = 'WALLET_ADDRESS', accepted = true, amount = '19000', cap = '1000000', existing = null } = {}) {
|
||||
const captured = [];
|
||||
return {
|
||||
captured,
|
||||
async query(sql, params) {
|
||||
if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: '20000', house_fee_atomic: '1000' }] };
|
||||
if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: 'WALLET_ADDRESS' }] };
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT m.id')) return { rows: [] };
|
||||
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: [] };
|
||||
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
|
||||
const ids = params[0];
|
||||
return {
|
||||
rows: ids.map((id, index) => ({
|
||||
id,
|
||||
user_id: index + 1,
|
||||
race_entry_lot_id: 100 + index,
|
||||
escrow_amount: '1',
|
||||
escrow_value_atomic: '10000'
|
||||
})),
|
||||
rowCount: ids.length
|
||||
};
|
||||
}
|
||||
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
|
||||
return { rows: [{
|
||||
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
|
||||
payout_liability_amount_atomic: amount,
|
||||
payout_liability_cap_atomic: cap,
|
||||
payout_liability_accepted_at: accepted ? new Date().toISOString() : null
|
||||
}], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('FROM payouts') && sql.includes('LIMIT 1')) {
|
||||
return { rows: existing ? [existing] : [], rowCount: existing ? 1 : 0 };
|
||||
}
|
||||
if (sql.includes('FROM users') && sql.includes('payout_address')) {
|
||||
return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [{ id: 99 }], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
return await fn(client);
|
||||
return fn(client);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function manager(overrides = {}) {
|
||||
return {
|
||||
payoutsEnabled: true,
|
||||
directModeEnabled: true,
|
||||
creditsModeEnabled: false,
|
||||
_scheduleBatchPayout: jest.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoom() {
|
||||
const room = new MatchRoom({
|
||||
economy: 'crypto_race',
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
seed: '0'.repeat(64)
|
||||
});
|
||||
room.start();
|
||||
room.playerStates.get('a').escaped = true;
|
||||
@@ -38,30 +89,77 @@ describe('MatchPayoutService', () => {
|
||||
return room;
|
||||
}
|
||||
|
||||
test('collectEntryTickets locks pot on match row', async () => {
|
||||
test('collectEntryTickets atomically locks exact pot and accepted payout terms', async () => {
|
||||
const db = makeDb();
|
||||
const service = new MatchPayoutService({ db });
|
||||
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
||||
const room = makeRoom();
|
||||
|
||||
await service.collectEntryTickets(room, [
|
||||
{ userId: 1, socketId: 'a' },
|
||||
{ userId: 2, socketId: 'b' }
|
||||
{ userId: 1, socketId: 'a', queueEntryId: 11 },
|
||||
{ userId: 2, socketId: 'b', queueEntryId: 12 }
|
||||
]);
|
||||
const updateMatch = db.captured.find(q => q.sql.includes('UPDATE matches'));
|
||||
|
||||
const updateMatch = db.captured.find(q => q.sql.includes('payout_liability_accepted_at = NOW()'));
|
||||
expect(updateMatch).toBeTruthy();
|
||||
expect(room.potAtomic).toBeGreaterThan(0);
|
||||
expect(updateMatch.params.slice(0, 6)).toEqual([
|
||||
'10000', '20000', '1000', 5, '19000', '100000000000000000'
|
||||
]);
|
||||
expect(JSON.parse(updateMatch.params[6])).toEqual(expect.objectContaining({
|
||||
playerCount: 2,
|
||||
payoutAmountAtomic: '19000',
|
||||
rulesetId: 'race'
|
||||
}));
|
||||
expect(room.potAtomic).toBe('20000');
|
||||
});
|
||||
|
||||
test('payoutWinner inserts one pending payout for winner', async () => {
|
||||
test('accepted ON -> OFF liability still creates a pending payout', async () => {
|
||||
const db = makeDb();
|
||||
const service = new MatchPayoutService({ db });
|
||||
const room = makeRoom();
|
||||
room.entryFeeAtomic = 10000;
|
||||
room.potAtomic = 20000;
|
||||
room.houseFeeAtomic = 1000;
|
||||
await service.payoutWinner(room);
|
||||
const gmm = manager({ payoutsEnabled: false });
|
||||
const service = new MatchPayoutService({
|
||||
db,
|
||||
env: enabledEnv({ MATCH_PAYOUTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false' }),
|
||||
gameModeManager: gmm
|
||||
});
|
||||
|
||||
const result = await service.payoutWinner(makeRoom());
|
||||
|
||||
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
||||
expect(insert).toBeTruthy();
|
||||
expect(insert.params[3]).toBe('19000'); // winner amount
|
||||
expect(result).toEqual(expect.objectContaining({ created: true, amount: '19000' }));
|
||||
expect(insert.params[3]).toBe('19000');
|
||||
expect(insert.params[5]).toBe('match_winner');
|
||||
// Global off pauses dispatch, but does not suppress recording the obligation.
|
||||
expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('OFF -> ON never creates a retroactive match liability', async () => {
|
||||
const db = makeDb({ accepted: false });
|
||||
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
||||
|
||||
const result = await service.payoutWinner(makeRoom());
|
||||
|
||||
expect(result).toEqual({ created: false, reason: 'liability_not_accepted' });
|
||||
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
|
||||
});
|
||||
|
||||
test('needs_review payout identity blocks a replacement row', async () => {
|
||||
const db = makeDb({ existing: { id: 77, status: 'needs_review' } });
|
||||
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
||||
|
||||
const result = await service.payoutWinner(makeRoom());
|
||||
|
||||
expect(result).toEqual({ created: false, reason: 'payout_exists', payoutId: 77 });
|
||||
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
|
||||
});
|
||||
|
||||
test('winner with no address records a claimable needs_review liability', async () => {
|
||||
const db = makeDb({ address: null });
|
||||
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
||||
|
||||
await service.payoutWinner(makeRoom());
|
||||
|
||||
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
||||
expect(insert.params[2]).toBe('PENDING_NO_ADDRESS');
|
||||
expect(insert.params[5]).toBe('match_winner_no_address');
|
||||
expect(insert.params[6]).toBe('needs_review');
|
||||
});
|
||||
});
|
||||
|
||||
+136
-2
@@ -1,7 +1,10 @@
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
const SocketHandlers = require('../src/network/socketHandlers');
|
||||
|
||||
describe('MatchQueue', () => {
|
||||
function makeDb() {
|
||||
let nextQueueId = 1;
|
||||
const queueUsers = new Map();
|
||||
return {
|
||||
queries: [],
|
||||
lastParams: null,
|
||||
@@ -18,9 +21,13 @@ describe('MatchQueue', () => {
|
||||
this.queries.push({ sql, params });
|
||||
let rows = [{ id: 1, created_at: new Date().toISOString(), credits: 100, race_entries: 1 }];
|
||||
let rowCount = 1;
|
||||
if (sql.includes('UPDATE match_queue_entries') && sql.includes('user_id = ANY')) {
|
||||
if (sql.includes('INSERT INTO match_queue_entries')) {
|
||||
const id = nextQueueId++;
|
||||
queueUsers.set(String(id), params[0]);
|
||||
rows = [{ id, created_at: new Date().toISOString() }];
|
||||
} else if (sql.includes('UPDATE match_queue_entries') && sql.includes('id = ANY')) {
|
||||
const ids = params[0] || [];
|
||||
rows = ids.map((uid, i) => ({ id: i + 10, user_id: uid }));
|
||||
rows = ids.map(id => ({ id, user_id: queueUsers.get(String(id)) }));
|
||||
rowCount = ids.length;
|
||||
}
|
||||
return { rows, rowCount };
|
||||
@@ -45,6 +52,21 @@ describe('MatchQueue', () => {
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('rejects a syntactically valid economy that the server has disabled', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = makeDb();
|
||||
const gameModeManager = { _getMatchEconomies: () => ({ free: true }) };
|
||||
const q = new MatchQueue({ db, gameModeManager });
|
||||
q.initialized = true;
|
||||
|
||||
const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'crypto_race' });
|
||||
|
||||
expect(result).toEqual({ success: false, reason: 'economy_disabled' });
|
||||
expect(q.length('crypto_race')).toBe(0);
|
||||
expect(db.queries).toHaveLength(0);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
test('free join adds to memory queue', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = makeDb();
|
||||
@@ -60,6 +82,7 @@ describe('MatchQueue', () => {
|
||||
process.env.MATCH_CREDITS_COST = '5';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
q.initialized = true;
|
||||
const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(q.length('credits_prestige')).toBe(1);
|
||||
@@ -68,6 +91,83 @@ describe('MatchQueue', () => {
|
||||
delete process.env.MATCH_CREDITS_COST;
|
||||
});
|
||||
|
||||
test.each([
|
||||
['credits_prestige', 'credits'],
|
||||
['crypto_race', 'race_entries']
|
||||
])('concurrent duplicate %s joins hold escrow exactly once', async (economy, debitColumn) => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
if (economy === 'crypto_race') {
|
||||
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUT_MAX = '1000000';
|
||||
process.env.MATCH_ENTRY_FEE_ATOMIC = '10000';
|
||||
process.env.MATCH_HOUSE_FEE_PERCENT = '5';
|
||||
process.env.MATCH_MAX_PLAYERS = '4';
|
||||
}
|
||||
let queued = false;
|
||||
let debits = 0;
|
||||
let joinLedgerRows = 0;
|
||||
const createdAt = new Date().toISOString();
|
||||
const db = {
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql) {
|
||||
if (sql.includes('INSERT INTO match_queue_entries')) {
|
||||
if (queued) return { rows: [], rowCount: 0 };
|
||||
queued = true;
|
||||
return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE match_queue_entries') && sql.includes("status = 'queued'")) {
|
||||
return { rows: [{ id: 77, created_at: createdAt }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE users') && sql.includes(`${debitColumn} = ${debitColumn} -`)) {
|
||||
debits += 1;
|
||||
return {
|
||||
rows: [{ credits: 9, race_entries: 0 }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (sql.includes('SELECT id, unit_value_atomic') && sql.includes('FROM race_entry_lots')) {
|
||||
return { rows: [{ id: 501, unit_value_atomic: '10000' }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE race_entry_lots')) {
|
||||
return { rows: [{ id: 501 }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('INSERT INTO credit_transactions') || sql.includes('INSERT INTO race_entry_transactions')) {
|
||||
joinLedgerRows += 1;
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const gameModeManager = economy === 'crypto_race' ? {
|
||||
payoutsEnabled: true,
|
||||
directModeEnabled: true,
|
||||
creditsModeEnabled: false,
|
||||
_getMatchEconomies: () => ({ free: true, crypto_race: true })
|
||||
} : null;
|
||||
const q = new MatchQueue({ db, gameModeManager });
|
||||
q.initialized = true;
|
||||
const request = () => q.join({ userId: 9, socketId: 's9', sessionToken: 't9', economy });
|
||||
|
||||
const results = await Promise.all([request(), request()]);
|
||||
|
||||
expect(results.every(result => result.success)).toBe(true);
|
||||
expect(results.filter(result => result.alreadyQueued).length).toBe(1);
|
||||
expect(debits).toBe(1);
|
||||
expect(joinLedgerRows).toBe(1);
|
||||
expect(q.length(economy)).toBe(1);
|
||||
delete process.env.MATCH_ENABLED;
|
||||
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
|
||||
delete process.env.MATCH_PAYOUTS_ENABLED;
|
||||
delete process.env.MATCH_PAYOUT_MAX;
|
||||
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
|
||||
delete process.env.MATCH_HOUSE_FEE_PERCENT;
|
||||
delete process.env.MATCH_MAX_PLAYERS;
|
||||
});
|
||||
|
||||
test('drain requires at least two queued players', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const db = makeDb();
|
||||
@@ -97,6 +197,7 @@ describe('MatchQueue', () => {
|
||||
process.env.MATCH_CREDITS_COST = '3';
|
||||
const db = makeDb();
|
||||
const q = new MatchQueue({ db });
|
||||
q.initialized = true;
|
||||
await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
|
||||
const leave = await q.leave(1, 'credits_prestige');
|
||||
expect(leave.success).toBe(true);
|
||||
@@ -105,3 +206,36 @@ describe('MatchQueue', () => {
|
||||
delete process.env.MATCH_CREDITS_COST;
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocketHandlers match queue acknowledgements', () => {
|
||||
test('emits the documented joined and left results to the client', async () => {
|
||||
const emitted = [];
|
||||
const socket = { id: 'sock', emit: (event, payload) => emitted.push({ event, payload }) };
|
||||
const ctx = {
|
||||
activeGames: new Map(),
|
||||
matchQueue: {
|
||||
isEnabled: () => true,
|
||||
enqueue: jest.fn().mockResolvedValue({ success: true, position: 2 }),
|
||||
leave: jest.fn().mockResolvedValue({ success: true })
|
||||
},
|
||||
rateLimiter: {
|
||||
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
|
||||
recordAttempt: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
_resolveMatchSession: jest.fn().mockResolvedValue({ userId: 1, socketId: 'sock', sessionToken: 't' }),
|
||||
debugManager: { CONSOLE_LOGGING: false }
|
||||
};
|
||||
|
||||
await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'join', economy: 'free' });
|
||||
await SocketHandlers.prototype._handleMatchQueue.call(ctx, socket, { action: 'leave', economy: 'free' });
|
||||
|
||||
expect(emitted).toContainEqual({
|
||||
event: 'match_queue_joined',
|
||||
payload: { success: true, position: 2, economy: 'free' }
|
||||
});
|
||||
expect(emitted).toContainEqual({
|
||||
event: 'match_queue_left',
|
||||
payload: { success: true, economy: 'free' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+163
-18
@@ -116,6 +116,48 @@ describe('match rework — crash + countdown (MatchManager)', () => {
|
||||
// Second call must be a benign no-op (no double finalize, no throw).
|
||||
expect(() => m.expire(room.id, 'admin')).not.toThrow();
|
||||
});
|
||||
|
||||
test('block deadline expires on the first later header after the active-play floor', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-07-21T12:00:00.000Z'));
|
||||
const m = mgr();
|
||||
const room = new MatchRoom({
|
||||
economy: 'free', startBlockHeight: 100,
|
||||
entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED
|
||||
});
|
||||
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
|
||||
{ countdownMs: NEVER, minDurationMs: 20000, hardCeilingMs: NEVER });
|
||||
room.start();
|
||||
|
||||
expect(m.expireBlockDeadlines(100)).toBe(0); // same header can never end its room
|
||||
jest.advanceTimersByTime(19999);
|
||||
expect(m.expireBlockDeadlines(101)).toBe(0); // later header, but floor not met
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(m.expireBlockDeadlines(101)).toBe(1);
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.endReason).toBe('block_deadline');
|
||||
expect(room.endBlockHeight).toBe(101);
|
||||
expect(m.expireBlockDeadlines(102)).toBe(0); // terminal transition is idempotent
|
||||
});
|
||||
|
||||
test('a ruleset can explicitly disable the block deadline', () => {
|
||||
const m = mgr();
|
||||
const room = new MatchRoom({
|
||||
economy: 'free', startBlockHeight: 100,
|
||||
entrants: { s1: { userId: 1 }, s2: { userId: 2 } }, seed: SEED,
|
||||
ruleset: {
|
||||
id: 'no-block-clock', mode: 'race', players: { min: 2, max: 2 },
|
||||
entities: { monster: false }, winCondition: { type: 'high-score' },
|
||||
timing: { blockDeadline: false, minDurationMs: 0 }
|
||||
}
|
||||
});
|
||||
m.attach(room, [{ userId: 1, socketId: 's1' }, { userId: 2, socketId: 's2' }],
|
||||
{ countdownMs: NEVER, minDurationMs: 0, hardCeilingMs: NEVER });
|
||||
room.start();
|
||||
|
||||
expect(m.expireBlockDeadlines(101)).toBe(0);
|
||||
expect(room.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => {
|
||||
@@ -166,6 +208,42 @@ describe('match rework — reconnect re-keying + ownership (MP-H1/H2)', () => {
|
||||
expect(room.occupants.has('evil')).toBe(false);
|
||||
expect(m.socketToMatch.has('evil')).toBe(false);
|
||||
});
|
||||
|
||||
test('a finished-room reconnect receives the terminal result immediately and once', () => {
|
||||
const { m, room } = activeMatch();
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
room._finalized = true; // durable finish retained during the normal cleanup grace
|
||||
|
||||
const sock = makeSocket('s1new');
|
||||
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
|
||||
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
|
||||
|
||||
const terminal = sock.emitted.filter(e => e.ev === 'match_end');
|
||||
expect(terminal).toHaveLength(1);
|
||||
expect(terminal[0].p).toEqual(expect.objectContaining({
|
||||
matchId: room.id,
|
||||
reason: 'hard_ceiling',
|
||||
winnerId: room.winnerId
|
||||
}));
|
||||
expect(terminal[0].p.players.find(p => p.id === 's1new')).toEqual(expect.objectContaining({
|
||||
placement: expect.any(Number),
|
||||
score: expect.any(Number)
|
||||
}));
|
||||
expect(terminal[0].p.fairness.seed).toBe(SEED);
|
||||
});
|
||||
|
||||
test('a reconnect during nondurable finish gets pending, never a premature winner', () => {
|
||||
const { m, room } = activeMatch();
|
||||
room.expire('hard_ceiling');
|
||||
room.finalize();
|
||||
room._finalized = false;
|
||||
|
||||
const sock = makeSocket('s1new');
|
||||
expect(m.handleReconnect(sock, { userId: 1 })).toBe(true);
|
||||
expect(sock.emitted.some(e => e.ev === 'match_settlement_pending')).toBe(true);
|
||||
expect(sock.emitted.some(e => e.ev === 'match_end')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — winner consistency across end paths', () => {
|
||||
@@ -194,18 +272,24 @@ describe('match rework — winner consistency across end paths', () => {
|
||||
describe('match rework — payout economics (MP-C4/H5)', () => {
|
||||
function capturingDb({ address = 'WALLET', pot = '20000', fee = '1000' } = {}) {
|
||||
const captured = [];
|
||||
const amount = (BigInt(pot) - BigInt(fee)).toString();
|
||||
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 query() { 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: [] };
|
||||
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
|
||||
return { rows: [{
|
||||
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
|
||||
payout_liability_amount_atomic: amount,
|
||||
payout_liability_cap_atomic: amount,
|
||||
payout_liability_accepted_at: new Date().toISOString()
|
||||
}], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('FROM payouts')) return { rows: [], rowCount: 0 };
|
||||
if (sql.includes('FROM users')) return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
|
||||
return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
@@ -237,28 +321,60 @@ describe('match rework — payout economics (MP-C4/H5)', () => {
|
||||
});
|
||||
|
||||
test('BigInt exactness: a pot beyond 2^53 loses no precision', async () => {
|
||||
const db = capturingDb({ address: 'WALLET', pot: '90071992547409920001', fee: '1' });
|
||||
const db = capturingDb({ address: 'WALLET', pot: '900719925474099201', 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
|
||||
expect(ins.params[3]).toBe('900719925474099200'); // exact; a float would round this
|
||||
});
|
||||
|
||||
test('collectEntryTickets uses integer basis-point math for a fractional house fee', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
process.env.MATCH_CRYPTO_RACE_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUTS_ENABLED = 'true';
|
||||
process.env.MATCH_PAYOUT_MAX = '1000000000000';
|
||||
process.env.MATCH_MAX_PLAYERS = '4';
|
||||
process.env.PAYOUTS_ENABLED = 'true';
|
||||
process.env.PAYMENTS_ENABLED = 'true';
|
||||
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 db = { async withTransaction(fn) { return fn({ async query(sql, params) {
|
||||
captured.push({ sql, params });
|
||||
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
|
||||
return {
|
||||
rows: params[0].map((id, index) => ({
|
||||
id,
|
||||
user_id: index + 1,
|
||||
race_entry_lot_id: 100 + index,
|
||||
escrow_amount: '1',
|
||||
escrow_value_atomic: '10000000000'
|
||||
})),
|
||||
rowCount: params[0].length
|
||||
};
|
||||
}
|
||||
return { rows: [{ race_entries: 1, id: 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 }]);
|
||||
await svc.collectEntryTickets(room, [
|
||||
{ userId: 1, queueEntryId: 11 },
|
||||
{ userId: 2, queueEntryId: 12 }
|
||||
]);
|
||||
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);
|
||||
expect(room.potAtomic).toBe('20000000000');
|
||||
delete process.env.MATCH_ENTRY_FEE_ATOMIC;
|
||||
delete process.env.MATCH_HOUSE_FEE_PERCENT;
|
||||
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
|
||||
delete process.env.MATCH_PAYOUTS_ENABLED;
|
||||
delete process.env.MATCH_PAYOUT_MAX;
|
||||
delete process.env.MATCH_MAX_PLAYERS;
|
||||
delete process.env.PAYOUTS_ENABLED;
|
||||
delete process.env.PAYMENTS_ENABLED;
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,7 +399,7 @@ describe('match rework — refunds on every cancellation path (MP-H3/H4)', () =>
|
||||
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('race_entries = race_entries + $1'))).toBe(true);
|
||||
expect(captured.some(c => c.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -331,10 +447,10 @@ describe('match rework — refunds on every cancellation path (MP-H3/H4)', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('match rework — payout-first isolated finalize (MP-C5)', () => {
|
||||
describe('match rework — durable-finish 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 () => {
|
||||
test('a nondurable winner creates no payout, leaderboard row, or final announcement', async () => {
|
||||
const io = makeIo();
|
||||
const throwingDb = { async withTransaction() { throw new Error('persist boom'); } };
|
||||
const m = new MatchManager({ io, db: throwingDb, debugManager: null });
|
||||
@@ -342,15 +458,44 @@ describe('match rework — payout-first isolated finalize (MP-C5)', () => {
|
||||
|
||||
const order = [];
|
||||
m.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
|
||||
m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); throw new Error('lb boom'); };
|
||||
m.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); };
|
||||
|
||||
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);
|
||||
expect(order).not.toContain('payout'); // no DB winner means no reconstructable payout target
|
||||
expect(order).not.toContain('leaderboard'); // no synthetic result without a durable match
|
||||
expect(room._finalized).not.toBe(true); // remains retryable in memory
|
||||
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false);
|
||||
expect(io.broadcasts.some(b => b.event === 'match_settlement_pending')).toBe(true);
|
||||
m.shutdown();
|
||||
});
|
||||
|
||||
test('a transient finish failure retries and announces only after durability succeeds', async () => {
|
||||
jest.useFakeTimers();
|
||||
const io = makeIo();
|
||||
const m = new MatchManager({ io, db: {}, debugManager: null });
|
||||
m.enabled = true;
|
||||
m._persistFinish = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('temporary database outage'))
|
||||
.mockResolvedValueOnce();
|
||||
m.matchPayoutService.payoutWinner = jest.fn().mockResolvedValue();
|
||||
m.matchLeaderboard.postMatch = jest.fn().mockResolvedValue();
|
||||
|
||||
const room = makeCryptoFinishedRoom();
|
||||
m.rooms.set(room.id, room);
|
||||
await m._finalize(room);
|
||||
expect(room._finalized).not.toBe(true);
|
||||
expect(io.broadcasts.some(b => b.event === 'match_end')).toBe(false);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(room._finalized).toBe(true);
|
||||
expect(m._persistFinish).toHaveBeenCalledTimes(2);
|
||||
expect(m.matchPayoutService.payoutWinner).toHaveBeenCalledTimes(1);
|
||||
expect(m.matchLeaderboard.postMatch).toHaveBeenCalledTimes(1);
|
||||
expect(io.broadcasts.filter(b => b.event === 'match_end')).toHaveLength(1);
|
||||
m.shutdown();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,6 +194,23 @@ describe('MatchRoom', () => {
|
||||
expect(room.playerStates.get('a').alive).toBe(false);
|
||||
expect(result.events.some(e => e.type === 'player_death')).toBe(true);
|
||||
});
|
||||
|
||||
test('fractional monster speed accumulates across ticks and zero stays stationary', () => {
|
||||
const room = makeRoom();
|
||||
room.start();
|
||||
room.monster.hasLineOfSight = jest.fn(() => true);
|
||||
room.monster.moveTowardPlayer = jest.fn(() => true);
|
||||
|
||||
room.difficultyConfig.monster.movesPerPlayerMove = 0.5;
|
||||
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(0);
|
||||
expect(room._moveMonster().filter(e => e.type === 'monster_move')).toHaveLength(1);
|
||||
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
|
||||
|
||||
room.difficultyConfig.monster.movesPerPlayerMove = 0;
|
||||
room._moveMonster();
|
||||
room._moveMonster();
|
||||
expect(room.monster.moveTowardPlayer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiration / rankings', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const MatchScheduler = require('../src/network/matchScheduler');
|
||||
const { deriveBlockMatchSeed } = MatchScheduler;
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
|
||||
describe('MatchScheduler', () => {
|
||||
@@ -6,10 +7,7 @@ describe('MatchScheduler', () => {
|
||||
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).
|
||||
// Stop the scheduler tail and every handed-off engine so Jest cannot leak handles.
|
||||
if (scheduler) scheduler.shutdown();
|
||||
if (manager) for (const engine of manager.engines.values()) engine.stop();
|
||||
scheduler = null;
|
||||
@@ -50,6 +48,7 @@ describe('MatchScheduler', () => {
|
||||
this.attached.push({ room, entrants, opts });
|
||||
},
|
||||
setEngine(id, engine) { engines.set(id, engine); },
|
||||
expireBlockDeadlines: jest.fn(),
|
||||
onTick() {},
|
||||
onFinish() {},
|
||||
expire() {}
|
||||
@@ -106,4 +105,74 @@ describe('MatchScheduler', () => {
|
||||
expect(manager.engines.size).toBe(1);
|
||||
expect(room.status).toBe('starting');
|
||||
});
|
||||
|
||||
test('operator-selected last-alive ruleset creates a real PvP match', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const queue = makeQueue();
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, rulesetId: 'last-alive' });
|
||||
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];
|
||||
expect(room.ruleset.id).toBe('last-alive');
|
||||
expect(room.pvpCombat).toBe(true);
|
||||
expect(room.variant).toBe('pvp');
|
||||
expect(room.snapshot().ruleset).toEqual(expect.objectContaining({
|
||||
id: 'last-alive',
|
||||
winCondition: 'last-alive'
|
||||
}));
|
||||
});
|
||||
|
||||
test('block-derived match seeds are reproducible and lock the durable entrant set', () => {
|
||||
const input = {
|
||||
blockHash: 'ab'.repeat(32),
|
||||
blockHeight: 123,
|
||||
economy: 'crypto_race',
|
||||
rulesetId: 'race',
|
||||
queueEntryIds: [12, 3]
|
||||
};
|
||||
const first = deriveBlockMatchSeed(input);
|
||||
const reordered = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 12] });
|
||||
const changedEntrant = deriveBlockMatchSeed({ ...input, queueEntryIds: [3, 13] });
|
||||
|
||||
expect(first).toEqual(reordered);
|
||||
expect(first.seed).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(first.derivation.queueEntryIds).toEqual(['3', '12']);
|
||||
expect(changedEntrant.seed).not.toBe(first.seed);
|
||||
});
|
||||
|
||||
test('production paid queues do not drain without a canonical block hash', async () => {
|
||||
const oldNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const queue = makeQueue();
|
||||
const drain = jest.spyOn(queue, 'drain');
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
try {
|
||||
await scheduler._drainEconomy('credits_prestige', 100, null);
|
||||
expect(drain).not.toHaveBeenCalled();
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
} finally {
|
||||
if (oldNodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = oldNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('each advancing block is offered once to active-room deadline processing', async () => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
const queue = makeQueue();
|
||||
manager = makeManager();
|
||||
scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
|
||||
|
||||
await scheduler.onBlock(100);
|
||||
await scheduler.onBlock(100);
|
||||
await scheduler.onBlock(101);
|
||||
|
||||
// getblockcount is normalized to its zero-based canonical header exactly once.
|
||||
expect(manager.expireBlockDeadlines.mock.calls).toEqual([[99], [100]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const noStore = require('../src/middleware/noStore');
|
||||
|
||||
describe('authenticated API cache policy', () => {
|
||||
test('sets explicit no-store headers before continuing', () => {
|
||||
const headers = {};
|
||||
const response = {
|
||||
setHeader: jest.fn((name, value) => { headers[name] = value; })
|
||||
};
|
||||
const next = jest.fn();
|
||||
|
||||
noStore({}, response, next);
|
||||
|
||||
expect(headers).toEqual({
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0'
|
||||
});
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
const SessionManager = require('../src/network/sessionManager');
|
||||
|
||||
function makeDb(initialGames, initialCredits = 0) {
|
||||
const games = new Map(initialGames.map(game => [game.id, { ...game }]));
|
||||
const users = new Map([[7, initialCredits]]);
|
||||
const refundReasons = new Set();
|
||||
|
||||
const query = jest.fn(async (sql, params = []) => {
|
||||
if (/SELECT id FROM games WHERE status = 'active'/i.test(sql)) {
|
||||
return {
|
||||
rows: Array.from(games.values())
|
||||
.filter(game => game.status === 'active')
|
||||
.map(game => ({ id: game.id }))
|
||||
};
|
||||
}
|
||||
if (/FROM games WHERE id = \$1 FOR UPDATE/i.test(sql)) {
|
||||
const game = games.get(params[0]);
|
||||
return { rows: game ? [{ ...game }] : [] };
|
||||
}
|
||||
if (/SELECT 1 FROM credit_transactions/i.test(sql)) {
|
||||
return { rows: refundReasons.has(params[1]) ? [{}] : [] };
|
||||
}
|
||||
if (/UPDATE users SET credits = credits \+/i.test(sql)) {
|
||||
const next = (users.get(params[1]) || 0) + Number(params[0]);
|
||||
users.set(params[1], next);
|
||||
return { rows: [{ credits: next }], rowCount: 1 };
|
||||
}
|
||||
if (/INSERT INTO credit_transactions/i.test(sql)) {
|
||||
refundReasons.add(params[2]);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (/UPDATE games SET status = \$1/i.test(sql)) {
|
||||
const game = games.get(params[1]);
|
||||
if (game) game.status = params[0];
|
||||
return { rows: [], rowCount: game ? 1 : 0 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
return {
|
||||
query,
|
||||
withTransaction: jest.fn(async callback => callback({ query })),
|
||||
credits: userId => users.get(userId),
|
||||
game: id => games.get(id),
|
||||
refundReasons
|
||||
};
|
||||
}
|
||||
|
||||
function manager(db, currentConfiguredCost = 99) {
|
||||
return new SessionManager({
|
||||
db,
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
gameModeManager: { creditsPerGameCost: currentConfiguredCost }
|
||||
});
|
||||
}
|
||||
|
||||
describe('orphan game recovery debit evidence', () => {
|
||||
test('does not mint a refund for a pre-created active row when entry charging never committed', async () => {
|
||||
const db = makeDb([{
|
||||
id: 1,
|
||||
user_id: 7,
|
||||
game_mode: 'PAID_CREDITS',
|
||||
payment_id: null,
|
||||
status: 'active',
|
||||
entry_consumed_at: null,
|
||||
entry_credits_spent: 5
|
||||
}], 10);
|
||||
|
||||
const result = await manager(db).recoverOrphanedGames();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
scanned: 1,
|
||||
finalized: 1,
|
||||
refunded: 0,
|
||||
creditsRefunded: 0,
|
||||
unresolved: []
|
||||
});
|
||||
expect(db.credits(7)).toBe(10);
|
||||
expect(db.game(1).status).toBe('expired');
|
||||
expect(db.refundReasons.size).toBe(0);
|
||||
});
|
||||
|
||||
test('restores the immutable debit snapshot, not the current configured entry cost', async () => {
|
||||
const db = makeDb([{
|
||||
id: 2,
|
||||
user_id: 7,
|
||||
game_mode: 'PAID_CREDITS',
|
||||
payment_id: null,
|
||||
status: 'active',
|
||||
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
|
||||
entry_credits_spent: 3
|
||||
}], 4);
|
||||
const recovery = manager(db, 99);
|
||||
|
||||
const first = await recovery.recoverOrphanedGames();
|
||||
const second = await recovery.recoverOrphanedGames();
|
||||
|
||||
expect(first).toEqual({
|
||||
ok: true,
|
||||
scanned: 1,
|
||||
finalized: 1,
|
||||
refunded: 1,
|
||||
creditsRefunded: 3,
|
||||
unresolved: []
|
||||
});
|
||||
expect(second).toEqual({
|
||||
ok: true,
|
||||
scanned: 0,
|
||||
finalized: 0,
|
||||
refunded: 0,
|
||||
creditsRefunded: 0,
|
||||
unresolved: []
|
||||
});
|
||||
expect(db.credits(7)).toBe(7);
|
||||
expect(db.game(2).status).toBe('refunded');
|
||||
expect(db.refundReasons).toEqual(new Set(['orphan_game_refunded:2']));
|
||||
});
|
||||
|
||||
test('does not silently convert a linked direct payment into credits', async () => {
|
||||
const db = makeDb([{
|
||||
id: 3,
|
||||
user_id: 7,
|
||||
game_mode: 'PAID_SINGLE',
|
||||
payment_id: 77,
|
||||
status: 'active',
|
||||
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
|
||||
entry_credits_spent: null
|
||||
}], 1);
|
||||
|
||||
const result = await manager(db).recoverOrphanedGames();
|
||||
|
||||
expect(result.refunded).toBe(0);
|
||||
expect(db.credits(7)).toBe(1);
|
||||
expect(db.game(3).status).toBe('expired');
|
||||
});
|
||||
|
||||
test('fails closed with explicit scan semantics when the orphan query fails', async () => {
|
||||
const db = {
|
||||
query: jest.fn().mockRejectedValue(new Error('database unavailable')),
|
||||
withTransaction: jest.fn()
|
||||
};
|
||||
|
||||
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
|
||||
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
||||
recovery: {
|
||||
ok: false,
|
||||
scope: 'orphaned_solo_games',
|
||||
scanFailed: true,
|
||||
scanned: 0,
|
||||
resolved: 0,
|
||||
unresolved: []
|
||||
}
|
||||
});
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('finishes other rows but rejects startup while any orphan remains unresolved', async () => {
|
||||
const db = makeDb([{
|
||||
id: 4,
|
||||
user_id: 7,
|
||||
game_mode: 'PAID_CREDITS',
|
||||
status: 'active',
|
||||
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
|
||||
entry_credits_spent: 2
|
||||
}, {
|
||||
id: 5,
|
||||
user_id: 7,
|
||||
game_mode: 'FREE',
|
||||
status: 'active',
|
||||
entry_consumed_at: null,
|
||||
entry_credits_spent: null
|
||||
}], 0);
|
||||
const baseTransaction = db.withTransaction;
|
||||
let attempts = 0;
|
||||
db.withTransaction = jest.fn(async callback => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('refund transaction failed');
|
||||
return baseTransaction(callback);
|
||||
});
|
||||
|
||||
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
|
||||
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
||||
recovery: {
|
||||
scope: 'orphaned_solo_games',
|
||||
scanFailed: false,
|
||||
scanned: 2,
|
||||
resolved: 1,
|
||||
unresolved: [{ type: 'game', id: '4' }]
|
||||
}
|
||||
});
|
||||
expect(db.game(4).status).toBe('active');
|
||||
expect(db.game(5).status).toBe('expired');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
const SocketHandlers = require('../src/network/socketHandlers');
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
||||
|
||||
const ENV_KEYS = [
|
||||
'NODE_ENV',
|
||||
'LEGAL_POLICY_VERSION',
|
||||
'PAID_ACKNOWLEDGEMENT_REQUIRED',
|
||||
'MATCH_ENABLED',
|
||||
'MATCH_CRYPTO_RACE_ENABLED',
|
||||
'MATCH_PAYOUTS_ENABLED'
|
||||
];
|
||||
|
||||
function manager(overrides = {}) {
|
||||
return {
|
||||
cryptoType: 'WOW',
|
||||
currencyLabel: 'WOW',
|
||||
network: 'mainnet',
|
||||
isTestNetwork: false,
|
||||
paymentsEnabled: false,
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: false,
|
||||
freePlayEnabled: true,
|
||||
payoutsEnabled: false,
|
||||
gameMode: 'PAID_CREDITS',
|
||||
isPayoutEnabledForMode: () => false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function acknowledgement(gameModeManager, overrides = {}) {
|
||||
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
|
||||
return {
|
||||
policyVersion: disclosure.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function socket() {
|
||||
return {
|
||||
id: 'socket-paid-flow',
|
||||
handshake: { address: '127.0.0.1', headers: {} },
|
||||
emit: jest.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function baseContext(gameModeManager = manager()) {
|
||||
return {
|
||||
gameModeManager,
|
||||
sessionManager: null,
|
||||
broadcastManager: { sendStatusUpdate: jest.fn() },
|
||||
rateLimiter: {
|
||||
checkLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfter: 0 }),
|
||||
recordAttempt: jest.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
_consumeFairnessAttempt: jest.fn().mockReturnValue({ proofVersion: 2 }),
|
||||
activeGames: new Map(),
|
||||
connectionHandler: { getUserBySocket: jest.fn().mockReturnValue({ serverId: 'socket-paid-flow' }) },
|
||||
debugManager: { getCurrentBlockHeight: jest.fn().mockReturnValue(100), CONSOLE_LOGGING: false },
|
||||
io: { to: jest.fn(() => ({ emit: jest.fn() })) }
|
||||
};
|
||||
}
|
||||
|
||||
describe('current paid acknowledgement gates every value-consuming socket flow', () => {
|
||||
let previous;
|
||||
|
||||
beforeEach(() => {
|
||||
previous = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.LEGAL_POLICY_VERSION = 'paid-flow-current-v1';
|
||||
process.env.PAID_ACKNOWLEDGEMENT_REQUIRED = 'false';
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
delete process.env.MATCH_CRYPTO_RACE_ENABLED;
|
||||
delete process.env.MATCH_PAYOUTS_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (previous[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('invoice request rejects a stale policy before fairness, wallet, or database work', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.paymentHandlers = { handlePaymentRequest: jest.fn() };
|
||||
|
||||
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
|
||||
type: 'credits_package',
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
|
||||
});
|
||||
|
||||
expect(ctx.paymentHandlers.handlePaymentRequest).not.toHaveBeenCalled();
|
||||
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
|
||||
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({
|
||||
code: 'PAID_ACK_VERSION', policyVersion: 'paid-flow-current-v1'
|
||||
}));
|
||||
});
|
||||
|
||||
test('invoice request accepts the current policy even with intake state reported off', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.paymentHandlers = { handlePaymentRequest: jest.fn().mockResolvedValue(undefined) };
|
||||
const ack = acknowledgement(ctx.gameModeManager);
|
||||
|
||||
await SocketHandlers.prototype.handlePaymentRequest.call(ctx, client, {
|
||||
type: 'credits_package', legalAcknowledgement: ack
|
||||
});
|
||||
|
||||
expect(ctx.paymentHandlers.handlePaymentRequest).toHaveBeenCalledWith(client, {
|
||||
type: 'credits_package', legalAcknowledgement: ack
|
||||
});
|
||||
});
|
||||
|
||||
test('auto-start rejects stale policy before consuming a fairness offer or credit', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.gameManager = { createGameForUser: jest.fn() };
|
||||
|
||||
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
|
||||
});
|
||||
|
||||
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
|
||||
expect(ctx.gameManager.createGameForUser).not.toHaveBeenCalled();
|
||||
expect(client.emit).toHaveBeenCalledWith('commerce_ack_required', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
|
||||
});
|
||||
|
||||
test('auto-start with current policy reaches existing-credit consumption while intake is off', async () => {
|
||||
const client = socket();
|
||||
const gameModeManager = manager({
|
||||
canUserStartGame: jest.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
effectiveMode: 'PAID_CREDITS',
|
||||
useCredits: true
|
||||
}),
|
||||
requiresPayoutAddressForMode: jest.fn().mockReturnValue(false),
|
||||
processGameStart: jest.fn().mockResolvedValue({ success: true, creditsRemaining: 4 })
|
||||
});
|
||||
const ctx = baseContext(gameModeManager);
|
||||
const game = {
|
||||
id: 'game-1',
|
||||
dbId: null,
|
||||
getState: jest.fn().mockReturnValue({}),
|
||||
getProofCommitment: jest.fn().mockReturnValue('commitment')
|
||||
};
|
||||
ctx.gameManager = { createGameForUser: jest.fn().mockResolvedValue(game) };
|
||||
|
||||
await SocketHandlers.prototype.handleAutoStart.call(ctx, client, {
|
||||
legalAcknowledgement: acknowledgement(gameModeManager)
|
||||
});
|
||||
|
||||
expect(gameModeManager.canUserStartGame).toHaveBeenCalledWith(client.id);
|
||||
expect(gameModeManager.processGameStart).toHaveBeenCalledWith(client.id, game.id, { forceFree: false });
|
||||
});
|
||||
|
||||
test('queued-credit entry rejects stale policy before queue eligibility or debit', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.queueHandler = { handleGameQueue: jest.fn() };
|
||||
|
||||
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
|
||||
});
|
||||
|
||||
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
|
||||
expect(ctx.queueHandler.handleGameQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('queued-credit entry accepts the current policy while invoice intake is off', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.queueHandler = { handleGameQueue: jest.fn().mockResolvedValue(undefined) };
|
||||
ctx.queueManager = { getPlayerIndex: jest.fn().mockReturnValue(-1) };
|
||||
|
||||
await SocketHandlers.prototype.handleJoinQueue.call(ctx, client, {
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
|
||||
});
|
||||
|
||||
expect(ctx.queueHandler.handleGameQueue).toHaveBeenCalledWith(
|
||||
client,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ fairnessProof: { proofVersion: 2 } })
|
||||
);
|
||||
});
|
||||
|
||||
test('early entry rejects stale policy before queue eligibility or credit debit', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.queueHandler = { handleEarlyEntry: jest.fn() };
|
||||
|
||||
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
|
||||
});
|
||||
|
||||
expect(ctx._consumeFairnessAttempt).not.toHaveBeenCalled();
|
||||
expect(ctx.queueHandler.handleEarlyEntry).not.toHaveBeenCalled();
|
||||
expect(client.emit).toHaveBeenCalledWith('early_entry_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
|
||||
});
|
||||
|
||||
test('early entry accepts current policy while invoice intake is off', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.queueHandler = { handleEarlyEntry: jest.fn().mockResolvedValue({ success: true }) };
|
||||
|
||||
await SocketHandlers.prototype.handleEarlyEntry.call(ctx, client, {
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
|
||||
});
|
||||
|
||||
expect(ctx.queueHandler.handleEarlyEntry).toHaveBeenCalledWith(
|
||||
client,
|
||||
expect.any(Function),
|
||||
{ fairnessProof: { proofVersion: 2 } }
|
||||
);
|
||||
});
|
||||
|
||||
test('paid PvP rejects stale policy before a credit or ticket is escrowed', async () => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.matchQueue = {
|
||||
isEnabled: jest.fn().mockReturnValue(true),
|
||||
enqueue: jest.fn(),
|
||||
leave: jest.fn()
|
||||
};
|
||||
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
|
||||
userId: 1, socketId: client.id, sessionToken: 'session'
|
||||
});
|
||||
|
||||
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
|
||||
action: 'join', economy: 'crypto_race',
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager, { policyVersion: 'stale-v0' })
|
||||
});
|
||||
|
||||
expect(ctx.matchQueue.enqueue).not.toHaveBeenCalled();
|
||||
expect(client.emit).toHaveBeenCalledWith('match_error', expect.objectContaining({ code: 'PAID_ACK_VERSION' }));
|
||||
});
|
||||
|
||||
test.each(['credits_prestige', 'crypto_race'])(
|
||||
'paid PvP %s accepts the current policy while new invoice intake is off',
|
||||
async economy => {
|
||||
const client = socket();
|
||||
const ctx = baseContext();
|
||||
ctx.matchQueue = {
|
||||
isEnabled: jest.fn().mockReturnValue(true),
|
||||
enqueue: jest.fn().mockResolvedValue({ success: true, position: 1 }),
|
||||
leave: jest.fn()
|
||||
};
|
||||
ctx._resolveMatchSession = jest.fn().mockResolvedValue({
|
||||
userId: 1, socketId: client.id, sessionToken: 'session'
|
||||
});
|
||||
|
||||
await SocketHandlers.prototype._handleMatchQueue.call(ctx, client, {
|
||||
action: 'join', economy,
|
||||
legalAcknowledgement: acknowledgement(ctx.gameModeManager)
|
||||
});
|
||||
|
||||
expect(ctx.matchQueue.enqueue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 1, economy }),
|
||||
{ economy }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test('continuation cache is canonical, capped, expires, and clears on failed selection', async () => {
|
||||
const gameModeManager = manager({
|
||||
gameMode: 'FREE',
|
||||
getCosmeticProduct: jest.fn().mockReturnValue(null)
|
||||
});
|
||||
const handler = new PaymentHandlers({
|
||||
io: { to: () => ({ emit: jest.fn() }) },
|
||||
gameModeManager,
|
||||
walletService: {},
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
queueManager: {},
|
||||
broadcastManager: { sendStatusUpdate: jest.fn() },
|
||||
sessionManager: null
|
||||
});
|
||||
const ack = acknowledgement(gameModeManager);
|
||||
try {
|
||||
handler._rememberPendingCommerceAcknowledgement('canonical', {
|
||||
...ack,
|
||||
ignored: 'never retained'
|
||||
});
|
||||
expect(handler._getPendingCommerceAcknowledgement('canonical')).toEqual(ack);
|
||||
expect(Object.keys(handler._getPendingCommerceAcknowledgement('canonical'))).toEqual([
|
||||
'policyVersion', 'ageEligible', 'termsRead', 'riskAccepted', 'testnetUnderstood'
|
||||
]);
|
||||
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
handler._rememberPendingCommerceAcknowledgement(`socket-${index}`, ack);
|
||||
}
|
||||
expect(handler.pendingCommerceAcknowledgement.size).toBe(256);
|
||||
|
||||
const expiresAt = handler.pendingCommerceAcknowledgement.get('socket-299').expiresAt;
|
||||
const now = jest.spyOn(Date, 'now').mockReturnValue(expiresAt);
|
||||
expect(handler._getPendingCommerceAcknowledgement('socket-299')).toBeNull();
|
||||
now.mockRestore();
|
||||
|
||||
handler._rememberPendingCommerceAcknowledgement('failed', ack);
|
||||
handler.pendingEntryFairness.set('failed', { proofVersion: 2 });
|
||||
await handler.createAndShowPaymentRequest({ id: 'failed' }, {
|
||||
paymentType: 'bogus',
|
||||
legalAcknowledgement: ack
|
||||
});
|
||||
expect(handler._getPendingCommerceAcknowledgement('failed')).toBeNull();
|
||||
expect(handler.pendingEntryFairness.has('failed')).toBe(false);
|
||||
} finally {
|
||||
handler.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect clears server-side acknowledgement and fairness continuation state', () => {
|
||||
const client = socket();
|
||||
const clearPendingCommerceAcknowledgement = jest.fn();
|
||||
const pendingEntryFairness = new Map([[client.id, { proofVersion: 2 }]]);
|
||||
const ctx = {
|
||||
fairnessOffers: { discardSocket: jest.fn() },
|
||||
paymentHandlers: { clearPendingCommerceAcknowledgement, pendingEntryFairness },
|
||||
connectionHandler: { handleDisconnect: jest.fn() }
|
||||
};
|
||||
|
||||
SocketHandlers.prototype.handleDisconnect.call(ctx, client);
|
||||
|
||||
expect(clearPendingCommerceAcknowledgement).toHaveBeenCalledWith(client.id);
|
||||
expect(pendingEntryFairness.has(client.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
const { hashSeed } = require('../src/game/provablyFair');
|
||||
|
||||
const ENV_KEYS = [
|
||||
'NODE_ENV', 'REQUIRE_PAID_FAIRNESS_V2', 'GAME_MODE', 'SINGLE_GAME_PRICE',
|
||||
'CREDITS_PACKAGE_PRICE', 'CREDITS_PER_GAME', 'CREDITS_PAYOUT_ENABLED',
|
||||
'CREDITS_PAYOUTS_ENABLED'
|
||||
];
|
||||
|
||||
function configManager() {
|
||||
return {
|
||||
getConfig: () => ({
|
||||
paymentsEnabled: true,
|
||||
currency: { symbol: 'XMR', decimals: 12 },
|
||||
modes: {
|
||||
direct: { enabled: true, price: 1000n },
|
||||
credits: { enabled: false, creditsPerGame: 1, packages: [] }
|
||||
},
|
||||
payouts: {
|
||||
enabled: false,
|
||||
rules: {
|
||||
direct: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } },
|
||||
credits: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 } }
|
||||
}
|
||||
},
|
||||
preferences: { preferCreditsFirst: false }
|
||||
}),
|
||||
getLegacyGameMode: () => 'PAID_SINGLE',
|
||||
eventBus: { on: jest.fn() }
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager(db, provider = null) {
|
||||
const providers = provider ? { getProvider: jest.fn(() => provider) } : { getProvider: jest.fn(() => null) };
|
||||
return new GameModeManager(
|
||||
db,
|
||||
{ createPaymentRequest: jest.fn() },
|
||||
{ CONSOLE_LOGGING: false },
|
||||
configManager(),
|
||||
providers
|
||||
);
|
||||
}
|
||||
|
||||
describe('paid fairness invoice durability', () => {
|
||||
let previousEnv;
|
||||
let logSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
previousEnv = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.REQUIRE_PAID_FAIRNESS_V2 = 'false';
|
||||
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
for (const key of ENV_KEYS) {
|
||||
if (previousEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = previousEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('production cannot disable v2 and a new manager restores the invoice-bound proof', async () => {
|
||||
const serverSeed = '12'.repeat(32);
|
||||
const proof = {
|
||||
proofVersion: 2,
|
||||
offerId: 'offer-before-payment',
|
||||
offerIssuedAt: 1700000000123,
|
||||
serverSeed,
|
||||
commitment: hashSeed(serverSeed),
|
||||
clientSeed: '34'.repeat(32)
|
||||
};
|
||||
let insertParams;
|
||||
let insertSql;
|
||||
const expiresAt = new Date('2030-01-01T00:00:00.000Z');
|
||||
const db = {
|
||||
query: jest.fn(async (sql, params = []) => {
|
||||
if (/SELECT \* FROM users WHERE id/i.test(sql)) return { rows: [{ id: 7 }] };
|
||||
if (/UPDATE payments SET status = 'expired'/i.test(sql)) return { rows: [] };
|
||||
if (/INSERT INTO payments/i.test(sql)) {
|
||||
insertSql = sql;
|
||||
insertParams = params;
|
||||
return { rows: [{ id: 55, expires_at: expiresAt }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
})
|
||||
};
|
||||
const provider = {
|
||||
createInvoice: jest.fn(async () => ({
|
||||
address: 'stagenet-address',
|
||||
addressIndex: 9,
|
||||
invoiceId: 'invoice-55',
|
||||
expiresAt
|
||||
}))
|
||||
};
|
||||
const firstProcess = makeManager(db, provider);
|
||||
|
||||
expect(firstProcess._requiresPaidFairnessV2()).toBe(true);
|
||||
const request = await firstProcess.createPaymentRequest('old-socket', 'single_game', {
|
||||
userId: 7,
|
||||
reuseExisting: false,
|
||||
fairnessProof: proof
|
||||
});
|
||||
expect(request.fairnessProof).toEqual(proof);
|
||||
// PostgreSQL's extended protocol rejects one placeholder when it is inferred as both
|
||||
// VARCHAR (the destination column) and TEXT (a CASE expression). Keep both uses explicit
|
||||
// and identical; this is exercised end-to-end by the stagenet invoice canary.
|
||||
expect(insertSql).toMatch(/\$12::varchar\(64\)/i);
|
||||
expect(insertSql).toMatch(/CASE WHEN \$12::varchar\(64\) IS NULL/i);
|
||||
expect(insertParams.slice(10, 16)).toEqual([
|
||||
2,
|
||||
proof.offerId,
|
||||
new Date(proof.offerIssuedAt),
|
||||
proof.commitment,
|
||||
proof.serverSeed,
|
||||
proof.clientSeed
|
||||
]);
|
||||
|
||||
// Simulate a process restart: only fields reconstructed from the payment row remain.
|
||||
const durableRow = {
|
||||
id: 55,
|
||||
subaddress: 'stagenet-address',
|
||||
expected_amount: '1000',
|
||||
payment_type: 'single_game',
|
||||
status: 'pending',
|
||||
expires_at: expiresAt,
|
||||
fairness_proof_version: insertParams[10],
|
||||
fairness_offer_id: insertParams[11],
|
||||
fairness_offer_issued_at: insertParams[12],
|
||||
fairness_commitment: insertParams[13],
|
||||
fairness_server_seed: insertParams[14],
|
||||
fairness_client_seed: insertParams[15],
|
||||
fairness_bound_at: new Date(),
|
||||
fairness_consumed_at: null
|
||||
};
|
||||
const restarted = makeManager({ query: jest.fn() });
|
||||
expect(restarted._mapPaymentRowToRequest(durableRow, 'single_game', null).fairnessProof)
|
||||
.toEqual(proof);
|
||||
});
|
||||
|
||||
test('entry claim atomically verifies and consumes the exact invoice proof', async () => {
|
||||
const serverSeed = 'ab'.repeat(32);
|
||||
const offerIssuedAt = 1700000000999;
|
||||
const payment = {
|
||||
id: 90,
|
||||
user_id: 7,
|
||||
fairness_proof_version: 2,
|
||||
fairness_offer_id: 'restart-proof',
|
||||
fairness_offer_issued_at: new Date(offerIssuedAt),
|
||||
fairness_commitment: hashSeed(serverSeed),
|
||||
fairness_server_seed: serverSeed,
|
||||
fairness_client_seed: 'cd'.repeat(32),
|
||||
fairness_bound_at: new Date(),
|
||||
fairness_consumed_at: null
|
||||
};
|
||||
const calls = [];
|
||||
const client = {
|
||||
query: jest.fn(async (sql, params = []) => {
|
||||
calls.push({ sql, params });
|
||||
if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] };
|
||||
if (/SELECT proof_version/i.test(sql)) {
|
||||
return { rows: [{
|
||||
proof_version: 2,
|
||||
fairness_offer_id: payment.fairness_offer_id,
|
||||
fairness_offer_issued_at: payment.fairness_offer_issued_at,
|
||||
proof_commitment: payment.fairness_commitment,
|
||||
server_seed: payment.fairness_server_seed,
|
||||
client_seed: payment.fairness_client_seed
|
||||
}] };
|
||||
}
|
||||
if (/UPDATE games/i.test(sql)) return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
if (/UPDATE payments[\s\S]*fairness_consumed_at/i.test(sql)) {
|
||||
return { rows: [{ id: payment.id }], rowCount: 1 };
|
||||
}
|
||||
if (/UPDATE users/i.test(sql)) return { rows: [], rowCount: 1 };
|
||||
return { rows: [], rowCount: 0 };
|
||||
})
|
||||
};
|
||||
const db = { withTransaction: jest.fn(async callback => callback(client)) };
|
||||
const manager = makeManager(db);
|
||||
|
||||
await expect(manager._processGameStartWithPayment(
|
||||
{ id: 7, payout_address: null },
|
||||
{ id: payment.id },
|
||||
'game-proof-seed'
|
||||
)).resolves.toEqual(expect.objectContaining({ success: true, paymentId: payment.id }));
|
||||
|
||||
const gameUpdate = calls.find(call => /UPDATE games/i.test(call.sql));
|
||||
const paymentConsume = calls.find(call => /UPDATE payments[\s\S]*fairness_consumed_at/i.test(call.sql));
|
||||
expect(gameUpdate.sql).toContain('entry_consumed_at = NOW()');
|
||||
expect(paymentConsume.sql).toContain('fairness_consumed_at IS NULL');
|
||||
});
|
||||
|
||||
test('tampering with the persisted game proof aborts before entry consumption', async () => {
|
||||
const serverSeed = 'ef'.repeat(32);
|
||||
const payment = {
|
||||
id: 91,
|
||||
user_id: 7,
|
||||
fairness_proof_version: 2,
|
||||
fairness_offer_id: 'tamper-proof',
|
||||
fairness_offer_issued_at: new Date(1700000001234),
|
||||
fairness_commitment: hashSeed(serverSeed),
|
||||
fairness_server_seed: serverSeed,
|
||||
fairness_client_seed: '01'.repeat(32),
|
||||
fairness_bound_at: new Date(),
|
||||
fairness_consumed_at: null
|
||||
};
|
||||
const client = {
|
||||
query: jest.fn(async sql => {
|
||||
if (/SELECT \* FROM payments/i.test(sql)) return { rows: [payment] };
|
||||
if (/SELECT proof_version/i.test(sql)) {
|
||||
return { rows: [{
|
||||
proof_version: 2,
|
||||
fairness_offer_id: payment.fairness_offer_id,
|
||||
fairness_offer_issued_at: payment.fairness_offer_issued_at,
|
||||
proof_commitment: payment.fairness_commitment,
|
||||
server_seed: payment.fairness_server_seed,
|
||||
client_seed: 'ff'.repeat(32)
|
||||
}] };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
})
|
||||
};
|
||||
const manager = makeManager({ withTransaction: jest.fn(async callback => callback(client)) });
|
||||
|
||||
await expect(manager._processGameStartWithPayment(
|
||||
{ id: 7, payout_address: null },
|
||||
{ id: payment.id },
|
||||
'tampered-game'
|
||||
)).rejects.toMatchObject({ code: 'PAYMENT_FAIRNESS_MISMATCH' });
|
||||
expect(client.query.mock.calls.some(([sql]) => /UPDATE games/i.test(sql))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const MatchScheduler = require('../src/network/matchScheduler');
|
||||
const MatchQueue = require('../src/network/matchQueue');
|
||||
const SocketHandlers = require('../src/network/socketHandlers');
|
||||
const {
|
||||
PAID_SEED_VERSION,
|
||||
buildPaidEntrantFreeze,
|
||||
deriveFutureBlockMatchSeed
|
||||
} = require('../src/network/matchFairness');
|
||||
|
||||
const FUTURE_HASH = 'ab'.repeat(32);
|
||||
|
||||
function entrant(queueEntryId, userId = queueEntryId) {
|
||||
return {
|
||||
queueEntryId,
|
||||
userId,
|
||||
socketId: `socket-${userId}`,
|
||||
sessionToken: `session-${userId}`,
|
||||
createdAt: queueEntryId
|
||||
};
|
||||
}
|
||||
|
||||
class DurablePaidQueueDouble {
|
||||
constructor(entries = []) {
|
||||
this.initialized = true;
|
||||
this.queues = {
|
||||
free: [],
|
||||
credits_prestige: entries.slice(),
|
||||
crypto_race: []
|
||||
};
|
||||
this.frozen = [];
|
||||
this.freezeCalls = [];
|
||||
this.cancelled = [];
|
||||
}
|
||||
|
||||
async refundUnavailableQueues() {}
|
||||
isEconomyAvailable() { return true; }
|
||||
isFinancialRecoveryReady() { return true; }
|
||||
|
||||
async freezePaidMatch(spec) {
|
||||
const queue = this.queues[spec.economy];
|
||||
if (!queue || queue.length < spec.minPlayers) return null;
|
||||
const entries = queue.slice(0, spec.maxPlayers);
|
||||
const freeze = buildPaidEntrantFreeze({
|
||||
freezeBlockHeight: spec.freezeBlockHeight,
|
||||
targetBlockHeight: spec.targetBlockHeight,
|
||||
economy: spec.economy,
|
||||
rulesetId: spec.rulesetId,
|
||||
queueEntryIds: entries.map(entry => entry.queueEntryId)
|
||||
});
|
||||
const group = {
|
||||
id: crypto.randomUUID(),
|
||||
economy: spec.economy,
|
||||
variant: spec.variant,
|
||||
rulesetId: spec.rulesetId,
|
||||
difficultyPreset: spec.difficultyPreset,
|
||||
maxPlayers: spec.maxPlayers,
|
||||
targetBlockHeight: spec.targetBlockHeight,
|
||||
freezeCommitment: freeze.freezeCommitment,
|
||||
precommitTipHeight: null,
|
||||
precommitVerifiedAt: null,
|
||||
freeze,
|
||||
entries,
|
||||
status: 'pending'
|
||||
};
|
||||
this.freezeCalls.push(spec);
|
||||
this.frozen.push(group);
|
||||
this.queues[spec.economy] = queue.slice(entries.length);
|
||||
return group;
|
||||
}
|
||||
|
||||
async verifyFrozenPrecommit(id, commitment, tip) {
|
||||
const group = this.frozen.find(candidate => candidate.id === id
|
||||
&& candidate.status === 'pending'
|
||||
&& candidate.freezeCommitment === commitment
|
||||
&& candidate.targetBlockHeight > tip);
|
||||
if (!group) return { verified: false };
|
||||
group.precommitTipHeight = tip;
|
||||
group.precommitVerifiedAt = new Date().toISOString();
|
||||
return {
|
||||
verified: true,
|
||||
observedTipHeight: tip,
|
||||
verifiedAt: group.precommitVerifiedAt
|
||||
};
|
||||
}
|
||||
|
||||
async listFrozenPaidMatches(throughBlockHeight) {
|
||||
return this.frozen.filter(group => group.status === 'pending'
|
||||
&& (throughBlockHeight == null || group.targetBlockHeight <= throughBlockHeight));
|
||||
}
|
||||
|
||||
noteFrozenStarted(id) {
|
||||
const group = this.frozen.find(candidate => candidate.id === id);
|
||||
if (group) group.status = 'starting';
|
||||
}
|
||||
|
||||
async cancelFrozenMatch(id) {
|
||||
const group = this.frozen.find(candidate => candidate.id === id && candidate.status === 'pending');
|
||||
if (!group) return { claimed: false, resolved: true, refunded: 0 };
|
||||
group.status = 'cancelled';
|
||||
this.cancelled.push(...group.entries.map(entry => entry.queueEntryId));
|
||||
return { claimed: true, resolved: true, refunded: group.entries.length };
|
||||
}
|
||||
|
||||
async refundEntries(entries) {
|
||||
const ids = new Set((entries || []).map(entry => String(entry.queueEntryId)));
|
||||
const group = this.frozen.find(candidate => candidate.entries.some(
|
||||
entry => ids.has(String(entry.queueEntryId))
|
||||
));
|
||||
if (group && group.status !== 'cancelled') {
|
||||
group.status = 'cancelled';
|
||||
this.cancelled.push(...group.entries.map(entry => entry.queueEntryId));
|
||||
}
|
||||
return { attempted: ids.size, resolved: ids.size, failed: 0 };
|
||||
}
|
||||
|
||||
async drain(economy, maxPlayers, minPlayers) {
|
||||
const queue = this.queues[economy] || [];
|
||||
if (queue.length < minPlayers) return null;
|
||||
const entries = queue.slice(0, maxPlayers);
|
||||
this.queues[economy] = queue.slice(entries.length);
|
||||
return { entries };
|
||||
}
|
||||
}
|
||||
|
||||
function managerDouble() {
|
||||
return {
|
||||
attached: [],
|
||||
engines: new Map(),
|
||||
db: null,
|
||||
attach(room, entries) { this.attached.push({ room, entries }); },
|
||||
setEngine(id, engine) { this.engines.set(id, engine); },
|
||||
onTick() {},
|
||||
onFinish() {}
|
||||
};
|
||||
}
|
||||
|
||||
describe('paid PvP future-block entrant freeze', () => {
|
||||
const schedulers = [];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MATCH_ENABLED = 'true';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(schedulers.splice(0).map(scheduler => scheduler.shutdown()));
|
||||
delete process.env.MATCH_ENABLED;
|
||||
});
|
||||
|
||||
function schedulerFor(queue, manager, provider, countProvider) {
|
||||
const scheduler = new MatchScheduler({
|
||||
matchQueue: queue,
|
||||
matchManager: manager,
|
||||
blockEntropyProvider: provider,
|
||||
blockCountProvider: countProvider,
|
||||
paidEntropyDelayBlocks: 2,
|
||||
paidEntropyConfirmations: 2,
|
||||
rulesetId: 'last-alive',
|
||||
maxPlayers: 2,
|
||||
countdownMs: 10 ** 9
|
||||
});
|
||||
schedulers.push(scheduler);
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
test('delay=2/conf=2 freezes FIFO at H, reads no hash through target, then starts from the stable exact header', async () => {
|
||||
const queue = new DurablePaidQueueDouble([entrant(12), entrant(3), entrant(99)]);
|
||||
const manager = managerDouble();
|
||||
let daemonCount = 101;
|
||||
const entropyCalls = [];
|
||||
const scheduler = schedulerFor(queue, manager, async height => {
|
||||
entropyCalls.push(height);
|
||||
if (height === 100) throw new Error('the freeze-block hash must never be requested');
|
||||
return height === 102
|
||||
? { block_header: { height, hash: FUTURE_HASH } }
|
||||
: null;
|
||||
}, async () => daemonCount);
|
||||
|
||||
// DebugManager emits block count 101, whose actual canonical tip header is height 100.
|
||||
await scheduler.onBlock(101);
|
||||
|
||||
expect(entropyCalls).toEqual([]);
|
||||
expect(queue.frozen).toHaveLength(1);
|
||||
expect(queue.frozen[0].entries.map(entry => entry.queueEntryId)).toEqual([12, 3]);
|
||||
expect(queue.frozen[0].freeze.queueEntryIds).toEqual(['3', '12']);
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
|
||||
daemonCount = 102;
|
||||
await scheduler.onBlock(102); // H+1
|
||||
daemonCount = 103;
|
||||
await scheduler.onBlock(103); // target exists, but only confirmation one
|
||||
expect(entropyCalls).toEqual([]);
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
|
||||
daemonCount = 104;
|
||||
await scheduler.onBlock(104); // target+1: confirmation two
|
||||
|
||||
expect(entropyCalls).toEqual([102, 102, 102]);
|
||||
expect(manager.attached).toHaveLength(1);
|
||||
const { room, entries } = manager.attached[0];
|
||||
const expected = deriveFutureBlockMatchSeed({
|
||||
blockHash: FUTURE_HASH,
|
||||
blockHeight: 102,
|
||||
freeze: queue.frozen[0].freeze
|
||||
});
|
||||
expect(entries.map(entry => entry.queueEntryId)).toEqual([12, 3]);
|
||||
expect(room.seed).toBe(expected.seed);
|
||||
expect(room.seedDerivation).toEqual(expect.objectContaining(expected.derivation));
|
||||
expect(room.seedDerivation.version).toBe(PAID_SEED_VERSION);
|
||||
expect(room.seedDerivation.blockHash).toBe(FUTURE_HASH);
|
||||
expect(room.seedDerivation).toEqual(expect.objectContaining({
|
||||
entropyDelayBlocks: 2,
|
||||
entropyConfirmations: 2,
|
||||
minimumConfirmedTipHeight: 103,
|
||||
precommitTipHeight: 100
|
||||
}));
|
||||
expect(room.fairnessProof(false)).toEqual(expect.objectContaining({
|
||||
blockHeight: 102,
|
||||
blockHash: FUTURE_HASH,
|
||||
freezeBlockHeight: 100,
|
||||
targetBlockHeight: 102,
|
||||
entropyDelayBlocks: 2,
|
||||
entropyConfirmations: 2,
|
||||
minimumConfirmedTipHeight: 103,
|
||||
precommitTipHeight: 100
|
||||
}));
|
||||
});
|
||||
|
||||
test('a delayed freeze commit that crosses its target performs zero hash/attach work and refunds once', async () => {
|
||||
const queue = new DurablePaidQueueDouble([entrant(1), entrant(2)]);
|
||||
const manager = managerDouble();
|
||||
manager.matchPayoutService = { collectEntryTickets: jest.fn() };
|
||||
const entropy = jest.fn();
|
||||
const scheduler = schedulerFor(queue, manager, entropy, async () => 203);
|
||||
|
||||
await scheduler.onBlock(101); // freeze H=100, target=102, post-commit tip already 202
|
||||
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled();
|
||||
expect(entropy).not.toHaveBeenCalled();
|
||||
expect(queue.freezeCalls).toHaveLength(1);
|
||||
expect(queue.cancelled).toEqual([1, 2]);
|
||||
await scheduler.onBlock(101);
|
||||
expect(queue.cancelled).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('strict count failure cannot activate and the due unverified freeze is refunded without a hash read', async () => {
|
||||
const queue = new DurablePaidQueueDouble([entrant(21), entrant(22)]);
|
||||
const manager = managerDouble();
|
||||
const entropy = jest.fn();
|
||||
const scheduler = schedulerFor(queue, manager, entropy, async () => {
|
||||
throw new Error('daemon unavailable');
|
||||
});
|
||||
|
||||
await scheduler.onBlock(301);
|
||||
await scheduler.onBlock(302);
|
||||
await scheduler.onBlock(303);
|
||||
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
expect(entropy).not.toHaveBeenCalled();
|
||||
expect(queue.cancelled).toEqual([21, 22]);
|
||||
});
|
||||
|
||||
test('a header reorg between the two pre-activation reads refunds without gameplay', async () => {
|
||||
const queue = new DurablePaidQueueDouble([entrant(41), entrant(42)]);
|
||||
const manager = managerDouble();
|
||||
let daemonCount = 201;
|
||||
const hashes = [FUTURE_HASH, 'cd'.repeat(32)];
|
||||
const entropy = jest.fn(async height => ({
|
||||
block_header: { height, hash: hashes.shift() || hashes.at(-1) }
|
||||
}));
|
||||
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
|
||||
await scheduler.onBlock(201); // H=200, target=202
|
||||
daemonCount = 204;
|
||||
await scheduler.onBlock(204);
|
||||
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
expect(manager.engines.size).toBe(0);
|
||||
expect(queue.cancelled).toEqual([41, 42]);
|
||||
expect(entropy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('a daemon response for the wrong height cannot activate the paid freeze', async () => {
|
||||
const queue = new DurablePaidQueueDouble([entrant(45), entrant(46)]);
|
||||
const manager = managerDouble();
|
||||
let daemonCount = 211;
|
||||
const entropy = jest.fn(async () => ({
|
||||
block_header: { height: 999, hash: FUTURE_HASH }
|
||||
}));
|
||||
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
|
||||
await scheduler.onBlock(211); // H=210, target=212
|
||||
daemonCount = 214;
|
||||
await scheduler.onBlock(214);
|
||||
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
expect(queue.frozen[0].status).toBe('pending');
|
||||
expect(entropy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a post-activation reorg aborts/refunds before engine attach', async () => {
|
||||
const queue = new DurablePaidQueueDouble([entrant(51), entrant(52)]);
|
||||
const manager = managerDouble();
|
||||
manager.matchPayoutService = { collectEntryTickets: jest.fn() };
|
||||
let daemonCount = 301;
|
||||
const hashes = [FUTURE_HASH, FUTURE_HASH, 'ef'.repeat(32)];
|
||||
const entropy = jest.fn(async height => ({
|
||||
block_header: { height, hash: hashes.shift() }
|
||||
}));
|
||||
const scheduler = schedulerFor(queue, manager, entropy, async () => daemonCount);
|
||||
await scheduler.onBlock(301);
|
||||
daemonCount = 304;
|
||||
await scheduler.onBlock(304);
|
||||
|
||||
expect(manager.attached).toHaveLength(0);
|
||||
expect(manager.matchPayoutService.collectEntryTickets).not.toHaveBeenCalled();
|
||||
expect(manager.engines.size).toBe(0);
|
||||
expect(queue.cancelled).toEqual([51, 52]);
|
||||
expect(entropy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('legacy v1 pending freezes are cancelled before any target lookup', async () => {
|
||||
const queue = new DurablePaidQueueDouble();
|
||||
const entries = [entrant(61), entrant(62)];
|
||||
queue.frozen.push({
|
||||
id: crypto.randomUUID(),
|
||||
economy: 'credits_prestige',
|
||||
rulesetId: 'last-alive',
|
||||
targetBlockHeight: 401,
|
||||
freezeCommitment: '0'.repeat(64),
|
||||
freeze: {
|
||||
version: 'future-block-freeze-v1', freezeBlockHeight: 400,
|
||||
targetBlockHeight: 401, economy: 'credits_prestige', rulesetId: 'last-alive',
|
||||
queueEntryIds: ['61', '62'], freezeCommitment: '0'.repeat(64)
|
||||
},
|
||||
entries,
|
||||
status: 'pending'
|
||||
});
|
||||
const entropy = jest.fn();
|
||||
const scheduler = schedulerFor(queue, managerDouble(), entropy, async () => 403);
|
||||
|
||||
await scheduler.onBlock(402);
|
||||
|
||||
expect(entropy).not.toHaveBeenCalled();
|
||||
expect(queue.cancelled).toEqual([61, 62]);
|
||||
});
|
||||
|
||||
test('the default provider fetches the exact canonical header height disclosed in proofs', async () => {
|
||||
const rpc = { getBlockByHeight: jest.fn().mockResolvedValue({ hash: FUTURE_HASH }) };
|
||||
const scheduler = new MatchScheduler({
|
||||
matchQueue: new DurablePaidQueueDouble(),
|
||||
matchManager: managerDouble(),
|
||||
debugManager: { rpcService: rpc },
|
||||
rulesetId: 'last-alive'
|
||||
});
|
||||
schedulers.push(scheduler);
|
||||
|
||||
await scheduler.blockEntropyProvider(808);
|
||||
|
||||
expect(rpc.getBlockByHeight).toHaveBeenCalledWith(808);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatchQueue durable freeze/refund transactions', () => {
|
||||
beforeEach(() => { process.env.MATCH_ENABLED = 'true'; });
|
||||
afterEach(() => { delete process.env.MATCH_ENABLED; });
|
||||
|
||||
test('writes pending match, exact queue links, and entrants in one freeze transaction', async () => {
|
||||
const calls = [];
|
||||
const entries = [entrant(61, 6), entrant(62, 7)];
|
||||
const db = {
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('INSERT INTO matches')) return { rows: [{ id: params[0] }], rowCount: 1 };
|
||||
if (sql.includes('UPDATE match_queue_entries')) {
|
||||
return {
|
||||
rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })),
|
||||
rowCount: entries.length
|
||||
};
|
||||
}
|
||||
return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
queue._queues.credits_prestige = entries.slice();
|
||||
|
||||
const frozen = await queue.freezePaidMatch({
|
||||
economy: 'credits_prestige',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
freezeBlockHeight: 600,
|
||||
targetBlockHeight: 601,
|
||||
rulesetId: 'last-alive',
|
||||
variant: 'pvp',
|
||||
difficultyPreset: 'race'
|
||||
});
|
||||
|
||||
expect(frozen.entries.map(entry => entry.queueEntryId)).toEqual([61, 62]);
|
||||
expect(queue.length('credits_prestige')).toBe(0);
|
||||
const pending = calls.find(call => call.sql.includes('INSERT INTO matches'));
|
||||
const links = calls.find(call => call.sql.includes('UPDATE match_queue_entries'));
|
||||
expect(pending.sql).toContain("'pending'");
|
||||
expect(pending.params[6]).toBe(frozen.freeze.freezeCommitment);
|
||||
expect(pending.params[8]).toBe(601);
|
||||
expect(links.params[1]).toEqual(['61', '62']);
|
||||
expect(calls.filter(call => call.sql.includes('INSERT INTO match_entrants'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('records the fresh post-commit daemon tip only while the target is still future', async () => {
|
||||
const calls = [];
|
||||
const verifiedAt = new Date('2026-07-21T12:00:00Z');
|
||||
const db = {
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
return {
|
||||
rows: [{
|
||||
id: params[0], start_block_height: 502,
|
||||
entropy_precommit_tip_height: params[2],
|
||||
entropy_precommit_verified_at: verifiedAt
|
||||
}],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
query: jest.fn()
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
|
||||
const proof = await queue.verifyFrozenPrecommit(
|
||||
crypto.randomUUID(), 'ab'.repeat(32), 500
|
||||
);
|
||||
|
||||
expect(proof).toEqual(expect.objectContaining({
|
||||
verified: true, targetBlockHeight: 502, observedTipHeight: 500
|
||||
}));
|
||||
expect(calls[0].sql).toContain('start_block_height > $3');
|
||||
expect(calls[0].sql).toContain('entropy_precommit_tip_height IS NULL');
|
||||
expect(calls[0].sql).toContain("status = 'pending'");
|
||||
expect(db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('startup cancels v1 and crash-before-verification freezes; a failed cancellation retries', async () => {
|
||||
const canonical = buildPaidEntrantFreeze({
|
||||
freezeBlockHeight: 800,
|
||||
targetBlockHeight: 802,
|
||||
economy: 'credits_prestige',
|
||||
rulesetId: 'last-alive',
|
||||
queueEntryIds: [81, 82]
|
||||
});
|
||||
const queue = new MatchQueue({ db: {} });
|
||||
queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'v2-unverified', freeze: canonical,
|
||||
freezeCommitment: canonical.freezeCommitment,
|
||||
precommitTipHeight: null, precommitVerifiedAt: null
|
||||
},
|
||||
{
|
||||
id: 'v1-legacy', freeze: { ...canonical, version: 'future-block-freeze-v1' },
|
||||
freezeCommitment: canonical.freezeCommitment,
|
||||
precommitTipHeight: 800, precommitVerifiedAt: new Date()
|
||||
}
|
||||
]);
|
||||
const attempts = new Map();
|
||||
queue.cancelFrozenMatch = jest.fn(async id => {
|
||||
attempts.set(id, (attempts.get(id) || 0) + 1);
|
||||
if (id === 'v2-unverified' && attempts.get(id) === 1) {
|
||||
return { resolved: false };
|
||||
}
|
||||
return { resolved: true };
|
||||
});
|
||||
|
||||
const first = await queue.cancelUnverifiedPaidFreezes(
|
||||
'match_cancel', { failOnError: false }
|
||||
);
|
||||
const second = await queue.cancelUnverifiedPaidFreezes(
|
||||
'match_cancel', { failOnError: false }
|
||||
);
|
||||
|
||||
expect(first).toEqual(expect.objectContaining({ ok: false, failed: 1 }));
|
||||
expect(second).toEqual(expect.objectContaining({ ok: true, failed: 0 }));
|
||||
expect(attempts.get('v2-unverified')).toBe(2);
|
||||
expect(attempts.get('v1-legacy')).toBe(2);
|
||||
});
|
||||
|
||||
test('a lost freeze COMMIT acknowledgement adopts the exact durable envelope once', async () => {
|
||||
const entries = [entrant(63, 9), entrant(64, 10)];
|
||||
let pendingParams = null;
|
||||
const db = {
|
||||
async withTransaction(fn) {
|
||||
await fn({
|
||||
async query(sql, params) {
|
||||
if (sql.includes('INSERT INTO matches')) {
|
||||
pendingParams = params;
|
||||
return { rows: [{ id: params[0] }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('UPDATE match_queue_entries')) {
|
||||
return {
|
||||
rows: entries.map(entry => ({ id: entry.queueEntryId, user_id: entry.userId })),
|
||||
rowCount: entries.length
|
||||
};
|
||||
}
|
||||
return { rows: [{ id: 1 }], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
throw new Error('commit acknowledgement lost');
|
||||
},
|
||||
async query(sql) {
|
||||
expect(sql).toContain("m.status = 'pending'");
|
||||
const dungeon = JSON.parse(pendingParams[7]);
|
||||
return {
|
||||
rows: entries.map(entry => ({
|
||||
id: pendingParams[0],
|
||||
economy: 'credits_prestige',
|
||||
variant: 'pvp',
|
||||
ruleset_id: 'last-alive',
|
||||
difficulty_preset: 'race',
|
||||
max_players: 2,
|
||||
seed_hash: pendingParams[6],
|
||||
start_block_height: 611,
|
||||
dungeon,
|
||||
queue_entry_id: entry.queueEntryId,
|
||||
user_id: entry.userId,
|
||||
socket_id: entry.socketId,
|
||||
session_token: entry.sessionToken,
|
||||
created_at: new Date(entry.createdAt).toISOString()
|
||||
})),
|
||||
rowCount: entries.length
|
||||
};
|
||||
}
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
queue._queues.credits_prestige = entries.slice();
|
||||
|
||||
const frozen = await queue.freezePaidMatch({
|
||||
economy: 'credits_prestige',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
freezeBlockHeight: 610,
|
||||
targetBlockHeight: 611,
|
||||
rulesetId: 'last-alive',
|
||||
variant: 'pvp',
|
||||
difficultyPreset: 'race'
|
||||
});
|
||||
|
||||
expect(frozen).toEqual(expect.objectContaining({
|
||||
id: pendingParams[0],
|
||||
freezeCommitment: pendingParams[6]
|
||||
}));
|
||||
expect(queue.length('credits_prestige')).toBe(0);
|
||||
});
|
||||
|
||||
test('shutdown cancellation claims a pending freeze and refunds each anchor exactly once', async () => {
|
||||
const freeze = buildPaidEntrantFreeze({
|
||||
freezeBlockHeight: 700,
|
||||
targetBlockHeight: 701,
|
||||
economy: 'credits_prestige',
|
||||
rulesetId: 'last-alive',
|
||||
queueEntryIds: [71, 72]
|
||||
});
|
||||
let pending = true;
|
||||
let creditsRefunded = 0;
|
||||
let ledgerRows = 0;
|
||||
const db = {
|
||||
async withTransaction(fn) {
|
||||
return fn({
|
||||
async query(sql) {
|
||||
if (sql.includes('UPDATE matches')) {
|
||||
if (!pending) return { rows: [], rowCount: 0 };
|
||||
pending = false;
|
||||
return {
|
||||
rows: [{ economy: 'credits_prestige', dungeon: { match_fairness_freeze: freeze } }],
|
||||
rowCount: 1
|
||||
};
|
||||
}
|
||||
if (sql.includes('UPDATE match_queue_entries')) {
|
||||
return {
|
||||
rows: [
|
||||
{ id: 71, user_id: 7, escrow_amount: '1', race_entry_lot_id: null },
|
||||
{ id: 72, user_id: 8, escrow_amount: '1', race_entry_lot_id: null }
|
||||
],
|
||||
rowCount: 2
|
||||
};
|
||||
}
|
||||
if (sql.includes('UPDATE users')) {
|
||||
creditsRefunded += 1;
|
||||
return { rows: [{ credits: 10 }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('INSERT INTO credit_transactions')) {
|
||||
ledgerRows += 1;
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const queue = new MatchQueue({ db });
|
||||
const freezeId = crypto.randomUUID();
|
||||
queue.listFrozenPaidMatches = jest.fn().mockResolvedValue([{
|
||||
id: freezeId, economy: 'credits_prestige', entries: [entrant(71), entrant(72)]
|
||||
}]);
|
||||
|
||||
await queue.shutdown();
|
||||
await queue.shutdown();
|
||||
|
||||
expect(queue.listFrozenPaidMatches).toHaveBeenCalledTimes(2);
|
||||
expect({ creditsRefunded, ledgerRows }).toEqual({ creditsRefunded: 2, ledgerRows: 2 });
|
||||
});
|
||||
|
||||
test('orderly server shutdown waits for scheduler, then refunds freezes, then stops matches', async () => {
|
||||
const order = [];
|
||||
const context = {
|
||||
beginShutdown: jest.fn(),
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
tavernManager: null,
|
||||
matchScheduler: { shutdown: jest.fn(async () => { order.push('scheduler'); }) },
|
||||
matchQueue: { shutdown: jest.fn(async () => { order.push('queue'); }) },
|
||||
matchManager: { shutdown: jest.fn(() => { order.push('manager'); }) },
|
||||
spectatorManager: null,
|
||||
chatHandler: null,
|
||||
connectionHandler: null,
|
||||
rateLimiter: null,
|
||||
memoryManager: null,
|
||||
sessionManager: null,
|
||||
suspendedGameManager: null,
|
||||
paymentHandlers: null,
|
||||
activeGames: new Map()
|
||||
};
|
||||
|
||||
await SocketHandlers.prototype.shutdown.call(context);
|
||||
|
||||
expect(order).toEqual(['scheduler', 'queue', 'manager']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const PaymentConfigManager = require('../src/config/paymentConfig');
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
|
||||
describe('PaymentConfigManager atomic-unit precision', () => {
|
||||
const originalPrice = process.env.DIRECT_GAME_PRICE;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPrice === undefined) delete process.env.DIRECT_GAME_PRICE;
|
||||
else process.env.DIRECT_GAME_PRICE = originalPrice;
|
||||
});
|
||||
|
||||
test('parses integer environment values above Number.MAX_SAFE_INTEGER exactly', () => {
|
||||
process.env.DIRECT_GAME_PRICE = '100000000000000001';
|
||||
|
||||
const config = new PaymentConfigManager({
|
||||
logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }
|
||||
}).getConfig();
|
||||
|
||||
expect(config.modes.direct.price).toBe(100000000000000001n);
|
||||
});
|
||||
|
||||
test('game mode snapshot and public transport preserve large atomic prices exactly', () => {
|
||||
process.env.DIRECT_GAME_PRICE = '100000000000000001';
|
||||
const manager = new PaymentConfigManager({
|
||||
logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }
|
||||
});
|
||||
const gmm = new GameModeManager(
|
||||
{ query: jest.fn() },
|
||||
{},
|
||||
{ CONSOLE_LOGGING: false },
|
||||
manager
|
||||
);
|
||||
|
||||
expect(gmm.singleGamePrice).toBe('100000000000000001');
|
||||
expect(gmm.getGameModeInfo().singleGamePrice).toBe('100000000000000001');
|
||||
expect(gmm.calculatePayout('PAID_SINGLE').amount).toBe('200000000000000002');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,16 @@
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
||||
|
||||
function legalAcknowledgement(gameModeManager) {
|
||||
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
|
||||
return {
|
||||
policyVersion: disclosure.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: disclosure.service.isTestNetwork === true
|
||||
};
|
||||
}
|
||||
|
||||
function makeMocks() {
|
||||
const user = { id: 'user-1', clientId: 'client1', payout_address: 'wow1address' };
|
||||
@@ -52,7 +64,9 @@ describe('PaymentHandlers basic', () => {
|
||||
const emitted = {};
|
||||
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
||||
ph = new PaymentHandlers(mocks);
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock1' });
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock1' }, {
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
expect(emitted.payment_created).toBeDefined();
|
||||
expect(emitted.payment_created.paymentId).toBe('pid123');
|
||||
// No paymentProviders registry on the mock -> legacy fallback to direct wallet monitoring.
|
||||
@@ -65,9 +79,190 @@ describe('PaymentHandlers basic', () => {
|
||||
mocks.gameModeManager.paymentProviders = { getProvider: jest.fn(() => gateway) };
|
||||
mocks.io.to = () => ({ emit: () => {} });
|
||||
ph = new PaymentHandlers(mocks);
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock2' });
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock2' }, {
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
expect(mocks.gameModeManager.paymentProviders.getProvider).toHaveBeenCalledWith('WOW');
|
||||
expect(gateway.startWatch).toHaveBeenCalled();
|
||||
expect(mocks.walletService.startPaymentMonitoring).not.toHaveBeenCalled(); // routed away from native
|
||||
});
|
||||
|
||||
test('direct payout invoice is not created before a required payout address exists', async () => {
|
||||
const mocks = makeMocks();
|
||||
const emitted = {};
|
||||
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
||||
mocks.gameModeManager.getOrCreateUser.mockResolvedValue({ id: 'user-1', payout_address: null });
|
||||
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
|
||||
mocks.gameModeManager.createPaymentRequest = jest.fn();
|
||||
ph = new PaymentHandlers(mocks);
|
||||
|
||||
await ph.handlePaymentRequest({ id: 'sock3' }, {
|
||||
type: 'single_game',
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
|
||||
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
|
||||
expect(emitted.payment_error).toEqual(expect.objectContaining({ code: 'PAYOUT_ADDRESS_REQUIRED' }));
|
||||
});
|
||||
|
||||
test.each(['single_game', 'credits_package'])(
|
||||
'payout-disabled %s invoice intake is not halted by an unknown/low wallet reserve',
|
||||
async (paymentType) => {
|
||||
const mocks = makeMocks();
|
||||
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(() => false);
|
||||
mocks.gameModeManager.alertService = {
|
||||
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'low' })
|
||||
};
|
||||
mocks.gameModeManager.createPaymentRequest = jest.fn(mocks.gameModeManager.createPaymentRequest);
|
||||
ph = new PaymentHandlers(mocks);
|
||||
|
||||
await ph.handlePaymentRequest({ id: `no-payout-${paymentType}` }, {
|
||||
type: paymentType,
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
|
||||
expect(mocks.gameModeManager.alertService.checkBalanceForGameStart).not.toHaveBeenCalled();
|
||||
expect(mocks.gameModeManager.createPaymentRequest).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
test('payout-bearing direct invoice remains fail closed under a critical reserve', async () => {
|
||||
const mocks = makeMocks();
|
||||
const emitted = {};
|
||||
mocks.io.to = () => ({ emit: (event, data) => { emitted[event] = data; } });
|
||||
mocks.gameModeManager.isPayoutEnabledForMode = jest.fn(mode => mode === 'PAID_SINGLE');
|
||||
mocks.gameModeManager.alertService = {
|
||||
checkBalanceForGameStart: jest.fn().mockResolvedValue({ halted: true, reason: 'critical' })
|
||||
};
|
||||
mocks.gameModeManager.createPaymentRequest = jest.fn();
|
||||
ph = new PaymentHandlers(mocks);
|
||||
|
||||
await ph.handlePaymentRequest({ id: 'payout-stagenet' }, {
|
||||
type: 'single_game',
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
|
||||
expect(mocks.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
|
||||
expect(emitted.balance_critical).toEqual(expect.objectContaining({ message: 'critical' }));
|
||||
});
|
||||
|
||||
test('product grant transaction failure emits review-required and never confirmation success', async () => {
|
||||
const mocks = makeMocks();
|
||||
const emitted = {};
|
||||
const socket = { id: 'product-failure', emit: (event, data) => { emitted[event] = data; } };
|
||||
mocks.gameModeManager.processProductPaymentConfirmation = jest.fn()
|
||||
.mockRejectedValue(new Error('receipt insert failed'));
|
||||
mocks.queueManager.removePlayer = jest.fn();
|
||||
ph = new PaymentHandlers(mocks);
|
||||
ph.socketPaymentMap.set(socket.id, {
|
||||
address: 'addr', paymentId: 'pid123', amount: 1000,
|
||||
paymentType: 'credits_package', package: { credits: 10 }
|
||||
});
|
||||
|
||||
await ph._handlePaymentStatus(socket, { id: 'pid123', address: 'addr', amount: 1000 }, null, {
|
||||
confirmed: true,
|
||||
complete: true,
|
||||
amount: 1000,
|
||||
confirmations: 2,
|
||||
receipts: []
|
||||
});
|
||||
|
||||
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
|
||||
code: 'PAYMENT_RECEIPT_REJECTED'
|
||||
}));
|
||||
expect(emitted.payment_confirmed).toBeUndefined();
|
||||
expect(mocks.queueManager.removePlayer).toHaveBeenCalledWith(socket.id);
|
||||
});
|
||||
|
||||
test('single-game payment queue preserves the consumed two-phase fairness proof', async () => {
|
||||
const mocks = makeMocks();
|
||||
let statusCallback;
|
||||
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
|
||||
statusCallback = callback;
|
||||
});
|
||||
const fairnessProof = {
|
||||
proofVersion: 2,
|
||||
offerId: 'offer-paid',
|
||||
serverSeed: 'a'.repeat(64),
|
||||
commitment: 'b'.repeat(64),
|
||||
clientSeed: 'c'.repeat(64)
|
||||
};
|
||||
const durableProof = {
|
||||
...fairnessProof,
|
||||
offerId: 'offer-persisted-on-invoice',
|
||||
offerIssuedAt: 1700000000000
|
||||
};
|
||||
mocks.gameModeManager.createPaymentRequest = jest.fn(async () => ({
|
||||
id: 'pid123',
|
||||
address: 'addr',
|
||||
amount: 1000,
|
||||
amountFormatted: '0.001',
|
||||
expiresAt: new Date(),
|
||||
fairnessProof: durableProof
|
||||
}));
|
||||
ph = new PaymentHandlers(mocks);
|
||||
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock4', emit: jest.fn() }, {
|
||||
fairnessProof,
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
|
||||
|
||||
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({
|
||||
paymentId: 'pid123',
|
||||
fairnessProof: durableProof
|
||||
}));
|
||||
});
|
||||
|
||||
test('production refuses to queue an unbound paid invoice', async () => {
|
||||
const mocks = makeMocks();
|
||||
let statusCallback;
|
||||
const emitted = {};
|
||||
mocks.gameModeManager._requiresPaidFairnessV2 = jest.fn(() => true);
|
||||
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
|
||||
statusCallback = callback;
|
||||
});
|
||||
const socket = {
|
||||
id: 'sock-unbound',
|
||||
emit: (event, data) => { emitted[event] = data; }
|
||||
};
|
||||
ph = new PaymentHandlers(mocks);
|
||||
|
||||
await ph.createAndShowPaymentRequest(socket, {
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
|
||||
|
||||
expect(mocks.queueManager.addPlayer).not.toHaveBeenCalled();
|
||||
expect(emitted.payment_review_required).toEqual(expect.objectContaining({
|
||||
code: 'PAYMENT_FAIRNESS_UNBOUND'
|
||||
}));
|
||||
});
|
||||
|
||||
test('fairness proof survives a payout-address prompt before monitoring starts', async () => {
|
||||
const mocks = makeMocks();
|
||||
let hasAddress = false;
|
||||
let statusCallback;
|
||||
mocks.gameModeManager.requiresPayoutAddressForMode = jest.fn(() => true);
|
||||
mocks.gameModeManager.getOrCreateUser.mockImplementation(async () => ({
|
||||
id: 'user-1',
|
||||
payout_address: hasAddress ? 'wow1address' : null
|
||||
}));
|
||||
mocks.walletService.startPaymentMonitoring.mockImplementation((_address, callback) => {
|
||||
statusCallback = callback;
|
||||
});
|
||||
const fairnessProof = { proofVersion: 2, offerId: 'address-offer', clientSeed: 'd'.repeat(64) };
|
||||
ph = new PaymentHandlers(mocks);
|
||||
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() }, {
|
||||
fairnessProof,
|
||||
legalAcknowledgement: legalAcknowledgement(mocks.gameModeManager)
|
||||
});
|
||||
expect(statusCallback).toBeUndefined();
|
||||
hasAddress = true;
|
||||
await ph.createAndShowPaymentRequest({ id: 'sock5', emit: jest.fn() });
|
||||
await statusCallback({ in_mempool: true, confirmed: false, amount: 1000 });
|
||||
|
||||
expect(mocks.queueManager.addPlayer).toHaveBeenCalledWith(expect.objectContaining({ fairnessProof }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const PaymentConfigManager = require('../src/config/paymentConfig');
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
|
||||
const ENV_KEYS = [
|
||||
'GAME_MODE',
|
||||
'PAYMENTS_ENABLED',
|
||||
'PAYMENT_MODES',
|
||||
'DIRECT_PAYMENT_ENABLED',
|
||||
'CREDITS_ENABLED',
|
||||
'FREE_PLAY_ENABLED',
|
||||
'PAYOUTS_ENABLED',
|
||||
'DIRECT_PAYOUTS_ENABLED',
|
||||
'CREDITS_PAYOUTS_ENABLED',
|
||||
'PAYOUT_MAX_PER_GAME',
|
||||
'CREDITS_PAYOUT_MAX'
|
||||
];
|
||||
|
||||
describe('production payment policy wiring', () => {
|
||||
let saved;
|
||||
|
||||
beforeEach(() => {
|
||||
saved = Object.fromEntries(ENV_KEYS.map(key => [key, process.env[key]]));
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('PAYOUTS_ENABLED=false remains authoritative with both per-mode flags true', () => {
|
||||
process.env.PAYMENTS_ENABLED = 'true';
|
||||
process.env.PAYMENT_MODES = 'direct,credits';
|
||||
process.env.PAYOUTS_ENABLED = 'false';
|
||||
process.env.DIRECT_PAYOUTS_ENABLED = 'true';
|
||||
process.env.CREDITS_PAYOUTS_ENABLED = 'true';
|
||||
process.env.FREE_PLAY_ENABLED = 'false';
|
||||
|
||||
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
|
||||
const manager = new GameModeManager(
|
||||
{ query: jest.fn(), withTransaction: jest.fn() },
|
||||
{ validateAddress: jest.fn() },
|
||||
{ CONSOLE_LOGGING: false },
|
||||
configManager
|
||||
);
|
||||
|
||||
expect(configManager.getConfig().payouts.enabled).toBe(false);
|
||||
expect(manager.payoutsEnabled).toBe(false);
|
||||
expect(manager.isPayoutEnabledForMode('PAID_SINGLE')).toBe(false);
|
||||
expect(manager.isPayoutEnabledForMode('PAID_CREDITS')).toBe(false);
|
||||
expect(manager.getGameModeInfo()).toEqual(expect.objectContaining({
|
||||
payoutsEnabled: false,
|
||||
directPayoutsEnabled: false,
|
||||
creditsPayoutsEnabled: false,
|
||||
freePlayEnabled: false
|
||||
}));
|
||||
});
|
||||
|
||||
test('PAYMENTS_ENABLED=false remains authoritative over every per-mode enable', () => {
|
||||
process.env.PAYMENTS_ENABLED = 'false';
|
||||
process.env.PAYMENT_MODES = 'direct,credits';
|
||||
process.env.DIRECT_PAYMENT_ENABLED = 'true';
|
||||
process.env.CREDITS_ENABLED = 'true';
|
||||
|
||||
const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig();
|
||||
|
||||
expect(config.paymentsEnabled).toBe(false);
|
||||
expect(config.modes.direct.enabled).toBe(false);
|
||||
expect(config.modes.credits.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('invoice creation refuses before database or wallet work when the master switch is off', async () => {
|
||||
process.env.PAYMENTS_ENABLED = 'false';
|
||||
process.env.PAYMENT_MODES = 'direct,credits';
|
||||
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
|
||||
const db = { query: jest.fn(), withTransaction: jest.fn() };
|
||||
const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() };
|
||||
const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager);
|
||||
|
||||
await expect(manager.createPaymentRequest('socket-disabled', 'single_game'))
|
||||
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
|
||||
await expect(manager.createPaymentRequest('socket-disabled', 'credits_package'))
|
||||
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
|
||||
expect(db.query).not.toHaveBeenCalled();
|
||||
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('per-product switches cannot be bypassed through the generic invoice method', async () => {
|
||||
process.env.PAYMENTS_ENABLED = 'true';
|
||||
process.env.PAYMENT_MODES = 'direct';
|
||||
process.env.DIRECT_PAYMENT_ENABLED = 'true';
|
||||
process.env.CREDITS_ENABLED = 'false';
|
||||
const configManager = new PaymentConfigManager({ logger: { warn() {}, info() {} } });
|
||||
const db = { query: jest.fn(), withTransaction: jest.fn() };
|
||||
const wallet = { createPaymentRequest: jest.fn(), validateAddress: jest.fn() };
|
||||
const manager = new GameModeManager(db, wallet, { CONSOLE_LOGGING: false }, configManager);
|
||||
|
||||
expect(manager.isPaymentIntakeEnabled('single_game')).toBe(true);
|
||||
expect(manager.isPaymentIntakeEnabled('credits_package')).toBe(false);
|
||||
await expect(manager.createPaymentRequest('socket-disabled', 'credits_package'))
|
||||
.rejects.toMatchObject({ code: 'PAYMENT_INTAKE_DISABLED' });
|
||||
expect(db.query).not.toHaveBeenCalled();
|
||||
expect(wallet.createPaymentRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('global per-game payout cap is an outer bound for direct and credit wins', () => {
|
||||
process.env.PAYMENT_MODES = 'direct,credits';
|
||||
process.env.PAYOUT_MAX_PER_GAME = '123456';
|
||||
|
||||
const config = new PaymentConfigManager({ logger: { warn() {}, info() {} } }).getConfig();
|
||||
|
||||
expect(config.payouts.rules.direct.maxPayout).toBe(123456n);
|
||||
expect(config.payouts.rules.credits.maxPayout).toBe(123456n);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,16 @@ describe('BTCPayProvider (Greenfield)', () => {
|
||||
expect(st.amount).toBe(150000000000); // 1.5 * 1e11
|
||||
});
|
||||
|
||||
test('gateway atomic amounts above Number.MAX_SAFE_INTEGER remain exact strings', async () => {
|
||||
const st = await mk('Settled', [{
|
||||
cryptoCode: 'XMR',
|
||||
totalPaid: '10000.000000000001',
|
||||
amount: '10000.000000000001'
|
||||
}], ['XMR']).getWalletStatus('inv1');
|
||||
expect(st.amount).toBe('10000000000000001');
|
||||
expect(st.required).toBe('10000000000000001');
|
||||
});
|
||||
|
||||
test('Processing -> in_mempool, not confirmed', async () => {
|
||||
const st = await mk('Processing', [{ cryptoCode: 'XMR', totalPaid: '0.5', amount: '1.5' }], ['XMR']).getWalletStatus('inv1');
|
||||
expect(st.in_mempool).toBe(true);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
|
||||
function chainReceipt(txHash, outputId, addressIndex, amount) {
|
||||
return {
|
||||
evidenceType: 'chain_output',
|
||||
evidenceId: `${txHash}:${outputId}`,
|
||||
providerId: 'native-monero',
|
||||
txHash,
|
||||
outputId,
|
||||
addressIndex,
|
||||
amount: String(amount),
|
||||
confirmed: true
|
||||
};
|
||||
}
|
||||
|
||||
function receiptDb(rows) {
|
||||
const payments = new Map(rows.map(row => [row.id, { provider_id: 'native-monero', status: 'pending', ...row }]));
|
||||
const receipts = [];
|
||||
const client = {
|
||||
query: jest.fn(async (sql, params = []) => {
|
||||
if (/SELECT id, status, expected_amount/i.test(sql)) {
|
||||
const payment = payments.get(params[0]);
|
||||
return { rows: payment ? [{ ...payment }] : [] };
|
||||
}
|
||||
if (/INSERT INTO payment_receipts/i.test(sql)) {
|
||||
const candidate = {
|
||||
payment_id: params[0], provider_id: params[1], evidence_type: params[2],
|
||||
evidence_id: params[3], tx_hash: params[4], output_id: params[5],
|
||||
address_index: params[6], amount: params[7]
|
||||
};
|
||||
const occupied = receipts.some(receipt => receipt.provider_id === candidate.provider_id
|
||||
&& receipt.evidence_id === candidate.evidence_id);
|
||||
if (!occupied) receipts.push(candidate);
|
||||
return { rows: [], rowCount: occupied ? 0 : 1 };
|
||||
}
|
||||
if (/COALESCE\(SUM\(amount\)/i.test(sql)) {
|
||||
const owned = receipts.filter(receipt => receipt.payment_id === params[0]
|
||||
&& receipt.provider_id === params[1]);
|
||||
const total = owned.reduce((sum, receipt) => sum + BigInt(receipt.amount), 0n);
|
||||
const hashes = owned.map(receipt => receipt.tx_hash).filter(Boolean).sort();
|
||||
return { rows: [{ total: total.toString(), primary_tx_hash: hashes[0] || null }] };
|
||||
}
|
||||
if (/UPDATE payments/i.test(sql)) {
|
||||
const payment = payments.get(params[0]);
|
||||
if (!payment || payment.status !== 'pending') return { rows: [] };
|
||||
payment.status = 'confirmed';
|
||||
payment.received_amount = params[1];
|
||||
payment.tx_hash = params[2];
|
||||
return { rows: [{ id: payment.id }] };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
})
|
||||
};
|
||||
return {
|
||||
withTransaction: jest.fn(async callback => callback(client)),
|
||||
payments,
|
||||
receipts
|
||||
};
|
||||
}
|
||||
|
||||
describe('durable incoming payment receipts', () => {
|
||||
test('one Monero transaction can confirm distinct invoice subaddresses, not the same output twice', async () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
const outputOne = '1'.repeat(64);
|
||||
const outputTwo = '2'.repeat(64);
|
||||
const db = receiptDb([
|
||||
{ id: 1, expected_amount: '100', address_index: 5 },
|
||||
{ id: 2, expected_amount: '100', address_index: 6 },
|
||||
{ id: 3, expected_amount: '100', address_index: 5 }
|
||||
]);
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
manager.db = db;
|
||||
|
||||
await expect(manager.confirmSingleGamePayment(1, '100', [chainReceipt(hash, outputOne, 5, 100)]))
|
||||
.resolves.toEqual({ updated: true });
|
||||
await expect(manager.confirmSingleGamePayment(2, '100', [chainReceipt(hash, outputTwo, 6, 100)]))
|
||||
.resolves.toEqual({ updated: true });
|
||||
await expect(manager.confirmSingleGamePayment(3, '100', [chainReceipt(hash, outputOne, 5, 100)]))
|
||||
.rejects.toThrow(/do not cover/i);
|
||||
|
||||
expect(db.payments.get(1).status).toBe('confirmed');
|
||||
expect(db.payments.get(2).status).toBe('confirmed');
|
||||
expect(db.payments.get(3).status).toBe('pending');
|
||||
expect(db.receipts).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('several confirmed top-ups can exactly cover one invoice', async () => {
|
||||
const db = receiptDb([{ id: 4, expected_amount: '100', address_index: 9 }]);
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
manager.db = db;
|
||||
const result = await manager.confirmSingleGamePayment(4, '100', [
|
||||
chainReceipt('b'.repeat(64), '3'.repeat(64), 9, 40),
|
||||
chainReceipt('c'.repeat(64), '4'.repeat(64), 9, 60)
|
||||
]);
|
||||
expect(result.updated).toBe(true);
|
||||
expect(db.receipts).toHaveLength(2);
|
||||
expect(db.payments.get(4).received_amount).toBe('100');
|
||||
});
|
||||
|
||||
test('distinct outputs in one transaction to one subaddress sum once despite replay', async () => {
|
||||
const hash = 'd'.repeat(64);
|
||||
const first = chainReceipt(hash, '5'.repeat(64), 12, 40);
|
||||
const second = chainReceipt(hash, '6'.repeat(64), 12, 60);
|
||||
const db = receiptDb([{ id: 5, expected_amount: '100', address_index: 12 }]);
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
manager.db = db;
|
||||
|
||||
const result = await manager.confirmSingleGamePayment(5, '140', [first, second, { ...first }]);
|
||||
|
||||
expect(result).toEqual({ updated: true });
|
||||
expect(db.receipts).toHaveLength(2);
|
||||
expect(db.payments.get(5).received_amount).toBe('100');
|
||||
});
|
||||
|
||||
test('migration removes txid-only uniqueness and installs the output-scoped forward guard', () => {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '../src/migrations/037_payment_receipt_evidence.sql'), 'utf8');
|
||||
expect(sql).toMatch(/DROP INDEX IF EXISTS idx_payments_tx_hash_unique/i);
|
||||
expect(sql).not.toMatch(/CREATE\s+UNIQUE\s+INDEX[\s\S]*?ON\s+payments\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
|
||||
expect(sql).toMatch(/ON\s+payment_receipts\s*\(\s*provider_id\s*,\s*evidence_id\s*\)/i);
|
||||
expect(sql).not.toMatch(/ON\s+payment_receipts\s*\(\s*tx_hash\s*,\s*address_index\s*\)/i);
|
||||
expect(sql).not.toMatch(/UNIQUE\s*\(subaddress\)/i);
|
||||
expect(sql).toMatch(/evidence_id\s*=\s*tx_hash\s*\|\|\s*':'\s*\|\|\s*output_id/i);
|
||||
expect(sql).toMatch(/OLD\.status IS DISTINCT FROM 'confirmed'/i);
|
||||
expect(sql).toMatch(/receipt_total < NEW\.expected_amount/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
const PaymentRefundService = require('../src/services/paymentRefundService');
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : { ...value };
|
||||
}
|
||||
|
||||
class Mutex {
|
||||
constructor() {
|
||||
this.locked = false;
|
||||
this.waiters = [];
|
||||
}
|
||||
|
||||
async acquire() {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
return () => this.release();
|
||||
}
|
||||
return new Promise(resolve => this.waiters.push(resolve));
|
||||
}
|
||||
|
||||
release() {
|
||||
const next = this.waiters.shift();
|
||||
if (next) {
|
||||
next(() => this.release());
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal transactional DB double implementing only the refund service's SQL. */
|
||||
class FakeRefundDb {
|
||||
constructor({ payment, user, grant = null, raceLot = null, packEntitlements = [], refund = null }) {
|
||||
this.payment = clone(payment);
|
||||
this.user = clone(user);
|
||||
this.grant = clone(grant);
|
||||
this.raceLot = clone(raceLot);
|
||||
this.packEntitlements = packEntitlements.map(clone);
|
||||
this.raceTransactions = [];
|
||||
this.refund = clone(refund);
|
||||
this.creditTransactions = [];
|
||||
this.paymentUpdateCount = 0;
|
||||
this.nextRefundId = refund?.id ? Number(refund.id) + 1 : 1;
|
||||
this.paymentLock = new Mutex();
|
||||
}
|
||||
|
||||
async getClient() {
|
||||
const db = this;
|
||||
let unlock = null;
|
||||
return {
|
||||
async query(text, params = []) {
|
||||
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
if (sql === 'begin') return { rows: [] };
|
||||
if (sql === 'commit' || sql === 'rollback') {
|
||||
unlock?.();
|
||||
unlock = null;
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('select p.*') && sql.includes('from payments p')) {
|
||||
unlock = await db.paymentLock.acquire();
|
||||
return { rows: db.payment && Number(db.payment.id) === Number(params[0]) ? [clone(db.payment)] : [] };
|
||||
}
|
||||
if (sql.includes('from payment_refunds') && sql.includes('for update')) {
|
||||
return { rows: db.refund ? [clone(db.refund)] : [] };
|
||||
}
|
||||
if (sql.includes('select id, credits, race_entries') && sql.includes('from users')) {
|
||||
return { rows: db.user && Number(db.user.id) === Number(params[0]) ? [clone(db.user)] : [] };
|
||||
}
|
||||
if (sql.includes('from payment_entitlement_grants') && sql.includes('for update')) {
|
||||
return { rows: db.grant ? [clone(db.grant)] : [] };
|
||||
}
|
||||
if (sql.startsWith('select id from games')) return { rows: [] };
|
||||
if (sql.startsWith('update users') && sql.includes('credits = credits -')) {
|
||||
db.user.credits = (BigInt(db.user.credits) - BigInt(params[0])).toString();
|
||||
db.user.total_credits_purchased = (BigInt(db.user.total_credits_purchased) - BigInt(params[1])).toString();
|
||||
db.user.race_entries -= Number(params[2]);
|
||||
db.user.premium_level = params[3];
|
||||
return { rows: [clone(db.user)] };
|
||||
}
|
||||
if (sql.startsWith('insert into credit_transactions')) {
|
||||
db.creditTransactions.push({
|
||||
user_id: params[0],
|
||||
amount: params[1],
|
||||
reason: params[2],
|
||||
balance_after: params[3],
|
||||
transaction_type: 'refund'
|
||||
});
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.startsWith('insert into race_entry_transactions')) {
|
||||
db.raceTransactions.push({ delta: params[1], payment_id: params[3] });
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.startsWith('update payment_entitlement_grants')) {
|
||||
if (sql.includes("status = 'needs_review'")) {
|
||||
db.grant.status = 'needs_review';
|
||||
db.grant.needs_review_at = new Date();
|
||||
} else {
|
||||
db.grant.status = 'reversed';
|
||||
db.grant.credits_reversed = params[1];
|
||||
db.grant.purchase_progress_reversed = params[2];
|
||||
db.grant.race_entries_reversed = params[3];
|
||||
db.grant.reversed_at = new Date();
|
||||
}
|
||||
return { rows: [clone(db.grant)], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('from race_entry_lots')) return { rows: db.raceLot ? [clone(db.raceLot)] : [] };
|
||||
if (sql.startsWith('update race_entry_lots')) {
|
||||
if (!db.raceLot || db.raceLot.refunded_at != null
|
||||
|| db.raceLot.remaining_entries !== db.raceLot.original_entries) return { rows: [], rowCount: 0 };
|
||||
db.raceLot.remaining_entries = 0;
|
||||
db.raceLot.refunded_at = new Date();
|
||||
return { rows: [{ id: db.raceLot.id }], rowCount: 1 };
|
||||
}
|
||||
if (sql.includes('from user_pack_entitlements') && sql.includes('for update')) {
|
||||
return { rows: db.packEntitlements.map(clone) };
|
||||
}
|
||||
if (sql.startsWith('delete from user_pack_entitlements')) {
|
||||
const wanted = new Set(params[1]);
|
||||
const removed = db.packEntitlements.filter(row => wanted.has(row.pack_id)
|
||||
&& String(row.metadata?.paymentId) === String(params[2]));
|
||||
db.packEntitlements = db.packEntitlements.filter(row => !removed.includes(row));
|
||||
return { rows: removed.map(row => ({ pack_id: row.pack_id })), rowCount: removed.length };
|
||||
}
|
||||
if (sql.startsWith('update payments')) {
|
||||
db.payment.status = 'refunded';
|
||||
db.paymentUpdateCount += 1;
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.startsWith('insert into payment_refunds')) {
|
||||
const needsReview = sql.includes("'needs_review'");
|
||||
db.refund = {
|
||||
id: db.nextRefundId++,
|
||||
payment_id: params[0],
|
||||
user_id: params[1],
|
||||
status: needsReview ? 'needs_review' : params[2],
|
||||
amount: needsReview ? params[2] : params[3],
|
||||
payout_address: needsReview ? params[3] : params[4],
|
||||
credits_deducted: needsReview ? '0' : params[5],
|
||||
purchase_progress_deducted: needsReview ? '0' : params[6],
|
||||
race_entries_deducted: needsReview ? 0 : params[7],
|
||||
packs_revoked: needsReview ? [] : JSON.parse(params[8]),
|
||||
premium_level_restored: needsReview ? null : params[9],
|
||||
reason: needsReview ? params[4] : params[10],
|
||||
tx_hash: null,
|
||||
error_message: needsReview ? params[5] : null,
|
||||
requested_at: needsReview || params[2] === 'requested' ? new Date() : null,
|
||||
processing_started_at: null,
|
||||
completed_at: null,
|
||||
needs_review_at: needsReview ? new Date() : null
|
||||
};
|
||||
return { rows: [clone(db.refund)] };
|
||||
}
|
||||
throw new Error(`Unexpected transaction SQL: ${sql}`);
|
||||
},
|
||||
release() {
|
||||
unlock?.();
|
||||
unlock = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async query(text, params = []) {
|
||||
const sql = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'processing'")) {
|
||||
if (!this.refund || Number(this.refund.payment_id) !== Number(params[0]) || this.refund.status !== 'requested') {
|
||||
return { rows: [] };
|
||||
}
|
||||
this.refund.status = 'processing';
|
||||
this.refund.processing_started_at = new Date();
|
||||
this.refund.error_message = null;
|
||||
return { rows: [clone(this.refund)] };
|
||||
}
|
||||
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'completed'")) {
|
||||
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
|
||||
return { rows: [] };
|
||||
}
|
||||
this.refund.status = 'completed';
|
||||
this.refund.tx_hash = params[1];
|
||||
this.refund.completed_at = new Date();
|
||||
this.refund.error_message = null;
|
||||
return { rows: [clone(this.refund)] };
|
||||
}
|
||||
if (sql.startsWith('update payment_refunds') && sql.includes("set status = 'needs_review'")) {
|
||||
if (!this.refund || Number(this.refund.id) !== Number(params[0]) || this.refund.status !== 'processing') {
|
||||
return { rows: [] };
|
||||
}
|
||||
this.refund.status = 'needs_review';
|
||||
this.refund.tx_hash = params[1] || this.refund.tx_hash;
|
||||
this.refund.error_message = params[2];
|
||||
this.refund.needs_review_at = new Date();
|
||||
return { rows: [clone(this.refund)] };
|
||||
}
|
||||
if (sql.startsWith('select *') && sql.includes('from payment_refunds')) {
|
||||
return { rows: this.refund && Number(this.refund.payment_id) === Number(params[0]) ? [clone(this.refund)] : [] };
|
||||
}
|
||||
throw new Error(`Unexpected pool SQL: ${sql}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
return new FakeRefundDb({
|
||||
payment: {
|
||||
id: 42,
|
||||
user_id: 7,
|
||||
expected_amount: '1000',
|
||||
received_amount: '900',
|
||||
credits_purchased: '6',
|
||||
status: 'confirmed',
|
||||
description: 'Credits purchase',
|
||||
...overrides.payment
|
||||
},
|
||||
user: {
|
||||
id: 7,
|
||||
credits: '6',
|
||||
race_entries: 0,
|
||||
total_credits_purchased: '6',
|
||||
premium_level: 'free',
|
||||
payout_address: '5'.repeat(95),
|
||||
...overrides.user
|
||||
},
|
||||
grant: overrides.grant === undefined ? {
|
||||
payment_id: 42,
|
||||
user_id: 7,
|
||||
source: 'product_confirmation',
|
||||
credits_granted: '6',
|
||||
purchase_progress_granted: '6',
|
||||
race_entries_granted: 0,
|
||||
packs_granted: [],
|
||||
premium_level_granted: null,
|
||||
premium_level_previous: null,
|
||||
status: 'active'
|
||||
} : overrides.grant,
|
||||
raceLot: overrides.raceLot || null,
|
||||
packEntitlements: overrides.packEntitlements || [],
|
||||
refund: overrides.refund || null
|
||||
});
|
||||
}
|
||||
|
||||
describe('PaymentRefundService', () => {
|
||||
test('the global payout gate refuses an automatic refund before any durable or wallet side effect', async () => {
|
||||
const db = fixture();
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({
|
||||
db,
|
||||
walletService,
|
||||
logger,
|
||||
isSendEnabled: () => false
|
||||
});
|
||||
|
||||
await expect(service.refundPayment({ paymentId: 42, sendFunds: true }))
|
||||
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.refund).toBeNull();
|
||||
expect(db.paymentUpdateCount).toBe(0);
|
||||
});
|
||||
|
||||
test('a kill switch raised after the refund claim prevents the wallet call and strands safely for review', async () => {
|
||||
const db = fixture();
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const isSendEnabled = jest.fn()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
const service = new PaymentRefundService({
|
||||
db,
|
||||
walletService,
|
||||
logger,
|
||||
isSendEnabled
|
||||
});
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(isSendEnabled).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const txHash = 'a'.repeat(64);
|
||||
const logger = { error: jest.fn() };
|
||||
|
||||
beforeEach(() => logger.error.mockClear());
|
||||
|
||||
test('concurrent transfer requests deduct and broadcast exactly once', async () => {
|
||||
const db = fixture();
|
||||
const walletService = {
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
|
||||
};
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const results = await Promise.all([
|
||||
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true }),
|
||||
service.refundPayment({ paymentId: 42, reason: 'duplicate request', sendFunds: true })
|
||||
]);
|
||||
|
||||
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
|
||||
expect(db.paymentUpdateCount).toBe(1);
|
||||
expect(db.payment.description).toBe('Credits purchase');
|
||||
expect(db.user.credits).toBe('0');
|
||||
expect(db.user.total_credits_purchased).toBe('0');
|
||||
expect(db.creditTransactions).toHaveLength(1);
|
||||
expect(db.creditTransactions[0].amount).toBe('-6');
|
||||
expect(db.refund.status).toBe('completed');
|
||||
expect(db.refund.amount).toBe('900');
|
||||
expect(db.refund.tx_hash).toBe(txHash);
|
||||
expect(results.filter(result => result.existing)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reverses exact purchase progress and a premium tier granted solely by the payment', async () => {
|
||||
const Entitlements = require('../src/multiplayer/entitlements');
|
||||
const db = fixture({
|
||||
user: {
|
||||
credits: '6',
|
||||
total_credits_purchased: '6',
|
||||
premium_level: 'supporter'
|
||||
},
|
||||
grant: {
|
||||
payment_id: 42,
|
||||
user_id: 7,
|
||||
source: 'product_confirmation',
|
||||
credits_granted: '6',
|
||||
purchase_progress_granted: '6',
|
||||
race_entries_granted: 0,
|
||||
packs_granted: [],
|
||||
premium_level_granted: 'supporter',
|
||||
premium_level_previous: 'free',
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
const before = Entitlements.snapshotForUser(db.user);
|
||||
expect(before.packs['generated-skins']).toBe(true);
|
||||
const service = new PaymentRefundService({ db, walletService: null, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: false });
|
||||
const after = Entitlements.snapshotForUser(db.user);
|
||||
|
||||
expect(result.refund.status).toBe('recorded');
|
||||
expect(db.user.credits).toBe('0');
|
||||
expect(db.user.total_credits_purchased).toBe('0');
|
||||
expect(db.user.premium_level).toBe('free');
|
||||
expect(after.packs['generated-skins']).toBe(false);
|
||||
expect(db.grant).toEqual(expect.objectContaining({
|
||||
status: 'reversed',
|
||||
credits_reversed: '6',
|
||||
purchase_progress_reversed: '6'
|
||||
}));
|
||||
expect(db.refund).toEqual(expect.objectContaining({
|
||||
credits_deducted: '6',
|
||||
purchase_progress_deducted: '6',
|
||||
premium_level_restored: 'free'
|
||||
}));
|
||||
});
|
||||
|
||||
test('changed purchase progress routes the whole refund to review without revoking benefits', async () => {
|
||||
const db = fixture({ user: { credits: '6', total_credits_purchased: '5' } });
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(result.refund.error_message).toContain('progression cannot be reversed safely');
|
||||
expect(db.user.credits).toBe('6');
|
||||
expect(db.user.total_credits_purchased).toBe('5');
|
||||
expect(db.payment.status).toBe('confirmed');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an unconsumed direct entry with unscoped purchase progress cannot auto-refund', async () => {
|
||||
const db = fixture({
|
||||
payment: { payment_type: 'single_game', credits_purchased: '0' },
|
||||
user: { credits: '0', total_credits_purchased: '1' },
|
||||
grant: null
|
||||
});
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(result.refund.error_message).toContain('purchase progression requires manual reconciliation');
|
||||
expect(db.user.total_credits_purchased).toBe('1');
|
||||
expect(db.payment.status).toBe('confirmed');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('record-only requests are durable, terminal, and idempotent', async () => {
|
||||
const db = fixture();
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
|
||||
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(first.refund.status).toBe('recorded');
|
||||
expect(second.refund.status).toBe('recorded');
|
||||
expect(second.existing).toBe(true);
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.paymentUpdateCount).toBe(1);
|
||||
expect(db.creditTransactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('ambiguous wallet failure moves to needs_review and is never retried', async () => {
|
||||
const db = fixture();
|
||||
const walletService = {
|
||||
processPayout: jest.fn().mockRejectedValue(new Error('timeout after transfer submission'))
|
||||
};
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const first = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
const second = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(first.refund.status).toBe('needs_review');
|
||||
expect(first.refund.error_message).toContain('timeout');
|
||||
expect(second.refund.status).toBe('needs_review');
|
||||
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
|
||||
expect(db.paymentUpdateCount).toBe(1);
|
||||
expect(db.creditTransactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('success without a valid transaction hash is treated as ambiguous', async () => {
|
||||
const db = fixture();
|
||||
const walletService = {
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true })
|
||||
};
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(result.refund.error_message).toContain('valid transaction hash');
|
||||
expect(walletService.processPayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('consumed purchased credits require review and no wallet transfer', async () => {
|
||||
const db = fixture({ user: { credits: '5' } });
|
||||
const walletService = {
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
|
||||
};
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(result.refund.error_message).toContain('already been consumed');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.user.credits).toBe('5');
|
||||
expect(db.payment.status).toBe('confirmed');
|
||||
expect(db.creditTransactions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('unused race tickets and payment-owned packs reverse exactly once', async () => {
|
||||
const db = fixture({
|
||||
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
|
||||
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
|
||||
grant: {
|
||||
payment_id: 42,
|
||||
user_id: 7,
|
||||
source: 'product_confirmation',
|
||||
credits_granted: '0',
|
||||
purchase_progress_granted: '0',
|
||||
race_entries_granted: 2,
|
||||
packs_granted: [{ id: 'kenney-3d-characters' }],
|
||||
premium_level_granted: null,
|
||||
premium_level_previous: null,
|
||||
status: 'active'
|
||||
},
|
||||
raceLot: { id: 8, original_entries: 2, remaining_entries: 2, refunded_at: null },
|
||||
packEntitlements: [{
|
||||
pack_id: 'kenney-3d-characters',
|
||||
source: 'product_purchase',
|
||||
metadata: { paymentId: 42 }
|
||||
}]
|
||||
});
|
||||
const service = new PaymentRefundService({ db, walletService: null, logger });
|
||||
|
||||
const first = await service.refundPayment({ paymentId: 42, sendFunds: false });
|
||||
const second = await service.refundPayment({ paymentId: 42, sendFunds: false });
|
||||
|
||||
expect(first.refund.status).toBe('recorded');
|
||||
expect(second.existing).toBe(true);
|
||||
expect(db.user.race_entries).toBe(0);
|
||||
expect(db.raceLot.remaining_entries).toBe(0);
|
||||
expect(db.raceLot.refunded_at).toBeTruthy();
|
||||
expect(db.packEntitlements).toHaveLength(0);
|
||||
expect(db.raceTransactions).toHaveLength(1);
|
||||
expect(db.grant.status).toBe('reversed');
|
||||
});
|
||||
|
||||
test('an escrowed race ticket makes the whole refund needs_review', async () => {
|
||||
const db = fixture({
|
||||
user: { credits: '0', total_credits_purchased: '0', race_entries: 2 },
|
||||
payment: { credits_purchased: '0', payment_type: 'cosmetic_pack' },
|
||||
grant: {
|
||||
payment_id: 42,
|
||||
user_id: 7,
|
||||
source: 'product_confirmation',
|
||||
credits_granted: '0',
|
||||
purchase_progress_granted: '0',
|
||||
race_entries_granted: 2,
|
||||
packs_granted: [],
|
||||
premium_level_granted: null,
|
||||
premium_level_previous: null,
|
||||
status: 'active'
|
||||
},
|
||||
raceLot: { id: 8, original_entries: 2, remaining_entries: 1, refunded_at: null }
|
||||
});
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(result.refund.error_message).toContain('consumed or escrowed');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.user.race_entries).toBe(2);
|
||||
expect(db.raceLot.remaining_entries).toBe(1);
|
||||
});
|
||||
|
||||
test('a pending unpaid invoice cannot deduct credits or send its expected amount', async () => {
|
||||
const db = fixture({
|
||||
payment: { status: 'pending', received_amount: '0' }
|
||||
});
|
||||
const walletService = {
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true, txHash })
|
||||
};
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('needs_review');
|
||||
expect(result.refund.amount).toBe('0');
|
||||
expect(result.refund.error_message).toContain('confirmed payment');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.user.credits).toBe('6');
|
||||
expect(db.creditTransactions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a stranded processing row requires review and is never resent', async () => {
|
||||
const db = fixture({
|
||||
payment: { status: 'refunded' },
|
||||
refund: {
|
||||
id: 11,
|
||||
payment_id: 42,
|
||||
user_id: 7,
|
||||
status: 'processing',
|
||||
amount: '900',
|
||||
payout_address: '5'.repeat(95),
|
||||
credits_deducted: '4',
|
||||
reason: 'Admin refund',
|
||||
tx_hash: null,
|
||||
error_message: null,
|
||||
requested_at: new Date(),
|
||||
processing_started_at: new Date()
|
||||
}
|
||||
});
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.refund.status).toBe('processing');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.paymentUpdateCount).toBe(0);
|
||||
expect(db.creditTransactions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a legacy refunded payment is imported without repeating unknown side effects', async () => {
|
||||
const db = fixture({
|
||||
payment: { status: 'refunded', received_amount: '900' }
|
||||
});
|
||||
const walletService = { processPayout: jest.fn() };
|
||||
const service = new PaymentRefundService({ db, walletService, logger });
|
||||
|
||||
const result = await service.refundPayment({ paymentId: 42, sendFunds: true });
|
||||
|
||||
expect(result.legacyImported).toBe(true);
|
||||
expect(result.existing).toBe(true);
|
||||
expect(result.refund.status).toBe('recorded');
|
||||
expect(result.refund.amount).toBe('900');
|
||||
expect(walletService.processPayout).not.toHaveBeenCalled();
|
||||
expect(db.paymentUpdateCount).toBe(0);
|
||||
expect(db.user.credits).toBe('6');
|
||||
expect(db.creditTransactions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,8 @@ describe('M1 — single payout ambiguous failure', () => {
|
||||
|
||||
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
|
||||
expect(reviewCall[1][2]).toBeNull(); // no valid transaction hash was observed
|
||||
expect(reviewCall[1][3]).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));
|
||||
@@ -88,7 +89,7 @@ describe('M1 — single payout ambiguous failure', () => {
|
||||
|
||||
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]));
|
||||
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$4/i.test(c[0]));
|
||||
expect(pendingCall).toBeDefined();
|
||||
expect(gmm.alertService.sendAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -124,25 +125,30 @@ describe('M3 — atomic single_game payment claim', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('M4 — completeGame never inserts an unattached payout', () => {
|
||||
test('refuses payout when the paying user cannot be resolved from the game row', async () => {
|
||||
describe('M4 — completeGame durably records every committed liability', () => {
|
||||
test('quarantines a liability when the paying identity cannot be resolved', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.withTransaction.mockImplementation(async (cb) => cb(db));
|
||||
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
|
||||
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
|
||||
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
|
||||
.mockResolvedValueOnce({ rows: [{ id: 88 }], rowCount: 1 }); // needs_review liability
|
||||
|
||||
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
||||
|
||||
expect(result.reason).toBe('Paying user not resolved');
|
||||
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 88, status: 'needs_review' }));
|
||||
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));
|
||||
expect(inserted).toBeDefined();
|
||||
expect(inserted[1]).toContain('solo_winner_identity_review');
|
||||
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_liability_needs_review', 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.withTransaction.mockImplementation(async (cb) => cb(db));
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
|
||||
@@ -164,6 +170,7 @@ describe('M5 — payout amount is clamped to configured bounds', () => {
|
||||
|
||||
test('skips a below-min payout entirely (no insert)', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
db.withTransaction.mockImplementation(async (cb) => cb(db));
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
const QueueManager = require('../src/network/queueManager');
|
||||
const PaymentHandlers = require('../src/network/paymentHandlers');
|
||||
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
||||
|
||||
function legalAcknowledgement(gameModeManager) {
|
||||
const disclosure = buildCommerceDisclosure(gameModeManager, process.env);
|
||||
return {
|
||||
policyVersion: disclosure.policyVersion,
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: disclosure.service.isTestNetwork === true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration-style tests for the payment confirmation flow.
|
||||
@@ -88,16 +100,18 @@ function buildHarness() {
|
||||
const fakeSocket = { id: socketId, emit: (event, data) => events.push({ event, data }) };
|
||||
|
||||
return {
|
||||
events, queueManager, paymentHandlers, fakeSocket,
|
||||
events, queueManager, paymentHandlers, fakeSocket, gameModeManager,
|
||||
getMonitorCallback: () => monitorCallback
|
||||
};
|
||||
}
|
||||
|
||||
describe('Payment flow integration: confirm-after-block immediate start', () => {
|
||||
test('unconfirmed at block tick then confirmed triggers immediate game', async () => {
|
||||
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
|
||||
const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness();
|
||||
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket, {
|
||||
legalAcknowledgement: legalAcknowledgement(gameModeManager)
|
||||
});
|
||||
const monitorCallback = getMonitorCallback();
|
||||
expect(monitorCallback).toBeDefined();
|
||||
|
||||
@@ -122,9 +136,11 @@ describe('Payment flow integration: confirm-after-block immediate start', () =>
|
||||
});
|
||||
|
||||
test('confirmed but UNDERPAID does not start a game or grant anything', async () => {
|
||||
const { events, queueManager, paymentHandlers, fakeSocket, getMonitorCallback } = buildHarness();
|
||||
const { events, queueManager, paymentHandlers, fakeSocket, gameModeManager, getMonitorCallback } = buildHarness();
|
||||
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket);
|
||||
await paymentHandlers.createAndShowPaymentRequest(fakeSocket, {
|
||||
legalAcknowledgement: legalAcknowledgement(gameModeManager)
|
||||
});
|
||||
const monitorCallback = getMonitorCallback();
|
||||
expect(monitorCallback).toBeDefined();
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gmm?.shutdown();
|
||||
});
|
||||
|
||||
describe('Credit Deduction - Negative Balance Prevention', () => {
|
||||
test('should fail gracefully when user has insufficient credits', async () => {
|
||||
const user = { id: 1, credits: 0 };
|
||||
@@ -242,9 +246,13 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
// Step 2: withTransaction — client.query calls:
|
||||
mockDb._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed' RETURNING id
|
||||
.mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users SET credits = credits + 15
|
||||
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] }) // snapshot product promise
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // mark payment confirmed
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] }) // lock user
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 15, total_credits_purchased: 15 }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // credit transaction
|
||||
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 }) // entitlement grant
|
||||
.mockResolvedValueOnce({ rows: [] }); // active pack grants
|
||||
|
||||
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 });
|
||||
|
||||
@@ -261,9 +269,13 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
// Step 2: withTransaction — client.query calls:
|
||||
mockDb._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
|
||||
.mockResolvedValueOnce({ rows: [{ credits: 30 }] }) // UPDATE users
|
||||
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 25, total_credits_purchased: 25 }] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
|
||||
|
||||
@@ -279,9 +291,13 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
|
||||
// Step 2: withTransaction — client.query calls:
|
||||
mockDb._mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] }) // UPDATE payments SET status='confirmed'
|
||||
.mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users
|
||||
.mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123, status: 'pending' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 123 }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, premium_level: 'free' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, credits: 10, total_credits_purchased: 10 }] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ payment_id: 123 }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null);
|
||||
|
||||
@@ -295,6 +311,9 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
gmm.gameMode = 'PAID_SINGLE';
|
||||
gmm.singleGamePrice = 100000000000;
|
||||
gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
||||
// Completion owns a real transaction; route every statement through the ordered
|
||||
// root query mock so these tests exercise completion + obligation atomically.
|
||||
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
|
||||
});
|
||||
|
||||
test('processes winning game with payout correctly', async () => {
|
||||
@@ -371,12 +390,15 @@ describe('Payment & Payout Comprehensive Tests', () => {
|
||||
mockDb.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 1, payout_address: null }] // No address
|
||||
});
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1001 }], rowCount: 1 }); // review liability
|
||||
|
||||
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.payout).toBeNull();
|
||||
expect(result.reason).toBe('No payout address');
|
||||
expect(result.payout).toEqual(expect.objectContaining({
|
||||
payoutId: 1001,
|
||||
status: 'needs_review'
|
||||
}));
|
||||
expect(mockWalletService.processPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -654,8 +676,13 @@ describe('Edge Cases and Error Handling', () => {
|
||||
gmm = new GameModeManager(mockDb, mockWalletService, createMockDebugManager(), createMockPaymentConfig());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gmm?.shutdown();
|
||||
});
|
||||
|
||||
test('handles payout insert failure gracefully', async () => {
|
||||
gmm.gameMode = 'PAID_SINGLE';
|
||||
mockDb.withTransaction.mockImplementation(async (callback) => callback(mockDb));
|
||||
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, payment_mode: 'direct' }] });
|
||||
mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update
|
||||
@@ -668,9 +695,11 @@ describe('Edge Cases and Error Handling', () => {
|
||||
|
||||
const result = await gmm.completeGame('socket1', 'game123', true, false, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.payout).toBeNull();
|
||||
expect(result.payoutError).toBeDefined();
|
||||
// The payout insert and game completion share one transaction. A failed obligation
|
||||
// therefore fails/rolls back completion instead of leaving a completed unpaid win.
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.payout).toBeUndefined();
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
test('handles database error during credits deduction', async () => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
const {
|
||||
PAYOUT_ADMISSION_LOCK_KEY,
|
||||
reservePayoutCapacity
|
||||
} = require('../src/services/payoutAdmissionService');
|
||||
|
||||
describe('serialized payout-liability admission', () => {
|
||||
const oldCritical = process.env.BALANCE_CRITICAL;
|
||||
beforeEach(() => { process.env.BALANCE_CRITICAL = '50'; });
|
||||
afterAll(() => {
|
||||
if (oldCritical === undefined) delete process.env.BALANCE_CRITICAL;
|
||||
else process.env.BALANCE_CRITICAL = oldCritical;
|
||||
});
|
||||
|
||||
function capacityClient() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
query: jest.fn(async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
if (/AS payout_rows/i.test(sql)) {
|
||||
return { rows: [{ payout_rows: '100', solo_commitments: '200', match_commitments: '300' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
test('requires unlocked balance for outstanding + new liability + reserve under one advisory lock', async () => {
|
||||
const client = capacityClient();
|
||||
const walletService = { isHealthy: true, getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '1050' }) };
|
||||
|
||||
const result = await reservePayoutCapacity({
|
||||
client,
|
||||
walletService,
|
||||
newLiability: '400',
|
||||
gameModeManager: null
|
||||
});
|
||||
|
||||
expect(result.required).toBe(1050n);
|
||||
expect(result.outstanding).toBe(600n);
|
||||
expect(client.calls[0]).toEqual(expect.objectContaining({
|
||||
params: [PAYOUT_ADMISSION_LOCK_KEY]
|
||||
}));
|
||||
expect(client.calls[0].sql).toMatch(/pg_advisory_xact_lock/i);
|
||||
});
|
||||
|
||||
test('fails closed one atomic unit below exact committed coverage', async () => {
|
||||
const client = capacityClient();
|
||||
await expect(reservePayoutCapacity({
|
||||
client,
|
||||
walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1049' }) },
|
||||
newLiability: '400'
|
||||
})).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_INSUFFICIENT' });
|
||||
});
|
||||
|
||||
test('counts only match liabilities that can still owe a payout', async () => {
|
||||
const client = capacityClient();
|
||||
|
||||
await reservePayoutCapacity({
|
||||
client,
|
||||
walletService: { isHealthy: true, getBalance: async () => ({ unlocked_balance: '1050' }) },
|
||||
newLiability: '400'
|
||||
});
|
||||
|
||||
const liabilityQuery = client.calls.find(({ sql }) => /AS payout_rows/i.test(sql)).sql;
|
||||
expect(liabilityQuery).toMatch(
|
||||
/FROM matches m[\s\S]*m\.status IN \('starting', 'active', 'finished'\)[\s\S]*NOT EXISTS/i
|
||||
);
|
||||
expect(liabilityQuery).not.toMatch(/m\.status IN \([^)]*'cancelled'/i);
|
||||
});
|
||||
|
||||
function creditManager({ payoutEligible, getBalance }) {
|
||||
const calls = [];
|
||||
const client = {
|
||||
query: jest.fn(async (sql) => {
|
||||
calls.push(sql);
|
||||
if (/AS payout_rows/i.test(sql)) {
|
||||
return { rows: [{ payout_rows: '0', solo_commitments: '0', match_commitments: '0' }] };
|
||||
}
|
||||
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
|
||||
return { rows: [{ credits: 1, total_credits_purchased: 2, payout_address: 'addr' }], rowCount: 1 };
|
||||
}
|
||||
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
|
||||
return { rows: [{ id: 9 }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
})
|
||||
};
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
manager.db = { withTransaction: async callback => callback(client) };
|
||||
manager.walletService = { isHealthy: true, getBalance };
|
||||
manager.creditsPerGameCost = 1;
|
||||
manager.requiresPayoutAddressForMode = () => payoutEligible;
|
||||
manager._computePayoutSnapshot = () => ({
|
||||
eligible: payoutEligible,
|
||||
escapeAmount: '200',
|
||||
treasureAmount: '300',
|
||||
escapeMult: 2,
|
||||
treasureMult: 3,
|
||||
terms: { version: 1, eligible: payoutEligible }
|
||||
});
|
||||
return { manager, calls };
|
||||
}
|
||||
|
||||
test('existing-credit payout game is denied before debit when balance is unknown', async () => {
|
||||
const { manager, calls } = creditManager({
|
||||
payoutEligible: true,
|
||||
getBalance: jest.fn().mockRejectedValue(new Error('wallet offline'))
|
||||
});
|
||||
|
||||
await expect(manager._processGameStartWithCredits(
|
||||
{ id: 1, credits: 2, payout_address: 'addr' }, 'sock', 'seed'
|
||||
)).rejects.toMatchObject({ code: 'PAYOUT_RESERVE_UNVERIFIED' });
|
||||
expect(calls.some(sql => /credits = credits -/i.test(sql))).toBe(false);
|
||||
});
|
||||
|
||||
test('payout-disabled prestige start stays open and never reads wallet balance', async () => {
|
||||
const getBalance = jest.fn().mockRejectedValue(new Error('wallet offline'));
|
||||
const { manager } = creditManager({ payoutEligible: false, getBalance });
|
||||
|
||||
const result = await manager._processGameStartWithCredits(
|
||||
{ id: 1, credits: 2, payout_address: null }, 'sock', 'seed'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(getBalance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('authoritative credit start rejects a missing required payout address before debit', async () => {
|
||||
const { manager, calls } = creditManager({ payoutEligible: true, getBalance: async () => ({ unlocked_balance: '1000' }) });
|
||||
|
||||
const result = await manager._processGameStartWithCredits(
|
||||
{ id: 1, credits: 2, payout_address: null }, 'sock', 'seed'
|
||||
);
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: false }));
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('authoritative direct-payment claim rechecks the locked user payout address', async () => {
|
||||
const calls = [];
|
||||
const client = {
|
||||
query: jest.fn(async (sql) => {
|
||||
calls.push(sql);
|
||||
if (/SELECT \* FROM payments/i.test(sql)) {
|
||||
return { rows: [{ id: 4, user_id: 1, status: 'confirmed' }], rowCount: 1 };
|
||||
}
|
||||
if (/SELECT id, payout_address[\s\S]*FROM users/i.test(sql)) {
|
||||
return { rows: [{ id: 1, payout_address: null }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
})
|
||||
};
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
manager.db = { withTransaction: async callback => callback(client) };
|
||||
manager._computePayoutSnapshot = () => ({
|
||||
eligible: false, escapeAmount: '0', treasureAmount: '0', terms: {}
|
||||
});
|
||||
manager._requiresPaidFairnessV2 = () => false;
|
||||
manager.requiresPayoutAddressForMode = () => true;
|
||||
|
||||
const result = await manager._processGameStartWithPayment(
|
||||
{ id: 1, payout_address: 'stale-client-cache' }, { id: 4 }, 'seed'
|
||||
);
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: false }));
|
||||
expect(calls.some(sql => /UPDATE games/i.test(sql))).toBe(false);
|
||||
expect(calls.some(sql => /UPDATE payments/i.test(sql))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('browser payout disclosures', () => {
|
||||
test('renders server-computed outcomes and never recreates multiplier amounts', () => {
|
||||
const paymentUi = fs.readFileSync(
|
||||
path.join(__dirname, '../html/js/ui/paymentUI.js'),
|
||||
'utf8'
|
||||
);
|
||||
const help = fs.readFileSync(
|
||||
path.join(__dirname, '../html/js/ui/helpModal.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(paymentUi).toContain('this.config?.payoutOutcomes?.[mode]');
|
||||
expect(paymentUi).toContain('configured cap applied');
|
||||
expect(paymentUi).not.toMatch(/parseFloat\([^\n]+\)\s*\*\s*[^\n]*multiplier/i);
|
||||
expect(help).not.toMatch(/Escape pays[\s\S]{0,80}×/);
|
||||
});
|
||||
});
|
||||
+116
-3
@@ -70,18 +70,131 @@ describe('PayoutRetryService.retryPayout', () => {
|
||||
.mockResolvedValue({ rows: [] });
|
||||
const wallet = {
|
||||
checkTransactionStatus: jest.fn(),
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'txNEW', fee: 10 })
|
||||
processPayout: jest.fn().mockResolvedValue({ success: true, txHash: 'd'.repeat(64), fee: 10 })
|
||||
};
|
||||
|
||||
await svcWith(db, wallet).retryPayout({ id: 7, user_id: 1, amount: '100', payout_address: 'wow1addr', status: 'failed', retry_count: 0 });
|
||||
await svcWith(db, wallet).retryPayout({
|
||||
id: 7,
|
||||
user_id: 1,
|
||||
amount: '100',
|
||||
payout_address: 'wow1addr',
|
||||
status: 'processing',
|
||||
retry_count: 0,
|
||||
last_error: 'not enough unlocked money to transfer'
|
||||
});
|
||||
|
||||
// tx_hash + completion happen in the SAME statement (atomic).
|
||||
const completion = findCall(client.query, /UPDATE payouts[\s\S]*tx_hash[\s\S]*status = 'completed'/i);
|
||||
expect(completion).toBeDefined();
|
||||
expect(completion[1]).toContain('txNEW');
|
||||
expect(completion[1]).toContain('d'.repeat(64));
|
||||
// No standalone pre-transaction tx_hash write on db.query.
|
||||
expect(findCall(db.query, /UPDATE payouts SET tx_hash = \$1, fee = \$2 WHERE id/i)).toBeUndefined();
|
||||
// Stats counted once.
|
||||
expect(findCall(client.query, /UPDATE users[\s\S]*total_amount_won/i)).toBeDefined();
|
||||
});
|
||||
|
||||
test('ambiguous failure without a tx hash is never sent again automatically', async () => {
|
||||
const { db } = makeHarness();
|
||||
const wallet = { checkTransactionStatus: jest.fn(), processPayout: jest.fn() };
|
||||
|
||||
await svcWith(db, wallet).retryPayout({
|
||||
id: 8,
|
||||
user_id: 1,
|
||||
amount: '100',
|
||||
payout_address: 'wow1addr',
|
||||
status: 'processing',
|
||||
retry_count: 1,
|
||||
last_error: 'RPC timeout after broadcast'
|
||||
});
|
||||
|
||||
expect(wallet.processPayout).not.toHaveBeenCalled();
|
||||
const review = findCall(db.query, /SET status = 'needs_review'/i);
|
||||
expect(review).toBeDefined();
|
||||
});
|
||||
|
||||
test('a recorded transaction hash that is temporarily not found is never resent', async () => {
|
||||
const { db } = makeHarness();
|
||||
const wallet = {
|
||||
checkTransactionStatus: jest.fn().mockResolvedValue({ exists: false }),
|
||||
processPayout: jest.fn()
|
||||
};
|
||||
|
||||
await svcWith(db, wallet).retryPayout({
|
||||
id: 11,
|
||||
user_id: 1,
|
||||
amount: '100',
|
||||
payout_address: 'wow1addr',
|
||||
tx_hash: 'possibly-broadcast-tx',
|
||||
status: 'processing',
|
||||
retry_count: 1
|
||||
});
|
||||
|
||||
expect(wallet.processPayout).not.toHaveBeenCalled();
|
||||
expect(findCall(db.query, /SET status = 'needs_review'/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PayoutRetryService.processRetries', () => {
|
||||
test('stale processing claims are quarantined and never selected for resend', async () => {
|
||||
const db = {
|
||||
query: jest.fn().mockResolvedValue({ rows: [{ id: 44 }], rowCount: 1 }),
|
||||
withTransaction: jest.fn(async (fn) => fn({
|
||||
query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 })
|
||||
}))
|
||||
};
|
||||
const wallet = { processPayout: jest.fn() };
|
||||
const service = new PayoutRetryService({
|
||||
db,
|
||||
walletService: wallet,
|
||||
staleProcessingMs: 60000,
|
||||
debugManager: { CONSOLE_LOGGING: false }
|
||||
});
|
||||
service.retryPayout = jest.fn();
|
||||
|
||||
await service.processRetries();
|
||||
|
||||
const recovery = findCall(db.query, /status = 'needs_review'[\s\S]*status = 'processing'/i);
|
||||
expect(recovery).toBeDefined();
|
||||
expect(service.retryPayout).not.toHaveBeenCalled();
|
||||
expect(wallet.processPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('claims failed rows as processing in the same transaction before dispatch', async () => {
|
||||
const candidate = {
|
||||
id: 9,
|
||||
user_id: 1,
|
||||
payout_address: 'wow1addr',
|
||||
amount: '100',
|
||||
status: 'processing',
|
||||
retry_count: 0,
|
||||
last_error: 'insufficient unlocked balance'
|
||||
};
|
||||
const client = { query: jest.fn() };
|
||||
client.query
|
||||
.mockResolvedValueOnce({ rows: [{ ...candidate, status: 'failed' }] })
|
||||
.mockResolvedValueOnce({ rows: [candidate] });
|
||||
const db = {
|
||||
query: jest.fn().mockResolvedValue({ rows: [] }),
|
||||
withTransaction: jest.fn(async (fn) => fn(client))
|
||||
};
|
||||
const service = svcWith(db, { processPayout: jest.fn() });
|
||||
service.retryPayout = jest.fn().mockResolvedValue();
|
||||
|
||||
await service.processRetries();
|
||||
|
||||
expect(findCall(client.query, /SET status = 'processing'/i)).toBeDefined();
|
||||
expect(service.retryPayout).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: 'processing' }));
|
||||
});
|
||||
|
||||
test('master predicate prevents both scan and dispatch', async () => {
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [] }), withTransaction: jest.fn() };
|
||||
const wallet = { processPayout: jest.fn() };
|
||||
const service = new PayoutRetryService({ db, walletService: wallet, isEnabled: () => false });
|
||||
|
||||
await service.processRetries();
|
||||
await service.retryPayout({ id: 10 });
|
||||
|
||||
expect(db.withTransaction).not.toHaveBeenCalled();
|
||||
expect(wallet.processPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
|
||||
const createMockDb = () => {
|
||||
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
|
||||
return {
|
||||
const db = {
|
||||
query: jest.fn().mockResolvedValue({ rows: [] }),
|
||||
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
|
||||
_mockClient: mockClient
|
||||
withTransaction: jest.fn()
|
||||
};
|
||||
db.withTransaction.mockImplementation(async (cb) => cb(db));
|
||||
return db;
|
||||
};
|
||||
|
||||
const createMockPaymentConfig = () => ({
|
||||
@@ -106,4 +106,75 @@ describe('Payout uses the snapshot recorded at game start', () => {
|
||||
const insertedPayout = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
||||
expect(insertedPayout).toBeUndefined();
|
||||
});
|
||||
|
||||
test('master payout switch prevents payout creation even when direct payouts are enabled', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
gmm.payoutsEnabled = false;
|
||||
gmm.directPayoutEnabled = true;
|
||||
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await gmm.completeGame('socket1', 'game-master-off', true, true, { moves: 10 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined();
|
||||
expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a start-time eligible liability survives a mid-game master kill switch', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
gmm.payoutsEnabled = false; // dispatch is paused after the game committed its terms
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
status: 'active',
|
||||
game_mode: 'PAID_SINGLE',
|
||||
payout_address: 'wow1addr',
|
||||
payout_eligible: true,
|
||||
payout_terms: {
|
||||
version: 1,
|
||||
eligible: true,
|
||||
escapeAmount: '222',
|
||||
treasureAmount: '333',
|
||||
escapeMultiplier: 2,
|
||||
treasureMultiplier: 3,
|
||||
minAmount: '1',
|
||||
maxAmount: '1000'
|
||||
}
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] })
|
||||
.mockResolvedValueOnce({ rows: [{ id: 901 }], rowCount: 1 });
|
||||
|
||||
const result = await gmm.completeGame('socket1', 'committed-game', true, false, {});
|
||||
|
||||
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 901, amount: '222' }));
|
||||
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeDefined();
|
||||
});
|
||||
|
||||
test('a start-time ineligible game never gains a payout after policy is enabled', async () => {
|
||||
const { gmm, db } = buildGmm();
|
||||
gmm.payoutsEnabled = true;
|
||||
gmm.directPayoutEnabled = true;
|
||||
db.query
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 2,
|
||||
user_id: 1,
|
||||
status: 'active',
|
||||
game_mode: 'PAID_SINGLE',
|
||||
payout_eligible: false,
|
||||
payout_terms: { version: 1, mode: 'PAID_SINGLE', eligible: false }
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const result = await gmm.completeGame('socket1', 'ineligible-game', true, true, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.payout).toBeNull();
|
||||
expect(db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const {
|
||||
normalizeLeaderboardPeriod,
|
||||
prestigeCutoff,
|
||||
queryPrestigeLeaderboard
|
||||
} = require('../src/network/prestigeLeaderboard');
|
||||
|
||||
describe('prestige leaderboard periods', () => {
|
||||
const now = new Date('2026-07-21T16:00:00.000Z');
|
||||
|
||||
test.each([
|
||||
['week', '2026-07-14T16:00:00.000Z'],
|
||||
['month', '2026-06-21T16:00:00.000Z'],
|
||||
['all', null],
|
||||
['unexpected', null]
|
||||
])('%s resolves to the exact cutoff', (period, expected) => {
|
||||
const cutoff = prestigeCutoff(period, now);
|
||||
expect(cutoff?.toISOString() || null).toBe(expected);
|
||||
});
|
||||
|
||||
test('unknown periods normalize to all', () => {
|
||||
expect(normalizeLeaderboardPeriod('YEAR')).toBe('all');
|
||||
});
|
||||
|
||||
test.each(['week', 'month', 'all'])('queries match rows with a strict boundary for %s', async (period) => {
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [] }) };
|
||||
await queryPrestigeLeaderboard(db, { period, limit: 17, now });
|
||||
|
||||
const [sql, params] = db.query.mock.calls[0];
|
||||
expect(sql).toContain("m.economy = 'credits_prestige'");
|
||||
expect(sql).toContain('me.placement = 1');
|
||||
expect(sql).toContain('m.winner_user_id = me.user_id');
|
||||
expect(sql).toContain("m.ruleset_id <> 'coop-escape'");
|
||||
expect(sql).not.toContain('me.escaped');
|
||||
expect(sql).toContain('m.ended_at > $1::timestamptz');
|
||||
expect(params[0]?.toISOString() || null).toBe(prestigeCutoff(period, now)?.toISOString() || null);
|
||||
expect(params[1]).toBe(17);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
CAPTURE_RULESET_IDS,
|
||||
DEFAULT_SEED,
|
||||
assertCaptureSafety,
|
||||
assertCaptureStateEnvironment,
|
||||
capturePageHtml,
|
||||
capturePresentation,
|
||||
captureResultSummary,
|
||||
captureStateHash,
|
||||
captureTraceHeader,
|
||||
createCaptureRoom,
|
||||
parseArgs,
|
||||
planBotMove,
|
||||
validateOptions
|
||||
} = require('../src/scripts/pvp-capture');
|
||||
|
||||
describe('deterministic PvP capture harness', () => {
|
||||
function options(extra = {}) {
|
||||
return validateOptions(parseArgs([
|
||||
'--confirm-local-free-only',
|
||||
'--no-video',
|
||||
...extra.args || []
|
||||
]));
|
||||
}
|
||||
|
||||
test('requires an explicit acknowledgement and can never run in production', () => {
|
||||
const safe = options();
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'development' }, { ...safe, confirmed: false }))
|
||||
.toThrow('--confirm-local-free-only');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'production' }, safe))
|
||||
.toThrow('NODE_ENV=development or NODE_ENV=test');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'development', PAYOUTS_ENABLED: 'true' }, safe))
|
||||
.toThrow('money-enabled environments');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'test', MATCH_CRYPTO_RACE_ENABLED: '1' }, safe))
|
||||
.toThrow('MATCH_CRYPTO_RACE_ENABLED');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'test', PAYMENTS_ENABLED: 'true' }, safe))
|
||||
.toThrow('PAYMENTS_ENABLED');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'test', CREDITS_PAYOUT_ENABLED: 'true' }, safe))
|
||||
.toThrow('CREDITS_PAYOUT_ENABLED');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'test', WALLET_RPC_URL: 'http://127.0.0.1:18083' }, safe))
|
||||
.toThrow('wallet-configured environments');
|
||||
expect(assertCaptureSafety({ NODE_ENV: 'test' }, safe)).toBe(true);
|
||||
});
|
||||
|
||||
test('refuses inherited gameplay tuning so a seed has one authoritative replay', () => {
|
||||
expect(() => assertCaptureStateEnvironment({ DIFFICULTY_PRESET: 'casino' }))
|
||||
.toThrow('inherited gameplay tuning: DIFFICULTY_PRESET');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'test', DUNGEON_WIDTH: '70' }, options()))
|
||||
.toThrow('inherited gameplay tuning: DUNGEON_WIDTH');
|
||||
expect(() => assertCaptureSafety({ NODE_ENV: 'development', MONSTER_SPEED: '2' }, options()))
|
||||
.toThrow('inherited gameplay tuning: MONSTER_SPEED');
|
||||
expect(assertCaptureStateEnvironment({})).toBe(true);
|
||||
});
|
||||
|
||||
test('accepts only local renderer/capture options and a fixed-width seed', () => {
|
||||
expect(parseArgs(['--help']).help).toBe(true);
|
||||
expect(() => validateOptions(parseArgs([
|
||||
'--confirm-local-free-only', '--mode', '3d'
|
||||
]))).toThrow('network-loaded renderers are forbidden');
|
||||
expect(() => validateOptions(parseArgs([
|
||||
'--confirm-local-free-only', '--seed', '1234'
|
||||
]))).toThrow('64 hexadecimal');
|
||||
expect(() => validateOptions(parseArgs([
|
||||
'--confirm-local-free-only', '--ruleset', 'crypto-race'
|
||||
]))).toThrow('--ruleset must be one of');
|
||||
expect(() => validateOptions(parseArgs([
|
||||
'--confirm-local-free-only', '--camera', 'wallet'
|
||||
]))).toThrow('--camera must be action or focus');
|
||||
expect(() => parseArgs(['--url', 'https://example.com'])).toThrow('Unknown option');
|
||||
});
|
||||
|
||||
test('creates every built-in multiplayer ruleset deterministically with no money state', () => {
|
||||
const config = options();
|
||||
const first = createCaptureRoom(config);
|
||||
const second = createCaptureRoom(config);
|
||||
|
||||
expect(config.seed).toBe(DEFAULT_SEED);
|
||||
expect(first.economy).toBe('free');
|
||||
expect(first.ruleset.id).toBe('race');
|
||||
expect(first.ruleset.economy.model).toBe('free');
|
||||
expect(first.entryFeeAtomic).toBe(0);
|
||||
expect(first.potAtomic).toBe(0);
|
||||
expect(first.houseFeeAtomic).toBe(0);
|
||||
expect(Array.from(first.occupants.keys())).toEqual(['bot-1', 'bot-2', 'bot-3', 'bot-4']);
|
||||
expect(first.seedHash).toBe(second.seedHash);
|
||||
expect(first.dungeon.map).toEqual(second.dungeon.map);
|
||||
expect(first.dungeon.exit).toEqual(second.dungeon.exit);
|
||||
expect(first.dungeon.treasure).toEqual(second.dungeon.treasure);
|
||||
|
||||
for (const rulesetId of CAPTURE_RULESET_IDS) {
|
||||
const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] }));
|
||||
expect(room.ruleset.id).toBe(rulesetId);
|
||||
expect(room.economy).toBe('free');
|
||||
expect(room.ruleset.economy.model).toBe('free');
|
||||
expect(room.entryFeeAtomic).toBe(0);
|
||||
expect(room.potAtomic).toBe(0);
|
||||
expect(room.houseFeeAtomic).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test.each(CAPTURE_RULESET_IDS)('%s uses deterministic bot inputs and authoritative state', rulesetId => {
|
||||
const config = options({ args: ['--ruleset', rulesetId, '--ticks', '30'] });
|
||||
const rooms = [createCaptureRoom(config), createCaptureRoom(config)];
|
||||
rooms.forEach(room => room.start());
|
||||
|
||||
for (let tick = 0; tick < 30; tick++) {
|
||||
for (const room of rooms) {
|
||||
const state = room.toGameState();
|
||||
for (const botId of room.occupants.keys()) {
|
||||
const move = planBotMove(botId, state);
|
||||
if (Math.abs(move.dx) + Math.abs(move.dy) === 1) {
|
||||
room.queueMove(botId, move.dx, move.dy);
|
||||
}
|
||||
}
|
||||
room.resolveTick();
|
||||
}
|
||||
expect(captureStateHash(rooms[0].toGameState()))
|
||||
.toBe(captureStateHash(rooms[1].toGameState()));
|
||||
}
|
||||
});
|
||||
|
||||
test('last-alive bots pursue a rival instead of following the race exit', () => {
|
||||
const move = planBotMove('bot-1', {
|
||||
tick: 1,
|
||||
ruleset: { id: 'last-alive' },
|
||||
visibleTiles: [["'1", "'1", "'1"]],
|
||||
exit: [0, 0],
|
||||
players: [
|
||||
{ id: 'bot-1', x: 0, y: 0, alive: true, finished: false },
|
||||
{ id: 'bot-2', x: 2, y: 0, alive: true, finished: false }
|
||||
]
|
||||
});
|
||||
expect(move).toEqual({ dx: 1, dy: 0 });
|
||||
});
|
||||
|
||||
test('presentation and final copy are ruleset-correct, including cooperative results', () => {
|
||||
expect(capturePresentation('last-alive')).toEqual(expect.objectContaining({
|
||||
title: 'LAST ALIVE', activeNoun: 'STANDING'
|
||||
}));
|
||||
expect(captureResultSummary({
|
||||
ruleset: { id: 'score-attack' }, winnerId: 'bot-2', tickCount: 55
|
||||
}, [{ id: 'bot-2', name: 'Nyx', score: 912 }])).toEqual(expect.objectContaining({
|
||||
headline: 'NYX TOPS THE BOARD', detail: expect.stringContaining('912 POINTS')
|
||||
}));
|
||||
expect(captureResultSummary({
|
||||
ruleset: { id: 'coop-escape' }, winnerId: 'bot-1', tickCount: 40
|
||||
}, [
|
||||
{ id: 'bot-1', escaped: true },
|
||||
{ id: 'bot-2', escaped: true }
|
||||
])).toEqual(expect.objectContaining({
|
||||
headline: 'TEAM ESCAPED TOGETHER', cooperative: true
|
||||
}));
|
||||
expect(captureResultSummary({
|
||||
ruleset: { id: 'last-alive' }, winnerId: 'bot-2', tickCount: 80
|
||||
}, [
|
||||
{ id: 'bot-1', name: 'Rook', alive: true, finished: false },
|
||||
{ id: 'bot-2', name: 'Nyx', alive: true, finished: false }
|
||||
])).toEqual(expect.objectContaining({ headline: 'NYX LEADS AT THE BELL', winnerStatus: 'LEADER' }));
|
||||
expect(captureResultSummary({
|
||||
ruleset: { id: 'race' }, winnerId: 'bot-1', tickCount: 10, endReason: 'capture_limit'
|
||||
}, [{ id: 'bot-1', name: 'Rook' }])).toEqual(expect.objectContaining({
|
||||
headline: 'ROOK LEADS AT THE BELL', winnerStatus: 'LEADER'
|
||||
}));
|
||||
expect(captureResultSummary({
|
||||
ruleset: { id: 'last-alive' }, winnerId: 'bot-1', tickCount: 10, endReason: 'all_dead'
|
||||
}, [{ id: 'bot-1', name: 'Rook', alive: false }])).toEqual(expect.objectContaining({
|
||||
headline: 'ROOK SURVIVED LONGEST', winnerStatus: 'LAST OUT'
|
||||
}));
|
||||
});
|
||||
|
||||
test('capture page uses local assets, a spectator focus camera, and no payment UI', () => {
|
||||
const html = capturePageHtml();
|
||||
expect(html).toContain('focusPlayerId:cameraFocus');
|
||||
expect(html).toContain("cameraMode=query.get('camera')||'action'");
|
||||
expect(html).toContain('id="roster"');
|
||||
expect(html).toContain('actionFocusUntil');
|
||||
expect(html).toContain("if(entity.kind==='player')entity.label=null");
|
||||
expect(html).toContain('controls:false');
|
||||
expect(html).toContain('/socket.io/socket.io.js');
|
||||
expect(html).not.toMatch(/https?:\/\/(?!127\.0\.0\.1)/);
|
||||
expect(html).not.toContain('crypto_race');
|
||||
expect(html).not.toContain('payment');
|
||||
});
|
||||
|
||||
test('parses the camera viewport as replay provenance', () => {
|
||||
const config = options({ args: ['--viewport', '1080x1920', '--camera', 'focus'] });
|
||||
const room = createCaptureRoom(config);
|
||||
const trace = captureTraceHeader(room, capturePresentation(room.ruleset), config);
|
||||
expect(config.viewport).toEqual({ width: 1080, height: 1920 });
|
||||
expect(config.camera).toBe('focus');
|
||||
expect(trace.camera).toEqual({
|
||||
mode: 'focus',
|
||||
focus: 'bot-1',
|
||||
renderer: 'tiles',
|
||||
viewport: { width: 1080, height: 1920 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
function loadMatchCopy() {
|
||||
const context = {};
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(
|
||||
fs.readFileSync(path.join(__dirname, '../html/js/ui/matchUiCopy.js'), 'utf8'),
|
||||
context
|
||||
);
|
||||
return context.MatchUiCopy;
|
||||
}
|
||||
|
||||
describe('multiplayer UI copy contract', () => {
|
||||
const copy = loadMatchCopy();
|
||||
|
||||
test.each([
|
||||
['race', 'racer', 'Reach the exit'],
|
||||
['last-alive', 'contender', 'Stay alive'],
|
||||
['score-attack', 'delver', 'highest score'],
|
||||
['coop-escape', 'teammate', 'Work together']
|
||||
])('%s exposes its own objective and participant language', (id, participant, objective) => {
|
||||
const mode = copy.normalize({ id });
|
||||
expect(mode.participant).toBe(participant);
|
||||
expect(mode.objective).toContain(objective);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['race', '🏆 You escaped first!'],
|
||||
['last-alive', '🏆 You are the last delver standing!'],
|
||||
['score-attack', '🏆 You set the high score!']
|
||||
])('%s renders a mode-specific competitive victory', (id, expected) => {
|
||||
const result = copy.finalResult({
|
||||
ruleset: { id },
|
||||
winnerId: 'me',
|
||||
players: [{ id: 'me', placement: 1, score: 812 }]
|
||||
}, 'me');
|
||||
expect(result.won).toBe(true);
|
||||
expect(result.text).toContain(expected);
|
||||
if (id === 'score-attack') expect(result.text).toContain('score 812');
|
||||
});
|
||||
|
||||
test('co-op reports one team outcome and never promotes placement #1 as an individual winner', () => {
|
||||
const result = copy.finalResult({
|
||||
ruleset: { id: 'coop-escape' },
|
||||
reason: 'all_escaped',
|
||||
winnerId: null,
|
||||
players: [
|
||||
{ id: 'me', placement: 2, escaped: true },
|
||||
{ id: 'ally', placement: 1, escaped: true }
|
||||
]
|
||||
}, 'me');
|
||||
|
||||
expect(result.won).toBe(true);
|
||||
expect(result.text).toContain('🤝 Team escaped!');
|
||||
expect(result.text).toContain('you reached safety');
|
||||
expect(result.text).not.toMatch(/placed|winner/i);
|
||||
});
|
||||
|
||||
test('live summaries use the active mode rather than race-only placement copy', () => {
|
||||
expect(copy.liveSummary({
|
||||
ruleset: { id: 'last-alive' },
|
||||
players: [{ id: 'me', alive: true }, { id: 'out', alive: false }]
|
||||
}, 'me')).toBe('1 contender still in');
|
||||
expect(copy.liveSummary({
|
||||
ruleset: { id: 'coop-escape' },
|
||||
players: [{ id: 'me', escaped: true, finished: true }, { id: 'ally', alive: true }]
|
||||
}, 'me')).toBe('1/2 safe · 1 still exploring');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public multiplayer controls and boards', () => {
|
||||
const matchHtml = fs.readFileSync(path.join(__dirname, '../html/match.html'), 'utf8');
|
||||
const tavernHtml = fs.readFileSync(path.join(__dirname, '../html/tavern.html'), 'utf8');
|
||||
const matchClient = fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8');
|
||||
const leaderboard = fs.readFileSync(path.join(__dirname, '../html/js/ui/leaderboard.js'), 'utf8');
|
||||
const designDoc = fs.readFileSync(path.join(__dirname, '../docs/TAVERN_AND_MULTIPLAYER.md'), 'utf8');
|
||||
|
||||
test('match and Tavern D-pads are named groups activated through native click', () => {
|
||||
expect(matchHtml).toMatch(/id="matchDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/);
|
||||
expect(tavernHtml).toMatch(/id="tavernDpad"[^>]*role="group"[^>]*aria-label="Tavern movement controls"/);
|
||||
expect(tavernHtml).toMatch(/id="spectatorDpad"[^>]*role="group"[^>]*aria-label="Match movement controls"/);
|
||||
expect(matchClient).toContain("b.addEventListener('click'");
|
||||
expect(tavernHtml).toContain("b.addEventListener('click'");
|
||||
});
|
||||
|
||||
test('Prestige is a distinct public board with an explicit non-mixing disclosure', () => {
|
||||
expect(leaderboard).toContain('data-board="prestige"');
|
||||
expect(leaderboard).toContain('Credit-entry competitive PvP scores only');
|
||||
expect(leaderboard).toContain('never mixed with Free or the Hall of Champions');
|
||||
});
|
||||
|
||||
test('operator docs require restart for mode/ruleset changes and do not promise future combat', () => {
|
||||
expect(designDoc).toContain('restart the service');
|
||||
expect(designDoc).toContain('does not hot-reload match modes or rulesets');
|
||||
expect(designDoc).not.toMatch(/PvP combat (?:remains|is planned)/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const QueueManager = require('../src/network/queueManager');
|
||||
|
||||
function buildManager() {
|
||||
const captured = [];
|
||||
const game = {
|
||||
id: 'game-id',
|
||||
dbId: 1,
|
||||
getState: () => ({}),
|
||||
getProofCommitment: () => ({ commitment: 'c'.repeat(64) })
|
||||
};
|
||||
const user = { id: 'socket-a', clientId: 'client-a' };
|
||||
const io = { to: jest.fn().mockReturnValue({ emit: jest.fn() }) };
|
||||
const manager = new QueueManager({
|
||||
debugManager: { getCurrentBlockHeight: () => 10 },
|
||||
broadcastManager: {},
|
||||
io,
|
||||
createGameForUser: jest.fn(async (_user, _type, options) => { captured.push(options); return game; }),
|
||||
getUserBySocket: () => user,
|
||||
activeGames: new Map(),
|
||||
gameModeManager: { processGameStart: jest.fn().mockResolvedValue({ success: true }) },
|
||||
consoleLogging: false
|
||||
});
|
||||
return { manager, captured, user };
|
||||
}
|
||||
|
||||
describe('all queue start paths preserve the consumed fairness proof', () => {
|
||||
const fairnessProof = { offerId: 'offer', serverSeed: 'a'.repeat(64), clientSeed: '01' };
|
||||
|
||||
test('next-block start', async () => {
|
||||
const { manager, captured } = buildManager();
|
||||
manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof });
|
||||
await manager.startGamesForWaiting(11);
|
||||
expect(captured[0].fairnessProof).toBe(fairnessProof);
|
||||
});
|
||||
|
||||
test('confirmed-payment immediate start', async () => {
|
||||
const { manager, captured } = buildManager();
|
||||
manager.addPlayer({ serverId: 'socket-a', clientId: 'client-a', userId: 1, fairnessProof });
|
||||
await manager.startGameImmediately('socket-a', 11);
|
||||
expect(captured[0].fairnessProof).toBe(fairnessProof);
|
||||
});
|
||||
|
||||
test('early entry start', async () => {
|
||||
const { manager, captured, user } = buildManager();
|
||||
await manager.startEarlyGame('socket-a', user, 11, { fairnessProof });
|
||||
expect(captured[0]).toEqual(expect.objectContaining({ earlyEntry: true, fairnessProof }));
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,19 @@
|
||||
|
||||
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', () => {
|
||||
@@ -61,3 +74,83 @@ describe('reconnect cannot bypass the limit (stable-id keyed)', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const sourceBuilder = path.join(__dirname, '..', 'scripts', 'deploy', 'build-release-artifact.sh');
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TZ: 'UTC' },
|
||||
stdio: options.stdio || ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
function write(root, relative, contents, mode = 0o644) {
|
||||
const target = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, contents, { mode });
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-test-'));
|
||||
write(root, 'scripts/deploy/build-release-artifact.sh', fs.readFileSync(sourceBuilder), 0o755);
|
||||
write(root, 'src/index.js', "'use strict';\n");
|
||||
write(root, 'src/package.json', JSON.stringify({
|
||||
name: 'fixture',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
start: 'node index.js',
|
||||
preflight: 'node scripts/preflight.js',
|
||||
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js',
|
||||
test: 'jest',
|
||||
'capture:pvp': 'node scripts/pvp-capture.js',
|
||||
'canary:stagenet': 'node scripts/stagenet-financial-canary.js',
|
||||
'db:create': 'node scripts/setup_db.js'
|
||||
}
|
||||
}) + '\n');
|
||||
write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n');
|
||||
write(root, 'src/scripts/preflight.js', "'use strict';\n");
|
||||
write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n");
|
||||
write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n");
|
||||
write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n");
|
||||
write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n");
|
||||
write(root, 'src/scripts/smoke.js', "throw new Error('source-only smoke helper');\n");
|
||||
write(root, 'src/jest.config.js', "module.exports = {};\n");
|
||||
write(root, 'src/sim/simulate.js', "throw new Error('source-only simulator');\n");
|
||||
write(root, 'src/.env.example', 'DB_PASSWORD=CHANGE_ME\n');
|
||||
write(root, 'src/.env.production.example', 'ADMIN_API_KEY=CHANGE_ME\n');
|
||||
write(root, 'html/index.html', '<!doctype html>\n');
|
||||
write(root, 'LICENSE', 'fixture license\n');
|
||||
write(root, '.env.example', 'DB_PASSWORD=CHANGE_ME\n');
|
||||
write(root, 'README.md', 'source documentation only\n');
|
||||
write(root, 'docs/internal.md', 'operator documentation only\n');
|
||||
write(root, 'test/runtime-exclusion.test.js', "throw new Error('must not ship');\n");
|
||||
write(root, 'scripts/deploy/example.service', '[Service]\nExecStart=/bin/false\n');
|
||||
write(root, 'scripts/deploy/migrate-monero-stagenet-wallet.sh', '#!/bin/sh\nexit 1\n', 0o755);
|
||||
write(root, '.gitignore', 'out*/\n');
|
||||
run('git', ['init', '-q'], { cwd: root });
|
||||
run('git', ['config', 'user.name', 'Release Test'], { cwd: root });
|
||||
run('git', ['config', 'user.email', 'release-test@example.invalid'], { cwd: root });
|
||||
run('git', ['add', '.'], { cwd: root });
|
||||
run('git', ['commit', '-qm', 'fixture'], { cwd: root });
|
||||
return root;
|
||||
}
|
||||
|
||||
function build(root, output, releaseId = 'candidate-1') {
|
||||
return run(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
|
||||
'--release-id', releaseId,
|
||||
'--output-dir', output
|
||||
], { cwd: root });
|
||||
}
|
||||
|
||||
function buildAsync(root, output, releaseId = 'candidate-1') {
|
||||
return new Promise(resolve => {
|
||||
const child = require('child_process').spawn(
|
||||
path.join(root, 'scripts/deploy/build-release-artifact.sh'),
|
||||
['--release-id', releaseId, '--output-dir', output],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env, TZ: 'UTC' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', chunk => { stdout += chunk; });
|
||||
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||
child.on('close', status => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('release artifact builder', () => {
|
||||
const roots = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('builds byte-identical, runtime-only artifacts with provenance markers', () => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
const first = path.join(root, 'out-a');
|
||||
const second = path.join(root, 'out-b');
|
||||
|
||||
const output = build(root, first);
|
||||
build(root, second);
|
||||
|
||||
const artifactA = path.join(first, 'candidate-1.tar.gz');
|
||||
const artifactB = path.join(second, 'candidate-1.tar.gz');
|
||||
expect(fs.readFileSync(artifactA).equals(fs.readFileSync(artifactB))).toBe(true);
|
||||
expect(output).toMatch(/^release_id=candidate-1$/m);
|
||||
expect(output).toMatch(/^commit=[0-9a-f]{40}$/m);
|
||||
expect(output).toMatch(/^source_sha256=[0-9a-f]{64}$/m);
|
||||
expect(output).toMatch(/^artifact_sha256=[0-9a-f]{64}$/m);
|
||||
|
||||
const members = run('tar', ['-tzf', artifactA]).trim().split('\n');
|
||||
expect(members).toContain('candidate-1/.release-source-sha256');
|
||||
expect(members).toContain('candidate-1/.release-commit');
|
||||
expect(members).toContain('candidate-1/.release-package-lock-sha256');
|
||||
expect(members).toContain('candidate-1/.release-files.sha256');
|
||||
expect(members).toContain('candidate-1/LICENSE');
|
||||
expect(members).toContain('candidate-1/src/index.js');
|
||||
expect(members).toContain('candidate-1/src/scripts/preflight.js');
|
||||
expect(members).toContain('candidate-1/src/scripts/migrate-disposable-clone.js');
|
||||
expect(members.some(member => member.includes('/.git/'))).toBe(false);
|
||||
expect(members.some(member => member.includes('/node_modules/'))).toBe(false);
|
||||
expect(members.some(member => /\/(?:\.env)$/.test(member))).toBe(false);
|
||||
expect(members.some(member => member.startsWith('candidate-1/docs/'))).toBe(false);
|
||||
expect(members.some(member => member.startsWith('candidate-1/test/'))).toBe(false);
|
||||
expect(members.some(member => member.startsWith('candidate-1/scripts/'))).toBe(false);
|
||||
expect(members.some(member => member.endsWith('.service'))).toBe(false);
|
||||
expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh');
|
||||
expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false);
|
||||
expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false);
|
||||
expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js');
|
||||
expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js');
|
||||
expect(members).not.toContain('candidate-1/src/scripts/setup_db.js');
|
||||
expect(members).not.toContain('candidate-1/src/scripts/smoke.js');
|
||||
expect(members).not.toContain('candidate-1/src/jest.config.js');
|
||||
expect(members.some(member => member.startsWith('candidate-1/src/sim/'))).toBe(false);
|
||||
|
||||
const allowedTopLevel = new Set([
|
||||
'.release-commit',
|
||||
'.release-files.sha256',
|
||||
'.release-id',
|
||||
'.release-package-lock-sha256',
|
||||
'.release-source-sha256',
|
||||
'LICENSE',
|
||||
'html',
|
||||
'src'
|
||||
]);
|
||||
const actualTopLevel = new Set(
|
||||
members
|
||||
.filter(member => member.startsWith('candidate-1/'))
|
||||
.map(member => member.slice('candidate-1/'.length).split('/')[0])
|
||||
.filter(Boolean)
|
||||
);
|
||||
expect(actualTopLevel).toEqual(allowedTopLevel);
|
||||
|
||||
run('sha256sum', ['--check', path.join(first, 'candidate-1.tar.gz.sha256')], { cwd: first });
|
||||
const extracted = path.join(root, 'extracted');
|
||||
fs.mkdirSync(extracted);
|
||||
run('tar', ['-xzf', artifactA, '-C', extracted]);
|
||||
run('sha256sum', ['--check', '.release-files.sha256'], {
|
||||
cwd: path.join(extracted, 'candidate-1')
|
||||
});
|
||||
const runtimePackage = JSON.parse(fs.readFileSync(
|
||||
path.join(extracted, 'candidate-1/src/package.json'), 'utf8'
|
||||
));
|
||||
expect(runtimePackage.scripts).toEqual({
|
||||
start: 'node index.js',
|
||||
preflight: 'node scripts/preflight.js',
|
||||
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js'
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['tracked modification', root => write(root, 'src/index.js', '// dirty\n')],
|
||||
['untracked file', root => write(root, 'untracked.txt', 'not reviewed\n')]
|
||||
])('refuses a dirty worktree: %s', (_label, dirty) => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
dirty(root);
|
||||
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('worktree is dirty');
|
||||
});
|
||||
|
||||
test('refuses tracked symlinks', () => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
fs.symlinkSync('index.js', path.join(root, 'src', 'linked.js'));
|
||||
run('git', ['add', '.'], { cwd: root });
|
||||
run('git', ['commit', '-qm', 'add symlink'], { cwd: root });
|
||||
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('tracked symlink, gitlink, or unsafe file mode');
|
||||
});
|
||||
|
||||
test('refuses tracked runtime environment files', () => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
write(root, '.env.production', 'SECRET=value\n');
|
||||
run('git', ['add', '-f', '.env.production'], { cwd: root });
|
||||
run('git', ['commit', '-qm', 'add forbidden env'], { cwd: root });
|
||||
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('tracked non-example environment file is forbidden');
|
||||
});
|
||||
|
||||
test('refuses unsafe release IDs and existing output', () => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
const output = path.join(root, 'out');
|
||||
let result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
|
||||
'--release-id', '../escape', '--output-dir', output
|
||||
], { cwd: root, encoding: 'utf8' });
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('release ID must be');
|
||||
|
||||
build(root, output, 'candidate-1');
|
||||
result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
|
||||
'--release-id', 'candidate-1', '--output-dir', output
|
||||
], { cwd: root, encoding: 'utf8' });
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('refusing to overwrite');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['missing', scripts => { delete scripts.preflight; }],
|
||||
['empty', scripts => { scripts.preflight = ' '; }],
|
||||
['non-string', scripts => { scripts.preflight = { command: 'node scripts/preflight.js' }; }]
|
||||
])('refuses a %s required runtime package script', (_label, mutate) => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
const manifestPath = path.join(root, 'src/package.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
mutate(manifest.scripts);
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
run('git', ['add', 'src/package.json'], { cwd: root });
|
||||
run('git', ['commit', '-qm', 'break required runtime script'], { cwd: root });
|
||||
|
||||
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('required runtime package script is absent or empty: preflight');
|
||||
});
|
||||
|
||||
test('a losing concurrent build cannot remove the winning same-ID publication', async () => {
|
||||
const root = fixture();
|
||||
roots.push(root);
|
||||
const output = path.join(root, 'out');
|
||||
|
||||
const results = await Promise.all([
|
||||
buildAsync(root, output),
|
||||
buildAsync(root, output),
|
||||
buildAsync(root, output),
|
||||
buildAsync(root, output)
|
||||
]);
|
||||
|
||||
expect(results.filter(result => result.status === 0)).toHaveLength(1);
|
||||
expect(results.filter(result => result.status !== 0)).toHaveLength(3);
|
||||
const artifact = path.join(output, 'candidate-1.tar.gz');
|
||||
const checksum = `${artifact}.sha256`;
|
||||
expect(fs.existsSync(artifact)).toBe(true);
|
||||
expect(fs.existsSync(checksum)).toBe(true);
|
||||
run('sha256sum', ['--check', checksum], { cwd: output });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function loadRenderModes(runtime, preloadedThree = null) {
|
||||
const window = {
|
||||
console,
|
||||
WOWNGEON_RUNTIME: runtime,
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
},
|
||||
RK: {
|
||||
TileRenderer: function TileRenderer() {},
|
||||
THREE: preloadedThree,
|
||||
},
|
||||
};
|
||||
window.window = window;
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../html/js/render/renderModes.js'),
|
||||
'utf8',
|
||||
);
|
||||
vm.runInNewContext(source, { window, console });
|
||||
return window.RK;
|
||||
}
|
||||
|
||||
function fakeElement(tagName) {
|
||||
const classes = new Set();
|
||||
const element = {
|
||||
tagName,
|
||||
children: [],
|
||||
className: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
attributes: {},
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
classList: {
|
||||
add(name) { classes.add(name); },
|
||||
remove(name) { classes.delete(name); },
|
||||
contains(name) { return classes.has(name); },
|
||||
},
|
||||
};
|
||||
Object.defineProperty(element, 'innerHTML', {
|
||||
set() { element.children = []; },
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
function loadPickerWithRuntimeDisabled() {
|
||||
const picker = fakeElement('div');
|
||||
const head = fakeElement('head');
|
||||
const document = {
|
||||
head,
|
||||
createElement: fakeElement,
|
||||
getElementById(id) {
|
||||
if (id === 'renderPicker') return picker;
|
||||
if (id === 'rp-style') return head.children.find(child => child.id === 'rp-style') || null;
|
||||
return null;
|
||||
},
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
console,
|
||||
document,
|
||||
WOWNGEON_RUNTIME: { rendererCdnEnabled: false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
RK: { TileRenderer: function TileRenderer() {} },
|
||||
};
|
||||
window.window = window;
|
||||
const context = { window, document, console };
|
||||
vm.runInNewContext(
|
||||
fs.readFileSync(path.join(__dirname, '../html/js/render/renderModes.js'), 'utf8'),
|
||||
context,
|
||||
);
|
||||
window.RK.entitlements.premium = true;
|
||||
window.RK.SPGame = { mode: () => 'tiles', setMode: () => true };
|
||||
vm.runInNewContext(
|
||||
fs.readFileSync(path.join(__dirname, '../html/js/ui/spRenderPicker.js'), 'utf8'),
|
||||
context,
|
||||
);
|
||||
window.RK.rebuildRenderPicker();
|
||||
return { picker, RK: window.RK };
|
||||
}
|
||||
|
||||
describe('optional renderer CDN policy', () => {
|
||||
test('external renderer loading defaults closed', () => {
|
||||
const RK = loadRenderModes(undefined);
|
||||
|
||||
expect(RK.externalRenderersEnabled()).toBe(false);
|
||||
expect(RK.isModeRuntimeAvailable('3d')).toBe(false);
|
||||
expect(RK.modeAvailability('3d')).toMatchObject({
|
||||
usable: false,
|
||||
runtimeAvailable: false,
|
||||
reason: 'runtime_unavailable',
|
||||
});
|
||||
expect(RK.canUseMode('3d')).toBe(false);
|
||||
|
||||
const callback = jest.fn();
|
||||
RK.ensureThree(callback);
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('a locally preloaded renderer remains usable with CDN loading disabled', () => {
|
||||
const localThree = {
|
||||
THREE: { REVISION: 'local-test' },
|
||||
GLTFLoader: function GLTFLoader() {},
|
||||
};
|
||||
const RK = loadRenderModes({ rendererCdnEnabled: false }, localThree);
|
||||
RK.entitlements.premium = true;
|
||||
|
||||
expect(RK.canUseMode('3d')).toBe(true);
|
||||
|
||||
const callback = jest.fn();
|
||||
RK.ensureThree(callback);
|
||||
expect(callback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('homepage picker labels policy-disabled 3D as unavailable, not purchasable', () => {
|
||||
const { picker } = loadPickerWithRuntimeDisabled();
|
||||
const modeButtons = picker.children[0].children;
|
||||
const threeButton = modeButtons.find(button => button.textContent.startsWith('3D'));
|
||||
|
||||
expect(threeButton.textContent).toContain('⛔');
|
||||
expect(threeButton.textContent).not.toContain('🔒');
|
||||
expect(threeButton.title).toMatch(/unavailable on this server/i);
|
||||
expect(threeButton.title).not.toMatch(/unlock with credits/i);
|
||||
expect(threeButton.attributes['aria-disabled']).toBe('true');
|
||||
});
|
||||
|
||||
test('server CSP trusts jsDelivr only when the renderer CDN flag is enabled', () => {
|
||||
const serverSource = fs.readFileSync(
|
||||
path.join(__dirname, '../src/index.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(serverSource).toContain('RENDERER_CDN_ENABLED');
|
||||
expect(serverSource).toMatch(/if \(rendererCdnEnabled\) \{[\s\S]*scriptSources\.push\('https:\/\/cdn\.jsdelivr\.net'\)/);
|
||||
expect(serverSource).not.toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net");
|
||||
});
|
||||
|
||||
test('browser pages use the pinned same-origin jQuery 3.7.1 asset', () => {
|
||||
const asset = fs.readFileSync(path.join(__dirname, '../html/js/lib/jquery-3.7.1.min.js'));
|
||||
const homepage = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8');
|
||||
const debugPage = fs.readFileSync(path.join(__dirname, '../html/debug.html'), 'utf8');
|
||||
|
||||
expect(asset.toString('utf8', 0, 100)).toContain('jQuery v3.7.1');
|
||||
expect(crypto.createHash('sha256').update(asset).digest('hex'))
|
||||
.toBe('3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260');
|
||||
expect(homepage).toContain('js/lib/jquery-3.7.1.min.js');
|
||||
expect(debugPage).toContain('js/lib/jquery-3.7.1.min.js');
|
||||
expect(homepage + debugPage).not.toContain('jquery-3.4.1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
const RpcService = require('../src/rpc/rpcService');
|
||||
|
||||
const { GENESIS_HASHES } = RpcService;
|
||||
|
||||
function daemonFixture({ cryptoType, network, height = 123, overrides = {} }) {
|
||||
const target = cryptoType === 'WOW' ? 300 : 120;
|
||||
return {
|
||||
count: { count: height },
|
||||
info: {
|
||||
status: 'OK',
|
||||
height,
|
||||
target,
|
||||
nettype: network,
|
||||
offline: false,
|
||||
untrusted: false,
|
||||
synchronized: true,
|
||||
...overrides
|
||||
},
|
||||
header: {
|
||||
status: 'OK',
|
||||
untrusted: false,
|
||||
block_header: { hash: GENESIS_HASHES[`${cryptoType}:${network}`] }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fixtureHttp(fixtures) {
|
||||
return {
|
||||
post: jest.fn(async (url, body) => {
|
||||
const endpoint = url.replace(/\/json_rpc$/, '');
|
||||
const fixture = fixtures[endpoint];
|
||||
if (fixture instanceof Error) throw fixture;
|
||||
if (!fixture) throw new Error('unknown endpoint');
|
||||
if (body.method === 'getblockcount') return { data: { result: fixture.count } };
|
||||
if (body.method === 'get_info') return { data: { result: fixture.info } };
|
||||
if (body.method === 'get_block_header_by_height') {
|
||||
return { data: { result: fixture.header } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${body.method}`);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
describe('RPCService pinned production identity', () => {
|
||||
test.each([
|
||||
['XMR', 'mainnet'],
|
||||
['XMR', 'stagenet'],
|
||||
['XMR', 'testnet'],
|
||||
['WOW', 'mainnet']
|
||||
])('accepts only the pinned %s %s genesis', async (cryptoType, network) => {
|
||||
const endpoint = 'http://daemon.test';
|
||||
const service = new RpcService({
|
||||
http: fixtureHttp({ [endpoint]: daemonFixture({ cryptoType, network }) }),
|
||||
primaryEndpoint: endpoint,
|
||||
fallbackEndpoint: endpoint,
|
||||
cryptoType,
|
||||
network,
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
const health = await service.healthCheck();
|
||||
|
||||
expect(health.healthy).toBe(true);
|
||||
expect(health.identity).toMatchObject({
|
||||
required: true,
|
||||
verified: true,
|
||||
expected: { cryptoType, network },
|
||||
actual: { cryptoType, network }
|
||||
});
|
||||
expect(JSON.stringify(health.identity)).not.toContain(GENESIS_HASHES[`${cryptoType}:${network}`]);
|
||||
});
|
||||
|
||||
test('cannot pass WOW identity with an XMR mainnet daemon', async () => {
|
||||
const endpoint = 'http://daemon.test';
|
||||
const service = new RpcService({
|
||||
http: fixtureHttp({ [endpoint]: daemonFixture({ cryptoType: 'XMR', network: 'mainnet' }) }),
|
||||
primaryEndpoint: endpoint,
|
||||
fallbackEndpoint: endpoint,
|
||||
cryptoType: 'WOW',
|
||||
network: 'mainnet',
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
const health = await service.healthCheck();
|
||||
|
||||
expect(health.healthy).toBe(false);
|
||||
expect(health.identity.verified).toBe(false);
|
||||
expect(health.primary.identityVerified).toBe(false);
|
||||
});
|
||||
|
||||
test('selects a correctly identified fallback when the primary is the wrong network', async () => {
|
||||
const primary = 'http://primary.test';
|
||||
const fallback = 'http://fallback.test';
|
||||
const http = fixtureHttp({
|
||||
[primary]: daemonFixture({ cryptoType: 'XMR', network: 'mainnet' }),
|
||||
[fallback]: daemonFixture({ cryptoType: 'XMR', network: 'stagenet' })
|
||||
});
|
||||
const service = new RpcService({
|
||||
http,
|
||||
primaryEndpoint: primary,
|
||||
fallbackEndpoint: fallback,
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
const height = await service.getBlockHeight();
|
||||
|
||||
expect(height).toBe(123);
|
||||
expect(service.currentEndpoint).toBe(fallback);
|
||||
expect(service.getIdentityStatus()).toMatchObject({
|
||||
verified: true,
|
||||
actual: { cryptoType: 'XMR', network: 'stagenet' }
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['zero height', { height: 0 }],
|
||||
['offline daemon', { offline: true }],
|
||||
['untrusted response', { untrusted: true }],
|
||||
['unsynchronized daemon', { synchronized: false }],
|
||||
['non-OK status', { status: 'BUSY' }]
|
||||
])('fails closed for %s', async (_label, overrides) => {
|
||||
const endpoint = 'http://daemon.test';
|
||||
const fixture = daemonFixture({ cryptoType: 'XMR', network: 'stagenet', overrides });
|
||||
if (overrides.height !== undefined) fixture.count.count = overrides.height;
|
||||
const service = new RpcService({
|
||||
http: fixtureHttp({ [endpoint]: fixture }),
|
||||
primaryEndpoint: endpoint,
|
||||
fallbackEndpoint: endpoint,
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
await expect(service.healthCheck()).resolves.toMatchObject({ healthy: false });
|
||||
});
|
||||
|
||||
test('treats an HTTP 200 JSON-RPC error as unhealthy', async () => {
|
||||
const endpoint = 'http://daemon.test';
|
||||
const http = { post: jest.fn().mockResolvedValue({
|
||||
data: { error: { code: -1, message: 'daemon error' } }
|
||||
}) };
|
||||
const service = new RpcService({
|
||||
http,
|
||||
primaryEndpoint: endpoint,
|
||||
fallbackEndpoint: endpoint,
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
await expect(service.healthCheck()).resolves.toMatchObject({ healthy: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const RpcService = require('../src/rpc/rpcService');
|
||||
|
||||
describe('RPCService production health', () => {
|
||||
const previous = {};
|
||||
|
||||
beforeAll(() => {
|
||||
previous.PRIMARY_RPC_ENDPOINT = process.env.PRIMARY_RPC_ENDPOINT;
|
||||
previous.FALLBACK_RPC_ENDPOINT = process.env.FALLBACK_RPC_ENDPOINT;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.PRIMARY_RPC_ENDPOINT = 'http://127.0.0.1:18081';
|
||||
process.env.FALLBACK_RPC_ENDPOINT = 'http://127.0.0.1:28081';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test('reports ready when the primary daemon responds', async () => {
|
||||
const http = { post: jest.fn().mockResolvedValue({ data: { result: { count: 123 } } }) };
|
||||
|
||||
const health = await new RpcService({ http }).healthCheck();
|
||||
|
||||
expect(health).toMatchObject({
|
||||
healthy: true,
|
||||
primary: { status: 'healthy' }
|
||||
});
|
||||
});
|
||||
|
||||
test('reports ready when only the fallback daemon responds', async () => {
|
||||
const http = { post: jest.fn()
|
||||
.mockRejectedValueOnce(new Error('primary offline'))
|
||||
.mockResolvedValueOnce({ data: { result: { count: 123 } } }) };
|
||||
|
||||
const health = await new RpcService({ http }).healthCheck();
|
||||
|
||||
expect(health).toMatchObject({
|
||||
healthy: true,
|
||||
primary: { status: 'unhealthy' },
|
||||
fallback: { status: 'healthy' }
|
||||
});
|
||||
});
|
||||
|
||||
test('reports not ready when every daemon endpoint is offline', async () => {
|
||||
const http = { post: jest.fn().mockRejectedValue(new Error('offline')) };
|
||||
|
||||
const health = await new RpcService({ http }).healthCheck();
|
||||
|
||||
expect(health.healthy).toBe(false);
|
||||
});
|
||||
|
||||
test('redacts credentials from endpoint labels', () => {
|
||||
expect(RpcService.endpointLabel('https://rpc-user:rpc-secret@example.test/json_rpc'))
|
||||
.toBe('https://example.test/json_rpc');
|
||||
});
|
||||
|
||||
test('strict block count never substitutes the cached UI height', async () => {
|
||||
const service = new RpcService({
|
||||
http: { post: jest.fn().mockRejectedValue(new Error('offline')) }
|
||||
});
|
||||
service.lastBlockHeight = 777;
|
||||
|
||||
await expect(service.getBlockCountStrict()).rejects.toThrow();
|
||||
await expect(service.getBlockHeight()).resolves.toBe(777);
|
||||
});
|
||||
});
|
||||
+130
-2
@@ -59,6 +59,15 @@ function findAdjacentWalkable(room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function stageSequentialExit(room) {
|
||||
room.dungeon.exit = [2, 2];
|
||||
room.walkable[2][1] = true;
|
||||
room.walkable[2][2] = true;
|
||||
room.walkable[3][2] = true;
|
||||
room.occupants.get('a').moveTo(1, 2);
|
||||
room.occupants.get('b').moveTo(2, 3);
|
||||
}
|
||||
|
||||
describe('MatchRoom drives new rulesets', () => {
|
||||
test('last-alive PvP: stepping onto a rival kills them and ends the match with the survivor as winner', () => {
|
||||
const room = new MatchRoom({
|
||||
@@ -87,6 +96,89 @@ describe('MatchRoom drives new rulesets', () => {
|
||||
expect(room.playerStates.get('b').placement).toBe(2);
|
||||
});
|
||||
|
||||
test('same-tick PvP is independent of packet order and eliminated actors never retaliate', () => {
|
||||
const run = (submissionOrder) => {
|
||||
const room = new MatchRoom({
|
||||
ruleset: { id: 'ordered-pvp', winCondition: { type: WIN.LAST_ALIVE }, entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 } },
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 }, c: { userId: 3 } },
|
||||
seed: SEED
|
||||
});
|
||||
room.start();
|
||||
|
||||
const ids = ['a', 'b', 'c'].sort((left, right) =>
|
||||
room._tickActionPriority(left, room.tickCount + 1)
|
||||
.localeCompare(room._tickActionPriority(right, room.tickCount + 1))
|
||||
);
|
||||
const [attacker, victim, downstream] = ids;
|
||||
room.occupants.get(attacker).moveTo(2, 2);
|
||||
room.occupants.get(victim).moveTo(3, 2);
|
||||
room.occupants.get(downstream).moveTo(4, 2);
|
||||
room.walkable[2][2] = true;
|
||||
room.walkable[2][3] = true;
|
||||
room.walkable[2][4] = true;
|
||||
|
||||
const moves = new Map([
|
||||
[attacker, [1, 0]], // kills victim first by committed tick priority
|
||||
[victim, [1, 0]] // would kill downstream if post-mortem actions ran
|
||||
]);
|
||||
for (const id of submissionOrder(ids)) room.queueMove(id, ...moves.get(id));
|
||||
const result = room.resolveTick();
|
||||
return {
|
||||
attacker,
|
||||
victim,
|
||||
downstream,
|
||||
events: result.events,
|
||||
status: room.status,
|
||||
winnerId: room.winnerId,
|
||||
players: Array.from(room.playerStates.entries()).map(([id, state]) => [id, {
|
||||
alive: state.alive,
|
||||
killedBy: state.killedBy,
|
||||
moves: state.moves,
|
||||
placement: state.placement
|
||||
}])
|
||||
};
|
||||
};
|
||||
|
||||
const forward = run(ids => [ids[0], ids[1]]);
|
||||
const reverse = run(ids => [ids[1], ids[0]]);
|
||||
expect(reverse).toEqual(forward);
|
||||
expect(forward.players.find(([id]) => id === forward.victim)[1].alive).toBe(false);
|
||||
expect(forward.players.find(([id]) => id === forward.downstream)[1].alive).toBe(true);
|
||||
expect(forward.events).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'move_failed', id: forward.victim, reason: 'eliminated_before_action'
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
test('last-alive corpses remain visible but do not trap surviving players', () => {
|
||||
const room = new MatchRoom({
|
||||
ruleset: {
|
||||
id: 'corpse-pass-through', winCondition: { type: WIN.LAST_ALIVE },
|
||||
entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 }
|
||||
},
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 }, c: { userId: 3 } },
|
||||
seed: SEED
|
||||
});
|
||||
room.start();
|
||||
room.walkable[2][1] = true;
|
||||
room.walkable[2][2] = true;
|
||||
room.walkable[2][3] = true;
|
||||
room.occupants.get('a').moveTo(1, 2);
|
||||
room.occupants.get('b').moveTo(2, 2);
|
||||
room.occupants.get('c').moveTo(3, 2);
|
||||
room._killPlayer('b', 'monster');
|
||||
|
||||
room.queueMove('a', 1, 0);
|
||||
const result = room.resolveTick();
|
||||
|
||||
expect(room.occupants.get('b')).toBeDefined();
|
||||
expect(room.occupants.get('a')).toEqual(expect.objectContaining({ x: 2, y: 2 }));
|
||||
expect(result.events).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'move', id: 'a' })
|
||||
]));
|
||||
});
|
||||
|
||||
test('high-score: reaching the exit does NOT instantly win — the match keeps going', () => {
|
||||
const room = new MatchRoom({
|
||||
ruleset: { id: 'test-score', winCondition: { type: WIN.HIGH_SCORE }, entities: { monster: false }, players: { min: 1, max: 4 } },
|
||||
@@ -94,14 +186,50 @@ describe('MatchRoom drives new rulesets', () => {
|
||||
seed: SEED
|
||||
});
|
||||
room.start();
|
||||
const exit = room.dungeon.exit;
|
||||
room.occupants.get('a').moveTo(exit[0], exit[1]);
|
||||
stageSequentialExit(room);
|
||||
room.queueMove('a', 1, 0);
|
||||
const res = room.resolveTick();
|
||||
|
||||
expect(room.playerStates.get('a').escaped).toBe(true);
|
||||
expect(room.status).toBe('active'); // b is still in play — no instant win
|
||||
expect(room.winnerId).toBeNull();
|
||||
expect(res.finished).toBe(false);
|
||||
|
||||
room.queueMove('b', 0, -1);
|
||||
const second = room.resolveTick();
|
||||
expect(room.playerStates.get('b').escaped).toBe(true);
|
||||
expect(room.occupants.get('b')).toEqual(expect.objectContaining({ x: 2, y: 2 }));
|
||||
expect(second.events).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'player_exit', id: 'b' })
|
||||
]));
|
||||
});
|
||||
|
||||
test('co-op success is collective and never invents an individual winner', () => {
|
||||
const room = new MatchRoom({
|
||||
ruleset: {
|
||||
id: 'test-coop', mode: 'coop',
|
||||
winCondition: { type: WIN.ALL_ESCAPE },
|
||||
entities: { monster: false }, players: { min: 2, max: 2 }
|
||||
},
|
||||
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
||||
seed: SEED
|
||||
});
|
||||
room.start();
|
||||
stageSequentialExit(room);
|
||||
room.queueMove('a', 1, 0);
|
||||
room.resolveTick();
|
||||
expect(room.status).toBe('active');
|
||||
|
||||
room.queueMove('b', 0, -1);
|
||||
const second = room.resolveTick();
|
||||
expect(room.endReason).toBe('all_escaped');
|
||||
expect(second.events).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'player_exit', id: 'b' })
|
||||
]));
|
||||
|
||||
room.finalize();
|
||||
expect(room.winnerId).toBeNull();
|
||||
expect([...room.playerStates.values()].map(s => s.placement).sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('race (default) still ends the moment someone escapes', () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const { isPayoutProcessingEnabled, isWalletRequired } = require('../src/config/runtimePolicy');
|
||||
|
||||
const baseConfig = () => ({
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
direct: { enabled: true },
|
||||
credits: { enabled: true }
|
||||
}
|
||||
},
|
||||
modes: {
|
||||
direct: { enabled: true },
|
||||
credits: { enabled: true }
|
||||
}
|
||||
});
|
||||
|
||||
const baseManager = () => ({
|
||||
payoutsEnabled: true,
|
||||
directPayoutEnabled: true,
|
||||
creditsPayoutEnabled: true
|
||||
});
|
||||
|
||||
describe('runtime payout worker policy', () => {
|
||||
test('master config switch suppresses every per-mode payout worker', () => {
|
||||
const config = baseConfig();
|
||||
config.payouts.enabled = false;
|
||||
|
||||
expect(isPayoutProcessingEnabled(config, baseManager())).toBe(false);
|
||||
});
|
||||
|
||||
test('manager emergency switch also suppresses workers after hot reload', () => {
|
||||
const manager = baseManager();
|
||||
manager.payoutsEnabled = false;
|
||||
|
||||
expect(isPayoutProcessingEnabled(baseConfig(), manager)).toBe(false);
|
||||
});
|
||||
|
||||
test('disabled modes cannot accidentally activate a worker through stale payout flags', () => {
|
||||
const config = baseConfig();
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = false;
|
||||
|
||||
expect(isPayoutProcessingEnabled(config, baseManager())).toBe(false);
|
||||
});
|
||||
|
||||
test('one explicitly active policy is enough to process its queued payouts', () => {
|
||||
const config = baseConfig();
|
||||
config.modes.direct.enabled = false;
|
||||
const manager = baseManager();
|
||||
manager.directPayoutEnabled = false;
|
||||
|
||||
expect(isPayoutProcessingEnabled(config, manager)).toBe(true);
|
||||
});
|
||||
|
||||
test('accepted durable liabilities keep the generic worker alive after admission is disabled', () => {
|
||||
const config = baseConfig();
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = false;
|
||||
const manager = baseManager();
|
||||
manager.directPayoutEnabled = false;
|
||||
manager.creditsPayoutEnabled = false;
|
||||
|
||||
expect(isPayoutProcessingEnabled(config, manager)).toBe(false);
|
||||
expect(isPayoutProcessingEnabled(config, manager, {
|
||||
settleAcceptedLiabilities: true
|
||||
})).toBe(true);
|
||||
|
||||
config.payouts.enabled = false;
|
||||
expect(isPayoutProcessingEnabled(config, manager, {
|
||||
settleAcceptedLiabilities: true
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('wallet remains mandatory for payout-only accepted-liability settlement', () => {
|
||||
const config = baseConfig();
|
||||
config.paymentsEnabled = false;
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = false;
|
||||
const manager = baseManager();
|
||||
manager.directPayoutEnabled = false;
|
||||
manager.creditsPayoutEnabled = false;
|
||||
|
||||
expect(isWalletRequired(config, manager)).toBe(false);
|
||||
expect(isWalletRequired(config, manager, {
|
||||
settleAcceptedLiabilities: true
|
||||
})).toBe(true);
|
||||
|
||||
config.payouts.enabled = false;
|
||||
expect(isWalletRequired(config, manager, {
|
||||
settleAcceptedLiabilities: true
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
+74
-1
@@ -47,6 +47,16 @@ function createMockDb() {
|
||||
users.set(id, newUser);
|
||||
return { rows: [newUser] };
|
||||
}
|
||||
if (text.includes('UPDATE users') && text.includes('SET socket_id = $1') &&
|
||||
text.includes('anon_token = $2') && text.includes('anon_token = $4')) {
|
||||
const [socketId, newToken, userId, presentedToken] = params;
|
||||
const user = users.get(userId);
|
||||
if (!user || user.anon_token !== presentedToken) return { rows: [] };
|
||||
user.socket_id = socketId;
|
||||
user.anon_token = newToken;
|
||||
user.last_seen = new Date();
|
||||
return { rows: [{ ...user }] };
|
||||
}
|
||||
if (text.includes('UPDATE users SET socket_id')) {
|
||||
const socketId = params[0];
|
||||
const userId = params[1];
|
||||
@@ -282,6 +292,69 @@ describe('SQL Injection Prevention', () => {
|
||||
expect(replay.resumed).toBe(false);
|
||||
});
|
||||
|
||||
test('concurrent resume is compare-and-swap: one wins, the loser is rejected, and stale live identity is disconnected', async () => {
|
||||
const localDb = createMockDb();
|
||||
const staleDisconnect = jest.fn();
|
||||
const io = {
|
||||
sockets: {
|
||||
sockets: new Map([['cas-socket-old', { disconnect: staleDisconnect }]])
|
||||
}
|
||||
};
|
||||
const manager = new SessionManager({
|
||||
db: localDb,
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
gameModeManager: { creditsPerGameCost: 1 },
|
||||
io
|
||||
});
|
||||
const created = await manager.resumeOrCreate({
|
||||
socketId: 'cas-socket-old',
|
||||
ipAddress: '127.0.0.1',
|
||||
resumeToken: null
|
||||
});
|
||||
|
||||
// Hold both SELECT-by-token calls until they have each observed the old token,
|
||||
// forcing the race to be decided by the conditional UPDATE rather than timing.
|
||||
const baseQuery = localDb.query;
|
||||
let waitingSelects = 0;
|
||||
let releaseFirstSelect;
|
||||
localDb.query = jest.fn(async (text, params = []) => {
|
||||
const result = await baseQuery(text, params);
|
||||
if (text.includes('SELECT * FROM users WHERE anon_token') && params[0] === created.token) {
|
||||
waitingSelects += 1;
|
||||
if (waitingSelects === 1) {
|
||||
await new Promise(resolve => { releaseFirstSelect = resolve; });
|
||||
} else if (releaseFirstSelect) {
|
||||
releaseFirstSelect();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const attempts = await Promise.allSettled([
|
||||
manager.resumeOrCreate({
|
||||
socketId: 'cas-socket-a',
|
||||
ipAddress: '127.0.0.1',
|
||||
resumeToken: created.token
|
||||
}),
|
||||
manager.resumeOrCreate({
|
||||
socketId: 'cas-socket-b',
|
||||
ipAddress: '127.0.0.1',
|
||||
resumeToken: created.token
|
||||
})
|
||||
]);
|
||||
|
||||
const fulfilled = attempts.filter(r => r.status === 'fulfilled');
|
||||
const rejected = attempts.filter(r => r.status === 'rejected');
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(fulfilled[0].value.resumed).toBe(true);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0].reason.code).toBe('SESSION_TOKEN_REPLAY');
|
||||
|
||||
expect(manager.sessions.has('cas-socket-old')).toBe(false);
|
||||
await expect(manager.getBySocket('cas-socket-old')).resolves.toBeNull();
|
||||
expect(staleDisconnect).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('should safely handle malicious socket ID', async () => {
|
||||
const maliciousSocketId = "'; DROP TABLE users; --";
|
||||
|
||||
@@ -378,4 +451,4 @@ module.exports = {
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,21 +9,34 @@
|
||||
|
||||
const SessionManager = require('../src/network/sessionManager');
|
||||
|
||||
function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set() }) {
|
||||
function makeDb({ singleGamePayments = [], gamesForPayment = new Set(), recoveredReasons = new Set(), entitlementPaymentIds = new Set() }) {
|
||||
let credits = 0;
|
||||
const handler = async (text, params = []) => {
|
||||
// credits_package recovery queries (unprocessed + orphaned) — none in these tests
|
||||
if (/FROM payments[\s\S]*credits_package/i.test(text)) return { rows: [] };
|
||||
if (/FROM payments\s+WHERE id = \$1[\s\S]*FOR UPDATE/i.test(text)) {
|
||||
const p = singleGamePayments.find(row => row.id === params[0]);
|
||||
return { rows: p ? [{ id: p.id, user_id: 7, status: 'confirmed', payment_type: 'single_game' }] : [] };
|
||||
}
|
||||
// outer "unconsumed single_game" discovery query
|
||||
if (/payment_type = 'single_game'/i.test(text)) {
|
||||
return {
|
||||
rows: singleGamePayments
|
||||
.filter(p => !gamesForPayment.has(p.id) && !recoveredReasons.has(`single_game_recovered:${p.id}`))
|
||||
.filter(p => !gamesForPayment.has(p.id)
|
||||
&& !entitlementPaymentIds.has(p.id)
|
||||
&& !recoveredReasons.has(`single_game_recovered:${p.id}`))
|
||||
.map(p => ({ id: p.id, confirmed_at: p.confirmed_at }))
|
||||
};
|
||||
}
|
||||
if (/SELECT id FROM payments WHERE id = \$1 FOR UPDATE/i.test(text)) return { rows: [{ id: params[0] }] };
|
||||
if (/SELECT 1 FROM games WHERE payment_id/i.test(text)) return { rows: gamesForPayment.has(params[0]) ? [{}] : [] };
|
||||
if (/SELECT payment_id FROM payment_entitlement_grants/i.test(text)) {
|
||||
return { rows: entitlementPaymentIds.has(params[0]) ? [{ payment_id: params[0] }] : [] };
|
||||
}
|
||||
if (/INSERT INTO payment_entitlement_grants/i.test(text)) {
|
||||
if (entitlementPaymentIds.has(params[0])) return { rows: [], rowCount: 0 };
|
||||
entitlementPaymentIds.add(params[0]);
|
||||
return { rows: [{ payment_id: params[0] }], rowCount: 1 };
|
||||
}
|
||||
if (/FROM credit_transactions WHERE user_id = \$1 AND reason = \$2/i.test(text)) {
|
||||
return { rows: recoveredReasons.has(params[1]) ? [{}] : [] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const nacl = require('../src/node_modules/tweetnacl');
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
function normalized(sql) {
|
||||
return sql.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function createStateDb(initialUsers) {
|
||||
const users = new Map(initialUsers.map(user => [user.id, { ...user }]));
|
||||
const challenges = new Map();
|
||||
let challengeId = 1;
|
||||
|
||||
async function query(sql, params = []) {
|
||||
const text = normalized(sql);
|
||||
|
||||
if (text.includes('SELECT id, socket_id, anon_token FROM users')) {
|
||||
const row = Array.from(users.values()).find(user =>
|
||||
user.socket_id === params[0] && user.anon_token === params[1]);
|
||||
return { rows: row ? [{ id: row.id, socket_id: row.socket_id, anon_token: row.anon_token }] : [] };
|
||||
}
|
||||
|
||||
if (text.startsWith('INSERT INTO smirk_challenges')) {
|
||||
const [challenge, socketId, userId, token] = params;
|
||||
const user = users.get(userId);
|
||||
if (!user || user.socket_id !== socketId || user.anon_token !== token) return { rows: [] };
|
||||
const row = {
|
||||
id: challengeId++, challenge, socket_id: socketId, user_id: userId,
|
||||
used: false, expires_at: Date.now() + 300000
|
||||
};
|
||||
challenges.set(challenge, row);
|
||||
return { rows: [{ id: row.id }] };
|
||||
}
|
||||
|
||||
if (text.startsWith('DELETE FROM smirk_challenges')) return { rows: [], rowCount: 0 };
|
||||
|
||||
if (text.startsWith('UPDATE smirk_challenges SET used = TRUE')) {
|
||||
const [challenge, userId, socketId] = params;
|
||||
const row = challenges.get(challenge);
|
||||
if (!row || row.used || row.user_id !== userId || row.socket_id !== socketId
|
||||
|| row.expires_at <= Date.now()) return { rows: [] };
|
||||
row.used = true;
|
||||
return { rows: [{ id: row.id }] };
|
||||
}
|
||||
|
||||
if (text.includes('SELECT id FROM users WHERE id = $1 AND socket_id = $2 AND anon_token = $3 FOR UPDATE')) {
|
||||
const user = users.get(params[0]);
|
||||
return {
|
||||
rows: user && user.socket_id === params[1] && user.anon_token === params[2]
|
||||
? [{ id: user.id }]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
if (text.includes('SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key')) {
|
||||
const owner = Array.from(users.values()).find(user =>
|
||||
user.smirk_public_key === params[0] && user.id !== params[1]);
|
||||
return { rows: owner ? [{
|
||||
id: owner.id,
|
||||
socket_id: owner.socket_id,
|
||||
payout_address: owner.payout_address
|
||||
}] : [] };
|
||||
}
|
||||
|
||||
if (text.startsWith('UPDATE users SET socket_id = NULL, anon_token = $1')) {
|
||||
const [newToken, userId, socketId, oldToken] = params;
|
||||
const user = users.get(userId);
|
||||
if (!user || user.socket_id !== socketId || user.anon_token !== oldToken) return { rows: [] };
|
||||
user.socket_id = null;
|
||||
user.anon_token = newToken;
|
||||
return { rows: [{ id: user.id }] };
|
||||
}
|
||||
|
||||
if (text.startsWith('UPDATE users SET socket_id = $1, anon_token = $2')) {
|
||||
const [socketId, newToken, userId, provenKey] = params;
|
||||
const user = users.get(userId);
|
||||
if (!user || user.smirk_public_key !== provenKey) return { rows: [] };
|
||||
user.socket_id = socketId;
|
||||
user.anon_token = newToken;
|
||||
return { rows: [{ id: user.id, payout_address: user.payout_address }] };
|
||||
}
|
||||
|
||||
if (text.includes('SELECT id FROM users WHERE socket_id = $1 FOR UPDATE')) {
|
||||
return {
|
||||
rows: Array.from(users.values())
|
||||
.filter(user => user.socket_id === params[0])
|
||||
.map(user => ({ id: user.id }))
|
||||
};
|
||||
}
|
||||
|
||||
if (text.startsWith('UPDATE users SET smirk_public_key = $1')) {
|
||||
const [key, userId, socketId, token] = params;
|
||||
const user = users.get(userId);
|
||||
if (!user || user.socket_id !== socketId || user.anon_token !== token) return { rows: [] };
|
||||
user.smirk_public_key = key;
|
||||
return { rows: [{ id: user.id }] };
|
||||
}
|
||||
|
||||
if (text.includes('SELECT smirk_public_key, payout_address FROM users WHERE anon_token = $1')) {
|
||||
const user = Array.from(users.values()).find(row => row.anon_token === params[0]);
|
||||
return { rows: user ? [{
|
||||
smirk_public_key: user.smirk_public_key,
|
||||
payout_address: user.payout_address
|
||||
}] : [] };
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
challenges,
|
||||
query: jest.fn(query),
|
||||
withTransaction: jest.fn(async callback => callback({ query }))
|
||||
};
|
||||
}
|
||||
|
||||
function makeSessionManager(tokens = []) {
|
||||
let index = 0;
|
||||
return {
|
||||
generateSecureToken: jest.fn(() => tokens[index++] || `generated-token-${index}`),
|
||||
disconnectUserSessions: jest.fn()
|
||||
};
|
||||
}
|
||||
|
||||
async function startServer(db, sessionManager) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createAuthRoutes({ db, sessionManager }));
|
||||
app.use((err, _req, res, _next) => {
|
||||
res.status(err.statusCode || 500).json({
|
||||
error: err.safeMessage || err.message,
|
||||
code: err.code || 'ERROR'
|
||||
});
|
||||
});
|
||||
return new Promise(resolve => {
|
||||
const server = http.createServer(app);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function request(server, path, { body, token } = {}) {
|
||||
const { port } = server.address();
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['X-Session-Token'] = token;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST', headers, body: JSON.stringify(body || {})
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
json: await response.json().catch(() => ({}))
|
||||
};
|
||||
}
|
||||
|
||||
function signedLegacyProof(challenge, keyPair = nacl.sign.keyPair()) {
|
||||
const signature = nacl.sign.detached(Buffer.from(challenge, 'utf8'), keyPair.secretKey);
|
||||
return {
|
||||
keyPair,
|
||||
publicKey: Buffer.from(keyPair.publicKey).toString('hex'),
|
||||
signature: Buffer.from(signature).toString('hex')
|
||||
};
|
||||
}
|
||||
|
||||
describe('Smirk stable-session ownership', () => {
|
||||
test('public victim socket id is insufficient: challenge requires its matching bearer and stores user_id', async () => {
|
||||
const db = createStateDb([
|
||||
{ id: 1, socket_id: 'victim-public-socket', anon_token: 'victim-secret' },
|
||||
{ id: 2, socket_id: 'attacker-socket', anon_token: 'attacker-secret' }
|
||||
]);
|
||||
const server = await startServer(db, makeSessionManager());
|
||||
try {
|
||||
const missing = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'victim-public-socket' }
|
||||
});
|
||||
expect(missing.status).toBe(401);
|
||||
|
||||
const stolenId = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'victim-public-socket' }, token: 'attacker-secret'
|
||||
});
|
||||
expect(stolenId.status).toBe(403);
|
||||
expect(db.challenges.size).toBe(0);
|
||||
|
||||
const legitimate = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'victim-public-socket' }, token: 'victim-secret'
|
||||
});
|
||||
expect(legitimate.status).toBe(200);
|
||||
expect(db.challenges.get(legitimate.json.challenge)).toMatchObject({
|
||||
user_id: 1,
|
||||
socket_id: 'victim-public-socket',
|
||||
used: false
|
||||
});
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a challenge issued to one stable user cannot be verified through another session', async () => {
|
||||
const db = createStateDb([
|
||||
{ id: 1, socket_id: 'victim-socket', anon_token: 'victim-token', smirk_public_key: null },
|
||||
{ id: 2, socket_id: 'attacker-socket', anon_token: 'attacker-token', smirk_public_key: null }
|
||||
]);
|
||||
const server = await startServer(db, makeSessionManager());
|
||||
try {
|
||||
const issued = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'victim-socket' }, token: 'victim-token'
|
||||
});
|
||||
const proof = signedLegacyProof(issued.json.challenge);
|
||||
const crossed = await request(server, '/api/auth/smirk/verify', {
|
||||
body: {
|
||||
socketId: 'attacker-socket', challenge: issued.json.challenge,
|
||||
publicKey: proof.publicKey, signature: proof.signature
|
||||
},
|
||||
token: 'attacker-token'
|
||||
});
|
||||
|
||||
expect(crossed.status).toBe(400);
|
||||
expect(db.users.get(1).smirk_public_key).toBeNull();
|
||||
expect(db.users.get(2).smirk_public_key).toBeNull();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('legitimate wallet linking still succeeds for the authenticated session', async () => {
|
||||
const db = createStateDb([
|
||||
{ id: 1, socket_id: 'owner-socket', anon_token: 'owner-token', smirk_public_key: null }
|
||||
]);
|
||||
const server = await startServer(db, makeSessionManager());
|
||||
try {
|
||||
const issued = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'owner-socket' }, token: 'owner-token'
|
||||
});
|
||||
const proof = signedLegacyProof(issued.json.challenge);
|
||||
const linked = await request(server, '/api/auth/smirk/verify', {
|
||||
body: {
|
||||
socketId: 'owner-socket', challenge: issued.json.challenge,
|
||||
publicKey: proof.publicKey, signature: proof.signature
|
||||
},
|
||||
token: 'owner-token'
|
||||
});
|
||||
|
||||
expect(linked.status).toBe(200);
|
||||
expect(linked.json).toMatchObject({ success: true, linked: true });
|
||||
expect(db.users.get(1).smirk_public_key).toBe(proof.publicKey);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('wallet adoption atomically displaces the fresh row, rotates tokens, leaves one socket owner, and revokes live sessions', async () => {
|
||||
const ownerKeyPair = nacl.sign.keyPair();
|
||||
const ownerKey = Buffer.from(ownerKeyPair.publicKey).toString('hex');
|
||||
const db = createStateDb([
|
||||
{
|
||||
id: 1, socket_id: 'old-owner-socket', anon_token: 'old-owner-token',
|
||||
smirk_public_key: ownerKey, payout_address: 'owner-address'
|
||||
},
|
||||
{
|
||||
id: 2, socket_id: 'fresh-public-socket', anon_token: 'fresh-token',
|
||||
smirk_public_key: null, payout_address: null
|
||||
}
|
||||
]);
|
||||
const sessionManager = makeSessionManager(['new-owner-token', 'revoked-fresh-token']);
|
||||
const server = await startServer(db, sessionManager);
|
||||
try {
|
||||
const issued = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'fresh-public-socket' }, token: 'fresh-token'
|
||||
});
|
||||
const proof = signedLegacyProof(issued.json.challenge, ownerKeyPair);
|
||||
const adopted = await request(server, '/api/auth/smirk/verify', {
|
||||
body: {
|
||||
socketId: 'fresh-public-socket', challenge: issued.json.challenge,
|
||||
publicKey: proof.publicKey, signature: proof.signature
|
||||
},
|
||||
token: 'fresh-token'
|
||||
});
|
||||
|
||||
expect(adopted.status).toBe(200);
|
||||
expect(adopted.json).toMatchObject({
|
||||
success: true,
|
||||
adopted: true,
|
||||
sessionToken: 'new-owner-token',
|
||||
address: 'owner-address'
|
||||
});
|
||||
expect(db.users.get(1)).toMatchObject({
|
||||
socket_id: 'fresh-public-socket', anon_token: 'new-owner-token'
|
||||
});
|
||||
expect(db.users.get(2)).toMatchObject({
|
||||
socket_id: null, anon_token: 'revoked-fresh-token'
|
||||
});
|
||||
expect(Array.from(db.users.values()).filter(u => u.socket_id === 'fresh-public-socket'))
|
||||
.toHaveLength(1);
|
||||
expect(Array.from(db.users.values()).some(u => u.anon_token === 'old-owner-token')).toBe(false);
|
||||
expect(Array.from(db.users.values()).some(u => u.anon_token === 'fresh-token')).toBe(false);
|
||||
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(sessionManager.disconnectUserSessions).toHaveBeenCalledWith(
|
||||
[2, 1],
|
||||
['fresh-public-socket', 'old-owner-socket']
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('challenge issuance is bounded per IP', async () => {
|
||||
const db = createStateDb([
|
||||
{ id: 1, socket_id: 'rate-socket', anon_token: 'rate-token', smirk_public_key: null }
|
||||
]);
|
||||
const server = await startServer(db, makeSessionManager());
|
||||
try {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const result = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'rate-socket' }, token: 'rate-token'
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
}
|
||||
const limited = await request(server, '/api/auth/smirk/challenge', {
|
||||
body: { socketId: 'rate-socket' }, token: 'rate-token'
|
||||
});
|
||||
expect(limited.status).toBe(429);
|
||||
expect(Number(limited.headers.get('retry-after'))).toBeGreaterThan(0);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const { isSmirkEnabled } = require('../src/auth/smirkPolicy');
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
const ClientSocketHandlers = require('../html/js/network/socketHandlers');
|
||||
|
||||
function gameModeInfoFixture() {
|
||||
const manager = Object.create(GameModeManager.prototype);
|
||||
Object.assign(manager, {
|
||||
creditsPayoutBaseValue: 0,
|
||||
cryptoType: 'WOW',
|
||||
network: 'mainnet',
|
||||
isTestNetwork: false,
|
||||
configSnapshot: { modes: { credits: { packages: [] } }, earlyEntry: {} },
|
||||
gameMode: 'FREE',
|
||||
freePlayEnabled: true,
|
||||
singleGamePrice: 0,
|
||||
creditsPackagePrice: 0,
|
||||
creditsPerGameCost: 1,
|
||||
paymentsEnabled: false,
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: false,
|
||||
payoutsEnabled: false,
|
||||
directPayoutMultipliers: {},
|
||||
creditPayoutMultipliers: {},
|
||||
formatAtomic: () => '0',
|
||||
formatAtomicHuman: () => '0',
|
||||
getCosmeticProducts: () => [],
|
||||
isPayoutEnabledForMode: () => false,
|
||||
_getMatchEconomies: () => [],
|
||||
_getMatchRulesetInfo: () => ({})
|
||||
});
|
||||
return manager.getGameModeInfo();
|
||||
}
|
||||
|
||||
describe('Smirk explicit opt-in policy', () => {
|
||||
const original = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ['SMIRK_ENABLED', 'MONERO_NETWORK']) original[key] = process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(original)) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('server policy is off for missing/truthy-ish values and every test network', () => {
|
||||
expect(isSmirkEnabled({ MONERO_NETWORK: 'mainnet' })).toBe(false);
|
||||
expect(isSmirkEnabled({ SMIRK_ENABLED: 'TRUE', MONERO_NETWORK: 'mainnet' })).toBe(false);
|
||||
expect(isSmirkEnabled({ SMIRK_ENABLED: '1', MONERO_NETWORK: 'mainnet' })).toBe(false);
|
||||
expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'stagenet' })).toBe(false);
|
||||
expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'testnet' })).toBe(false);
|
||||
expect(isSmirkEnabled({ SMIRK_ENABLED: 'true', MONERO_NETWORK: 'mainnet' })).toBe(true);
|
||||
});
|
||||
|
||||
test('game-mode payload defaults Smirk off and advertises it only for exact mainnet opt-in', () => {
|
||||
delete process.env.SMIRK_ENABLED;
|
||||
process.env.MONERO_NETWORK = 'mainnet';
|
||||
expect(gameModeInfoFixture().smirkEnabled).toBe(false);
|
||||
|
||||
process.env.SMIRK_ENABLED = 'true';
|
||||
expect(gameModeInfoFixture().smirkEnabled).toBe(true);
|
||||
|
||||
process.env.MONERO_NETWORK = 'stagenet';
|
||||
expect(gameModeInfoFixture().smirkEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('browser gate accepts only the explicit boolean true sent by the server', () => {
|
||||
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({})).toBe(false);
|
||||
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: 'true' })).toBe(false);
|
||||
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: false })).toBe(false);
|
||||
expect(ClientSocketHandlers._isSmirkExplicitlyEnabled({ smirkEnabled: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -230,39 +230,58 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
// Mock DB modelling the atomic single-use UPDATE ... WHERE used=FALSE ... RETURNING.
|
||||
function mockDb(challengeValue, socketId) {
|
||||
const challenges = new Map([[challengeValue, { id: 1, socket_id: socketId, used: false }]]);
|
||||
return {
|
||||
challenges,
|
||||
query: async (sql, params = []) => {
|
||||
const s = sql.replace(/\s+/g, ' ').trim();
|
||||
if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) {
|
||||
const [ch, sock] = params;
|
||||
const row = challenges.get(ch);
|
||||
if (row && row.socket_id === sock && row.used === false) {
|
||||
row.used = true; // atomic FALSE -> TRUE, exactly once
|
||||
return { rows: [{ id: row.id }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
}
|
||||
if (/SELECT id FROM users WHERE socket_id = \$1$/i.test(s)) {
|
||||
return { rows: [{ id: 77 }] };
|
||||
}
|
||||
if (/SELECT id FROM users WHERE smirk_public_key = \$1 AND id != \$2/i.test(s)) {
|
||||
return { rows: [] }; // not linked elsewhere
|
||||
}
|
||||
if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) {
|
||||
return { rows: [] };
|
||||
function mockDb(challengeValue, socketId, token) {
|
||||
const user = { id: 77, socket_id: socketId, anon_token: token };
|
||||
const challenges = new Map([[
|
||||
challengeValue,
|
||||
{ id: 1, socket_id: socketId, user_id: user.id, used: false }
|
||||
]]);
|
||||
const query = async (sql, params = []) => {
|
||||
const s = sql.replace(/\s+/g, ' ').trim();
|
||||
if (/SELECT id, socket_id, anon_token FROM users/i.test(s)) {
|
||||
return params[0] === user.socket_id && params[1] === user.anon_token
|
||||
? { rows: [user] }
|
||||
: { rows: [] };
|
||||
}
|
||||
if (/^UPDATE smirk_challenges SET used = TRUE/i.test(s)) {
|
||||
const [ch, userId, sock] = params;
|
||||
const row = challenges.get(ch);
|
||||
if (row && row.user_id === userId && row.socket_id === sock && row.used === false) {
|
||||
row.used = true; // atomic FALSE -> TRUE, exactly once
|
||||
return { rows: [{ id: row.id }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
},
|
||||
}
|
||||
if (/SELECT id FROM users WHERE id = \$1 AND socket_id = \$2 AND anon_token = \$3 FOR UPDATE/i.test(s)) {
|
||||
return params[0] === user.id && params[1] === user.socket_id && params[2] === user.anon_token
|
||||
? { rows: [{ id: user.id }] }
|
||||
: { rows: [] };
|
||||
}
|
||||
if (/SELECT id, socket_id, payout_address FROM users WHERE smirk_public_key/i.test(s)) {
|
||||
return { rows: [] }; // not linked elsewhere
|
||||
}
|
||||
if (/^UPDATE users SET smirk_public_key = \$1/i.test(s)) {
|
||||
return { rows: [{ id: user.id }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
};
|
||||
return {
|
||||
challenges,
|
||||
query,
|
||||
withTransaction: async (fn) => fn({ query }),
|
||||
};
|
||||
}
|
||||
|
||||
function startServer(db) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createAuthRoutes({ db }));
|
||||
app.use(createAuthRoutes({
|
||||
db,
|
||||
sessionManager: {
|
||||
generateSecureToken: () => 'unused-test-token',
|
||||
disconnectUserSessions: jest.fn()
|
||||
}
|
||||
}));
|
||||
// Minimal error handler mirroring the app's (ValidationError -> 400, etc.).
|
||||
app.use((err, req, res, _next) => {
|
||||
res.status(err.statusCode || 500).json({ error: err.message });
|
||||
@@ -273,11 +292,11 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
|
||||
});
|
||||
}
|
||||
|
||||
async function postJson(server, path, body) {
|
||||
async function postJson(server, path, body, token) {
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
@@ -287,20 +306,21 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
|
||||
test('a valid NIP-98 event links once, then the identical replay is rejected', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const socketId = 'sock-xyz-1';
|
||||
const token = 'route-session-token';
|
||||
const challenge = 'route-nonce-9f8e7d';
|
||||
// created_at must be genuinely fresh — the route uses real Date.now().
|
||||
const ev = buildEvent(sk, { created_at: Math.floor(Date.now() / 1000), challenge });
|
||||
|
||||
const db = mockDb(challenge, socketId);
|
||||
const db = mockDb(challenge, socketId, token);
|
||||
const server = await startServer(db);
|
||||
try {
|
||||
const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev });
|
||||
const first = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.json).toMatchObject({ success: true, linked: true });
|
||||
expect(db.challenges.get(challenge).used).toBe(true);
|
||||
|
||||
// Replay the exact same event: the single-use consume now fails -> 400.
|
||||
const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev });
|
||||
const second = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
|
||||
expect(second.status).toBe(400);
|
||||
expect(second.json.error).toBeTruthy();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
const {
|
||||
createSocketOriginAllowRequest,
|
||||
normalizedConfiguredOrigin,
|
||||
normalizedRequestOrigin
|
||||
} = require('../src/network/socketOriginPolicy');
|
||||
|
||||
function allowed(env, headers) {
|
||||
const allowRequest = createSocketOriginAllowRequest(env);
|
||||
return new Promise((resolve, reject) => {
|
||||
allowRequest({ headers }, (error, accepted) => {
|
||||
if (error) reject(error);
|
||||
else resolve(accepted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('Socket.IO production Origin policy', () => {
|
||||
test('development preserves permissive behavior', async () => {
|
||||
await expect(allowed({ NODE_ENV: 'development' }, { origin: 'not a URL' }))
|
||||
.resolves.toBe(true);
|
||||
await expect(allowed({}, { origin: 'https://cross-origin.example' }))
|
||||
.resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('production allows an absent Origin only after HOSTED_BY validates', async () => {
|
||||
const env = { NODE_ENV: 'production', HOSTED_BY: 'https://play.example.test' };
|
||||
await expect(allowed(env, {})).resolves.toBe(true);
|
||||
await expect(allowed(env, undefined)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('allows only the exact normalized scheme and host', async () => {
|
||||
const env = {
|
||||
NODE_ENV: 'production',
|
||||
HOSTED_BY: 'HTTPS://Play.Example.Test:443/public/path?ignored=yes'
|
||||
};
|
||||
|
||||
await expect(allowed(env, { origin: 'https://play.example.test' })).resolves.toBe(true);
|
||||
await expect(allowed(env, { origin: 'https://PLAY.EXAMPLE.TEST:443/' })).resolves.toBe(true);
|
||||
await expect(allowed(env, { origin: 'http://play.example.test' })).resolves.toBe(false);
|
||||
await expect(allowed(env, { origin: 'https://other.example.test' })).resolves.toBe(false);
|
||||
await expect(allowed(env, { origin: 'https://play.example.test:444' })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'null',
|
||||
'not a URL',
|
||||
'ftp://play.example.test',
|
||||
'https://user:password@play.example.test',
|
||||
'https://play.example.test/socket/path',
|
||||
'https://play.example.test/./',
|
||||
'https://play.example.test?query=yes',
|
||||
'https://play.example.test#fragment',
|
||||
''
|
||||
])('rejects malformed browser Origin %p', async (origin) => {
|
||||
await expect(allowed({
|
||||
NODE_ENV: 'production',
|
||||
HOSTED_BY: 'https://play.example.test'
|
||||
}, { origin })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a non-string or multi-value Origin header', async () => {
|
||||
const env = { NODE_ENV: 'production', HOSTED_BY: 'https://play.example.test' };
|
||||
await expect(allowed(env, { origin: ['https://play.example.test'] })).resolves.toBe(false);
|
||||
await expect(allowed(env, { origin: null })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
undefined,
|
||||
'',
|
||||
'not a URL',
|
||||
'ftp://play.example.test',
|
||||
'https://user:password@play.example.test'
|
||||
])('fails closed for invalid production HOSTED_BY %p', async (hostedBy) => {
|
||||
const env = { NODE_ENV: 'production', HOSTED_BY: hostedBy };
|
||||
await expect(allowed(env, {})).resolves.toBe(false);
|
||||
await expect(allowed(env, { origin: 'https://play.example.test' })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('normalizers preserve default-port normalization and reject request paths', () => {
|
||||
expect(normalizedConfiguredOrigin('https://EXAMPLE.test:443/path')).toBe('https://example.test');
|
||||
expect(normalizedRequestOrigin('https://EXAMPLE.test:443/')).toBe('https://example.test');
|
||||
expect(normalizedRequestOrigin('https://example.test/path')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GameModeManager = require('../src/game/gameModeManager');
|
||||
|
||||
function paymentConfig() {
|
||||
return {
|
||||
getConfig: () => ({
|
||||
paymentsEnabled: true,
|
||||
currency: { symbol: 'WOW', decimals: 11 },
|
||||
modes: {
|
||||
direct: { enabled: true, price: 100n, requiresAddress: true },
|
||||
credits: { enabled: true, creditsPerGame: 1, requiresAddress: true, packages: [{ id: 'p', credits: 1, price: 100n }] }
|
||||
},
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1n, maxPayout: 1000n },
|
||||
credits: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, baseValue: 100n, minPayout: 1n, maxPayout: 1000n }
|
||||
}
|
||||
},
|
||||
preferences: { preferCreditsFirst: true },
|
||||
earlyEntry: { enabled: false }
|
||||
}),
|
||||
getLegacyGameMode: () => 'PAID_SINGLE',
|
||||
eventBus: { on() {} }
|
||||
};
|
||||
}
|
||||
|
||||
function managerWith(db) {
|
||||
const manager = new GameModeManager(
|
||||
db,
|
||||
{ processPayout: jest.fn(), processBatchPayout: jest.fn() },
|
||||
{ CONSOLE_LOGGING: false },
|
||||
paymentConfig()
|
||||
);
|
||||
manager._scheduleBatchPayout = jest.fn();
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe('solo liability commitment', () => {
|
||||
test('credit debit and immutable payout terms are written in the same transaction', async () => {
|
||||
const calls = [];
|
||||
const client = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
|
||||
return { rows: [{ credits: 4, total_credits_purchased: 5 }], rowCount: 1 };
|
||||
}
|
||||
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
|
||||
return { rows: [{ id: 10 }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
const db = {
|
||||
query: jest.fn(),
|
||||
withTransaction: jest.fn(async callback => callback(client))
|
||||
};
|
||||
const manager = managerWith(db);
|
||||
|
||||
const result = await manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'seed');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(db.withTransaction).toHaveBeenCalledTimes(1);
|
||||
const commitment = calls.find(call => /payout_committed_at = NOW\(\)/i.test(call.sql));
|
||||
expect(commitment).toBeDefined();
|
||||
expect(commitment.params[6]).toBe(true);
|
||||
expect(JSON.parse(commitment.params[7])).toEqual(expect.objectContaining({
|
||||
version: 2,
|
||||
mode: 'PAID_CREDITS',
|
||||
eligible: true,
|
||||
escapeAmount: '200',
|
||||
treasureAmount: '300',
|
||||
minAmount: '1',
|
||||
maxAmount: '1000'
|
||||
}));
|
||||
});
|
||||
|
||||
test('a missing game row aborts before a credit ledger entry can be committed', async () => {
|
||||
const calls = [];
|
||||
const client = {
|
||||
async query(sql) {
|
||||
calls.push(sql);
|
||||
if (/UPDATE users[\s\S]*credits = credits -/i.test(sql)) {
|
||||
return { rows: [{ credits: 4 }], rowCount: 1 };
|
||||
}
|
||||
if (/UPDATE games SET game_mode = 'PAID_CREDITS'/i.test(sql)) {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
const manager = managerWith({ query: jest.fn(), withTransaction: async callback => callback(client) });
|
||||
|
||||
await expect(manager._processGameStartWithCredits({ id: 1, credits: 5, payout_address: 'addr' }, 'sock', 'missing'))
|
||||
.rejects.toMatchObject({ code: 'GAME_ROW_REQUIRED' });
|
||||
expect(calls.some(sql => /INSERT INTO credit_transactions/i.test(sql))).toBe(false);
|
||||
});
|
||||
|
||||
test('completion rolls back if its payout obligation cannot be inserted', async () => {
|
||||
const durable = { status: 'active', payout: false };
|
||||
const gameRow = {
|
||||
id: 5,
|
||||
user_id: 1,
|
||||
game_mode: 'PAID_SINGLE',
|
||||
status: 'active',
|
||||
payout_address: 'addr',
|
||||
payout_eligible: true,
|
||||
payout_terms: {
|
||||
version: 1,
|
||||
eligible: true,
|
||||
escapeAmount: '200',
|
||||
treasureAmount: '300',
|
||||
escapeMultiplier: 2,
|
||||
treasureMultiplier: 3,
|
||||
minAmount: '1',
|
||||
maxAmount: '1000'
|
||||
}
|
||||
};
|
||||
const db = {
|
||||
query: jest.fn(),
|
||||
async withTransaction(callback) {
|
||||
const tx = { status: durable.status, payout: durable.payout };
|
||||
const client = {
|
||||
async query(sql) {
|
||||
if (/FROM games[\s\S]*FOR UPDATE/i.test(sql)) return { rows: [gameRow], rowCount: 1 };
|
||||
if (/UPDATE games[\s\S]*completed_at = NOW/i.test(sql)) {
|
||||
tx.status = 'won';
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (/SELECT id, status FROM payouts/i.test(sql)) return { rows: [], rowCount: 0 };
|
||||
if (/SELECT id, payout_address FROM users/i.test(sql)) return { rows: [{ id: 1, payout_address: 'addr' }], rowCount: 1 };
|
||||
if (/INSERT INTO payouts/i.test(sql)) throw new Error('payout insert failed');
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
};
|
||||
const value = await callback(client);
|
||||
durable.status = tx.status;
|
||||
durable.payout = tx.payout;
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const manager = managerWith(db);
|
||||
|
||||
const result = await manager.completeGame('sock', 'seed', true, false, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(durable).toEqual({ status: 'active', payout: false });
|
||||
});
|
||||
|
||||
test('database identity guards cover every payout status and commitments are immutable', () => {
|
||||
const migration = fs.readFileSync(
|
||||
path.join(__dirname, '../src/migrations/032_solo_liability_invariants.sql'),
|
||||
'utf8'
|
||||
);
|
||||
expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_game[\s\S]*WHERE game_id IS NOT NULL;/i);
|
||||
expect(migration).toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_match[\s\S]*WHERE match_id IS NOT NULL;/i);
|
||||
expect(migration).not.toMatch(/CREATE UNIQUE INDEX idx_payouts_one_per_(?:game|match)[\s\S]{0,160}status IN/i);
|
||||
expect(migration).toMatch(/reject_game_payout_commitment_mutation/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
const GameManager = require('../src/game/gameManager');
|
||||
const MovementManager = require('../src/game/movementManager');
|
||||
const SuspendedGameManager = require('../src/network/suspendedGameManager');
|
||||
|
||||
function makeHarness(completions, { retryMs = 1000 } = {}) {
|
||||
const targeted = [];
|
||||
const io = {
|
||||
to: jest.fn(socketId => ({
|
||||
emit: (event, payload) => targeted.push({ socketId, event, payload })
|
||||
})),
|
||||
emit: jest.fn()
|
||||
};
|
||||
const game = {
|
||||
id: 'solo-terminal-seed',
|
||||
socketId: 'socket-1',
|
||||
dbUserId: 44,
|
||||
player: { hasTreasure: true },
|
||||
moveCount: 17,
|
||||
startedAt: Date.now() - 2400,
|
||||
movePlayer: jest.fn(),
|
||||
endGame: jest.fn(function endGame() { this.gameState = 'ended'; }),
|
||||
getProofReveal: jest.fn(() => ({ commitment: 'proof', seed: 'revealed-after-terminal' }))
|
||||
};
|
||||
const activeGames = new Map([['socket-1', game]]);
|
||||
const gameModeManager = {
|
||||
completeGame: jest.fn(),
|
||||
db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) }
|
||||
};
|
||||
for (const result of completions) gameModeManager.completeGame.mockResolvedValueOnce(result);
|
||||
const manager = new GameManager({
|
||||
activeGames,
|
||||
io,
|
||||
broadcastManager: {},
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
gameModeManager,
|
||||
settlementRetryBaseMs: retryMs,
|
||||
settlementRetryMaxMs: retryMs
|
||||
});
|
||||
return { manager, game, activeGames, gameModeManager, io, targeted };
|
||||
}
|
||||
|
||||
describe('solo terminal settlement retry', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('retains one frozen result after success:false and publishes it once after retry succeeds', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-07-21T12:00:00Z'));
|
||||
const harness = makeHarness([
|
||||
{ success: false, error: 'Temporary database error' },
|
||||
{ success: true, payout: { payoutId: 91, status: 'queued', amount: '300', multiplier: 3 } }
|
||||
]);
|
||||
|
||||
const first = await harness.manager.handleGameOver(
|
||||
{ id: 'socket-1' },
|
||||
harness.game,
|
||||
'won',
|
||||
'escaped',
|
||||
'You escaped!',
|
||||
777
|
||||
);
|
||||
|
||||
expect(first.success).toBe(false);
|
||||
expect(harness.activeGames.get('socket-1')).toBe(harness.game);
|
||||
expect(harness.game.settlementPending).toBe(true);
|
||||
expect(harness.game.endGame).toHaveBeenCalledTimes(1);
|
||||
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(0);
|
||||
expect(harness.targeted.filter(event => event.event === 'game_settlement_pending')).toHaveLength(1);
|
||||
expect(harness.io.emit).not.toHaveBeenCalledWith('win_feed', expect.anything());
|
||||
|
||||
// Retaining the game blocks both movement and replacement-game creation while the exact
|
||||
// terminal transaction is unresolved.
|
||||
const movement = new MovementManager({
|
||||
activeGames: harness.activeGames,
|
||||
io: harness.io,
|
||||
debugManager: {},
|
||||
moveCooldown: 0
|
||||
});
|
||||
movement.handleMove('socket-1', { direction: 'right' });
|
||||
expect(harness.game.movePlayer).not.toHaveBeenCalled();
|
||||
await expect(harness.manager.createGameForUser({ id: 'socket-1' }))
|
||||
.rejects.toThrow('active or settlement-pending game');
|
||||
|
||||
// Even accidental object mutation cannot change the already-frozen DB arguments.
|
||||
harness.game.moveCount = 999;
|
||||
harness.game.player.hasTreasure = false;
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
|
||||
for (const call of harness.gameModeManager.completeGame.mock.calls) {
|
||||
expect(call).toEqual([
|
||||
'socket-1',
|
||||
'solo-terminal-seed',
|
||||
true,
|
||||
true,
|
||||
{
|
||||
moves: 17,
|
||||
durationSeconds: 2,
|
||||
score: 777,
|
||||
reason: 'escaped',
|
||||
outcome: 'escaped'
|
||||
}
|
||||
]);
|
||||
}
|
||||
expect(harness.activeGames.has('socket-1')).toBe(false);
|
||||
expect(harness.game.settlementPending).toBe(false);
|
||||
expect(harness.game.settlementCommitted).toBe(true);
|
||||
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1);
|
||||
expect(harness.io.emit.mock.calls.filter(([event]) => event === 'leaderboard_update')).toHaveLength(1);
|
||||
expect(harness.io.emit.mock.calls.filter(([event]) => event === 'win_feed')).toHaveLength(1);
|
||||
|
||||
// A late duplicate terminal callback is a no-op after commit.
|
||||
await harness.manager.handleGameOver(
|
||||
{ id: 'socket-1' }, harness.game, 'lost', 'timeout', 'Too late', 0
|
||||
);
|
||||
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
|
||||
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('graceful shutdown cancels timers and drains an unresolved terminal result before DB close', async () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = makeHarness([
|
||||
{ success: false, error: 'Temporary database error' },
|
||||
{ success: true, payout: null }
|
||||
], { retryMs: 60000 });
|
||||
|
||||
await harness.manager.handleGameOver(
|
||||
{ id: 'socket-1' }, harness.game, 'lost', 'monster', 'Caught', 0
|
||||
);
|
||||
const result = await harness.manager.shutdown({ timeoutMs: 1000, retryIntervalMs: 1 });
|
||||
|
||||
expect(result).toEqual({ initial: 1, settled: 1, pending: 0 });
|
||||
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
|
||||
expect(harness.activeGames.size).toBe(0);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('targets the reconnect socket and removes its re-keyed ownership after settlement', async () => {
|
||||
jest.useFakeTimers();
|
||||
const harness = makeHarness([
|
||||
{ success: false, error: 'Temporary database error' },
|
||||
{ success: true, payout: null }
|
||||
]);
|
||||
|
||||
await harness.manager.handleGameOver(
|
||||
{ id: 'socket-1' }, harness.game, 'won', 'escaped', 'Escaped', 500
|
||||
);
|
||||
|
||||
// Mirrors SuspendedGameManager.restoreGame(): the same object moves to the new socket key.
|
||||
harness.activeGames.delete('socket-1');
|
||||
harness.game.socketId = 'socket-2';
|
||||
harness.activeGames.set('socket-2', harness.game);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(harness.gameModeManager.completeGame.mock.calls[1][0]).toBe('socket-2');
|
||||
expect(harness.targeted.filter(event => event.event === 'game_over')).toEqual([
|
||||
expect.objectContaining({ socketId: 'socket-2' })
|
||||
]);
|
||||
expect(harness.activeGames.has('socket-1')).toBe(false);
|
||||
expect(harness.activeGames.has('socket-2')).toBe(false);
|
||||
});
|
||||
|
||||
test('reconnect restores a pending terminal lock without re-counting it as a playable game', () => {
|
||||
jest.useFakeTimers();
|
||||
const activeGames = new Map();
|
||||
const suspended = new SuspendedGameManager({
|
||||
debugManager: { CONSOLE_LOGGING: false },
|
||||
activeGames,
|
||||
cleanupTimeoutMs: 60000
|
||||
});
|
||||
const game = {
|
||||
id: 'pending-reconnect',
|
||||
settlementPending: true,
|
||||
player: { x: 1, y: 2, hasTreasure: true },
|
||||
moveCount: 9
|
||||
};
|
||||
const newUser = { joinGame: jest.fn() };
|
||||
|
||||
suspended.suspendGame(44, 'socket-old', game);
|
||||
const restored = suspended.restoreGame(44, 'socket-new', newUser);
|
||||
|
||||
expect(restored.game).toBe(game);
|
||||
expect(activeGames.get('socket-new')).toBe(game);
|
||||
expect(newUser.joinGame).not.toHaveBeenCalled();
|
||||
expect(game.user).toBe(newUser);
|
||||
suspended.cleanup();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -54,4 +54,59 @@ describe('SP client-state → Scene adapter', () => {
|
||||
const scene = RK.sceneFromGameState(state, {});
|
||||
expect(scene.grid[0][0]).toBe('floor');
|
||||
});
|
||||
|
||||
test('multiplayer players[] become racers and an explicit socket id owns the camera', () => {
|
||||
const state = {
|
||||
visibleTiles: [[F, F, F], [F, F, F]],
|
||||
exploredTiles: [[F, F, F], [F, F, F]],
|
||||
players: [
|
||||
{ id: 'rival', x: 0, y: 0, name: 'Rival', alive: true },
|
||||
{ id: 'mine', x: 2, y: 1, name: 'Me', avatar: 'char-ranger', alive: true, placement: 2 }
|
||||
],
|
||||
entrance: [0, 0], exit: [2, 1], monster: null, lighting: {}
|
||||
};
|
||||
|
||||
// String second args were used by the original match client; keep them backward compatible.
|
||||
const scene = RK.sceneFromGameState(state, 'mine');
|
||||
const racers = scene.entities.filter(e => e.kind === 'player');
|
||||
expect(racers).toHaveLength(2);
|
||||
expect(racers.find(e => e.id === 'mine')).toMatchObject({
|
||||
x: 2, y: 1, label: 'Me', avatar: 'char-ranger', you: true, cameraTarget: true, placement: 2
|
||||
});
|
||||
expect(racers.find(e => e.id === 'rival')).toMatchObject({ you: false, cameraTarget: false });
|
||||
});
|
||||
|
||||
test('server you flag is honored and spectator camera falls back to a live racer', () => {
|
||||
const base = {
|
||||
visibleTiles: [[F, F, F]], exploredTiles: [[F, F, F]],
|
||||
entrance: [0, 0], exit: [2, 0], monster: null, lighting: {}
|
||||
};
|
||||
const marked = RK.sceneFromGameState({
|
||||
...base,
|
||||
players: [{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 1, y: 0, you: true }]
|
||||
}, {});
|
||||
expect(marked.entities.find(e => e.id === 'b')).toMatchObject({ you: true, cameraTarget: true });
|
||||
|
||||
const spectator = RK.sceneFromGameState({
|
||||
...base,
|
||||
players: [
|
||||
{ id: 'dead', x: 0, y: 0, alive: false },
|
||||
{ id: 'live', x: 1, y: 0, alive: true },
|
||||
{ id: 'done', x: 2, y: 0, alive: true, finished: true }
|
||||
]
|
||||
}, { viewerId: 'spectator' });
|
||||
expect(spectator.entities.find(e => e.cameraTarget).id).toBe('live');
|
||||
expect(spectator.entities.some(e => e.you)).toBe(false);
|
||||
});
|
||||
|
||||
test('match treasure object shape renders until it has a carrier', () => {
|
||||
const base = {
|
||||
visibleTiles: [[F, F]], exploredTiles: [[F, F]],
|
||||
players: [{ id: 'a', x: 0, y: 0 }], monster: null, lighting: {}
|
||||
};
|
||||
const loose = RK.sceneFromGameState({ ...base, treasure: { x: 1, y: 0, carrierId: null } }, {});
|
||||
expect(loose.entities.some(e => e.id === 'treasure')).toBe(true);
|
||||
const carried = RK.sceneFromGameState({ ...base, treasure: { x: 1, y: 0, carrierId: 'a' } }, {});
|
||||
expect(carried.entities.some(e => e.id === 'treasure')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
-- Behavioral smoke test for migrations 032/035/037/038/039/040.
|
||||
-- Run with psql -X -v ON_ERROR_STOP=1 after migrations. Everything rolls back.
|
||||
BEGIN;
|
||||
|
||||
DO $economic_invariants$
|
||||
DECLARE
|
||||
owner_id INTEGER;
|
||||
other_id INTEGER;
|
||||
product_payment_id INTEGER;
|
||||
solo_payment_id INTEGER;
|
||||
game_row_id INTEGER;
|
||||
payout_row_id INTEGER;
|
||||
match_row_id UUID;
|
||||
receipt_row_id BIGINT;
|
||||
receipt_tx CHAR(64) := md5(random()::text) || md5(clock_timestamp()::text);
|
||||
receipt_output CHAR(64) := md5(clock_timestamp()::text) || md5(random()::text);
|
||||
saved_address TEXT := 'test-wallet-' || txid_current()::text;
|
||||
BEGIN
|
||||
INSERT INTO users (socket_id, username, payout_address)
|
||||
VALUES ('economic-owner-' || txid_current()::text, 'economic-owner', saved_address)
|
||||
RETURNING id INTO owner_id;
|
||||
|
||||
INSERT INTO users (socket_id, username, payout_address)
|
||||
VALUES ('economic-other-' || txid_current()::text, 'economic-other', 'test-wallet-other')
|
||||
RETURNING id INTO other_id;
|
||||
|
||||
INSERT INTO payments (
|
||||
user_id, socket_id, subaddress, address_index, expected_amount, payment_type,
|
||||
payment_mode, credit_package_id, product_id, product_grants, description,
|
||||
provider_id, provider_invoice_id, status, expires_at
|
||||
) VALUES (
|
||||
owner_id, 'economic-product', 'test-product-subaddress', 771, 100,
|
||||
'credits_package', 'credits', 'small', 'small', '{"credits":10}'::jsonb,
|
||||
'10 credits', 'native-monero', 'test-product-invoice', 'pending', NOW() + INTERVAL '1 hour'
|
||||
) RETURNING id INTO product_payment_id;
|
||||
|
||||
-- Legacy pending rows retain one pre-receipt path to snapshot their product promise.
|
||||
UPDATE payments
|
||||
SET product_grants = '{"credits":10,"bonus":1}'::jsonb
|
||||
WHERE id = product_payment_id;
|
||||
|
||||
INSERT INTO payment_receipts (
|
||||
payment_id, provider_id, evidence_type, evidence_id, tx_hash, output_id,
|
||||
address_index, amount, confirmed
|
||||
) VALUES (
|
||||
product_payment_id, 'native-monero', 'chain_output',
|
||||
receipt_tx || ':' || receipt_output, receipt_tx, receipt_output, 771, 100, TRUE
|
||||
) RETURNING id INTO receipt_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE payments SET expected_amount = 1 WHERE id = product_payment_id;
|
||||
RAISE EXCEPTION 'receipt-backed invoice price mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
UPDATE payments SET product_grants = '{"credits":999}'::jsonb WHERE id = product_payment_id;
|
||||
RAISE EXCEPTION 'receipt-backed product promise mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
-- Settlement and refund state remain operationally mutable.
|
||||
UPDATE payments
|
||||
SET status = 'confirmed', received_amount = 100, confirmed_at = NOW()
|
||||
WHERE id = product_payment_id;
|
||||
UPDATE payments SET status = 'refunded' WHERE id = product_payment_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE payments SET provider_invoice_id = 'rewritten' WHERE id = product_payment_id;
|
||||
RAISE EXCEPTION 'refunded invoice provider identity mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
INSERT INTO payments (
|
||||
user_id, socket_id, subaddress, address_index, expected_amount, payment_type,
|
||||
product_id, product_grants, provider_id, provider_invoice_id, status
|
||||
) VALUES (
|
||||
owner_id, 'economic-expired', 'test-expired-subaddress', 773, 50,
|
||||
'credits_package', 'expired-small', '{"credits":5}'::jsonb,
|
||||
'native-monero', 'test-expired-invoice', 'expired'
|
||||
) RETURNING id INTO product_payment_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE payments SET subaddress = 'rewritten-expired-address' WHERE id = product_payment_id;
|
||||
RAISE EXCEPTION 'expired invoice destination mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
UPDATE payment_receipts SET amount = 1 WHERE id = receipt_row_id;
|
||||
RAISE EXCEPTION 'payment receipt update was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
DELETE FROM payment_receipts WHERE id = receipt_row_id;
|
||||
RAISE EXCEPTION 'payment receipt deletion was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
INSERT INTO payments (
|
||||
user_id, socket_id, subaddress, address_index, expected_amount, payment_type,
|
||||
provider_id, provider_invoice_id, status, fairness_proof_version,
|
||||
fairness_offer_id, fairness_offer_issued_at, fairness_commitment,
|
||||
fairness_server_seed, fairness_client_seed, fairness_bound_at
|
||||
) VALUES (
|
||||
owner_id, 'economic-solo', 'test-solo-subaddress', 772, 100, 'single_game',
|
||||
'native-monero', 'test-solo-invoice', 'pending', 2,
|
||||
'test-offer-' || txid_current()::text, NOW(), repeat('a', 64),
|
||||
repeat('b', 64), 'client-seed', NOW()
|
||||
) RETURNING id INTO solo_payment_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE payments SET user_id = other_id WHERE id = solo_payment_id;
|
||||
RAISE EXCEPTION 'fairness-bound invoice owner mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
INSERT INTO games (
|
||||
user_id, socket_id, game_mode, payment_id, status, dungeon_seed,
|
||||
payout_escape_amount, payout_treasure_amount, payout_escape_mult,
|
||||
payout_treasure_mult, payout_address, payout_eligible, payout_terms,
|
||||
payout_committed_at
|
||||
) VALUES (
|
||||
owner_id, 'economic-game', 'PAID_SINGLE', solo_payment_id, 'active',
|
||||
'economic-seed-' || txid_current()::text, 200, 300, 2, 3,
|
||||
saved_address, TRUE, '{"escape":200,"treasure":300}'::jsonb, NOW()
|
||||
) RETURNING id INTO game_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE games SET user_id = other_id WHERE id = game_row_id;
|
||||
RAISE EXCEPTION 'committed game owner mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
UPDATE games SET moves_made = moves_made + 1 WHERE id = game_row_id;
|
||||
|
||||
INSERT INTO payouts (
|
||||
user_id, game_id, payout_address, amount, multiplier, reason, status
|
||||
) VALUES (
|
||||
owner_id, game_row_id, 'PENDING_NO_ADDRESS', 200, 2,
|
||||
'solo_winner_no_address', 'needs_review'
|
||||
) RETURNING id INTO payout_row_id;
|
||||
|
||||
-- The sole destination exception is the exact saved-address claim transition.
|
||||
UPDATE payouts
|
||||
SET payout_address = saved_address, status = 'pending'
|
||||
WHERE id = payout_row_id;
|
||||
UPDATE payouts SET retry_count = retry_count + 1 WHERE id = payout_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE payouts SET payout_address = 'test-wallet-rewritten' WHERE id = payout_row_id;
|
||||
RAISE EXCEPTION 'claimed payout destination mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
UPDATE payouts SET amount = 1 WHERE id = payout_row_id;
|
||||
RAISE EXCEPTION 'payout amount mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO payouts (
|
||||
user_id, payout_address, amount, multiplier, reason, status
|
||||
) VALUES (
|
||||
owner_id, 'PENDING_NO_ADDRESS', 1, 1,
|
||||
'solo_winner_no_address', 'pending'
|
||||
);
|
||||
RAISE EXCEPTION 'invalid no-address payout shape was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
-- An unresolved paid winner remains recorded for manual review, but its missing owner means
|
||||
-- the saved-address exception must never make it dispatchable automatically.
|
||||
INSERT INTO payouts (
|
||||
user_id, payout_address, amount, multiplier, reason, status
|
||||
) VALUES (
|
||||
NULL, 'PENDING_NO_ADDRESS', 1, 1,
|
||||
'solo_winner_identity_review', 'needs_review'
|
||||
) RETURNING id INTO payout_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE payouts
|
||||
SET payout_address = saved_address, status = 'pending'
|
||||
WHERE id = payout_row_id;
|
||||
RAISE EXCEPTION 'unresolved solo winner address claim was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
INSERT INTO matches (
|
||||
economy, variant, ruleset_id, difficulty_preset, max_players,
|
||||
seed_hash, dungeon, start_block_height
|
||||
) VALUES (
|
||||
'credits_prestige', 'pvp', 'last-alive', 'race', 2,
|
||||
repeat('c', 64),
|
||||
jsonb_build_object('match_fairness_freeze', jsonb_build_object(
|
||||
'version', 'future-block-freeze-v2',
|
||||
'freezeBlockHeight', 800,
|
||||
'targetBlockHeight', 802,
|
||||
'entropyDelayBlocks', 2,
|
||||
'economy', 'credits_prestige',
|
||||
'rulesetId', 'last-alive',
|
||||
'queueEntryIds', jsonb_build_array('81', '82'),
|
||||
'freezeCommitment', repeat('c', 64)
|
||||
)),
|
||||
802
|
||||
) RETURNING id INTO match_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE matches SET entropy_precommit_tip_height = 800 WHERE id = match_row_id;
|
||||
RAISE EXCEPTION 'partial paid entropy precommit marker was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
UPDATE matches
|
||||
SET entropy_precommit_tip_height = 800,
|
||||
entropy_precommit_verified_at = NOW()
|
||||
WHERE id = match_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE matches SET entropy_precommit_tip_height = 799 WHERE id = match_row_id;
|
||||
RAISE EXCEPTION 'paid entropy precommit witness mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
UPDATE matches SET seed_hash = repeat('d', 64) WHERE id = match_row_id;
|
||||
RAISE EXCEPTION 'verified pending paid freeze identity mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
-- The single activation transition may replace the freeze envelope with the derived proof.
|
||||
UPDATE matches
|
||||
SET status = 'starting',
|
||||
seed_hash = repeat('e', 64),
|
||||
dungeon = jsonb_build_object('match_fairness', jsonb_build_object(
|
||||
'version', 'future-chain-block-v2',
|
||||
'freezeCommitment', repeat('c', 64)
|
||||
))
|
||||
WHERE id = match_row_id;
|
||||
|
||||
BEGIN
|
||||
UPDATE matches SET ruleset_id = 'race' WHERE id = match_row_id;
|
||||
RAISE EXCEPTION 'active paid match fairness identity mutation was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
UPDATE matches SET status = 'active', started_at = NOW() WHERE id = match_row_id;
|
||||
|
||||
RAISE NOTICE 'economic invariants behavioral test passed';
|
||||
END
|
||||
$economic_invariants$;
|
||||
|
||||
ROLLBACK;
|
||||
@@ -0,0 +1,382 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
LIVE_CONFIRM,
|
||||
SAFE_PROFILE_CONFIRM,
|
||||
SCENARIOS,
|
||||
addVisible,
|
||||
assertEmptyFinancialDatabase,
|
||||
assertInvoiceNotOwned,
|
||||
assertLiveSafety,
|
||||
canonicalAcknowledgement,
|
||||
chooseMove,
|
||||
readConfiguration,
|
||||
safeMessage,
|
||||
validateExpectedDatabaseName,
|
||||
verifyCanaryDatabaseHandshake,
|
||||
verifyGameOver
|
||||
} = require('../src/scripts/stagenet-financial-canary');
|
||||
const {
|
||||
CANARY_HANDSHAKE_PROTOCOL,
|
||||
computeProof
|
||||
} = require('../src/services/canaryDatabaseIdentity');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '../src/scripts/stagenet-financial-canary.js');
|
||||
|
||||
function configEnv(overrides = {}) {
|
||||
return {
|
||||
E2E_MODE: 'preflight',
|
||||
E2E_TARGET: 'http://127.0.0.1:3102',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function botState() {
|
||||
return {
|
||||
goal: null,
|
||||
objective: null,
|
||||
frontiersTried: new Set()
|
||||
};
|
||||
}
|
||||
|
||||
function visibleState({ player, exit, treasure }) {
|
||||
return {
|
||||
player,
|
||||
exit,
|
||||
treasure,
|
||||
monster: null,
|
||||
visibleTiles: {
|
||||
0: { 0: "'1", 1: "'1", 2: "'1" },
|
||||
1: { 0: "'1", 1: "'1", 2: "'1" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('XMR stagenet financial canary safety contract', () => {
|
||||
test('recognizes only the two explicit financial scenarios', () => {
|
||||
expect(Object.keys(SCENARIOS)).toEqual(['direct-2x', 'credits-3x']);
|
||||
expect(SCENARIOS['direct-2x']).toEqual(expect.objectContaining({
|
||||
paymentType: 'single_game',
|
||||
gameMode: 'PAID_SINGLE',
|
||||
multiplier: 2,
|
||||
collectTreasure: false
|
||||
}));
|
||||
expect(SCENARIOS['credits-3x']).toEqual(expect.objectContaining({
|
||||
paymentType: 'credits_package',
|
||||
gameMode: 'PAID_CREDITS',
|
||||
multiplier: 3,
|
||||
collectTreasure: true
|
||||
}));
|
||||
expect(() => readConfiguration(configEnv({ E2E_SCENARIO: 'anything-else' })))
|
||||
.toThrow(/direct-2x or credits-3x/);
|
||||
});
|
||||
|
||||
test('refuses public targets, embedded credentials, paths, and the wrong port', () => {
|
||||
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'https://example.test:3102' })))
|
||||
.toThrow(/localhost/);
|
||||
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://user:pass@127.0.0.1:3102' })))
|
||||
.toThrow(/credentials/);
|
||||
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://127.0.0.1:3000' })))
|
||||
.toThrow(/3102/);
|
||||
expect(() => readConfiguration(configEnv({ E2E_TARGET: 'http://127.0.0.1:3102/app' })))
|
||||
.toThrow(/path/);
|
||||
});
|
||||
|
||||
test('requires a scenario outside public preflight and scenario-specific live intent', () => {
|
||||
expect(() => readConfiguration(configEnv({ E2E_MODE: 'database-preflight' })))
|
||||
.toThrow(/E2E_SCENARIO/);
|
||||
|
||||
const env = configEnv({
|
||||
E2E_MODE: 'live-stagenet',
|
||||
E2E_SCENARIO: 'credits-3x',
|
||||
E2E_MAX_TRANSFER_ATOMIC: '500000000000',
|
||||
E2E_CONFIRM: LIVE_CONFIRM,
|
||||
E2E_SCENARIO_CONFIRM: SCENARIOS['credits-3x'].scenarioConfirm,
|
||||
E2E_CANARY_PROFILE: SAFE_PROFILE_CONFIRM,
|
||||
E2E_DATABASE_NONCE_FILE: '/run/credentials/canary/database-nonce'
|
||||
});
|
||||
const config = readConfiguration(env);
|
||||
expect(() => assertLiveSafety(config, env)).not.toThrow();
|
||||
expect(() => assertLiveSafety(config, { ...env, E2E_SCENARIO_CONFIRM: 'DIRECT_2X_ESCAPE' }))
|
||||
.toThrow(/CREDITS_PACKAGE_THEN_3X/);
|
||||
expect(() => assertLiveSafety(config, { ...env, E2E_CONFIRM: '' }))
|
||||
.toThrow(/E2E_CONFIRM/);
|
||||
const noNonceConfig = readConfiguration({ ...env, E2E_DATABASE_NONCE_FILE: '' });
|
||||
expect(() => assertLiveSafety(noNonceConfig, env)).toThrow(/DATABASE_NONCE_FILE/);
|
||||
expect(() => assertLiveSafety(
|
||||
readConfiguration({ ...env, E2E_MAX_TRANSFER_ATOMIC: '' }), env
|
||||
)).toThrow(/MAX_TRANSFER/);
|
||||
});
|
||||
|
||||
test('builds exactly the current five-field stagenet acknowledgement', () => {
|
||||
const acknowledgement = canonicalAcknowledgement({
|
||||
policyVersion: '2026-07-21-v2',
|
||||
paidAcknowledgementRequired: true,
|
||||
service: {
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
isTestNetwork: true,
|
||||
paymentsEnabled: true,
|
||||
directPaidEntryEnabled: true,
|
||||
paidCreditsEnabled: true,
|
||||
soloPayoutsEnabled: true,
|
||||
anyPayoutsEnabled: true,
|
||||
paidPrestigeOnly: false
|
||||
}
|
||||
});
|
||||
expect(acknowledgement).toEqual({
|
||||
policyVersion: '2026-07-21-v2',
|
||||
ageEligible: true,
|
||||
termsRead: true,
|
||||
riskAccepted: true,
|
||||
testnetUnderstood: true
|
||||
});
|
||||
expect(Object.keys(acknowledgement)).toHaveLength(5);
|
||||
expect(Object.isFrozen(acknowledgement)).toBe(true);
|
||||
expect(() => canonicalAcknowledgement({
|
||||
policyVersion: 'v1',
|
||||
paidAcknowledgementRequired: true,
|
||||
service: {
|
||||
cryptoType: 'XMR',
|
||||
network: 'mainnet',
|
||||
isTestNetwork: false,
|
||||
paymentsEnabled: true,
|
||||
directPaidEntryEnabled: true,
|
||||
paidCreditsEnabled: true,
|
||||
soloPayoutsEnabled: true,
|
||||
anyPayoutsEnabled: true,
|
||||
paidPrestigeOnly: false
|
||||
}
|
||||
})).toThrow(/stagenet/);
|
||||
});
|
||||
|
||||
test('requires a scenario-named dedicated canary E2E database', () => {
|
||||
expect(() => validateExpectedDatabaseName(
|
||||
'monerogue_canary_direct_e2e', SCENARIOS['direct-2x']
|
||||
)).not.toThrow();
|
||||
expect(() => validateExpectedDatabaseName(
|
||||
'monerogue_canary_credits_e2e', SCENARIOS['credits-3x']
|
||||
)).not.toThrow();
|
||||
expect(() => validateExpectedDatabaseName('monerogue', SCENARIOS['direct-2x']))
|
||||
.toThrow(/canary and e2e/);
|
||||
expect(() => validateExpectedDatabaseName(
|
||||
'monerogue_canary_credits_e2e', SCENARIOS['direct-2x']
|
||||
)).toThrow(/scenario tag/);
|
||||
});
|
||||
|
||||
test('freshness guard covers all financial/game ledgers and current migrations', async () => {
|
||||
const row = {
|
||||
users: 1,
|
||||
seed_admins: 1,
|
||||
required_migrations: 5,
|
||||
payments: 0,
|
||||
games: 0,
|
||||
payouts: 0,
|
||||
receipts: 0,
|
||||
refunds: 0,
|
||||
late_reviews: 0,
|
||||
credit_transactions: 0,
|
||||
entitlement_grants: 0,
|
||||
pack_entitlements: 0,
|
||||
matches: 0,
|
||||
match_entrants: 0,
|
||||
match_events: 0,
|
||||
match_queue_entries: 0,
|
||||
race_entry_transactions: 0,
|
||||
race_entry_lots: 0
|
||||
};
|
||||
const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
try {
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) };
|
||||
await expect(assertEmptyFinancialDatabase(db)).resolves.toBeUndefined();
|
||||
expect(db.query.mock.calls[0][0]).toContain('payment_receipts');
|
||||
expect(db.query.mock.calls[0][0]).toContain('payment_entitlement_grants');
|
||||
expect(db.query.mock.calls[0][0]).toContain('race_entry_lots');
|
||||
|
||||
const dirty = { query: jest.fn().mockResolvedValue({
|
||||
rows: [{ ...row, receipts: 1 }]
|
||||
}) };
|
||||
await expect(assertEmptyFinancialDatabase(dirty)).rejects.toThrow(/receipts/);
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('verifies the app and harness exact database identity before the live runner', async () => {
|
||||
const directory = fs.mkdtempSync('/tmp/wowngeon-canary-handshake-');
|
||||
const nonceFile = path.join(directory, 'nonce');
|
||||
const nonce = 'cd'.repeat(32);
|
||||
fs.writeFileSync(nonceFile, `${nonce}\n`, { mode: 0o600 });
|
||||
const config = readConfiguration(configEnv({
|
||||
E2E_MODE: 'live-stagenet',
|
||||
E2E_SCENARIO: 'direct-2x',
|
||||
E2E_MAX_TRANSFER_ATOMIC: '1000',
|
||||
E2E_DATABASE_NONCE_FILE: nonceFile
|
||||
}));
|
||||
const identity = {
|
||||
clusterId: '7612345678901234567',
|
||||
databaseOid: '24591',
|
||||
databaseName: 'monerogue_canary_direct_e2e'
|
||||
};
|
||||
const db = { query: jest.fn().mockResolvedValue({ rows: [{
|
||||
cluster_id: identity.clusterId,
|
||||
database_oid: identity.databaseOid,
|
||||
database_name: identity.databaseName
|
||||
}] }) };
|
||||
const request = jest.fn(async (_target, relative, options) => {
|
||||
const challenge = options.headers['X-Canary-Database-Challenge'];
|
||||
return {
|
||||
response: {
|
||||
status: 200,
|
||||
headers: { get: name => name === 'cache-control' ? 'no-store' : null }
|
||||
},
|
||||
body: {
|
||||
protocol: CANARY_HANDSHAKE_PROTOCOL,
|
||||
challenge,
|
||||
database: identity,
|
||||
proof: computeProof(nonce, identity, challenge)
|
||||
}
|
||||
};
|
||||
});
|
||||
const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
try {
|
||||
await expect(verifyCanaryDatabaseHandshake(config, db, {
|
||||
E2E_EXPECT_DATABASE: identity.databaseName
|
||||
}, request)).resolves.toBeUndefined();
|
||||
expect(request).toHaveBeenCalledWith(config.target,
|
||||
'/api/canary/database-identity', expect.any(Object));
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires affirmative proof that invoice and funding wallets differ', () => {
|
||||
expect(() => assertInvoiceNotOwned({
|
||||
error: { message: "Address doesn't belong to the wallet" }
|
||||
})).not.toThrow();
|
||||
expect(() => assertInvoiceNotOwned({ result: { index: { major: 0, minor: 1 } } }))
|
||||
.toThrow(/self-payment/);
|
||||
expect(() => assertInvoiceNotOwned({ error: { message: 'daemon unavailable' } }))
|
||||
.toThrow(/could not prove/);
|
||||
});
|
||||
|
||||
test('direct bot avoids treasure while credits bot gets treasure before exit', () => {
|
||||
const directState = visibleState({
|
||||
player: { x: 0, y: 0, hasTreasure: false },
|
||||
treasure: [1, 0],
|
||||
exit: [0, 1]
|
||||
});
|
||||
const directKnown = new Map();
|
||||
addVisible(directKnown, directState);
|
||||
expect(chooseMove(directKnown, directState, botState(), SCENARIOS['direct-2x']))
|
||||
.toBe('down');
|
||||
|
||||
const creditsState = visibleState({
|
||||
player: { x: 0, y: 0, hasTreasure: false },
|
||||
treasure: [1, 0],
|
||||
exit: [2, 0]
|
||||
});
|
||||
const creditsKnown = new Map();
|
||||
addVisible(creditsKnown, creditsState);
|
||||
const creditsBot = botState();
|
||||
expect(chooseMove(creditsKnown, creditsState, creditsBot, SCENARIOS['credits-3x']))
|
||||
.toBe('right');
|
||||
const collected = {
|
||||
...creditsState,
|
||||
player: { x: 1, y: 0, hasTreasure: true },
|
||||
treasure: null
|
||||
};
|
||||
expect(chooseMove(creditsKnown, collected, creditsBot, SCENARIOS['credits-3x']))
|
||||
.toBe('right');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['direct-2x', 10n, false],
|
||||
['credits-3x', 7n, true]
|
||||
])('verifies exact game-over liability and two-party proof for %s', (scenarioName, base, treasure) => {
|
||||
const scenario = SCENARIOS[scenarioName];
|
||||
const serverSeed = '11'.repeat(32);
|
||||
const clientSeed = '22'.repeat(32);
|
||||
const commitment = crypto.createHash('sha256').update(serverSeed).digest('hex');
|
||||
const effectiveSeed = crypto.createHmac('sha256', serverSeed).update(clientSeed).digest('hex');
|
||||
const offer = { offerId: '33'.repeat(32), commitment };
|
||||
const gameId = 'game-proof-id';
|
||||
const expected = base * BigInt(scenario.multiplier);
|
||||
const gameOver = {
|
||||
status: 'won',
|
||||
reason: 'escaped',
|
||||
treasure,
|
||||
payout: {
|
||||
success: true,
|
||||
mode: scenario.gameMode,
|
||||
payout: {
|
||||
status: 'queued',
|
||||
payoutId: 9,
|
||||
amount: expected.toString(),
|
||||
multiplier: scenario.multiplier
|
||||
}
|
||||
},
|
||||
proof: {
|
||||
gameId,
|
||||
offerId: offer.offerId,
|
||||
commitment,
|
||||
clientSeed,
|
||||
serverSeed,
|
||||
effectiveSeed,
|
||||
gameResult: { won: true, treasureFound: treasure }
|
||||
}
|
||||
};
|
||||
const write = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
try {
|
||||
expect(verifyGameOver(gameOver, {
|
||||
scenario, payoutBase: base, offer, clientSeed
|
||||
}, { proof: { gameId } })).toEqual({
|
||||
expected,
|
||||
payoutId: 9,
|
||||
gameId
|
||||
});
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('redacts addresses, hashes, tokens, credentials, and database URL passwords', () => {
|
||||
const address = '5'.repeat(95);
|
||||
const hash = 'a'.repeat(64);
|
||||
const raw = `${address} ${hash} token=secret {"authorization":"jsonsecret"} Bearer othersecret `
|
||||
+ 'postgres://alice:password@example.invalid/db';
|
||||
const safe = safeMessage(raw);
|
||||
expect(safe).toContain('[redacted-address]');
|
||||
expect(safe).toContain('[redacted-64-byte-value]');
|
||||
expect(safe).not.toContain(address);
|
||||
expect(safe).not.toContain(hash);
|
||||
expect(safe).not.toContain('secret');
|
||||
expect(safe).not.toContain('password@example');
|
||||
});
|
||||
|
||||
test('source has one non-retriable relay call, a guarded main, and both exact flows', () => {
|
||||
const source = fs.readFileSync(SCRIPT, 'utf8');
|
||||
expect((source.match(/funding\.rpc\('transfer'/g) || [])).toHaveLength(1);
|
||||
expect(source).toMatch(/transferGate\.attempted = true;[\s\S]*do_not_relay: false/);
|
||||
expect(source).toContain('if (require.main === module)');
|
||||
expect(source).toContain("type: 'single_game'");
|
||||
expect(source).toContain("type: 'credits_package'");
|
||||
expect(source).toContain("socket.emit('auto_start'");
|
||||
expect(source).toContain('legalAcknowledgement: acknowledgement');
|
||||
expect(source).not.toContain('console.log(');
|
||||
const mainBody = source.slice(source.indexOf('async function main('));
|
||||
expect(mainBody.indexOf('assertLiveSafety(config, env)'))
|
||||
.toBeLessThan(mainBody.indexOf('publicPreflight(config)'));
|
||||
expect(mainBody.indexOf('verifyCanaryDatabaseHandshake(config, db, env)'))
|
||||
.toBeLessThan(mainBody.indexOf('runLive(config, modules, db, legal, env)'));
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/package.json'), 'utf8'));
|
||||
expect(manifest.scripts['canary:stagenet'])
|
||||
.toBe('node scripts/stagenet-financial-canary.js');
|
||||
});
|
||||
});
|
||||
@@ -27,15 +27,19 @@ describe('TavernMatchBridge', () => {
|
||||
return {
|
||||
id,
|
||||
economy: 'free',
|
||||
variant: 'race',
|
||||
tickCount: 5,
|
||||
status: 'active',
|
||||
rows: 2,
|
||||
cols: 3,
|
||||
occupants: new Map([['a', { id: 'a', x: 1, y: 2, name: 'A', avatar: 'default', facing: 'down' }]]),
|
||||
playerStates: new Map([['a', { alive: true, finished: false, escaped: false, hasTreasure: false, placement: null }]]),
|
||||
monster: { x: 3, y: 4 },
|
||||
treasure: null,
|
||||
dungeon: { exit: [10, 10] },
|
||||
dungeon: { map: [["'1", "'1", '#'], ['#', "'1", '>']], entrance: [0, 0], exit: [2, 1] },
|
||||
winnerId: null,
|
||||
seedHash: 'deadbeef'
|
||||
seedHash: 'deadbeef',
|
||||
seed: 'never-publish-this'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,4 +69,30 @@ describe('TavernMatchBridge', () => {
|
||||
expect(list[0].matchId).toBe('m3');
|
||||
expect(list[0].players.length).toBe(1);
|
||||
});
|
||||
|
||||
test('public snapshots include a renderer-safe map but never the unrevealed seed', () => {
|
||||
const room = makeRoom('m-render');
|
||||
room.toGameState = () => ({
|
||||
visibleTiles: room.dungeon.map,
|
||||
exploredTiles: room.dungeon.map,
|
||||
lighting: {},
|
||||
dungeonRows: 2,
|
||||
dungeonCols: 3,
|
||||
players: [{ id: 'a', x: 1, y: 1, alive: true }],
|
||||
monster: { x: 0, y: 1 },
|
||||
treasure: { x: 1, y: 0, carrierId: null },
|
||||
entrance: [0, 0],
|
||||
exit: [2, 1],
|
||||
seed: room.seed,
|
||||
privateEvents: [{ input: 'left' }]
|
||||
});
|
||||
const { bridge } = makeBridge({ rooms: [room] });
|
||||
const snap = bridge.getActiveMatches()[0];
|
||||
|
||||
expect(snap.visibleTiles).toEqual(room.dungeon.map);
|
||||
expect(snap.exploredTiles).toEqual(room.dungeon.map);
|
||||
expect(snap).toMatchObject({ dungeonRows: 2, dungeonCols: 3, seedHash: 'deadbeef' });
|
||||
expect(snap.seed).toBeUndefined();
|
||||
expect(snap.privateEvents).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('renderVerifyPage social card meta', () => {
|
||||
ogImage: 'og-card-xmr.png'
|
||||
});
|
||||
expect(html).toContain('property="og:title"');
|
||||
expect(html).toContain('Monero Roguelike — Provably-Fair Roguelike');
|
||||
expect(html).toContain('Monero Roguelike — Dungeon Layout Verification');
|
||||
expect(html).toContain('<meta property="og:image" content="https://monerogue.app/og-card-xmr.png">');
|
||||
expect(html).toContain('content="summary_large_image"');
|
||||
expect(html).toContain('<meta property="og:url" content="https://monerogue.app/verify/seedABC">');
|
||||
@@ -39,4 +39,17 @@ describe('renderVerifyPage social card meta', () => {
|
||||
expect(html).not.toContain('<script>x');
|
||||
expect(html).toContain('"><script>x');
|
||||
});
|
||||
|
||||
test('renders API verification results with text nodes, not dynamic innerHTML', () => {
|
||||
const html = renderVerifyPage('seed-safe', null, { gameName: 'Wownerogue' });
|
||||
expect(html).not.toMatch(/result\.innerHTML\s*=/);
|
||||
expect(html).toMatch(/code\.textContent\s*=/);
|
||||
expect(html).toMatch(/showFailure\(data\.message \|\| data\.error/);
|
||||
});
|
||||
|
||||
test('states the proof scope without claiming to replay outcomes or payouts', () => {
|
||||
const html = renderVerifyPage('seed-scope', null, { gameName: 'Wownerogue' });
|
||||
expect(html).toContain('recorded dungeon layouts');
|
||||
expect(html).toContain('does not replay player input, block timing, monster turns, the reported outcome, or payout delivery');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
const WalletRPCService = require('../src/payments/walletRPCService');
|
||||
|
||||
const ADDRESSES = Object.freeze({
|
||||
'XMR:mainnet': `4${'1'.repeat(94)}`,
|
||||
'XMR:stagenet': `5${'1'.repeat(94)}`,
|
||||
'XMR:testnet': `9${'1'.repeat(94)}`,
|
||||
'WOW:mainnet': `Wo${'1'.repeat(95)}`
|
||||
});
|
||||
|
||||
function walletHttp({ address, network, height = 123, valid = true }) {
|
||||
return {
|
||||
post: jest.fn(async (_url, body) => {
|
||||
if (body.method === 'get_version') return { data: { result: { version: 1 } } };
|
||||
if (body.method === 'get_height') return { data: { result: { height } } };
|
||||
if (body.method === 'get_address') return { data: { result: { address } } };
|
||||
if (body.method === 'validate_address') {
|
||||
return { data: { result: { valid, nettype: network } } };
|
||||
}
|
||||
if (body.method === 'create_address') {
|
||||
return { data: { result: { address, address_index: 1 } } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${body.method}`);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
describe('WalletRPCService production identity', () => {
|
||||
const debugManager = { CONSOLE_LOGGING: false };
|
||||
|
||||
test.each([
|
||||
['XMR', 'mainnet'],
|
||||
['XMR', 'stagenet'],
|
||||
['XMR', 'testnet'],
|
||||
['WOW', 'mainnet']
|
||||
])('accepts a validated %s %s primary address', async (cryptoType, network) => {
|
||||
const address = ADDRESSES[`${cryptoType}:${network}`];
|
||||
const service = new WalletRPCService(debugManager, {
|
||||
http: walletHttp({ address, network }),
|
||||
walletEndpoint: 'http://wallet.test',
|
||||
cryptoType,
|
||||
network,
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
await expect(service.initialize()).resolves.toBe(true);
|
||||
expect(service.getIdentityStatus()).toMatchObject({
|
||||
required: true,
|
||||
verified: true,
|
||||
expected: { cryptoType, network },
|
||||
actual: { cryptoType, network }
|
||||
});
|
||||
expect(JSON.stringify(service.getHealthStatus())).not.toContain(address);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['wrong chain', 'WOW', 'mainnet', ADDRESSES['XMR:mainnet'], 'mainnet'],
|
||||
['wrong network', 'XMR', 'mainnet', ADDRESSES['XMR:stagenet'], 'stagenet']
|
||||
])('fails initialization for the %s without logging the address', async (
|
||||
_label, cryptoType, expectedNetwork, address, actualNetwork
|
||||
) => {
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const service = new WalletRPCService(debugManager, {
|
||||
http: walletHttp({ address, network: actualNetwork }),
|
||||
walletEndpoint: 'http://wallet.test',
|
||||
cryptoType,
|
||||
network: expectedNetwork,
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
await expect(service.initialize()).resolves.toBe(false);
|
||||
expect(service.getIdentityStatus().verified).toBe(false);
|
||||
expect(error.mock.calls.flat().join(' ')).not.toContain(address);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
test('blocks money-changing calls when the configured identity does not match', async () => {
|
||||
const http = walletHttp({
|
||||
address: ADDRESSES['XMR:mainnet'],
|
||||
network: 'mainnet'
|
||||
});
|
||||
const service = new WalletRPCService(debugManager, {
|
||||
http,
|
||||
walletEndpoint: 'http://wallet.test',
|
||||
cryptoType: 'WOW',
|
||||
network: 'mainnet',
|
||||
identityRequired: true
|
||||
});
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(service.createPaymentRequest(1, 'entry', 'user', 'socket'))
|
||||
.rejects.toMatchObject({ code: 'WALLET_IDENTITY_MISMATCH' });
|
||||
expect(http.post.mock.calls.map(([, body]) => body.method)).not.toContain('create_address');
|
||||
expect(error.mock.calls.flat().join(' ')).not.toContain(ADDRESSES['XMR:mainnet']);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
test('development may connect for diagnostics but remains explicitly unverified', async () => {
|
||||
const service = new WalletRPCService(debugManager, {
|
||||
http: walletHttp({ address: ADDRESSES['XMR:stagenet'], network: 'stagenet' }),
|
||||
walletEndpoint: 'http://wallet.test',
|
||||
cryptoType: 'XMR',
|
||||
network: 'mainnet',
|
||||
identityRequired: false
|
||||
});
|
||||
|
||||
await expect(service.initialize()).resolves.toBe(true);
|
||||
expect(service.getIdentityStatus()).toMatchObject({ required: false, verified: false });
|
||||
});
|
||||
|
||||
test('a transport-only liveness response cannot revive stale production identity', async () => {
|
||||
const service = new WalletRPCService(debugManager, {
|
||||
http: walletHttp({ address: ADDRESSES['XMR:stagenet'], network: 'stagenet' }),
|
||||
walletEndpoint: 'http://wallet.test',
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
identityRequired: true,
|
||||
identityMaxAgeMs: 1000
|
||||
});
|
||||
await expect(service.initialize()).resolves.toBe(true);
|
||||
service.networkIdentity.checkedAt = Date.now() - 2000;
|
||||
service.isHealthy = false;
|
||||
|
||||
await expect(service.rpcCall('get_version')).resolves.toBeDefined();
|
||||
|
||||
expect(service.isHealthy).toBe(false);
|
||||
expect(service.getHealthStatus().healthy).toBe(false);
|
||||
expect(service.getIdentityStatus().verified).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a zero-height wallet and redacts URL credentials from health', async () => {
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const service = new WalletRPCService(debugManager, {
|
||||
http: walletHttp({ address: ADDRESSES['XMR:stagenet'], network: 'stagenet', height: 0 }),
|
||||
walletEndpoint: 'http://rpc-user:rpc-password@wallet.test',
|
||||
cryptoType: 'XMR',
|
||||
network: 'stagenet',
|
||||
identityRequired: true
|
||||
});
|
||||
|
||||
await expect(service.initialize()).resolves.toBe(false);
|
||||
expect(service.getHealthStatus().endpoint).toBe('http://wallet.test');
|
||||
expect(JSON.stringify(service.getHealthStatus())).not.toMatch(/rpc-user|rpc-password/);
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const deployPath = name => path.join(__dirname, '..', 'scripts', 'deploy', name);
|
||||
const deploy = name => fs.readFileSync(deployPath(name), 'utf8');
|
||||
|
||||
describe('parallel Monero stagenet wallet candidate boundary', () => {
|
||||
const preflight = deploy('monero-stagenet-parallel-preflight.py');
|
||||
const unit = deploy('monerogue-stagenet-wallet-candidate.service');
|
||||
const runbook = deploy('README-monero-stagenet-parallel-candidate.md');
|
||||
|
||||
test('preflight is fixed, read-only, and does not inspect password-bearing sources', () => {
|
||||
expect(preflight).toContain('LEGACY_UNIT = "monero-wallet-rpc.service"');
|
||||
expect(preflight).toContain('LEGACY_RPC_PORT = 38083');
|
||||
expect(preflight).toContain('CANDIDATE_RPC_PORT = 38084');
|
||||
expect(preflight).toContain('VERIFY_RPC_PORT = 39084');
|
||||
expect(preflight).toContain('PR_SET_DUMPABLE');
|
||||
expect(preflight).toContain('O_NOFOLLOW');
|
||||
expect(preflight).toContain('details.st_nlink != 1');
|
||||
expect(preflight).not.toContain('/cmdline');
|
||||
expect(preflight).not.toContain('ExecStart');
|
||||
expect(preflight).not.toContain('wallet-password');
|
||||
expect(preflight).not.toMatch(/systemctl\([^\n]*(?:stop|start|restart|enable)/);
|
||||
expect(preflight).not.toMatch(/os\.(?:mkdir|unlink|remove|rename|chmod|chown|replace)\(/);
|
||||
expect(preflight).not.toContain('os.O_WRONLY');
|
||||
expect(preflight).not.toContain('mainnet');
|
||||
});
|
||||
|
||||
test('candidate unit is a manual inert service with no live-app dependency', () => {
|
||||
expect(unit).toContain('User=monerogue-wallet-candidate');
|
||||
expect(unit).toContain('Group=monerogue-wallet-candidate');
|
||||
expect(unit).toContain('SupplementaryGroups=');
|
||||
expect(unit).toContain('ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY');
|
||||
expect(unit).toContain('--config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf');
|
||||
expect(unit).toContain('--log-file /dev/null');
|
||||
expect(unit).toContain('Restart=no');
|
||||
expect(unit).toContain('LimitCORE=0');
|
||||
expect(unit).toContain('ProtectSystem=strict');
|
||||
expect(unit).not.toContain('[Install]');
|
||||
expect(unit).not.toContain('WantedBy=');
|
||||
expect(unit).not.toContain('monerogue.service');
|
||||
expect(unit).not.toContain('38083');
|
||||
expect(unit).not.toContain('mainnet');
|
||||
});
|
||||
|
||||
test('runbook keeps host mutation and production deployment at NO-GO', () => {
|
||||
expect(runbook).toContain('STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT');
|
||||
expect(runbook).toContain('never alters the legacy artifacts');
|
||||
expect(runbook).toContain('renameat2(RENAME_NOREPLACE)');
|
||||
expect(runbook).toContain('write `READY` **last**');
|
||||
expect(runbook).toContain('production-deployable answer is **no**');
|
||||
expect(runbook).toContain('mnemonic/off-host recovery custody is resolved separately');
|
||||
});
|
||||
|
||||
test('disposable filesystem fixture passes independently', () => {
|
||||
const result = spawnSync(
|
||||
'/usr/bin/python3',
|
||||
[deployPath('monero-stagenet-parallel-fixture-test.py')],
|
||||
{ encoding: 'utf8', timeout: 15000 }
|
||||
);
|
||||
expect({ status: result.status, signal: result.signal, stderr: result.stderr }).toEqual({
|
||||
status: 0,
|
||||
signal: null,
|
||||
stderr: expect.stringContaining('OK')
|
||||
});
|
||||
});
|
||||
});
|
||||
+236
-16
@@ -3,6 +3,34 @@ const WalletRPCService = require('../src/payments/walletRPCService');
|
||||
// Minimal debugManager mock
|
||||
const debugManagerMock = { CONSOLE_LOGGING: false };
|
||||
|
||||
function confirmedTransfer({ txid, amount, height, minor, major = 0, confirmations = 1,
|
||||
doubleSpend = false, unlockTime = 0 }) {
|
||||
return {
|
||||
txid,
|
||||
amount,
|
||||
height,
|
||||
confirmations,
|
||||
double_spend_seen: doubleSpend,
|
||||
unlock_time: unlockTime,
|
||||
subaddr_index: { major, minor }
|
||||
};
|
||||
}
|
||||
|
||||
function incomingOutput({ txHash, amount, blockHeight, minor, major = 0, pubkey,
|
||||
globalIndex, frozen = false, spent = false, unlocked = false }) {
|
||||
return {
|
||||
tx_hash: txHash,
|
||||
amount,
|
||||
block_height: blockHeight,
|
||||
frozen,
|
||||
global_index: globalIndex,
|
||||
pubkey,
|
||||
spent,
|
||||
subaddr_index: { major, minor },
|
||||
unlocked
|
||||
};
|
||||
}
|
||||
|
||||
describe('WalletRPCService interface', () => {
|
||||
|
||||
test('exposes expected methods', () => {
|
||||
@@ -25,6 +53,17 @@ describe('WalletRPCService interface', () => {
|
||||
await expect(svc.processBatchPayout([])).rejects.toThrow(/destinations/i);
|
||||
});
|
||||
|
||||
test('the injected payout gate blocks every transfer entrypoint before wallet RPC', async () => {
|
||||
const svc = new WalletRPCService(debugManagerMock, { transferAllowed: () => false });
|
||||
svc.rpcCall = jest.fn();
|
||||
|
||||
await expect(svc.processPayout({ address: '5'.repeat(95), amount: 1 }))
|
||||
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
|
||||
await expect(svc.processBatchPayout([{ address: '5'.repeat(95), amount: 1 }]))
|
||||
.rejects.toMatchObject({ code: 'PAYOUT_DISPATCH_DISABLED', statusCode: 503 });
|
||||
expect(svc.rpcCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rpcCall wraps failures in ExternalServiceError', async () => {
|
||||
const svc = new WalletRPCService(debugManagerMock);
|
||||
svc.walletEndpoint = 'http://127.0.0.1:65535';
|
||||
@@ -73,10 +112,10 @@ describe('WalletRPCService.normalizeAtomicAmount', () => {
|
||||
expect(result).toBe(largeValue.toString());
|
||||
});
|
||||
|
||||
test('truncates floating point numbers to integers', () => {
|
||||
expect(svc.normalizeAtomicAmount(100.9)).toBe(100);
|
||||
expect(svc.normalizeAtomicAmount(100.1)).toBe(100);
|
||||
expect(svc.normalizeAtomicAmount(99.9999)).toBe(99);
|
||||
test('rejects fractional atomic-unit numbers', () => {
|
||||
expect(() => svc.normalizeAtomicAmount(100.9)).toThrow();
|
||||
expect(() => svc.normalizeAtomicAmount(100.1)).toThrow();
|
||||
expect(() => svc.normalizeAtomicAmount(99.9999)).toThrow();
|
||||
});
|
||||
|
||||
test('throws on zero amount', () => {
|
||||
@@ -154,7 +193,7 @@ describe('WalletRPCService.processPayout', () => {
|
||||
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
|
||||
svc.rpcCall = jest.fn().mockResolvedValueOnce({
|
||||
result: {
|
||||
tx_hash_list: ['abc123def456'],
|
||||
tx_hash_list: ['a'.repeat(64)],
|
||||
tx_key_list: ['key789'],
|
||||
fee_list: [1000000]
|
||||
}
|
||||
@@ -168,7 +207,7 @@ describe('WalletRPCService.processPayout', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.txHash).toBe('abc123def456');
|
||||
expect(result.txHash).toBe('a'.repeat(64));
|
||||
expect(result.txKey).toBe('key789');
|
||||
expect(result.fee).toBe(1000000);
|
||||
expect(result.userId).toBe('user1');
|
||||
@@ -189,7 +228,7 @@ describe('WalletRPCService.processPayout', () => {
|
||||
test('includes subaddr_indices when subaddressIndex specified', async () => {
|
||||
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
|
||||
svc.rpcCall = jest.fn().mockResolvedValueOnce({
|
||||
result: { tx_hash_list: ['xyz'], tx_key_list: ['key'], fee_list: [100] }
|
||||
result: { tx_hash_list: ['b'.repeat(64)], tx_key_list: ['key'], fee_list: [100] }
|
||||
});
|
||||
|
||||
await svc.processPayout({
|
||||
@@ -208,7 +247,7 @@ describe('WalletRPCService.processPayout', () => {
|
||||
test('does not include subaddr_indices for invalid index values', async () => {
|
||||
svc.validateAddress = jest.fn().mockResolvedValue({ valid: true });
|
||||
svc.rpcCall = jest.fn().mockResolvedValueOnce({
|
||||
result: { tx_hash_list: ['xyz'], tx_key_list: ['key'], fee_list: [100] }
|
||||
result: { tx_hash_list: ['c'.repeat(64)], tx_key_list: ['key'], fee_list: [100] }
|
||||
});
|
||||
|
||||
await svc.processPayout({
|
||||
@@ -412,20 +451,23 @@ describe('WalletRPCService.checkPaymentStatus', () => {
|
||||
confirmed: false
|
||||
});
|
||||
|
||||
// Mock RPC calls
|
||||
// Contract-shaped wallet RPC calls: get_transfers has transaction metadata but no output
|
||||
// identity; incoming_transfers has pubkey/global_index and uses tx_hash/block_height names.
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
pool: [],
|
||||
in: [{
|
||||
txid: 'txhash123',
|
||||
amount: 100000000000,
|
||||
height: 1000
|
||||
}]
|
||||
in: [confirmedTransfer({
|
||||
txid: 'd'.repeat(64), amount: 100000000000, height: 1000,
|
||||
confirmations: 5, minor: 5
|
||||
})]
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: { height: 1005 } // Current height for confirmations
|
||||
result: { transfers: [incomingOutput({
|
||||
txHash: 'd'.repeat(64), pubkey: '1'.repeat(64), globalIndex: 12345,
|
||||
amount: 100000000000, blockHeight: 1000, minor: 5
|
||||
})] }
|
||||
});
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1testaddr');
|
||||
@@ -434,7 +476,185 @@ describe('WalletRPCService.checkPaymentStatus', () => {
|
||||
expect(status.confirmed).toBe(true);
|
||||
expect(status.amount).toBe(100000000000);
|
||||
expect(status.confirmations).toBe(5); // 1005 - 1000
|
||||
expect(status.txHash).toBe('txhash123');
|
||||
expect(status.txHash).toBe('d'.repeat(64));
|
||||
expect(status.receipts).toEqual([expect.objectContaining({
|
||||
txHash: 'd'.repeat(64),
|
||||
outputId: '1'.repeat(64),
|
||||
evidenceId: `${'d'.repeat(64)}:${'1'.repeat(64)}`,
|
||||
addressIndex: 5,
|
||||
amount: '100000000000',
|
||||
confirmed: true
|
||||
})]);
|
||||
expect(svc.rpcCall).toHaveBeenNthCalledWith(2, 'incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [5]
|
||||
});
|
||||
});
|
||||
|
||||
test('does not combine a confirmed dust transfer with an unconfirmed pool top-up', async () => {
|
||||
svc.addressToUser.set('wow1mixed', {
|
||||
userId: 'user1', socketId: 'sock1', amount: 1000,
|
||||
addressIndex: 7, accountIndex: 0, detected: false, confirmed: false
|
||||
});
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({ result: {
|
||||
pool: [{ txid: 'e'.repeat(64), amount: 999, subaddr_index: { major: 0, minor: 7 } }],
|
||||
in: [confirmedTransfer({ txid: 'f'.repeat(64), amount: 1, height: 200,
|
||||
confirmations: 1, minor: 7 })]
|
||||
} })
|
||||
.mockResolvedValueOnce({ result: { transfers: [incomingOutput({
|
||||
txHash: 'f'.repeat(64), pubkey: '2'.repeat(64), globalIndex: 200,
|
||||
amount: 1, blockHeight: 200, minor: 7
|
||||
})] } });
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1mixed');
|
||||
|
||||
expect(status.confirmed).toBe(true);
|
||||
expect(status.in_mempool).toBe(true);
|
||||
expect(status.complete).toBe(false);
|
||||
expect(status.amount).toBe(1);
|
||||
expect(status.pendingAmount).toBe(999);
|
||||
expect(status.observedAmount).toBe(1000);
|
||||
expect(status.receipts).toHaveLength(1);
|
||||
expect(status.receipts[0].txHash).toBe('f'.repeat(64));
|
||||
});
|
||||
|
||||
test('returns every confirmed top-up receipt in deterministic order', async () => {
|
||||
svc.addressToUser.set('wow1topups', {
|
||||
userId: 'user1', socketId: 'sock1', amount: 1000,
|
||||
addressIndex: 8, accountIndex: 0, detected: false, confirmed: false
|
||||
});
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({ result: { pool: [], in: [
|
||||
confirmedTransfer({ txid: 'b'.repeat(64), amount: 400, height: 301,
|
||||
confirmations: 3, minor: 8 }),
|
||||
confirmedTransfer({ txid: 'a'.repeat(64), amount: 600, height: 302,
|
||||
confirmations: 2, minor: 8 })
|
||||
] } })
|
||||
.mockResolvedValueOnce({ result: { transfers: [
|
||||
incomingOutput({ txHash: 'b'.repeat(64), pubkey: '3'.repeat(64), globalIndex: 301,
|
||||
amount: 400, blockHeight: 301, minor: 8 }),
|
||||
incomingOutput({ txHash: 'a'.repeat(64), pubkey: '4'.repeat(64), globalIndex: 302,
|
||||
amount: 600, blockHeight: 302, minor: 8 })
|
||||
] } });
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1topups');
|
||||
|
||||
expect(status.complete).toBe(true);
|
||||
expect(status.amount).toBe(1000);
|
||||
expect(status.receipts.map(receipt => receipt.txHash)).toEqual([
|
||||
'a'.repeat(64),
|
||||
'b'.repeat(64)
|
||||
]);
|
||||
expect(status.confirmations).toBe(2);
|
||||
});
|
||||
|
||||
test('counts distinct same-transaction outputs to one subaddress and ignores output replay', async () => {
|
||||
const txid = '9'.repeat(64);
|
||||
const firstPubkey = '5'.repeat(64);
|
||||
const secondPubkey = '6'.repeat(64);
|
||||
svc.addressToUser.set('wow1sameoutputscope', {
|
||||
userId: 'user1', socketId: 'sock1', amount: 100,
|
||||
addressIndex: 13, accountIndex: 0, detected: false, confirmed: false
|
||||
});
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({ result: { pool: [], in: [confirmedTransfer({
|
||||
txid, amount: 100, height: 400, confirmations: 4, minor: 13
|
||||
})] } })
|
||||
.mockResolvedValueOnce({ result: { transfers: [
|
||||
incomingOutput({ txHash: txid, pubkey: firstPubkey, globalIndex: 401,
|
||||
amount: 40, blockHeight: 400, minor: 13 }),
|
||||
incomingOutput({ txHash: txid, pubkey: secondPubkey, globalIndex: 402,
|
||||
amount: 60, blockHeight: 400, minor: 13 }),
|
||||
incomingOutput({ txHash: txid, pubkey: firstPubkey, globalIndex: 401,
|
||||
amount: 40, blockHeight: 400, minor: 13 })
|
||||
] } });
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1sameoutputscope');
|
||||
|
||||
expect(status.complete).toBe(true);
|
||||
expect(status.amount).toBe(100);
|
||||
expect(status.receipts).toHaveLength(2);
|
||||
expect(status.receipts.map(receipt => receipt.outputId)).toEqual([firstPubkey, secondPubkey]);
|
||||
});
|
||||
|
||||
test('fails closed when a confirmed transfer has no stable output identifier', async () => {
|
||||
svc.addressToUser.set('wow1missingoutputid', {
|
||||
userId: 'user1', socketId: 'sock1', amount: 100,
|
||||
addressIndex: 14, accountIndex: 0, detected: false, confirmed: false
|
||||
});
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({ result: { pool: [], in: [confirmedTransfer({
|
||||
txid: '8'.repeat(64), amount: 100, height: 500, confirmations: 1, minor: 14
|
||||
})] } })
|
||||
.mockResolvedValueOnce({ result: { transfers: [incomingOutput({
|
||||
txHash: '8'.repeat(64), amount: 100, blockHeight: 500, minor: 14
|
||||
})] } });
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1missingoutputid');
|
||||
|
||||
expect(status.complete).toBe(false);
|
||||
expect(status.confirmed).toBe(false);
|
||||
expect(status.amount).toBe(0);
|
||||
expect(status.receipts).toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores double-spend-marked and explicitly timelocked incoming transfers', async () => {
|
||||
svc.addressToUser.set('wow1unsafeoutputs', {
|
||||
userId: 'user1', socketId: 'sock1', amount: 100,
|
||||
addressIndex: 15, accountIndex: 0, detected: false, confirmed: false
|
||||
});
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({ result: { pool: [], in: [
|
||||
confirmedTransfer({ txid: '7'.repeat(64), amount: 100, height: 600,
|
||||
confirmations: 2, minor: 15, doubleSpend: true }),
|
||||
confirmedTransfer({ txid: '6'.repeat(64), amount: 100, height: 601,
|
||||
confirmations: 2, minor: 15, unlockTime: 42 }),
|
||||
confirmedTransfer({ txid: '5'.repeat(64), amount: 100, height: 602,
|
||||
confirmations: 2, minor: 15, unlockTime: Number.MAX_SAFE_INTEGER + 1 })
|
||||
] } })
|
||||
.mockResolvedValueOnce({ result: { transfers: [
|
||||
incomingOutput({ txHash: '7'.repeat(64), pubkey: 'a'.repeat(64), globalIndex: 600,
|
||||
amount: 100, blockHeight: 600, minor: 15 }),
|
||||
incomingOutput({ txHash: '6'.repeat(64), pubkey: 'b'.repeat(64), globalIndex: 601,
|
||||
amount: 100, blockHeight: 601, minor: 15 }),
|
||||
incomingOutput({ txHash: '5'.repeat(64), pubkey: 'c'.repeat(64), globalIndex: 602,
|
||||
amount: 100, blockHeight: 602, minor: 15 })
|
||||
] } });
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1unsafeoutputs');
|
||||
|
||||
expect(status.complete).toBe(false);
|
||||
expect(status.confirmed).toBe(false);
|
||||
expect(status.amount).toBe(0);
|
||||
expect(status.receipts).toEqual([]);
|
||||
});
|
||||
|
||||
test('requires the configured minimum confirmations and rejects frozen output evidence', async () => {
|
||||
svc = new WalletRPCService(debugManagerMock, { minConfirmations: 2 });
|
||||
svc.addressToUser.set('wow1depth', {
|
||||
userId: 'user1', socketId: 'sock1', amount: 100,
|
||||
addressIndex: 16, accountIndex: 0, detected: false, confirmed: false
|
||||
});
|
||||
svc.rpcCall = jest.fn()
|
||||
.mockResolvedValueOnce({ result: { pool: [], in: [
|
||||
confirmedTransfer({ txid: '4'.repeat(64), amount: 50, height: 700,
|
||||
confirmations: 1, minor: 16 }),
|
||||
confirmedTransfer({ txid: '3'.repeat(64), amount: 50, height: 699,
|
||||
confirmations: 2, minor: 16 })
|
||||
] } })
|
||||
.mockResolvedValueOnce({ result: { transfers: [
|
||||
incomingOutput({ txHash: '4'.repeat(64), pubkey: 'd'.repeat(64), globalIndex: 700,
|
||||
amount: 50, blockHeight: 700, minor: 16 }),
|
||||
incomingOutput({ txHash: '3'.repeat(64), pubkey: 'e'.repeat(64), globalIndex: 699,
|
||||
amount: 50, blockHeight: 699, minor: 16, frozen: true })
|
||||
] } });
|
||||
|
||||
const status = await svc.checkPaymentStatus('wow1depth');
|
||||
|
||||
expect(status).toEqual(expect.objectContaining({
|
||||
complete: false, confirmed: false, amount: 0, confirmations: 0
|
||||
}));
|
||||
expect(status.receipts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const WalletRPCService = require('../src/payments/walletRPCService');
|
||||
|
||||
describe('WalletRPCService HTTP authentication', () => {
|
||||
const originalUser = process.env.WALLET_RPC_USER;
|
||||
const originalPassword = process.env.WALLET_RPC_PASSWORD;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.WALLET_RPC_USER;
|
||||
delete process.env.WALLET_RPC_PASSWORD;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalUser === undefined) delete process.env.WALLET_RPC_USER;
|
||||
else process.env.WALLET_RPC_USER = originalUser;
|
||||
if (originalPassword === undefined) delete process.env.WALLET_RPC_PASSWORD;
|
||||
else process.env.WALLET_RPC_PASSWORD = originalPassword;
|
||||
});
|
||||
|
||||
test('answers a wallet RPC Digest challenge with the configured credential', async () => {
|
||||
process.env.WALLET_RPC_USER = 'rpc-user';
|
||||
process.env.WALLET_RPC_PASSWORD = 'rpc-password';
|
||||
const unauthorized = new Error('unauthorized');
|
||||
unauthorized.response = {
|
||||
status: 401,
|
||||
headers: {
|
||||
'www-authenticate': 'Digest realm="monero-rpc", nonce="abc123", qop="auth", opaque="opaque1", algorithm=MD5'
|
||||
}
|
||||
};
|
||||
const http = { post: jest.fn()
|
||||
.mockRejectedValueOnce(unauthorized)
|
||||
.mockResolvedValueOnce({ data: { result: { height: 1 } } }) };
|
||||
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, { http });
|
||||
|
||||
await service.rpcCall('get_height');
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(2,
|
||||
expect.stringContaining('/json_rpc'),
|
||||
expect.objectContaining({ method: 'get_height' }),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Digest .*username="rpc-user".*realm="monero-rpc".*nonce="abc123".*qop=auth/)
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('omits authentication for a loopback no-login service', async () => {
|
||||
const http = { post: jest.fn().mockResolvedValue({ data: { result: { height: 1 } } }) };
|
||||
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, { http });
|
||||
|
||||
await service.rpcCall('get_height');
|
||||
|
||||
expect(http.post).toHaveBeenCalledTimes(1);
|
||||
expect(http.post.mock.calls[0][2].headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
test('production refuses a wallet that accepts the unauthenticated probe', async () => {
|
||||
const http = { post: jest.fn().mockResolvedValue({ data: { result: { version: 1 } } }) };
|
||||
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, {
|
||||
http,
|
||||
rpcUser: 'rpc-user',
|
||||
rpcPassword: 'rpc-password',
|
||||
authRequired: true
|
||||
});
|
||||
|
||||
await expect(service.ensureAuthentication({ force: true }))
|
||||
.rejects.toMatchObject({ code: 'WALLET_RPC_AUTH_NOT_ENFORCED', statusCode: 503 });
|
||||
expect(service.getHealthStatus()).toMatchObject({
|
||||
healthy: false,
|
||||
authentication: { required: true, verified: false, reason: 'not_enforced' }
|
||||
});
|
||||
});
|
||||
|
||||
test('production proves both the unauthenticated challenge and an authenticated response', async () => {
|
||||
const challenge = () => {
|
||||
const error = new Error('unauthorized');
|
||||
error.response = {
|
||||
status: 401,
|
||||
headers: {
|
||||
'www-authenticate': 'Digest realm="monero-rpc", nonce="abc123", qop="auth", algorithm=MD5'
|
||||
}
|
||||
};
|
||||
return error;
|
||||
};
|
||||
const http = { post: jest.fn(async (_url, _body, config) => {
|
||||
if (!config?.headers?.Authorization) throw challenge();
|
||||
return { data: { result: { version: 1 } } };
|
||||
}) };
|
||||
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, {
|
||||
http,
|
||||
rpcUser: 'rpc-user',
|
||||
rpcPassword: 'rpc-password',
|
||||
authRequired: true
|
||||
});
|
||||
|
||||
await expect(service.ensureAuthentication({ force: true })).resolves.toMatchObject({
|
||||
verified: true,
|
||||
reason: null
|
||||
});
|
||||
expect(http.post).toHaveBeenCalledTimes(2);
|
||||
expect(http.post.mock.calls[1][2].headers.Authorization).toMatch(/^Digest /);
|
||||
expect(JSON.stringify(service.getHealthStatus())).not.toMatch(/rpc-user|rpc-password/);
|
||||
});
|
||||
|
||||
test('production refuses missing wallet credentials without contacting RPC', async () => {
|
||||
const http = { post: jest.fn() };
|
||||
const service = new WalletRPCService({ CONSOLE_LOGGING: false }, {
|
||||
http,
|
||||
authRequired: true
|
||||
});
|
||||
|
||||
await expect(service.ensureAuthentication({ force: true }))
|
||||
.rejects.toMatchObject({ code: 'WALLET_RPC_AUTH_UNVERIFIED', statusCode: 503 });
|
||||
expect(http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
function makeElement(tag = 'div') {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
style: {},
|
||||
children: [],
|
||||
listeners: {},
|
||||
capturedPointers: new Set(),
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; },
|
||||
removeChild(child) { this.children = this.children.filter(c => c !== child); child.parentNode = null; },
|
||||
addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); },
|
||||
removeEventListener(name, fn) {
|
||||
this.listeners[name] = (this.listeners[name] || []).filter(listener => listener !== fn);
|
||||
},
|
||||
dispatch(name, event = {}) {
|
||||
event.target ||= this;
|
||||
for (const listener of [...(this.listeners[name] || [])]) listener(event);
|
||||
},
|
||||
setAttribute() {},
|
||||
querySelector() { return null; },
|
||||
setPointerCapture(pointerId) { this.capturedPointers.add(pointerId); },
|
||||
releasePointerCapture(pointerId) { this.capturedPointers.delete(pointerId); }
|
||||
};
|
||||
}
|
||||
|
||||
function loadKit(ResizeObserver) {
|
||||
const document = { createElement: makeElement };
|
||||
const context = { document, console, Math };
|
||||
if (ResizeObserver) context.ResizeObserver = ResizeObserver;
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/zoomControl.js'), 'utf8'), context);
|
||||
return context.RK;
|
||||
}
|
||||
|
||||
describe('render-kit viewport camera', () => {
|
||||
test('centers a focus point and clamps the view at map edges', () => {
|
||||
const RK = loadKit();
|
||||
const host = makeElement();
|
||||
host.clientWidth = 300;
|
||||
host.clientHeight = 200;
|
||||
const canvas = makeElement('canvas');
|
||||
canvas.width = 600;
|
||||
canvas.height = 400;
|
||||
const renderer = { name: 'tiles', canvas, focusPoint: { x: 500, y: 300 } };
|
||||
const camera = RK.attachCamera(host, { zoom: 1, min: 0.5, controls: false });
|
||||
|
||||
camera.update(renderer);
|
||||
expect(canvas.style.transform).toBe('translate(-300px,-200px) scale(1)');
|
||||
|
||||
renderer.focusPoint = { x: 300, y: 200 };
|
||||
camera.update(renderer);
|
||||
expect(canvas.style.transform).toBe('translate(-150px,-100px) scale(1)');
|
||||
|
||||
camera.setZoom(0.5);
|
||||
expect(canvas.style.transform).toBe('translate(0px,0px) scale(0.5)');
|
||||
});
|
||||
|
||||
test('delegates projection zoom and drag pan to the native 3D camera', () => {
|
||||
const RK = loadKit();
|
||||
const host = makeElement();
|
||||
host.clientWidth = 300;
|
||||
host.clientHeight = 200;
|
||||
const setZoom = jest.fn();
|
||||
const setPan = jest.fn();
|
||||
const renderer = { name: '3d', setZoom, setPan };
|
||||
const camera = RK.attachCamera(host, { zoom: 1.2, controls: false });
|
||||
|
||||
camera.update(renderer);
|
||||
expect(setZoom).toHaveBeenLastCalledWith(1.2);
|
||||
expect(setPan).toHaveBeenLastCalledWith(0, 0);
|
||||
host.dispatch('pointerdown', { pointerId: 1, clientX: 10, clientY: 20 });
|
||||
host.dispatch('pointermove', { pointerId: 1, clientX: 34, clientY: 31 });
|
||||
expect(setPan).toHaveBeenLastCalledWith(24, 11);
|
||||
camera.setZoom(1.5);
|
||||
expect(setZoom).toHaveBeenLastCalledWith(1.5);
|
||||
camera.reset();
|
||||
expect(setPan).toHaveBeenLastCalledWith(0, 0);
|
||||
});
|
||||
|
||||
test('fits a compact room to a responsive share of the viewport and restores that fit', () => {
|
||||
const RK = loadKit();
|
||||
const host = makeElement();
|
||||
host.clientWidth = 900;
|
||||
host.clientHeight = 550;
|
||||
const canvas = makeElement('canvas');
|
||||
canvas.width = 288;
|
||||
canvas.height = 168;
|
||||
const renderer = { name: 'tiles', canvas, focusPoint: { x: 144, y: 84 } };
|
||||
const camera = RK.attachCamera(host, { zoom: 1.25, fitRatio: 0.75, min: 0.5, max: 3.2, controls: false });
|
||||
|
||||
camera.fit(renderer);
|
||||
expect(camera.getZoom()).toBeCloseTo(2.34375);
|
||||
expect(canvas.style.transform).toBe('translate(112.5px,78.125px) scale(2.34375)');
|
||||
|
||||
camera.setZoom(1.1);
|
||||
camera.reset();
|
||||
expect(camera.getZoom()).toBeCloseTo(2.34375);
|
||||
});
|
||||
|
||||
test('pans with one pointer and pinches with two pointers', () => {
|
||||
const RK = loadKit();
|
||||
const host = makeElement();
|
||||
host.clientWidth = 300;
|
||||
host.clientHeight = 200;
|
||||
const canvas = makeElement('canvas');
|
||||
canvas.width = 600;
|
||||
canvas.height = 400;
|
||||
const renderer = { name: 'tiles', canvas, focusPoint: { x: 300, y: 200 } };
|
||||
const camera = RK.attachCamera(host, { zoom: 1, min: 0.5, max: 3, controls: false });
|
||||
camera.update(renderer);
|
||||
|
||||
host.dispatch('pointerdown', { pointerId: 1, clientX: 0, clientY: 0 });
|
||||
host.dispatch('pointermove', { pointerId: 1, clientX: 20, clientY: 10 });
|
||||
expect(canvas.style.transform).toBe('translate(-130px,-90px) scale(1)');
|
||||
expect(host.capturedPointers.has(1)).toBe(true);
|
||||
|
||||
host.dispatch('pointerdown', { pointerId: 2, clientX: 100, clientY: 10 });
|
||||
host.dispatch('pointermove', { pointerId: 2, clientX: 100, clientY: 10 });
|
||||
host.dispatch('pointermove', { pointerId: 2, clientX: 180, clientY: 10 });
|
||||
expect(camera.getZoom()).toBeCloseTo(2);
|
||||
|
||||
host.dispatch('pointerup', { pointerId: 1 });
|
||||
host.dispatch('pointercancel', { pointerId: 2 });
|
||||
expect(host.style.cursor).toBe('grab');
|
||||
});
|
||||
|
||||
test('resize auto-fit follows the current renderer after a renderer switch', () => {
|
||||
let observer;
|
||||
class FakeResizeObserver {
|
||||
constructor(callback) { this.callback = callback; observer = this; }
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
const RK = loadKit(FakeResizeObserver);
|
||||
const host = makeElement();
|
||||
host.clientWidth = 900;
|
||||
host.clientHeight = 600;
|
||||
const firstCanvas = makeElement('canvas');
|
||||
firstCanvas.width = 300;
|
||||
firstCanvas.height = 200;
|
||||
const secondCanvas = makeElement('canvas');
|
||||
secondCanvas.width = 600;
|
||||
secondCanvas.height = 300;
|
||||
const first = { name: 'tiles', canvas: firstCanvas };
|
||||
const second = { name: 'iso', canvas: secondCanvas };
|
||||
const camera = RK.attachCamera(host, { zoom: 1, fitRatio: 0.75, min: 0.5, max: 3, controls: false });
|
||||
|
||||
camera.fit(first);
|
||||
const firstTransform = firstCanvas.style.transform;
|
||||
camera.update(second);
|
||||
host.clientWidth = 600;
|
||||
host.clientHeight = 300;
|
||||
observer.callback();
|
||||
|
||||
expect(camera.getZoom()).toBeCloseTo(0.75);
|
||||
expect(secondCanvas.style.transform).toBe('translate(75px,37.5px) scale(0.75)');
|
||||
expect(firstCanvas.style.transform).toBe(firstTransform);
|
||||
});
|
||||
|
||||
test('destroy removes host and control listeners, disconnects resize, and permits clean reattachment', () => {
|
||||
let observer;
|
||||
class FakeResizeObserver {
|
||||
constructor(callback) { this.callback = callback; observer = this; }
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
const RK = loadKit(FakeResizeObserver);
|
||||
const host = makeElement();
|
||||
host.clientWidth = 300;
|
||||
host.clientHeight = 200;
|
||||
const nativeSetZoom = jest.fn();
|
||||
const camera = RK.attachCamera(host, { zoom: 1.2 });
|
||||
camera.update({ name: '3d', setZoom: nativeSetZoom });
|
||||
const controls = host.children[0];
|
||||
const buttons = controls.children.filter(child => child.tagName === 'BUTTON');
|
||||
|
||||
host.dispatch('pointerdown', { pointerId: 7, clientX: 10, clientY: 10 });
|
||||
expect(host.capturedPointers.has(7)).toBe(true);
|
||||
expect(Object.values(host.listeners).flat()).toHaveLength(6);
|
||||
expect(buttons.every(button => button.listeners.click.length === 1)).toBe(true);
|
||||
|
||||
camera.destroy();
|
||||
|
||||
expect(Object.values(host.listeners).flat()).toHaveLength(0);
|
||||
expect(buttons.every(button => button.listeners.click.length === 0)).toBe(true);
|
||||
expect(host.children).not.toContain(controls);
|
||||
expect(host.capturedPointers.size).toBe(0);
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(host._rkCamera).toBeUndefined();
|
||||
|
||||
const callCount = nativeSetZoom.mock.calls.length;
|
||||
host.dispatch('wheel', { deltaY: -100, preventDefault: jest.fn() });
|
||||
observer.callback(); // A queued callback after disconnect must also be inert.
|
||||
expect(nativeSetZoom).toHaveBeenCalledTimes(callCount);
|
||||
|
||||
const replacement = RK.attachCamera(host, { zoom: 1.5, controls: false });
|
||||
expect(replacement).not.toBe(camera);
|
||||
camera.destroy(); // Stale/double teardown must not detach the replacement camera.
|
||||
expect(RK.attachCamera(host, { controls: false })).toBe(replacement);
|
||||
replacement.destroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user