136 lines
5.6 KiB
PHP
136 lines
5.6 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use App\Services\Secrets\SecretVault;
|
|
use App\Services\Stripe\HttpStripeClient;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
/**
|
|
* Credentials stored in the database instead of the .env file.
|
|
*
|
|
* The stakes are a live payment key, so what is tested here is mostly what the
|
|
* vault REFUSES to do.
|
|
*/
|
|
beforeEach(function () {
|
|
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
config()->set('services.stripe.secret', 'sk_env_fallback');
|
|
});
|
|
|
|
it('prefers a stored key over the environment, and says which is in force', function () {
|
|
$vault = app(SecretVault::class);
|
|
|
|
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback')
|
|
->and($vault->source('stripe.secret'))->toBe('environment');
|
|
|
|
$vault->put('stripe.secret', 'sk_live_stored_value', User::factory()->create());
|
|
|
|
expect($vault->get('stripe.secret'))->toBe('sk_live_stored_value')
|
|
->and($vault->source('stripe.secret'))->toBe('stored');
|
|
|
|
// Removing it falls back rather than leaving nothing.
|
|
$vault->forget('stripe.secret');
|
|
expect($vault->get('stripe.secret'))->toBe('sk_env_fallback');
|
|
});
|
|
|
|
it('stores the value encrypted, and never in the clear', function () {
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_verysecret', User::factory()->create());
|
|
|
|
$stored = DB::table('app_secrets')->where('key', 'stripe.secret')->value('value');
|
|
|
|
expect($stored)->not->toContain('sk_live_verysecret');
|
|
});
|
|
|
|
it('shows only an outline, never the value', function () {
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH1234', User::factory()->create());
|
|
|
|
$outline = app(SecretVault::class)->outline('stripe.secret');
|
|
|
|
expect($outline)->toContain('1234')->and($outline)->not->toContain('ABCDEFGH');
|
|
});
|
|
|
|
it('refuses a key it does not know', function () {
|
|
// A form that can set any environment variable is a privilege-escalation
|
|
// primitive. The registry is the whole point.
|
|
expect(fn () => app(SecretVault::class)->put('app.key', 'whatever', User::factory()->create()))
|
|
->toThrow(RuntimeException::class);
|
|
});
|
|
|
|
it('refuses to store or read anything without its own key', function () {
|
|
// Falling back to APP_KEY would put payment credentials under a key that
|
|
// gets rotated as routine maintenance.
|
|
config()->set('admin_access.secrets_key', '');
|
|
|
|
expect(app(SecretVault::class)->isUsable())->toBeFalse()
|
|
->and(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create()))
|
|
->toThrow(RuntimeException::class);
|
|
});
|
|
|
|
it('reports unusable for a malformed key too, not only an empty one', function () {
|
|
// Codex R15#3, P2: SecretVault::isUsable() only delegates to
|
|
// SecretCipher::isUsable() — this proves the delegation actually carries
|
|
// the fixed verdict through, rather than assuming it does because the
|
|
// one call is a one-liner.
|
|
config()->set('admin_access.secrets_key', 'viel-zu-kurz');
|
|
|
|
expect(app(SecretVault::class)->isUsable())->toBeFalse();
|
|
});
|
|
|
|
it('fails loudly when a stored value cannot be decrypted, instead of using the old one', function () {
|
|
// "The key cannot be read" and "there is no key" call for different
|
|
// actions, and only one of them is fixed by typing it in again.
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_stored', User::factory()->create());
|
|
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
|
|
expect(fn () => app(SecretVault::class)->get('stripe.secret'))->toThrow(RuntimeException::class);
|
|
});
|
|
|
|
it('hands the Stripe client the stored key rather than the environment one', function () {
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_from_console', User::factory()->create());
|
|
|
|
Http::fake(['*' => Http::response(['id' => 'prod_1'], 200)]);
|
|
|
|
app(HttpStripeClient::class)->createProduct('Test');
|
|
|
|
Http::assertSent(
|
|
fn ($request) => $request->hasHeader('Authorization', 'Bearer sk_live_from_console'),
|
|
);
|
|
});
|
|
|
|
it('accepts the key in the format the documentation tells you to generate', function () {
|
|
// `head -c 32 /dev/urandom | base64` produces raw base64 with no prefix.
|
|
// Requiring the prefix made every read and write fail on exactly the
|
|
// documented setup — a 44-byte key where AES-256 wants 32.
|
|
$raw = random_bytes(32);
|
|
|
|
foreach ([base64_encode($raw), 'base64:'.base64_encode($raw)] as $spelling) {
|
|
config()->set('admin_access.secrets_key', $spelling);
|
|
|
|
$vault = app(SecretVault::class);
|
|
$vault->put('stripe.secret', 'sk_live_roundtrip', User::factory()->create());
|
|
|
|
expect($vault->get('stripe.secret'))->toBe('sk_live_roundtrip', $spelling);
|
|
}
|
|
});
|
|
|
|
it('refuses a key that is not 32 bytes rather than failing later', function () {
|
|
config()->set('admin_access.secrets_key', 'viel-zu-kurz');
|
|
|
|
expect(fn () => app(SecretVault::class)->put('stripe.secret', 'sk_x', User::factory()->create()))
|
|
->toThrow(RuntimeException::class);
|
|
});
|
|
|
|
it('keeps the original creation date when a key is rotated', function () {
|
|
// The one piece of audit metadata that answers "how long has this been
|
|
// here?" must survive a rotation.
|
|
$user = User::factory()->create();
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_first', $user);
|
|
|
|
$created = DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at');
|
|
|
|
$this->travel(2)->days();
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_second', $user);
|
|
|
|
expect(DB::table('app_secrets')->where('key', 'stripe.secret')->value('created_at'))->toBe($created);
|
|
});
|