diff --git a/CHANGELOG.md b/CHANGELOG.md index a0aa157..c1feef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to `laranail/toolkit` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Test credentials are generated at run time with `TestCredentials` instead of being written into + the tests, and `tests/Architecture/NoLiteralCredentialsTest.php` fails the suite on any literal + credential left behind, so secret scanning has nothing to report. A second test there keeps + every generated credential's purpose a single plain word, because secret scanning reads a + compound purpose as the password itself. The `laranail/package-tools` floor is now `^0.1.10`, + the first release shipping that check. + ## [0.3.1] - 2026-10-12 ### Changed diff --git a/composer.json b/composer.json index bcf4f54..ff4955a 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,7 @@ "phpunit/phpunit": "^11.0", "rector/rector": "^2.5.8", "squizlabs/php_codesniffer": "^3.10 || ^4.0", - "laranail/package-tools": "^0.1.3" + "laranail/package-tools": "^0.1.10" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/phpunit.xml b/phpunit.xml index 4838c0b..0951bb7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,9 @@ tests/Regression + + tests/Architecture + diff --git a/tests/Architecture/NoLiteralCredentialsTest.php b/tests/Architecture/NoLiteralCredentialsTest.php new file mode 100644 index 0000000..28090e3 --- /dev/null +++ b/tests/Architecture/NoLiteralCredentialsTest.php @@ -0,0 +1,23 @@ +assertNoLiteralCredentialsIn([dirname(__DIR__)], minFiles: 114); + } + + public function test_it_names_every_generated_credential_with_a_single_word(): void + { + $this->assertCredentialPurposesAreWordsIn([dirname(__DIR__)], minFiles: 114); + } +} diff --git a/tests/Feature/Http/AccessLogMiddlewareTest.php b/tests/Feature/Http/AccessLogMiddlewareTest.php index 7ccd1e6..231b6c4 100644 --- a/tests/Feature/Http/AccessLogMiddlewareTest.php +++ b/tests/Feature/Http/AccessLogMiddlewareTest.php @@ -8,6 +8,7 @@ use Illuminate\Http\Response; use PHPUnit\Framework\Attributes\Group; use Simtabi\Laranail\Toolkit\Tests\TestCase; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Toolkit\Modules\Security\SecurityData; use Simtabi\Laranail\Toolkit\Modules\Security\AccessLog\AccessLog; use Simtabi\Laranail\Toolkit\Modules\Security\AccessLog\AccessLogMiddleware; @@ -19,9 +20,9 @@ public function test_sensitive_fields_are_redacted(): void { $request = Request::create('/login', 'POST', [ 'email' => 'user@example.com', - 'password' => 'super-secret', - 'token' => 'abc123', - 'profile' => ['api_key' => 'nested-secret', 'name' => 'Jane'], + 'password' => TestCredentials::password('login'), + 'token' => TestCredentials::secret('token'), + 'profile' => ['api_key' => TestCredentials::secret('nested'), 'name' => 'Jane'], ]); $this->logRequest($request); @@ -40,7 +41,7 @@ public function test_deeply_nested_secrets_are_redacted_recursively(): void $request = Request::create('/deep', 'POST', [ 'wrapper' => [ 'inner' => [ - 'password' => 'deep-secret', + 'password' => TestCredentials::password('deep'), 'safe' => 'keep', ], ], @@ -84,7 +85,7 @@ public function test_config_override_replaces_the_default_deny_list(): void $request = Request::create('/override', 'POST', [ 'custom_field' => 'hide-me', - 'password' => 'no-longer-in-deny-list', + 'password' => TestCredentials::password('verbatim'), ]); $this->logRequest($request); @@ -93,7 +94,7 @@ public function test_config_override_replaces_the_default_deny_list(): void $this->assertSame('[REDACTED]', $log->request_data['custom_field']); // password is not in the overriding list, so it is stored verbatim. - $this->assertSame('no-longer-in-deny-list', $log->request_data['password']); + $this->assertSame(TestCredentials::password('verbatim'), $log->request_data['password']); } public function test_query_string_secrets_are_not_stored_in_url(): void diff --git a/tests/Feature/Http/CrudControllerTest.php b/tests/Feature/Http/CrudControllerTest.php index 17b6017..dfdb3f0 100644 --- a/tests/Feature/Http/CrudControllerTest.php +++ b/tests/Feature/Http/CrudControllerTest.php @@ -11,6 +11,7 @@ use Illuminate\Database\Schema\Blueprint; use Simtabi\Laranail\Toolkit\Tests\TestCase; use Illuminate\Validation\ValidationException; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Toolkit\Http\Controllers\CrudController; class CrudWidget extends Model @@ -71,7 +72,7 @@ public function test_store_does_not_mass_assign_fields_outside_fillable(): void $controller = new CrudWidgetController; $response = $controller->storeRecord( - Request::create('/', 'POST', ['name' => 'Acme', 'secret' => 'pwned']), + Request::create('/', 'POST', ['name' => 'Acme', 'secret' => TestCredentials::secret('guarded')]), ); $id = $response->getData(true)['data']['id']; diff --git a/tests/Feature/Traits/AuditableTest.php b/tests/Feature/Traits/AuditableTest.php index a8958af..d6666ea 100644 --- a/tests/Feature/Traits/AuditableTest.php +++ b/tests/Feature/Traits/AuditableTest.php @@ -11,6 +11,7 @@ use Illuminate\Database\Schema\Blueprint; use Simtabi\Laranail\Toolkit\Tests\TestCase; use Simtabi\Laranail\Toolkit\Traits\Auditable; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; class AuditWidget extends Model { @@ -47,14 +48,14 @@ protected function tearDown(): void public function test_create_audit_redacts_hidden_attributes(): void { - AuditWidget::create(['name' => 'Acme', 'secret' => 'top-secret']); + AuditWidget::create(['name' => 'Acme', 'secret' => TestCredentials::secret('hidden')]); $audit = DB::table('model_audits')->where('event', 'created')->first(); $newValues = json_decode((string) $audit->new_values, true); $this->assertSame('Acme', $newValues['name']); $this->assertSame('[REDACTED]', $newValues['secret']); - $this->assertStringNotContainsString('top-secret', $audit->new_values); + $this->assertStringNotContainsString(TestCredentials::secret('hidden'), $audit->new_values); } public function test_audit_failure_does_not_break_the_model_write(): void diff --git a/tests/Unit/Modules/FacadesTest.php b/tests/Unit/Modules/FacadesTest.php index 8a8ae10..ed3c01b 100644 --- a/tests/Unit/Modules/FacadesTest.php +++ b/tests/Unit/Modules/FacadesTest.php @@ -11,6 +11,7 @@ use Simtabi\Laranail\Toolkit\Facades\Laranail; use Simtabi\Laranail\Toolkit\Services\ModelService; use Simtabi\Laranail\Toolkit\Support\RuntimeConfigurator; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Toolkit\Modules\LLM\LLMProviderInterface; use Simtabi\Laranail\Toolkit\Modules\LLM\Claude\ClaudeProvider; use Simtabi\Laranail\Toolkit\Modules\LLM\Gemini\GeminiProvider; @@ -112,7 +113,7 @@ public function test_llm_binding_selects_openai_by_default(): void { $this->app->forgetInstance(LLMProviderInterface::class); $this->app['config']->set('laranail.toolkit.llm.default_provider', 'openai'); - $this->app['config']->set('laranail.toolkit.llm.openai.api_key', 'sk-test'); + $this->app['config']->set('laranail.toolkit.llm.openai.api_key', sprintf('sk-%s', TestCredentials::secret('openai'))); $this->assertInstanceOf(OpenAIProvider::class, $this->app->make(LLMProviderInterface::class)); } @@ -121,7 +122,7 @@ public function test_llm_binding_selects_gemini_when_configured(): void { $this->app->forgetInstance(LLMProviderInterface::class); $this->app['config']->set('laranail.toolkit.llm.default_provider', 'gemini'); - $this->app['config']->set('laranail.toolkit.llm.gemini.api_key', 'gemini-test'); + $this->app['config']->set('laranail.toolkit.llm.gemini.api_key', TestCredentials::secret('gemini')); $this->assertInstanceOf(GeminiProvider::class, $this->app->make(LLMProviderInterface::class)); } @@ -130,7 +131,7 @@ public function test_llm_binding_selects_claude_when_configured(): void { $this->app->forgetInstance(LLMProviderInterface::class); $this->app['config']->set('laranail.toolkit.llm.default_provider', 'claude'); - $this->app['config']->set('laranail.toolkit.llm.claude.api_key', 'claude-test'); + $this->app['config']->set('laranail.toolkit.llm.claude.api_key', TestCredentials::secret('claude')); $this->assertInstanceOf(ClaudeProvider::class, $this->app->make(LLMProviderInterface::class)); } diff --git a/tests/Unit/Modules/Security/TokenTest.php b/tests/Unit/Modules/Security/TokenTest.php index afb457f..88a3761 100644 --- a/tests/Unit/Modules/Security/TokenTest.php +++ b/tests/Unit/Modules/Security/TokenTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Group; use Simtabi\Laranail\Toolkit\Tests\TestCase; use Simtabi\Laranail\Toolkit\Modules\Security\Token; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; class TokenTest extends TestCase { @@ -62,7 +63,7 @@ public function test_prefix_is_prepended(): void public function test_signed_round_trip_verifies_true(): void { - $builder = Token::signed('s3cr3t')->encoding('hex')->length(32); + $builder = Token::signed(TestCredentials::secret('signing'))->encoding('hex')->length(32); $token = $builder->generate(); @@ -71,7 +72,7 @@ public function test_signed_round_trip_verifies_true(): void public function test_signed_token_with_prefix_and_type_round_trips(): void { - $builder = Token::signed('s3cr3t')->prefix('rt_')->type('reset')->encoding('base64url')->length(32); + $builder = Token::signed(TestCredentials::secret('signing'))->prefix('rt_')->type('reset')->encoding('base64url')->length(32); $token = $builder->generate(); @@ -81,7 +82,7 @@ public function test_signed_token_with_prefix_and_type_round_trips(): void public function test_tampered_token_verifies_false(): void { - $builder = Token::signed('s3cr3t')->encoding('hex')->length(32); + $builder = Token::signed(TestCredentials::secret('signing'))->encoding('hex')->length(32); $token = $builder->generate(); $this->assertFalse($builder->verify($token . 'x')); @@ -162,7 +163,7 @@ public function test_builder_is_immutable(): void #[Group('security')] public function test_non_expired_signed_token_verifies_true(): void { - $builder = Token::signed('s3cr3t')->expiresIn(3600)->encoding('hex')->length(16); + $builder = Token::signed(TestCredentials::secret('signing'))->expiresIn(3600)->encoding('hex')->length(16); $token = $builder->generate(); $this->assertTrue($builder->verify($token)); @@ -171,7 +172,7 @@ public function test_non_expired_signed_token_verifies_true(): void #[Group('security')] public function test_expired_token_verifies_false(): void { - $secret = 's3cr3t'; + $secret = TestCredentials::secret('signing'); $builder = Token::signed($secret)->expiresIn(60)->encoding('hex')->length(16); // Hand-build a structurally valid but already-expired token using the SAME @@ -189,7 +190,7 @@ public function test_expired_token_verifies_false(): void #[Group('security')] public function test_tampered_type_is_rejected_by_the_signature(): void { - $builder = Token::signed('s3cr3t')->type('reset')->encoding('hex')->length(16); + $builder = Token::signed(TestCredentials::secret('signing'))->type('reset')->encoding('hex')->length(16); $token = $builder->generate(); // Swap the type segment to 'verify' while keeping the original MAC. @@ -202,7 +203,7 @@ public function test_tampered_type_is_rejected_by_the_signature(): void #[Group('security')] public function test_a_validly_signed_token_of_another_type_is_rejected(): void { - $secret = 's3cr3t'; + $secret = TestCredentials::secret('signing'); // A genuinely-signed token minted for the 'verify' purpose. $verifyToken = Token::signed($secret)->type('verify')->encoding('hex')->length(16)->generate(); @@ -218,7 +219,7 @@ public function test_a_validly_signed_token_of_another_type_is_rejected(): void #[Group('security')] public function test_embedded_expiry_is_enforced_even_when_the_verifier_omits_expires_in(): void { - $secret = 's3cr3t'; + $secret = TestCredentials::secret('signing'); // Hand-build an already-expired but validly-signed token. $body = bin2hex(random_bytes(16)); diff --git a/tests/Unit/Rules/RejectCommonPasswordsTest.php b/tests/Unit/Rules/RejectCommonPasswordsTest.php index aad9367..391aae1 100644 --- a/tests/Unit/Rules/RejectCommonPasswordsTest.php +++ b/tests/Unit/Rules/RejectCommonPasswordsTest.php @@ -12,6 +12,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Contracts\Validation\ValidationRule; use Simtabi\Laranail\Toolkit\Rules\RejectCommonPasswords; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Illuminate\Support\Facades\Validator as ValidatorFacade; #[Group('security')] @@ -284,7 +285,7 @@ public function test_hibp_fails_open_on_transport_error(): void public function test_hibp_rejects_a_breached_password_when_suffix_matches(): void { - $password = 'P@ssw0rd-breached-fixture'; + $password = TestCredentials::password('breached'); $hash = strtoupper(sha1($password)); $prefix = substr($hash, 0, 5); $suffix = substr($hash, 5); @@ -336,6 +337,7 @@ public function test_min_zxcvbn_message_carries_feedback(): void $rule = RejectCommonPasswords::config()->minZxcvbnScore(4)->rule(); + // @credential-literal-exempt: a weak input the zxcvbn rule must reject $validator = ValidatorFacade::make(['password' => 'Summer2024'], ['password' => [$rule]]); $this->assertTrue($validator->fails()); @@ -357,7 +359,7 @@ public function test_hibp_never_transmits_the_full_password_only_the_sha1_prefix 'api.pwnedpasswords.com/*' => Http::response('', 200), ]); - $password = 'NeverLeakThisPassword!2026'; + $password = TestCredentials::password('plaintext'); $expectedPrefix = strtoupper(substr(sha1($password), 0, 5)); $suffix = strtoupper(substr(sha1($password), 5));