diff --git a/public/index.php b/public/index.php index 0845a87..37a9d30 100644 --- a/public/index.php +++ b/public/index.php @@ -2,8 +2,136 @@ declare(strict_types=1); -// Front controller. The router and handler dispatch land in Task 13. -// For now this returns a 200 with a stub so the dev server is reachable. -http_response_code(200); -header('Content-Type: text/plain; charset=utf-8'); -echo "BattleForge dev server up.\n"; +use BattleForge\Http\CsrfToken; +use BattleForge\Http\Handlers\GetAssets; +use BattleForge\Http\Handlers\GetBattlefieldEditor; +use BattleForge\Http\Handlers\GetHomePage; +use BattleForge\Http\Handlers\GetTeamEditor; +use BattleForge\Http\Handlers\PostBattlefieldEditor; +use BattleForge\Http\Handlers\PostImageUpload; +use BattleForge\Http\Handlers\PostStartMatch; +use BattleForge\Http\Handlers\PostTeamEditor; +use BattleForge\Http\Request; +use BattleForge\Http\Response; +use BattleForge\Http\Router; + +require __DIR__ . '/../vendor/autoload.php'; + +// 1. Read the app secret. +$secret = getenv('BATTLEFORGE_SECRET'); +if ($secret === false || $secret === '') { + $secretFile = __DIR__ . '/../var/secret.key'; + if (!is_file($secretFile)) { + if (!is_dir(dirname($secretFile))) { + mkdir(dirname($secretFile), 0700, true); + } + file_put_contents($secretFile, random_bytes(32)); + chmod($secretFile, 0600); + } + $secret = file_get_contents($secretFile); + if ($secret === false) { + http_response_code(500); + echo "Failed to read app secret.\n"; + return; + } +} + +// 2. Ensure the __csrf cookie is set. If absent, issue a fresh token. +$cookies = $_COOKIE; +$existingCookie = (string) ($cookies['__csrf'] ?? ''); +if ($existingCookie === '') { + [$token, $cookie] = CsrfToken::issue($secret); + setcookie('__csrf', $cookie, [ + 'expires' => time() + 86400, + 'path' => '/', + 'secure' => isset($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $existingCookie = $cookie; + $cookies['__csrf'] = $cookie; +} + +// 3. Compute the upload-endpoint user token (derived from the same secret). +$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads'); + +// 4. Determine the request method, path, and content type. +$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); +$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); +$path = parse_url($uri, PHP_URL_PATH) ?: '/'; +$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? ''); +$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null; +if ($contentType !== null) { + $semicolon = strpos($contentType, ';'); + if ($semicolon !== false) { + $contentType = substr($contentType, 0, $semicolon); + } + $contentType = trim($contentType); +} + +// 5. Read the raw body for fetch POSTs that submit JSON. +$rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input')); + +// 6. Build a Request with the request data, the CSRF secret, and the upload token. +$server = $_SERVER; +if (!isset($server['__csrf_secret'])) { + $server['__csrf_secret'] = $secret; +} +$server['__uploads_token'] = $uploadsToken; + +$request = new Request( + get: $_GET, + post: $_POST, + files: $_FILES, + cookies: $cookies, + server: $server, + queryString: $queryString, + method: $method, + path: $path, + contentType: $contentType, + rawBody: $rawBody, +); + +// 7. Configure the router with the eight routes. +$uploadsRoot = __DIR__ . '/../var/uploads'; +$placeholderDir = __DIR__ . '/assets/placeholders'; + +$homePage = static fn (Request $r, array $p): Response => (new GetHomePage())->handle($r, $p); +$getTeam = static fn (Request $r, array $p): Response => (new GetTeamEditor())->handle($r, $p); +$postTeam = static fn (Request $r, array $p): Response => (new PostTeamEditor())->handle($r, $p); +$getBattlefield = static fn (Request $r, array $p): Response => (new GetBattlefieldEditor())->handle($r, $p); +$postBattlefield = static fn (Request $r, array $p): Response => (new PostBattlefieldEditor())->handle($r, $p); +$postStart = static fn (Request $r, array $p): Response => (new PostStartMatch())->handle($r, $p); +$postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload($uploadsRoot))->handle($r, $p); +$getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response { + return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p); +}; +$getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response { + $handler = new GetAssets($placeholderDir, $uploadsRoot); + return $handler->handle( + $r, + ['kind' => 'uploads', 'userToken' => $p['userToken'] ?? '', 'filename' => $p['filename'] ?? ''], + ); +}; + +$router = new Router(); +$router->add('GET', '/', $homePage); +$router->add('GET', '/scenarios/{id}/edit/team', $getTeam); +$router->add('POST', '/scenarios/{id}/edit/team', $postTeam); +$router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield); +$router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield); +$router->add('POST', '/scenarios/{id}/start', $postStart); +$router->add('POST', '/assets/upload', $postUpload); +$router->add('GET', '/assets/uploads/{userToken}/{filename}', $getUploadedAsset); +$router->add('GET', '/assets/{kind}/{filename}', $getAssets); + +// 8. Dispatch and emit. +$response = $router->dispatch($request); + +http_response_code($response->status); +foreach ($response->headers as $name => $value) { + header($name . ': ' . $value); +} +echo $response->body; + +return $response->status; diff --git a/src/Application/ImageUploadService.php b/src/Application/ImageUploadService.php index c375eee..797f7a4 100644 --- a/src/Application/ImageUploadService.php +++ b/src/Application/ImageUploadService.php @@ -33,6 +33,6 @@ final class ImageUploadService // file itself to 0600 in case the server's umask is permissive. chmod($destination, 0600); - return '/assets/' . $this->userToken . '/' . $filename; + return '/assets/uploads/' . $this->userToken . '/' . $filename; } } diff --git a/src/Http/Handlers/GetAssets.php b/src/Http/Handlers/GetAssets.php index ec6b35d..1696085 100644 --- a/src/Http/Handlers/GetAssets.php +++ b/src/Http/Handlers/GetAssets.php @@ -27,9 +27,13 @@ final class GetAssets if ($kind === 'uploads') { $userToken = $params['userToken'] ?? ''; - $requestToken = $request->cookies['__uploads_token'] ?? ''; + if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) { + return Response::html(404, '

