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));