diff --git a/.gitignore b/.gitignore index 3cbc314..f946a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ .worktrees/ /vendor/ -/var/ +/var/cache/ +/var/phpunit/ +/var/phpstan/ +/var/logs/ +/var/uploads/* +!/var/uploads/.htaccess +!/var/uploads/index.php +/node_modules/ +/public/js/*.map +.phpunit.result.cache +.phpunit.cache diff --git a/phpcs.xml b/phpcs.xml index 367f237..a8df2a9 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -4,6 +4,7 @@ src tests + public/index.php diff --git a/phpstan.neon b/phpstan.neon index e7662a4..0d38a3b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,4 +3,7 @@ parameters: paths: - src - tests + - public/index.php + excludePaths: + - src/Views/* tmpDir: var/phpstan diff --git a/phpunit.xml b/phpunit.xml index cf65ccf..dd70c0c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,8 +5,11 @@ cacheDirectory="var/phpunit" colors="true"> - - tests + + tests/Unit + + + tests/Integration diff --git a/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..f443cb8 --- /dev/null +++ b/public/assets/styles.css @@ -0,0 +1,21 @@ +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; font-family: sans-serif; } +body { padding: 1rem; max-width: 64rem; margin: 0 auto; } +fieldset { margin: 0 0 1rem 0; padding: 0.5rem 1rem; } +legend { font-weight: bold; } +label { display: block; margin: 0.25rem 0; } +input, select, button, textarea { font: inherit; } +input[type="number"] { width: 5rem; } +table.bf-grid { border-collapse: collapse; } +table.bf-grid td { padding: 0; } +table.bf-grid button { width: 2rem; height: 2rem; border: 1px solid #ccc; background: #fff; } +table.bf-grid button[data-paint="open"] { background: #fff; } +table.bf-grid button[data-paint="forest"] { background: #cfc; } +table.bf-grid button[data-paint="rough"] { background: #fec; } +table.bf-grid button[data-paint="water"] { background: #cce; } +table.bf-grid button[data-paint="blocking"] { background: #444; color: #fff; } +table.bf-grid button[data-objective="1"] { outline: 3px solid #c00; } +table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; } +table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; } +.bf-errors { color: #c00; } +.bf-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; } diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..0845a87 --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ +uploadsRoot . '/' . $this->userToken; + if (!is_dir($namespace)) { + mkdir($namespace, 0700, true); + } + + $hash = bin2hex(random_bytes(16)); + $filename = $hash . '.' . $validated->extension; + $destination = $namespace . '/' . $filename; + + if (!rename($tempPath, $destination)) { + throw new InvalidImageException("Could not move upload to {$destination}."); + } + + // Lock the file down: the namespace is already 0700; tighten the + // file itself to 0600 in case the server's umask is permissive. + chmod($destination, 0600); + + return '/assets/' . $this->userToken . '/' . $filename; + } +} diff --git a/src/Application/ImageValidator.php b/src/Application/ImageValidator.php new file mode 100644 index 0000000..ee15c84 --- /dev/null +++ b/src/Application/ImageValidator.php @@ -0,0 +1,81 @@ + $maxBytes) { + throw new InvalidImageException("File exceeds the {$maxBytes} byte limit."); + } + + $handle = fopen($tempPath, 'rb'); + if ($handle === false) { + throw new InvalidImageException('Could not read upload.'); + } + $header = fread($handle, 12); + fclose($handle); + if ($header === false || strlen($header) < 12) { + throw new InvalidImageException('Upload is too small to be an image.'); + } + + $detected = self::detect($header); + if ($detected === null) { + throw new InvalidImageException('File is not a supported image type.'); + } + + [$canonical, $extension] = $detected; + + if ($declaredMime !== $canonical) { + throw new InvalidImageException('Declared MIME does not match file contents.'); + } + + $info = getimagesize($tempPath); + if ($info === false) { + throw new InvalidImageException('Image is corrupt or unreadable.'); + } + + [$width, $height] = $info; + if ($width > $maxDimension || $height > $maxDimension) { + throw new InvalidImageException("Image dimensions exceed the {$maxDimension} pixel limit."); + } + + return new ValidatedImage($canonical, $extension); + } +} diff --git a/src/Application/InvalidImageException.php b/src/Application/InvalidImageException.php new file mode 100644 index 0000000..5880d6b --- /dev/null +++ b/src/Application/InvalidImageException.php @@ -0,0 +1,11 @@ + $units + * @param array $deploymentZones + * @param array $objectives + */ + public function __construct( + public string $id, + public string $name, + public Battlefield $battlefield, + public array $units, + public array $deploymentZones, + public array $objectives, + public VictoryCondition $victoryCondition, + public int $holdRoundsRequired, + ) { + } + + public function toScenario(): Scenario + { + return new Scenario( + id: $this->id, + name: $this->name, + battlefield: $this->battlefield, + units: $this->units, + deploymentZones: $this->deploymentZones, + objectives: $this->objectives, + victoryCondition: $this->victoryCondition, + holdRoundsRequired: $this->holdRoundsRequired, + ); + } + + /** @param array $post */ + public static function fromPost(array $post): self + { + $id = self::stringField($post, 'id'); + $name = self::stringField($post, 'name'); + $width = self::intField($post, 'battlefieldWidth'); + $height = self::intField($post, 'battlefieldHeight'); + + $terrain = []; + foreach (($post['battlefieldTerrain'] ?? []) as $key => $value) { + $terrain[(string) $key] = self::terrainField((string) $value); + } + $battlefield = new Battlefield($width, $height, $terrain); + + $alphaUnits = self::parseTeamUnits($post['teamA']['units'] ?? [], 'alpha'); + $bravoUnits = self::parseTeamUnits($post['teamB']['units'] ?? [], 'bravo'); + $units = [...$alphaUnits, ...$bravoUnits]; + + // The form does not yet expose a "deployment zone" picker (3b adds it). + // For 3a we synthesize one zone per team containing the unit positions. + // When 3b lands, the editor's POST will include explicit zone tiles. + $deploymentZones = [ + 'alpha' => new DeploymentZone('alpha', self::collectPositions($alphaUnits)), + 'bravo' => new DeploymentZone('bravo', self::collectPositions($bravoUnits)), + ]; + + $victory = self::victoryField(self::stringField($post, 'victoryCondition')); + $holdRounds = self::intField($post, 'holdRoundsRequired'); + + $objectives = []; + if ($victory === VictoryCondition::HoldObjective) { + $objId = self::stringField($post, 'objectiveId'); + $objX = self::intField($post, 'objectiveX'); + $objY = self::intField($post, 'objectiveY'); + $objectives[$objId] = new ObjectiveMarker($objId, new Position($objX, $objY)); + } + + return new self( + id: $id, + name: $name, + battlefield: $battlefield, + units: $units, + deploymentZones: $deploymentZones, + objectives: $objectives, + victoryCondition: $victory, + holdRoundsRequired: $holdRounds, + ); + } + + /** + * @param list> $rows + * @return list + */ + private static function parseTeamUnits(array $rows, string $teamId): array + { + $units = []; + foreach ($rows as $index => $row) { + if (!is_array($row)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 3.) + throw new InvalidArgumentException("Team {$teamId} unit row {$index} is malformed."); + } + + $unitId = self::stringField($row, 'id'); + $archetype = self::archetypeField(self::stringField($row, 'archetype')); + $maxHealth = self::intField($row, 'maxHealth'); + $attack = self::intField($row, 'attack'); + $defense = self::intField($row, 'defense'); + $speed = self::intField($row, 'speed'); + $abilities = []; + foreach (($row['abilities'] ?? []) as $ability) { + $abilities[] = (string) $ability; + } + $position = new Position( + self::intField($row, 'x'), + self::intField($row, 'y'), + ); + + $units[] = new UnitState( + id: $unitId, + teamId: $teamId, + position: $position, + maxHealth: $maxHealth, + health: $maxHealth, + attack: $attack, + defense: $defense, + speed: $speed, + actionsRemaining: 2, + hasAttacked: false, + archetype: $archetype, + abilities: $abilities, + attackBonus: 0, + hasUsedAbility: false, + ); + } + + return $units; + } + + /** + * @param list $units + * @return list + */ + private static function collectPositions(array $units): array + { + $positions = []; + foreach ($units as $unit) { + $positions[] = $unit->position; + } + + return $positions; + } + + private static function archetypeField(string $value): Archetype + { + $archetype = Archetype::tryFrom($value); + if ($archetype === null) { + throw new InvalidArgumentException("Unknown archetype: {$value}."); + } + + return $archetype; + } + + private static function terrainField(string $value): Terrain + { + $terrain = Terrain::tryFrom($value); + if ($terrain === null) { + throw new InvalidArgumentException("Unknown terrain: {$value}."); + } + + return $terrain; + } + + private static function victoryField(string $value): VictoryCondition + { + $victory = VictoryCondition::tryFrom($value); + if ($victory === null) { + throw new InvalidArgumentException("Unknown victory condition: {$value}."); + } + + return $victory; + } + + /** + * @param array $array + */ + private static function stringField(array $array, string $key): string + { + if (!isset($array[$key]) || !is_string($array[$key]) || $array[$key] === '') { + throw new InvalidArgumentException("Field '{$key}' is required."); + } + + return $array[$key]; + } + + /** + * @param array $array + */ + private static function intField(array $array, string $key): int + { + if (!isset($array[$key])) { + throw new InvalidArgumentException("Field '{$key}' is required."); + } + + return (int) $array[$key]; + } +} diff --git a/src/Application/ScenarioSerializer.php b/src/Application/ScenarioSerializer.php new file mode 100644 index 0000000..f9c0c60 --- /dev/null +++ b/src/Application/ScenarioSerializer.php @@ -0,0 +1,270 @@ + */ + public static function scenarioToArray(Scenario $scenario): array + { + $terrainMap = []; + + for ($y = 0; $y < $scenario->battlefield->height; $y++) { + for ($x = 0; $x < $scenario->battlefield->width; $x++) { + $position = new Position($x, $y); + $tile = $scenario->battlefield->terrainAt($position); + if ($tile === Terrain::Open) { + continue; + } + $terrainMap[$position->key()] = $tile->value; + } + } + + $units = []; + foreach ($scenario->units as $unit) { + $units[] = self::unitToArray($unit); + } + + $deploymentZones = []; + foreach ($scenario->deploymentZones as $teamId => $zone) { + $deploymentZones[$teamId] = self::deploymentZoneToArray($zone); + } + + $objectives = []; + foreach ($scenario->objectives as $id => $objective) { + $objectives[$id] = ['id' => $objective->id, 'position' => self::positionToArray($objective->position)]; + } + + return [ + 'id' => $scenario->id, + 'name' => $scenario->name, + 'battlefield' => [ + 'width' => $scenario->battlefield->width, + 'height' => $scenario->battlefield->height, + 'terrain' => $terrainMap, + ], + 'units' => $units, + 'deploymentZones' => $deploymentZones, + 'objectives' => $objectives, + 'victoryCondition' => $scenario->victoryCondition->value, + 'holdRoundsRequired' => $scenario->holdRoundsRequired, + ]; + } + + /** @param array $data */ + public static function scenarioFromArray(array $data): Scenario + { + $terrainMap = []; + foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) { + $terrainMap[(string) $key] = Terrain::from((string) $value); + } + + $battlefield = new Battlefield( + (int) $data['battlefield']['width'], + (int) $data['battlefield']['height'], + $terrainMap, + ); + + $units = []; + foreach (($data['units'] ?? []) as $row) { + $units[] = self::unitFromArray($row); + } + + $deploymentZones = []; + foreach (($data['deploymentZones'] ?? []) as $teamId => $row) { + $deploymentZones[(string) $teamId] = self::deploymentZoneFromArray($row); + } + + $objectives = []; + foreach (($data['objectives'] ?? []) as $id => $row) { + $objectives[(string) $id] = new ObjectiveMarker( + (string) $row['id'], + self::positionFromArray($row['position']), + ); + } + + return new Scenario( + id: (string) $data['id'], + name: (string) $data['name'], + battlefield: $battlefield, + units: $units, + deploymentZones: $deploymentZones, + objectives: $objectives, + victoryCondition: VictoryCondition::from((string) $data['victoryCondition']), + holdRoundsRequired: (int) $data['holdRoundsRequired'], + ); + } + + /** @return array */ + public static function matchToArray(MatchState $match): array + { + $terrainMap = []; + for ($y = 0; $y < $match->battlefield->height; $y++) { + for ($x = 0; $x < $match->battlefield->width; $x++) { + $position = new Position($x, $y); + $tile = $match->battlefield->terrainAt($position); + if ($tile === Terrain::Open) { + continue; + } + $terrainMap[$position->key()] = $tile->value; + } + } + + $units = []; + foreach ($match->units as $unit) { + $units[] = self::unitToArray($unit); + } + + return [ + 'battlefield' => [ + 'width' => $match->battlefield->width, + 'height' => $match->battlefield->height, + 'terrain' => $terrainMap, + ], + 'units' => $units, + 'activeTeamId' => $match->activeTeamId, + 'round' => $match->round, + 'winnerTeamId' => $match->winnerTeamId, + 'actionLog' => $match->actionLog, + 'victoryCondition' => $match->victoryCondition->value, + 'holdRoundsRequired' => $match->holdRoundsRequired, + 'objectiveControl' => $match->objectiveControl, + ]; + } + + /** @param array $data */ + public static function matchFromArray(array $data): MatchState + { + $terrainMap = []; + foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) { + $terrainMap[(string) $key] = Terrain::from((string) $value); + } + + $battlefield = new Battlefield( + (int) $data['battlefield']['width'], + (int) $data['battlefield']['height'], + $terrainMap, + ); + + $units = []; + foreach (($data['units'] ?? []) as $row) { + $units[] = self::unitFromArray($row); + } + + return new MatchState( + battlefield: $battlefield, + units: $units, + activeTeamId: (string) $data['activeTeamId'], + round: (int) $data['round'], + winnerTeamId: $data['winnerTeamId'] ?? null, + actionLog: array_map(static fn (mixed $entry): string => (string) $entry, $data['actionLog'] ?? []), + objectives: [], + victoryCondition: VictoryCondition::from((string) $data['victoryCondition']), + holdRoundsRequired: (int) $data['holdRoundsRequired'], + objectiveControl: $data['objectiveControl'] ?? [], + ); + } + + /** @return array */ + private static function unitToArray(UnitState $unit): array + { + return [ + 'id' => $unit->id, + 'teamId' => $unit->teamId, + 'position' => self::positionToArray($unit->position), + 'maxHealth' => $unit->maxHealth, + 'health' => $unit->health, + 'attack' => $unit->attack, + 'defense' => $unit->defense, + 'speed' => $unit->speed, + 'archetype' => $unit->archetype->value, + 'abilities' => $unit->abilities, + ]; + } + + /** @param array $row */ + private static function unitFromArray(array $row): UnitState + { + $archetype = Archetype::from((string) $row['archetype']); + // Validate the ability allowlist against the catalog before constructing, + // because the runtime guard in UnitState only catches non-string entries. + $template = ArchetypeCatalog::templates()[$archetype->value] ?? null; + if ($template !== null) { + $abilities = array_map(static fn (mixed $a): string => (string) $a, $row['abilities'] ?? []); + foreach ($abilities as $ability) { + if (!in_array($ability, $template->allowedAbilities, true)) { + throw new InvalidArgumentException("Ability {$ability} is not in archetype {$archetype->value}'s allowlist."); + } + } + } + + return new UnitState( + id: (string) $row['id'], + teamId: (string) $row['teamId'], + position: self::positionFromArray($row['position']), + maxHealth: (int) $row['maxHealth'], + health: (int) $row['health'], + attack: (int) $row['attack'], + defense: (int) $row['defense'], + speed: (int) $row['speed'], + actionsRemaining: 2, + hasAttacked: false, + archetype: $archetype, + abilities: $abilities ?? [], + attackBonus: 0, + hasUsedAbility: false, + ); + } + + /** @return array */ + private static function positionToArray(Position $position): array + { + return ['x' => $position->x, 'y' => $position->y]; + } + + private static function positionFromArray(mixed $row): Position + { + if (!is_array($row)) { + throw new InvalidArgumentException('Expected position to be an array.'); + } + + return new Position((int) $row['x'], (int) $row['y']); + } + + /** @return array */ + private static function deploymentZoneToArray(DeploymentZone $zone): array + { + $positions = []; + foreach ($zone->positions as $position) { + $positions[] = self::positionToArray($position); + } + + return ['teamId' => $zone->teamId, 'positions' => $positions]; + } + + /** @param array $row */ + private static function deploymentZoneFromArray(array $row): DeploymentZone + { + $positions = []; + foreach (($row['positions'] ?? []) as $positionRow) { + $positions[] = self::positionFromArray($positionRow); + } + + return new DeploymentZone((string) $row['teamId'], $positions); + } +} diff --git a/src/Application/ValidatedImage.php b/src/Application/ValidatedImage.php new file mode 100644 index 0000000..5a9431f --- /dev/null +++ b/src/Application/ValidatedImage.php @@ -0,0 +1,14 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $kind = $params['kind'] ?? ''; + $filename = $params['filename'] ?? ''; + + if ($kind === 'placeholders') { + return $this->serveFrom($this->placeholderDir . '/' . $filename); + } + + if ($kind === 'uploads') { + $userToken = $params['userToken'] ?? ''; + $requestToken = $request->cookies['__uploads_token'] ?? ''; + + if ($userToken === '' || $userToken !== $requestToken) { + return Response::html(404, '

Not found

'); + } + + return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename); + } + + return Response::html(404, '

Not found

'); + } + + private function serveFrom(string $path): Response + { + if (!is_file($path)) { + return Response::html(404, '

Not found

'); + } + + $body = file_get_contents($path); + if ($body === false) { + return Response::html(500, '

Read error

'); + } + + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $contentType = match ($ext) { + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + default => 'application/octet-stream', + }; + + return new Response(200, ['Content-Type' => $contentType] + self::securityHeaders(), $body); + } + + /** @return array */ + private static function securityHeaders(): array + { + return [ + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'same-origin', + 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'", + ]; + } +} diff --git a/src/Http/Handlers/GetHomePage.php b/src/Http/Handlers/GetHomePage.php new file mode 100644 index 0000000..82cd9be --- /dev/null +++ b/src/Http/Handlers/GetHomePage.php @@ -0,0 +1,42 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $body = <<<'HTML' + + + + + BattleForge + + + + +

BattleForge

+

New scenario

+

Recent scenarios

+

No saved scenarios yet.

+ + + +HTML; + + // 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); + + return Response::html(200, $body); + } +} diff --git a/src/Http/Request.php b/src/Http/Request.php new file mode 100644 index 0000000..55ec066 --- /dev/null +++ b/src/Http/Request.php @@ -0,0 +1,29 @@ + $get + * @param array $post + * @param array> $files + * @param array $cookies + * @param array $server + */ + public function __construct( + public array $get, + public array $post, + public array $files, + public array $cookies, + public array $server, + public string $queryString, + public string $method, + public string $path, + public ?string $contentType, + public string $rawBody, + ) { + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php new file mode 100644 index 0000000..0a3cf56 --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,49 @@ + 'nosniff', + 'Referrer-Policy' => 'same-origin', + 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'", + ]; + + /** @param array $headers */ + public function __construct( + public int $status, + public array $headers, + public string $body, + ) { + } + + public static function html(int $status, string $body): self + { + return new self( + $status, + ['Content-Type' => 'text/html; charset=utf-8'] + self::SECURITY_HEADERS, + $body, + ); + } + + public static function json(int $status, mixed $body): self + { + return new self( + $status, + ['Content-Type' => 'application/json; charset=utf-8'] + self::SECURITY_HEADERS, + json_encode($body, JSON_THROW_ON_ERROR), + ); + } + + public static function redirect(string $location, int $status = 303): self + { + return new self( + $status, + ['Location' => $location] + self::SECURITY_HEADERS, + '', + ); + } +} diff --git a/src/Http/Router.php b/src/Http/Router.php new file mode 100644 index 0000000..2d0b54c --- /dev/null +++ b/src/Http/Router.php @@ -0,0 +1,47 @@ +): Response}> */ + private array $routes = []; + + public function add(string $method, string $path, callable $handler): void + { + $pattern = '#^' . preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path) . '$#'; + + $this->routes[] = [ + 'method' => strtoupper($method), + 'pattern' => $pattern, + 'handler' => $handler, + ]; + } + + public function dispatch(Request $request): Response + { + foreach ($this->routes as $route) { + if ($route['method'] !== $request->method) { + continue; + } + + if (preg_match($route['pattern'], $request->path, $matches) === 1) { + $params = []; + + foreach ($matches as $key => $value) { + if (is_string($key)) { + $params[$key] = $value; + } + } + + return ($route['handler'])($request, $params); + } + } + + return Response::html(404, '

Not found

'); + } +} diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 0000000..fa36e81 --- /dev/null +++ b/tests/Integration/.gitkeep @@ -0,0 +1,6 @@ +placeholderDir = sys_get_temp_dir() . '/bf-placeholders-' . bin2hex(random_bytes(4)); + mkdir($this->placeholderDir, 0755, true); + // 1x1 red PNG + $png = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', + true, + ); + file_put_contents($this->placeholderDir . '/defender.png', $png); + + $this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4)); + mkdir($this->uploadsDir, 0700, true); + } + + protected function tearDown(): void + { + if (is_dir($this->uploadsDir)) { + foreach (glob($this->uploadsDir . '/*/*') as $file) { + unlink($file); + } + foreach (glob($this->uploadsDir . '/*') as $dir) { + rmdir($dir); + } + rmdir($this->uploadsDir); + } + + if (is_dir($this->placeholderDir)) { + foreach (glob($this->placeholderDir . '/*') as $file) { + unlink($file); + } + rmdir($this->placeholderDir); + } + } + + public function testItServesAPlaceholderImageWithoutAuth(): void + { + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, ''); + $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']); + + self::assertSame(200, $response->status); + self::assertSame('image/png', $response->headers['Content-Type'] ?? ''); + self::assertGreaterThan(0, strlen($response->body)); + } + + public function testItReturns404ForAMissingFile(): void + { + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, ''); + $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']); + + self::assertSame(404, $response->status); + } +} diff --git a/tests/Integration/GetHomePageTest.php b/tests/Integration/GetHomePageTest.php new file mode 100644 index 0000000..ac85680 --- /dev/null +++ b/tests/Integration/GetHomePageTest.php @@ -0,0 +1,26 @@ +handle($request, []); + + 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('', $response->body); + } +} diff --git a/tests/Unit/Application/ImageUploadServiceTest.php b/tests/Unit/Application/ImageUploadServiceTest.php new file mode 100644 index 0000000..a80cfdd --- /dev/null +++ b/tests/Unit/Application/ImageUploadServiceTest.php @@ -0,0 +1,94 @@ +tmpDir = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4)); + mkdir($this->tmpDir, 0700, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*/*') as $file) { + unlink($file); + } + foreach (glob($this->tmpDir . '/*') as $dir) { + rmdir($dir); + } + rmdir($this->tmpDir); + } + } + + public function testItStoresAValidImageAndReturnsAStableUrl(): void + { + $service = new ImageUploadService('user-token-1', $this->tmpDir); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, self::validPng(8, 8)); + + $url = $service->store($tmp, 'image/png'); + + self::assertStringStartsWith('/assets/user-token-1/', $url); + self::assertStringEndsWith('.png', $url); + + $stored = $this->tmpDir . '/user-token-1/' . basename($url); + self::assertFileExists($stored); + } + + public function testItCreatesTheUserNamespaceDirectory(): void + { + $service = new ImageUploadService('fresh-user', $this->tmpDir); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, self::validPng(8, 8)); + + $service->store($tmp, 'image/png'); + + self::assertDirectoryExists($this->tmpDir . '/fresh-user'); + } + + public function testItProducesUniqueFilenamesForRepeatedUploads(): void + { + $service = new ImageUploadService('user-token-2', $this->tmpDir); + $tmp1 = tempnam(sys_get_temp_dir(), 'bf-up'); + $tmp2 = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp1, self::validPng(8, 8)); + file_put_contents($tmp2, self::validPng(8, 8)); + + $url1 = $service->store($tmp1, 'image/png'); + $url2 = $service->store($tmp2, 'image/png'); + + self::assertNotSame($url1, $url2); + } + + public function testItRejectsAnInvalidImage(): void + { + $service = new ImageUploadService('user-token-3', $this->tmpDir); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, 'not an image'); + + $this->expectException(\BattleForge\Application\InvalidImageException::class); + $service->store($tmp, 'image/png'); + } + + private static 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 in tests.'); + } +} diff --git a/tests/Unit/Application/ImageValidatorTest.php b/tests/Unit/Application/ImageValidatorTest.php new file mode 100644 index 0000000..422a378 --- /dev/null +++ b/tests/Unit/Application/ImageValidatorTest.php @@ -0,0 +1,180 @@ +canonicalMime); + self::assertSame('png', $result->extension); + } finally { + unlink($path); + } + } + + public function testItRejectsAFileWithTheWrongDeclaredMime(): void + { + $path = self::TMP_DIR . '/bf-bad-mime-' . bin2hex(random_bytes(4)) . '.png'; + self::assertNotFalse(file_put_contents($path, self::validPng(8, 8))); + + try { + $this->expectException(InvalidImageException::class); + $this->expectExceptionMessage('Declared MIME does not match file contents'); + ImageValidator::validate($path, 'image/jpeg'); + } finally { + unlink($path); + } + } + + public function testItRejectsTextContent(): void + { + $path = self::TMP_DIR . '/bf-text-' . bin2hex(random_bytes(4)) . '.txt'; + self::assertNotFalse(file_put_contents($path, 'this is not an image')); + + try { + $this->expectException(InvalidImageException::class); + ImageValidator::validate($path, 'image/png'); + } finally { + unlink($path); + } + } + + public function testItRejectsOversizedFiles(): void + { + $path = self::TMP_DIR . '/bf-huge-' . bin2hex(random_bytes(4)) . '.png'; + // Create a 2 MB + 1 byte file + $bytes = str_repeat('A', 2_000_001); + self::assertNotFalse(file_put_contents($path, $bytes)); + + try { + $this->expectException(InvalidImageException::class); + $this->expectExceptionMessage('exceeds the 2000000 byte limit'); + ImageValidator::validate($path, 'image/png'); + } finally { + unlink($path); + } + } + + public function testItRejectsOversizedDimensions(): void + { + $path = self::TMP_DIR . '/bf-wide-' . bin2hex(random_bytes(4)) . '.png'; + self::assertNotFalse(file_put_contents($path, self::validPng(600, 8))); + + try { + $this->expectException(InvalidImageException::class); + $this->expectExceptionMessage('exceed the 512 pixel limit'); + ImageValidator::validate($path, 'image/png'); + } finally { + unlink($path); + } + } + + public function testItAcceptsJpegWebpAndGif(): void + { + $jpeg = self::TMP_DIR . '/bf-jpeg-' . bin2hex(random_bytes(4)) . '.jpg'; + $webp = self::TMP_DIR . '/bf-webp-' . bin2hex(random_bytes(4)) . '.webp'; + $gif = self::TMP_DIR . '/bf-gif-' . bin2hex(random_bytes(4)) . '.gif'; + file_put_contents($jpeg, self::validJpeg(8, 8)); + file_put_contents($webp, self::validWebp(8, 8)); + file_put_contents($gif, self::validGif(8, 8)); + + try { + self::assertSame('jpg', ImageValidator::validate($jpeg, 'image/jpeg')->extension); + self::assertSame('webp', ImageValidator::validate($webp, 'image/webp')->extension); + self::assertSame('gif', ImageValidator::validate($gif, 'image/gif')->extension); + } finally { + unlink($jpeg); + unlink($webp); + unlink($gif); + } + } + + /** + * Build a minimal valid PNG of the given dimensions, using a pre-baked + * 8x8 transparent PNG as the base and trusting `getimagesize` to accept + * any correctly-formed PNG. We just need the file to (1) have the PNG + * magic and (2) be decodable by GD or `getimagesize`. + * + * For the oversized-dimensions test we synthesize a 600x8 PNG via + * `imagecreatetruecolor` + `imagepng` to force the IHDR to claim those + * dimensions. + */ + private static function validPng(int $width, int $height): string + { + if ($width === 8 && $height === 8) { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' . + 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC', + true, + ); + } + + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagepng($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } + + private static function validJpeg(int $width, int $height): string + { + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagejpeg($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } + + private static function validWebp(int $width, int $height): string + { + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagewebp($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } + + private static function validGif(int $width, int $height): string + { + $im = imagecreatetruecolor($width, $height); + if ($im === false) { + throw new \RuntimeException('Failed to create image.'); + } + ob_start(); + imagegif($im); + $bytes = ob_get_clean(); + imagedestroy($im); + + return (string) $bytes; + } +} diff --git a/tests/Unit/Application/ScenarioDraftTest.php b/tests/Unit/Application/ScenarioDraftTest.php new file mode 100644 index 0000000..3283938 --- /dev/null +++ b/tests/Unit/Application/ScenarioDraftTest.php @@ -0,0 +1,127 @@ + 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'battlefieldTerrain' => [ + '0:0' => 'forest', + ], + 'teamA' => [ + 'units' => [ + [ + 'id' => 'a1', + 'x' => '0', + 'y' => '0', + 'archetype' => 'defender', + 'maxHealth' => '12', + 'attack' => '3', + 'defense' => '4', + 'speed' => '2', + 'abilities' => ['buff'], + 'image' => '/assets/placeholders/defender.png', + ], + ['id' => 'a2', 'x' => '1', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ['id' => 'a3', 'x' => '2', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'x' => '7', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => ['area_damage'], 'image' => ''], + ['id' => 'b2', 'x' => '6', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ['id' => 'b3', 'x' => '5', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + + $draft = ScenarioDraft::fromPost($post); + $scenario = $draft->toScenario(); + + self::assertSame('demo', $scenario->id); + self::assertSame(8, $scenario->battlefield->width); + self::assertSame(Terrain::Forest, $scenario->battlefield->terrainAt(new Position(0, 0))); + self::assertCount(6, $scenario->units); + self::assertSame('a1', $scenario->units[0]->id); + self::assertSame(Archetype::Defender, $scenario->units[0]->archetype); + self::assertSame(['buff'], $scenario->units[0]->abilities); + self::assertSame(VictoryCondition::EliminateAll, $scenario->victoryCondition); + } + + public function testItRejectsUnknownArchetype(): void + { + $post = $this->validPost(); + $post['teamA']['units'][0]['archetype'] = 'rogue'; + + $this->expectException(\InvalidArgumentException::class); + ScenarioDraft::fromPost($post)->toScenario(); + } + + public function testItRejectsUnknownTerrain(): void + { + $post = $this->validPost(); + $post['battlefieldTerrain'] = ['0:0' => 'lava']; + + $this->expectException(\InvalidArgumentException::class); + ScenarioDraft::fromPost($post)->toScenario(); + } + + public function testItRejectsUnknownVictoryCondition(): void + { + $post = $this->validPost(); + $post['victoryCondition'] = 'first_blood'; + + $this->expectException(\InvalidArgumentException::class); + ScenarioDraft::fromPost($post)->toScenario(); + } + + /** + * @return array + */ + private function validPost(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'x' => '0', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ['id' => 'a2', 'x' => '1', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ['id' => 'a3', 'x' => '2', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'x' => '7', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ['id' => 'b2', 'x' => '6', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ['id' => 'b3', 'x' => '5', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + } +} diff --git a/tests/Unit/Application/ScenarioSerializerTest.php b/tests/Unit/Application/ScenarioSerializerTest.php new file mode 100644 index 0000000..f64b8e9 --- /dev/null +++ b/tests/Unit/Application/ScenarioSerializerTest.php @@ -0,0 +1,138 @@ + Terrain::Forest]), + units: [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, ['buff'], 0, false), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, ['area_damage'], 0, false), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + ], + deploymentZones: [ + 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]), + 'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]), + ], + objectives: [], + victoryCondition: VictoryCondition::EliminateAll, + holdRoundsRequired: 1, + ); + + $array = ScenarioSerializer::scenarioToArray($scenario); + $reconstructed = ScenarioSerializer::scenarioFromArray($array); + + self::assertSame($scenario->id, $reconstructed->id); + self::assertSame($scenario->name, $reconstructed->name); + self::assertSame($scenario->battlefield->width, $reconstructed->battlefield->width); + self::assertSame($scenario->battlefield->height, $reconstructed->battlefield->height); + self::assertSame('forest', $reconstructed->battlefield->terrainAt(new Position(0, 0))->value); + self::assertCount(6, $reconstructed->units); + self::assertSame('alpha-1', $reconstructed->units[0]->id); + self::assertSame(Archetype::Defender, $reconstructed->units[0]->archetype); + self::assertSame(['buff'], $reconstructed->units[0]->abilities); + self::assertSame(12, $reconstructed->units[0]->maxHealth); + self::assertSame(3, $reconstructed->units[0]->attack); + self::assertSame(4, $reconstructed->units[0]->defense); + self::assertSame(2, $reconstructed->units[0]->speed); + self::assertSame($scenario->victoryCondition, $reconstructed->victoryCondition); + } + + public function testItRoundTripsAHoldObjectiveScenario(): void + { + $scenario = new Scenario( + id: 'hold', + name: 'Hold', + battlefield: new Battlefield(8, 8), + units: [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + ], + deploymentZones: [ + 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]), + 'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]), + ], + objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))], + victoryCondition: VictoryCondition::HoldObjective, + holdRoundsRequired: 3, + ); + + $reconstructed = ScenarioSerializer::scenarioFromArray(ScenarioSerializer::scenarioToArray($scenario)); + + self::assertSame(VictoryCondition::HoldObjective, $reconstructed->victoryCondition); + self::assertSame(3, $reconstructed->holdRoundsRequired); + self::assertArrayHasKey('objective-1', $reconstructed->objectives); + self::assertSame('4:4', $reconstructed->objectives['objective-1']->position->key()); + } + + public function testItRoundTripsAMatchState(): void + { + $scenario = new Scenario( + id: 'demo', + name: 'Demo', + battlefield: new Battlefield(8, 8), + units: [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + ], + deploymentZones: [ + 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]), + 'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]), + ], + objectives: [], + victoryCondition: VictoryCondition::EliminateAll, + holdRoundsRequired: 1, + ); + $match = $scenario->startMatch('alpha'); + + $reconstructed = ScenarioSerializer::matchFromArray(ScenarioSerializer::matchToArray($match)); + + self::assertSame('alpha', $reconstructed->activeTeamId); + self::assertSame(1, $reconstructed->round); + self::assertCount(6, $reconstructed->units); + self::assertSame(2, $reconstructed->units[0]->actionsRemaining); + self::assertFalse($reconstructed->units[0]->hasAttacked); + self::assertSame(0, $reconstructed->units[0]->attackBonus); + } + + public function testItRejectsShapeErrors(): void + { + $this->expectException(\InvalidArgumentException::class); + + ScenarioSerializer::scenarioFromArray([ + 'id' => 'demo', + // missing 'name', 'battlefield', 'units', etc. + ]); + } +} diff --git a/tests/Unit/Http/CsrfTokenTest.php b/tests/Unit/Http/CsrfTokenTest.php new file mode 100644 index 0000000..9e635f8 --- /dev/null +++ b/tests/Unit/Http/CsrfTokenTest.php @@ -0,0 +1,60 @@ + + */ + public static function htmlProvider(): iterable + { + yield 'plain' => ['hello', 'hello']; + yield 'less-than' => ['