Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions apps/socket/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

// Mock WebSocket for testing
class MockWebSocket {
public readyState: number = WebSocket.CONNECTING;
public messages: any[] = [];
private onmessageHandler: ((event: MessageEvent) => void) | null = null;
private onopenHandler: (() => void) | null = null;
private oncloseHandler: (() => void) | null = null;

constructor(url: string) {
// Simulate connection
setTimeout(() => {
this.readyState = WebSocket.OPEN;
this.onopenHandler?.();
}, 10);
}

send(data: string) {
this.messages.push(JSON.parse(data));
// Simulate async message handling
setTimeout(() => {
if (this.onmessageHandler) {
const event = { data: JSON.stringify(this.messages[this.messages.length - 1]) } as MessageEvent;
this.onmessageHandler(event);
}
}, 5);
}

close() {
this.readyState = WebSocket.CLOSED;
setTimeout(() => {
this.oncloseHandler?.();
}, 5);
}

addEventListener(type: string, handler: any) {
if (type === 'message') this.onmessageHandler = handler;
if (type === 'open') this.onopenHandler = handler;
if (type === 'close') this.oncloseHandler = handler;
}

removeEventListener(type: string, handler: any) {
if (type === 'message' && this.onmessageHandler === handler) this.onmessageHandler = null;
if (type === 'open' && this.onopenHandler === handler) this.onopenHandler = null;
if (type === 'close' && this.oncloseHandler === handler) this.oncloseHandler = null;
}
}

// Mock the global WebSocket
global.WebSocket = MockWebSocket as any;

// Re-index modules after mocking
import { index } from './index.js';

describe('Socket Server - Message Handling', () => {
let mockWs: MockWebSocket;

beforeEach(() => {
mockWs = new MockWebSocket('ws://localhost:8080');
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should create room on create_room message', () => {
const data = { type: 'create_room', playerId: 'white' };

mockWs.send(JSON.stringify(data));

// Check if room was created (this would be verified by the actual game state)
expect(mockWs.messages.length).toBeGreaterThan(0);
const roomCreatedMsg = mockWs.messages.find(m => m.type === 'room_created');
expect(roomCreatedMsg).toBeDefined();
expect(roomCreatedMsg?.roomName).toMatch(/^.{8}$/); // Should have 8-char room code
});

it('should validate invalid playerId in create_room', () => {
const data = { type: 'create_room', playerId: 'invalid' };

mockWs.send(JSON.stringify(data));

const errorMsg = mockWs.messages.find(m => m.type === 'error');
expect(errorMsg).toBeDefined();
expect(errorMsg?.message).toContain('Invalid playerId');
});

it('should join existing room on join_room message', () => {
const data = { type: 'join_room', roomName: 'TEST123', playerId: 'black' };

mockWs.send(JSON.stringify(data));

const roomJoinedMsg = mockWs.messages.find(m => m.type === 'room_joined');
expect(roomJoinedMsg).toBeDefined();
expect(roomJoinedMsg?.roomName).toBe('TEST123');
});

it('should validate playerId in join_room', () => {
const data = { type: 'join_room', roomName: 'TEST123', playerId: 'invalid' };

mockWs.send(JSON.stringify(data));

const errorMsg = mockWs.messages.find(m => m.type === 'error');
expect(errorMsg).toBeDefined();
expect(errorMsg?.message).toContain('Invalid playerId');
});

it('should validate move coordinates', () => {
const data = {
type: 'make_move',
roomName: 'TEST123',
playerId: 'white',
from: { x: 9, y: 0 }, // Invalid x coordinate
to: { x: 0, y: 0 }
};

mockWs.send(JSON.stringify(data));

const errorMsg = mockWs.messages.find(m => m.type === 'error');
expect(errorMsg).toBeDefined();
expect(errorMsg?.message).toContain('Invalid move coordinates');
});

it('should validate turn in make_move', () => {
const data = {
type: 'make_move',
roomName: 'TEST123',
playerId: 'white', // Try to move when it's black's turn
from: { x: 6, y: 4 },
to: { x: 4, y: 4 }
};

// Set initial state to black's turn
const state = {
games: {
'TEST123': {
state: { turn: 'black', board: index.state.board }
} as any
}
};
Object.assign(index.state as any, state.games);

mockWs.send(JSON.stringify(data));

const errorMsg = mockWs.messages.find(m => m.type === 'error');
expect(errorMsg).toBeDefined();
expect(errorMsg?.message).toContain('Not your turn');
});

it('should validate promotion piece in make_move', () => {
const data = {
type: 'make_move',
roomName: 'TEST123',
playerId: 'white',
from: { x: 6, y: 0 }, // White pawn at 6,0
to: { x: 0, y: 0 }, // Moving to promotion row
promotionPiece: 'X' // Invalid promotion piece
};

mockWs.send(JSON.stringify(data));

const errorMsg = mockWs.messages.find(m => m.type === 'error');
expect(errorMsg).toBeDefined();
expect(errorMsg?.message).toContain('Invalid promotion piece');
});

it('should return legal moves on can_move request', () => {
const data = {
type: 'can_move',
roomName: 'TEST123',
playerId: 'white',
index: { row: 6, col: 4 }
};

mockWs.send(JSON.stringify(data));

const availableMovesMsg = mockWs.messages.find(m => m.type === 'available_moves');
expect(availableMovesMsg).toBeDefined();
expect(Array.isArray(availableMovesMsg?.moves)).toBe(true);
});

it('should handle suggest_move request', () => {
const data = {
type: 'suggest_move',
roomName: 'TEST123',
playerId: 'white'
};

mockWs.send(JSON.stringify(data));

const suggestionsMsg = mockWs.messages.find(m => m.type === 'move_suggestions');
expect(suggestionsMsg).toBeDefined();
expect(suggestionsMsg?.suggestions).toBeDefined();
expect(Array.isArray(suggestionsMsg?.suggestions)).toBe(true);
});
});
99 changes: 97 additions & 2 deletions apps/socket/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ const games: Record<string, ChessGame> = {};
const roomClients = new Map<string, Set<WebSocket>>();
const clientRoom = new Map<WebSocket, string>();

