Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,698 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user