Files
BattleForge/tests/Integration/FullFlowTest.php
T
Keith Solomon d15dfc6835 fix: escape scenario id when serialising saved page
PostTeamEditor interpolated the scenario id into a <script> block as
'localStorage.setItem("scenario:<id>", ...)', so an id containing
'</script><script>...</script>' would break out of the JS-string context
and execute in the browser. Use json_encode with JSON_HEX_TAG / HEX_AMP /
HEX_APOS / HEX_QUOT so the id is always a safe JS string literal.

Adds a PostTeamEditorTest case that submits '</script><script>alert(1)</script>'
and asserts the literal '<script>alert(1)</script>' substring is absent
from the response. Updates the existing happy-path and FullFlowTest
assertions to match the new 'scenario:"demo"' (quoted) form.
2026-07-06 23:22:42 -05:00

227 lines
8.3 KiB
PHP

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