Files

570 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')->insertOrIgnore([
['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', TransactionType::find(1)->toArray())
->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', TransactionType::find(1)->toArray())
->call('doTransfer');
// Should redirect back with error
$this->assertPaymentRefused($response);
// 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->actAsProfileManager($user, $organization, 'organization', "Organization\\{$organization->id}\\organization-manager");
$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', TransactionType::find(1)->toArray())
->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->actAsProfileManager($user, $org1, 'organization', "Organization\\{$org1->id}\\organization-manager");
// 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', TransactionType::find(1)->toArray())
->call('doTransfer');
$this->assertPaymentRefused($response);
$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->actAsProfileManager($user, $bank, 'bank', "Bank\\{$bank->id}\\bank-manager");
$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', TransactionType::find(1)->toArray())
->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->managers()->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->actAsProfileManager($user, $bank1, 'bank', "Bank\\{$bank1->id}\\bank-manager");
// 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', TransactionType::find(1)->toArray())
->call('doTransfer');
$this->assertPaymentRefused($response);
$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!
// The pay form refuses to mount for a profile the logged-in user does not own,
// so the payment can never be submitted (doTransfer() also re-checks ownership)
Livewire::test(\App\Http\Livewire\Pay::class)->assertStatus(403);
// 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', TransactionType::find(1)->toArray())
->call('doTransfer');
$this->assertPaymentRefused($response);
$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', TransactionType::find(1)->toArray())
->call('doTransfer');
$this->assertPaymentRefused($response);
$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', TransactionType::find(1)->toArray())
->call('doTransfer');
$this->assertPaymentRefused($response);
$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: the pay page sends guests to the login, so the component is never reached
$this->get(route('pay'))->assertRedirect();
$this->assertGuest('web');
$this->assertDatabaseMissing('transactions', [
'from_account_id' => $userAccount->id,
]);
}
/**
* 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', TransactionType::find(1)->toArray())
->call('doTransfer');
$this->assertPaymentRefused($response);
$this->assertDatabaseMissing('transactions', [
'to_account_id' => $recipientAccount->id,
]);
}
/**
* Log in as the user behind an elevated profile, with the role that may pay for it
*/
private function actAsProfileManager(User $user, $profile, string $guard, string $role): void
{
\Spatie\Permission\Models\Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
$user->assignRole($role);
$this->actingAs($user, 'web');
$this->actingAs($profile, $guard);
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->id]);
}
/**
* A refused payment is logged, reported and shown to the user as an error notification
*/
private function assertPaymentRefused($response): void
{
$response->assertDispatched('wireui:notification', function ($event, $params) {
return ($params[0]['options']['icon'] ?? $params['options']['icon'] ?? null) === 'error';
});
}
}