feat: add MatchActionSupport for shared CSRF/turn/state verification

This commit is contained in:
Keith Solomon
2026-07-26 00:36:49 -05:00
parent b444f98aa0
commit 47dc95fa56
3 changed files with 278 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
use BattleForge\Domain\MatchState;
/**
* @phpstan-type MatchIdAndMatch = array{matchId: string, match: MatchState}
*/
final readonly class MatchActionResult
{
/**
* @param ?MatchIdAndMatch $matchIdAndMatch
*/
public function __construct(
public ?Response $response,
public ?array $matchIdAndMatch,
) {
}
/** @param MatchIdAndMatch $pair */
public static function ok(array $pair): self
{
return new self(null, $pair);
}
public static function reject(Response $response): self
{
return new self($response, null);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
final class MatchActionSupport
{
public function __construct(private readonly string $secret)
{
}
public function verify(Request $request): MatchActionResult
{
$submitted = (string) ($request->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]);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class MatchActionSupportTest extends TestCase
{
private const SECRET = 'support-test-secret';
public function testItRejectsARequestWithAMissingCsrfToken(): void
{
$support = new MatchActionSupport(self::SECRET);
$request = $this->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<string, mixed> */
private function validMatchArray(): array
{
return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch());
}
/**
* @param array<string, mixed> $match
* @param array<string, string> $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
);
}
}