try to fix this spaghetti of a code
This commit is contained in:
+80
-101
@@ -1,110 +1,89 @@
|
||||
const {test, before, after} = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {once} = require("node:events");
|
||||
const {WebSocket} = require("ws");
|
||||
process.env.DATABASE_PATH = ":memory:";
|
||||
process.env.TOKEN_SECRET = "isolated-test-secret";
|
||||
const api = require("../gambling");
|
||||
let base;
|
||||
const sessions = Object.create(null);
|
||||
before(async () => {
|
||||
await api.initializeDatabase({migrate: false});
|
||||
api.server.listen(0, "127.0.0.1");
|
||||
await once(api.server, "listening");
|
||||
base = `http://127.0.0.1:${api.server.address().port}`;
|
||||
});
|
||||
after(() => api.close());
|
||||
async function request(route, {user, method = "GET", body} = {}) {
|
||||
return fetch(base + route, {method, headers: {
|
||||
...(body !== undefined ? {"Content-Type": "application/json"} : {}),
|
||||
...(user ? {Authorization: `Bearer ${sessions[user]}`} : {})
|
||||
}, body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
}
|
||||
const {test} = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {once} = require('node:events');
|
||||
const crypto = require('node:crypto');
|
||||
const {WebSocket} = require('ws');
|
||||
const {createApplication} = require('../gambling');
|
||||
const {parseCookies} = require('../lib/transport');
|
||||
const secret = 'test-secret-with-at-least-thirty-two-characters';
|
||||
function nextMessage(socket, type) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {socket.off("message", listener); reject(new Error(`Timeout: ${type}`));}, 3000);
|
||||
function listener(raw) {
|
||||
const value = JSON.parse(raw);
|
||||
if (value.type !== type) return;
|
||||
clearTimeout(timer); socket.off("message", listener); resolve(value);
|
||||
}
|
||||
socket.on("message", listener);
|
||||
const timeout = setTimeout(() => {socket.off('message', listener); reject(new Error(`Timeout waiting for ${type}`));}, 3000);
|
||||
const listener = raw => {const message = JSON.parse(raw); if (message.type !== type) return; clearTimeout(timeout); socket.off('message', listener); resolve(message);};
|
||||
socket.on('message', listener);
|
||||
});
|
||||
}
|
||||
test("HTTP authentication and WebSocket lobby lifecycle", async () => {
|
||||
assert.equal((await request("/balance")).status, 401);
|
||||
assert.equal((await request("/register", {method: "POST"})).status, 400);
|
||||
for (const user of ["alice", "bob", "__proto__"]) {
|
||||
const response = await request("/register", {method: "POST", body: {username: user, password: "test-password"}});
|
||||
assert.equal(response.status, 200);
|
||||
sessions[user] = (await response.json()).token;
|
||||
}
|
||||
assert.equal((await request("/balance", {user: "alice"})).status, 200);
|
||||
assert.equal((await request("/register", {method: "POST", body: {username: "long", password: "x".repeat(73)}})).status, 400);
|
||||
const oldToken = sessions.alice;
|
||||
const login = await request("/login", {method: "POST", body: {username: " ALICE ", password: "test-password"}});
|
||||
sessions.alice = (await login.json()).token;
|
||||
assert.notEqual(sessions.alice, oldToken);
|
||||
assert.equal((await request("/lobby/join?lobbyId=__proto__", {user: "alice"})).status, 404);
|
||||
const socket = new WebSocket(base.replace("http", "ws"), {headers: {Authorization: `Bearer ${sessions.alice}`, Cookie: "bad=%ZZ"}});
|
||||
await nextMessage(socket, "auth:success");
|
||||
let pending = nextMessage(socket, "lobby:state");
|
||||
socket.send(JSON.stringify({type: "lobby:create"}));
|
||||
const lobby = await pending;
|
||||
assert.deepEqual(lobby.players, ["alice"]);
|
||||
assert.equal((await request("/lobby/create", {user: "alice"})).status, 409);
|
||||
assert.equal((await request(`/lobby/join?lobbyId=${lobby.lobbyId}`, {user: "bob"})).status, 200);
|
||||
pending = nextMessage(socket, "poker:started");
|
||||
socket.send(JSON.stringify({type: "poker:start"}));
|
||||
await pending;
|
||||
pending = nextMessage(socket, "poker:error");
|
||||
socket.send(JSON.stringify({type: "poker:start"}));
|
||||
assert.match((await pending).message, /already started/);
|
||||
pending = nextMessage(socket, "error");
|
||||
socket.send("null");
|
||||
assert.match((await pending).message, /object/);
|
||||
const closed = once(socket, "close");
|
||||
await request("/logout", {user: "alice", method: "DELETE"});
|
||||
socket.send(JSON.stringify({type: "lobby:create"}));
|
||||
await closed;
|
||||
assert.equal((await request("/balance", {user: "alice"})).status, 403);
|
||||
async function fixture(t, options = {}) {
|
||||
const api = await createApplication({secret, databasePath: ':memory:', bcryptRounds: 4, intermissionMs: 60000, ...options});
|
||||
await api.listen(0); t.after(() => api.close());
|
||||
const base = `http://127.0.0.1:${api.server.address().port}`;
|
||||
const request = async (route, {token, method = 'GET', body, headers = {}} = {}) => fetch(base + route, {method, redirect: 'manual',
|
||||
headers: {...(token ? {Authorization: `Bearer ${token}`} : {}), ...(body !== undefined ? {'Content-Type': 'application/json'} : {}), ...headers},
|
||||
body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
return {api, base, request};
|
||||
}
|
||||
test('HTTP contract, sessions, origin checks, and socket lifecycle', async t => {
|
||||
const {api, base, request} = await fixture(t);
|
||||
assert.equal((await request('/balance')).status, 401);
|
||||
assert.equal((await request('/poker.html')).status, 302);
|
||||
assert.equal((await request('/register', {method: 'POST'})).status, 400);
|
||||
assert.equal((await request('/lobby/create')).status, 405);
|
||||
const registered = await request('/register', {method: 'POST', body: {username: '__proto__', password: 'password'}});
|
||||
const {token} = await registered.json();
|
||||
assert.equal(registered.status, 200);
|
||||
assert.match(registered.headers.get('set-cookie'), /HttpOnly/);
|
||||
assert.notEqual((await api.repository.user('__proto__')).sessionHash, token);
|
||||
assert.equal((await request('/lobby/create', {method: 'POST', token, headers: {Origin: 'https://evil.invalid'}, body: {requestId: crypto.randomUUID()}})).status, 403);
|
||||
const bob = await api.sessions.register({username: 'bob', password: 'password'});
|
||||
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${token}`, Cookie: 'bad=%ZZ'}});
|
||||
await nextMessage(socket, 'auth:success');
|
||||
let pending = nextMessage(socket, 'command:ack');
|
||||
socket.send(JSON.stringify({type: 'lobby:create', requestId: crypto.randomUUID()}));
|
||||
const created = await pending;
|
||||
assert.equal(created.commandType, 'lobby:create');
|
||||
assert.equal((await request('/lobby/join', {method: 'POST', token: bob.token, body: {lobbyId: created.lobbyId, requestId: crypto.randomUUID()}})).status, 200);
|
||||
const start = {type: 'poker:start', lobbyId: created.lobbyId, requestId: crypto.randomUUID()};
|
||||
pending = nextMessage(socket, 'command:ack'); socket.send(JSON.stringify(start)); await pending;
|
||||
pending = nextMessage(socket, 'error'); socket.send(JSON.stringify({...start, requestId: crypto.randomUUID()}));
|
||||
assert.equal((await pending).code, 'GAME_STARTED');
|
||||
pending = nextMessage(socket, 'error'); socket.send('null'); assert.equal((await pending).code, 'INVALID_MESSAGE');
|
||||
const closed = once(socket, 'close');
|
||||
await request('/logout', {method: 'DELETE', token});
|
||||
assert.equal((await closed)[0], 1008);
|
||||
assert.equal((await request('/balance', {token})).status, 401);
|
||||
});
|
||||
test("leaving before the current seat preserves turn and independent lobby roster", async () => {
|
||||
const id = "test-table";
|
||||
api.playerMap[id] = ["alice", "bob", "__proto__"];
|
||||
const game = await api.createPokerGame(id);
|
||||
assert.equal(game.players[game.currentPlayerIndex], "bob");
|
||||
assert.notEqual(game.players, api.playerMap[id]);
|
||||
await api.removePlayerFromPokerGame(id, "alice");
|
||||
assert.equal(game.players[game.currentPlayerIndex], "bob");
|
||||
assert.equal(game.contributions.alice, 10);
|
||||
api.clearTurnTimer(game);
|
||||
test('login rotation immediately closes old sockets; proxy headers are untrusted by default', async t => {
|
||||
const {api, base, request} = await fixture(t);
|
||||
const user = await api.sessions.register({username: 'alice', password: 'password'});
|
||||
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${user.token}`}});
|
||||
await nextMessage(socket, 'auth:success');
|
||||
const closed = once(socket, 'close');
|
||||
const response = await request('/login', {method: 'POST', headers: {'X-Forwarded-Proto': 'https'}, body: {username: 'ALICE', password: 'password'}});
|
||||
assert.equal(response.status, 200); assert.doesNotMatch(response.headers.get('set-cookie'), /; Secure/);
|
||||
assert.equal((await closed)[0], 1008);
|
||||
assert.equal(await api.sessions.verify(user.token), null);
|
||||
});
|
||||
test("fractional and nonnumeric bets do not mutate the pot", () => {
|
||||
const game = api.pokerGames["test-table"];
|
||||
const pot = game.pot;
|
||||
for (const amount of [0.5, "10", null, Infinity, -1]) {
|
||||
assert.match(api.applyPokerAction(game, "bob", "bet", {amount}).error, /whole number/);
|
||||
assert.equal(game.pot, pot);
|
||||
}
|
||||
test('authentication is rate limited and cookies tolerate invalid escapes', async t => {
|
||||
const {request} = await fixture(t, {authLimit: 2});
|
||||
for (let index = 0; index < 2; index++) assert.equal((await request('/login', {method: 'POST', body: {username: 'missing', password: 'password'}})).status, 401);
|
||||
const limited = await request('/login', {method: 'POST', body: {username: 'missing', password: 'password'}});
|
||||
assert.equal(limited.status, 429); assert.ok(limited.headers.has('retry-after'));
|
||||
assert.equal(parseCookies('bad=%ZZ; authorization=ok').authorization, 'ok');
|
||||
});
|
||||
test("malformed cookie encoding is ignored", () => {
|
||||
assert.equal(api.parseCookieHeader("bad=%ZZ; authorization=valid").authorization, "valid");
|
||||
test('socket expires without waiting for a client message', async t => {
|
||||
const {api, base} = await fixture(t, {sessionSeconds: 1});
|
||||
const {token} = await api.sessions.register({username: 'alice', password: 'password'});
|
||||
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${token}`}});
|
||||
const closed = once(socket, 'close');
|
||||
await nextMessage(socket, 'auth:success');
|
||||
assert.equal((await closed)[0], 1008);
|
||||
});
|
||||
|
||||
test("ante-only all-ins run out the board without waiting for a broke player", async () => {
|
||||
const id = "all-in-table";
|
||||
api.playerMap[id] = ["alice", "bob"];
|
||||
api.pokerGames[id] = {round: 1, phase: "intermission", dealerIndex: 0, stacks: {alice: 10, bob: 10}};
|
||||
const game = await api.createPokerGame(id);
|
||||
assert.equal(game.round, 1);
|
||||
assert.equal(game.communityCards.length, 5);
|
||||
assert.equal(game.potAwarded, true);
|
||||
assert.equal(game.stacks.alice + game.stacks.bob, 20);
|
||||
// Allow the asynchronous persistence and intermission transition to complete.
|
||||
for (let i = 0; i < 100 && api.pokerGames[id] === game; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(api.pokerGames[id].phase, "intermission");
|
||||
assert.equal(api.pokerGames[id].round, 2);
|
||||
test('WebSocket rejects cross-origin upgrades', async t => {
|
||||
const {api, base} = await fixture(t);
|
||||
const {token} = await api.sessions.register({username: 'alice', password: 'password'});
|
||||
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${token}`, Origin: 'https://evil.invalid'}});
|
||||
socket.on('error', () => {});
|
||||
const [, response] = await once(socket, 'unexpected-response');
|
||||
assert.equal(response.statusCode, 403); response.resume(); socket.terminate();
|
||||
});
|
||||
module.exports = {fixture};
|
||||
|
||||
Reference in New Issue
Block a user