Merge feature/editor-handlers-and-views into develop

Plan 3b: editor handlers, views, and front controller.

Adds the 6 view templates (layout, home, team-editor, battlefield-editor,
match-stub deleted, upload-result deleted), the 5 remaining HTTP handlers
(GetTeamEditor, PostTeamEditor, GetBattlefieldEditor, PostBattlefieldEditor,
PostImageUpload, PostStartMatch), the real front controller in
public/index.php, and the FullFlowTest integration test. The final
whole-branch review found 3 Critical issues (broken upload-asset URL contract,
upload token source mismatch, and an XSS in the PostTeamEditor success page)
plus 2 Important issues (dead home.php/match-stub.php/upload-result.php);
all were fixed in 4 follow-up commits before this merge.

196/196 tests pass; PHPStan level 6 clean; PHPCS 0 errors on new files.
The web app is fully functional from the browser: php -S 0.0.0.0:8000 -t public
serves the home page, team editor, battlefield editor, image upload endpoint,
and asset-serving endpoint end-to-end.
This commit is contained in:
Keith Solomon
2026-07-07 10:11:27 -05:00
23 changed files with 1391 additions and 32 deletions
+133 -5
View File
@@ -2,8 +2,136 @@
declare(strict_types=1);
// Front controller. The router and handler dispatch land in Task 13.
// For now this returns a 200 with a stub so the dev server is reachable.
http_response_code(200);
header('Content-Type: text/plain; charset=utf-8');
echo "BattleForge dev server up.\n";
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\GetAssets;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Handlers\GetHomePage;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Router;
require __DIR__ . '/../vendor/autoload.php';
// 1. Read the app secret.
$secret = getenv('BATTLEFORGE_SECRET');
if ($secret === false || $secret === '') {
$secretFile = __DIR__ . '/../var/secret.key';
if (!is_file($secretFile)) {
if (!is_dir(dirname($secretFile))) {
mkdir(dirname($secretFile), 0700, true);
}
file_put_contents($secretFile, random_bytes(32));
chmod($secretFile, 0600);
}
$secret = file_get_contents($secretFile);
if ($secret === false) {
http_response_code(500);
echo "Failed to read app secret.\n";
return;
}
}
// 2. Ensure the __csrf cookie is set. If absent, issue a fresh token.
$cookies = $_COOKIE;
$existingCookie = (string) ($cookies['__csrf'] ?? '');
if ($existingCookie === '') {
[$token, $cookie] = CsrfToken::issue($secret);
setcookie('__csrf', $cookie, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
$existingCookie = $cookie;
$cookies['__csrf'] = $cookie;
}
// 3. Compute the upload-endpoint user token (derived from the same secret).
$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads');
// 4. Determine the request method, path, and content type.
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? '');
$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null;
if ($contentType !== null) {
$semicolon = strpos($contentType, ';');
if ($semicolon !== false) {
$contentType = substr($contentType, 0, $semicolon);
}
$contentType = trim($contentType);
}
// 5. Read the raw body for fetch POSTs that submit JSON.
$rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input'));
// 6. Build a Request with the request data, the CSRF secret, and the upload token.
$server = $_SERVER;
if (!isset($server['__csrf_secret'])) {
$server['__csrf_secret'] = $secret;
}
$server['__uploads_token'] = $uploadsToken;
$request = new Request(
get: $_GET,
post: $_POST,
files: $_FILES,
cookies: $cookies,
server: $server,
queryString: $queryString,
method: $method,
path: $path,
contentType: $contentType,
rawBody: $rawBody,
);
// 7. Configure the router with the eight routes.
$uploadsRoot = __DIR__ . '/../var/uploads';
$placeholderDir = __DIR__ . '/assets/placeholders';
$homePage = static fn (Request $r, array $p): Response => (new GetHomePage())->handle($r, $p);
$getTeam = static fn (Request $r, array $p): Response => (new GetTeamEditor())->handle($r, $p);
$postTeam = static fn (Request $r, array $p): Response => (new PostTeamEditor())->handle($r, $p);
$getBattlefield = static fn (Request $r, array $p): Response => (new GetBattlefieldEditor())->handle($r, $p);
$postBattlefield = static fn (Request $r, array $p): Response => (new PostBattlefieldEditor())->handle($r, $p);
$postStart = static fn (Request $r, array $p): Response => (new PostStartMatch())->handle($r, $p);
$postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload($uploadsRoot))->handle($r, $p);
$getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p);
};
$getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
$handler = new GetAssets($placeholderDir, $uploadsRoot);
return $handler->handle(
$r,
['kind' => 'uploads', 'userToken' => $p['userToken'] ?? '', 'filename' => $p['filename'] ?? ''],
);
};
$router = new Router();
$router->add('GET', '/', $homePage);
$router->add('GET', '/scenarios/{id}/edit/team', $getTeam);
$router->add('POST', '/scenarios/{id}/edit/team', $postTeam);
$router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield);
$router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield);
$router->add('POST', '/scenarios/{id}/start', $postStart);
$router->add('POST', '/assets/upload', $postUpload);
$router->add('GET', '/assets/uploads/{userToken}/{filename}', $getUploadedAsset);
$router->add('GET', '/assets/{kind}/{filename}', $getAssets);
// 8. Dispatch and emit.
$response = $router->dispatch($request);
http_response_code($response->status);
foreach ($response->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $response->body;
return $response->status;
+1 -1
View File
@@ -33,6 +33,6 @@ final class ImageUploadService
// file itself to 0600 in case the server's umask is permissive.
chmod($destination, 0600);
return '/assets/' . $this->userToken . '/' . $filename;
return '/assets/uploads/' . $this->userToken . '/' . $filename;
}
}
+6 -2
View File
@@ -27,9 +27,13 @@ final class GetAssets
if ($kind === 'uploads') {
$userToken = $params['userToken'] ?? '';
$requestToken = $request->cookies['__uploads_token'] ?? '';
if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) {
return Response::html(404, '<h1>Not found</h1>');
}
if ($userToken === '' || $userToken !== $requestToken) {
$requestToken = (string) ($request->server['__uploads_token'] ?? '');
if ($userToken !== $requestToken) {
return Response::html(404, '<h1>Not found</h1>');
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetBattlefieldEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$scenarioId = $params['id'] ?? 'new';
$width = 8;
$height = 8;
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/battlefield-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
+5 -23
View File
@@ -12,30 +12,12 @@ 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;
$csrf = $request->cookies['__csrf'] ?? '';
// The CSRF token placeholder is filled by the front controller (Task 16)
// before the body is sent. The handler does not see the cookie or the
// secret; it just receives a pre-issued token from the front controller.
$token = $request->cookies['__csrf'] ?? '';
$body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body);
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/home.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetTeamEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$csrf = $request->cookies['__csrf'] ?? '';
$scenarioId = $params['id'] ?? 'new';
$error = null;
$old = [];
$post = [];
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/team-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostBattlefieldEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
try {
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]);
}
try {
$draft = ScenarioDraft::fromPost($payload);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
} catch (\InvalidArgumentException $exception) {
return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]);
}
return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ImageUploadService;
use BattleForge\Application\InvalidImageException;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostImageUpload
{
public function __construct(private readonly string $uploadsRoot)
{
}
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->post['_csrf'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
$file = $request->files['image'] ?? null;
if ($file === null) {
return Response::json(400, ['error' => 'No image uploaded.']);
}
$tempPath = (string) ($file['tmp_name'] ?? '');
$declaredMime = (string) ($file['type'] ?? '');
if ($tempPath === '' || !is_file($tempPath)) {
// Production: $request->files['image']['tmp_name'] is PHP-set from the multipart body,
// so is_file() confirms the temp file exists. is_uploaded_file() is stricter but
// unsuitable for unit tests that synthesize tmp files via tempnam().
return Response::json(400, ['error' => 'Upload is not a file.']);
}
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
try {
$service = new ImageUploadService($userToken, $this->uploadsRoot);
$url = $service->store($tempPath, $declaredMime);
} catch (InvalidImageException $exception) {
return Response::json(400, ['error' => $exception->getMessage()]);
}
return Response::json(200, ['url' => $url]);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostStartMatch
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::json(403, ['error' => 'csrf']);
}
try {
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
$draft = ScenarioDraft::fromPost($payload);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
$match = $scenario->startMatch('alpha');
} catch (\JsonException $exception) {
return Response::json(400, ['errors' => ['Malformed JSON.']]);
} catch (\InvalidArgumentException $exception) {
return Response::json(400, ['errors' => [$exception->getMessage()]]);
}
return Response::json(200, ['match' => ScenarioSerializer::matchToArray($match)]);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioDraft;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Escape;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostTeamEditor
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$submitted = (string) ($request->post['_csrf'] ?? '');
$expected = (string) ($request->cookies['__csrf'] ?? '');
$secret = (string) ($request->server['__csrf_secret'] ?? '');
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
return Response::html(403, '<h1>Forbidden</h1>');
}
try {
$draft = ScenarioDraft::fromPost($request->post);
$scenario = $draft->toScenario();
ScenarioValidator::validate($scenario);
} catch (\InvalidArgumentException $exception) {
return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post);
}
$json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
$url = (string) ($params['id'] ?? $scenario->id);
$safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$body = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Saved</title></head>
<body>
<p>Scenario saved.</p>
<script type="module">
localStorage.setItem('scenario:{$safeUrl}', $json);
</script>
</body>
</html>
HTML;
return Response::html(200, $body);
}
/** @param array<string, mixed> $post */
private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response
{
$error = Escape::html($error);
$csrf = Escape::html($csrf);
$scenarioId = Escape::html($scenarioId);
$old = array_intersect_key($post, array_flip([
'id',
'name',
'battlefieldWidth',
'battlefieldHeight',
'victoryCondition',
'holdRoundsRequired',
]));
$old = array_map(static fn ($v): string => is_scalar($v) ? (string) $v : '', $old);
$teamA = $post['teamA']['units'] ?? [];
$teamB = $post['teamB']['units'] ?? [];
ob_start();
require_once __DIR__ . '/../../Views/layout.php';
require __DIR__ . '/../../Views/team-editor.php';
$body = (string) ob_get_clean();
return Response::html(200, $body);
}
/** @return array<string, mixed> */
private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array
{
return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var string $scenarioId Provided by the front controller from the URL.
* @var int $width Battlefield width (8-16).
* @var int $height Battlefield height (8-16).
*/
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
$terrain = ['open', 'forest', 'rough', 'water', 'blocking'];
?><?php
render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $height, $terrain): void {
?>
<h1>Battlefield editor</h1>
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Battlefield</legend>
<label>Width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e((string) $width) ?>" required></label>
<label>Height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e((string) $height) ?>" required></label>
</fieldset>
<fieldset>
<legend>Terrain palette</legend>
<?php foreach ($terrain as $t) : ?>
<button type="button" data-paint="<?= $a($t) ?>" class="bf-paint"><?= $e($t) ?></button>
<?php endforeach; ?>
</fieldset>
<fieldset>
<legend>Grid (<?= $e((string) $width) ?> × <?= $e((string) $height) ?>)</legend>
<table class="bf-grid" id="bf-grid">
<?php for ($y = 0; $y < $height; $y++) : ?>
<tr>
<?php for ($x = 0; $x < $width; $x++) : ?>
<td><button type="button" class="bf-tile" data-x="<?= $e((string) $x) ?>" data-y="<?= $e((string) $y) ?>" data-paint="open">.</button></td>
<?php endfor; ?>
</tr>
<?php endfor; ?>
</table>
</fieldset>
<fieldset>
<legend>Deployment zones</legend>
<label><input type="radio" name="zoneMode" value="alpha" checked> Place team A zones</label>
<label><input type="radio" name="zoneMode" value="bravo"> Place team B zones</label>
<label><input type="radio" name="zoneMode" value="objective"> Place objective</label>
</fieldset>
<button type="submit">Save</button>
</form>
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
<?php
}, $csrf, 'Battlefield editor');
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
?><?php
render_layout(static function () use ($csrf): void {
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?>
<h1>BattleForge</h1>
<p><a href="<?= $a('/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="<?= $a('/js/storage.js') ?>"></script>
<?php
}, $csrf, 'BattleForge');
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/**
* Render the shared document chrome around a body closure.
*
* @param callable(): void $body Emits the template's body content.
* @param string $csrf The pre-issued CSRF token (HMAC-verified by the front controller).
* @param string $title Page title; HTML-escaped by the layout.
* @param string $extraHead Optional extra `<head>` content (e.g. a per-template script tag).
*/
function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void
{
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($title) ?></title>
<link rel="stylesheet" href="<?= $a('/assets/styles.css') ?>">
<meta name="csrf-token" content="<?= $a($csrf) ?>">
<?= $extraHead /* trusted raw HTML for the optional extra-head block */ ?>
</head>
<body>
<?php $body(); ?>
</body>
</html>
<?php
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Http\Escape;
/**
* @var string $csrf Provided by the front controller.
* @var ?string $error Optional validator error message; HTML-escaped on output.
* @var array<string, string> $old Optional old form values, keyed by field name; HTML-escaped on output.
* @var array<string, mixed> $post Raw form data (for repopulating per-unit fields).
*/
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
$archetypes = ArchetypeCatalog::templates();
?><?php
render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old, $post): void {
?>
<h1>Team editor</h1>
<?php if ($error !== null) : ?>
<div class="bf-errors"><p><?= $e($error) ?></p></div>
<?php endif; ?>
<form method="post" action="<?= $a('/scenarios/' . $e($old['id'] ?? 'new') . '/edit/team') ?>" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
<fieldset>
<legend>Scenario</legend>
<label>Id: <input type="text" name="id" value="<?= $e($old['id'] ?? '') ?>" required></label>
<label>Name: <input type="text" name="name" value="<?= $e($old['name'] ?? '') ?>" required></label>
<label>Battlefield width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e($old['battlefieldWidth'] ?? '8') ?>" required></label>
<label>Battlefield height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e($old['battlefieldHeight'] ?? '8') ?>" required></label>
</fieldset>
<fieldset>
<legend>Team A</legend>
<?php for ($i = 0; $i < 3; $i++) :
$row = $post['teamA']['units'][$i] ?? null; ?>
<div class="bf-unit">
<input type="text" name="teamA[units][<?= $i ?>][id]" placeholder="a-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
<select name="teamA[units][<?= $i ?>][archetype]" required>
<?php foreach ($archetypes as $key => $template) : ?>
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="teamA[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
<input type="number" name="teamA[units][<?= $i ?>][attack]" placeholder="attack" required>
<input type="number" name="teamA[units][<?= $i ?>][defense]" placeholder="defense" required>
<input type="number" name="teamA[units][<?= $i ?>][speed]" placeholder="speed" required>
<input type="text" name="teamA[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '0') ?>" required>
<input type="text" name="teamA[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '0') ?>" required>
<input type="file" name="teamA[units][<?= $i ?>][image]" accept="image/*">
<input type="hidden" name="teamA[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
</div>
<?php endfor; ?>
</fieldset>
<fieldset>
<legend>Team B</legend>
<?php for ($i = 0; $i < 3; $i++) :
$row = $post['teamB']['units'][$i] ?? null; ?>
<div class="bf-unit">
<input type="text" name="teamB[units][<?= $i ?>][id]" placeholder="b-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
<select name="teamB[units][<?= $i ?>][archetype]" required>
<?php foreach ($archetypes as $key => $template) : ?>
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
<?php endforeach; ?>
</select>
<input type="number" name="teamB[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
<input type="number" name="teamB[units][<?= $i ?>][attack]" placeholder="attack" required>
<input type="number" name="teamB[units][<?= $i ?>][defense]" placeholder="defense" required>
<input type="number" name="teamB[units][<?= $i ?>][speed]" placeholder="speed" required>
<input type="text" name="teamB[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '7') ?>" required>
<input type="text" name="teamB[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '7') ?>" required>
<input type="file" name="teamB[units][<?= $i ?>][image]" accept="image/*">
<input type="hidden" name="teamB[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
</div>
<?php endfor; ?>
</fieldset>
<fieldset>
<legend>Victory</legend>
<label><input type="radio" name="victoryCondition" value="eliminate_all"<?= (($old['victoryCondition'] ?? 'eliminate_all') === 'eliminate_all') ? ' checked' : '' ?>> Eliminate all</label>
<label><input type="radio" name="victoryCondition" value="hold_objective"<?= (($old['victoryCondition'] ?? '') === 'hold_objective') ? ' checked' : '' ?>> Hold objective</label>
<label>Hold rounds: <input type="number" name="holdRoundsRequired" min="1" max="10" value="<?= $e($old['holdRoundsRequired'] ?? '1') ?>"></label>
</fieldset>
<button type="submit">Save</button>
</form>
<?php
}, $csrf, 'Team editor');
+226
View File
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use PHPUnit\Framework\TestCase;
final class FullFlowTest extends TestCase
{
private const SECRET = 'unit-test-secret';
protected function setUp(): void
{
// Reset superglobals between requests.
$_GET = [];
$_POST = [];
$_FILES = [];
$_COOKIE = [];
$_SERVER = [];
}
public function testTheFullCreateAndSaveFlow(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
// Use a deterministic secret so the upload token HMAC matches between upload and fetch.
putenv('BATTLEFORGE_SECRET=' . self::SECRET);
try {
// Step 1: GET home page.
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/';
$homeBody = $this->runFrontController();
self::assertStringContainsString('New scenario', $homeBody);
self::assertStringContainsString('name="csrf-token"', $homeBody);
// Step 2: POST team editor.
$_COOKIE['__csrf'] = $cookie;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team';
$_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
$_SERVER['__csrf_secret'] = self::SECRET;
$_POST = $this->validTeamPost($token);
$teamBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
self::assertStringContainsString('localStorage.setItem', $teamBody);
self::assertStringContainsString('scenario:"demo"', $teamBody);
// Step 3: POST battlefield editor.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/battlefield';
$_SERVER['CONTENT_TYPE'] = 'application/json';
$_SERVER['HTTP_X_CSRF_TOKEN'] = $token;
$_POST = [];
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
$_SERVER['__raw_body'] = $this->rawBody;
$bfBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$bfJson = json_decode($bfBody, true);
self::assertSame(true, $bfJson['ok'] ?? null);
// Step 4: POST start-match.
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/scenarios/demo/start';
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
$_SERVER['__raw_body'] = $this->rawBody;
$startBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$startJson = json_decode($startBody, true);
self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null);
self::assertSame(1, $startJson['match']['round'] ?? null);
self::assertCount(6, $startJson['match']['units'] ?? []);
// Step 5: POST image upload, then GET the returned URL.
$this->uploadAndFetchAsset();
} finally {
// Always purge the per-install uploads dir after the test.
$uploadsRoot = __DIR__ . '/../../var/uploads';
if (is_dir($uploadsRoot)) {
foreach (glob($uploadsRoot . '/*/*') as $file) {
@unlink($file);
}
foreach (glob($uploadsRoot . '/*') as $dir) {
@rmdir($dir);
}
}
}
}
private function uploadAndFetchAsset(): void
{
$expectedToken = hash_hmac('sha256', self::SECRET, 'bf-uploads');
$expectedDir = realpath(__DIR__ . '/../../var/uploads') ?: (__DIR__ . '/../../var/uploads');
// Create the multipart body manually so we can pre-compute the file we expect to find.
$png = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
true,
);
$tmp = tempnam(sys_get_temp_dir(), 'bf-upload-');
file_put_contents($tmp, $png);
try {
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$_COOKIE['__csrf'] = $cookie;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/assets/upload';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test';
$_SERVER['__csrf_secret'] = self::SECRET;
$_POST = ['_csrf' => $token];
$_FILES = [
'image' => [
'name' => 'test.png',
'type' => 'image/png',
'tmp_name' => $tmp,
'error' => 0,
'size' => strlen($png),
],
];
$this->rawBody = '';
$_SERVER['__raw_body'] = '';
$body = $this->runFrontController();
self::assertSame(200, $this->lastStatus, 'upload response: ' . $body);
$payload = json_decode($body, true);
$url = is_array($payload) ? (string) ($payload['url'] ?? '') : '';
self::assertNotSame('', $url);
self::assertStringStartsWith('/assets/uploads/' . $expectedToken . '/', $url);
// Now fetch the asset back through the front controller.
$_COOKIE = [];
$_POST = [];
$_FILES = [];
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = $url;
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['__raw_body']);
$this->rawBody = '';
$fetchBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus, 'fetch response: ' . $fetchBody);
self::assertSame($png, $fetchBody);
// Clean up the uploaded file so the outer finally does not loop on it.
$filename = substr($url, strlen('/assets/uploads/' . $expectedToken . '/'));
@unlink($expectedDir . '/' . $expectedToken . '/' . $filename);
} finally {
@unlink($tmp);
}
}
private string $rawBody = '';
private int $lastStatus = 0;
private function runFrontController(): string
{
$this->lastStatus = 0;
$body = $this->captureOutput(function (): void {
$this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code();
});
return $body;
}
/** @param callable(): void $fn */
private function captureOutput(callable $fn): string
{
ob_start();
try {
$fn();
} finally {
$output = (string) ob_get_clean();
}
return $output;
}
/** @return array<string, mixed> */
private function validTeamPost(string $token): array
{
return [
'_csrf' => $token,
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => '8',
'battlefieldHeight' => '8',
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => '1',
];
}
/** @return array<string, mixed> */
private function validBattlefieldPayload(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
/**
* @return list<array<string, string|int>>
*/
private function unitRows(string $teamPrefix, int $xStart, int $xEnd, int $y): array
{
$rows = [];
for ($i = 0; $i <= $xEnd - $xStart; $i++) {
$rows[] = [
'id' => "{$teamPrefix}{$i}",
'archetype' => 'defender',
'maxHealth' => 12,
'attack' => 3,
'defense' => 4,
'speed' => 2,
'x' => $xStart + $i,
'y' => $y,
];
}
return $rows;
}
}
+57
View File
@@ -67,4 +67,61 @@ final class GetAssetsTest extends TestCase
self::assertSame(404, $response->status);
}
public function testItServesAnUploadedAssetWhenTheServerTokenMatches(): void
{
$userToken = str_repeat('a', 64);
$namespace = $this->uploadsDir . '/' . $userToken;
mkdir($namespace, 0700, true);
$payload = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
true,
);
file_put_contents($namespace . '/demo.png', $payload);
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
$request = new Request(
[],
[],
[],
[],
['__uploads_token' => $userToken],
'',
'GET',
'/assets/uploads/' . $userToken . '/demo.png',
null,
'',
);
$response = $handler->handle(
$request,
['kind' => 'uploads', 'userToken' => $userToken, 'filename' => 'demo.png'],
);
self::assertSame(200, $response->status);
self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
self::assertSame($payload, $response->body);
}
public function testItRefusesAnUploadedAssetWhenTheServerTokenDiffers(): void
{
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
$request = new Request(
[],
[],
[],
[],
['__uploads_token' => str_repeat('a', 64)],
'',
'GET',
'/assets/uploads/abc/def.png',
null,
'',
);
$response = $handler->handle(
$request,
['kind' => 'uploads', 'userToken' => str_repeat('b', 64), 'filename' => 'def.png'],
);
self::assertSame(404, $response->status);
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBattlefieldEditorTest extends TestCase
{
public function testItReturnsTheBattlefieldEditorPageWithAnEmptyGrid(): void
{
$handler = new GetBattlefieldEditor();
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/battlefield', 'text/html', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
self::assertStringContainsString('class="bf-grid"', $response->body);
self::assertStringContainsString('name="_csrf"', $response->body);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetTeamEditorTest extends TestCase
{
public function testItReturnsTheTeamEditorPageWithSecurityHeaders(): void
{
$handler = new GetTeamEditor();
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/team', 'text/html', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
self::assertStringContainsString('name="_csrf"', $response->body);
self::assertStringContainsString('name="id"', $response->body);
self::assertStringContainsString('name="victoryCondition"', $response->body);
self::assertStringContainsString('value="defender"', $response->body);
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostBattlefieldEditorTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfHeader(): void
{
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(rawBody: '{}', csrfHeader: '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
self::assertStringContainsString('csrf', $response->body);
}
public function testItAcceptsAValidBattlefieldJsonAndReturnsOk(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(
rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame(true, $body['ok'] ?? null);
self::assertSame('demo', $body['scenario']['id'] ?? null);
}
public function testItReturns400OnAnInvalidShape(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostBattlefieldEditor();
$request = $this->buildRequest(
rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(400, $response->status);
$body = json_decode($response->body, true);
self::assertSame(false, $body['ok'] ?? null);
self::assertNotEmpty($body['errors'] ?? []);
}
/**
* @param array<string, string> $cookies
* @param array<string, string> $server
*/
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = [], array $server = []): Request
{
$server['HTTP_X_CSRF_TOKEN'] = $csrfHeader;
$server['CONTENT_TYPE'] = 'application/json';
return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/battlefield', 'application/json', $rawBody);
}
/** @return array<string, mixed> */
private function validBattlefield(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostImageUploadTest extends TestCase
{
private const SECRET = 'unit-test-secret';
private string $uploadsRoot;
protected function setUp(): void
{
$this->uploadsRoot = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4));
mkdir($this->uploadsRoot, 0700, true);
}
protected function tearDown(): void
{
if (is_dir($this->uploadsRoot)) {
foreach (glob($this->uploadsRoot . '/*/*') as $file) {
unlink($file);
}
foreach (glob($this->uploadsRoot . '/*') as $dir) {
rmdir($dir);
}
rmdir($this->uploadsRoot);
}
}
public function testItRejectsARequestWithAMissingCsrfField(): void
{
$handler = new PostImageUpload($this->uploadsRoot);
$request = new Request([], [], [], [], [], '', 'POST', '/assets/upload', 'multipart/form-data', '');
$response = $handler->handle($request, []);
self::assertSame(403, $response->status);
}
public function testItAcceptsAValidImageAndReturnsAUrl(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostImageUpload($this->uploadsRoot);
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
file_put_contents($tmp, $this->validPng(8, 8));
$request = new Request(
get: [],
post: ['_csrf' => $token],
files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 70, 'type' => 'image/png']],
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
queryString: '',
method: 'POST',
path: '/assets/upload',
contentType: 'multipart/form-data',
rawBody: '',
);
$response = $handler->handle($request, []);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertStringStartsWith('/assets/', $body['url'] ?? '');
self::assertStringEndsWith('.png', $body['url'] ?? '');
}
public function testItReturns400OnAnInvalidImage(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostImageUpload($this->uploadsRoot);
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
file_put_contents($tmp, 'not an image');
$request = new Request(
get: [],
post: ['_csrf' => $token],
files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 12, 'type' => 'image/png']],
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
queryString: '',
method: 'POST',
path: '/assets/upload',
contentType: 'multipart/form-data',
rawBody: '',
);
$response = $handler->handle($request, []);
self::assertSame(400, $response->status);
}
private function validPng(int $width, int $height): string
{
if ($width === 8 && $height === 8) {
return base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
true,
);
}
throw new \InvalidArgumentException('Only 8x8 base PNG supported.');
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostStartMatchTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfHeader(): void
{
$handler = new PostStartMatch();
$request = $this->buildRequest('{}', '');
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
}
public function testItAcceptsAValidScenarioAndReturnsTheInitialMatch(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
self::assertSame('alpha', $body['match']['activeTeamId'] ?? null);
self::assertSame(1, $body['match']['round'] ?? null);
self::assertCount(6, $body['match']['units'] ?? []);
}
public function testItReturns400OnAnInvalidScenario(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostStartMatch();
$request = $this->buildRequest(
rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR),
csrfHeader: $token,
cookies: ['__csrf' => $cookie],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(400, $response->status);
}
/** @param array<string, string> $cookies */
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request
{
$server = [
'HTTP_X_CSRF_TOKEN' => $csrfHeader,
'__csrf_secret' => self::SECRET,
'CONTENT_TYPE' => 'application/json',
];
return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/start', 'application/json', $rawBody);
}
/** @return array<string, mixed> */
private function validScenario(): array
{
return [
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => 8,
'battlefieldHeight' => 8,
'battlefieldTerrain' => [],
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => 1,
];
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Domain\Archetype;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostTeamEditorTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testItRejectsARequestWithAMissingCsrfToken(): void
{
$handler = new PostTeamEditor();
$request = $this->buildRequest(post: ['id' => 'demo']);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
self::assertStringContainsString('Forbidden', $response->body);
}
public function testItRejectsARequestWithAnInvalidCsrfToken(): void
{
$handler = new PostTeamEditor();
$request = $this->buildRequest(post: ['id' => 'demo', '_csrf' => 'tampered']);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(403, $response->status);
}
public function testItSavesAValidScenarioAndReturnsTheLocalStorageSnippet(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$request = $this->buildRequest(
post: $this->validPost($token),
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('localStorage.setItem', $response->body);
self::assertStringContainsString('scenario:"demo"', $response->body);
}
public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$post = $this->validPost($token);
$post['teamA']['units'][0]['maxHealth'] = '9999';
$request = $this->buildRequest(
post: $post,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => 'demo']);
self::assertSame(200, $response->status);
self::assertStringContainsString('bf-errors', $response->body);
self::assertStringContainsString('outside archetype bounds', $response->body);
}
public function testItEscapesAMaliciousIdSoItCannotBreakOutOfTheScriptBlock(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$handler = new PostTeamEditor();
$post = $this->validPost($token);
$post['id'] = '</script><script>alert(1)</script>';
$request = $this->buildRequest(
post: $post,
cookies: ['__csrf' => $cookie],
server: ['__csrf_secret' => self::SECRET],
);
$response = $handler->handle($request, ['id' => $post['id']]);
self::assertSame(200, $response->status);
// The literal payload that would execute must NOT appear verbatim in the body.
self::assertStringNotContainsString('<script>alert(1)</script>', $response->body);
// The JSON-encoded id is what we want to see, with the </ tag escaped to <\/.
self::assertStringContainsString('<\\/script><script>alert(1)<\\/script>', $response->body);
}
/**
* @param array<string, mixed> $post
* @param array<string, string> $cookies
* @param array<string, string> $server
*/
private function buildRequest(array $post, array $cookies = [], array $server = []): Request
{
return new Request([], $post, [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/team', 'application/x-www-form-urlencoded', '');
}
/** @return array<string, mixed> */
private function validPost(string $token): array
{
return [
'_csrf' => $token,
'id' => 'demo',
'name' => 'Demo',
'battlefieldWidth' => '8',
'battlefieldHeight' => '8',
'teamA' => [
'units' => [
['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '0', 'y' => '0'],
['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '1', 'y' => '0'],
['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '2', 'y' => '0'],
],
],
'teamB' => [
'units' => [
['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '7', 'y' => '7'],
['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '6', 'y' => '7'],
['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '5', 'y' => '7'],
],
],
'victoryCondition' => 'eliminate_all',
'holdRoundsRequired' => '1',
];
}
}
@@ -38,7 +38,7 @@ final class ImageUploadServiceTest extends TestCase
$url = $service->store($tmp, 'image/png');
self::assertStringStartsWith('/assets/user-token-1/', $url);
self::assertStringStartsWith('/assets/uploads/user-token-1/', $url);
self::assertStringEndsWith('.png', $url);
$stored = $this->tmpDir . '/user-token-1/' . basename($url);