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:
+133
-5
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user