181 lines
7.2 KiB
PHP
181 lines
7.2 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\ConfirmForgetSecret;
|
|
use App\Livewire\Admin\ConfirmSaveSecret;
|
|
use App\Livewire\Admin\Secrets;
|
|
use App\Livewire\Admin\Secrets as SecretsPage;
|
|
use App\Services\Secrets\SecretVault;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* The credentials page has two gates, and the second one is the point.
|
|
*
|
|
* The capability decides who may open it. The password decides whether THIS
|
|
* session may see or change anything — because the realistic threat is not a
|
|
* stranger but an unlocked machine, and a session is exactly what that gives
|
|
* away.
|
|
*/
|
|
beforeEach(function () {
|
|
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
});
|
|
|
|
it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one', function () {
|
|
// Codex R15#3, P2: render() feeds 'usable' => $vault->isUsable() to the
|
|
// view, and the banner is the one place an operator actually sees that
|
|
// verdict. Before the fix, a malformed (nonempty, wrong-length) key made
|
|
// isUsable() answer true, so this banner stayed hidden — the page said
|
|
// credentials could be stored here when they could not. Asserted before
|
|
// the lock gate specifically because @if (! $usable) sits above @if (!
|
|
// $unlocked) in the view; a signed-in-but-locked session must still see
|
|
// it.
|
|
config()->set('admin_access.secrets_key', 'zu-kurz');
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->test(SecretsPage::class)
|
|
->assertSee(__('secrets.no_key'));
|
|
});
|
|
|
|
it('is not reachable without the capability', function () {
|
|
// Every operator has console.view. That must not mean "can read the
|
|
// payment key".
|
|
Livewire::actingAs(operator('Admin'), 'operator')
|
|
->test(SecretsPage::class)
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('shows nothing until the password is confirmed', function () {
|
|
$owner = operator('Owner');
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner);
|
|
|
|
$page = Livewire::actingAs($owner, 'operator')->test(SecretsPage::class);
|
|
|
|
$page->assertSee(__('secrets.locked_title'))
|
|
// Not even the outline before unlocking.
|
|
->assertDontSee('9999')
|
|
->assertViewHas('unlocked', false);
|
|
});
|
|
|
|
it('refuses every action while locked, not merely hiding the buttons', function () {
|
|
// A Livewire action is reachable by anyone who can post to /livewire/update.
|
|
$owner = operator('Owner');
|
|
|
|
foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(SecretsPage::class)
|
|
->call($action, $arg)
|
|
->assertForbidden();
|
|
}
|
|
});
|
|
|
|
it('unlocks with the password, then stores and clears the entered value', function () {
|
|
$owner = operator('Owner');
|
|
|
|
$page = Livewire::actingAs($owner, 'operator')
|
|
->test(SecretsPage::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->assertHasNoErrors()
|
|
->assertViewHas('unlocked', true);
|
|
|
|
$page->set('entered.stripe_secret', 'sk_live_NEWKEY1234')
|
|
->call('save', 'stripe.secret')
|
|
->assertHasNoErrors();
|
|
|
|
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234')
|
|
// Out of the component state as soon as it is stored: a Livewire
|
|
// property travels to the browser and back in the snapshot.
|
|
->and($page->get('entered')['stripe_secret'])->toBe('');
|
|
});
|
|
|
|
it('never puts a stored secret into the component snapshot', function () {
|
|
$owner = operator('Owner');
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner);
|
|
|
|
$page = Livewire::actingAs($owner, 'operator')
|
|
->test(SecretsPage::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword');
|
|
|
|
expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42');
|
|
});
|
|
|
|
it('can be locked again without signing out', function () {
|
|
$owner = operator('Owner');
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(SecretsPage::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->assertViewHas('unlocked', true)
|
|
->call('forgetPasswordConfirmation')
|
|
->assertViewHas('unlocked', false);
|
|
});
|
|
|
|
// R23: confirmation moved from wire:confirm to ConfirmSaveSecret /
|
|
// ConfirmForgetSecret. Neither modal can see Secrets' own (deferred)
|
|
// `entered` state, so they dispatch back to the page instead of mutating
|
|
// anything themselves — save()/forget() keep their existing guard().
|
|
|
|
it('does not open the save/forget confirmation without the capability', function () {
|
|
Livewire::actingAs(operator('Admin'), 'operator')
|
|
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
|
|
->assertForbidden();
|
|
|
|
Livewire::actingAs(operator('Admin'), 'operator')
|
|
->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret'])
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('confirms a save through the modal without storing anything itself', function () {
|
|
$owner = operator('Owner');
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
|
|
->assertSee(__('secrets.item.stripe_secret'))
|
|
->call('confirm')
|
|
->assertDispatched('secret-save-confirmed', key: 'stripe.secret');
|
|
|
|
// The modal only asked; nothing is stored until the page itself saves.
|
|
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
|
|
});
|
|
|
|
it('saves the typed value once the page receives the confirmed event', function () {
|
|
// Exercises the forwarding listener directly — the same round trip a
|
|
// browser produces by dispatching openModal then confirm (checked
|
|
// manually against admin.dev.clupilot.com, since a global browser event
|
|
// reaching a sibling component's #[On] listener is not something a
|
|
// single-component Livewire::test() can simulate).
|
|
$owner = operator('Owner');
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(SecretsPage::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->set('entered.stripe_secret', 'sk_live_VIACONFIRM1')
|
|
->call('onSaveConfirmed', 'stripe.secret')
|
|
->assertHasNoErrors();
|
|
|
|
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1');
|
|
});
|
|
|
|
it('forgets the stored value once the page receives the confirmed event', function () {
|
|
$owner = operator('Owner');
|
|
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner);
|
|
|
|
Livewire::actingAs($owner, 'operator')
|
|
->test(SecretsPage::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->call('onForgetConfirmed', 'stripe.secret');
|
|
|
|
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
|
|
});
|
|
|
|
it('binds the form to a key Livewire can actually write to', function () {
|
|
// A dot in a Livewire property path means nesting, so a registry key with a
|
|
// dot in it would be written to entered['stripe']['secret'] and the value
|
|
// would never reach the save.
|
|
expect(Secrets::field('stripe.secret'))->toBe('stripe_secret')
|
|
->and(str_contains(Secrets::field('stripe.secret'), '.'))->toBeFalse();
|
|
});
|