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; } }