From eed678e688783d71ed84524d1e62c8e0a908afae Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 17:22:46 -0500 Subject: [PATCH 01/11] chore: add web toolchain and infrastructure for Plan 3 --- .gitignore | 12 +++++++++++- phpcs.xml | 1 + phpstan.neon | 3 +++ phpunit.xml | 7 +++++-- public/assets/styles.css | 21 +++++++++++++++++++++ public/index.php | 9 +++++++++ var/uploads/.htaccess | 2 ++ var/uploads/index.php | 7 +++++++ 8 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 public/assets/styles.css create mode 100644 public/index.php create mode 100644 var/uploads/.htaccess create mode 100644 var/uploads/index.php diff --git a/.gitignore b/.gitignore index 3cbc314..f946a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ .worktrees/ /vendor/ -/var/ +/var/cache/ +/var/phpunit/ +/var/phpstan/ +/var/logs/ +/var/uploads/* +!/var/uploads/.htaccess +!/var/uploads/index.php +/node_modules/ +/public/js/*.map +.phpunit.result.cache +.phpunit.cache diff --git a/phpcs.xml b/phpcs.xml index 367f237..a8df2a9 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -4,6 +4,7 @@ src tests + public/index.php diff --git a/phpstan.neon b/phpstan.neon index e7662a4..0d38a3b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,4 +3,7 @@ parameters: paths: - src - tests + - public/index.php + excludePaths: + - src/Views/* tmpDir: var/phpstan diff --git a/phpunit.xml b/phpunit.xml index cf65ccf..dd70c0c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,8 +5,11 @@ cacheDirectory="var/phpunit" colors="true"> - - tests + + tests/Unit + + + tests/Integration diff --git a/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..f443cb8 --- /dev/null +++ b/public/assets/styles.css @@ -0,0 +1,21 @@ +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; font-family: sans-serif; } +body { padding: 1rem; max-width: 64rem; margin: 0 auto; } +fieldset { margin: 0 0 1rem 0; padding: 0.5rem 1rem; } +legend { font-weight: bold; } +label { display: block; margin: 0.25rem 0; } +input, select, button, textarea { font: inherit; } +input[type="number"] { width: 5rem; } +table.bf-grid { border-collapse: collapse; } +table.bf-grid td { padding: 0; } +table.bf-grid button { width: 2rem; height: 2rem; border: 1px solid #ccc; background: #fff; } +table.bf-grid button[data-paint="open"] { background: #fff; } +table.bf-grid button[data-paint="forest"] { background: #cfc; } +table.bf-grid button[data-paint="rough"] { background: #fec; } +table.bf-grid button[data-paint="water"] { background: #cce; } +table.bf-grid button[data-paint="blocking"] { background: #444; color: #fff; } +table.bf-grid button[data-objective="1"] { outline: 3px solid #c00; } +table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; } +table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; } +.bf-errors { color: #c00; } +.bf-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; } diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..0845a87 --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ + Date: Mon, 6 Jul 2026 17:24:41 -0500 Subject: [PATCH 02/11] chore: track tests/Integration directory with placeholder The phpunit.xml split into unit + integration testsuites requires the integration directory to exist on disk, even when empty. The .gitkeep keeps the directory present in fresh checkouts and on CI; the first real integration test in a later task will replace the placeholder. --- tests/Integration/.gitkeep | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/Integration/.gitkeep diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 0000000..fa36e81 --- /dev/null +++ b/tests/Integration/.gitkeep @@ -0,0 +1,6 @@ + Date: Mon, 6 Jul 2026 17:35:58 -0500 Subject: [PATCH 03/11] feat: add output escaping helpers --- src/Http/Escape.php | 27 ++++++++++++++ tests/Unit/Http/EscapeTest.php | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/Http/Escape.php create mode 100644 tests/Unit/Http/EscapeTest.php diff --git a/src/Http/Escape.php b/src/Http/Escape.php new file mode 100644 index 0000000..69ea427 --- /dev/null +++ b/src/Http/Escape.php @@ -0,0 +1,27 @@ + + */ + public static function htmlProvider(): iterable + { + yield 'plain' => ['hello', 'hello']; + yield 'less-than' => [' + + +HTML; + + // The CSRF token placeholder is filled by the front controller (Task 16) + // before the body is sent. The handler does not see the cookie or the + // secret; it just receives a pre-issued token from the front controller. + $token = $request->cookies['__csrf'] ?? ''; + $body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body); + + return Response::html(200, $body); + } +} diff --git a/tests/Integration/GetAssetsTest.php b/tests/Integration/GetAssetsTest.php new file mode 100644 index 0000000..31f6bf6 --- /dev/null +++ b/tests/Integration/GetAssetsTest.php @@ -0,0 +1,70 @@ +placeholderDir = sys_get_temp_dir() . '/bf-placeholders-' . bin2hex(random_bytes(4)); + mkdir($this->placeholderDir, 0755, true); + // 1x1 red PNG + $png = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', + true, + ); + file_put_contents($this->placeholderDir . '/defender.png', $png); + + $this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4)); + mkdir($this->uploadsDir, 0700, true); + } + + protected function tearDown(): void + { + if (is_dir($this->uploadsDir)) { + foreach (glob($this->uploadsDir . '/*/*') as $file) { + unlink($file); + } + foreach (glob($this->uploadsDir . '/*') as $dir) { + rmdir($dir); + } + rmdir($this->uploadsDir); + } + + if (is_dir($this->placeholderDir)) { + foreach (glob($this->placeholderDir . '/*') as $file) { + unlink($file); + } + rmdir($this->placeholderDir); + } + } + + public function testItServesAPlaceholderImageWithoutAuth(): void + { + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, ''); + $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']); + + self::assertSame(200, $response->status); + self::assertSame('image/png', $response->headers['Content-Type'] ?? ''); + self::assertGreaterThan(0, strlen($response->body)); + } + + public function testItReturns404ForAMissingFile(): void + { + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, ''); + $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']); + + self::assertSame(404, $response->status); + } +} diff --git a/tests/Integration/GetHomePageTest.php b/tests/Integration/GetHomePageTest.php new file mode 100644 index 0000000..ac85680 --- /dev/null +++ b/tests/Integration/GetHomePageTest.php @@ -0,0 +1,26 @@ +handle($request, []); + + self::assertSame(200, $response->status); + self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? ''); + self::assertSame('nosniff', $response->headers['X-Content-Type-Options']); + self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']); + self::assertStringContainsString('', $response->body); + } +}