Not found

'); + } - if ($userToken === '' || $userToken !== $requestToken) { + $requestToken = (string) ($request->server['__uploads_token'] ?? ''); + + if ($userToken !== $requestToken) { return Response::html(404, '

Not found

'); } diff --git a/src/Http/Handlers/GetBattlefieldEditor.php b/src/Http/Handlers/GetBattlefieldEditor.php new file mode 100644 index 0000000..1a79d03 --- /dev/null +++ b/src/Http/Handlers/GetBattlefieldEditor.php @@ -0,0 +1,27 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $csrf = $request->cookies['__csrf'] ?? ''; + $scenarioId = $params['id'] ?? 'new'; + $width = 8; + $height = 8; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/battlefield-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } +} diff --git a/src/Http/Handlers/GetHomePage.php b/src/Http/Handlers/GetHomePage.php index 82cd9be..32275ef 100644 --- a/src/Http/Handlers/GetHomePage.php +++ b/src/Http/Handlers/GetHomePage.php @@ -12,30 +12,12 @@ final class GetHomePage /** @param array $params */ public function handle(Request $request, array $params): Response { - $body = <<<'HTML' - - - - - BattleForge - - - - -

BattleForge

-

New scenario

-

Recent scenarios

-

No saved scenarios yet.

- - - -HTML; + $csrf = $request->cookies['__csrf'] ?? ''; - // The CSRF token placeholder is filled by the front controller (Task 16) - // before the body is sent. The handler does not see the cookie or the - // secret; it just receives a pre-issued token from the front controller. - $token = $request->cookies['__csrf'] ?? ''; - $body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body); + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/home.php'; + $body = (string) ob_get_clean(); return Response::html(200, $body); } diff --git a/src/Http/Handlers/GetTeamEditor.php b/src/Http/Handlers/GetTeamEditor.php new file mode 100644 index 0000000..0e73d23 --- /dev/null +++ b/src/Http/Handlers/GetTeamEditor.php @@ -0,0 +1,29 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $csrf = $request->cookies['__csrf'] ?? ''; + $scenarioId = $params['id'] ?? 'new'; + + $error = null; + $old = []; + $post = []; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/team-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } +} diff --git a/src/Http/Handlers/PostBattlefieldEditor.php b/src/Http/Handlers/PostBattlefieldEditor.php new file mode 100644 index 0000000..c95aa14 --- /dev/null +++ b/src/Http/Handlers/PostBattlefieldEditor.php @@ -0,0 +1,43 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $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 Response::json(403, ['error' => 'csrf']); + } + + try { + $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]); + } + + try { + $draft = ScenarioDraft::fromPost($payload); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + } catch (\InvalidArgumentException $exception) { + return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]); + } + + return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]); + } +} diff --git a/src/Http/Handlers/PostImageUpload.php b/src/Http/Handlers/PostImageUpload.php new file mode 100644 index 0000000..cfb35d4 --- /dev/null +++ b/src/Http/Handlers/PostImageUpload.php @@ -0,0 +1,56 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->post['_csrf'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::json(403, ['error' => 'csrf']); + } + + $file = $request->files['image'] ?? null; + if ($file === null) { + return Response::json(400, ['error' => 'No image uploaded.']); + } + + $tempPath = (string) ($file['tmp_name'] ?? ''); + $declaredMime = (string) ($file['type'] ?? ''); + + if ($tempPath === '' || !is_file($tempPath)) { + // Production: $request->files['image']['tmp_name'] is PHP-set from the multipart body, + // so is_file() confirms the temp file exists. is_uploaded_file() is stricter but + // unsuitable for unit tests that synthesize tmp files via tempnam(). + return Response::json(400, ['error' => 'Upload is not a file.']); + } + + $userToken = hash_hmac('sha256', $secret, 'bf-uploads'); + + try { + $service = new ImageUploadService($userToken, $this->uploadsRoot); + $url = $service->store($tempPath, $declaredMime); + } catch (InvalidImageException $exception) { + return Response::json(400, ['error' => $exception->getMessage()]); + } + + return Response::json(200, ['url' => $url]); + } +} diff --git a/src/Http/Handlers/PostStartMatch.php b/src/Http/Handlers/PostStartMatch.php new file mode 100644 index 0000000..ce076d3 --- /dev/null +++ b/src/Http/Handlers/PostStartMatch.php @@ -0,0 +1,41 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $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 Response::json(403, ['error' => 'csrf']); + } + + try { + $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); + $draft = ScenarioDraft::fromPost($payload); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + $match = $scenario->startMatch('alpha'); + } catch (\JsonException $exception) { + return Response::json(400, ['errors' => ['Malformed JSON.']]); + } catch (\InvalidArgumentException $exception) { + return Response::json(400, ['errors' => [$exception->getMessage()]]); + } + + return Response::json(200, ['match' => ScenarioSerializer::matchToArray($match)]); + } +} diff --git a/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php new file mode 100644 index 0000000..3b39eb3 --- /dev/null +++ b/src/Http/Handlers/PostTeamEditor.php @@ -0,0 +1,87 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->post['_csrf'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::html(403, '

