ConfigTest.php 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\App;
  4. use App\App\Config;
  5. use PHPUnit\Framework\TestCase;
  6. /**
  7. * Boot-time configuration validation.
  8. *
  9. * SEC_REVIEW F37: `LOCAL_ADMIN_PASSWORD_HASH` must be Argon2id or bcrypt
  10. * cost ≥ 12. Anything weaker — bcrypt cost 4, plain text, md5-crypt,
  11. * argon2i — is rejected at boot so a misconfigured deployment crashes
  12. * on `docker compose up` instead of accepting hashed-but-cracked
  13. * passwords on the local-admin path.
  14. */
  15. final class ConfigTest extends TestCase
  16. {
  17. public function testValidArgon2idIsAccepted(): void
  18. {
  19. $hash = password_hash('test', PASSWORD_ARGON2ID);
  20. self::assertSame([], Config::collectErrors($this->localBaseSettings($hash)));
  21. }
  22. public function testValidBcryptAtMinimumCostIsAccepted(): void
  23. {
  24. $hash = password_hash('test', PASSWORD_BCRYPT, ['cost' => Config::BCRYPT_MIN_COST]);
  25. self::assertSame([], Config::collectErrors($this->localBaseSettings($hash)));
  26. }
  27. public function testBcryptBelowMinimumCostIsRejected(): void
  28. {
  29. $hash = password_hash('test', PASSWORD_BCRYPT, ['cost' => 4]);
  30. $errors = Config::collectErrors($this->localBaseSettings($hash));
  31. self::assertNotEmpty($errors);
  32. $joined = implode("\n", $errors);
  33. self::assertStringContainsString('cost=4', $joined);
  34. self::assertStringContainsString('LOCAL_ADMIN_PASSWORD_HASH', $joined);
  35. }
  36. public function testArgon2iIsRejected(): void
  37. {
  38. if (!defined('PASSWORD_ARGON2I')) {
  39. self::markTestSkipped('argon2i not built into this PHP');
  40. }
  41. $hash = password_hash('test', PASSWORD_ARGON2I);
  42. $errors = Config::collectErrors($this->localBaseSettings($hash));
  43. self::assertNotEmpty($errors);
  44. self::assertStringContainsString('argon2i', implode("\n", $errors));
  45. }
  46. public function testMd5CryptIsRejected(): void
  47. {
  48. // crypt() with $1$ prefix produces md5-crypt, which password_get_info
  49. // reports as 'unknown'. The literal string SEC_REVIEW called out.
  50. $hash = '$1$salt1234$KdyIvFMZJKR1qDJ5qE5W31';
  51. $errors = Config::collectErrors($this->localBaseSettings($hash));
  52. self::assertNotEmpty($errors);
  53. self::assertStringContainsString('"unknown"', implode("\n", $errors));
  54. }
  55. public function testPlainStringIsRejected(): void
  56. {
  57. $errors = Config::collectErrors($this->localBaseSettings('not-a-hash-at-all'));
  58. self::assertNotEmpty($errors);
  59. }
  60. public function testEmptyHashIsStillRejectedWhenLocalEnabled(): void
  61. {
  62. $errors = Config::collectErrors($this->localBaseSettings(''));
  63. self::assertNotEmpty($errors);
  64. self::assertStringContainsString('empty', implode("\n", $errors));
  65. }
  66. public function testHashIsNotValidatedWhenLocalAdminDisabled(): void
  67. {
  68. // Operators who only run OIDC don't need a strong dummy hash.
  69. $settings = [
  70. 'ui_service_token' => 'irdb_svc_AAAA',
  71. 'api_base_url' => 'http://api:8081',
  72. 'oidc_enabled' => true,
  73. 'oidc_issuer' => 'https://issuer',
  74. 'oidc_client_id' => 'cid',
  75. 'oidc_client_secret' => 'csec',
  76. 'oidc_redirect_uri' => 'https://r/cb',
  77. 'local_admin_enabled' => false,
  78. 'local_admin_username' => '',
  79. 'local_admin_password_hash' => 'this-would-fail-if-checked',
  80. ];
  81. self::assertSame([], Config::collectErrors($settings));
  82. }
  83. /**
  84. * @return array<string, mixed>
  85. */
  86. private function localBaseSettings(string $hash): array
  87. {
  88. return [
  89. 'ui_service_token' => 'irdb_svc_AAAA',
  90. 'api_base_url' => 'http://api:8081',
  91. 'oidc_enabled' => false,
  92. 'local_admin_enabled' => true,
  93. 'local_admin_username' => 'admin',
  94. 'local_admin_password_hash' => $hash,
  95. ];
  96. }
  97. }