976 lines
32 KiB
TypeScript
976 lines
32 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
|
|
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import { adminStore } from './admin-access-fixture';
|
|
import {
|
|
adminLogin,
|
|
requireAdmin,
|
|
adminLogoutAll,
|
|
AdminError,
|
|
type AdminRole,
|
|
} from '../db/admin-access';
|
|
import {
|
|
readAdminLobby,
|
|
configureAdminLobby,
|
|
} from '../db/admin-lobby-configuration';
|
|
import { applyAdminRoomControl } from '../db/admin-lifecycle';
|
|
import {
|
|
validAdminLobbyInput,
|
|
type AdminLobbyInput,
|
|
type AdminLobbyAction,
|
|
} from '../lib/admin-lobby-configuration';
|
|
import {
|
|
freshAdminLobby,
|
|
applyAdminLobbyAction,
|
|
} from '../game/admin-lobby-configuration';
|
|
import * as engine from '../game/engine';
|
|
import * as bots from '../game/bots';
|
|
import * as seatAiDelegation from '../lib/seat-ai-delegation';
|
|
import type * as Rooms from '../db/rooms';
|
|
|
|
const secret = () => randomBytes(32).toString('hex');
|
|
const status = (wanted: number) => (error: unknown) =>
|
|
error instanceof AdminError && error.status === wanted;
|
|
const operation = (
|
|
expectedVersion: number,
|
|
action: AdminLobbyAction = { type: 'rules', advanced: true },
|
|
): AdminLobbyInput => ({
|
|
operationId: randomUUID(),
|
|
expectedVersion,
|
|
action,
|
|
reason: 'Private operational reason',
|
|
});
|
|
function loadRooms(database: D1Database) {
|
|
const exports = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports,
|
|
crypto: webcrypto,
|
|
TextEncoder,
|
|
structuredClone,
|
|
JSON,
|
|
require: (name: string) => {
|
|
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
|
if (name === '@/game/engine') return engine;
|
|
if (name === '@/game/bots') return bots;
|
|
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
|
|
throw new Error('Unexpected dependency ' + name);
|
|
},
|
|
},
|
|
);
|
|
return exports as typeof Rooms;
|
|
}
|
|
async function fixture(role: AdminRole = 'operator') {
|
|
const store = adminStore();
|
|
assert.ok(store.sqlite instanceof DatabaseSync);
|
|
const account = store.provision(role, 'Private administrator name');
|
|
const login = await adminLogin(store.database, account.key);
|
|
const identity = await requireAdmin(store.database, login.token);
|
|
const rooms = loadRooms(store.database);
|
|
const owner = await rooms.createRoom('Lobby owner', 'atreides', false, []);
|
|
const code = owner.view.code;
|
|
const auth = await rooms.authenticate(code, owner.token);
|
|
const guest = await rooms.joinRoom(code, 'Lobby guest', 'emperor');
|
|
const guestAuth = await rooms.authenticate(code, guest.token!);
|
|
const row = () =>
|
|
store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(code)!;
|
|
const version = () => Number(row().version);
|
|
const read = () => readAdminLobby(store.database, identity, code);
|
|
const configure = (input = operation(version())) =>
|
|
configureAdminLobby(store.database, identity, code, input);
|
|
const secureRows = () =>
|
|
[
|
|
'seats',
|
|
'seat_recovery_keys',
|
|
'seat_recovery_receipts',
|
|
'seat_handover_offers',
|
|
'seat_handover_claim_receipts',
|
|
'seat_ai_delegations',
|
|
'room_entry_receipts',
|
|
'room_messages',
|
|
].map((table) => store.sqlite.prepare(`SELECT * FROM ${table}`).all());
|
|
return {
|
|
...store,
|
|
account,
|
|
login,
|
|
identity,
|
|
rooms,
|
|
owner,
|
|
guest,
|
|
code,
|
|
auth,
|
|
guestAuth,
|
|
row,
|
|
version,
|
|
read,
|
|
configure,
|
|
secureRows,
|
|
};
|
|
}
|
|
function pauseBatch(f: Awaited<ReturnType<typeof fixture>>, nth = 2) {
|
|
let enter!: () => void,
|
|
release!: () => void,
|
|
count = 0;
|
|
const waiting = new Promise<void>((resolve) => {
|
|
enter = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
f.hooks.beforeBatch = async () => {
|
|
if (++count !== nth) return;
|
|
delete f.hooks.beforeBatch;
|
|
enter();
|
|
await gate;
|
|
};
|
|
return { waiting, release };
|
|
}
|
|
const audits = (f: Awaited<ReturnType<typeof fixture>>) =>
|
|
f.sqlite.prepare('SELECT * FROM admin_lobby_operations').all();
|
|
|
|
void test('lobby migration is additive and preserves existing game/seat/admin/creation records', () => {
|
|
const store = adminStore(':memory:', false);
|
|
try {
|
|
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
|
.filter((file) => file.endsWith('.sql') && file < '0011')
|
|
.sort())
|
|
store.sqlite.exec(
|
|
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
|
);
|
|
store.sqlite
|
|
.prepare(
|
|
'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)',
|
|
)
|
|
.run('PRESERVE', 'private saved bytes', 5, 42);
|
|
store.sqlite
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
|
)
|
|
.run('private hash', 'PRESERVE', 'owner');
|
|
store.provision('owner');
|
|
const rows = () =>
|
|
[
|
|
'rooms',
|
|
'seats',
|
|
'admin_accounts',
|
|
'admin_audit',
|
|
'admin_room_creations',
|
|
].map((table) => store.sqlite.prepare(`SELECT * FROM ${table}`).all());
|
|
const before = rows();
|
|
store.sqlite.exec(
|
|
readFileSync(
|
|
new URL(
|
|
'../drizzle/0011_admin_lobby_configuration.sql',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.deepEqual(rows(), before);
|
|
assert.deepEqual(
|
|
store.sqlite.prepare('SELECT * FROM admin_lobby_operations').all(),
|
|
[],
|
|
);
|
|
} finally {
|
|
store.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('admin lobby input permits only exact bounded commands with valid base factions and public parameters', () => {
|
|
const valid: AdminLobbyAction[] = [
|
|
{ type: 'rules', advanced: true },
|
|
{ type: 'techTokens', enabled: true },
|
|
{ type: 'strongholdCards', enabled: false },
|
|
{ type: 'shortAllianceVictory', enabled: true },
|
|
{ type: 'addBot', faction: 'guild', difficulty: 'Hard' },
|
|
{ type: 'removeBot', target: 'bot' },
|
|
{
|
|
type: 'configureBot',
|
|
target: 'bot',
|
|
faction: 'guild',
|
|
difficulty: 'Easy',
|
|
position: 6,
|
|
},
|
|
{ type: 'assignHost', target: 'guest' },
|
|
];
|
|
for (const action of valid)
|
|
assert.equal(validAdminLobbyInput(operation(3, action)), true);
|
|
const malformed = [
|
|
{ type: 'start' },
|
|
{ type: 'ready' },
|
|
{ type: 'homeworlds', enabled: true },
|
|
{ type: 'setAutopilot', difficulty: 'Hard' },
|
|
{ type: 'addBot', faction: 'ixians', difficulty: 'Hard' },
|
|
{ type: 'addBot', faction: 'guild', difficulty: 'Impossible' },
|
|
{ type: 'rules', advanced: 'true' },
|
|
{ type: 'shortAllianceVictory', enabled: 'true' },
|
|
{ type: 'shortAllianceVictory', enabled: true, extra: true },
|
|
{ type: 'assignHost', target: '' },
|
|
{ type: 'assignHost', target: 'guest', token: secret() },
|
|
{
|
|
type: 'configureBot',
|
|
target: 'bot',
|
|
faction: 'guild',
|
|
difficulty: 'Easy',
|
|
position: 7,
|
|
},
|
|
];
|
|
for (const action of malformed)
|
|
assert.equal(validAdminLobbyInput({ ...operation(3), action }), false);
|
|
for (const patch of [
|
|
{ operationId: randomUUID() + '\n' },
|
|
{ expectedVersion: -1 },
|
|
{ expectedVersion: Number.MAX_SAFE_INTEGER },
|
|
{ reason: ' ' },
|
|
{ reason: 'x'.repeat(301) },
|
|
{ reason: 'line\nbreak' },
|
|
{ sessionToken: secret() },
|
|
])
|
|
assert.equal(validAdminLobbyInput({ ...operation(3), ...patch }), false);
|
|
});
|
|
|
|
void test('viewer receives only public lobby configuration and active-human host eligibility without mutation', async () => {
|
|
const f = await fixture('viewer');
|
|
try {
|
|
const before = f.row(),
|
|
secure = f.secureRows();
|
|
const view = await f.read();
|
|
assert.equal(view.editable, true);
|
|
assert.equal(view.blockedReason, null);
|
|
assert.equal(view.status, 'lobby');
|
|
assert.equal(view.host, f.auth.playerId);
|
|
assert.ok(view.players.every((player) => player.hostEligible));
|
|
assert.deepEqual(
|
|
view.players.map((player) => player.position),
|
|
[1, 2],
|
|
);
|
|
assert.deepEqual(Object.keys(view).sort(), [
|
|
'advanced',
|
|
'blockedReason',
|
|
'code',
|
|
'control',
|
|
'editable',
|
|
'host',
|
|
'players',
|
|
'shortAllianceVictory',
|
|
'status',
|
|
'strongholdCards',
|
|
'techTokens',
|
|
'version',
|
|
]);
|
|
assert.deepEqual(Object.keys(view.players[0]).sort(), [
|
|
'bot',
|
|
'faction',
|
|
'hostEligible',
|
|
'id',
|
|
'name',
|
|
'position',
|
|
'ready',
|
|
]);
|
|
for (const value of [
|
|
f.owner.token,
|
|
f.guest.token!,
|
|
f.identity.id,
|
|
f.identity.sessionHash,
|
|
'traitor',
|
|
'spice',
|
|
'prediction',
|
|
'deck',
|
|
])
|
|
assert.equal(JSON.stringify(view).includes(value), false);
|
|
assert.deepEqual(f.row(), before);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
await assert.rejects(f.configure(), status(403));
|
|
assert.deepEqual(audits(f), []);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('audited admin short-alliance selection preserves seats, resets readiness and replays exactly once', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
assert.equal((await f.read()).shortAllianceVictory, false);
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
const secure = f.secureRows();
|
|
const input = operation(f.version(), { type: 'shortAllianceVictory', enabled: true });
|
|
const changed = await f.configure(input);
|
|
assert.equal(changed.lobby.shortAllianceVictory, true);
|
|
assert.ok(changed.lobby.players.every(player => !player.ready));
|
|
assert.equal((await f.rooms.readRoom(f.code)).shortAllianceVictory, true);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
assert.equal(audits(f).length, 1);
|
|
const audit = audits(f)[0];
|
|
assert.equal(JSON.parse(String(audit.before_configuration)).shortAllianceVictory, false);
|
|
assert.equal(JSON.parse(String(audit.after_configuration)).shortAllianceVictory, true);
|
|
const replay = await f.configure(input);
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.appliedVersion, changed.appliedVersion);
|
|
assert.equal(replay.lobby.shortAllianceVictory, true);
|
|
assert.equal(audits(f).length, 1);
|
|
const disabled = await f.configure(operation(f.version(), { type: 'shortAllianceVictory', enabled: false }));
|
|
assert.equal(disabled.lobby.shortAllianceVictory, false);
|
|
assert.equal((await f.rooms.readRoom(f.code)).shortAllianceVictory, undefined);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('neutral admin changes reuse ordinary rule/AI actions, preserve host sessions and clear human readiness', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
const secure = f.secureRows();
|
|
await f.configure(
|
|
operation(f.version(), { type: 'rules', advanced: true }),
|
|
);
|
|
await f.configure(
|
|
operation(f.version(), { type: 'techTokens', enabled: true }),
|
|
);
|
|
await f.configure(
|
|
operation(f.version(), { type: 'strongholdCards', enabled: true }),
|
|
);
|
|
const added = await f.configure(
|
|
operation(f.version(), {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Easy',
|
|
}),
|
|
);
|
|
const bot = added.lobby.players.find((player) => player.bot)!;
|
|
assert.equal(bot.hostEligible, false);
|
|
assert.equal(bot.ready, true);
|
|
const configured = await f.configure(
|
|
operation(f.version(), {
|
|
type: 'configureBot',
|
|
target: bot.id,
|
|
faction: 'harkonnen',
|
|
difficulty: 'Brutal',
|
|
position: 6,
|
|
}),
|
|
);
|
|
assert.deepEqual(
|
|
configured.lobby.players.find((player) => player.id === bot.id),
|
|
{
|
|
id: bot.id,
|
|
name: 'Harkonnen AI',
|
|
faction: 'harkonnen',
|
|
bot: 'Brutal',
|
|
position: 6,
|
|
ready: true,
|
|
hostEligible: false,
|
|
},
|
|
);
|
|
assert.ok(
|
|
configured.lobby.players
|
|
.filter((player) => !player.bot)
|
|
.every((player) => !player.ready),
|
|
);
|
|
const changed = await f.configure(
|
|
operation(f.version(), { type: 'rules', advanced: false }),
|
|
);
|
|
assert.equal(changed.lobby.strongholdCards, false);
|
|
assert.equal(changed.lobby.techTokens, true);
|
|
await f.configure(
|
|
operation(f.version(), { type: 'removeBot', target: bot.id }),
|
|
);
|
|
const g = await f.rooms.readRoom(f.code);
|
|
assert.equal(g.players.length, 2);
|
|
assert.equal(g.host, f.auth.playerId);
|
|
assert.ok(
|
|
g.players.every(
|
|
(player) =>
|
|
player.reserves === 20 &&
|
|
player.spice === 0 &&
|
|
player.hand.length === 0,
|
|
),
|
|
);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
assert.ok(
|
|
g.log
|
|
.slice(2)
|
|
.every((entry) => entry.text.startsWith('An administrator')),
|
|
);
|
|
assert.equal(JSON.stringify(g.log).includes(f.identity.name), false);
|
|
assert.equal(
|
|
JSON.stringify(g.log).includes('Private operational reason'),
|
|
false,
|
|
);
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
assert.equal(
|
|
(await f.rooms.readSeatView(f.code, auth)).me,
|
|
auth.playerId,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('fresh supported guard accepts legacy player circles and disabled modules but rejects used pieces, unknown runtime and unsupported profiles generically', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const original = await f.rooms.readRoom(f.code);
|
|
const legacy = structuredClone(original);
|
|
delete legacy.playerPositions;
|
|
legacy.homeworlds = null;
|
|
legacy.nexusCards = null;
|
|
delete legacy.leaderSkills;
|
|
legacy.discoveryEnabled = false;
|
|
assert.equal(freshAdminLobby(legacy), true);
|
|
const corruptions: ((game: engine.Game) => void)[] = [
|
|
(g) => {
|
|
g.players[0].spice = 1;
|
|
},
|
|
(g) => {
|
|
g.players[0].reserves = 19;
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].dead = true;
|
|
},
|
|
(g) => {
|
|
g.expansions = ['ix'];
|
|
},
|
|
(g) => {
|
|
g.homeworlds = { custody: null };
|
|
},
|
|
(g) => {
|
|
g.techTokens = { axlotl: { owner: g.host, spice: 0 } } as NonNullable<
|
|
engine.Game['techTokens']
|
|
>;
|
|
},
|
|
(g) => {
|
|
(g as unknown as Record<string, unknown>).futurePrivateField =
|
|
'PRIVATE_SENTINEL';
|
|
},
|
|
(g) => {
|
|
g.playerPositions![g.players[1].id] = 1;
|
|
},
|
|
(g) => {
|
|
g.deck = [{ id: 'PRIVATE_CARD' }] as engine.Game['deck'];
|
|
},
|
|
];
|
|
for (const corrupt of corruptions) {
|
|
const game = structuredClone(original);
|
|
corrupt(game);
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
|
.run(JSON.stringify(game), f.code);
|
|
const before = f.row();
|
|
const view = await f.read();
|
|
assert.equal(view.editable, false);
|
|
assert.equal(JSON.stringify(view).includes('PRIVATE'), false);
|
|
await assert.rejects(
|
|
f.configure(),
|
|
(error) => status(409)(error) && !String(error).includes('PRIVATE'),
|
|
);
|
|
assert.deepEqual(f.row(), before);
|
|
assert.deepEqual(audits(f), []);
|
|
}
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
|
.run('{bad private save', f.code);
|
|
assert.equal((await f.read()).status, 'unreadable');
|
|
await assert.rejects(f.configure(), status(409));
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('host reassignment changes authority only, clears human readiness and preserves both original saved seats and pieces', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
const before = await f.rooms.readRoom(f.code),
|
|
secure = f.secureRows();
|
|
const result = await f.configure(
|
|
operation(f.version(), {
|
|
type: 'assignHost',
|
|
target: f.guestAuth.playerId,
|
|
}),
|
|
);
|
|
assert.equal(result.lobby.host, f.guestAuth.playerId);
|
|
const next = await f.rooms.readRoom(f.code);
|
|
assert.deepEqual(
|
|
next.players.map((player) => ({ ...player, ready: false })),
|
|
before.players.map((player) => ({ ...player, ready: false })),
|
|
);
|
|
assert.ok(next.players.every((player) => !player.ready));
|
|
assert.deepEqual(next.playerPositions, before.playerPositions);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.auth, f.version(), {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Hard',
|
|
}),
|
|
/host/i,
|
|
);
|
|
await f.rooms.act(f.code, f.guestAuth, f.version(), {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Hard',
|
|
});
|
|
const bot = (await f.read()).players.find((player) => player.bot)!;
|
|
await assert.rejects(
|
|
f.configure(
|
|
operation(f.version(), { type: 'assignHost', target: bot.id }),
|
|
),
|
|
status(409),
|
|
);
|
|
await assert.rejects(
|
|
f.configure(
|
|
operation(f.version(), { type: 'removeBot', target: f.auth.playerId }),
|
|
),
|
|
status(409),
|
|
);
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
assert.equal(
|
|
(await f.rooms.readSeatView(f.code, auth)).me,
|
|
auth.playerId,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('mismatched saved room codes reject edits without repair while completed receipts remain confirmable', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const input = operation(f.version());
|
|
const applied = await f.configure(input);
|
|
f.sqlite
|
|
.prepare(
|
|
"UPDATE rooms SET state=json_set(state,'$.code','MISMATCH') WHERE code=?",
|
|
)
|
|
.run(f.code);
|
|
const before = f.row();
|
|
const view = await f.read();
|
|
assert.equal(view.code, f.code);
|
|
assert.equal(view.editable, false);
|
|
assert.equal(
|
|
view.blockedReason,
|
|
'This saved lobby needs inspection before configuration.',
|
|
);
|
|
await assert.rejects(
|
|
f.configure(
|
|
operation(f.version(), { type: 'techTokens', enabled: true }),
|
|
),
|
|
status(409),
|
|
);
|
|
const replay = await f.configure(input);
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.appliedVersion, applied.appliedVersion);
|
|
assert.deepEqual(replay.lobby, view);
|
|
assert.deepEqual(f.row(), before);
|
|
assert.equal(audits(f).length, 1);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('freshness checks require own fields and reject prototype-named corrupt player or root data', async () => {
|
|
for (const target of ['player', 'root'] as const) {
|
|
const f = await fixture();
|
|
try {
|
|
const game = await f.rooms.readRoom(f.code);
|
|
const malformed = (target === 'player'
|
|
? game.players[0]
|
|
: game) as unknown as Record<string, unknown>;
|
|
if (target === 'player') delete malformed.spice;
|
|
Object.defineProperty(malformed, '__proto__', {
|
|
value: {},
|
|
enumerable: true,
|
|
});
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
|
.run(JSON.stringify(game), f.code);
|
|
assert.equal(freshAdminLobby(JSON.parse(String(f.row().state))), false);
|
|
const before = f.row(),
|
|
secure = f.secureRows();
|
|
const view = await f.read();
|
|
assert.equal(view.editable, false);
|
|
assert.equal(
|
|
view.blockedReason,
|
|
'This saved lobby needs inspection before configuration.',
|
|
);
|
|
await assert.rejects(
|
|
f.configure(
|
|
operation(f.version(), {
|
|
type: 'assignHost',
|
|
target: f.guestAuth.playerId,
|
|
}),
|
|
),
|
|
status(409),
|
|
);
|
|
assert.deepEqual(f.row(), before);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
assert.deepEqual(audits(f), []);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('neutral configuration retains genuine Basic/Advanced setup, module prerequisites and human readiness', async () => {
|
|
for (const advanced of [false, true]) {
|
|
const f = await fixture();
|
|
try {
|
|
if (advanced)
|
|
await f.configure(
|
|
operation(f.version(), { type: 'rules', advanced: true }),
|
|
);
|
|
await f.configure(
|
|
operation(f.version(), { type: 'techTokens', enabled: true }),
|
|
);
|
|
if (advanced)
|
|
await f.configure(
|
|
operation(f.version(), { type: 'strongholdCards', enabled: true }),
|
|
);
|
|
else
|
|
await assert.rejects(
|
|
f.configure(
|
|
operation(f.version(), { type: 'strongholdCards', enabled: true }),
|
|
),
|
|
status(409),
|
|
);
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
const start = {
|
|
type: 'start',
|
|
...(advanced ? { advancedPreview: true } : {}),
|
|
} as engine.Action;
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.auth, f.version(), start),
|
|
/three players/i,
|
|
);
|
|
await f.configure(
|
|
operation(f.version(), {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Hard',
|
|
}),
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.auth, f.version(), start),
|
|
/ready/i,
|
|
);
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
if (advanced)
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.auth, f.version(), { type: 'start' }),
|
|
/preview|unfinished/i,
|
|
);
|
|
const view = await f.rooms.act(f.code, f.auth, f.version(), start);
|
|
assert.equal(view.status, 'setup');
|
|
assert.equal(view.advancedPreview, advanced);
|
|
assert.ok(
|
|
view.players.find((player) => player.id === f.auth.playerId)!
|
|
.traitorChoices?.length,
|
|
);
|
|
const adminView = await f.read();
|
|
assert.equal(adminView.editable, false);
|
|
assert.equal(adminView.status, 'setup');
|
|
assert.equal(JSON.stringify(adminView).includes('traitor'), false);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('exact lobby receipt replays after start with current public metadata and never repeats its change', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const input = operation(f.version(), {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Hard',
|
|
});
|
|
const applied = await f.configure(input);
|
|
const same = {
|
|
...input,
|
|
action: { difficulty: 'Hard', faction: 'guild', type: 'addBot' } as const,
|
|
reason: ' ' + input.reason + ' ',
|
|
};
|
|
assert.deepEqual(await f.configure(same), { ...applied, replayed: true });
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
await f.rooms.act(f.code, f.auth, f.version(), { type: 'start' });
|
|
const before = f.row();
|
|
const replay = await f.configure(input);
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.appliedVersion, applied.appliedVersion);
|
|
assert.equal(replay.lobby.status, 'setup');
|
|
assert.equal(replay.lobby.version, f.version());
|
|
assert.deepEqual(f.row(), before);
|
|
assert.equal(audits(f).length, 1);
|
|
for (const patch of [
|
|
{ reason: 'Changed reason' },
|
|
{ expectedVersion: f.version() },
|
|
{ action: { ...input.action, difficulty: 'Easy' } },
|
|
])
|
|
await assert.rejects(
|
|
f.configure({ ...input, ...patch } as AdminLobbyInput),
|
|
status(409),
|
|
);
|
|
const other = f.provision('operator');
|
|
const login = await adminLogin(f.database, other.key);
|
|
const identity = await requireAdmin(f.database, login.token);
|
|
await assert.rejects(
|
|
configureAdminLobby(f.database, identity, f.code, input),
|
|
status(409),
|
|
);
|
|
const text = JSON.stringify(audits(f));
|
|
for (const privateValue of [
|
|
f.identity.sessionHash,
|
|
f.owner.token,
|
|
f.guest.token!,
|
|
'traitorChoices',
|
|
'deck',
|
|
])
|
|
assert.equal(text.includes(privateValue), false);
|
|
assert.equal(audits(f)[0].actor_admin_id, f.identity.id);
|
|
assert.equal(audits(f)[0].reason, input.reason);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('paused lobbies reject configuration while joining locks permit it, without changing operational flags', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const pause = (revision: number, paused: boolean, joinLocked: boolean) =>
|
|
applyAdminRoomControl(f.database, f.identity, f.code, {
|
|
operationId: randomUUID(),
|
|
expectedRevision: revision,
|
|
paused,
|
|
joinLocked,
|
|
reason: 'QA control',
|
|
});
|
|
await pause(0, true, false);
|
|
const before = f.row();
|
|
assert.equal((await f.read()).editable, false);
|
|
await assert.rejects(f.configure(), status(409));
|
|
assert.deepEqual(f.row(), before);
|
|
await pause(1, false, true);
|
|
const result = await f.configure();
|
|
assert.equal(result.lobby.advanced, true);
|
|
assert.equal(result.lobby.control.joinLocked, true);
|
|
assert.equal(result.lobby.control.revision, 2);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('administrator session/role changes are fenced at snapshot reads, writes and exact receipt replay', async () => {
|
|
for (const phase of ['read', 'write', 'replay'] as const)
|
|
for (const mode of ['logout', 'disable', 'expire', 'demote'] as const) {
|
|
const f = await fixture();
|
|
try {
|
|
const input = operation(f.version());
|
|
if (phase === 'replay') await f.configure(input);
|
|
const before = f.row(),
|
|
beforeAudit = audits(f);
|
|
const gate = pauseBatch(f, phase === 'write' ? 2 : 1);
|
|
const pending = phase === 'read' ? f.read() : f.configure(input);
|
|
await gate.waiting;
|
|
if (mode === 'logout') await adminLogoutAll(f.database, f.identity);
|
|
else if (mode === 'disable')
|
|
f.sqlite
|
|
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
|
|
.run(f.identity.id);
|
|
else if (mode === 'expire')
|
|
f.sqlite
|
|
.prepare(
|
|
'UPDATE admin_sessions SET expires_at=1 WHERE token_hash=?',
|
|
)
|
|
.run(f.identity.sessionHash);
|
|
else
|
|
f.sqlite
|
|
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
|
|
.run(f.identity.id);
|
|
gate.release();
|
|
if (phase === 'read' && mode === 'demote') {
|
|
const result = await pending;
|
|
assert.ok('code' in result);
|
|
assert.equal(result.code, f.code);
|
|
} else
|
|
await assert.rejects(pending, status(mode === 'demote' ? 403 : 401));
|
|
assert.deepEqual(f.row(), before);
|
|
assert.deepEqual(audits(f), beforeAudit);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('prepared lobby writes lose to start, join, human action, pause and target-seat revocation without overwriting the winner', async () => {
|
|
for (const winner of [
|
|
'start',
|
|
'join',
|
|
'action',
|
|
'pause',
|
|
'target',
|
|
] as const) {
|
|
const f = await fixture();
|
|
try {
|
|
if (winner === 'start')
|
|
for (const auth of [f.auth, f.guestAuth])
|
|
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
|
|
const input = operation(
|
|
f.version(),
|
|
winner === 'target'
|
|
? { type: 'assignHost', target: f.guestAuth.playerId }
|
|
: { type: 'rules', advanced: true },
|
|
);
|
|
const gate = pauseBatch(f);
|
|
const pending = f.configure(input);
|
|
await gate.waiting;
|
|
if (winner === 'start')
|
|
await f.rooms.act(f.code, f.auth, f.version(), { type: 'start' });
|
|
else if (winner === 'join')
|
|
await f.rooms.joinRoom(f.code, 'Joining human', 'guild');
|
|
else if (winner === 'action')
|
|
await f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' });
|
|
else if (winner === 'pause')
|
|
await applyAdminRoomControl(f.database, f.identity, f.code, {
|
|
operationId: randomUUID(),
|
|
expectedRevision: 0,
|
|
paused: true,
|
|
joinLocked: false,
|
|
reason: 'QA race',
|
|
});
|
|
else
|
|
f.sqlite
|
|
.prepare('UPDATE seats SET revoked=1 WHERE token_hash=?')
|
|
.run(f.guestAuth.tokenHash);
|
|
const before = f.row(),
|
|
secure = f.secureRows();
|
|
gate.release();
|
|
await assert.rejects(pending, status(409));
|
|
assert.deepEqual(f.row(), before);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
assert.equal(audits(f).length, 0);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('concurrent same operation commits one receipt and conflicting operations commit only one version', async () => {
|
|
for (const same of [true, false]) {
|
|
const f = await fixture();
|
|
try {
|
|
const input = operation(f.version());
|
|
const gate = pauseBatch(f);
|
|
const pending = f.configure(input);
|
|
await gate.waiting;
|
|
const won = await f.configure(
|
|
same
|
|
? input
|
|
: operation(f.version(), {
|
|
type: 'addBot',
|
|
faction: 'guild',
|
|
difficulty: 'Hard',
|
|
}),
|
|
);
|
|
const before = f.row();
|
|
gate.release();
|
|
if (same) assert.deepEqual(await pending, { ...won, replayed: true });
|
|
else await assert.rejects(pending, status(409));
|
|
assert.deepEqual(f.row(), before);
|
|
assert.equal(audits(f).length, 1);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('audit failure rolls back room configuration/version and rejected pure actions leave input untouched', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const game = await f.rooms.readRoom(f.code),
|
|
copy = structuredClone(game);
|
|
assert.throws(() =>
|
|
applyAdminLobbyAction(
|
|
game,
|
|
{ type: 'addBot', faction: 'atreides', difficulty: 'Hard' },
|
|
new Set([f.auth.playerId, f.guestAuth.playerId]),
|
|
),
|
|
);
|
|
assert.deepEqual(game, copy);
|
|
const before = f.row(),
|
|
secure = f.secureRows();
|
|
f.sqlite.exec(
|
|
"CREATE TRIGGER fail_lobby_audit BEFORE INSERT ON admin_lobby_operations BEGIN SELECT RAISE(ABORT,'QA audit failure'); END;",
|
|
);
|
|
await assert.rejects(f.configure(), /QA audit failure/);
|
|
assert.deepEqual(f.row(), before);
|
|
assert.deepEqual(f.secureRows(), secure);
|
|
assert.equal(audits(f).length, 0);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('neutral lobby operation and host assignment survive database reopen without changing any saved-seat authority', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-lobby-'));
|
|
let store = adminStore(join(dir, 'rooms.sqlite'));
|
|
try {
|
|
const account = store.provision('owner');
|
|
const login = await adminLogin(store.database, account.key);
|
|
const identity = await requireAdmin(store.database, login.token);
|
|
const rooms = loadRooms(store.database);
|
|
const owner = await rooms.createRoom('Owner', 'atreides', false, []);
|
|
const code = owner.view.code;
|
|
const guest = await rooms.joinRoom(code, 'Guest', 'guild');
|
|
const auth = await rooms.authenticate(code, guest.token!);
|
|
const input = operation(guest.view.version, {
|
|
type: 'assignHost',
|
|
target: auth.playerId,
|
|
});
|
|
const applied = await configureAdminLobby(
|
|
store.database,
|
|
identity,
|
|
code,
|
|
input,
|
|
);
|
|
const seatRows = store.sqlite.prepare('SELECT * FROM seats').all();
|
|
store.sqlite.close();
|
|
store = adminStore(join(dir, 'rooms.sqlite'), false);
|
|
assert.deepEqual(
|
|
await configureAdminLobby(store.database, identity, code, input),
|
|
{ ...applied, replayed: true },
|
|
);
|
|
assert.deepEqual(
|
|
store.sqlite.prepare('SELECT * FROM seats').all(),
|
|
seatRows,
|
|
);
|
|
const restored = await loadRooms(store.database).readSeatView(code, auth);
|
|
assert.equal(restored.host, auth.playerId);
|
|
assert.equal(restored.me, auth.playerId);
|
|
} finally {
|
|
store.sqlite.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|