Forbidden

'); + } + + try { + $draft = ScenarioDraft::fromPost($request->post); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + } catch (\InvalidArgumentException $exception) { + return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post); + } + + $json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR); + $url = (string) ($params['id'] ?? $scenario->id); + $safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + $body = << + +Saved + +

Scenario saved.

+ + + +HTML; + + return Response::html(200, $body); + } + + /** @param array $post */ + private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response + { + $error = Escape::html($error); + $csrf = Escape::html($csrf); + $scenarioId = Escape::html($scenarioId); + + $old = array_intersect_key($post, array_flip([ + 'id', + 'name', + 'battlefieldWidth', + 'battlefieldHeight', + 'victoryCondition', + 'holdRoundsRequired', + ])); + $old = array_map(static fn ($v): string => is_scalar($v) ? (string) $v : '', $old); + + $teamA = $post['teamA']['units'] ?? []; + $teamB = $post['teamB']['units'] ?? []; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/team-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } + + /** @return array */ + private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array + { + return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario); + } +} diff --git a/src/Views/battlefield-editor.php b/src/Views/battlefield-editor.php new file mode 100644 index 0000000..e61211d --- /dev/null +++ b/src/Views/battlefield-editor.php @@ -0,0 +1,56 @@ + Escape::html($v); +$a = static fn (string $v): string => Escape::attr($v); +$terrain = ['open', 'forest', 'rough', 'water', 'blocking']; +?> +

