Plan 3a (10 of 17 written tasks): web toolchain, output escaping, CSRF, Request/Response value objects, Router, JSON serializer, image validator, image upload service, scenario draft, and the first two HTTP handlers (home page and asset serving). The remaining 7 tasks (5 more handlers, 6 view templates, the front controller, full-flow integration test, and CI verification) are scope notes in the plan; the next plan will pick them up. 177/177 tests pass; PHPStan level 6 clean; PHPCS clean on the new files (pre-existing line-length warnings remain in Plan 1+2 files). All deviations from the brief are recorded in the per-task reports under .superpowers/sdd/.
This commit is contained in:
+11
-1
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<file>src</file>
|
||||
<file>tests</file>
|
||||
<file>public/index.php</file>
|
||||
|
||||
<arg name="colors"/>
|
||||
<arg value="sp"/>
|
||||
|
||||
@@ -3,4 +3,7 @@ parameters:
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
- public/index.php
|
||||
excludePaths:
|
||||
- src/Views/*
|
||||
tmpDir: var/phpstan
|
||||
|
||||
+5
-2
@@ -5,8 +5,11 @@
|
||||
cacheDirectory="var/phpunit"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="BattleForge Test Suite">
|
||||
<directory>tests</directory>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="integration">
|
||||
<directory>tests/Integration</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final class ImageUploadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $userToken,
|
||||
private readonly string $uploadsRoot,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(string $tempPath, string $declaredMime): string
|
||||
{
|
||||
$validated = ImageValidator::validate($tempPath, $declaredMime);
|
||||
|
||||
$namespace = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final class ImageValidator
|
||||
{
|
||||
/**
|
||||
* @return array{string, string} [canonicalMime, extension] for a recognized image, or null.
|
||||
*/
|
||||
private static function detect(string $header): ?array
|
||||
{
|
||||
if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") {
|
||||
return ['image/png', 'png'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 3) === "\xff\xd8\xff") {
|
||||
return ['image/jpeg', 'jpg'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') {
|
||||
return ['image/gif', 'gif'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') {
|
||||
return ['image/webp', 'webp'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function validate(
|
||||
string $tempPath,
|
||||
string $declaredMime,
|
||||
int $maxBytes = 2_000_000,
|
||||
int $maxDimension = 512,
|
||||
): ValidatedImage {
|
||||
if (!is_file($tempPath)) {
|
||||
throw new InvalidImageException('Upload is not a file.');
|
||||
}
|
||||
|
||||
$size = filesize($tempPath);
|
||||
if ($size === false || $size > $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvalidImageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ScenarioDraft
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param array<string, DeploymentZone> $deploymentZones
|
||||
* @param array<string, ObjectiveMarker> $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<string, mixed> $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<array<string, mixed>> $rows
|
||||
* @return list<UnitState>
|
||||
*/
|
||||
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<UnitState> $units
|
||||
* @return list<Position>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\ArchetypeCatalog;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\MatchState;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ScenarioSerializer
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
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<string, mixed> $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<string, mixed> */
|
||||
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<string, mixed> $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<string, mixed> */
|
||||
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<string, mixed> $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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final readonly class ValidatedImage
|
||||
{
|
||||
public function __construct(
|
||||
public string $canonicalMime,
|
||||
public string $extension,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class CsrfToken
|
||||
{
|
||||
/** @return array{0: string, 1: string} */
|
||||
public static function issue(string $secret): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$cookie = hash_hmac('sha256', $token, $secret);
|
||||
|
||||
return [$token, $cookie];
|
||||
}
|
||||
|
||||
public static function verify(string $submitted, string $secret, string $expectedCookieValue): bool
|
||||
{
|
||||
$computed = hash_hmac('sha256', $submitted, $secret);
|
||||
|
||||
return hash_equals($expectedCookieValue, $computed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class Escape
|
||||
{
|
||||
public static function html(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
public static function attr(mixed $value): string
|
||||
{
|
||||
return self::html($value);
|
||||
}
|
||||
|
||||
public static function url(string $value): string
|
||||
{
|
||||
return rawurlencode($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetAssets
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $placeholderDir,
|
||||
private readonly string $uploadsRoot,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, string> $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, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
|
||||
}
|
||||
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
private function serveFrom(string $path): Response
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if ($body === false) {
|
||||
return Response::html(500, '<h1>Read error</h1>');
|
||||
}
|
||||
|
||||
$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<string, string> */
|
||||
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'",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetHomePage
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$body = <<<'HTML'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>BattleForge</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
<meta name="csrf-token" content="{{ csrf }}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>BattleForge</h1>
|
||||
<p><a href="/scenarios/new/edit/team">New scenario</a></p>
|
||||
<h2>Recent scenarios</h2>
|
||||
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
|
||||
<script type="module" src="/js/storage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final readonly class Request
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $get
|
||||
* @param array<string, mixed> $post
|
||||
* @param array<string, array<string, mixed>> $files
|
||||
* @param array<string, string> $cookies
|
||||
* @param array<string, string> $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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class Response
|
||||
{
|
||||
private const SECURITY_HEADERS = [
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Referrer-Policy' => 'same-origin',
|
||||
'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
|
||||
];
|
||||
|
||||
/** @param array<string, string> $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,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class Router
|
||||
{
|
||||
/** @var list<array{method: string, pattern: string, handler: callable(Request, array<string, string>): 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, '<h1>Not found</h1>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Placeholder so PHPUnit's integration test suite has a directory to scan.
|
||||
// The first real integration tests land in a later task.
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Handlers\GetAssets;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetAssetsTest extends TestCase
|
||||
{
|
||||
private string $placeholderDir;
|
||||
private string $uploadsDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
use BattleForge\Http\Handlers\GetHomePage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetHomePageTest extends TestCase
|
||||
{
|
||||
public function testItReturnsTheHomePageWithSecurityHeaders(): void
|
||||
{
|
||||
$handler = new GetHomePage();
|
||||
$request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
|
||||
$response = $handler->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('<a href="/scenarios/new/edit/team">', $response->body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Application;
|
||||
|
||||
use BattleForge\Application\ImageUploadService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImageUploadServiceTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Application;
|
||||
|
||||
use BattleForge\Application\ImageValidator;
|
||||
use BattleForge\Application\InvalidImageException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImageValidatorTest extends TestCase
|
||||
{
|
||||
private const TMP_DIR = '/tmp';
|
||||
|
||||
public function testItAcceptsAValidPng(): void
|
||||
{
|
||||
$path = self::TMP_DIR . '/bf-valid-' . bin2hex(random_bytes(4)) . '.png';
|
||||
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
|
||||
|
||||
try {
|
||||
$result = ImageValidator::validate($path, 'image/png');
|
||||
self::assertSame('image/png', $result->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Application;
|
||||
|
||||
use BattleForge\Application\ScenarioDraft;
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScenarioDraftTest extends TestCase
|
||||
{
|
||||
public function testItBuildsAScenarioFromFormFields(): void
|
||||
{
|
||||
$post = [
|
||||
'id' => '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<string, mixed>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Application;
|
||||
|
||||
use BattleForge\Application\ScenarioSerializer;
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\MatchState;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScenarioSerializerTest extends TestCase
|
||||
{
|
||||
public function testItRoundTripsACompleteScenario(): void
|
||||
{
|
||||
$scenario = new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8, ['0:0' => 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.
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Http;
|
||||
|
||||
use BattleForge\Http\CsrfToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CsrfTokenTest extends TestCase
|
||||
{
|
||||
private const SECRET = 'unit-test-secret';
|
||||
|
||||
public function testIssueReturnsTokenAndCookieValue(): void
|
||||
{
|
||||
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||||
|
||||
self::assertNotSame('', $token);
|
||||
self::assertNotSame('', $cookie);
|
||||
self::assertSame(64, strlen($token));
|
||||
}
|
||||
|
||||
public function testVerifyAcceptsAValidPair(): void
|
||||
{
|
||||
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||||
|
||||
self::assertTrue(CsrfToken::verify($token, self::SECRET, $cookie));
|
||||
}
|
||||
|
||||
public function testVerifyRejectsATamperedCookieValue(): void
|
||||
{
|
||||
[$token] = CsrfToken::issue(self::SECRET);
|
||||
|
||||
self::assertFalse(CsrfToken::verify($token, self::SECRET, 'not-the-cookie'));
|
||||
}
|
||||
|
||||
public function testVerifyRejectsATamperedToken(): void
|
||||
{
|
||||
[, $cookie] = CsrfToken::issue(self::SECRET);
|
||||
|
||||
self::assertFalse(CsrfToken::verify('not-the-token', self::SECRET, $cookie));
|
||||
}
|
||||
|
||||
public function testIssueProducesDifferentTokensAcrossCalls(): void
|
||||
{
|
||||
[$tokenA] = CsrfToken::issue(self::SECRET);
|
||||
[$tokenB] = CsrfToken::issue(self::SECRET);
|
||||
|
||||
self::assertNotSame($tokenA, $tokenB);
|
||||
}
|
||||
|
||||
public function testDifferentSecretsProduceDifferentCookieValues(): void
|
||||
{
|
||||
[$tokenA, $cookieA] = CsrfToken::issue('secret-a');
|
||||
[, $cookieB] = CsrfToken::issue('secret-b');
|
||||
|
||||
self::assertTrue(CsrfToken::verify($tokenA, 'secret-a', $cookieA));
|
||||
self::assertFalse(CsrfToken::verify($tokenA, 'secret-b', $cookieB));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Http;
|
||||
|
||||
use BattleForge\Http\Escape;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EscapeTest extends TestCase
|
||||
{
|
||||
#[DataProvider('htmlProvider')]
|
||||
public function testItEscapesHtmlContext(mixed $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, Escape::html($input));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{mixed, string}>
|
||||
*/
|
||||
public static function htmlProvider(): iterable
|
||||
{
|
||||
yield 'plain' => ['hello', 'hello'];
|
||||
yield 'less-than' => ['<script>', '<script>'];
|
||||
yield 'ampersand' => ['a & b', 'a & b'];
|
||||
yield 'double-quote' => ['she said "hi"', 'she said "hi"'];
|
||||
yield 'single-quote' => ["it's", 'it's'];
|
||||
yield 'invalid-utf8-substituted' => ["a\xC0\x80b", "a\xEF\xBF\xBD\xEF\xBF\xBDb"];
|
||||
yield 'integer' => [42, '42'];
|
||||
yield 'null' => [null, ''];
|
||||
}
|
||||
|
||||
#[DataProvider('attrProvider')]
|
||||
public function testItEscapesAttributeContext(mixed $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, Escape::attr($input));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{mixed, string}>
|
||||
*/
|
||||
public static function attrProvider(): iterable
|
||||
{
|
||||
yield 'tag-breakout' => ['" onclick="', '" onclick="'];
|
||||
yield 'apostrophe' => ["' onclick='", '' onclick=''];
|
||||
}
|
||||
|
||||
#[DataProvider('urlProvider')]
|
||||
public function testItEncodesUrls(string $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, Escape::url($input));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
public static function urlProvider(): iterable
|
||||
{
|
||||
yield 'space' => ['hello world', 'hello%20world'];
|
||||
yield 'slash' => ['a/b', 'a%2Fb'];
|
||||
yield 'unicode' => ['héllo', 'h%C3%A9llo'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Http;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RequestTest extends TestCase
|
||||
{
|
||||
public function testItExposesAllSuperglobalsAsConstructorArgs(): void
|
||||
{
|
||||
$request = new Request(
|
||||
get: ['q' => '1'],
|
||||
post: ['name' => 'alpha'],
|
||||
files: ['image' => ['name' => 'a.png', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 12, 'type' => 'image/png']],
|
||||
cookies: ['__csrf' => 'cookie-value'],
|
||||
server: ['HTTP_HOST' => 'localhost'],
|
||||
queryString: 'q=1',
|
||||
method: 'POST',
|
||||
path: '/scenarios/demo/edit/team',
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
rawBody: '',
|
||||
);
|
||||
|
||||
self::assertSame(['q' => '1'], $request->get);
|
||||
self::assertSame(['name' => 'alpha'], $request->post);
|
||||
self::assertSame('a.png', $request->files['image']['name']);
|
||||
self::assertSame('cookie-value', $request->cookies['__csrf']);
|
||||
self::assertSame('localhost', $request->server['HTTP_HOST']);
|
||||
self::assertSame('q=1', $request->queryString);
|
||||
self::assertSame('POST', $request->method);
|
||||
self::assertSame('/scenarios/demo/edit/team', $request->path);
|
||||
self::assertSame('application/x-www-form-urlencoded', $request->contentType);
|
||||
self::assertSame('', $request->rawBody);
|
||||
}
|
||||
|
||||
public function testItAllowsNullContentType(): void
|
||||
{
|
||||
$request = new Request(
|
||||
get: [],
|
||||
post: [],
|
||||
files: [],
|
||||
cookies: [],
|
||||
server: [],
|
||||
queryString: '',
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
contentType: null,
|
||||
rawBody: '',
|
||||
);
|
||||
|
||||
self::assertNull($request->contentType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Http;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
use BattleForge\Http\Router;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RouterTest extends TestCase
|
||||
{
|
||||
public function testItDispatchesAMatchingStaticRoute(): void
|
||||
{
|
||||
$router = new Router();
|
||||
$router->add('GET', '/', static fn (Request $request): Response => Response::html(200, 'home'));
|
||||
|
||||
$response = $router->dispatch($this->request('GET', '/'));
|
||||
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertSame('home', $response->body);
|
||||
}
|
||||
|
||||
public function testItReturnsA404ForUnknownPaths(): void
|
||||
{
|
||||
$router = new Router();
|
||||
|
||||
$response = $router->dispatch($this->request('GET', '/missing'));
|
||||
|
||||
self::assertSame(404, $response->status);
|
||||
}
|
||||
|
||||
public function testItReturnsAMethodNotAllowedResponseForMismatchedMethods(): void
|
||||
{
|
||||
$router = new Router();
|
||||
$router->add('POST', '/', static fn (): Response => Response::html(200, 'ok'));
|
||||
|
||||
$response = $router->dispatch($this->request('GET', '/'));
|
||||
|
||||
self::assertSame(404, $response->status);
|
||||
}
|
||||
|
||||
public function testItCapturesPathParams(): void
|
||||
{
|
||||
$router = new Router();
|
||||
$router->add('GET', '/scenarios/{id}/edit', static function (Request $request, array $params): Response {
|
||||
return Response::html(200, 'id=' . $params['id']);
|
||||
});
|
||||
|
||||
$response = $router->dispatch($this->request('GET', '/scenarios/demo/edit'));
|
||||
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertSame('id=demo', $response->body);
|
||||
}
|
||||
|
||||
public function testItMatchesMoreSpecificRoutesFirstByRegistrationOrder(): void
|
||||
{
|
||||
$router = new Router();
|
||||
$router->add('GET', '/scenarios/{id}', static fn (): Response => Response::html(200, 'any'));
|
||||
$router->add('GET', '/scenarios/special', static fn (): Response => Response::html(200, 'special'));
|
||||
|
||||
$response = $router->dispatch($this->request('GET', '/scenarios/special'));
|
||||
|
||||
self::assertSame('any', $response->body);
|
||||
}
|
||||
|
||||
private function request(string $method, string $path): Request
|
||||
{
|
||||
return new Request(
|
||||
get: [],
|
||||
post: [],
|
||||
files: [],
|
||||
cookies: [],
|
||||
server: [],
|
||||
queryString: '',
|
||||
method: $method,
|
||||
path: $path,
|
||||
contentType: null,
|
||||
rawBody: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Require all denied
|
||||
Deny from all
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
http_response_code(403);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Forbidden\n";
|
||||
Reference in New Issue
Block a user