diff --git a/src/Http/MatchActionResult.php b/src/Http/MatchActionResult.php new file mode 100644 index 0000000..bb67ac7 --- /dev/null +++ b/src/Http/MatchActionResult.php @@ -0,0 +1,33 @@ +server['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return MatchActionResult::reject(Response::json(403, ['error' => 'csrf'])); + } + + $payload = json_decode($request->rawBody, true); + if (!is_array($payload)) { + return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']])); + } + + $matchId = (string) ($payload['matchId'] ?? ''); + $matchData = $payload['match'] ?? null; + if (!is_array($matchData)) { + return MatchActionResult::reject(Response::json(400, ['errors' => ['Match is required.']])); + } + + try { + $match = ScenarioSerializer::matchFromArray($matchData); + } catch (\JsonException $exception) { + return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']])); + } catch (\InvalidArgumentException $exception) { + return MatchActionResult::reject(Response::json(400, ['errors' => [$exception->getMessage()]])); + } + + $supplied = (string) ($request->server['HTTP_X_TURN_TOKEN'] ?? ''); + if ( + !TurnToken::verify( + $this->secret, + $matchId, + $match->activeTeamId, + $match->round, + count($match->actionLog), + $supplied, + ) + ) { + return MatchActionResult::reject(Response::json(409, ['error' => 'turn'])); + } + + return MatchActionResult::ok(['matchId' => $matchId, 'match' => $match]); + } +} diff --git a/tests/Unit/Http/MatchActionSupportTest.php b/tests/Unit/Http/MatchActionSupportTest.php new file mode 100644 index 0000000..d0d9cbc --- /dev/null +++ b/tests/Unit/Http/MatchActionSupportTest.php @@ -0,0 +1,184 @@ +buildRequest( + match: $this->validMatchArray(), + csrf: '', + turnToken: 'irrelevant', + ); + + $result = $support->verify($request); + + self::assertNotNull($result->response); + self::assertSame(403, $result->response->status); + self::assertNull($result->matchIdAndMatch); + } + + public function testItRejectsARequestWithABadTurnToken(): void + { + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + $match = $this->validMatch(); + $matchId = '0123456789abcdef'; + $badToken = TurnToken::issue(self::SECRET, $matchId, 'bravo', 1, 0); + + $support = new MatchActionSupport(self::SECRET); + $request = $this->buildRequest( + match: $this->validMatchArray(), + csrf: $csrf, + turnToken: $badToken, + cookies: ['__csrf' => $cookie], + ); + + $result = $support->verify($request); + + self::assertNotNull($result->response); + self::assertSame(409, $result->response->status); + $body = json_decode($result->response->body, true); + self::assertSame('turn', $body['error'] ?? null); + } + + public function testItRejectsAMalformedMatchState(): void + { + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + $support = new MatchActionSupport(self::SECRET); + $request = $this->buildRequest( + match: ['this' => 'is not a match'], + csrf: $csrf, + turnToken: 'whatever', + cookies: ['__csrf' => $cookie], + ); + + $result = $support->verify($request); + + self::assertNotNull($result->response); + self::assertSame(400, $result->response->status); + } + + public function testItReturnsTheValidatedPairForAValidRequest(): void + { + $match = $this->validMatch(); + $matchArray = $this->validMatchArray(); + $matchId = '0123456789abcdef'; + $token = TurnToken::issue( + self::SECRET, + $matchId, + $match->activeTeamId, + $match->round, + count($match->actionLog) + ); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $support = new MatchActionSupport(self::SECRET); + $body = json_encode(['matchId' => $matchId, 'match' => $matchArray], JSON_THROW_ON_ERROR); + $request = new Request( + [], + [], + [], + ['__csrf' => $cookie], + [ + 'HTTP_X_CSRF_TOKEN' => $csrf, + 'HTTP_X_TURN_TOKEN' => $token, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ], + '', + 'POST', + '/matches/current/move', + 'application/json', + $body + ); + + $result = $support->verify($request); + + self::assertNull($result->response); + self::assertNotNull($result->matchIdAndMatch); + self::assertSame($matchId, $result->matchIdAndMatch['matchId']); + self::assertInstanceOf(MatchState::class, $result->matchIdAndMatch['match']); + } + + private function validMatch(): MatchState + { + return new MatchState( + new Battlefield(8, 8), + [ + $this->makeUnit('alpha-1', 'alpha', 0, 0, Archetype::Defender), + $this->makeUnit('alpha-2', 'alpha', 1, 0, Archetype::Defender), + $this->makeUnit('alpha-3', 'alpha', 2, 0, Archetype::Defender), + $this->makeUnit('bravo-1', 'bravo', 7, 7, Archetype::Striker), + $this->makeUnit('bravo-2', 'bravo', 6, 7, Archetype::Striker), + $this->makeUnit('bravo-3', 'bravo', 5, 7, Archetype::Striker), + ], + 'alpha', + ); + } + + private function makeUnit(string $id, string $teamId, int $x, int $y, Archetype $archetype): UnitState + { + return new UnitState( + $id, + $teamId, + new Position($x, $y), + 10, + 10, + 3, + 3, + 2, + 2, + false, + $archetype, + ); + } + + /** @return array */ + private function validMatchArray(): array + { + return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch()); + } + + /** + * @param array $match + * @param array $cookies + */ + private function buildRequest(array $match, string $csrf, string $turnToken, array $cookies = []): Request + { + $body = json_encode(['matchId' => '0123456789abcdef', 'match' => $match], JSON_THROW_ON_ERROR); + return new Request( + [], + [], + [], + $cookies, + [ + 'HTTP_X_CSRF_TOKEN' => $csrf, + 'HTTP_X_TURN_TOKEN' => $turnToken, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ], + '', + 'POST', + '/matches/current/move', + 'application/json', + $body + ); + } +}