From 48e3f311586c08c1daf37011607403db4d1876c0 Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Fri, 18 Sep 2026 13:38:43 -0500 Subject: [PATCH] Add environment configuration validation --- .env.example | 185 +++++++++---------- .github/workflows/ci.yml | 13 ++ app/Commands/EnvCheck.php | 32 ++++ app/Commands/EnvDiff.php | 45 +++++ app/Commands/EnvDocs.php | 30 ++++ app/Commands/EnvDump.php | 30 ++++ app/Config/App.php | 15 ++ app/Config/EnvSchema.php | 208 ++++++++++++++++++++++ app/Config/Events.php | 24 +++ app/Exceptions/EnvValidationException.php | 15 ++ app/Libraries/AppExceptionHandler.php | 18 ++ app/Services/EnvFileManager.php | 143 +++++++++++++++ app/Services/EnvStartupGuard.php | 20 +++ app/Services/EnvValidator.php | 157 ++++++++++++++++ docker-compose.yml | 26 ++- docs/configuration.md | 38 ++++ e2e/support/environment.ts | 4 + tests/Tests/Unit/EnvFileManagerTest.php | 70 ++++++++ tests/Tests/Unit/EnvStartupGuardTest.php | 37 ++++ tests/Tests/Unit/EnvValidatorTest.php | 77 ++++++++ tests/Tests/Unit/ErrorHandlerTest.php | 22 +++ 21 files changed, 1106 insertions(+), 103 deletions(-) create mode 100644 app/Commands/EnvCheck.php create mode 100644 app/Commands/EnvDiff.php create mode 100644 app/Commands/EnvDocs.php create mode 100644 app/Commands/EnvDump.php create mode 100644 app/Config/EnvSchema.php create mode 100644 app/Exceptions/EnvValidationException.php create mode 100644 app/Services/EnvFileManager.php create mode 100644 app/Services/EnvStartupGuard.php create mode 100644 app/Services/EnvValidator.php create mode 100644 docs/configuration.md create mode 100644 tests/Tests/Unit/EnvFileManagerTest.php create mode 100644 tests/Tests/Unit/EnvStartupGuardTest.php create mode 100644 tests/Tests/Unit/EnvValidatorTest.php diff --git a/.env.example b/.env.example index a375e16..0143e5e 100644 --- a/.env.example +++ b/.env.example @@ -1,114 +1,105 @@ -# ============================================================================= -# CodeIgniter 4 Chat Application - Environment Configuration -# ============================================================================= -# Copy this file to .env and update the values for your environment. -# IMPORTANT: Never commit .env to version control! -# -# This file is used by both Docker Compose and CodeIgniter. -# ============================================================================= +# Generated by `php spark env:dump`. Do not edit by hand. +# Copy this file to .env and replace every CHANGE-ME value. -# ----------------------------------------------------------------------------- -# Application Settings -# ----------------------------------------------------------------------------- -# Environment: development, testing, or production +# Application +# Active CodeIgniter runtime environment. +# Type: enum (development, testing, production); required CI_ENVIRONMENT=development - -# Application URL (used by CodeIgniter for generating links) -# For Docker: Use the host port, not the container port +# Public application URL, including its scheme. +# Type: url; required APP_URL=http://localhost:8000 - -# Port to expose the web application (default: 8000) +# Host port exposed by the web container. +# Type: int (1–65535); optional APP_PORT=8000 -# ----------------------------------------------------------------------------- -# Database Configuration -# ----------------------------------------------------------------------------- -# This application supports both MySQL and SQLite databases. -# -# QUICK START WITH SQLITE (recommended for beginners): -# 1. Set DB_DRIVER=SQLite3 -# 2. Run: php spark migrate -# 3. That's it! No database server needed. -# -# FOR MYSQL (production or Docker): -# 1. Set DB_DRIVER=MySQLi (or leave unset, it's the default) -# 2. Configure the MySQL settings below -# 3. Run: php spark migrate -# ----------------------------------------------------------------------------- - -# Database driver: MySQLi (default) or SQLite3 -# Set to SQLite3 for easy local development without a database server +# Database +# Database driver used by the application. +# Type: enum (MySQLi, SQLite3); required DB_DRIVER=MySQLi - -# ----------------------------------------------------------------------------- -# MySQL Configuration (only needed if DB_DRIVER=MySQLi) -# ----------------------------------------------------------------------------- -# These settings are used by both Docker Compose and CodeIgniter. - -# Database name +# MySQL database name. +# Type: string; required when DB_DRIVER=MySQLi DB_DATABASE=ci4_chat - -# Database user (application account) +# MySQL application account. +# Type: string; required when DB_DRIVER=MySQLi DB_USERNAME=ci4_user -DB_PASSWORD=ci4_password - -# Database root password (only used by MySQL container) -DB_ROOT_PASSWORD=rootpassword - -# Port to expose MySQL on the host (default: 3307 to avoid conflicts) -# Connect with a database client using localhost:3307 +# Password for the MySQL application account. +# Type: string; required when DB_DRIVER=MySQLi +DB_PASSWORD=CHANGE-ME +# MySQL root password used only by Docker Compose. +# Type: string; optional +DB_ROOT_PASSWORD=CHANGE-ME +# Host port exposed by the MySQL container. +# Type: int (1–65535); optional DB_PORT=3307 -# ----------------------------------------------------------------------------- -# SQLite Configuration (only needed if DB_DRIVER=SQLite3) -# ----------------------------------------------------------------------------- -# When using SQLite, the database file is automatically created at: -# writable/database/chat.db -# -# No additional configuration is required for SQLite. -# The database directory is created automatically when the app starts. - -# ----------------------------------------------------------------------------- -# WebSocket Configuration -# ----------------------------------------------------------------------------- -# Port for the WebSocket server +# WebSocket +# Host port exposed by the WebSocket server. +# Type: int (1–65535); optional WEBSOCKET_PORT=8080 - -# WebSocket URL for the frontend to connect to -# This should be accessible from the user's browser +# Browser-accessible WebSocket URL using ws or wss. +# Type: websocket_url; required WEBSOCKET_URL=ws://localhost:8080 - -# ----------------------------------------------------------------------------- -# CodeIgniter Settings (used when running locally without Docker) -# ----------------------------------------------------------------------------- -# Uncomment and modify these for local development without Docker. -# When using Docker, the docker-compose.yml overrides these. - -# database.default.hostname=localhost -# database.default.database=ci4_chat -# database.default.username=ci4_user -# database.default.password=ci4_password -# database.default.DBDriver=MySQLi -# database.default.port=3306 - -# ----------------------------------------------------------------------------- -# Optional: Security Settings -# ----------------------------------------------------------------------------- -# Encryption key for CodeIgniter (generate with: php spark key:generate) +# Secret reserved for signing WebSocket authentication material. +# Type: string; required +WEBSOCKET_TOKEN_SECRET=CHANGE-ME + +# Development +# Allows destructive benchmark data seeding outside production. +# Type: bool; optional +BENCHMARK_MODE=false + +# Push notifications +# Public VAPID key exposed to browser push clients. +# Type: string; optional +# push.vapidPublicKey= + +# CodeIgniter overrides +# Direct override for the default database host. +# Type: string; optional +# database.default.hostname= +# Direct override for the default database name. +# Type: string; optional +# database.default.database= +# Direct override for the default database user. +# Type: string; optional +# database.default.username= +# Direct override for the default database password. +# Type: string; optional +# database.default.password= +# Direct override for the default database driver. +# Type: enum (MySQLi, SQLite3); optional +# database.default.DBDriver= +# Direct override for the default database port. +# Type: int (1–65535); optional +# database.default.port= +# CodeIgniter encryption key. +# Type: string; optional # encryption.key= -# ----------------------------------------------------------------------------- -# Optional: Email Settings -# ----------------------------------------------------------------------------- -# email.protocol=smtp +# Email +# Email delivery protocol. +# Type: enum (mail, sendmail, smtp); optional +# email.protocol= +# SMTP server hostname. +# Type: string; optional # email.SMTPHost= +# SMTP account username. +# Type: string; optional # email.SMTPUser= +# SMTP account password. +# Type: string; optional # email.SMTPPass= -# email.SMTPPort=587 -# email.SMTPCrypto=tls - -# ----------------------------------------------------------------------------- -# Optional: Session Settings -# ----------------------------------------------------------------------------- -# session.driver=CodeIgniter\Session\Handlers\FileHandler -# session.savePath=writable/session +# SMTP server port. +# Type: int (1–65535); optional +# email.SMTPPort= +# SMTP transport encryption mode. +# Type: enum (tls, ssl); optional +# email.SMTPCrypto= + +# Session +# Fully-qualified CodeIgniter session handler class. +# Type: string; optional +# session.driver= +# Session handler storage location. +# Type: string; optional +# session.savePath= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac74e58..fa9f80c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,19 @@ jobs: - name: Install Composer dependencies run: composer install --prefer-dist --no-progress --no-interaction + - name: Validate environment configuration + env: + DB_PASSWORD: db-${{ github.run_id }}-${{ github.run_attempt }}-validation-value + DB_ROOT_PASSWORD: root-${{ github.run_id }}-${{ github.run_attempt }}-validation-value + WEBSOCKET_TOKEN_SECRET: websocket-${{ github.sha }}-${{ github.run_id }} + run: | + cp .env.example .env + sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=${DB_PASSWORD}|" .env + sed -i "s|^DB_ROOT_PASSWORD=.*|DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}|" .env + sed -i "s|^WEBSOCKET_TOKEN_SECRET=.*|WEBSOCKET_TOKEN_SECRET=${WEBSOCKET_TOKEN_SECRET}|" .env + php spark env:check --no-header + php spark env:diff --no-header + - name: Check PHP coding style run: composer cs-check diff --git a/app/Commands/EnvCheck.php b/app/Commands/EnvCheck.php new file mode 100644 index 0000000..6791a6e --- /dev/null +++ b/app/Commands/EnvCheck.php @@ -0,0 +1,32 @@ +schema)->validate(); + if ($errors === []) { + CLI::write('Environment configuration is valid.', 'green'); + + return EXIT_SUCCESS; + } + + CLI::error('Environment configuration is invalid:'); + foreach ($errors as $error) { + CLI::error('- ' . $error); + } + + return EXIT_ERROR; + } +} diff --git a/app/Commands/EnvDiff.php b/app/Commands/EnvDiff.php new file mode 100644 index 0000000..552c686 --- /dev/null +++ b/app/Commands/EnvDiff.php @@ -0,0 +1,45 @@ +schema)->diff($contents); + if (! $diff['stale']) { + CLI::write('.env.example matches the environment schema.', 'green'); + + return EXIT_SUCCESS; + } + + foreach ($diff['missing'] as $name) { + CLI::error('Missing from .env.example: ' . $name); + } + foreach ($diff['unexpected'] as $name) { + CLI::error('Not declared in EnvSchema: ' . $name); + } + if ($diff['missing'] === [] && $diff['unexpected'] === []) { + CLI::error('.env.example content is stale. Run php spark env:dump.'); + } + + return EXIT_ERROR; + } +} diff --git a/app/Commands/EnvDocs.php b/app/Commands/EnvDocs.php new file mode 100644 index 0000000..67097ff --- /dev/null +++ b/app/Commands/EnvDocs.php @@ -0,0 +1,30 @@ +schema)->renderDocumentation(); + if (file_put_contents($path, $contents) === false) { + CLI::error('Unable to write ' . $path); + + return EXIT_ERROR; + } + + CLI::write('Generated ' . $path, 'green'); + + return EXIT_SUCCESS; + } +} diff --git a/app/Commands/EnvDump.php b/app/Commands/EnvDump.php new file mode 100644 index 0000000..6014857 --- /dev/null +++ b/app/Commands/EnvDump.php @@ -0,0 +1,30 @@ +schema)->renderExample(); + if (file_put_contents($path, $contents) === false) { + CLI::error('Unable to write ' . $path); + + return EXIT_ERROR; + } + + CLI::write('Generated ' . $path, 'green'); + + return EXIT_SUCCESS; + } +} diff --git a/app/Config/App.php b/app/Config/App.php index 93489ad..b4decd5 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -199,4 +199,19 @@ class App extends BaseConfig * @see http://www.w3.org/TR/CSP/ */ public bool $CSPEnabled = true; + + public function __construct() + { + parent::__construct(); + + if (ENVIRONMENT === 'testing') { + return; + } + + $appUrl = env('APP_URL', ''); + $scheme = is_string($appUrl) ? parse_url($appUrl, PHP_URL_SCHEME) : null; + if (is_string($appUrl) && filter_var($appUrl, FILTER_VALIDATE_URL) !== false && in_array($scheme, ['http', 'https'], true)) { + $this->baseURL = rtrim($appUrl, '/') . '/'; + } + } } diff --git a/app/Config/EnvSchema.php b/app/Config/EnvSchema.php new file mode 100644 index 0000000..fb581db --- /dev/null +++ b/app/Config/EnvSchema.php @@ -0,0 +1,208 @@ +> + */ + public array $schema = [ + 'CI_ENVIRONMENT' => [ + 'group' => 'Application', + 'type' => 'enum', + 'values' => ['development', 'testing', 'production'], + 'required' => true, + 'example' => 'development', + 'description' => 'Active CodeIgniter runtime environment.', + ], + 'APP_URL' => [ + 'group' => 'Application', + 'type' => 'url', + 'required' => true, + 'example' => 'http://localhost:8000', + 'description' => 'Public application URL, including its scheme.', + ], + 'APP_PORT' => [ + 'group' => 'Application', + 'type' => 'int', + 'min' => 1, + 'max' => 65535, + 'default' => 8000, + 'description' => 'Host port exposed by the web container.', + ], + 'DB_DRIVER' => [ + 'group' => 'Database', + 'type' => 'enum', + 'values' => ['MySQLi', 'SQLite3'], + 'required' => true, + 'example' => 'MySQLi', + 'description' => 'Database driver used by the application.', + ], + 'DB_DATABASE' => [ + 'group' => 'Database', + 'type' => 'string', + 'required_when' => ['DB_DRIVER' => 'MySQLi'], + 'example' => 'ci4_chat', + 'description' => 'MySQL database name.', + ], + 'DB_USERNAME' => [ + 'group' => 'Database', + 'type' => 'string', + 'required_when' => ['DB_DRIVER' => 'MySQLi'], + 'example' => 'ci4_user', + 'description' => 'MySQL application account.', + ], + 'DB_PASSWORD' => [ + 'group' => 'Database', + 'type' => 'string', + 'required_when' => ['DB_DRIVER' => 'MySQLi'], + 'secret' => true, + 'min_length' => 12, + 'forbidden_values' => ['change-me', 'changeme', 'ci4_password', 'password', 'your_mysql_password'], + 'description' => 'Password for the MySQL application account.', + ], + 'DB_ROOT_PASSWORD' => [ + 'group' => 'Database', + 'type' => 'string', + 'secret' => true, + 'min_length' => 12, + 'forbidden_values' => ['change-me', 'changeme', 'rootpassword', 'password'], + 'example' => 'CHANGE-ME', + 'description' => 'MySQL root password used only by Docker Compose.', + ], + 'DB_PORT' => [ + 'group' => 'Database', + 'type' => 'int', + 'min' => 1, + 'max' => 65535, + 'default' => 3307, + 'description' => 'Host port exposed by the MySQL container.', + ], + 'WEBSOCKET_PORT' => [ + 'group' => 'WebSocket', + 'type' => 'int', + 'min' => 1, + 'max' => 65535, + 'default' => 8080, + 'description' => 'Host port exposed by the WebSocket server.', + ], + 'WEBSOCKET_URL' => [ + 'group' => 'WebSocket', + 'type' => 'websocket_url', + 'required' => true, + 'example' => 'ws://localhost:8080', + 'description' => 'Browser-accessible WebSocket URL using ws or wss.', + ], + 'WEBSOCKET_TOKEN_SECRET' => [ + 'group' => 'WebSocket', + 'type' => 'string', + 'required' => true, + 'secret' => true, + 'min_length' => 32, + 'forbidden_values' => ['change-me', 'changeme', 'your-secret-here', 'websocket-secret'], + 'description' => 'Secret reserved for signing WebSocket authentication material.', + ], + 'BENCHMARK_MODE' => [ + 'group' => 'Development', + 'type' => 'bool', + 'default' => false, + 'description' => 'Allows destructive benchmark data seeding outside production.', + ], + 'push.vapidPublicKey' => [ + 'group' => 'Push notifications', + 'type' => 'string', + 'description' => 'Public VAPID key exposed to browser push clients.', + ], + 'database.default.hostname' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'string', + 'description' => 'Direct override for the default database host.', + ], + 'database.default.database' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'string', + 'description' => 'Direct override for the default database name.', + ], + 'database.default.username' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'string', + 'description' => 'Direct override for the default database user.', + ], + 'database.default.password' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'string', + 'secret' => true, + 'forbidden_values' => ['change-me', 'changeme', 'ci4_password', 'password', 'your_mysql_password'], + 'description' => 'Direct override for the default database password.', + ], + 'database.default.DBDriver' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'enum', + 'values' => ['MySQLi', 'SQLite3'], + 'description' => 'Direct override for the default database driver.', + ], + 'database.default.port' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'int', + 'min' => 1, + 'max' => 65535, + 'description' => 'Direct override for the default database port.', + ], + 'encryption.key' => [ + 'group' => 'CodeIgniter overrides', + 'type' => 'string', + 'secret' => true, + 'forbidden_values' => ['change-me', 'changeme', 'your-secret-here'], + 'description' => 'CodeIgniter encryption key.', + ], + 'email.protocol' => [ + 'group' => 'Email', + 'type' => 'enum', + 'values' => ['mail', 'sendmail', 'smtp'], + 'description' => 'Email delivery protocol.', + ], + 'email.SMTPHost' => [ + 'group' => 'Email', + 'type' => 'string', + 'description' => 'SMTP server hostname.', + ], + 'email.SMTPUser' => [ + 'group' => 'Email', + 'type' => 'string', + 'description' => 'SMTP account username.', + ], + 'email.SMTPPass' => [ + 'group' => 'Email', + 'type' => 'string', + 'secret' => true, + 'forbidden_values' => ['change-me', 'changeme', 'password'], + 'description' => 'SMTP account password.', + ], + 'email.SMTPPort' => [ + 'group' => 'Email', + 'type' => 'int', + 'min' => 1, + 'max' => 65535, + 'description' => 'SMTP server port.', + ], + 'email.SMTPCrypto' => [ + 'group' => 'Email', + 'type' => 'enum', + 'values' => ['tls', 'ssl'], + 'description' => 'SMTP transport encryption mode.', + ], + 'session.driver' => [ + 'group' => 'Session', + 'type' => 'string', + 'description' => 'Fully-qualified CodeIgniter session handler class.', + ], + 'session.savePath' => [ + 'group' => 'Session', + 'type' => 'string', + 'description' => 'Session handler storage location.', + ], + ]; +} diff --git a/app/Config/Events.php b/app/Config/Events.php index 1aaa7f4..8b4f56a 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -2,6 +2,8 @@ namespace Config; +use App\Services\EnvStartupGuard; +use App\Services\EnvValidator; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\FrameworkException; use CodeIgniter\HotReloader\HotReloader; @@ -24,6 +26,10 @@ */ Events::on('pre_system', static function (): void { + if (ENVIRONMENT !== 'testing') { + new EnvStartupGuard(new EnvValidator(new EnvSchema()->schema))->assertValid(); + } + if (ENVIRONMENT !== 'testing') { if (ini_get('zlib.output_compression')) { throw FrameworkException::forEnabledZlibOutputCompression(); @@ -53,3 +59,21 @@ } } }); + +Events::on('pre_command', static function (): void { + if (ENVIRONMENT === 'testing') { + return; + } + + $command = $_SERVER['argv'][1] ?? 'list'; + $target = $_SERVER['argv'][2] ?? ''; + if ( + str_starts_with($command, 'env:') + || $command === 'list' + || ($command === 'help' && str_starts_with($target, 'env:')) + ) { + return; + } + + new EnvStartupGuard(new EnvValidator(new EnvSchema()->schema))->assertValid(); +}); diff --git a/app/Exceptions/EnvValidationException.php b/app/Exceptions/EnvValidationException.php new file mode 100644 index 0000000..f88e0eb --- /dev/null +++ b/app/Exceptions/EnvValidationException.php @@ -0,0 +1,15 @@ + $errors */ + public function __construct(public readonly array $errors) + { + parent::__construct("Environment configuration is invalid:\n- " . implode("\n- ", $errors), 503); + } +} diff --git a/app/Libraries/AppExceptionHandler.php b/app/Libraries/AppExceptionHandler.php index 554cda2..60c3ef3 100644 --- a/app/Libraries/AppExceptionHandler.php +++ b/app/Libraries/AppExceptionHandler.php @@ -2,6 +2,7 @@ namespace App\Libraries; +use App\Exceptions\EnvValidationException; use CodeIgniter\Debug\ExceptionHandlerInterface; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; @@ -20,6 +21,23 @@ public function handle( int $statusCode, int $exitCode, ): void { + if ($exception instanceof EnvValidationException) { + log_message('critical', 'Application startup rejected invalid environment configuration: {errors}', [ + 'errors' => implode('; ', $exception->errors), + ]); + + $message = ENVIRONMENT === 'production' + ? 'Service unavailable due to invalid environment configuration.' + : $exception->getMessage(); + $response + ->setStatusCode(503) + ->setContentType('text/plain') + ->setBody($message) + ->send(); + + return; + } + $type = $statusCode === 404 ? ErrorHandler::ERROR_TYPE_NOT_FOUND : ErrorHandler::ERROR_TYPE_SERVER; diff --git a/app/Services/EnvFileManager.php b/app/Services/EnvFileManager.php new file mode 100644 index 0000000..e2464b3 --- /dev/null +++ b/app/Services/EnvFileManager.php @@ -0,0 +1,143 @@ +> $schema */ + public function __construct(private readonly array $schema) + { + } + + public function renderExample(): string + { + $output = [ + '# Generated by `php spark env:dump`. Do not edit by hand.', + '# Copy this file to .env and replace every CHANGE-ME value.', + ]; + $lastGroup = null; + + foreach ($this->schema as $name => $rules) { + $group = $rules['group'] ?? 'Other'; + if ($group !== $lastGroup) { + $output[] = ''; + $output[] = '# ' . $group; + $lastGroup = $group; + } + + $output[] = '# ' . $rules['description']; + $output[] = '# Type: ' . $this->typeDescription($rules) . '; ' . $this->requirementDescription($rules); + + $value = $this->exampleValue($rules); + $optionalWithoutValue = $value === '' && ! ($rules['required'] ?? false) && ! isset($rules['required_when']); + $output[] = ($optionalWithoutValue ? '# ' : '') . $name . '=' . $value; + } + + return implode("\n", $output) . "\n"; + } + + public function renderDocumentation(): string + { + $output = [ + '# Environment configuration', + '', + 'This file is generated by `php spark env:docs` from `Config\\EnvSchema`.', + 'Run `php spark env:check` before starting the application and `php spark env:diff` after changing the schema.', + '', + '| Variable | Type | Required | Default | Description |', + '| --- | --- | --- | --- | --- |', + ]; + + foreach ($this->schema as $name => $rules) { + $default = array_key_exists('default', $rules) ? $this->displayValue($rules['default']) : '—'; + if (($rules['secret'] ?? false) === true && $default !== '—') { + $default = '(secret)'; + } + $output[] = sprintf( + '| `%s` | %s | %s | %s | %s |', + $name, + $this->escapeTable($this->typeDescription($rules)), + $this->escapeTable($this->requirementDescription($rules)), + $this->escapeTable($default), + $this->escapeTable((string) $rules['description']), + ); + } + + $output[] = ''; + $output[] = 'Secret values must never use placeholders such as `CHANGE-ME`, `password`, or `your-secret-here`.'; + + return implode("\n", $output) . "\n"; + } + + /** @return array{missing: list, unexpected: list, stale: bool} */ + public function diff(string $contents): array + { + preg_match_all('/^\s*#?\s*([A-Za-z][A-Za-z0-9_.]*)\s*=/m', $contents, $matches); + $documented = array_values(array_unique($matches[1])); + $declared = array_keys($this->schema); + + return [ + 'missing' => array_values(array_diff($declared, $documented)), + 'unexpected' => array_values(array_diff($documented, $declared)), + 'stale' => $contents !== $this->renderExample(), + ]; + } + + /** @param array $rules */ + private function exampleValue(array $rules): string + { + if (($rules['secret'] ?? false) === true && (($rules['required'] ?? false) || isset($rules['required_when']))) { + return 'CHANGE-ME'; + } + if (array_key_exists('default', $rules)) { + return $this->displayValue($rules['default']); + } + + return (string) ($rules['example'] ?? ''); + } + + /** @param array $rules */ + private function typeDescription(array $rules): string + { + if (($rules['type'] ?? null) === 'enum') { + return 'enum (' . implode(', ', $rules['values']) . ')'; + } + if (($rules['type'] ?? null) === 'int' && (isset($rules['min']) || isset($rules['max']))) { + return sprintf('int (%s–%s)', $rules['min'] ?? '…', $rules['max'] ?? '…'); + } + + return (string) ($rules['type'] ?? 'string'); + } + + /** @param array $rules */ + private function requirementDescription(array $rules): string + { + if (($rules['required'] ?? false) === true) { + return 'required'; + } + if (isset($rules['required_when'])) { + $requirements = []; + foreach ($rules['required_when'] as $name => $value) { + $requirements[] = $name . '=' . $value; + } + + return 'required when ' . implode(' and ', $requirements); + } + + return 'optional'; + } + + private function displayValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return (string) $value; + } + + private function escapeTable(string $value): string + { + return str_replace('|', '\\|', $value); + } +} diff --git a/app/Services/EnvStartupGuard.php b/app/Services/EnvStartupGuard.php new file mode 100644 index 0000000..15982cd --- /dev/null +++ b/app/Services/EnvStartupGuard.php @@ -0,0 +1,20 @@ +validator->validate(); + if ($errors !== []) { + throw new EnvValidationException($errors); + } + } +} diff --git a/app/Services/EnvValidator.php b/app/Services/EnvValidator.php new file mode 100644 index 0000000..0875649 --- /dev/null +++ b/app/Services/EnvValidator.php @@ -0,0 +1,157 @@ +> $schema + * @param callable(string): mixed|null $reader + */ + public function __construct(private readonly array $schema, ?callable $reader = null) + { + $this->reader = $reader !== null + ? Closure::fromCallable($reader) + : static fn (string $name): mixed => env($name, null); + } + + /** @return list */ + public function validate(): array + { + $errors = []; + $values = []; + + foreach (array_keys($this->schema) as $name) { + $values[$name] = ($this->reader)($name); + } + + foreach ($this->schema as $name => $rules) { + $value = $values[$name]; + $missing = $value === null || $value === ''; + $required = ($rules['required'] ?? false) === true || $this->isConditionallyRequired($rules, $values); + + if ($missing) { + if ($required && ! array_key_exists('default', $rules)) { + $errors[] = sprintf('%s is required.', $name); + } + + continue; + } + + $typeError = $this->validateType($name, $value, $rules); + if ($typeError !== null) { + $errors[] = $typeError; + + continue; + } + + $stringValue = (string) $value; + if (isset($rules['min_length']) && strlen($stringValue) < $rules['min_length']) { + $errors[] = sprintf('%s must be at least %d characters.', $name, $rules['min_length']); + } + + $forbidden = array_map( + static fn (mixed $candidate): string => strtolower(trim((string) $candidate)), + $rules['forbidden_values'] ?? [], + ); + if (in_array(strtolower(trim($stringValue)), $forbidden, true)) { + $errors[] = sprintf('%s contains a forbidden placeholder value.', $name); + } + } + + return $errors; + } + + /** + * @param array $rules + * @param array $values + */ + private function isConditionallyRequired(array $rules, array $values): bool + { + if (! isset($rules['required_when']) || ! is_array($rules['required_when'])) { + return false; + } + + foreach ($rules['required_when'] as $name => $expected) { + if (($values[$name] ?? null) !== $expected) { + return false; + } + } + + return true; + } + + /** @param array $rules */ + private function validateType(string $name, mixed $value, array $rules): ?string + { + $type = $rules['type'] ?? 'string'; + $stringValue = (string) $value; + + return match ($type) { + 'bool' => $this->validateBool($name, $value), + 'int' => $this->validateInt($name, $stringValue, $rules), + 'enum' => $this->validateEnum($name, $stringValue, $rules), + 'url' => filter_var($stringValue, FILTER_VALIDATE_URL) === false + ? sprintf('%s must be a valid URL.', $name) + : null, + 'websocket_url' => $this->validateWebSocketUrl($name, $stringValue), + 'string' => null, + default => sprintf('%s has unsupported schema type %s.', $name, $type), + }; + } + + private function validateBool(string $name, mixed $value): ?string + { + if (is_bool($value)) { + return null; + } + + $allowed = ['1', '0', 'true', 'false', 'yes', 'no', 'on', 'off']; + + return in_array(strtolower((string) $value), $allowed, true) + ? null + : sprintf('%s must be a boolean.', $name); + } + + /** @param array $rules */ + private function validateInt(string $name, string $value, array $rules): ?string + { + if (filter_var($value, FILTER_VALIDATE_INT) === false) { + return sprintf('%s must be an integer.', $name); + } + + $integer = (int) $value; + if (isset($rules['min']) && $integer < $rules['min']) { + return sprintf('%s must be at least %d.', $name, $rules['min']); + } + if (isset($rules['max']) && $integer > $rules['max']) { + return sprintf('%s must be at most %d.', $name, $rules['max']); + } + + return null; + } + + /** @param array $rules */ + private function validateEnum(string $name, string $value, array $rules): ?string + { + $allowed = $rules['values'] ?? []; + + return in_array($value, $allowed, true) + ? null + : sprintf('%s must be one of: %s.', $name, implode(', ', $allowed)); + } + + private function validateWebSocketUrl(string $name, string $value): ?string + { + $parts = parse_url($value); + if ($parts === false || ! in_array($parts['scheme'] ?? null, ['ws', 'wss'], true) || empty($parts['host'])) { + return sprintf('%s must be a valid ws:// or wss:// URL.', $name); + } + + return null; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 39eefe9..4ca26db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,18 +42,24 @@ services: # Application settings app.baseURL: ${APP_URL:-http://localhost:8000} + APP_URL: ${APP_URL:-http://localhost:8000} + APP_PORT: ${APP_PORT:-8000} # Database connection settings # These override the values in app/Config/Database.php + DB_DRIVER: ${DB_DRIVER:-MySQLi} + DB_DATABASE: ${DB_DATABASE:-ci4_chat} + DB_USERNAME: ${DB_USERNAME:-ci4_user} + DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} database.default.hostname: db database.default.database: ${DB_DATABASE:-ci4_chat} database.default.username: ${DB_USERNAME:-ci4_user} - database.default.password: ${DB_PASSWORD:-ci4_password} + database.default.password: ${DB_PASSWORD:?Set DB_PASSWORD in .env} database.default.DBDriver: MySQLi database.default.port: 3306 - # WebSocket server URL (for the web app to connect to) WEBSOCKET_URL: ${WEBSOCKET_URL:-ws://localhost:8080} + WEBSOCKET_TOKEN_SECRET: ${WEBSOCKET_TOKEN_SECRET:?Set WEBSOCKET_TOKEN_SECRET in .env} volumes: # Mount the writable directory for logs, cache, and sessions # This persists data between container restarts @@ -88,12 +94,12 @@ services: - "${DB_PORT:-3307}:3306" environment: # MySQL root password (for administrative tasks) - MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?Set DB_ROOT_PASSWORD in .env} # Application database and user MYSQL_DATABASE: ${DB_DATABASE:-ci4_chat} MYSQL_USER: ${DB_USERNAME:-ci4_user} - MYSQL_PASSWORD: ${DB_PASSWORD:-ci4_password} + MYSQL_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} volumes: # Persist database data between container restarts - mysql-data:/var/lib/mysql @@ -104,7 +110,7 @@ services: networks: - ci4-chat-network healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpassword}"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:?Set DB_ROOT_PASSWORD in .env}"] interval: 10s timeout: 5s retries: 5 @@ -134,9 +140,17 @@ services: database.default.hostname: db database.default.database: ${DB_DATABASE:-ci4_chat} database.default.username: ${DB_USERNAME:-ci4_user} - database.default.password: ${DB_PASSWORD:-ci4_password} + database.default.password: ${DB_PASSWORD:?Set DB_PASSWORD in .env} database.default.DBDriver: MySQLi database.default.port: 3306 + APP_URL: ${APP_URL:-http://localhost:8000} + APP_PORT: ${APP_PORT:-8000} + DB_DRIVER: ${DB_DRIVER:-MySQLi} + DB_DATABASE: ${DB_DATABASE:-ci4_chat} + DB_USERNAME: ${DB_USERNAME:-ci4_user} + DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + WEBSOCKET_URL: ${WEBSOCKET_URL:-ws://localhost:8080} + WEBSOCKET_TOKEN_SECRET: ${WEBSOCKET_TOKEN_SECRET:?Set WEBSOCKET_TOKEN_SECRET in .env} # Override the default command to run the WebSocket server command: ["php", "spark", "chat:websocket", "--port=8080"] # Run as the websocket user created in the Dockerfile diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4c0deac --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,38 @@ +# Environment configuration + +This file is generated by `php spark env:docs` from `Config\EnvSchema`. +Run `php spark env:check` before starting the application and `php spark env:diff` after changing the schema. + +| Variable | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `CI_ENVIRONMENT` | enum (development, testing, production) | required | — | Active CodeIgniter runtime environment. | +| `APP_URL` | url | required | — | Public application URL, including its scheme. | +| `APP_PORT` | int (1–65535) | optional | 8000 | Host port exposed by the web container. | +| `DB_DRIVER` | enum (MySQLi, SQLite3) | required | — | Database driver used by the application. | +| `DB_DATABASE` | string | required when DB_DRIVER=MySQLi | — | MySQL database name. | +| `DB_USERNAME` | string | required when DB_DRIVER=MySQLi | — | MySQL application account. | +| `DB_PASSWORD` | string | required when DB_DRIVER=MySQLi | — | Password for the MySQL application account. | +| `DB_ROOT_PASSWORD` | string | optional | — | MySQL root password used only by Docker Compose. | +| `DB_PORT` | int (1–65535) | optional | 3307 | Host port exposed by the MySQL container. | +| `WEBSOCKET_PORT` | int (1–65535) | optional | 8080 | Host port exposed by the WebSocket server. | +| `WEBSOCKET_URL` | websocket_url | required | — | Browser-accessible WebSocket URL using ws or wss. | +| `WEBSOCKET_TOKEN_SECRET` | string | required | — | Secret reserved for signing WebSocket authentication material. | +| `BENCHMARK_MODE` | bool | optional | false | Allows destructive benchmark data seeding outside production. | +| `push.vapidPublicKey` | string | optional | — | Public VAPID key exposed to browser push clients. | +| `database.default.hostname` | string | optional | — | Direct override for the default database host. | +| `database.default.database` | string | optional | — | Direct override for the default database name. | +| `database.default.username` | string | optional | — | Direct override for the default database user. | +| `database.default.password` | string | optional | — | Direct override for the default database password. | +| `database.default.DBDriver` | enum (MySQLi, SQLite3) | optional | — | Direct override for the default database driver. | +| `database.default.port` | int (1–65535) | optional | — | Direct override for the default database port. | +| `encryption.key` | string | optional | — | CodeIgniter encryption key. | +| `email.protocol` | enum (mail, sendmail, smtp) | optional | — | Email delivery protocol. | +| `email.SMTPHost` | string | optional | — | SMTP server hostname. | +| `email.SMTPUser` | string | optional | — | SMTP account username. | +| `email.SMTPPass` | string | optional | — | SMTP account password. | +| `email.SMTPPort` | int (1–65535) | optional | — | SMTP server port. | +| `email.SMTPCrypto` | enum (tls, ssl) | optional | — | SMTP transport encryption mode. | +| `session.driver` | string | optional | — | Fully-qualified CodeIgniter session handler class. | +| `session.savePath` | string | optional | — | Session handler storage location. | + +Secret values must never use placeholders such as `CHANGE-ME`, `password`, or `your-secret-here`. diff --git a/e2e/support/environment.ts b/e2e/support/environment.ts index 7cabb7f..511f663 100644 --- a/e2e/support/environment.ts +++ b/e2e/support/environment.ts @@ -13,7 +13,11 @@ const inheritedEnvironment = Object.fromEntries( export const applicationEnvironment: Record = { ...inheritedEnvironment, CI_ENVIRONMENT: 'development', + APP_URL: `${baseURL}/`, + APP_PORT: '8085', DB_DRIVER: 'SQLite3', + WEBSOCKET_URL: 'ws://127.0.0.1:8080', + WEBSOCKET_TOKEN_SECRET: `e2e-${'x'.repeat(32)}`, app_baseURL: `${baseURL}/`, app_CSPEnabled: 'false', cookie_secure: 'false', diff --git a/tests/Tests/Unit/EnvFileManagerTest.php b/tests/Tests/Unit/EnvFileManagerTest.php new file mode 100644 index 0000000..c02f4ab --- /dev/null +++ b/tests/Tests/Unit/EnvFileManagerTest.php @@ -0,0 +1,70 @@ +> */ + private array $schema = [ + 'APP_PORT' => [ + 'group' => 'Application', + 'type' => 'int', + 'default' => 8000, + 'description' => 'Application port.', + ], + 'APP_SECRET' => [ + 'group' => 'Application', + 'type' => 'string', + 'required' => true, + 'secret' => true, + 'description' => 'Application secret.', + ], + 'OPTIONAL_VALUE' => [ + 'group' => 'Application', + 'type' => 'string', + 'description' => 'Optional setting.', + ], + ]; + + public function testExampleUsesDefaultsAndSafeSecretPlaceholder(): void + { + $example = (new EnvFileManager($this->schema))->renderExample(); + + $this->assertStringContainsString("APP_PORT=8000\n", $example); + $this->assertStringContainsString("APP_SECRET=CHANGE-ME\n", $example); + $this->assertStringContainsString("# OPTIONAL_VALUE=\n", $example); + } + + public function testDiffIncludesCommentedOptionalVariables(): void + { + $diff = (new EnvFileManager($this->schema))->diff("APP_PORT=8000\n# OPTIONAL_VALUE=\nEXTRA=value\n"); + + $this->assertSame(['APP_SECRET'], $diff['missing']); + $this->assertSame(['EXTRA'], $diff['unexpected']); + $this->assertTrue($diff['stale']); + } + + public function testDiffDetectsStaleGeneratedContentWithoutNameDrift(): void + { + $manager = new EnvFileManager($this->schema); + $example = str_replace('APP_PORT=8000', 'APP_PORT=9000', $manager->renderExample()); + + $diff = $manager->diff($example); + + $this->assertSame([], $diff['missing']); + $this->assertSame([], $diff['unexpected']); + $this->assertTrue($diff['stale']); + } + + public function testDocumentationListsEverySchemaEntry(): void + { + $documentation = (new EnvFileManager($this->schema))->renderDocumentation(); + + foreach (array_keys($this->schema) as $name) { + $this->assertStringContainsString('`' . $name . '`', $documentation); + } + } +} diff --git a/tests/Tests/Unit/EnvStartupGuardTest.php b/tests/Tests/Unit/EnvStartupGuardTest.php new file mode 100644 index 0000000..87681d8 --- /dev/null +++ b/tests/Tests/Unit/EnvStartupGuardTest.php @@ -0,0 +1,37 @@ + null)); + + $guard->assertValid(); + + $this->addToAssertionCount(1); + } + + public function testInvalidConfigurationBecomesAServiceUnavailableError(): void + { + $validator = new EnvValidator( + ['APP_URL' => ['type' => 'url', 'required' => true]], + static fn (string $name): null => null, + ); + + try { + (new EnvStartupGuard($validator))->assertValid(); + $this->fail('Expected invalid configuration to stop startup.'); + } catch (EnvValidationException $exception) { + $this->assertSame(503, $exception->getCode()); + $this->assertSame(['APP_URL is required.'], $exception->errors); + $this->assertStringContainsString('Environment configuration is invalid', $exception->getMessage()); + } + } +} diff --git a/tests/Tests/Unit/EnvValidatorTest.php b/tests/Tests/Unit/EnvValidatorTest.php new file mode 100644 index 0000000..f607293 --- /dev/null +++ b/tests/Tests/Unit/EnvValidatorTest.php @@ -0,0 +1,77 @@ +validator(['TOKEN' => ['type' => 'string', 'required' => true]])->validate(); + + $this->assertSame(['TOKEN is required.'], $errors); + } + + public function testWrongTypeFails(): void + { + $errors = $this->validator(['ENABLED' => ['type' => 'bool']], ['ENABLED' => 'sometimes'])->validate(); + + $this->assertSame(['ENABLED must be a boolean.'], $errors); + } + + public function testOutOfRangeIntegerFails(): void + { + $errors = $this->validator( + ['PORT' => ['type' => 'int', 'min' => 1, 'max' => 65535]], + ['PORT' => '70000'], + )->validate(); + + $this->assertSame(['PORT must be at most 65535.'], $errors); + } + + public function testEnumMismatchFails(): void + { + $errors = $this->validator( + ['DRIVER' => ['type' => 'enum', 'values' => ['MySQLi', 'SQLite3']]], + ['DRIVER' => 'mysql'], + )->validate(); + + $this->assertSame(['DRIVER must be one of: MySQLi, SQLite3.'], $errors); + } + + public function testForbiddenSecretFailsWithoutExposingItsValue(): void + { + $errors = $this->validator( + ['TOKEN' => ['type' => 'string', 'secret' => true, 'forbidden_values' => ['change-me']]], + ['TOKEN' => 'CHANGE-ME'], + )->validate(); + + $this->assertSame(['TOKEN contains a forbidden placeholder value.'], $errors); + $this->assertStringNotContainsString('CHANGE-ME', implode(' ', $errors)); + } + + public function testConditionalRequirementUsesAnotherSchemaValue(): void + { + $schema = [ + 'DRIVER' => ['type' => 'enum', 'values' => ['MySQLi', 'SQLite3']], + 'PASSWORD' => ['type' => 'string', 'required_when' => ['DRIVER' => 'MySQLi']], + ]; + + $this->assertSame( + ['PASSWORD is required.'], + $this->validator($schema, ['DRIVER' => 'MySQLi'])->validate(), + ); + $this->assertSame([], $this->validator($schema, ['DRIVER' => 'SQLite3'])->validate()); + } + + /** + * @param array> $schema + * @param array $values + */ + private function validator(array $schema, array $values = []): EnvValidator + { + return new EnvValidator($schema, static fn (string $name): mixed => $values[$name] ?? null); + } +} diff --git a/tests/Tests/Unit/ErrorHandlerTest.php b/tests/Tests/Unit/ErrorHandlerTest.php index 135a696..bb6373b 100644 --- a/tests/Tests/Unit/ErrorHandlerTest.php +++ b/tests/Tests/Unit/ErrorHandlerTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use App\Exceptions\EnvValidationException; use App\Libraries\AppExceptionHandler; use App\Libraries\ErrorHandler; use App\Services\CorrelationId; @@ -18,6 +19,27 @@ */ final class ErrorHandlerTest extends CIUnitTestCase { + public function testEnvironmentValidationExceptionReturnsServiceUnavailableResponse(): void + { + $request = new IncomingRequest(new App(), new URI('https://example.com/'), null, new UserAgent()); + $response = new Response(new App()); + $handler = new AppExceptionHandler(new ErrorHandler($request, $response, new CorrelationId($request))); + + ob_start(); + $handler->handle( + new EnvValidationException(['APP_URL must be a valid URL.']), + $request, + $response, + 503, + EXIT_ERROR, + ); + $output = ob_get_clean(); + + $this->assertSame(503, $response->getStatusCode()); + $this->assertStringContainsString('Environment configuration is invalid', $output); + $this->assertStringContainsString('APP_URL must be a valid URL.', $output); + } + public function testJsonErrorsUseNestedEnvelopeWithMatchingCorrelationHeader(): void { $request = new IncomingRequest(new App(), new URI('https://example.com/chat'), null, new UserAgent());