Initial commit
This commit is contained in:
570
tests/Feature/Security/Authorization/PaymentMultiAuthTest.php
Normal file
570
tests/Feature/Security/Authorization/PaymentMultiAuthTest.php
Normal file
@@ -0,0 +1,570 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Payment Multi-Auth Tests (Livewire Pay Component)
|
||||
*
|
||||
* Tests that the Livewire Pay component correctly validates account ownership
|
||||
* and prevents unauthorized payments across all authentication guards.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group multi-guard
|
||||
* @group payment
|
||||
* @group critical
|
||||
*/
|
||||
class PaymentMultiAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create transaction types (using insert to avoid mass assignment protection)
|
||||
\DB::table('transaction_types')->insert([
|
||||
['id' => 1, 'name' => 'worked_hours', 'label' => 'Worked Hours', 'icon' => 'clock'],
|
||||
['id' => 2, 'name' => 'gift', 'label' => 'Gift', 'icon' => 'gift'],
|
||||
['id' => 3, 'name' => 'donation', 'label' => 'Donation', 'icon' => 'hand-thumb-up'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can make payment from their own account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_make_payment_from_own_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Test payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot make payment from another user's account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_make_payment_from_another_users_account()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$user1Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user1->id]);
|
||||
|
||||
// Try to make payment from user2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $user2Account->id) // Unauthorized account!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Unauthorized payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
// Should redirect back with error
|
||||
$response->assertRedirect();
|
||||
|
||||
// Transaction should NOT be created
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $user2Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can make payment from their own account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_make_payment_from_own_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $orgAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 120)
|
||||
->set('description', 'Organization payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $orgAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot make payment from another organization's account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_make_payment_from_another_organizations_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$org1Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org1->id,
|
||||
]);
|
||||
|
||||
$org2Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as org1
|
||||
$this->actingAs($org1, 'organization');
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $org1->id]);
|
||||
|
||||
// Try to make payment from org2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $org2Account->id) // Unauthorized!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 120)
|
||||
->set('description', 'Unauthorized org payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $org2Account->id,
|
||||
'description' => 'Unauthorized org payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can make payment from their own account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_make_payment_from_own_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
'limit_min' => -10000,
|
||||
'limit_max' => 10000,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class]);
|
||||
session(['activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $bankAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 200)
|
||||
->set('description', 'Bank payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $bankAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 200,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank cannot make payment from another bank's account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_cannot_make_payment_from_another_banks_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank1 = Bank::factory()->create();
|
||||
$bank2 = Bank::factory()->create();
|
||||
$bank1->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$bank1Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank1->id,
|
||||
]);
|
||||
|
||||
$bank2Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as bank1
|
||||
$this->actingAs($bank1, 'bank');
|
||||
session(['activeProfileType' => Bank::class]);
|
||||
session(['activeProfileId' => $bank1->id]);
|
||||
|
||||
// Try to make payment from bank2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $bank2Account->id) // Unauthorized!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 200)
|
||||
->set('description', 'Unauthorized bank payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $bank2Account->id,
|
||||
'description' => 'Unauthorized bank payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test session manipulation attack prevention
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function session_manipulation_attack_is_prevented()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$user1Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to impersonate user2
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user2->id]); // Attacker sets this to user2!
|
||||
|
||||
// Try to make payment from user2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $user2Account->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Session manipulation attack')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
// Should be blocked because doTransfer() checks getAccountsInfo()
|
||||
// which validates against the session's active profile
|
||||
$response->assertRedirect();
|
||||
|
||||
// Transaction should NOT be created
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $user2Account->id,
|
||||
'description' => 'Session manipulation attack',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cross-guard attack prevention (user trying to use org account)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function cross_guard_attack_is_prevented()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as user (web guard)
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
// Try to make payment from organization's account while logged in as user
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $orgAccount->id) // Org account, but user guard!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 120)
|
||||
->set('description', 'Cross-guard attack')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $orgAccount->id,
|
||||
'description' => 'Cross-guard attack',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment to same account is prevented
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function payment_to_same_account_is_prevented()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', $userAccount->id) // Same account!
|
||||
->set('amount', 60)
|
||||
->set('description', 'Self payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $userAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment to non-existent account is prevented
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function payment_to_nonexistent_account_is_prevented()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', 99999) // Non-existent account
|
||||
->set('amount', 60)
|
||||
->set('description', 'Payment to nowhere')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'to_account_id' => 99999,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot make payments
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_make_payments()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Not authenticated
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Unauthenticated payment')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
// Should fail due to lack of authentication
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'description' => 'Unauthenticated payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin profile without account cannot make payments
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_without_account_cannot_make_payments()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
// Admin has no account
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class]);
|
||||
session(['activeProfileId' => $admin->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', null) // No account
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Admin payment without account')
|
||||
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
|
||||
->call('doTransfer');
|
||||
|
||||
// Should show error notification
|
||||
$response->assertDispatched('notification');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user