Files
timebank-cc-public/tests/Feature/Security/IDOR/BankManagerAccountsScopeIDORTest.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'));
}
}