Files
timebank-cc-public/tests/Feature/Security/Authorization/PaymentMultiAuthTest.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

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