Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,569 @@
|
||||
<?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';
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user