diff --git a/chessServer/.env.example b/chessServer/.env.example index 4e25e7cc..6b187836 100644 --- a/chessServer/.env.example +++ b/chessServer/.env.example @@ -1,5 +1,22 @@ NODE_ENV=development + +# Port the socket/HTTP server listens on. Defaults to 3001 if unset. PORT=3001 + +# Comma-separated list of origins allowed to connect (CORS + Socket.IO). +# Falls back to a hardcoded dev/prod allowlist in src/index.js if unset. +# Required in production (one of CORS_ORIGIN or ALLOWED_ORIGINS). CORS_ORIGIN=http://localhost:3000,http://localhost:3002 ALLOWED_ORIGINS= + +# Base URL of middlewareNode, used to verify PvP game identity +# (GET /challenge/game/:gameId) and to report finished game results +# (POST /internal/gameResults). Required in production. MIDDLEWARE_URL=http://localhost:8000 + +# Shared secret the chess server authenticates with when reporting a PvP +# result to the middleware (X-Service-Key header). Must match the same +# value configured on middlewareNode. Required in production. See the PvP +# results plan (v2), T5 — generate one random 32-byte value and store it as +# a secret on both services, not in source control. +CHESS_SERVICE_KEY= diff --git a/chessServer/src/managers/EventHandlers.js b/chessServer/src/managers/EventHandlers.js index a6958ba7..86c208c3 100644 --- a/chessServer/src/managers/EventHandlers.js +++ b/chessServer/src/managers/EventHandlers.js @@ -1,4 +1,5 @@ const GameManager = require("./GameManager"); +const buildResultRequest = require("../reporting/resultRequest"); const gameManager = new GameManager(); @@ -10,55 +11,35 @@ const gameManager = new GameManager(); * balance to credit — the coin idea was dropped in favour of a separate * leaderboard stat). See documentation/student-vs-student-design.md §7. * + * Authenticated with CHESS_SERVICE_KEY, not a player's token — the chess + * server reports with its own identity, and the middleware checks the result + * against the PvpGame it saved when the challenge was accepted. See the PvP + * results plan (v2), target design point 4. + * * Idempotency is the middleware's job, keyed on `gameId`: a reconnect, a retry, * or both clients reporting the same game is a no-op there. This side just * reports once per decided game and tolerates failure — a lost report costs one * game's stats, it must never break the players' "game over" experience. * - * Mirrors the existing activity pattern in the "move" handler, which PUTs to - * `${MIDDLEWARE_URL}/activities/:username/activity` with a Bearer credential. - * * @param {Object} game - the finished game (supplies gameId and both players) * @param {Object} outcome - { over, reason, winnerUsername?, loserUsername? } - * @param {string} [credentials] - Bearer token of a player in this game */ -const reportGameResult = async (game, outcome, credentials) => { +const reportGameResult = async (game, outcome) => { if (!process.env.MIDDLEWARE_URL) { console.log("[gameResults] MIDDLEWARE_URL unset — skipping report"); return; } - // The middleware only accepts a report from a player in the game, so fall - // back to a seated player's token when the ending event carried none - // (resign and disconnect have no payload). - const token = credentials || (game.players || []).map((p) => p.credentials).find(Boolean); - if (!token) { - console.log(`[gameResults] no credentials for game ${game.gameId} — skipping report`); + if (!process.env.CHESS_SERVICE_KEY) { + console.log(`[gameResults] CHESS_SERVICE_KEY unset — skipping report for ${game.gameId}`); return; } - const isDraw = !outcome.winnerUsername; - const body = isDraw - ? { - gameId: game.gameId, - result: "draw", - reason: "draw", - players: game.players.map((p) => p.username), - } - : { - gameId: game.gameId, - result: "win", - reason: outcome.reason, - winnerUsername: outcome.winnerUsername, - loserUsername: outcome.loserUsername, - }; + const { path, headers, body } = buildResultRequest(game, outcome, process.env.CHESS_SERVICE_KEY); try { - const response = await fetch(`${process.env.MIDDLEWARE_URL}/gameResults`, { + const response = await fetch(`${process.env.MIDDLEWARE_URL}${path}`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authentication: `Bearer ${token}`, - }, + headers, body: JSON.stringify(body), }); if (!response.ok) { @@ -81,9 +62,8 @@ const reportGameResult = async (game, outcome, credentials) => { * @param {Object} game - the finished game * @param {Object} outcome - { over, reason, winnerUsername?, loserUsername? } * @param {Server} io - * @param {string} [credentials] - Bearer token of the reporting client */ -const emitGameOver = async (game, outcome, io, credentials) => { +const emitGameOver = async (game, outcome, io) => { const payload = JSON.stringify(outcome); [game.student.id, game.mentor.id].forEach((id) => { if (id) io.to(id).emit("gameover", payload); @@ -92,7 +72,7 @@ const emitGameOver = async (game, outcome, io, credentials) => { // A draw still counts as a played game, so report it too — only the // opponent-never-joined case (no usernames at all) is skipped. if (game.isPvp && (outcome.winnerUsername || outcome.reason === "draw")) { - await reportGameResult(game, outcome, credentials); + await reportGameResult(game, outcome); } }; @@ -134,20 +114,48 @@ const registerSocketHandlers = (socket, io) => { /** * Handles creating or joining a student-vs-student (PvP) game by gameId. - * The gameId + both usernames come from an accepted challenge (middleware). - * Expected payload: { gameId, challenger, opponent, username, credentials } + * + * Identity is verified against the middleware, never trusted from the + * client: the chess server calls GET /challenge/game/:gameId with the + * joining player's own token, and seats the player under the `you` the + * middleware returns (derived from that token), not the `username` field + * the client sent. If the two differ, the client is lying about who it + * is — the join is rejected rather than silently corrected, so a client + * bug surfaces instead of being hidden. `white`/`black` likewise come + * only from that response. See the PvP results plan (v2), target design + * point 2 and T4. + * + * Expected payload: { gameId, username, credentials } */ - socket.on("newpvpgame", (msg) => { + socket.on("newpvpgame", async (msg) => { try { const parsed = JSON.parse(msg); + const { gameId, username, credentials } = parsed; + + if (!process.env.MIDDLEWARE_URL) { + throw new Error("MIDDLEWARE_URL unset — cannot verify game"); + } + + const response = await fetch(`${process.env.MIDDLEWARE_URL}/challenge/game/${gameId}`, { + headers: { Authorization: `Bearer ${credentials}` }, + }); + if (!response.ok) { + socket.emit("gameerror", `Unable to verify game (${response.status})`); + return; + } + const { you, white, black } = await response.json(); + + if (username !== you) { + socket.emit("gameerror", "username does not match your login"); + return; + } const result = gameManager.createOrJoinPvpGame({ - gameId: parsed.gameId, - challenger: parsed.challenger, - opponent: parsed.opponent, - username: parsed.username, - socketId: socket.id, - credentials: parsed.credentials + gameId, + username: you, + white, + black, + socketId: socket.id }); socket.emit( @@ -222,7 +230,7 @@ const registerSocketHandlers = (socket, io) => { if (outcome && outcome.over) { const game = gameManager.getGameBySocketId(socket.id); if (game) { - await emitGameOver(game, outcome, io, credentials); + await emitGameOver(game, outcome, io); } } if(!computerMove && credentials) { diff --git a/chessServer/src/managers/GameManager.js b/chessServer/src/managers/GameManager.js index 815b1bec..7cd6c930 100644 --- a/chessServer/src/managers/GameManager.js +++ b/chessServer/src/managers/GameManager.js @@ -90,29 +90,33 @@ class GameManager { * `game.isPvp` marks that the slot NAMES carry no meaning in this game. * See student-vs-student-design.md §5a. * - * The challenger takes white. Whichever client connects first creates the - * game; the second joins it by gameId. Reconnecting with the same username - * reclaims the original seat and color rather than creating a new game. + * `username`, `white` and `black` must come from the middleware's + * GET /challenge/game/:gameId response — never from the client socket + * message directly. The caller (EventHandlers' `newpvpgame` handler) + * verifies the joining socket against that response before calling this, + * so by the time this method runs, identity has already been checked. + * Whichever client connects first creates the game; the second joins it + * by gameId. Reconnecting with the same username reclaims the original + * seat and color rather than creating a new game. * - * Each seat also carries the joining client's `credentials` (bearer token). - * The result report at game end is sent to the middleware as one of the two - * players, and resign/disconnect endings are triggered by a socket event - * that carries no body — so the token has to be captured at join time. + * Seats no longer carry a player token: results are reported to the + * middleware with CHESS_SERVICE_KEY, not a player's bearer token, so + * there is nothing to capture here. See the PvP results plan (v2), T4. * - * @param {Object} param0 - Contains gameId, challenger, opponent, username, socketId, credentials + * @param {Object} param0 - Contains gameId, username, white, black, socketId * @returns {Object} Game object, assigned color, and new game status */ - createOrJoinPvpGame({ gameId, challenger, opponent, username, socketId, credentials }) { + createOrJoinPvpGame({ gameId, username, white, black, socketId }) { if (!gameId) { throw new Error("A gameId is required to join a student-vs-student game!"); } - if (!challenger || !opponent) { - throw new Error("Both challenger and opponent usernames are required!"); + if (!white || !black) { + throw new Error("Both white and black usernames are required!"); } - if (challenger === opponent) { + if (white === black) { throw new Error("A student cannot challenge themselves!"); } - if (username !== challenger && username !== opponent) { + if (username !== white && username !== black) { throw new Error("You are not a player in this game!"); } @@ -125,30 +129,27 @@ class GameManager { throw new Error("You are not a player in this game!"); } seat.id = socketId; - if (credentials) seat.credentials = credentials; return { game, color: seat.color, newGame: false }; } // First player in creates the game; both seats are known upfront from - // the accepted challenge, so the opponent's seat just waits for a socket. + // the accepted challenge, so the second player's seat just waits for a socket. const board = new Chess(); - const challengerPlayer = { - username: challenger, - id: username === challenger ? socketId : null, - credentials: username === challenger ? credentials : null, + const whitePlayer = { + username: white, + id: username === white ? socketId : null, color: "white" }; - const opponentPlayer = { - username: opponent, - id: username === opponent ? socketId : null, - credentials: username === opponent ? credentials : null, + const blackPlayer = { + username: black, + id: username === black ? socketId : null, color: "black" }; const newGame = { - student: challengerPlayer, - mentor: opponentPlayer, - players: [challengerPlayer, opponentPlayer], + student: whitePlayer, + mentor: blackPlayer, + players: [whitePlayer, blackPlayer], gameId, isPvp: true, boardState: board, @@ -159,7 +160,7 @@ class GameManager { return { game: newGame, - color: username === challenger ? "white" : "black", + color: username === white ? "white" : "black", newGame: true }; } diff --git a/chessServer/src/reporting/resultRequest.js b/chessServer/src/reporting/resultRequest.js new file mode 100644 index 00000000..3f66dc54 --- /dev/null +++ b/chessServer/src/reporting/resultRequest.js @@ -0,0 +1,43 @@ +/** + * Builds the chess server's game-result report to the middleware. + * + * A pure function with no `require` calls, so middlewareNode's own tests can + * import this file directly by relative path and assert against the exact + * request it produces (see middlewareNode/tests/contract.chessServerReport.test.js, + * the PvP results plan v2's T4b). If the header name or the path ever drifts + * between the two services, that contract test fails in CI instead of both + * sides quietly agreeing with themselves. See target design point 5. + * + * @param {Object} game - the finished game; supplies gameId and both players + * @param {Object} outcome - { reason, winnerUsername?, loserUsername? } + * @param {string} key - the shared secret (CHESS_SERVICE_KEY) + * @returns {{ path: string, headers: Object, body: Object }} + */ +function buildResultRequest(game, outcome, key) { + const isDraw = !outcome.winnerUsername; + const body = isDraw + ? { + gameId: game.gameId, + result: "draw", + reason: "draw", + players: game.players.map((p) => p.username), + } + : { + gameId: game.gameId, + result: "win", + reason: outcome.reason, + winnerUsername: outcome.winnerUsername, + loserUsername: outcome.loserUsername, + }; + + return { + path: "/internal/gameResults", + headers: { + "Content-Type": "application/json", + "X-Service-Key": key, + }, + body, + }; +} + +module.exports = buildResultRequest; diff --git a/chessServer/src/tests/EventHandlers.pvp.test.js b/chessServer/src/tests/EventHandlers.pvp.test.js new file mode 100644 index 00000000..d2d3b5be --- /dev/null +++ b/chessServer/src/tests/EventHandlers.pvp.test.js @@ -0,0 +1,148 @@ +/** + * Unit tests — "newpvpgame" verifies identity against the middleware before + * seating a player (never trusts the client's own claim), and a finished PvP + * game reports its result with the chess server's own service key. See the + * PvP results plan (v2), T4. + * + * global.fetch is mocked; everything else (GameManager, the socket handlers) + * is real. Sockets are plain objects with a jest.fn() emit and an on() that + * records handlers, driven directly — no real network/socket.io needed for + * this layer (src/tests/index.test.js already covers the real-socket path). + */ + +const registerSocketHandlers = require("../managers/EventHandlers"); + +function fakeSocket(id) { + const handlers = {}; + return { + id, + on: (event, handler) => { + handlers[event] = handler; + }, + emit: jest.fn(), + _handlers: handlers, + }; +} + +const fakeIo = { + to: () => ({ emit: jest.fn() }), + // GameManager.broadcastBoardState reaches into io.sockets.sockets.get(id); + // no real sockets are registered here, so every lookup is a harmless miss. + sockets: { sockets: { get: () => undefined } }, +}; + +beforeEach(() => { + process.env.MIDDLEWARE_URL = "http://middleware.test"; + global.fetch = jest.fn(); +}); + +afterEach(() => { + jest.resetAllMocks(); + delete process.env.MIDDLEWARE_URL; + delete process.env.CHESS_SERVICE_KEY; +}); + +describe("newpvpgame — identity is verified against the middleware", () => { + test("rejects when the client-claimed username does not match the middleware's", async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ gameId: "g1", you: "alice", white: "alice", black: "bob", status: "active" }), + }); + + const socket = fakeSocket("s1"); + registerSocketHandlers(socket, fakeIo); + + await socket._handlers["newpvpgame"]( + JSON.stringify({ gameId: "g1", username: "mallory", credentials: "tok" }) + ); + + expect(socket.emit).toHaveBeenCalledWith("gameerror", "username does not match your login"); + }); + + test("rejects an unaccepted/unknown gameId", async () => { + global.fetch.mockResolvedValue({ ok: false, status: 404 }); + + const socket = fakeSocket("s1"); + registerSocketHandlers(socket, fakeIo); + + await socket._handlers["newpvpgame"]( + JSON.stringify({ gameId: "ghost", username: "alice", credentials: "tok" }) + ); + + expect(socket.emit).toHaveBeenCalledWith("gameerror", expect.stringContaining("404")); + }); + + test("seats the verified player using white/black from the middleware response only", async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ gameId: "g1", you: "alice", white: "alice", black: "bob", status: "active" }), + }); + + const socket = fakeSocket("s1"); + registerSocketHandlers(socket, fakeIo); + + // The client claims the opposite colors — they must be ignored. + await socket._handlers["newpvpgame"]( + JSON.stringify({ gameId: "g1", username: "alice", white: "mallory", black: "mallory", credentials: "tok" }) + ); + + expect(socket.emit).toHaveBeenCalledWith("boardstate", expect.any(String)); + const [, payload] = socket.emit.mock.calls.find(([event]) => event === "boardstate"); + expect(JSON.parse(payload).color).toBe("white"); + }); +}); + +describe("a finished PvP game reports once, with the chess server's own key", () => { + test("checkmate reports exactly once to /internal/gameResults with X-Service-Key", async () => { + process.env.CHESS_SERVICE_KEY = "test-key"; + + global.fetch.mockImplementation((url, opts) => { + if (String(url).endsWith("/challenge/game/g1")) { + const you = opts.headers.Authorization.replace("Bearer ", ""); + return Promise.resolve({ + ok: true, + json: async () => ({ gameId: "g1", you, white: "alice", black: "bob", status: "active" }), + }); + } + if (String(url).endsWith("/internal/gameResults")) { + return Promise.resolve({ ok: true, status: 201 }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + const alice = fakeSocket("sAlice"); + const bob = fakeSocket("sBob"); + registerSocketHandlers(alice, fakeIo); + registerSocketHandlers(bob, fakeIo); + + await alice._handlers["newpvpgame"]( + JSON.stringify({ gameId: "g1", username: "alice", credentials: "alice" }) + ); + await bob._handlers["newpvpgame"]( + JSON.stringify({ gameId: "g1", username: "bob", credentials: "bob" }) + ); + + // Fool's Mate: 1. f3 e5 2. g4 Qh4# — white (alice) is checkmated. + await alice._handlers["move"](JSON.stringify({ from: "f2", to: "f3" })); + await bob._handlers["move"](JSON.stringify({ from: "e7", to: "e5" })); + await alice._handlers["move"](JSON.stringify({ from: "g2", to: "g4" })); + await bob._handlers["move"](JSON.stringify({ from: "d8", to: "h4" })); + + const reportCalls = global.fetch.mock.calls.filter(([url]) => + String(url).endsWith("/internal/gameResults") + ); + expect(reportCalls).toHaveLength(1); + + const [url, opts] = reportCalls[0]; + expect(url).toBe("http://middleware.test/internal/gameResults"); + expect(opts.headers["X-Service-Key"]).toBe("test-key"); + const body = JSON.parse(opts.body); + expect(body).toEqual({ + gameId: "g1", + result: "win", + reason: "checkmate", + winnerUsername: "bob", + loserUsername: "alice", + }); + }); +}); diff --git a/chessServer/src/tests/GameManager.test.js b/chessServer/src/tests/GameManager.test.js index d99e1613..d4abfa31 100644 --- a/chessServer/src/tests/GameManager.test.js +++ b/chessServer/src/tests/GameManager.test.js @@ -143,10 +143,15 @@ describe('GameManager', () => { }); // --- Student-vs-student (PvP) join by gameId (§5) ----------------------- + // + // gameId, username, white and black all come from the middleware's + // GET /challenge/game/:gameId response (verified by EventHandlers before + // calling this) — never from the client directly. See the PvP results + // plan (v2), T4. - test('creates a PvP game and seats the challenger as white', () => { + test('creates a PvP game and seats white as white', () => { const res = gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Alice', socketId: 'sA' }); expect(res.newGame).toBe(true); @@ -155,44 +160,28 @@ describe('GameManager', () => { expect(gameManager.getGameByGameId('g1')).toBe(res.game); }); - test('each PvP seat keeps its own credentials for the end-of-game report', () => { - // Resign and disconnect carry no payload, so the token has to be captured - // at join time or the result can never be reported to the middleware. + test('reconnecting with the same username reclaims the original seat', () => { gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', - username: 'Alice', socketId: 'sA', credentials: 'token-alice' - }); - const res = gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', - username: 'Cara', socketId: 'sC', credentials: 'token-cara' - }); - - const seats = Object.fromEntries(res.game.players.map((p) => [p.username, p.credentials])); - expect(seats).toEqual({ Alice: 'token-alice', Cara: 'token-cara' }); - }); - - test('reconnecting refreshes the seat credentials rather than blanking them', () => { - gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', - username: 'Alice', socketId: 'sA', credentials: 'token-alice' + gameId: 'g1', white: 'Alice', black: 'Cara', + username: 'Alice', socketId: 'sA' }); - // Reconnect with no token supplied — keep the one we already had. const res = gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Alice', socketId: 'sA2' }); const alice = res.game.players.find((p) => p.username === 'Alice'); expect(alice.id).toBe('sA2'); - expect(alice.credentials).toBe('token-alice'); + expect(res.color).toBe('white'); + expect(res.newGame).toBe(false); }); test('second PvP player joins the same game by gameId as black', () => { gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Alice', socketId: 'sA' }); const res = gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Cara', socketId: 'sC' }); expect(res.newGame).toBe(false); @@ -202,7 +191,7 @@ describe('GameManager', () => { test('rejects a non-player trying to join a PvP game', () => { expect(() => gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Mallory', socketId: 'sM' })).toThrow(/not a player/); }); @@ -217,11 +206,11 @@ describe('GameManager', () => { test('a forfeit in a PvP game awards the win to the opponent', () => { gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Alice', socketId: 'sA' }); gameManager.createOrJoinPvpGame({ - gameId: 'g1', challenger: 'Alice', opponent: 'Cara', + gameId: 'g1', white: 'Alice', black: 'Cara', username: 'Cara', socketId: 'sC' }); const res = gameManager.resign('sC', 'disconnect'); // Cara drops diff --git a/chessServer/src/tests/validateEnvironment.test.js b/chessServer/src/tests/validateEnvironment.test.js new file mode 100644 index 00000000..b5bcbce6 --- /dev/null +++ b/chessServer/src/tests/validateEnvironment.test.js @@ -0,0 +1,66 @@ +const validateEnvironment = require("../validateEnvironment"); + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(process, "exit").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +afterAll(() => { + process.env = ORIGINAL_ENV; +}); + +describe("validateEnvironment", () => { + test("does nothing outside production", () => { + process.env.NODE_ENV = "test"; + delete process.env.CHESS_SERVICE_KEY; + delete process.env.MIDDLEWARE_URL; + validateEnvironment(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + test("exits in production when CHESS_SERVICE_KEY is missing", () => { + process.env.NODE_ENV = "production"; + process.env.MIDDLEWARE_URL = "https://middleware.example.com"; + process.env.CORS_ORIGIN = "https://ystemandchess.com"; + delete process.env.CHESS_SERVICE_KEY; + + validateEnvironment(); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("CHESS_SERVICE_KEY")); + }); + + test("exits in production when neither CORS_ORIGIN nor ALLOWED_ORIGINS is set", () => { + process.env.NODE_ENV = "production"; + process.env.MIDDLEWARE_URL = "https://middleware.example.com"; + process.env.CHESS_SERVICE_KEY = "a-real-key"; + delete process.env.CORS_ORIGIN; + delete process.env.ALLOWED_ORIGINS; + + validateEnvironment(); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("CORS_ORIGIN or ALLOWED_ORIGINS") + ); + }); + + test("passes in production when every required var is set", () => { + process.env.NODE_ENV = "production"; + process.env.MIDDLEWARE_URL = "https://middleware.example.com"; + process.env.CHESS_SERVICE_KEY = "a-real-key"; + process.env.CORS_ORIGIN = "https://ystemandchess.com"; + + validateEnvironment(); + + expect(process.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/chessServer/src/validateEnvironment.js b/chessServer/src/validateEnvironment.js index 14a9bdc7..7cb6ded1 100644 --- a/chessServer/src/validateEnvironment.js +++ b/chessServer/src/validateEnvironment.js @@ -1,6 +1,16 @@ -const REQUIRED_PRODUCTION_VARS = [ - "MIDDLEWARE_URL", -]; +/** + * Fails fast in production when required secrets/config are missing, instead + * of booting and silently degrading. CHESS_SERVICE_KEY unset has exactly that + * silent-failure shape today: the PvP result report just logs and skips (see + * EventHandlers.reportGameResult) — this is the production-startup guard the + * PvP results plan (v2), T4, asks for, folded into the env check this repo's + * "signed-environment-separation" work already added for MIDDLEWARE_URL/CORS. + * + * Only enforced when NODE_ENV === "production" — local/dev/test runs are + * unaffected, same as the other "skip and log" call sites already behave. + */ + +const REQUIRED_PRODUCTION_VARS = ["MIDDLEWARE_URL", "CHESS_SERVICE_KEY"]; function hasCorsConfiguration() { return Boolean( diff --git a/documentation/student-vs-student-design.md b/documentation/student-vs-student-design.md index 76b94982..fe13bee8 100644 --- a/documentation/student-vs-student-design.md +++ b/documentation/student-vs-student-design.md @@ -172,25 +172,37 @@ The `gameover` event triggers one side-effect: the chessServer reports the finished game to the middleware. No balance is credited — the result is stored and the score is derived from it. +**v2 update** ("PvP game results: server-authoritative reporting"): there is +no player-facing write anymore. Only the chess server can report a result, +authenticated with its own service credential, and only for a game the +middleware already knows was accepted by two real players. See §10 for why. + ### Route naming -`POST /gameResults` — plural, resource-first, matching the existing -`/leaderboard`, `/badges`, `/activities` convention. +`/gameResults` — plural, resource-first, matching the existing `/leaderboard`, +`/badges`, `/activities` convention. The chess server's own write path is +nested under `/internal` to mark it as not player-facing. ### Contract ``` -POST /gameResults (requireAuth; caller must be one of the two players) +POST /internal/gameResults (requireServiceKey — X-Service-Key: CHESS_SERVICE_KEY) win: { gameId, result: "win", reason: "checkmate"|"resign"|"disconnect", winnerUsername, loserUsername, playedAt? } draw: { gameId, result: "draw", reason: "draw", players: [a, b], playedAt? } - -> 201 { success, duplicate: false, gameResult } + -> 201 { success, duplicate: false, gameResult } // gameResult.source: "chessServer" -> 200 { success, duplicate: true, gameResult } // already recorded + -> 404 unknown gameId (no accepted PvpGame) + -> 400 reported players don't match the accepted game -GET /gameResults/:username +GET /gameResults/:username (requireAuth — any logged-in role) -> { success, data: { wins, draws, losses, gamesPlayed, chessScore } } + +GET /challenge/game/:gameId (requireAuth) + -> { gameId, you, white, black, status } // `you` is derived from the caller's own JWT + -> 403 if the caller is not one of the two players; 404 if unknown ``` ### Why a record, not a counter @@ -241,12 +253,26 @@ duplicate, not a 500. ## 9. Testing - Unit: `chessServer/src/tests/GameManager.test.js` — checkmate/draw/resign - outcomes, winner resolution by color, PvP pairing, per-seat credentials. + outcomes, winner resolution by color, PvP pairing by `white`/`black`. +- Unit: `chessServer/src/tests/EventHandlers.pvp.test.js` — `newpvpgame` + rejects a client-claimed username that doesn't match the middleware's, seats + only from the middleware's `white`/`black`, and a finished game reports + exactly once with `CHESS_SERVICE_KEY`. - Integration: two socket clients play a scholar's-mate line; assert both receive `gameover` with the correct `winnerUsername`. -- Result API: `middlewareNode/tests/gameResults.test.js` — idempotency on - `gameId` (including the unique-index race), the participant-only guard, and - win/draw body validation. +- Result API: `middlewareNode/tests/internalGameResults.test.js` — service-key + auth, unknown-gameId / player-mismatch rejection, idempotency on `gameId` + (including the unique-index race). `middlewareNode/tests/gameResults.test.js` + covers the read side and that the legacy player-facing POST is gone (404). +- Contract: `middlewareNode/tests/contract.chessServerReport.test.js` imports + `chessServer/src/reporting/resultRequest.js` directly and sends its exact + output through the real `/internal/gameResults` route (real service-key + check, real models via mongodb-memory-server) — the two services can't pass + their own tests in isolation while disagreeing with each other, which is how + the v1 header bug went unnoticed. +- Challenge persistence: `middlewareNode/tests/challenge.pvpgame.test.js` — + accepting a challenge persists exactly one `PvpGame`, and + `GET /challenge/game/:gameId` returns it only to one of its two players. - Separation: `middlewareNode/tests/leaderboard.test.js` asserts chess results never move the engagement `score`, and that `sortBy=chess` ranks independently of it. @@ -258,9 +284,19 @@ duplicate, not a 500. Matchmaking queue / ELO, spectators, rematch, anti-cheat, and any spendable currency. Direct-challenge only. -Known limitation carried into v1: the reporting client is trusted to report -honestly. `POST /gameResults` requires the caller to be one of the two players, -which stops a third party fabricating results, but a player could still report -a game they lost as a win. Closing that means the chessServer holding its own -service credential rather than relaying a player's token — worth doing before -the chess score is used for anything that matters. +**Closed in v2** ("PvP game results: server-authoritative reporting"): v1's +known limitation — a player's own token reported their own game's result, so a +losing player could report themselves a win — is gone. The chess server now +holds its own service credential (`CHESS_SERVICE_KEY`) and reports through +`POST /internal/gameResults`, which only the chess server can call. Identity +is verified where players join, not trusted from reporting: on `newpvpgame` +the chess server calls `GET /challenge/game/:gameId` with the joining player's +own JWT and seats them under the username the middleware's own auth resolves, +never the username the client claims. A result is only accepted if its +winner/loser match the `PvpGame` the middleware recorded when the challenge +was accepted — so a fabricated `gameId` or mismatched players is rejected +before anything is stored. See §7 for the current contract. + +Still out of scope: win trading between two cooperating real accounts (a +separate follow-up plan), matchmaking, ELO, spectators and rematch (unchanged +from v1, still gated on usage data this fix makes possible to collect). diff --git a/documentation/student-vs-student-feature-guide.md b/documentation/student-vs-student-feature-guide.md index c45582f4..5737f6e7 100644 --- a/documentation/student-vs-student-feature-guide.md +++ b/documentation/student-vs-student-feature-guide.md @@ -19,33 +19,48 @@ engagement score — see the design doc §1). Three layers cooperate: Student A profile ──"Challenge cara"──▶ middleware /challenge ──▶ B's incoming list (PlayStudent.tsx) (in-memory) (B short-polls) │ Accept - both sides now hold the same gameId ◀───────────────────────────────┘ + both sides now hold the same gameId, and the middleware + saves a PvpGame {gameId, white: A, black: B, status: "active"} ◀───┘ │ - each client ─"newpvpgame {gameId, challenger, opponent, username, credentials}"─▶ chessServer - │ (createOrJoinPvpGame pairs them: challenger = white) + each client ─"newpvpgame {gameId, username, credentials}"─▶ chessServer + │ chessServer calls GET /challenge/game/:gameId + │ with the player's own JWT, gets back {you, white, black} + │ from the MIDDLEWARE's auth — never trusts the client's + │ own claim. Mismatch → rejected, nobody seated. + │ createOrJoinPvpGame pairs them from that response: white = white seat. ...moves sync over sockets... │ a move causes checkmate ──▶ detectOutcome() resolves winner BY COLOR │ chessServer emits "gameover" {winnerUsername, loserUsername, reason} to BOTH │ - POST /gameResults ──▶ one immutable record, idempotent on gameId + POST /internal/gameResults (X-Service-Key: CHESS_SERVICE_KEY) ──▶ + middleware checks winner/loser match the saved PvpGame, then stores + one immutable record, idempotent on gameId, source: "chessServer" │ leaderboard / analytics compute W-D-L + chessScore on read ``` +Players never write their own result — only the chess server can, and only +for a game the middleware already knows two real players accepted. See the +design doc §7 and §10. + ### Key files | File | Responsibility | |---|---| | `react-ystemandchess/src/features/student/student-profile/PlayStudent.tsx` | "Play a Student" tab: send challenge, poll for acceptance, accept/decline incoming | | `react-ystemandchess/.../Modals/LeaderboardModal.tsx` | Sortable **Chess** column (score + W–D–L), separate from Score | -| `middlewareNode/src/routes/challenge.js` | Challenge handshake endpoints (in-memory, TTL-swept) | -| `middlewareNode/src/routes/gameResults.js` | `POST /gameResults` (idempotent, participant-only), `GET /gameResults/:username` | -| `middlewareNode/src/models/gameResults.js` | One immutable record per finished game; `gameId` unique | +| `middlewareNode/src/routes/challenge.js` | Challenge handshake endpoints (in-memory, TTL-swept); accept also saves a `PvpGame`; `GET /challenge/game/:gameId` lets the chess server verify a joining player | +| `middlewareNode/src/models/PvpGame.js` | The middleware's record of who the two real players in a `gameId` are; `active` → `finished` | +| `middlewareNode/src/middleware/requireServiceKey.js` | Gates `/internal/gameResults` on `CHESS_SERVICE_KEY` instead of a player JWT | +| `middlewareNode/src/routes/internalGameResults.js` | `POST /internal/gameResults` — the chess server's only write path, validated against the saved `PvpGame` | +| `middlewareNode/src/routes/gameResults.js` | `GET /gameResults/:username` only — no player-facing POST | +| `middlewareNode/src/models/gameResults.js` | One immutable record per finished game; `gameId` unique; `source: "chessServer" \| "legacy-unverified"` | | `middlewareNode/src/utils/studentStats.js` | `getChessRecord` / `getChessRecords` / `chessScoreFrom` — the single scoring source | -| `chessServer/src/managers/GameManager.js` | `createOrJoinPvpGame`, `detectOutcome`, `resign`, the `isOver` latch | -| `chessServer/src/managers/EventHandlers.js` | `newpvpgame` / `resign` socket events, `emitGameOver` + `reportGameResult` | +| `chessServer/src/managers/GameManager.js` | `createOrJoinPvpGame` (seats from `white`/`black` only), `detectOutcome`, `resign`, the `isOver` latch | +| `chessServer/src/managers/EventHandlers.js` | `newpvpgame` (verifies identity via the middleware) / `resign` socket events, `emitGameOver` + `reportGameResult` | +| `chessServer/src/reporting/resultRequest.js` | Pure function building the `{path, headers, body}` the chess server sends; imported directly by the middleware's contract test | --- @@ -55,14 +70,18 @@ Student A profile ──"Challenge cara"──▶ middleware /challenge ── ```bash cd chessServer && npx jest src/tests/GameManager.test.js -# → Tests: 17 passed +cd chessServer && npx jest src/tests/EventHandlers.pvp.test.js # identity verification + one report ``` -### Result API — idempotency, participant guard, scoring +### Result API — service-key auth, idempotency, scoring ```bash -cd middlewareNode && npx jest tests/gameResults.test.js -# → Tests: 16 passed +cd middlewareNode && npx jest tests/internalGameResults.test.js # the chess server's write path +cd middlewareNode && npx jest tests/gameResults.test.js # read path + legacy POST is gone +cd middlewareNode && npx jest tests/challenge.pvpgame.test.js # accept persists a PvpGame + +# Cross-service: the chess server's actual request shape against the real route +cd middlewareNode && npx jest tests/contract.chessServerReport.test.js # The separation guarantee (chess results never move the engagement score): cd middlewareNode && npx jest tests/leaderboard.test.js @@ -114,30 +133,37 @@ curl -sX POST localhost:8000/challenge -H 'Content-Type: application/json' \ -d '{"fromUsername":"alice","toUsername":"cara"}' # → {challengeId, gameId} curl -s localhost:8000/challenge/incoming/cara # cara sees it curl -sX POST localhost:8000/challenge//accept # → {gameId, challenger, opponent} + # (also saves a PvpGame) + +# either player can verify the game — this is what the chess server calls on join +curl -s localhost:8000/challenge/game/ -H "Authorization: Bearer $ALICE_TOKEN" +# → {gameId, you: "alice", white: "alice", black: "cara", status: "active"} ``` ### Watch the result API (no browser) -```bash -TOKEN= +Only the chess server can write a result now — a player's own token can't. -# record a game (as one of the two players) -curl -sX POST localhost:8000/gameResults \ - -H 'Content-Type: application/json' -H "Authentication: Bearer $TOKEN" \ +```bash +# record a game — chess server identity, not a player's token +curl -sX POST localhost:8000/internal/gameResults \ + -H 'Content-Type: application/json' -H "X-Service-Key: $CHESS_SERVICE_KEY" \ -d '{"gameId":"g1","result":"win","reason":"checkmate", "winnerUsername":"alice","loserUsername":"cara"}' # → 201 {duplicate:false} +# (requires a PvpGame already saved for "g1" — i.e. a real accepted challenge) # report it again — idempotent, nothing changes -curl -sX POST localhost:8000/gameResults \ - -H 'Content-Type: application/json' -H "Authentication: Bearer $TOKEN" \ +curl -sX POST localhost:8000/internal/gameResults \ + -H 'Content-Type: application/json' -H "X-Service-Key: $CHESS_SERVICE_KEY" \ -d '{"gameId":"g1","result":"win","reason":"checkmate", "winnerUsername":"alice","loserUsername":"cara"}' # → 200 {duplicate:true} -curl -s localhost:8000/gameResults/alice -H "Authentication: Bearer $TOKEN" +# a player's own token no longer works for writing — only reading +curl -s localhost:8000/gameResults/alice -H "Authorization: Bearer $TOKEN" # → {wins, draws, losses, gamesPlayed, chessScore} # the leaderboard shows it as its own column, and can rank by it -curl -s 'localhost:8000/leaderboard?sortBy=chess' -H "Authentication: Bearer $TOKEN" +curl -s 'localhost:8000/leaderboard?sortBy=chess' -H "Authorization: Bearer $TOKEN" ``` --- @@ -149,9 +175,12 @@ curl -s 'localhost:8000/leaderboard?sortBy=chess' -H "Authentication: Bearer $TO | Winner resolved correctly (by color, both game types) | unit: Fool's-mate → `cara` wins | | Draws detected, no winner | unit: insufficient-material | | Resign / disconnect forfeit to opponent | unit + E2E | -| Both seats keep their own token for the end-of-game report | unit: per-seat credentials | +| A joining player is seated under their own verified identity, not a client-claimed username | `EventHandlers.pvp.test.js` — mismatch rejected, seat comes from the middleware response only | | No double-count after a decided game | unit: "cannot be resigned again" + `gameId` idempotency tests | -| A non-participant cannot report a game | `gameResults.test.js` — 403 | +| Only the chess server (service key) can report a result | `internalGameResults.test.js` — 401 without/with wrong key | +| A result is only accepted for a real accepted game, with matching players | `internalGameResults.test.js` — 404 unknown gameId, 400 player mismatch | +| The two services agree on the report's shape without being mocked at each other | `contract.chessServerReport.test.js` | +| A player can no longer write their own result | `gameResults.test.js` — legacy POST returns 404 | | Chess results never move the engagement score | `leaderboard.test.js` — separation tests | | Full handshake → paired game → gameover on both clients | E2E 11/11 | @@ -162,8 +191,11 @@ curl -s 'localhost:8000/leaderboard?sortBy=chess' -H "Authentication: Bearer $TO **Works end-to-end today:** the entire **challenge handshake** — send / accept / decline, live polling, self-challenge rejection, duplicate dedup — all of the **game and outcome logic** (pairing by `gameId`, move sync, checkmate/draw/resign/ -disconnect detection, single-count guarantee), and **result recording + scoring** -(`POST /gameResults` → leaderboard Chess column and analytics `chess` block). +disconnect detection, single-count guarantee), **server-authoritative result +recording** (`POST /internal/gameResults`, service-key gated, validated against +the accepted `PvpGame` → leaderboard Chess column and analytics `chess` block), +and **identity verification on join** (a player is seated under the username +the middleware's own auth resolves, never a client-supplied one). **Pending:** @@ -171,6 +203,10 @@ disconnect detection, single-count guarantee), and **result recording + scoring* chess client; embedding the board in the profile via `postMessage` waits on the chess-client refactor. Until a client emits `newpvpgame`, the reporting path is exercised by tests and the E2E driver rather than by real browser play. -- **Trust model.** The reporting client is a player, so a determined student - could report a loss as a win. See the design doc §10 — the fix is a chessServer - service credential instead of a relayed player token. +- **Deploy.** `CHESS_SERVICE_KEY` needs to exist as a matching secret on both + services in production before this reaches real traffic — see the PvP results + plan (v2), T5. Until then, reports fail exactly as they do today (no regression, + just no fix yet). +- **Win trading.** The v1 trust gap (a player reporting their own result) is + closed, but two cooperating real accounts can still play and throw real games + to farm chess score — see the separate "PvP follow-ups" plan. diff --git a/middlewareNode/src/middleware/requireServiceKey.js b/middlewareNode/src/middleware/requireServiceKey.js new file mode 100644 index 00000000..5ce6bcff --- /dev/null +++ b/middlewareNode/src/middleware/requireServiceKey.js @@ -0,0 +1,38 @@ +/** + * Require Service Key Middleware + * + * Gates the chess server's internal result-reporting endpoint. The caller is + * not a student — it's the chess server itself, authenticating with a shared + * secret (CHESS_SERVICE_KEY) instead of a player JWT. See the PvP results + * plan (v2), target design point 4. + * + * Uses crypto.timingSafeEqual to compare keys, which requires equal-length + * buffers — lengths are compared first so a wrong-length key takes the same + * code path as a wrong-value key instead of throwing. + */ + +const crypto = require("crypto"); + +const requireServiceKey = (req, res, next) => { + const expected = process.env.CHESS_SERVICE_KEY; + const provided = req.headers["x-service-key"]; + + if (!expected || !provided) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const expectedBuf = Buffer.from(expected); + const providedBuf = Buffer.from(provided); + + const matches = + expectedBuf.length === providedBuf.length && + crypto.timingSafeEqual(expectedBuf, providedBuf); + + if (!matches) { + return res.status(401).json({ error: "Unauthorized" }); + } + + next(); +}; + +module.exports = requireServiceKey; diff --git a/middlewareNode/src/models/PvpGame.js b/middlewareNode/src/models/PvpGame.js new file mode 100644 index 00000000..858f2110 --- /dev/null +++ b/middlewareNode/src/models/PvpGame.js @@ -0,0 +1,36 @@ +/** + * PvpGame Schema + * + * Created the moment a challenge is accepted. It is the middleware's record of + * who the two real players in a gameId are, so the chess server can verify a + * joining socket against it instead of trusting whatever username the client + * sends. See documentation/student-vs-student-design.md §10 and the + * "PvP game results: server-authoritative reporting" plan (v2), target design. + * + * `gameId` is the same id the challenge flow (routes/challenge.js) hands out + * when the challenge is created — unique here too, so an accept can only ever + * produce one PvpGame per game. + */ + +const mongoose = require("mongoose"); + +const PvpGameSchema = new mongoose.Schema( + { + gameId: { type: String, required: true, unique: true, index: true }, + + // The challenger is always seated white, the opponent black — matches the + // chess server's GameManager.createOrJoinPvpGame convention. + white: { type: String, required: true }, + black: { type: String, required: true }, + + // "active" while the game is being played; "finished" once a result has + // been accepted for it. Internal results can only flip active -> finished. + status: { type: String, enum: ["active", "finished"], default: "active", index: true }, + + acceptedAt: { type: Date, default: Date.now }, + finishedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +module.exports = mongoose.model("PvpGame", PvpGameSchema); diff --git a/middlewareNode/src/models/gameResults.js b/middlewareNode/src/models/gameResults.js index 69e900d5..5eca85f9 100644 --- a/middlewareNode/src/models/gameResults.js +++ b/middlewareNode/src/models/gameResults.js @@ -14,7 +14,12 @@ * or both clients reporting the same game can never double-count it. * * Records are written only by the chessServer at game end (checkmate, resign, - * or disconnect-forfeit) via POST /gameResults. + * or disconnect-forfeit) via POST /internal/gameResults, authenticated with + * CHESS_SERVICE_KEY rather than a player JWT — see the PvP results plan (v2). + * `source` records how a record got here: "chessServer" for everything + * reported through that path, "legacy-unverified" for records that predate + * it (the old player-facing POST never actually worked — see finding #1 of + * that plan — so any pre-existing record is suspect, not verified history). */ const mongoose = require("mongoose"); @@ -52,6 +57,17 @@ const GameResultsSchema = new mongoose.Schema( }, playedAt: { type: Date, default: Date.now, index: true }, + + // Defaults to "legacy-unverified" rather than being required: any write + // that doesn't explicitly claim source: "chessServer" (only + // routes/internalGameResults.js does) should safely fall back to + // unverified, not fail. This also keeps older code paths that create a + // GameResults doc without knowing about this field working unchanged. + source: { + type: String, + enum: ["chessServer", "legacy-unverified"], + default: "legacy-unverified", + }, }, { timestamps: true } ); diff --git a/middlewareNode/src/routes/challenge.js b/middlewareNode/src/routes/challenge.js index 7aea391d..cbaef150 100644 --- a/middlewareNode/src/routes/challenge.js +++ b/middlewareNode/src/routes/challenge.js @@ -14,8 +14,9 @@ const express = require('express'); const crypto = require('crypto'); -const router = express.Router({ mergeParams: true }); const requireAuth = require('../middleware/requireAuth'); +const PvpGame = require('../models/PvpGame'); +const router = express.Router({ mergeParams: true }); // challengeId -> { id, gameId, fromUsername, toUsername, status, createdAt } // status: "pending" | "accepted" | "declined" @@ -136,8 +137,14 @@ router.get('/:id', requireAuth, (req, res) => { /** * POST /challenge/:id/accept * Opponent accepts; both sides now share `gameId`. + * + * Also persists a PvpGame record (white = challenger, black = opponent) — + * this is the middleware's own record of who the two real players are, so + * GET /challenge/game/:gameId can later verify a joining chess-server socket + * against it instead of trusting a client-supplied username. See the PvP + * results plan (v2), target design. */ -router.post('/:id/accept', requireAuth, (req, res) => { +router.post('/:id/accept', requireAuth, async (req, res) => { sweepExpired(); const challenge = challenges.get(req.params.id); if (!challenge) { @@ -153,6 +160,24 @@ router.post('/:id/accept', requireAuth, (req, res) => { return res.status(409).json({ error: `Challenge already ${challenge.status}` }); } challenge.status = 'accepted'; + + try { + await PvpGame.create({ + gameId: challenge.gameId, + white: challenge.fromUsername, + black: challenge.toUsername, + }); + } catch (err) { + // Unique-index race: two accepts for the same challenge can't both get + // here in practice (the in-memory status guard above already blocks a + // second accept), but if they somehow did, the duplicate key means a + // PvpGame already exists for this gameId — nothing more to do. + if (!err || err.code !== 11000) { + console.error('challenge accept — failed to save PvpGame:', err && err.message); + return res.status(500).json({ error: 'Server error' }); + } + } + return res.status(200).json({ gameId: challenge.gameId, challenger: challenge.fromUsername, @@ -160,6 +185,30 @@ router.post('/:id/accept', requireAuth, (req, res) => { }); }); +/** + * GET /challenge/game/:gameId + * Lets the chess server verify who the two real players in a game are before + * seating a joining socket. Behind requireAuth: the caller's own JWT decides + * `you`, so a socket can never claim to be someone else's seat. + */ +router.get('/game/:gameId', requireAuth, async (req, res) => { + const game = await PvpGame.findOne({ gameId: req.params.gameId }); + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + const you = req.user.username; + if (you !== game.white && you !== game.black) { + return res.status(403).json({ error: 'You are not a player in this game' }); + } + return res.status(200).json({ + gameId: game.gameId, + you, + white: game.white, + black: game.black, + status: game.status, + }); +}); + /** * POST /challenge/:id/decline */ diff --git a/middlewareNode/src/routes/gameResults.js b/middlewareNode/src/routes/gameResults.js index 154c013d..f8cfd395 100644 --- a/middlewareNode/src/routes/gameResults.js +++ b/middlewareNode/src/routes/gameResults.js @@ -18,17 +18,21 @@ * reporting the same game is a no-op, not a double count. * * Endpoints: - * POST /gameResults -> record one finished game (idempotent) * GET /gameResults/:username -> that student's W/D/L + chess score * - * Mounted behind requireAuth (any logged-in role). POST additionally requires - * that the caller was one of the two players — a student cannot report a game - * they did not play. + * Mounted behind requireAuth (any logged-in role). + * + * There is no player-facing POST here. A student cannot write their own game + * result — only the chess server can, via the separately-mounted, service-key + * -gated POST /internal/gameResults (routes/internalGameResults.js), which + * reuses buildRecord/serialize below for validation and response shape. See + * the PvP results plan (v2): the old player-facing POST never actually + * reached the database (finding #1), so a student posting to the legacy path + * now just gets a 404 — there is no route left to match. */ const express = require("express"); const router = express.Router(); -const GameResults = require("../models/gameResults"); const { getChessRecord } = require("../utils/studentStats"); const VALID_RESULTS = ["win", "draw"]; @@ -44,6 +48,7 @@ function serialize(doc) { loserUsername: doc.loserUsername, reason: doc.reason, playedAt: doc.playedAt, + source: doc.source, }; } @@ -102,48 +107,6 @@ function buildRecord(body) { }; } -/** - * POST /gameResults - * Body (win): { gameId, result: "win", reason, winnerUsername, loserUsername, playedAt? } - * Body (draw): { gameId, result: "draw", reason: "draw", players: [a, b], playedAt? } - * - * Idempotent on gameId: re-reporting a recorded game returns 200 with - * duplicate: true and changes nothing. - */ -router.post("/", async (req, res) => { - try { - const { error, record } = buildRecord(req.body); - if (error) return res.status(400).json({ success: false, error }); - - // Only a participant may report the game. - const caller = req.user && req.user.username; - if (!caller || !record.players.includes(caller)) { - return res - .status(403) - .json({ success: false, error: "Only a player in this game may report its result" }); - } - - const existing = await GameResults.findOne({ gameId: record.gameId }); - if (existing) { - return res.json({ success: true, duplicate: true, gameResult: serialize(existing) }); - } - - const created = await GameResults.create(record); - return res.status(201).json({ success: true, duplicate: false, gameResult: serialize(created) }); - } catch (err) { - // Unique-index race: the other client reported the same game first. That's - // the idempotency guarantee doing its job, not an error. - if (err && err.code === 11000) { - const existing = await GameResults.findOne({ gameId: req.body.gameId }); - if (existing) { - return res.json({ success: true, duplicate: true, gameResult: serialize(existing) }); - } - } - console.error("gameResults POST /:", err.message); - return res.status(500).json({ success: false, error: "Server error" }); - } -}); - /** * GET /gameResults/:username * That student's competitive record and derived chess score. @@ -159,3 +122,6 @@ router.get("/:username", async (req, res) => { }); module.exports = router; +// Reused by routes/internalGameResults.js for body validation and response shape. +module.exports.buildRecord = buildRecord; +module.exports.serialize = serialize; diff --git a/middlewareNode/src/routes/internalGameResults.js b/middlewareNode/src/routes/internalGameResults.js new file mode 100644 index 00000000..43d69242 --- /dev/null +++ b/middlewareNode/src/routes/internalGameResults.js @@ -0,0 +1,79 @@ +/** + * Internal Game Results Routes — /internal/gameResults + * + * The chess server's own reporting path, authenticated with CHESS_SERVICE_KEY + * (middleware/requireServiceKey) instead of a player JWT. See the PvP results + * plan (v2), target design points 3-4. + * + * The middleware only accepts a result for a PvpGame it knows about — one + * created when a challenge was accepted (routes/challenge.js) — and only when + * the reported winner/loser (or both drawn players) match that game's two + * real players. This is what makes a result trustworthy: the chess server + * decides who won, but the middleware decides whether a game was real. + * + * gameId remains the idempotency key: a second report for an already-recorded + * game returns 200 duplicate: true and writes nothing, same guarantee as the + * old player-facing POST had. + */ + +const express = require("express"); +const router = express.Router(); +const requireServiceKey = require("../middleware/requireServiceKey"); +const GameResults = require("../models/gameResults"); +const PvpGame = require("../models/PvpGame"); +const { buildRecord, serialize } = require("./gameResults"); + +/** True when the reported participants are exactly this PvpGame's two players. */ +function playersMatch(pvpGame, players) { + const expected = [pvpGame.white, pvpGame.black].sort(); + const actual = [...players].sort(); + return expected[0] === actual[0] && expected[1] === actual[1]; +} + +/** + * POST /internal/gameResults + * Body: same shape as the old player-facing POST /gameResults. + * Behind requireServiceKey — the caller is the chess server, not a player. + */ +router.post("/", requireServiceKey, async (req, res) => { + try { + const { error, record } = buildRecord(req.body); + if (error) return res.status(400).json({ success: false, error }); + + const pvpGame = await PvpGame.findOne({ gameId: record.gameId }); + if (!pvpGame) { + return res.status(404).json({ success: false, error: "Game not found" }); + } + if (!playersMatch(pvpGame, record.players)) { + return res + .status(400) + .json({ success: false, error: "Reported players do not match the accepted game" }); + } + + const existing = await GameResults.findOne({ gameId: record.gameId }); + if (existing) { + return res.json({ success: true, duplicate: true, gameResult: serialize(existing) }); + } + + // Flip the game to finished before inserting — this is the one write that + // decides a report "wins", so a concurrent duplicate falls through to the + // GameResults unique-index race below instead of double-finishing. + await PvpGame.updateOne({ gameId: record.gameId, status: "active" }, { $set: { status: "finished", finishedAt: new Date() } }); + + const created = await GameResults.create({ ...record, source: "chessServer" }); + return res.status(201).json({ success: true, duplicate: false, gameResult: serialize(created) }); + } catch (err) { + // Unique-index race: another report for the same game won first. That's + // the idempotency guarantee doing its job, not an error. + if (err && err.code === 11000) { + const existing = await GameResults.findOne({ gameId: req.body.gameId }); + if (existing) { + return res.json({ success: true, duplicate: true, gameResult: serialize(existing) }); + } + } + console.error("internalGameResults POST /:", err.message); + return res.status(500).json({ success: false, error: "Server error" }); + } +}); + +module.exports = router; diff --git a/middlewareNode/src/scripts/markLegacyGameResults.js b/middlewareNode/src/scripts/markLegacyGameResults.js new file mode 100644 index 00000000..13dbb1c5 --- /dev/null +++ b/middlewareNode/src/scripts/markLegacyGameResults.js @@ -0,0 +1,43 @@ +/** + * One-time migration: mark every pre-existing gameResults record + * source: "legacy-unverified". + * + * See the PvP results plan (v2), T6. The player-facing POST /gameResults never + * actually reached the database before this plan (the chess server sent an + * `Authentication` header; passport only reads `Authorization` — see finding + * #1), so any record that predates CHESS_SERVICE_KEY reporting did not come + * through the verified chessServer path. "legacy-unverified" doesn't claim + * those games didn't happen — it just means they weren't recorded through a + * path that checked a real accepted PvpGame. Devin decides whether chess + * score should exclude them; Karthik's backfill should read only + * source: "chessServer" (see the plan's T6/T8 notes). + * + * Usage: + * node src/scripts/markLegacyGameResults.js + * + * Safe to run multiple times — only touches documents missing `source`. + * New records from the chess server always set `source` themselves, so this + * script never overwrites one. + */ + +require("dotenv").config(); +const mongoose = require("mongoose"); +const config = require("config"); + +async function run() { + await mongoose.connect(config.get("mongoURI")); + console.log("Connected to MongoDB"); + + const result = await mongoose.connection.collection("gameresults").updateMany( + { source: { $exists: false } }, + { $set: { source: "legacy-unverified" } } + ); + + console.log(`Migration complete: ${result.modifiedCount} documents marked legacy-unverified`); + await mongoose.disconnect(); +} + +run().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/middlewareNode/src/server.js b/middlewareNode/src/server.js index 91c651ef..295cd1fb 100644 --- a/middlewareNode/src/server.js +++ b/middlewareNode/src/server.js @@ -141,7 +141,16 @@ app.use("/streak", streakRoutes); app.use("/badges", require("./routes/badges")); app.use("/chat", require("./routes/chat")); app.use("/challenge", require("./routes/challenge")); +// PvP results plan (v2): the only write path left under /gameResults was the +// player-facing POST, now removed (a student can no longer report their own +// game — see routes/gameResults.js). currencyEventLimiter stays here since +// GET is still mounted behind it and there's no harm rate-limiting a read, +// but it's no longer guarding a write. The actual write path moved to +// /internal/gameResults below, protected by CHESS_SERVICE_KEY instead of a +// per-user limiter, since its only legitimate caller is the chess server. app.use("/gameResults", currencyEventLimiter, requireAuth, require("./routes/gameResults")); +// Chess server only — authenticated with CHESS_SERVICE_KEY, not a player JWT. +app.use("/internal/gameResults", require("./routes/internalGameResults")); app.use("/analytics", analyticsLimiter, adminGuard, require("./routes/analytics")); app.use("/leaderboard", leaderboardLimiter, requireAuth, require("./routes/leaderboard")); diff --git a/middlewareNode/tests/challenge.pvpgame.test.js b/middlewareNode/tests/challenge.pvpgame.test.js new file mode 100644 index 00000000..28904a7e --- /dev/null +++ b/middlewareNode/tests/challenge.pvpgame.test.js @@ -0,0 +1,119 @@ +/** + * Integration tests — challenge accept persists a PvpGame, and + * GET /challenge/game/:gameId verifies a joining player against it. + * + * See the PvP results plan (v2), T1 and T2. Uses mongodb-memory-server + the + * real PvpGame model (no mocks) so the unique-index and lookup behavior is + * verified directly, matching the pattern in tests/badges.concurrency.test.js. + * requireAuth is mocked the same way tests/gameResults.test.js does it: the + * caller's username comes from an x-test-user header instead of a real JWT. + */ + +jest.mock("../src/middleware/requireAuth", () => (req, res, next) => { + const username = req.headers["x-test-user"]; + if (!username) return res.status(401).json({ error: "Unauthorized" }); + req.user = { username, role: "student" }; + next(); +}); + +const { MongoMemoryServer } = require("mongodb-memory-server"); +const mongoose = require("mongoose"); +const express = require("express"); +const request = require("supertest"); + +jest.setTimeout(60000); + +let mongod; +let app; +let challenge; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create({ instance: { launchTimeout: 30000 } }); + await mongoose.connect(mongod.getUri() + "ystem"); + + challenge = require("../src/routes/challenge"); + app = express(); + app.use(express.json()); + app.use("/challenge", challenge); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +afterEach(async () => { + challenge._reset(); + const collections = mongoose.connection.collections; + await Promise.all(Object.values(collections).map((c) => c.deleteMany({}))); +}); + +/** + * Creates and accepts a challenge, returning its gameId. Both calls now need + * an identity header: POST /challenge requires the caller to be fromUsername, + * and only the recipient (toUsername) may accept. + */ +async function acceptedGame(fromUsername = "alice", toUsername = "bob") { + const created = await request(app) + .post("/challenge") + .set("x-test-user", fromUsername) + .send({ fromUsername, toUsername }); + const accept = await request(app) + .post(`/challenge/${created.body.challengeId}/accept`) + .set("x-test-user", toUsername); + return { challengeId: created.body.challengeId, gameId: accept.body.gameId, accept }; +} + +describe("POST /challenge/:id/accept — persists a PvpGame", () => { + test("accept creates exactly one PvpGame with white=challenger, black=opponent", async () => { + const { gameId } = await acceptedGame("alice", "bob"); + + const PvpGame = require("../src/models/PvpGame"); + const docs = await PvpGame.find({ gameId }); + expect(docs).toHaveLength(1); + expect(docs[0].white).toBe("alice"); + expect(docs[0].black).toBe("bob"); + expect(docs[0].status).toBe("active"); + }); + + test("a second accept of the same challenge returns 409 and creates no extra PvpGame", async () => { + const { challengeId, gameId } = await acceptedGame("alice", "bob"); + + const second = await request(app) + .post(`/challenge/${challengeId}/accept`) + .set("x-test-user", "bob"); + expect(second.status).toBe(409); + + const PvpGame = require("../src/models/PvpGame"); + const docs = await PvpGame.find({ gameId }); + expect(docs).toHaveLength(1); + }); +}); + +describe("GET /challenge/game/:gameId", () => { + test("200 — either player gets you/white/black/status", async () => { + const { gameId } = await acceptedGame("alice", "bob"); + + const asWhite = await request(app).get(`/challenge/game/${gameId}`).set("x-test-user", "alice"); + expect(asWhite.status).toBe(200); + expect(asWhite.body).toEqual({ gameId, you: "alice", white: "alice", black: "bob", status: "active" }); + + const asBlack = await request(app).get(`/challenge/game/${gameId}`).set("x-test-user", "bob"); + expect(asBlack.status).toBe(200); + expect(asBlack.body.you).toBe("bob"); + }); + + test("403 — a student who is not one of the two players", async () => { + const { gameId } = await acceptedGame("alice", "bob"); + + const res = await request(app).get(`/challenge/game/${gameId}`).set("x-test-user", "mallory"); + expect(res.status).toBe(403); + }); + + test("404 — an unknown gameId", async () => { + const res = await request(app) + .get("/challenge/game/00000000-0000-0000-0000-000000000000") + .set("x-test-user", "alice"); + expect(res.status).toBe(404); + }); +}); diff --git a/middlewareNode/tests/contract.chessServerReport.test.js b/middlewareNode/tests/contract.chessServerReport.test.js new file mode 100644 index 00000000..12c46954 --- /dev/null +++ b/middlewareNode/tests/contract.chessServerReport.test.js @@ -0,0 +1,111 @@ +/** + * Cross-service contract test — the chess server's result-report request + * shape against the middleware's actual internal route. See the PvP results + * plan (v2), T4b. + * + * This is the test that makes "the header name and path live in + * resultRequest.js and nowhere else on the chess server side" actually true: + * it imports that file directly from the chess server's source tree (CI + * checks out the whole monorepo, so the relative require works even though + * the two services are built into separate Docker images), builds a request + * with it, and sends that exact request through the real middleware route — + * real requireServiceKey, real PvpGame/GameResults models via + * mongodb-memory-server, nothing mocked. If either side changes the header + * name or the path without the other, this fails in CI instead of each + * service's own tests passing in isolation (which is exactly how the + * Authentication/Authorization header bug this plan fixes went unnoticed). + */ + +const buildResultRequest = require("../../chessServer/src/reporting/resultRequest"); + +const { MongoMemoryServer } = require("mongodb-memory-server"); +const mongoose = require("mongoose"); +const express = require("express"); +const request = require("supertest"); + +jest.setTimeout(60000); + +const SERVICE_KEY = "contract-test-service-key"; + +let mongod; +let app; +let PvpGame; +let GameResults; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create({ instance: { launchTimeout: 30000 } }); + await mongoose.connect(mongod.getUri() + "ystem"); + + process.env.CHESS_SERVICE_KEY = SERVICE_KEY; + + PvpGame = require("../src/models/PvpGame"); + GameResults = require("../src/models/gameResults"); + const internalGameResults = require("../src/routes/internalGameResults"); + + app = express(); + app.use(express.json()); + app.use("/internal/gameResults", internalGameResults); +}); + +afterAll(async () => { + delete process.env.CHESS_SERVICE_KEY; + await mongoose.disconnect(); + await mongod.stop(); +}); + +afterEach(async () => { + const collections = mongoose.connection.collections; + await Promise.all(Object.values(collections).map((c) => c.deleteMany({}))); +}); + +/** Sends a { path, headers, body } request (as built by resultRequest.js) through supertest. */ +function sendBuiltRequest({ path, headers, body }) { + return request(app).post(path).set(headers).send(body); +} + +describe("resultRequest.js output against the real /internal/gameResults route", () => { + test("happy path — a real chess-server-shaped request is accepted and stored once", async () => { + await PvpGame.create({ gameId: "contract-g1", white: "alice", black: "bob" }); + + const game = { gameId: "contract-g1", players: [{ username: "alice" }, { username: "bob" }] }; + const outcome = { reason: "checkmate", winnerUsername: "bob", loserUsername: "alice" }; + const built = buildResultRequest(game, outcome, SERVICE_KEY); + + const res = await sendBuiltRequest(built); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.gameResult.source).toBe("chessServer"); + + const stored = await GameResults.find({ gameId: "contract-g1" }); + expect(stored).toHaveLength(1); + expect(stored[0].winnerUsername).toBe("bob"); + }); + + test("a draw-shaped request is also accepted", async () => { + await PvpGame.create({ gameId: "contract-g2", white: "alice", black: "bob" }); + + const game = { gameId: "contract-g2", players: [{ username: "alice" }, { username: "bob" }] }; + const outcome = { reason: "draw" }; // no winnerUsername => resultRequest builds a draw + const built = buildResultRequest(game, outcome, SERVICE_KEY); + + const res = await sendBuiltRequest(built); + + expect(res.status).toBe(201); + expect(res.body.gameResult.result).toBe("draw"); + }); + + test("wrong key — proves the service-key check actually runs, not just the happy path", async () => { + await PvpGame.create({ gameId: "contract-g3", white: "alice", black: "bob" }); + + const game = { gameId: "contract-g3", players: [{ username: "alice" }, { username: "bob" }] }; + const outcome = { reason: "checkmate", winnerUsername: "alice", loserUsername: "bob" }; + const built = buildResultRequest(game, outcome, "a-completely-wrong-key"); + + const res = await sendBuiltRequest(built); + + expect(res.status).toBe(401); + const stored = await GameResults.find({ gameId: "contract-g3" }); + expect(stored).toHaveLength(0); + }); +}); diff --git a/middlewareNode/tests/gameResults.test.js b/middlewareNode/tests/gameResults.test.js index 81733080..a2807432 100644 --- a/middlewareNode/tests/gameResults.test.js +++ b/middlewareNode/tests/gameResults.test.js @@ -1,13 +1,13 @@ /** - * Integration tests — POST/GET /gameResults + * Integration tests — GET /gameResults * * The GameResults model is mocked; utils/studentStats is NOT, so the scoring * these tests assert is the real formula the leaderboard and analytics use. * - * Covers the three properties the design depends on: - * - idempotency on gameId (a game can never be counted twice), - * - only a participant may report a game, - * - win/draw bodies are validated so junk can't reach the collection. + * There is no player-facing POST to test here anymore — a student can't write + * their own game result. See tests/internalGameResults.test.js for the chess + * server's service-key-gated reporting path (POST /internal/gameResults), and + * the "legacy POST is gone" case below for the 404 that replaces it. */ jest.mock("../src/middleware/requireAuth", () => (req, _res, next) => { @@ -36,111 +36,10 @@ const WIN_BODY = { loserUsername: "bob", }; -const DRAW_BODY = { - gameId: "game-2", - result: "draw", - reason: "draw", - players: ["alice", "bob"], -}; - -describe("POST /gameResults — recording", () => { - beforeEach(() => { - GameResults.findOne.mockResolvedValue(null); - GameResults.create.mockImplementation(async (doc) => doc); - }); - - test("201 — records a decisive game", async () => { - const res = await request(app).post("/gameResults").send(WIN_BODY); - expect(res.status).toBe(201); - expect(res.body.success).toBe(true); - expect(res.body.duplicate).toBe(false); - expect(res.body.gameResult.winnerUsername).toBe("alice"); - expect(res.body.gameResult.players).toEqual(["alice", "bob"]); - }); - - test("201 — records a draw with both players and no winner", async () => { - const res = await request(app).post("/gameResults").send(DRAW_BODY); - expect(res.status).toBe(201); - expect(res.body.gameResult.result).toBe("draw"); - expect(res.body.gameResult.winnerUsername).toBeNull(); - expect(res.body.gameResult.loserUsername).toBeNull(); - expect(res.body.gameResult.players).toEqual(["alice", "bob"]); - }); - - test("a resign is recorded as a win, not a special result", async () => { - const res = await request(app) - .post("/gameResults") - .send({ ...WIN_BODY, reason: "resign" }); - expect(res.status).toBe(201); - expect(res.body.gameResult.result).toBe("win"); - expect(res.body.gameResult.reason).toBe("resign"); - }); -}); - -describe("POST /gameResults — idempotency on gameId", () => { - test("re-reporting a recorded game returns 200 duplicate and writes nothing", async () => { - GameResults.findOne.mockResolvedValue({ ...WIN_BODY, players: ["alice", "bob"] }); - - const res = await request(app).post("/gameResults").send(WIN_BODY); - expect(res.status).toBe(200); - expect(res.body.duplicate).toBe(true); - expect(GameResults.create).not.toHaveBeenCalled(); - }); - - test("a unique-index race is resolved as a duplicate, not a 500", async () => { - // Both clients report at once: findOne sees nothing, create loses the race. - GameResults.findOne - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ ...WIN_BODY, players: ["alice", "bob"] }); - GameResults.create.mockRejectedValue({ code: 11000 }); - +describe("POST /gameResults — legacy player-facing path is gone", () => { + test("404 — a student token posting to the old route finds no route to match", async () => { const res = await request(app).post("/gameResults").send(WIN_BODY); - expect(res.status).toBe(200); - expect(res.body.duplicate).toBe(true); - }); -}); - -describe("POST /gameResults — authorization", () => { - beforeEach(() => { - GameResults.findOne.mockResolvedValue(null); - GameResults.create.mockImplementation(async (doc) => doc); - }); - - test("403 — a student who did not play the game cannot report it", async () => { - const res = await request(app) - .post("/gameResults") - .set("x-test-user", "mallory") - .send(WIN_BODY); - expect(res.status).toBe(403); - expect(GameResults.create).not.toHaveBeenCalled(); - }); - - test("the loser may report the game", async () => { - const res = await request(app).post("/gameResults").set("x-test-user", "bob").send(WIN_BODY); - expect(res.status).toBe(201); - }); -}); - -describe("POST /gameResults — validation", () => { - beforeEach(() => { - GameResults.findOne.mockResolvedValue(null); - GameResults.create.mockImplementation(async (doc) => doc); - }); - - const badBodies = [ - ["missing gameId", { ...WIN_BODY, gameId: undefined }], - ["unknown result", { ...WIN_BODY, result: "forfeit" }], - ["unknown reason", { ...WIN_BODY, reason: "boredom" }], - ["win with no loser", { ...WIN_BODY, loserUsername: undefined }], - ["win against yourself", { ...WIN_BODY, loserUsername: "alice" }], - ["draw with one player", { ...DRAW_BODY, players: ["alice"] }], - ["draw with a non-draw reason", { ...DRAW_BODY, reason: "checkmate" }], - ]; - - test.each(badBodies)("400 — %s", async (_label, body) => { - const res = await request(app).post("/gameResults").send(body); - expect(res.status).toBe(400); - expect(GameResults.create).not.toHaveBeenCalled(); + expect(res.status).toBe(404); }); }); diff --git a/middlewareNode/tests/internalGameResults.test.js b/middlewareNode/tests/internalGameResults.test.js new file mode 100644 index 00000000..6ba83fd3 --- /dev/null +++ b/middlewareNode/tests/internalGameResults.test.js @@ -0,0 +1,145 @@ +/** + * Integration tests — POST /internal/gameResults + * + * The chess server's own reporting path (PvP results plan v2, T3). Models are + * mocked here the same way tests/gameResults.test.js mocks GameResults — the + * real-database equivalent of the happy path is covered end-to-end by + * tests/contract.chessServerReport.test.js (T4b), which mounts this route for + * real against mongodb-memory-server. + */ + +jest.mock("../src/models/gameResults"); +jest.mock("../src/models/PvpGame"); + +const express = require("express"); +const request = require("supertest"); +const internalGameResults = require("../src/routes/internalGameResults"); +const GameResults = require("../src/models/gameResults"); +const PvpGame = require("../src/models/PvpGame"); + +const app = express(); +app.use(express.json()); +app.use("/internal/gameResults", internalGameResults); + +const SERVICE_KEY = "test-service-key"; + +beforeEach(() => { + process.env.CHESS_SERVICE_KEY = SERVICE_KEY; +}); + +afterEach(() => { + jest.clearAllMocks(); + delete process.env.CHESS_SERVICE_KEY; +}); + +const WIN_BODY = { + gameId: "game-1", + result: "win", + reason: "checkmate", + winnerUsername: "alice", + loserUsername: "bob", +}; + +const ACTIVE_GAME = { gameId: "game-1", white: "alice", black: "bob", status: "active" }; + +describe("POST /internal/gameResults — service key", () => { + test("401 — missing key", async () => { + const res = await request(app).post("/internal/gameResults").send(WIN_BODY); + expect(res.status).toBe(401); + }); + + test("401 — wrong key", async () => { + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", "wrong-key") + .send(WIN_BODY); + expect(res.status).toBe(401); + }); +}); + +describe("POST /internal/gameResults — game lookup and validation", () => { + test("404 — unknown gameId", async () => { + PvpGame.findOne.mockResolvedValue(null); + + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", SERVICE_KEY) + .send(WIN_BODY); + expect(res.status).toBe(404); + }); + + test("400 — winner/loser do not match the accepted game's players", async () => { + PvpGame.findOne.mockResolvedValue({ gameId: "game-1", white: "alice", black: "carol", status: "active" }); + + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", SERVICE_KEY) + .send(WIN_BODY); // reports bob, not carol + expect(res.status).toBe(400); + }); + + test("400 — invalid body never reaches the game lookup", async () => { + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", SERVICE_KEY) + .send({ ...WIN_BODY, result: "forfeit" }); + expect(res.status).toBe(400); + expect(PvpGame.findOne).not.toHaveBeenCalled(); + }); +}); + +describe("POST /internal/gameResults — recording", () => { + beforeEach(() => { + PvpGame.findOne.mockResolvedValue({ ...ACTIVE_GAME }); + PvpGame.updateOne.mockResolvedValue({ acknowledged: true }); + GameResults.findOne.mockResolvedValue(null); + GameResults.create.mockImplementation(async (doc) => doc); + }); + + test("201 — happy path stores source: chessServer", async () => { + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", SERVICE_KEY) + .send(WIN_BODY); + + expect(res.status).toBe(201); + expect(res.body.duplicate).toBe(false); + expect(res.body.gameResult.source).toBe("chessServer"); + expect(GameResults.create).toHaveBeenCalledWith( + expect.objectContaining({ gameId: "game-1", source: "chessServer" }) + ); + expect(PvpGame.updateOne).toHaveBeenCalledWith( + { gameId: "game-1", status: "active" }, + { $set: expect.objectContaining({ status: "finished" }) } + ); + }); + + test("200 duplicate:true — second report for the same game writes nothing", async () => { + GameResults.findOne.mockResolvedValue({ ...WIN_BODY, players: ["alice", "bob"], source: "chessServer" }); + + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", SERVICE_KEY) + .send(WIN_BODY); + + expect(res.status).toBe(200); + expect(res.body.duplicate).toBe(true); + expect(GameResults.create).not.toHaveBeenCalled(); + expect(PvpGame.updateOne).not.toHaveBeenCalled(); + }); + + test("a unique-index race is resolved as a duplicate, not a 500", async () => { + GameResults.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...WIN_BODY, players: ["alice", "bob"], source: "chessServer" }); + GameResults.create.mockRejectedValue({ code: 11000 }); + + const res = await request(app) + .post("/internal/gameResults") + .set("X-Service-Key", SERVICE_KEY) + .send(WIN_BODY); + + expect(res.status).toBe(200); + expect(res.body.duplicate).toBe(true); + }); +});