diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bafe7..cbdae7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- **Test credentials are generated at run time.** The provider secrets the suite configured as + literals are now `TestCredentials::secret('')`, so no literal a secret scanner would + report reaches the repository, and `tests/Arch/NoLiteralCredentialsTest.php` fails on any literal + added later. The `laranail/package-tools` floor is now `^0.1.10`, and a second check keeps every + purpose passed to `TestCredentials` a single plain word, since a secret scanner reads a compound + purpose as the password itself. - The components render through `laranail/captcha::components.*`. `laranail-captcha::` still resolves the same files. - Docs, the README and the install command lead with the scoped tags and rule. diff --git a/composer.json b/composer.json index db0fc4e..64501dd 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "laranail/console": "^0.1", "laranail/db-tools": "^0.1", "laranail/enumerator": "^0.1", - "laranail/package-tools": "^0.1.3", + "laranail/package-tools": "^0.1.10", "psr/clock": "^1.0", "psr/log": "^3.0" }, diff --git a/tests/Arch/NoLiteralCredentialsTest.php b/tests/Arch/NoLiteralCredentialsTest.php new file mode 100644 index 0000000..1619832 --- /dev/null +++ b/tests/Arch/NoLiteralCredentialsTest.php @@ -0,0 +1,17 @@ +assertNoLiteralCredentialsIn([dirname(__DIR__)], minFiles: 24); + expect(true)->toBeTrue(); +}); + +it('names every generated credential with a single word', function (): void { + $this->assertCredentialPurposesAreWordsIn([dirname(__DIR__)], minFiles: 24); + expect(true)->toBeTrue(); +}); diff --git a/tests/Feature/AdapterContractTest.php b/tests/Feature/AdapterContractTest.php index 83fc7f1..bf4a1cb 100644 --- a/tests/Feature/AdapterContractTest.php +++ b/tests/Feature/AdapterContractTest.php @@ -8,6 +8,7 @@ use Simtabi\Laranail\Captcha\Enums\ErrorCode; use Illuminate\Http\Client\ConnectionException; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Captcha\ValueObjects\VerificationContext; /** @@ -30,7 +31,7 @@ foreach (httpProviders() as $provider) { $credentials[$provider->value] = [ 'site_key' => 'site-key', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), 'project_id' => 'demo-project', 'client' => 'demo', ]; @@ -115,13 +116,13 @@ function httpProviders(): array })->with('http adapters'); it('never leaks the secret into the failure it returns', function (Provider $provider): void { - Http::fake(fn () => throw new ConnectionException('POST failed: secret=secret-key')); + Http::fake(fn () => throw new ConnectionException('POST failed: secret=' . TestCredentials::secret('captcha'))); $result = app(AdapterFactory::class)->make($provider)->verify('a-token', VerificationContext::none()); // A Guzzle RequestException stringifies the request it failed on, and that request body // carries the secret. Reporting the exception verbatim is how a secret reaches the log. - expect(json_encode($result->toArray()))->not->toContain('secret-key'); + expect(json_encode($result->toArray()))->not->toContain(TestCredentials::secret('captcha')); })->with('http adapters'); it('passes a genuine success through', function (Provider $provider, array $body): void { diff --git a/tests/Feature/BrowserRuntimeTest.php b/tests/Feature/BrowserRuntimeTest.php index 92b49ee..7131f85 100644 --- a/tests/Feature/BrowserRuntimeTest.php +++ b/tests/Feature/BrowserRuntimeTest.php @@ -6,6 +6,7 @@ use Symfony\Component\Process\Process; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * Runs the widget in a real browser. @@ -118,8 +119,8 @@ function writeManifest(array $pages): void // Three shapes, because the runtime branches on all three: a server-rendered challenge with no // vendor script, a hosted widget holding live state, and a provider with nothing to click. writeFixture('math', 'math', []); - writeFixture('turnstile', 'turnstile', ['site_key' => 'site-key-abc', 'secret' => 'secret-key']); - writeFixture('recaptcha-v3', 'recaptcha-v3', ['site_key' => 'site-key-abc', 'secret' => 'secret-key']); + writeFixture('turnstile', 'turnstile', ['site_key' => 'site-key-abc', 'secret' => TestCredentials::secret('captcha')]); + writeFixture('recaptcha-v3', 'recaptcha-v3', ['site_key' => 'site-key-abc', 'secret' => TestCredentials::secret('captcha')]); writeManifest(['math', 'turnstile', 'recaptcha-v3']); diff --git a/tests/Feature/CommandsTest.php b/tests/Feature/CommandsTest.php index 7437616..8d6ce58 100644 --- a/tests/Feature/CommandsTest.php +++ b/tests/Feature/CommandsTest.php @@ -6,6 +6,7 @@ use Simtabi\Laranail\Captcha\Enums\Provider; use Simtabi\Laranail\Captcha\Models\CaptchaSetting; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * The commands, actually run. @@ -44,7 +45,7 @@ config()->set('laranail.captcha.provider', 'turnstile'); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abcdef', - 'secret' => 'a-secret', + 'secret' => TestCredentials::secret('captcha'), ]); config()->set('laranail.captcha.verification.enforce_hostname', true); config()->set('laranail.captcha.verification.allowed_hostnames', []); @@ -71,7 +72,7 @@ it('shows where every provider resolves its credentials from', function (): void { config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abcdef', - 'secret' => 'a-secret', + 'secret' => TestCredentials::secret('captcha'), ]); app()->forgetInstance(CredentialStore::class); @@ -83,13 +84,13 @@ it('never prints a secret', function (): void { config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abcdef', - 'secret' => 'the-actual-secret', + 'secret' => TestCredentials::secret('printed'), ]); app()->forgetInstance(CredentialStore::class); $this->artisan('laranail::captcha.keys')->assertSuccessful(); - expect(app(Kernel::class)->output())->not->toContain('the-actual-secret'); + expect(app(Kernel::class)->output())->not->toContain(TestCredentials::secret('printed')); }); it('publishes the configuration', function (): void { diff --git a/tests/Feature/CredentialResolutionTest.php b/tests/Feature/CredentialResolutionTest.php index 8aa3853..cd4f46e 100644 --- a/tests/Feature/CredentialResolutionTest.php +++ b/tests/Feature/CredentialResolutionTest.php @@ -7,6 +7,7 @@ use Simtabi\Laranail\Captcha\Models\CaptchaSetting; use Simtabi\Laranail\Captcha\Enums\CredentialSource; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * The resolution order the package promises: database, then config, then test keys. @@ -19,7 +20,7 @@ config()->set('laranail.captcha.credentials.database.enabled', true); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'config-site-key', - 'secret' => 'config-secret', + 'secret' => TestCredentials::secret('config'), ]); app()->forgetInstance(CredentialStore::class); @@ -39,14 +40,17 @@ function setting(string $environment, string $key, string $value): void // Written through the model so the encrypted cast round-trips exactly as it will in an // application. A raw builder insert would bypass the cast and prove nothing about the read. setting('testing', 'site_key', 'db-site-key'); - setting('testing', 'secret', 'db-secret'); + setting('testing', 'secret', TestCredentials::secret('database')); $credentials = app(CredentialStore::class)->get(Provider::Turnstile, 'testing'); + // The secret assertion below proves the row won only while the two secrets differ. + expect(TestCredentials::secret('database'))->not->toBe(TestCredentials::secret('config')); + // An operator changing a key in an admin UI has to beat the .env the application booted with, // or the admin UI is decorative. expect($credentials?->siteKey)->toBe('db-site-key') - ->and($credentials?->secret)->toBe('db-secret') + ->and($credentials?->secret)->toBe(TestCredentials::secret('database')) ->and($credentials?->source)->toBe(CredentialSource::Database); }); @@ -59,7 +63,7 @@ function setting(string $environment, string $key, string $value): void it('scopes rows to the environment they were written for', function (): void { setting('production', 'site_key', 'prod-site-key'); - setting('production', 'secret', 'prod-secret'); + setting('production', 'secret', TestCredentials::secret('production')); // A production row must not answer for a staging lookup. This is the whole reason the // environment column is not nullable. diff --git a/tests/Feature/RuntimeScriptTest.php b/tests/Feature/RuntimeScriptTest.php index 52e52b7..8246586 100644 --- a/tests/Feature/RuntimeScriptTest.php +++ b/tests/Feature/RuntimeScriptTest.php @@ -6,6 +6,7 @@ use Symfony\Component\Process\Process; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; /** * Runs the emitted JavaScript, rather than asserting it was emitted. @@ -28,7 +29,7 @@ function extractRuntime(): string config()->set('laranail.captcha.provider', 'turnstile'); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key-abc', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), ]); app()->forgetInstance(CaptchaService::class); diff --git a/tests/Feature/Security/PostVerificationChecksTest.php b/tests/Feature/Security/PostVerificationChecksTest.php index 7ce37ae..64ef3bf 100644 --- a/tests/Feature/Security/PostVerificationChecksTest.php +++ b/tests/Feature/Security/PostVerificationChecksTest.php @@ -8,6 +8,7 @@ use Simtabi\Laranail\Captcha\Enums\ErrorCode; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; use Simtabi\Laranail\Captcha\ValueObjects\VerificationContext; /** @@ -22,7 +23,7 @@ config()->set('laranail.captcha.provider', 'turnstile'); config()->set('laranail.captcha.environments.default.turnstile', [ 'site_key' => 'site-key', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), ]); freezeClockAt('2026-08-12T12:00:00+00:00'); @@ -132,7 +133,7 @@ function turnstileAnswers(array $overrides = []): void it('blocks a score below the review threshold', function (): void { config()->set('laranail.captcha.provider', 'recaptcha-v3'); config()->set('laranail.captcha.environments.default.recaptcha-v3', [ - 'site_key' => 'site-key', 'secret' => 'secret-key', + 'site_key' => 'site-key', 'secret' => TestCredentials::secret('captcha'), ]); Http::fake(fn () => Http::response(['success' => true, 'score' => 0.1], 200)); @@ -144,7 +145,7 @@ function turnstileAnswers(array $overrides = []): void it('marks a middling score for review rather than blocking it', function (): void { config()->set('laranail.captcha.provider', 'recaptcha-v3'); config()->set('laranail.captcha.environments.default.recaptcha-v3', [ - 'site_key' => 'site-key', 'secret' => 'secret-key', + 'site_key' => 'site-key', 'secret' => TestCredentials::secret('captcha'), ]); Http::fake(fn () => Http::response(['success' => true, 'score' => 0.4], 200)); @@ -159,7 +160,7 @@ function turnstileAnswers(array $overrides = []): void it('allows a high score outright', function (): void { config()->set('laranail.captcha.provider', 'recaptcha-v3'); config()->set('laranail.captcha.environments.default.recaptcha-v3', [ - 'site_key' => 'site-key', 'secret' => 'secret-key', + 'site_key' => 'site-key', 'secret' => TestCredentials::secret('captcha'), ]); Http::fake(fn () => Http::response(['success' => true, 'score' => 0.9], 200)); diff --git a/tests/Feature/WidgetTest.php b/tests/Feature/WidgetTest.php index 328ac0c..3ec7319 100644 --- a/tests/Feature/WidgetTest.php +++ b/tests/Feature/WidgetTest.php @@ -6,13 +6,14 @@ use Simtabi\Laranail\Captcha\Enums\Provider; use Simtabi\Laranail\Captcha\Services\CaptchaService; use Simtabi\Laranail\Captcha\Contracts\CredentialStore; +use Simtabi\Laranail\Package\Tools\Testing\TestCredentials; function useProvider(string $provider): void { config()->set('laranail.captcha.provider', $provider); config()->set("laranail.captcha.environments.default.{$provider}", [ 'site_key' => 'site-key-abc', - 'secret' => 'secret-key', + 'secret' => TestCredentials::secret('captcha'), ]); app()->forgetInstance(CaptchaService::class);