From 2dc7bc1130c5041deb636520029247c24a7a43c0 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:18:27 -0500 Subject: [PATCH] feat: add image upload and start-match handlers --- src/Http/Handlers/PostImageUpload.php | 56 ++++++++++++ src/Http/Handlers/PostStartMatch.php | 41 +++++++++ tests/Integration/PostImageUploadTest.php | 105 ++++++++++++++++++++++ tests/Integration/PostStartMatchTest.php | 96 ++++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 src/Http/Handlers/PostImageUpload.php create mode 100644 src/Http/Handlers/PostStartMatch.php create mode 100644 tests/Integration/PostImageUploadTest.php create mode 100644 tests/Integration/PostStartMatchTest.php 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/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, + ]; + } +}