571 lines
19 KiB
PHP
571 lines
19 KiB
PHP
<?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');
|
|
}
|
|
}
|