fix(showcase): harden Smirk checkout and multiplayer presentation

This commit is contained in:
2026-10-21 12:43:29 -04:00
parent 5712007d31
commit c10fbd7142
30 changed files with 1695 additions and 151 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
* window), so a map full of long-expired entries stayed full.
*/
const express = require('express');
const express = require('../src/node_modules/express');
const http = require('http');
const createAuthRoutes = require('../src/routes/auth');
+177
View File
@@ -1,4 +1,6 @@
const MatchQueue = require('../src/network/matchQueue');
const MatchManager = require('../src/network/matchManager');
const MatchRoom = require('../src/multiplayer/MatchRoom');
const SocketHandlers = require('../src/network/socketHandlers');
describe('MatchQueue', () => {
@@ -238,4 +240,179 @@ describe('SocketHandlers match queue acknowledgements', () => {
payload: { success: true, economy: 'free' }
});
});
test('reconnect restores owned queued entries without joining or charging again', async () => {
const emitted = [];
const socket = { id: 'new-socket', emit: (event, payload) => emitted.push({ event, payload }) };
const reattach = jest.fn(async (userId, economy, socketId, token) => {
expect(userId).toBe(7);
expect(socketId).toBe(socket.id);
expect(token).toBe('current-session');
return economy === 'free' || economy === 'credits_prestige'
? { inQueue: true, position: economy === 'free' ? 2 : 1 }
: { inQueue: false };
});
const enqueue = jest.fn();
const handleReconnect = jest.fn(() => false);
const context = {
matchManager: { enabled: true, handleReconnect },
matchQueue: { reattach, enqueue },
_resolveMatchSession: jest.fn(async () => ({ userId: 7, sessionToken: 'current-session' })),
debugManager: { CONSOLE_LOGGING: false }
};
await SocketHandlers.prototype._handleMatchReconnect.call(context, socket, {
userId: 999, economy: 'crypto_race', sessionToken: 'untrusted'
});
expect(handleReconnect).toHaveBeenCalledWith(socket, expect.objectContaining({ userId: 7 }));
expect(reattach).toHaveBeenCalledWith(7, 'free', socket.id, 'current-session');
expect(reattach).toHaveBeenCalledWith(7, 'credits_prestige', socket.id, 'current-session');
expect(reattach).toHaveBeenCalledWith(7, 'crypto_race', socket.id, 'current-session');
expect(enqueue).not.toHaveBeenCalled();
expect(emitted.filter(item => item.event === 'match_queue_joined')).toEqual([
{ event: 'match_queue_joined', payload: {
success: true, economy: 'free', position: 2, alreadyQueued: true, restored: true
} },
{ event: 'match_queue_joined', payload: {
success: true, economy: 'credits_prestige', position: 1, alreadyQueued: true, restored: true
} }
]);
});
test('reconnect does not reclaim a queue row while its active match owns the player', async () => {
const socket = { id: 'new-socket', emit: jest.fn() };
const reattach = jest.fn();
const context = {
matchManager: { enabled: true, handleReconnect: jest.fn(() => true) },
matchQueue: { reattach },
_resolveMatchSession: jest.fn(async () => ({ userId: 7, sessionToken: 'current-session' }))
};
await SocketHandlers.prototype._handleMatchReconnect.call(context, socket);
expect(reattach).not.toHaveBeenCalled();
expect(socket.emit).not.toHaveBeenCalledWith('match_queue_joined', expect.anything());
});
test('reconnect does not use a client claimed user when session resolution fails', async () => {
const socket = { id: 'new-socket', emit: jest.fn() };
const handleReconnect = jest.fn();
const reattach = jest.fn();
const context = {
matchManager: { enabled: true, handleReconnect },
matchQueue: { reattach },
_resolveMatchSession: jest.fn(async () => null)
};
await SocketHandlers.prototype._handleMatchReconnect.call(context, socket, { userId: 7 });
expect(handleReconnect).not.toHaveBeenCalled();
expect(reattach).not.toHaveBeenCalled();
expect(socket.emit).not.toHaveBeenCalled();
});
});
describe('MatchQueue queued reconnect', () => {
test('updates the durable queued row and memory socket while preserving its place', async () => {
process.env.MATCH_ENABLED = 'true';
const statements = [];
const queuedRow = { id: 31, created_at: new Date('2026-10-21T10:00:00Z') };
const db = { query: jest.fn(async (sql, params) => {
statements.push(sql);
expect(params).toEqual(['new-socket', 'current-session', 7, 'free']);
return { rowCount: 1, rows: [queuedRow] };
}) };
const queue = new MatchQueue({ db });
queue._queues.free.push({
queueEntryId: queuedRow.id, userId: 7, socketId: 'old-socket',
sessionToken: 'old-session', createdAt: queuedRow.created_at.getTime()
});
try {
const restored = await queue.reattach(7, 'free', 'new-socket', 'current-session');
expect(restored).toEqual({ inQueue: true, position: 1 });
expect(queue._queues.free[0]).toMatchObject({
queueEntryId: queuedRow.id, userId: 7, socketId: 'new-socket',
sessionToken: 'current-session', createdAt: queuedRow.created_at.getTime()
});
expect(statements).toHaveLength(1);
} finally {
delete process.env.MATCH_ENABLED;
}
});
test('a matched or absent durable row cannot be restored from stale memory', async () => {
process.env.MATCH_ENABLED = 'true';
const db = { query: jest.fn(async () => ({ rowCount: 0, rows: [] })) };
const queue = new MatchQueue({ db });
queue._queues.free.push({ userId: 7, socketId: 'old-socket' });
try {
expect(await queue.reattach(7, 'free', 'new-socket', 'current-session'))
.toEqual({ inQueue: false });
expect(queue._queues.free[0].socketId).toBe('old-socket');
} finally {
delete process.env.MATCH_ENABLED;
}
});
test('a restored entrant receives the countdown and start on the new socket', async () => {
jest.useFakeTimers();
process.env.MATCH_ENABLED = 'true';
const db = { query: jest.fn(async () => ({
rowCount: 1, rows: [{ id: 31, created_at: new Date('2026-10-21T10:00:00Z') }]
})) };
const queue = new MatchQueue({ db });
queue._queues.free.push({
queueEntryId: 31, userId: 7, socketId: 'old-socket',
sessionToken: 'old-session', createdAt: Date.parse('2026-10-21T10:00:00Z')
});
const makeSocket = id => ({
id, channels: new Set(), events: [],
join(channel) { this.channels.add(channel); },
emit(event, payload) { this.events.push({ event, payload }); }
});
const oldSocket = makeSocket('old-socket');
const newSocket = makeSocket('new-socket');
const otherSocket = makeSocket('other-socket');
const sockets = new Map([[newSocket.id, newSocket], [otherSocket.id, otherSocket]]);
const io = {
sockets: { sockets },
to(channel) {
return { emit(event, payload) {
for (const socket of sockets.values()) {
if (socket.channels.has(channel)) socket.emit(event, payload);
}
} };
}
};
let manager;
try {
await queue.reattach(7, 'free', newSocket.id, 'current-session');
const entrant = queue._queues.free[0];
const room = new MatchRoom({
economy: 'free',
entrants: {
[entrant.socketId]: { userId: entrant.userId },
[otherSocket.id]: { userId: 8 }
},
seed: '0'.repeat(64)
});
manager = new MatchManager({ io, db: null });
manager.setEngine(room.id, { start: () => room.start(), stop: () => {} });
manager.attach(room, [entrant, { userId: 8, socketId: otherSocket.id }], {
countdownMs: 20, minDurationMs: 20000, hardCeilingMs: 60000
});
expect(newSocket.events.some(item => item.event === 'match_joined')).toBe(true);
expect(oldSocket.events).toHaveLength(0);
jest.advanceTimersByTime(20);
expect(room.status).toBe('active');
expect(newSocket.events.some(item => item.event === 'match_start')).toBe(true);
expect(oldSocket.events).toHaveLength(0);
} finally {
manager?.shutdown();
jest.useRealTimers();
delete process.env.MATCH_ENABLED;
}
});
});
+46
View File
@@ -7,6 +7,7 @@ const {
DEFAULT_SEED,
assertCaptureSafety,
assertCaptureStateEnvironment,
assertFreeRoom,
capturePageHtml,
capturePresentation,
captureResultSummary,
@@ -123,6 +124,51 @@ describe('deterministic PvP capture harness', () => {
}
});
test('the curated escape race reaches a decisive first exit with no money state', () => {
const config = options({ args: ['--ruleset', 'race', '--ticks', '120'] });
const room = createCaptureRoom(config);
room.start();
const events = [];
for (let tick = 0; tick < config.ticks && room.status === 'active'; tick++) {
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);
}
}
events.push(...room.resolveTick().events);
}
room.finalize();
const exits = events.filter(event => event.type === 'player_exit');
expect(room.status).toBe('finished');
expect(room.endReason).toBe('escaped');
expect(exits).toHaveLength(1);
expect(room.winnerId).toBe(exits[0].id);
expect(room.playerStates.get(room.winnerId).escaped).toBe(true);
expect(assertFreeRoom(room)).toBe(true);
});
test('race and co-op use distinct exit completion rules', () => {
for (const rulesetId of ['race', 'coop-escape']) {
const room = createCaptureRoom(options({ args: ['--ruleset', rulesetId] }));
room.start();
const [firstId] = room.occupants.keys();
const [x, y] = room.dungeon.exit;
room.occupants.get(firstId).moveTo(x, y);
const result = room.resolveTick();
expect(result.events.some(event => event.type === 'player_exit' && event.id === firstId))
.toBe(true);
expect(result.finished).toBe(rulesetId === 'race');
expect(room.status).toBe(rulesetId === 'race' ? 'finished' : 'active');
expect(room.pvpCombat).toBe(false);
expect(assertFreeRoom(room)).toBe(true);
}
});
test('last-alive bots pursue a rival instead of following the race exit', () => {
const move = planBotMove('bot-1', {
tick: 1,
+2
View File
@@ -40,6 +40,7 @@ function fixture() {
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/showcase-browser-smoke.js', "throw new Error('source-only browser harness');\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");
@@ -136,6 +137,7 @@ describe('release artifact builder', () => {
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/showcase-browser-smoke.js');
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');
+229
View File
@@ -0,0 +1,229 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function makeElement(tag = 'div') {
const attributes = {};
const element = {
tagName: tag.toUpperCase(),
style: {},
dataset: {},
children: [],
listeners: {},
attributes,
disabled: false,
textContent: '',
className: '',
classList: {
values: new Set(),
add(value) { this.values.add(value); },
remove(value) { this.values.delete(value); },
contains(value) { return this.values.has(value); },
toggle(value, force) {
const enabled = force == null ? !this.values.has(value) : !!force;
if (enabled) this.values.add(value); else this.values.delete(value);
return enabled;
}
},
addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); },
dispatch(name, event = {}) {
event.target ||= this;
(this.listeners[name] || []).forEach(fn => fn(event));
},
appendChild(child) { this.children.push(child); child.parentNode = this; },
setAttribute(name, value) { attributes[name] = String(value); },
querySelectorAll(selector) {
if (selector === '[data-economy]') return this.children.filter(child => child.dataset.economy);
if (selector === '[data-dir]') return this.children.filter(child => child.dataset.dir);
return [];
}
};
Object.defineProperty(element, 'innerHTML', {
get() { return ''; },
set() { element.children = []; }
});
return element;
}
function loadMatchClient() {
const ids = [
'economies', 'status', 'queueBtn', 'leaveBtn', 'queuePanel', 'racePanel', 'raceMode',
'raceEconomy', 'raceTimer', 'racePlace', 'raceResult', 'stage', 'renderModes',
'modeTitle', 'modeDescription', 'modeBadge', 'rulesetCatalog', 'economyHelp',
'objectiveHint', 'matchDpad'
];
const elements = Object.fromEntries(ids.map(id => [id, makeElement(id.includes('Btn') ? 'button' : 'div')]));
const socketHandlers = {};
const windowHandlers = {};
const emitted = [];
const socket = {
id: 'local-player', connected: true,
on(name, fn) { socketHandlers[name] = fn; },
emit(name, payload) { emitted.push({ name, payload }); }
};
const modeList = [
{ id: 'tiles', label: 'Tiled', premium: false },
{ id: 'ascii', label: 'ASCII', premium: false }
];
const RK = {
RENDER_MODES: modeList,
loadMode: () => 'tiles',
saveMode: () => {},
canUseMode: () => true,
modeAvailability: id => ({ usable: true, runtimeAvailable: true }),
createRendererAsync: (mode, host, options, done) => done({ name: mode, destroy() {}, render() {} })
};
const MatchCopy = {
normalize: raw => ({ id: raw.id || 'race', label: 'Race', description: 'Reach the exit', badge: 'RACE', objective: 'reach the exit' }),
fromPayload: (data, fallback) => fallback,
plural: () => 'delvers',
liveSummary: () => 'Racing',
finalResult: () => ({ text: 'Finished', mode: { id: 'race' } })
};
const window = {
RK,
MatchUiCopy: MatchCopy,
addEventListener(name, fn) { windowHandlers[name] = fn; },
CommerceConsent: null
};
const document = { getElementById: id => elements[id], createElement: tag => makeElement(tag) };
const context = {
window, document, io: () => socket, localStorage: { getItem: () => null, setItem() {} },
navigator: { userAgent: 'test' }, console, Date, Math, Promise, TextEncoder,
setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {},
Array, Object, Number, String, RegExp
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/matchClient.js'), 'utf8'), context);
return { elements, socket, socketHandlers, windowHandlers, emitted, modeList };
}
describe('renderer controls and failed skin fallback', () => {
test('match render mode announces its selected state and updates it after a switch', () => {
const { elements, socketHandlers } = loadMatchClient();
socketHandlers.game_mode_info({ modes: { match: { enabled: true, economies: { free: true }, rulesets: [{ id: 'race' }] } } });
let [tiled, ascii] = elements.renderModes.children;
expect(tiled.attributes['aria-pressed']).toBe('true');
expect(ascii.attributes['aria-pressed']).toBe('false');
ascii.dispatch('click');
[tiled, ascii] = elements.renderModes.children;
expect(tiled.attributes['aria-pressed']).toBe('false');
expect(ascii.attributes['aria-pressed']).toBe('true');
});
test('a restored queue selects the server-returned economy and exposes the leave action', () => {
const { elements, socketHandlers } = loadMatchClient();
socketHandlers.game_mode_info({ modes: { match: {
enabled: true,
economies: { free: true, credits_prestige: true },
rulesets: [{ id: 'race' }]
} } });
socketHandlers.match_queue_joined({ success: true, economy: 'credits_prestige', position: 2, restored: true });
const [free, prestige] = elements.economies.children;
expect(free.attributes['aria-pressed']).toBe('false');
expect(prestige.attributes['aria-pressed']).toBe('true');
expect(elements.queueBtn.classList.contains('hidden')).toBe(true);
expect(elements.leaveBtn.classList.contains('hidden')).toBe(false);
expect(elements.status.textContent).toContain('Queue restored for Prestige');
});
test.each([
['INPUT', false], ['TEXTAREA', false], ['SELECT', false], ['DIV', true]
])('match movement ignores text-entry and select targets (%s)', (tagName, contentEditable) => {
const { socketHandlers, windowHandlers, emitted } = loadMatchClient();
socketHandlers.match_start({ ruleset: { id: 'race' } });
const target = { tagName, isContentEditable: contentEditable };
let prevented = false;
windowHandlers.keydown({ key: 'ArrowUp', target, preventDefault() { prevented = true; } });
expect(prevented).toBe(false);
expect(emitted.some(event => event.name === 'match_move')).toBe(false);
});
test('match movement ignores contenteditable targets but still moves from the game surface', () => {
const { socketHandlers, windowHandlers, emitted } = loadMatchClient();
socketHandlers.match_start({ ruleset: { id: 'race' } });
let prevented = false;
windowHandlers.keydown({ key: 'ArrowUp', target: { tagName: 'DIV', isContentEditable: true }, preventDefault() { prevented = true; } });
expect(prevented).toBe(false);
windowHandlers.keydown({ key: 'ArrowUp', target: { tagName: 'DIV' }, preventDefault() { prevented = true; } });
expect(prevented).toBe(true);
expect(emitted.some(event => event.name === 'match_move' && event.payload.dx === 0 && event.payload.dy === -1)).toBe(true);
});
test('a failed premium skin completes pending loads and draws a free character fallback', () => {
const images = [];
const RK = {
CHARS: { 'char-villager': { frame: 1 } },
normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }),
appearance: appearance => ({ label: appearance.avatar }),
isChar: id => id === 'char-villager',
charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }),
charAtlas: () => ({}),
charFrame: () => ({ squash: 1, bob: 0 }),
drawCharTileCanvas: jest.fn(),
charOverlayParts: () => []
};
const context = {
window: null,
Image: class { constructor() { images.push(this); } set src(value) { this.url = value; } },
console: { warn: jest.fn() }
};
context.window = context;
context.RK = RK;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/skins.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'), context);
const visual = RK.avatarVisuals.resolve({ avatar: 'wownero-rogue' }, { projection: 'topdown' });
const ready = jest.fn();
const canvas = { beginPath() {}, ellipse() {}, stroke() {} };
const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true };
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
expect(images).toHaveLength(1);
images[0].onerror();
expect(ready).toHaveBeenCalledTimes(1);
expect(RK.skinSheetFailed('wownero-rogue')).toBe(true);
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, {})).toBe(true);
expect(RK.drawCharTileCanvas).toHaveBeenCalled();
});
test('a normal character still waits for its atlas and resolves the render callback', () => {
let atlasReady;
let atlasLoaded = false;
const RK = {
CHARS: { 'char-villager': { frame: 1 } },
normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }),
appearance: appearance => ({ label: appearance.avatar }),
isChar: id => id === 'char-villager',
charAtlas: () => atlasLoaded ? {} : null,
loadCharAtlas: callback => { atlasReady = callback; },
charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }),
charFrame: () => ({ squash: 1, bob: 0 }),
drawCharTileCanvas: jest.fn(),
charOverlayParts: () => []
};
const context = { window: null, Image: class {}, console };
context.window = context;
context.RK = RK;
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/skins.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/avatarVisuals.js'), 'utf8'), context);
const visual = RK.avatarVisuals.resolve({ avatar: 'char-villager' }, { projection: 'topdown' });
const ready = jest.fn();
const canvas = { beginPath() {}, ellipse() {}, stroke() {} };
const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true };
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
atlasLoaded = true;
atlasReady();
expect(ready).toHaveBeenCalledTimes(1);
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, {})).toBe(true);
expect(RK.drawCharTileCanvas).toHaveBeenCalled();
});
});
+58
View File
@@ -0,0 +1,58 @@
const PaymentHandlers = require('../src/network/paymentHandlers');
const GameModeManager = require('../src/game/gameModeManager');
const ConnectionHandler = require('../src/network/connectionHandler');
const SocketHandlers = require('../src/network/socketHandlers');
const { paymentRequest } = require('../html/js/network/smirkPaymentAdapter');
const row = {
id: 31, user_id: 7, subaddress: 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn',
expected_amount: '123456789012', description: '10 credit package', payment_type: 'credits_package',
expires_at: new Date(Date.now() + 3600000).toISOString()
};
test.each([false, true])('authenticated session events expose the invoice owner without deriving it from the browser: resumed=%s', resumed => {
const emit = jest.fn();
ConnectionHandler.prototype._emitSessionEvents.call({ io: { to: () => ({ emit }) } },
{ id: 'fixture-socket' }, { resumed, token: 'fixture-session', user: { id: 7 } });
expect(emit).toHaveBeenCalledWith(resumed ? 'session_resumed' : 'session_token', expect.objectContaining({ userId: 7 }));
});
test.each(['request', 'show', 'restore'])('the %s invoice path supplies an exact, spendable WOW contract', async path => {
const manager = Object.create(GameModeManager.prototype);
Object.assign(manager, {
cryptoType: 'WOW', network: 'mainnet', currencyDecimals: 11,
isPaymentIntakeEnabled: () => true,
formatAtomicHuman: () => '1.2346',
gameMode: 'PAID_CREDITS', creditsPackagePrice: row.expected_amount,
getPrimaryCreditPackage: () => ({ price: row.expected_amount, credits: 10 }),
createPaymentRequest: jest.fn(async () => manager._mapPaymentRowToRequest(row, 'credits_package', { credits: 10 })),
db: { query: jest.fn(async () => ({ rows: [row] })) }
});
const emitted = [];
const io = { to: () => ({ emit: (event, data) => emitted.push([event, data]) }) };
const handlers = new PaymentHandlers({
io, gameModeManager: manager, debugManager: { CONSOLE_LOGGING: false },
queueManager: { getUserBySocket: () => ({ serverId: 'fixture-socket' }) },
broadcastManager: { sendStatusUpdate: jest.fn() },
walletService: {}
});
handlers._requirePaidAcknowledgement = () => ({ policyVersion: 'fixture' });
handlers._allowInvoiceFlowForReserve = async () => true;
handlers._ensureRequiredPayoutAddress = async () => ({ ok: true });
handlers._monitorAddress = jest.fn();
const socket = { id: 'fixture-socket', emit: (event, data) => emitted.push([event, data]) };
try {
if (path === 'request') await handlers.handlePaymentRequest(socket, { type: 'credits_package' });
else if (path === 'show') await handlers.createAndShowPaymentRequest(socket, { paymentType: 'credits_package' });
else await SocketHandlers.prototype._restorePaymentMonitoring.call({
gameModeManager: manager, paymentHandlers: handlers, debugManager: { CONSOLE_LOGGING: false },
connectionHandler: { getUserBySocket: () => ({ serverId: socket.id }) },
broadcastManager: { sendStatusUpdate: jest.fn() }
}, socket, 7);
const payload = emitted.find(([event]) => event === 'payment_created')?.[1];
expect(payload).toMatchObject({ ownerUserId: 7, asset: 'wow', network: 'mainnet', humanAmount: '1.23456789012', memo: row.description });
expect(paymentRequest(payload)).toEqual({ asset: 'wow', amount: '1.23456789012', address: row.subaddress, memo: row.description });
} finally {
handlers.dispose();
}
});
+116
View File
@@ -0,0 +1,116 @@
const fs = require('fs');
const vm = require('vm');
function loadAuth(wallet) {
const context = {
window: { smirk: wallet, socket: { id: 'fixture-socket', on() {}, emit: jest.fn() }, location: { origin: 'https://game.test' } },
localStorage: { getItem: () => 'fixture-session' },
document: {}, URL, console: { log() {}, warn() {}, error() {} }, setTimeout() {},
$: () => ({ ready() {}, prop() { return this; }, css() { return this; }, text() { return this; }, remove() {} }),
SocketHandlers: { _smirkEnabled: true },
fetch: jest.fn(async path => ({
ok: true,
json: async () => path.endsWith('challenge') ? { challenge: 'game-fixture-challenge' } : { linked: true }
}))
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(require.resolve('../html/js/network/smirkAuth'), 'utf8') + '\nthis.auth = SmirkAuth;', context);
return context;
}
function walletFixture() {
return {
connect: jest.fn().mockResolvedValue({ wow: 'fixture-wow-key' }),
signNostrEvent: jest.fn().mockResolvedValue({ pubkey: 'fixture-nostr-key' }),
getNostrPublicKey: jest.fn().mockResolvedValue('fixture-nostr-key'),
getAddresses: jest.fn().mockResolvedValue({ wow: 'fixture-wow-address' }),
getPublicKeys: jest.fn().mockResolvedValue({ wow: 'fixture-wow-key' }),
signMessage: jest.fn().mockResolvedValue({ signatures: [{ asset: 'wow', publicKey: 'legacy-key', signature: 'fixture-proof' }] })
};
}
describe('Smirk client identity and WOW grant boundaries', () => {
test('keeps Nostr identity while explicitly acquiring only WOW asset scope', async () => {
const wallet = walletFixture();
const { auth } = loadAuth(wallet);
await auth.login();
expect(wallet.connect).toHaveBeenCalledWith(['wow']);
expect(wallet.signNostrEvent).toHaveBeenCalledTimes(1);
expect(wallet.signMessage).not.toHaveBeenCalled();
});
test('does not switch account credential method after Nostr transport failure', async () => {
const wallet = walletFixture();
wallet.signNostrEvent.mockRejectedValue(Object.assign(new Error('transport interrupted'), { code: 'TIMEOUT' }));
const { auth } = loadAuth(wallet);
await expect(auth.login()).rejects.toThrow('transport interrupted');
expect(wallet.signMessage).not.toHaveBeenCalled();
});
test('WOW scope denial stops the flow without another signature popup', async () => {
const wallet = walletFixture();
wallet.connect.mockRejectedValue(Object.assign(new Error('denied'), { code: 'USER_REJECTED' }));
const { auth } = loadAuth(wallet);
await expect(auth.login()).rejects.toMatchObject({ code: 'USER_REJECTED' });
expect(wallet.signNostrEvent).not.toHaveBeenCalled();
expect(wallet.signMessage).not.toHaveBeenCalled();
});
test('a declined Nostr grant does not retry signing or switch credentials', async () => {
const wallet = walletFixture();
wallet.signNostrEvent.mockRejectedValue(Object.assign(new Error('scope required'), { code: 'NOT_AUTHORIZED' }));
wallet.getNostrPublicKey.mockResolvedValue(null);
const { auth } = loadAuth(wallet);
await expect(auth.login()).rejects.toThrow();
expect(wallet.signNostrEvent).toHaveBeenCalledTimes(1);
expect(wallet.signMessage).not.toHaveBeenCalled();
});
test('legacy-only wallets still prove the same credential with an explicit WOW grant', async () => {
const wallet = walletFixture();
delete wallet.signNostrEvent;
delete wallet.getNostrPublicKey;
const { auth } = loadAuth(wallet);
await auth.login();
expect(wallet.connect).toHaveBeenCalledTimes(1);
expect(wallet.connect).toHaveBeenCalledWith(['wow']);
expect(wallet.signMessage).toHaveBeenCalledTimes(1);
});
test('backend account linkage does not imply a WOW grant and never prompts on status check', async () => {
const wallet = walletFixture();
wallet.getPublicKeys.mockResolvedValue({ wow: null });
const { auth } = loadAuth(wallet);
await auth.checkStatus();
expect(auth._isLinked).toBe(true);
expect(auth._wowReady).toBe(false);
expect(wallet.connect).not.toHaveBeenCalled();
await auth.requestWowAccess();
expect(auth._wowReady).toBe(true);
expect(wallet.signNostrEvent).not.toHaveBeenCalled();
});
test.each(['forbidden', 'offline'])('failed status lookup clears stale account and scope: %s', async failure => {
const context = loadAuth(walletFixture());
context.auth._isLinked = true;
context.auth._wowReady = true;
context.fetch.mockImplementation(async () => {
if (failure === 'offline') throw new Error('offline fixture');
return { ok: false };
});
await context.auth.checkStatus();
expect(context.auth._isLinked).toBe(false);
expect(context.auth._wowReady).toBe(false);
});
test('a prior account status response cannot relink the current session', async () => {
const context = loadAuth(walletFixture());
let finish;
context.fetch.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
const pending = context.auth.checkStatus();
context.SocketHandlers._paymentSessionEpoch = 2;
finish({ ok: true, json: async () => ({ linked: true }) });
await pending;
expect(context.auth._isLinked).toBe(false);
expect(context.auth._wowReady).toBe(false);
});
});
+138
View File
@@ -0,0 +1,138 @@
const { createAdapter, paymentRequest } = require('../html/js/network/smirkPaymentAdapter');
const NOW = 1800000000000;
function invoice(extra = {}) {
return {
paymentId: 31, ownerUserId: 7, asset: 'wow', network: 'mainnet',
amount: '123456789012', humanAmount: '1.23456789012',
address: 'Wo' + '1'.repeat(95), expiresAt: new Date(NOW + 60000).toISOString(),
paymentType: 'credits_package', memo: 'Buy 10 credits', package: { credits: 10 }, ...extra
};
}
function setup(extra = {}) {
const values = new Map();
const storage = {
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key)
};
let tail = Promise.resolve();
const withLock = (_name, work) => {
const result = tail.then(work);
tail = result.catch(() => {});
return result;
};
const options = { storage, withLock, crypto: require('crypto').webcrypto, now: () => NOW, ...extra };
return { adapter: createAdapter(options), options, values };
}
describe('Smirk WOW invoice contract', () => {
test('uses exact server decimal and memo without inventing wallet parameters', () => {
expect(paymentRequest(invoice(), NOW)).toEqual({
asset: 'wow', amount: '1.23456789012', address: invoice().address, memo: 'Buy 10 credits'
});
});
test('accepts a WOW RPC subaddress as well as a standard address', () => {
const address = 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn';
expect(paymentRequest(invoice({ address }), NOW).address).toBe(address);
});
test('restored invoices keep the persisted memo without package display metadata', () => {
expect(paymentRequest(invoice({ package: undefined, restored: true }), NOW).memo).toBe('Buy 10 credits');
});
test.each([
{ humanAmount: undefined, amountFormatted: undefined },
{ humanAmount: '0' }, { humanAmount: '-1' }, { humanAmount: '1e-3' },
{ humanAmount: '1.000000000001' }, { humanAmount: 1 },
{ humanAmount: '1.23' }, { amount: '1.2' },
{ asset: 'xmr' }, { asset: undefined }, { network: 'stagenet' }, { network: undefined },
{ paymentId: undefined }, { ownerUserId: undefined }, { address: 'not-a-wow-address' }, { memo: undefined },
{ expiresAt: undefined }, { expiresAt: new Date(NOW).toISOString() }
])('refuses invalid or mismatched invoice before wallet invocation: %j', async extra => {
const { adapter } = setup();
const wallet = { requestPayment: jest.fn() };
await expect(adapter.pay(invoice(extra), wallet)).rejects.toThrow();
expect(wallet.requestPayment).not.toHaveBeenCalled();
});
});
describe('Smirk submission evidence and repeat protection', () => {
test.each([
[{ success: true, txid: 'a'.repeat(64) }, 'submitted'],
[{ success: true }, 'unknown'],
[{ success: false }, 'failed'],
[{ success: false, txid: 'a'.repeat(64) }, 'unknown'],
[undefined, 'unknown'], [true, 'unknown'], [{ txid: 'a'.repeat(64) }, 'unknown'],
[{ success: true, txid: 'malformed' }, 'unknown']
])('classifies wallet result %j as %s without confirming credits', async (result, state) => {
const { adapter } = setup();
const wallet = { requestPayment: jest.fn().mockResolvedValue(result) };
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state });
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state, repeated: true });
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
});
test.each([['USER_REJECTED', 'cancelled'], ['NOT_AUTHORIZED', 'unavailable'], ['TIMEOUT', 'unknown']])(
'classifies %s and never automatically retries', async (code, state) => {
const { adapter } = setup();
const wallet = { requestPayment: jest.fn().mockRejectedValue(Object.assign(new Error('fixture'), { code })) };
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state });
await adapter.pay(invoice(), wallet);
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
}
);
test('coordinates concurrent events, two tabs and a page reload for the same invoice', async () => {
const { adapter, options } = setup();
const secondTab = createAdapter(options);
const wallet = { requestPayment: jest.fn().mockResolvedValue({ success: true }) };
await Promise.all([adapter.pay(invoice(), wallet), secondTab.pay(invoice(), wallet)]);
const reloaded = createAdapter(options);
expect(await reloaded.pay(invoice({ reused: true }), wallet)).toMatchObject({ state: 'unknown', repeated: true });
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
});
test('records intent before invoking a wallet and retains unknown after interruption', async () => {
const { adapter, options } = setup();
const wallet = { requestPayment: jest.fn(async () => {
expect(createAdapter(options).status(invoice()).state).toBe('unknown');
throw new Error('Transport closed after possible submission');
}) };
expect(await adapter.pay(invoice(), wallet)).toMatchObject({ state: 'unknown' });
expect(createAdapter(options).status(invoice())).toMatchObject({ state: 'unknown' });
});
test.each([
{ ownerUserId: 8 }, { memo: 'different purchase' },
{ humanAmount: '2', amount: '200000000000' },
{ expiresAt: new Date(NOW + 120000).toISOString() }
])('refuses changed immutable invoice fields after an attempt: %j', async extra => {
const { adapter } = setup();
const wallet = { requestPayment: jest.fn().mockResolvedValue({ success: true }) };
await adapter.pay(invoice(), wallet);
await expect(adapter.pay(invoice(extra), wallet)).rejects.toThrow(/changed/i);
expect(wallet.requestPayment).toHaveBeenCalledTimes(1);
});
test('rechecks session admission inside the cross-tab lock before writing intent', async () => {
const { adapter } = setup();
const wallet = { requestPayment: jest.fn() };
await expect(adapter.pay(invoice(), wallet, () => { throw new Error('Account changed'); })).rejects.toThrow('Account changed');
expect(wallet.requestPayment).not.toHaveBeenCalled();
expect(adapter.status(invoice())).toBeNull();
});
test.each([
{ withLock: null },
{ storage: null },
{ storage: { getItem() { throw new Error('blocked'); }, setItem() {} } }
])('refuses unsafe repeat protection before requesting funds', async options => {
const { adapter } = setup(options);
const wallet = { requestPayment: jest.fn() };
await expect(adapter.pay(invoice(), wallet)).rejects.toThrow();
expect(wallet.requestPayment).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
const handlers = require('../html/js/network/socketHandlers');
test('new, duplicate and recovered invoice events display details without opening a wallet', () => {
const previous = handlers._showPaymentFlow;
handlers._showPaymentFlow = jest.fn();
global.SmirkAuth = { _isLinked: true, isAvailable: () => true };
global.window = { smirk: { requestPayment: jest.fn() } };
const payment = jest.spyOn(handlers, '_trySmirkPayment').mockResolvedValue();
try {
handlers.onPaymentCreated({ paymentId: 8 });
handlers.onPaymentCreated({ paymentId: 8 });
handlers.onPaymentCreated({ paymentId: 8, reused: true });
expect(payment).not.toHaveBeenCalled();
expect(handlers._showPaymentFlow).toHaveBeenCalledTimes(3);
} finally {
payment.mockRestore();
handlers._showPaymentFlow = previous;
delete global.SmirkAuth;
delete global.window;
}
});
test('account changes invalidate invoice consent and cannot submit an old owner invoice', async () => {
const chain = {
append() { return this; }, text: jest.fn(function() { return this; }),
prop: jest.fn(function() { return this; }), remove: jest.fn(),
hide: jest.fn(), val: jest.fn(), removeData: jest.fn(function() { return this; })
};
global.$ = () => chain;
global.UI = { scrollChat() {} };
global.localStorage = { setItem() {} };
global.SmirkAuth = { _isLinked: true, _wowReady: true };
global.window = { smirk: { requestPayment: jest.fn() } };
const refresh = jest.spyOn(handlers, '_refreshSmirkLinked').mockImplementation(() => {});
const address = jest.spyOn(handlers, '_updateAddressButtonStatus').mockImplementation(() => {});
const adapter = jest.spyOn(handlers, '_getSmirkPaymentAdapter').mockReturnValue({ pay: jest.fn() });
const hideQR = jest.spyOn(handlers, 'hidePaymentQR').mockImplementation(() => {});
try {
Object.assign(handlers, { _smirkEnabled: true, _cryptoType: 'WOW', _network: 'mainnet', _sessionUserId: 7, _activePaymentId: 31 });
handlers.onSessionResumed({ token: 'fixture-session', userId: 8 });
expect(handlers._sessionUserId).toBe(8);
expect(handlers._activePaymentId).toBeNull();
expect(chain.val).toHaveBeenCalledWith('');
expect(chain.removeData).toHaveBeenCalledWith('payment-id');
expect(hideQR).toHaveBeenCalled();
expect(SmirkAuth._isLinked).toBe(false);
expect(SmirkAuth._wowReady).toBe(false);
await handlers._trySmirkPayment({ paymentId: 31, ownerUserId: 7 });
expect(adapter).not.toHaveBeenCalled();
expect(window.smirk.requestPayment).not.toHaveBeenCalled();
} finally {
refresh.mockRestore(); address.mockRestore(); adapter.mockRestore(); hideQR.mockRestore();
for (const key of ['$', 'UI', 'localStorage', 'SmirkAuth', 'window']) delete global[key];
handlers._sessionUserId = null;
}
});