From 12b1547facddd6ffd435b89bd106ba1f91ffd8c4 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 21:54:59 -0500 Subject: [PATCH] feat: add team editor GET and POST handlers --- src/Http/Handlers/GetTeamEditor.php | 29 ++++++ src/Http/Handlers/PostTeamEditor.php | 86 ++++++++++++++++++ tests/Integration/GetTeamEditorTest.php | 29 ++++++ tests/Integration/PostTeamEditorTest.php | 107 +++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 src/Http/Handlers/GetTeamEditor.php create mode 100644 src/Http/Handlers/PostTeamEditor.php create mode 100644 tests/Integration/GetTeamEditorTest.php create mode 100644 tests/Integration/PostTeamEditorTest.php diff --git a/src/Http/Handlers/GetTeamEditor.php b/src/Http/Handlers/GetTeamEditor.php new file mode 100644 index 0000000..0e73d23 --- /dev/null +++ b/src/Http/Handlers/GetTeamEditor.php @@ -0,0 +1,29 @@ + $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); + } +} diff --git a/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php new file mode 100644 index 0000000..6eb286c --- /dev/null +++ b/src/Http/Handlers/PostTeamEditor.php @@ -0,0 +1,86 @@ + $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, '

Forbidden

'); + } + + 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); + $body = << + +Saved + +

Scenario saved.

+ + + +HTML; + + return Response::html(200, $body); + } + + /** @param array $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 */ + private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array + { + return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario); + } +} diff --git a/tests/Integration/GetTeamEditorTest.php b/tests/Integration/GetTeamEditorTest.php new file mode 100644 index 0000000..456573f --- /dev/null +++ b/tests/Integration/GetTeamEditorTest.php @@ -0,0 +1,29 @@ +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); + } +} diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php new file mode 100644 index 0000000..c814f9f --- /dev/null +++ b/tests/Integration/PostTeamEditorTest.php @@ -0,0 +1,107 @@ +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); + } + + /** + * @param array $post + * @param array $cookies + * @param array $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 */ + 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', + ]; + } +}