| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\App;
- use App\App\Config;
- use PHPUnit\Framework\TestCase;
- /**
- * Boot-time configuration validation.
- *
- * SEC_REVIEW F37: `LOCAL_ADMIN_PASSWORD_HASH` must be Argon2id or bcrypt
- * cost ≥ 12. Anything weaker — bcrypt cost 4, plain text, md5-crypt,
- * argon2i — is rejected at boot so a misconfigured deployment crashes
- * on `docker compose up` instead of accepting hashed-but-cracked
- * passwords on the local-admin path.
- */
- final class ConfigTest extends TestCase
- {
- public function testValidArgon2idIsAccepted(): void
- {
- $hash = password_hash('test', PASSWORD_ARGON2ID);
- self::assertSame([], Config::collectErrors($this->localBaseSettings($hash)));
- }
- public function testValidBcryptAtMinimumCostIsAccepted(): void
- {
- $hash = password_hash('test', PASSWORD_BCRYPT, ['cost' => Config::BCRYPT_MIN_COST]);
- self::assertSame([], Config::collectErrors($this->localBaseSettings($hash)));
- }
- public function testBcryptBelowMinimumCostIsRejected(): void
- {
- $hash = password_hash('test', PASSWORD_BCRYPT, ['cost' => 4]);
- $errors = Config::collectErrors($this->localBaseSettings($hash));
- self::assertNotEmpty($errors);
- $joined = implode("\n", $errors);
- self::assertStringContainsString('cost=4', $joined);
- self::assertStringContainsString('LOCAL_ADMIN_PASSWORD_HASH', $joined);
- }
- public function testArgon2iIsRejected(): void
- {
- if (!defined('PASSWORD_ARGON2I')) {
- self::markTestSkipped('argon2i not built into this PHP');
- }
- $hash = password_hash('test', PASSWORD_ARGON2I);
- $errors = Config::collectErrors($this->localBaseSettings($hash));
- self::assertNotEmpty($errors);
- self::assertStringContainsString('argon2i', implode("\n", $errors));
- }
- public function testMd5CryptIsRejected(): void
- {
- // crypt() with $1$ prefix produces md5-crypt, which password_get_info
- // reports as 'unknown'. The literal string SEC_REVIEW called out.
- $hash = '$1$salt1234$KdyIvFMZJKR1qDJ5qE5W31';
- $errors = Config::collectErrors($this->localBaseSettings($hash));
- self::assertNotEmpty($errors);
- self::assertStringContainsString('"unknown"', implode("\n", $errors));
- }
- public function testPlainStringIsRejected(): void
- {
- $errors = Config::collectErrors($this->localBaseSettings('not-a-hash-at-all'));
- self::assertNotEmpty($errors);
- }
- public function testEmptyHashIsStillRejectedWhenLocalEnabled(): void
- {
- $errors = Config::collectErrors($this->localBaseSettings(''));
- self::assertNotEmpty($errors);
- self::assertStringContainsString('empty', implode("\n", $errors));
- }
- public function testHashIsNotValidatedWhenLocalAdminDisabled(): void
- {
- // Operators who only run OIDC don't need a strong dummy hash.
- $settings = [
- 'ui_service_token' => 'irdb_svc_AAAA',
- 'api_base_url' => 'http://api:8081',
- 'oidc_enabled' => true,
- 'oidc_issuer' => 'https://issuer',
- 'oidc_client_id' => 'cid',
- 'oidc_client_secret' => 'csec',
- 'oidc_redirect_uri' => 'https://r/cb',
- 'local_admin_enabled' => false,
- 'local_admin_username' => '',
- 'local_admin_password_hash' => 'this-would-fail-if-checked',
- ];
- self::assertSame([], Config::collectErrors($settings));
- }
- /**
- * @return array<string, mixed>
- */
- private function localBaseSettings(string $hash): array
- {
- return [
- 'ui_service_token' => 'irdb_svc_AAAA',
- 'api_base_url' => 'http://api:8081',
- 'oidc_enabled' => false,
- 'local_admin_enabled' => true,
- 'local_admin_username' => 'admin',
- 'local_admin_password_hash' => $hash,
- ];
- }
- }
|