feat: add MatchActionSupport for shared CSRF/turn/state verification
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user