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')); } }