Battlefield editor

+

Scenario:

+
+ +
+ Battlefield + + +
+
+ Terrain palette + + + +
+
+ Grid ( × ) + + + + + + + + +
+
+
+ Deployment zones + + + +
+ +
+ + Escape::html($v); + $a = static fn (string $v): string => Escape::attr($v); + ?> +

BattleForge

+

New scenario

+

Recent scenarios

+

No saved scenarios yet.

+ + ` content (e.g. a per-template script tag). + */ +function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void +{ + $e = static fn (string $v): string => Escape::html($v); + $a = static fn (string $v): string => Escape::attr($v); + ?> + + + + + <?= $e($title) ?> + + + + + + + + + $old Optional old form values, keyed by field name; HTML-escaped on output. + * @var array $post Raw form data (for repopulating per-unit fields). + */ +$e = static fn (string $v): string => Escape::html($v); +$a = static fn (string $v): string => Escape::attr($v); +$archetypes = ArchetypeCatalog::templates(); +?> +

Team editor

+ +

+ +
+ +
+ Scenario + + + + +
+
+ Team A + +
+ + + + + + + + + + +
+ +
+
+ Team B + +
+ + + + + + + + + + +
+ +
+
+ Victory + + + +
+ +
+ runFrontController(); + self::assertStringContainsString('New scenario', $homeBody); + self::assertStringContainsString('name="csrf-token"', $homeBody); + + // Step 2: POST team editor. + $_COOKIE['__csrf'] = $cookie; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['__csrf_secret'] = self::SECRET; + $_POST = $this->validTeamPost($token); + $teamBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus); + self::assertStringContainsString('localStorage.setItem', $teamBody); + self::assertStringContainsString('scenario:"demo"', $teamBody); + + // Step 3: POST battlefield editor. + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/battlefield'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $token; + $_POST = []; + $this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR); + $_SERVER['__raw_body'] = $this->rawBody; + $bfBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus); + $bfJson = json_decode($bfBody, true); + self::assertSame(true, $bfJson['ok'] ?? null); + + // Step 4: POST start-match. + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/scenarios/demo/start'; + $this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR); + $_SERVER['__raw_body'] = $this->rawBody; + $startBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus); + $startJson = json_decode($startBody, true); + self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null); + self::assertSame(1, $startJson['match']['round'] ?? null); + self::assertCount(6, $startJson['match']['units'] ?? []); + + // Step 5: POST image upload, then GET the returned URL. + $this->uploadAndFetchAsset(); + } finally { + // Always purge the per-install uploads dir after the test. + $uploadsRoot = __DIR__ . '/../../var/uploads'; + if (is_dir($uploadsRoot)) { + foreach (glob($uploadsRoot . '/*/*') as $file) { + @unlink($file); + } + foreach (glob($uploadsRoot . '/*') as $dir) { + @rmdir($dir); + } + } + } + } + + private function uploadAndFetchAsset(): void + { + $expectedToken = hash_hmac('sha256', self::SECRET, 'bf-uploads'); + $expectedDir = realpath(__DIR__ . '/../../var/uploads') ?: (__DIR__ . '/../../var/uploads'); + + // Create the multipart body manually so we can pre-compute the file we expect to find. + $png = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', + true, + ); + $tmp = tempnam(sys_get_temp_dir(), 'bf-upload-'); + file_put_contents($tmp, $png); + + try { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $_COOKIE['__csrf'] = $cookie; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/assets/upload'; + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test'; + $_SERVER['__csrf_secret'] = self::SECRET; + $_POST = ['_csrf' => $token]; + $_FILES = [ + 'image' => [ + 'name' => 'test.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => strlen($png), + ], + ]; + $this->rawBody = ''; + $_SERVER['__raw_body'] = ''; + $body = $this->runFrontController(); + self::assertSame(200, $this->lastStatus, 'upload response: ' . $body); + $payload = json_decode($body, true); + $url = is_array($payload) ? (string) ($payload['url'] ?? '') : ''; + self::assertNotSame('', $url); + self::assertStringStartsWith('/assets/uploads/' . $expectedToken . '/', $url); + + // Now fetch the asset back through the front controller. + $_COOKIE = []; + $_POST = []; + $_FILES = []; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = $url; + unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['__raw_body']); + $this->rawBody = ''; + $fetchBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus, 'fetch response: ' . $fetchBody); + self::assertSame($png, $fetchBody); + + // Clean up the uploaded file so the outer finally does not loop on it. + $filename = substr($url, strlen('/assets/uploads/' . $expectedToken . '/')); + @unlink($expectedDir . '/' . $expectedToken . '/' . $filename); + } finally { + @unlink($tmp); + } + } + + private string $rawBody = ''; + private int $lastStatus = 0; + + private function runFrontController(): string + { + $this->lastStatus = 0; + $body = $this->captureOutput(function (): void { + $this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code(); + }); + return $body; + } + + /** @param callable(): void $fn */ + private function captureOutput(callable $fn): string + { + ob_start(); + try { + $fn(); + } finally { + $output = (string) ob_get_clean(); + } + return $output; + } + + /** @return array */ + private function validTeamPost(string $token): array + { + return [ + '_csrf' => $token, + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)], + 'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + } + + /** @return array */ + private function validBattlefieldPayload(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)], + 'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } + + /** + * @return list> + */ + private function unitRows(string $teamPrefix, int $xStart, int $xEnd, int $y): array + { + $rows = []; + for ($i = 0; $i <= $xEnd - $xStart; $i++) { + $rows[] = [ + 'id' => "{$teamPrefix}{$i}", + 'archetype' => 'defender', + 'maxHealth' => 12, + 'attack' => 3, + 'defense' => 4, + 'speed' => 2, + 'x' => $xStart + $i, + 'y' => $y, + ]; + } + return $rows; + } +} diff --git a/tests/Integration/GetAssetsTest.php b/tests/Integration/GetAssetsTest.php index 31f6bf6..4caa14b 100644 --- a/tests/Integration/GetAssetsTest.php +++ b/tests/Integration/GetAssetsTest.php @@ -67,4 +67,61 @@ final class GetAssetsTest extends TestCase self::assertSame(404, $response->status); } + + public function testItServesAnUploadedAssetWhenTheServerTokenMatches(): void + { + $userToken = str_repeat('a', 64); + $namespace = $this->uploadsDir . '/' . $userToken; + mkdir($namespace, 0700, true); + $payload = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', + true, + ); + file_put_contents($namespace . '/demo.png', $payload); + + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request( + [], + [], + [], + [], + ['__uploads_token' => $userToken], + '', + 'GET', + '/assets/uploads/' . $userToken . '/demo.png', + null, + '', + ); + $response = $handler->handle( + $request, + ['kind' => 'uploads', 'userToken' => $userToken, 'filename' => 'demo.png'], + ); + + self::assertSame(200, $response->status); + self::assertSame('image/png', $response->headers['Content-Type'] ?? ''); + self::assertSame($payload, $response->body); + } + + public function testItRefusesAnUploadedAssetWhenTheServerTokenDiffers(): void + { + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request( + [], + [], + [], + [], + ['__uploads_token' => str_repeat('a', 64)], + '', + 'GET', + '/assets/uploads/abc/def.png', + null, + '', + ); + $response = $handler->handle( + $request, + ['kind' => 'uploads', 'userToken' => str_repeat('b', 64), 'filename' => 'def.png'], + ); + + self::assertSame(404, $response->status); + } } diff --git a/tests/Integration/GetBattlefieldEditorTest.php b/tests/Integration/GetBattlefieldEditorTest.php new file mode 100644 index 0000000..3b2867d --- /dev/null +++ b/tests/Integration/GetBattlefieldEditorTest.php @@ -0,0 +1,25 @@ +handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? ''); + self::assertStringContainsString('class="bf-grid"', $response->body); + self::assertStringContainsString('name="_csrf"', $response->body); + } +} diff --git a/tests/Integration/GetTeamEditorTest.php b/tests/Integration/GetTeamEditorTest.php new file mode 100644 index 0000000..456573f --- /dev/null +++ b/tests/Integration/GetTeamEditorTest.php @@ -0,0 +1,29 @@ +handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? ''); + self::assertSame('nosniff', $response->headers['X-Content-Type-Options']); + self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']); + self::assertStringContainsString('name="_csrf"', $response->body); + self::assertStringContainsString('name="id"', $response->body); + self::assertStringContainsString('name="victoryCondition"', $response->body); + self::assertStringContainsString('value="defender"', $response->body); + } +} diff --git a/tests/Integration/PostBattlefieldEditorTest.php b/tests/Integration/PostBattlefieldEditorTest.php new file mode 100644 index 0000000..6228031 --- /dev/null +++ b/tests/Integration/PostBattlefieldEditorTest.php @@ -0,0 +1,101 @@ +buildRequest(rawBody: '{}', csrfHeader: ''); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + self::assertStringContainsString('csrf', $response->body); + } + + public function testItAcceptsAValidBattlefieldJsonAndReturnsOk(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostBattlefieldEditor(); + $request = $this->buildRequest( + rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame(true, $body['ok'] ?? null); + self::assertSame('demo', $body['scenario']['id'] ?? null); + } + + public function testItReturns400OnAnInvalidShape(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostBattlefieldEditor(); + $request = $this->buildRequest( + rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(400, $response->status); + $body = json_decode($response->body, true); + self::assertSame(false, $body['ok'] ?? null); + self::assertNotEmpty($body['errors'] ?? []); + } + + /** + * @param array $cookies + * @param array $server + */ + private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = [], array $server = []): Request + { + $server['HTTP_X_CSRF_TOKEN'] = $csrfHeader; + $server['CONTENT_TYPE'] = 'application/json'; + + return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/battlefield', 'application/json', $rawBody); + } + + /** @return array */ + private function validBattlefield(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0], + ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0], + ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7], + ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7], + ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } +} diff --git a/tests/Integration/PostImageUploadTest.php b/tests/Integration/PostImageUploadTest.php new file mode 100644 index 0000000..ac44f7e --- /dev/null +++ b/tests/Integration/PostImageUploadTest.php @@ -0,0 +1,105 @@ +uploadsRoot = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4)); + mkdir($this->uploadsRoot, 0700, true); + } + + protected function tearDown(): void + { + if (is_dir($this->uploadsRoot)) { + foreach (glob($this->uploadsRoot . '/*/*') as $file) { + unlink($file); + } + foreach (glob($this->uploadsRoot . '/*') as $dir) { + rmdir($dir); + } + rmdir($this->uploadsRoot); + } + } + + public function testItRejectsARequestWithAMissingCsrfField(): void + { + $handler = new PostImageUpload($this->uploadsRoot); + $request = new Request([], [], [], [], [], '', 'POST', '/assets/upload', 'multipart/form-data', ''); + $response = $handler->handle($request, []); + + self::assertSame(403, $response->status); + } + + public function testItAcceptsAValidImageAndReturnsAUrl(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostImageUpload($this->uploadsRoot); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, $this->validPng(8, 8)); + $request = new Request( + get: [], + post: ['_csrf' => $token], + files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 70, 'type' => 'image/png']], + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + queryString: '', + method: 'POST', + path: '/assets/upload', + contentType: 'multipart/form-data', + rawBody: '', + ); + $response = $handler->handle($request, []); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertStringStartsWith('/assets/', $body['url'] ?? ''); + self::assertStringEndsWith('.png', $body['url'] ?? ''); + } + + public function testItReturns400OnAnInvalidImage(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostImageUpload($this->uploadsRoot); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, 'not an image'); + $request = new Request( + get: [], + post: ['_csrf' => $token], + files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 12, 'type' => 'image/png']], + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + queryString: '', + method: 'POST', + path: '/assets/upload', + contentType: 'multipart/form-data', + rawBody: '', + ); + $response = $handler->handle($request, []); + + self::assertSame(400, $response->status); + } + + private function validPng(int $width, int $height): string + { + if ($width === 8 && $height === 8) { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' . + 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC', + true, + ); + } + throw new \InvalidArgumentException('Only 8x8 base PNG supported.'); + } +} diff --git a/tests/Integration/PostStartMatchTest.php b/tests/Integration/PostStartMatchTest.php new file mode 100644 index 0000000..1229f9c --- /dev/null +++ b/tests/Integration/PostStartMatchTest.php @@ -0,0 +1,96 @@ +buildRequest('{}', ''); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + } + + public function testItAcceptsAValidScenarioAndReturnsTheInitialMatch(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostStartMatch(); + $request = $this->buildRequest( + rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame('alpha', $body['match']['activeTeamId'] ?? null); + self::assertSame(1, $body['match']['round'] ?? null); + self::assertCount(6, $body['match']['units'] ?? []); + } + + public function testItReturns400OnAnInvalidScenario(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostStartMatch(); + $request = $this->buildRequest( + rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(400, $response->status); + } + + /** @param array $cookies */ + private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request + { + $server = [ + 'HTTP_X_CSRF_TOKEN' => $csrfHeader, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ]; + + return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/start', 'application/json', $rawBody); + } + + /** @return array */ + private function validScenario(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0], + ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0], + ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7], + ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7], + ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } +} diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php new file mode 100644 index 0000000..9a6fb0c --- /dev/null +++ b/tests/Integration/PostTeamEditorTest.php @@ -0,0 +1,127 @@ +buildRequest(post: ['id' => 'demo']); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + self::assertStringContainsString('Forbidden', $response->body); + } + + public function testItRejectsARequestWithAnInvalidCsrfToken(): void + { + $handler = new PostTeamEditor(); + $request = $this->buildRequest(post: ['id' => 'demo', '_csrf' => 'tampered']); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + } + + public function testItSavesAValidScenarioAndReturnsTheLocalStorageSnippet(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $request = $this->buildRequest( + post: $this->validPost($token), + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('localStorage.setItem', $response->body); + self::assertStringContainsString('scenario:"demo"', $response->body); + } + + public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $post = $this->validPost($token); + $post['teamA']['units'][0]['maxHealth'] = '9999'; + $request = $this->buildRequest( + post: $post, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('bf-errors', $response->body); + self::assertStringContainsString('outside archetype bounds', $response->body); + } + + public function testItEscapesAMaliciousIdSoItCannotBreakOutOfTheScriptBlock(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $post = $this->validPost($token); + $post['id'] = ''; + $request = $this->buildRequest( + post: $post, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => $post['id']]); + + self::assertSame(200, $response->status); + // The literal payload that would execute must NOT appear verbatim in the body. + self::assertStringNotContainsString('', $response->body); + // The JSON-encoded id is what we want to see, with the