699 lines
28 KiB
PHP
699 lines
28 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Security\IDOR;
|
|
|
|
use App\Http\Livewire\Profiles\Manage as ProfilesManage;
|
|
use App\Models\Account;
|
|
use App\Models\Bank;
|
|
use App\Models\Organization;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Livewire\Livewire;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Bank-manager Accounts-scope IDOR Tests
|
|
*
|
|
* A regular (non-central) bank-manager can now open the Profiles\Manage
|
|
* component to edit accounts / issue reversals for their own bank's clients.
|
|
* These tests verify that scoping is enforced server-side and cannot be
|
|
* bypassed by manipulating the profile ID / model name passed to Livewire
|
|
* methods, and that non-accounts actions stay out of reach entirely.
|
|
*
|
|
* @group security
|
|
* @group idor
|
|
* @group critical
|
|
*/
|
|
class BankManagerAccountsScopeIDORTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// organization_roles has no migration in this repo (pre-existing gap —
|
|
// the table only exists on the dev DB, created out-of-band). Create it
|
|
// here so RefreshDatabase-based tests can exercise OrganizationRole::
|
|
// lookups (Manage.php queries this table when rendering the modals).
|
|
if (!DB::getSchemaBuilder()->hasTable('organization_roles')) {
|
|
DB::getSchemaBuilder()->create('organization_roles', function ($table) {
|
|
$table->id();
|
|
$table->string('name');
|
|
$table->string('label');
|
|
$table->longText('permissions');
|
|
$table->boolean('is_system')->default(false);
|
|
$table->timestamps();
|
|
$table->unique('name');
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log the given user into the web guard (Spatie roles live on User) and
|
|
* the given bank into the bank guard (the active elevated profile),
|
|
* mirroring how SwitchGuardTrait leaves both guards authenticated.
|
|
*/
|
|
private function actingAsBankManager(User $user, Bank $bank): void
|
|
{
|
|
$role = Role::firstOrCreate([
|
|
'name' => "Bank\\{$bank->id}\\bank-manager",
|
|
'guard_name' => 'web',
|
|
]);
|
|
$user->assignRole($role);
|
|
|
|
$this->actingAs($user, 'web');
|
|
$this->actingAs($bank, 'bank');
|
|
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
|
}
|
|
|
|
private function attachClient(Bank $bank, $client): void
|
|
{
|
|
DB::table('bank_clients')->insert([
|
|
'bank_id' => $bank->id,
|
|
'client_type' => get_class($client),
|
|
'client_id' => $client->id,
|
|
'relationship_type' => 'local',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/** @test */
|
|
public function bank_manager_can_open_accounts_for_own_bank_client()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create(); // level defaults to 1 (non-central)
|
|
|
|
$client = User::factory()->create();
|
|
$this->attachClient($bank, $client);
|
|
Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $client->id,
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $client->id, User::class);
|
|
|
|
$component->assertStatus(200);
|
|
$component->assertHasNoErrors();
|
|
$this->assertSame($client->id, $component->get('editAccounts')['id']);
|
|
}
|
|
|
|
/** @test */
|
|
public function bank_manager_cannot_open_accounts_for_another_banks_client()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bankA = Bank::factory()->create();
|
|
$bankB = Bank::factory()->create();
|
|
|
|
// Client belongs to Bank B, not Bank A
|
|
$foreignClient = User::factory()->create();
|
|
$this->attachClient($bankB, $foreignClient);
|
|
Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $foreignClient->id,
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bankA);
|
|
|
|
// IDOR attempt: manager of Bank A tries to open Bank B's client accounts
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $foreignClient->id, User::class);
|
|
|
|
$component->assertStatus(403);
|
|
}
|
|
|
|
/** @test */
|
|
public function bank_manager_cannot_update_accounts_for_another_banks_client_via_tampered_state()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bankA = Bank::factory()->create();
|
|
$bankB = Bank::factory()->create();
|
|
|
|
$ownClient = User::factory()->create();
|
|
$this->attachClient($bankA, $ownClient);
|
|
Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $ownClient->id,
|
|
]);
|
|
|
|
$foreignClient = User::factory()->create();
|
|
$this->attachClient($bankB, $foreignClient);
|
|
Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $foreignClient->id,
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bankA);
|
|
|
|
// Legitimately open own client's accounts modal first...
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $ownClient->id, User::class);
|
|
$component->assertStatus(200);
|
|
|
|
// ...then tamper with the Livewire-exposed editAccounts state to point
|
|
// at Bank B's client before submitting the save action.
|
|
$component->set('editAccounts.id', $foreignClient->id);
|
|
$component->set('editAccounts.modelClass', User::class);
|
|
|
|
$component->call('updateAccounts');
|
|
|
|
$component->assertStatus(403);
|
|
}
|
|
|
|
/** @test */
|
|
public function bank_manager_cannot_access_clients_of_organization_type_belonging_to_another_bank()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bankA = Bank::factory()->create();
|
|
$bankB = Bank::factory()->create();
|
|
|
|
$foreignOrgClient = Organization::factory()->create();
|
|
$this->attachClient($bankB, $foreignOrgClient);
|
|
Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $foreignOrgClient->id,
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bankA);
|
|
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $foreignOrgClient->id, Organization::class);
|
|
|
|
$component->assertStatus(403);
|
|
}
|
|
|
|
/** @test */
|
|
public function bank_manager_cannot_reach_non_accounts_profile_actions()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
|
|
$ownClient = User::factory()->create();
|
|
$this->attachClient($bank, $ownClient);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
// Profile edit, profile links, and delete/restore must all stay out of
|
|
// reach for a scoped (non-central) bank-manager, even for their own client.
|
|
Livewire::test(ProfilesManage::class)
|
|
->call('openEditProfileModal', $ownClient->id, User::class)
|
|
->assertStatus(403);
|
|
|
|
Livewire::test(ProfilesManage::class)
|
|
->call('openAttachProfilesModal', $ownClient->id, User::class)
|
|
->assertStatus(403);
|
|
|
|
Livewire::test(ProfilesManage::class)
|
|
->call('openDeleteProfileModal', $ownClient->id, User::class)
|
|
->assertStatus(403);
|
|
|
|
Livewire::test(ProfilesManage::class)
|
|
->call('openRestoreProfileModal', $ownClient->id, User::class)
|
|
->assertStatus(403);
|
|
}
|
|
|
|
/** @test */
|
|
public function reversal_claws_back_from_recipient_and_restores_the_sender()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
$bankAccount = Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
// Both sender (Organization) and recipient (User) are clients of the
|
|
// same bank, mirroring the real TTT -> Ronald scenario.
|
|
$sender = Organization::factory()->create();
|
|
$this->attachClient($bank, $sender);
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
$recipient = User::factory()->create();
|
|
$this->attachClient($bank, $recipient);
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$original = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 1, // Work
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
// Bank-manager opens the RECIPIENT's accounts modal (as would happen
|
|
// if they were looking at Ronald's account, not TTT's), then looks up
|
|
// the transaction by ID rather than picking from a per-account list.
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $recipient->id, User::class)
|
|
->call('openReversalModal');
|
|
$component->assertStatus(200);
|
|
|
|
$component->set('reversalLookupId', (string) $original->id);
|
|
$component->call('lookupReversalTransaction');
|
|
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
|
|
|
// Both the sender and recipient must be offered as requester options.
|
|
$requesterOptions = $component->get('reversalRequesterOptions');
|
|
$this->assertCount(2, $requesterOptions);
|
|
$requesterTypesAndIds = collect($requesterOptions)->map(fn ($o) => [$o['type'], $o['id']])->all();
|
|
$this->assertContains([Organization::class, $sender->id], $requesterTypesAndIds);
|
|
$this->assertContains([User::class, $recipient->id], $requesterTypesAndIds);
|
|
|
|
$chosenRequester = collect($requesterOptions)->firstWhere('id', $recipient->id);
|
|
$component->set('reversalRequestedBy', $chosenRequester['key']);
|
|
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component->call('issueReversal');
|
|
|
|
$component->assertStatus(200);
|
|
|
|
// Leg A: recipient -> bank (claw back what they wrongly received)
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $recipientAccount->id,
|
|
'to_account_id' => $bankAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
|
|
// Leg B: bank -> sender (restore what they originally paid out)
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $bankAccount->id,
|
|
'to_account_id' => $senderAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
|
|
// Both new transactions' descriptions mention the chosen requester.
|
|
$legA = DB::table('transactions')->where('from_account_id', $recipientAccount->id)->where('to_account_id', $bankAccount->id)->where('transaction_type_id', 8)->first();
|
|
$legB = DB::table('transactions')->where('from_account_id', $bankAccount->id)->where('to_account_id', $senderAccount->id)->where('transaction_type_id', 8)->first();
|
|
$this->assertStringContainsString($recipient->full_name ?? $recipient->name, $legA->description);
|
|
$this->assertStringContainsString($recipient->full_name ?? $recipient->name, $legB->description);
|
|
}
|
|
|
|
/** @test */
|
|
public function issuing_a_reversal_requires_a_valid_requester_from_the_transactions_two_parties()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
$sender = Organization::factory()->create();
|
|
$this->attachClient($bank, $sender);
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
$recipient = User::factory()->create();
|
|
$this->attachClient($bank, $recipient);
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$original = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 1, // Work
|
|
]);
|
|
|
|
$unrelatedThirdParty = User::factory()->create();
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $recipient->id, User::class)
|
|
->call('openReversalModal');
|
|
$component->set('reversalLookupId', (string) $original->id);
|
|
$component->call('lookupReversalTransaction');
|
|
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
|
|
|
// No requester selected at all — must be rejected.
|
|
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component->call('issueReversal');
|
|
$this->assertSame(0, DB::table('transactions')->where('transaction_type_id', 8)->count());
|
|
|
|
// A requester that is NOT one of the transaction's two actual parties
|
|
// — must also be rejected, even though it's a real profile.
|
|
$component->set('reversalRequestedBy', 'User:' . $unrelatedThirdParty->id);
|
|
$component->call('issueReversal');
|
|
$this->assertSame(0, DB::table('transactions')->where('transaction_type_id', 8)->count(), 'A requester who is not party to the transaction must not be accepted.');
|
|
}
|
|
|
|
/** @test */
|
|
public function a_transaction_cannot_be_reversed_twice()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
$bankAccount = Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
$sender = Organization::factory()->create();
|
|
$this->attachClient($bank, $sender);
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
$recipient = User::factory()->create();
|
|
$this->attachClient($bank, $recipient);
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$original = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 1, // Work
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
// First reversal — should succeed normally.
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $recipient->id, User::class)
|
|
->call('openReversalModal');
|
|
$component->set('reversalLookupId', (string) $original->id);
|
|
$component->call('lookupReversalTransaction');
|
|
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
|
$component->set('reversalRequestedBy', $component->get('reversalRequesterOptions')[0]['key']);
|
|
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component->call('issueReversal');
|
|
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $recipientAccount->id,
|
|
'to_account_id' => $bankAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
$this->assertSame(2, DB::table('transactions')->where('transaction_type_id', 8)->count());
|
|
|
|
// Reopen the modal and look up the same original transaction again —
|
|
// the lookup must reject it as already reversed...
|
|
$component2 = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $recipient->id, User::class)
|
|
->call('openReversalModal');
|
|
|
|
$component2->set('reversalLookupId', (string) $original->id);
|
|
$component2->call('lookupReversalTransaction');
|
|
$component2->assertSet('reversalSelectedTransactionId', null);
|
|
$this->assertNotEmpty($component2->get('reversalLookupError'));
|
|
|
|
// ...and even if forced via tampered state, issueReversal() must reject
|
|
// it rather than create a second (opposite-direction) reversal pair
|
|
// that would silently undo the first one.
|
|
$component2->set('reversalSelectedTransactionId', $original->id);
|
|
$component2->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component2->call('issueReversal');
|
|
|
|
$this->assertSame(2, DB::table('transactions')->where('transaction_type_id', 8)->count(), 'A second reversal of the same transaction must not be created.');
|
|
}
|
|
|
|
/** @test */
|
|
public function bank_coordinator_cannot_open_accounts_management_at_all()
|
|
{
|
|
$coordinator = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
|
|
$client = User::factory()->create();
|
|
$this->attachClient($bank, $client);
|
|
Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $client->id,
|
|
]);
|
|
|
|
// bank-coordinator role instead of bank-manager
|
|
$role = Role::firstOrCreate([
|
|
'name' => "Bank\\{$bank->id}\\bank-coordinator",
|
|
'guard_name' => 'web',
|
|
]);
|
|
$coordinator->assignRole($role);
|
|
|
|
$this->actingAs($coordinator, 'web');
|
|
$this->actingAs($bank, 'bank');
|
|
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
|
|
|
// mount() itself should reject: not central bank, not bank-manager
|
|
Livewire::test(ProfilesManage::class)->assertStatus(403);
|
|
}
|
|
|
|
/** @test */
|
|
public function reversal_is_authorized_when_only_the_recipient_is_in_scope()
|
|
{
|
|
// The rule is "sender OR recipient in scope" — a bank-manager can
|
|
// still reverse a transaction where only the RECIPIENT is their own
|
|
// client, even if the sender belongs to (or is unrelated to) another
|
|
// bank entirely.
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
$bankAccount = Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
$recipientClient = User::factory()->create();
|
|
$this->attachClient($bank, $recipientClient);
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipientClient->id,
|
|
]);
|
|
|
|
$unrelatedSender = Organization::factory()->create();
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $unrelatedSender->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 1, // Work
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $recipientClient->id, User::class)
|
|
->call('openReversalModal');
|
|
|
|
$component->set('reversalLookupId', (string) $transaction->id);
|
|
$component->call('lookupReversalTransaction');
|
|
$component->assertSet('reversalSelectedTransactionId', $transaction->id);
|
|
|
|
$component->set('reversalRequestedBy', $component->get('reversalRequesterOptions')[0]['key']);
|
|
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component->call('issueReversal');
|
|
|
|
$component->assertStatus(200);
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $recipientAccount->id,
|
|
'to_account_id' => $bankAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $bankAccount->id,
|
|
'to_account_id' => $senderAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
}
|
|
|
|
/** @test */
|
|
public function reversal_is_rejected_when_neither_party_is_in_scope()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
// Neither the sender nor the recipient is a client of this bank —
|
|
// the viewed account itself belongs to a DIFFERENT client of this
|
|
// bank, and the transaction merely happens to be one of its rows.
|
|
$viewedClient = User::factory()->create();
|
|
$this->attachClient($bank, $viewedClient);
|
|
$viewedAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $viewedClient->id,
|
|
]);
|
|
|
|
$outOfScopeRecipient = User::factory()->create();
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $outOfScopeRecipient->id,
|
|
]);
|
|
|
|
$outOfScopeSender = Organization::factory()->create();
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $outOfScopeSender->id,
|
|
]);
|
|
|
|
// A transaction between two out-of-scope parties, unrelated to the
|
|
// viewed account, forced directly by ID (simulating tampered state
|
|
// that bypassed the normal list-building/filtering).
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 1, // Work
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $viewedClient->id, User::class)
|
|
->call('openReversalModal');
|
|
$component->assertStatus(200);
|
|
|
|
// The lookup itself must fail: this transaction touches neither of
|
|
// the viewed profile's accounts, so it's not found for this profile.
|
|
$component->set('reversalLookupId', (string) $transaction->id);
|
|
$component->call('lookupReversalTransaction');
|
|
$component->assertSet('reversalSelectedTransactionId', null);
|
|
$this->assertNotEmpty($component->get('reversalLookupError'));
|
|
|
|
// Even if forced via tampered state, issueReversal() must still reject
|
|
// it rather than create a reversal.
|
|
$component->set('reversalSelectedTransactionId', $transaction->id);
|
|
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component->call('issueReversal');
|
|
$component->assertStatus(403);
|
|
|
|
$this->assertDatabaseMissing('transactions', [
|
|
'from_account_id' => $recipientAccount->id,
|
|
'to_account_id' => $senderAccount->id,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
$this->assertDatabaseMissing('transactions', [
|
|
'to_account_id' => $senderAccount->id,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
}
|
|
|
|
/** @test */
|
|
public function reversal_lookup_finds_a_transaction_regardless_of_which_of_the_profiles_accounts_it_is_on()
|
|
{
|
|
// A profile can have more than one account of the same accountable
|
|
// type (a real-world data anomaly seen in production — e.g. two
|
|
// duplicate "organization" accounts for the same Organization). The
|
|
// old per-account button/list only ever searched ONE account at a
|
|
// time, so a transaction on the "other" account was invisible. The
|
|
// ID lookup must check ALL of the profile's accounts.
|
|
$manager = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
$bankAccount = Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
$sender = User::factory()->create();
|
|
$this->attachClient($bank, $sender);
|
|
|
|
$recipient = Organization::factory()->create();
|
|
$this->attachClient($bank, $recipient);
|
|
// Two separate accounts for the same recipient Organization.
|
|
$recipientAccountA = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
$recipientAccountB = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
// The transaction landed on the SECOND account (accountAccountB),
|
|
// not the one the accounts-edit modal would default to viewing.
|
|
$original = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccountB->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 1, // Work
|
|
]);
|
|
|
|
$this->actingAsBankManager($manager, $bank);
|
|
|
|
$component = Livewire::test(ProfilesManage::class)
|
|
->call('openEditAccountsModal', $recipient->id, Organization::class)
|
|
->call('openReversalModal');
|
|
$component->assertStatus(200);
|
|
|
|
$component->set('reversalLookupId', (string) $original->id);
|
|
$component->call('lookupReversalTransaction');
|
|
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
|
$this->assertEmpty($component->get('reversalLookupError'));
|
|
|
|
$component->set('reversalRequestedBy', $component->get('reversalRequesterOptions')[0]['key']);
|
|
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
|
$component->call('issueReversal');
|
|
$component->assertStatus(200);
|
|
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $recipientAccountB->id,
|
|
'to_account_id' => $bankAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
$this->assertDatabaseHas('transactions', [
|
|
'from_account_id' => $bankAccount->id,
|
|
'to_account_id' => $senderAccount->id,
|
|
'amount' => 5,
|
|
'transaction_type_id' => 8,
|
|
]);
|
|
}
|
|
|
|
/** @test */
|
|
public function central_bank_mount_is_never_scoped_regardless_of_bank_clients()
|
|
{
|
|
$manager = User::factory()->create();
|
|
$centralBank = Bank::factory()->create(['level' => 0]);
|
|
|
|
// A client that is NOT linked to this bank via bank_clients at all —
|
|
// central bank access must not depend on the bank_clients pivot, and
|
|
// mount() must not scope a central bank the way it scopes a regular
|
|
// bank-manager (no scopedToBankId set at all).
|
|
$unlinkedUser = User::factory()->create();
|
|
Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $unlinkedUser->id,
|
|
]);
|
|
|
|
$this->actingAs($manager, 'web');
|
|
$this->actingAs($centralBank, 'bank');
|
|
session(['activeProfileType' => Bank::class, 'activeProfileId' => $centralBank->id]);
|
|
|
|
$component = Livewire::test(ProfilesManage::class);
|
|
$component->assertStatus(200);
|
|
$this->assertNull($component->get('scopedToBankId'));
|
|
}
|
|
}
|