// Track room activity timestamps for cleanup
const roomLastActivity = new Map<string, number>();
const ROOM_CLEANUP_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds

// Clean up inactive rooms periodically
setInterval(() => {
const now = Date.now();
for (const [roomName, lastActivity] of roomLastActivity.entries()) {
if (now - lastActivity > ROOM_CLEANUP_TIMEOUT) {
const clients = roomClients.get(roomName);
if (clients && clients.size === 0) {
// No clients in inactive room, clean it up
console.log(`Cleaning up inactive room: ${roomName}`);
delete games[roomName];
roomClients.delete(roomName);
roomLastActivity.delete(roomName);
}
}
}
}, 60 * 1000); // Check every minute

const PORT = Number(process.env.PORT || 8080);
const DEFAULT_ALLOWED_ORIGINS = [
"https://chesss.thecabbro.com",
Expand Down Expand Up @@ -37,6 +58,8 @@ const joinRoom = (ws: WebSocket, roomName: string) => {

roomClients.get(roomName)?.add(ws);
clientRoom.set(ws, roomName);
// Update room activity timestamp
roomLastActivity.set(roomName, Date.now());
};

const broadcastToRoom = (roomName: string, payload: unknown) => {
Expand Down Expand Up @@ -97,6 +120,16 @@ wss.on("connection", ws => {
switch (data.type) {
case "create_room": {
const playerId = data.playerId || "white";

// Validate player identity
const validPlayerIds = ["white", "black"];
if (!playerId || !validPlayerIds.includes(playerId)) {
return ws.send(JSON.stringify({
type: "error",
message: "Invalid playerId. Must be 'white' or 'black'."
}));
}

const newGame = new ChessGame(playerId);
games[newGame.state.roomId] = newGame;
joinRoom(ws, newGame.state.roomId);
Expand All @@ -118,6 +151,23 @@ wss.on("connection", ws => {
if (!playerId)
return ws.send(JSON.stringify({ type: "error", message: "Missing playerId" }));

// Validate player identity
const validPlayerIds = ["white", "black"];
if (!validPlayerIds.includes(playerId)) {
return ws.send(JSON.stringify({
type: "error",
message: "Invalid playerId. Must be 'white' or 'black'."
}));
}

// Prevent spoofing: check if player ID is already taken
if (game.state.players.includes(playerId)) {
return ws.send(JSON.stringify({
type: "error",
message: "This player ID is already in use in this room."
}));
}

if (!game.state.players.includes(playerId))
game.state.players.push(playerId);

Expand All @@ -144,7 +194,7 @@ wss.on("connection", ws => {
}

case "make_move": {
const { roomName, playerId, from, to } = data;
const { roomName, playerId, from, to, promotionPiece } = data;
const game = games[roomName];
if (!game)
return ws.send(JSON.stringify({ type: "error", message: "Game not found" }));
Expand All @@ -155,8 +205,53 @@ wss.on("connection", ws => {
to.x < 0 || to.x > 7 || to.y < 0 || to.y > 7)
return ws.send(JSON.stringify({ type: "error", message: "Invalid move coordinates" }));

// Validate promotion piece if provided
if (promotionPiece) {
const validPromotions = playerId === "white" ? ["Q", "R", "B", "N"] : ["q", "r", "b", "n"];
if (!validPromotions.includes(promotionPiece)) {
return ws.send(JSON.stringify({
type: "error",
message: "Invalid promotion piece. Must be one of: " + validPromotions.join(", ")
}));
}
}

// Authoritative move validation before executing
if (game.state.turn !== playerId) {
return ws.send(JSON.stringify({
type: "error",
message: "Not your turn",
currentTurn: game.state.turn
}));
}

const piece = game.state.board[from.x]?.[from.y];
if (!piece) {
return ws.send(JSON.stringify({ type: "error", message: "No piece at source position" }));
}

// Check if piece belongs to the player
const isWhitePiece = piece === piece.toUpperCase();
const isWhitePlayer = playerId === "white";
if (isWhitePiece !== isWhitePlayer) {
return ws.send(JSON.stringify({ type: "error", message: "Cannot move opponent's piece" }));
}

// Validate move is legal
const legalMoves = game.getMoves(from.x, from.y, playerId);
const isLegalMove = legalMoves.some(m => m.x === to.x && m.y === to.y);
if (!isLegalMove) {
return ws.send(JSON.stringify({
type: "error",
message: "Illegal move",
availableMoves: legalMoves
}));
}

try {
const result = game.makeMove(from, to, playerId);
const result = game.makeMove(from, to, playerId, promotionPiece);
// Update room activity timestamp on move
roomLastActivity.set(roomName, Date.now());
broadcastToRoom(roomName, {
type: "move_made",
...result,
Expand Down
Loading