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('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()); Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['id' => 'prod_1'], 200)]); app(App\Services\Stripe\HttpStripeClient::class)->createProduct('Test'); Illuminate\Support\Facades\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); });