a6de43edc1
- pnpm workspace: shared/server/client packages - Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy), per-player view filter, zod validation, rate limiting, grace-window disconnect handling - Client: Svelte 5 + Vite, click-to-move board, moderator panel, promotion/draw dialogs - Shared: protocol types, ModeratorText enum, geometricMoves helper (provably zero opponent-info leak) - 43 tests pass (21 shared, 22 server incl. 4 real-WS integration) - Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed, Caddy block for chess.sethpc.xyz - Live at https://chess.sethpc.xyz Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { Chess } from 'chess.js';
|
|
import { buildView, ownSquares } from '../../src/view.js';
|
|
import type { Game } from '../../src/state.js';
|
|
import { RATE_LIMIT } from '../../src/state.js';
|
|
|
|
function makeGame(mode: 'blind' | 'vanilla', fen?: string, status: 'active' | 'finished' = 'active'): Game {
|
|
return {
|
|
id: 'testtest',
|
|
mode,
|
|
highlightingEnabled: false,
|
|
status,
|
|
createdAt: Date.now(),
|
|
chess: fen ? new Chess(fen) : new Chess(),
|
|
moveHistory: [],
|
|
announcements: [],
|
|
players: {
|
|
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0, rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
|
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0, rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
|
|
},
|
|
armed: null,
|
|
drawOffer: null,
|
|
disconnectAt: {},
|
|
};
|
|
}
|
|
|
|
describe('buildView: security boundary', () => {
|
|
it('blind/active white view contains zero black pieces', () => {
|
|
const g = makeGame('blind');
|
|
const view = buildView(g, 'w');
|
|
for (const piece of Object.values(view.pieces)) {
|
|
expect(piece?.color).toBe('w');
|
|
}
|
|
expect(Object.keys(view.pieces).length).toBe(16); // all 16 white pieces
|
|
});
|
|
|
|
it('blind/active black view contains zero white pieces', () => {
|
|
const g = makeGame('blind');
|
|
const view = buildView(g, 'b');
|
|
for (const piece of Object.values(view.pieces)) {
|
|
expect(piece?.color).toBe('b');
|
|
}
|
|
expect(Object.keys(view.pieces).length).toBe(16);
|
|
});
|
|
|
|
it('vanilla/active shows both colors', () => {
|
|
const g = makeGame('vanilla');
|
|
const view = buildView(g, 'w');
|
|
expect(Object.keys(view.pieces).length).toBe(32);
|
|
});
|
|
|
|
it('blind/finished reveals both colors (post-game review)', () => {
|
|
const g = makeGame('blind', undefined, 'finished');
|
|
const view = buildView(g, 'w');
|
|
expect(Object.keys(view.pieces).length).toBe(32);
|
|
});
|
|
|
|
it('blind: inCheck is null for non-actor (info leak prevention)', () => {
|
|
// Black to move and is in check. White's view says null (it's not white's turn,
|
|
// and revealing inCheck-status of opponent leaks info).
|
|
const g = makeGame('blind', 'rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3');
|
|
const view = buildView(g, 'b');
|
|
// It's white's turn here. Black viewer is not the to-move side.
|
|
expect(view.inCheck).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ownSquares', () => {
|
|
it('starting position returns 16 own squares', () => {
|
|
const g = makeGame('blind');
|
|
expect(ownSquares(g, 'w').size).toBe(16);
|
|
expect(ownSquares(g, 'b').size).toBe(16);
|
|
});
|
|
|
|
it('contains only own-color squares', () => {
|
|
const g = makeGame('blind');
|
|
const wSet = ownSquares(g, 'w');
|
|
expect(wSet.has('e2')).toBe(true);
|
|
expect(wSet.has('e7')).toBe(false);
|
|
});
|
|
});
|