|
@@ -0,0 +1,109 @@
|
|
|
|
|
+<?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,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|