plugins/hushle/src/__tests__/random.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { hushlePlugin, type HushleState } from '../index';
import { secureRandom } from '../random';
import { splitIntoTeams } from '../ui/model';
import { createTestHarness } from '@lobbyforge/plugin-sdk/testing';
/**
* Security follow-up: card draws, team ids and the room split come from
* the platform CSPRNG, never `Math.random` (whose state can be recovered
* from the cards already seen, predicting the next ones).
*/
afterEach(() => {
vi.restoreAllMocks();
});
describe('hushle randomness', () => {
it('secureRandom returns floats in [0, 1) from crypto.getRandomValues', () => {
const crypto = vi.spyOn(globalThis.crypto, 'getRandomValues');
const math = vi.spyOn(Math, 'random');
for (let i = 0; i < 200; i += 1) {
const value = secureRandom();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
expect(crypto).toHaveBeenCalledTimes(200);
expect(math).not.toHaveBeenCalled();
});
it('draws cards and mints team ids without Math.random', async () => {
const math = vi.spyOn(Math, 'random');
const crypto = vi.spyOn(globalThis.crypto, 'getRandomValues');
const harness = createTestHarness<HushleState, Parameters<typeof hushlePlugin.handleAction>[2]>({
plugin: hushlePlugin,
players: ['p1', 'p2', 'p3', 'p4'],
});
await harness.startGame();
await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1' });
await harness.performAction('p1', {
type: 'set-teams',
teams: [
{ name: 'A', playerIds: ['p1', 'p2'] },
{ name: 'B', playerIds: ['p3', 'p4'] },
],
});
const teams = harness.getState().teams;
expect(teams.map((t) => t.id)).toEqual([expect.stringMatching(/^team-[0-9a-z]{8}$/), expect.stringMatching(/^team-[0-9a-z]{8}$/)]);
await harness.performAction('p1', { type: 'start-turn', teamId: teams[0]!.id, explainerId: 'p1' });
await harness.performAction('p1', { type: 'next-card' });
expect(harness.getState().currentCard).not.toBeNull();
expect(math).not.toHaveBeenCalled();
expect(crypto).toHaveBeenCalled();
});
it('splits the room with the CSPRNG by default, and an injected rng still pins it', () => {
const math = vi.spyOn(Math, 'random');
const split = splitIntoTeams(['a', 'b', 'c', 'd', 'e'], 4, ['Ice', 'Amber']);
expect(split.teams.flatMap((t) => t.playerIds).length).toBe(4);
expect(math).not.toHaveBeenCalled();
const pinned = splitIntoTeams(['a', 'b', 'c', 'd'], 2, ['Ice', 'Amber'], () => 0);
expect(pinned).toEqual(splitIntoTeams(['a', 'b', 'c', 'd'], 2, ['Ice', 'Amber'], () => 0));
});
});