551 lines
17 KiB
PHP
551 lines
17 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\Transaction;
|
|
use App\Models\TransactionType;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Transaction View Authorization Tests
|
|
*
|
|
* Tests that users can only view transactions involving their own accounts
|
|
* and cannot view other users' transactions across all authentication guards.
|
|
*
|
|
* @group security
|
|
* @group authorization
|
|
* @group multi-guard
|
|
* @group transaction-view
|
|
* @group critical
|
|
*/
|
|
class TransactionViewAuthorizationTest 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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Test user can view transaction they are involved in (sender)
|
|
*
|
|
* @test
|
|
*/
|
|
public function user_can_view_transaction_as_sender()
|
|
{
|
|
$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,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $userAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 60,
|
|
]);
|
|
|
|
$this->actingAs($user, 'web');
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test user can view transaction they are involved in (recipient)
|
|
*
|
|
* @test
|
|
*/
|
|
public function user_can_view_transaction_as_recipient()
|
|
{
|
|
$sender = User::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
$userAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $user->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $userAccount->id,
|
|
'amount' => 60,
|
|
]);
|
|
|
|
$this->actingAs($user, 'web');
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test user cannot view transaction they are not involved in
|
|
*
|
|
* @test
|
|
*/
|
|
public function user_cannot_view_transaction_they_are_not_involved_in()
|
|
{
|
|
$user = User::factory()->create();
|
|
$sender = User::factory()->create();
|
|
$recipient = User::factory()->create();
|
|
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 60,
|
|
]);
|
|
|
|
// User is NOT involved in this transaction
|
|
$this->actingAs($user, 'web');
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* Test organization can view transaction they are involved in
|
|
*
|
|
* @test
|
|
*/
|
|
public function organization_can_view_transaction_they_are_involved_in()
|
|
{
|
|
$orgUser = User::factory()->create();
|
|
$organization = Organization::factory()->create();
|
|
$organization->users()->attach($orgUser->id);
|
|
$recipient = User::factory()->create();
|
|
|
|
$orgAccount = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $organization->id,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $orgAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 120,
|
|
]);
|
|
|
|
$this->actingAs($organization, 'organization');
|
|
session(['activeProfileType' => Organization::class]);
|
|
session(['activeProfileId' => $organization->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test organization cannot view transaction of another organization
|
|
*
|
|
* @test
|
|
*/
|
|
public function organization_cannot_view_another_organizations_transaction()
|
|
{
|
|
$user = User::factory()->create();
|
|
$org1 = Organization::factory()->create();
|
|
$org2 = Organization::factory()->create();
|
|
$org1->users()->attach($user->id);
|
|
$recipient = User::factory()->create();
|
|
|
|
$org2Account = Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $org2->id,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $org2Account->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 120,
|
|
]);
|
|
|
|
// Logged in as org1, trying to view org2's transaction
|
|
$this->actingAs($org1, 'organization');
|
|
session(['activeProfileType' => Organization::class]);
|
|
session(['activeProfileId' => $org1->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* Test bank can view transaction they are involved in
|
|
*
|
|
* @test
|
|
*/
|
|
public function bank_can_view_transaction_they_are_involved_in()
|
|
{
|
|
$bankUser = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
$bank->managers()->attach($bankUser->id);
|
|
$recipient = User::factory()->create();
|
|
|
|
$bankAccount = Account::factory()->create([
|
|
'accountable_type' => Bank::class,
|
|
'accountable_id' => $bank->id,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $bankAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 200,
|
|
]);
|
|
|
|
$this->actingAs($bank, 'bank');
|
|
session(['activeProfileType' => Bank::class]);
|
|
session(['activeProfileId' => $bank->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test session manipulation to view unauthorized transaction is blocked
|
|
*
|
|
* @test
|
|
*/
|
|
public function session_manipulation_to_view_transaction_is_blocked()
|
|
{
|
|
$user1 = User::factory()->create();
|
|
$user2 = User::factory()->create();
|
|
$recipient = User::factory()->create();
|
|
|
|
$user2Account = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $user2->id,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $user2Account->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 60,
|
|
]);
|
|
|
|
// Logged in as user1
|
|
$this->actingAs($user1, 'web');
|
|
|
|
// Malicious: manipulate session to impersonate user2
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user2->id]); // Attacker sets this!
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
// Should be blocked by statement() query which checks session against database
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* Test cross-guard attack to view transaction is blocked
|
|
*
|
|
* @test
|
|
*/
|
|
public function cross_guard_attack_to_view_transaction_is_blocked()
|
|
{
|
|
$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,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $orgAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 120,
|
|
]);
|
|
|
|
// Logged in as user (web guard)
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Malicious: manipulate session to view org's transaction
|
|
session(['activeProfileType' => Organization::class]);
|
|
session(['activeProfileId' => $organization->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
// Should be blocked because auth guard doesn't match session
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* Test unauthenticated user cannot view transactions
|
|
*
|
|
* @test
|
|
*/
|
|
public function unauthenticated_user_cannot_view_transactions()
|
|
{
|
|
$sender = User::factory()->create();
|
|
$recipient = User::factory()->create();
|
|
|
|
$senderAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $sender->id,
|
|
]);
|
|
|
|
$recipientAccount = Account::factory()->create([
|
|
'accountable_type' => User::class,
|
|
'accountable_id' => $recipient->id,
|
|
]);
|
|
|
|
$transaction = Transaction::factory()->create([
|
|
'from_account_id' => $senderAccount->id,
|
|
'to_account_id' => $recipientAccount->id,
|
|
'amount' => 60,
|
|
]);
|
|
|
|
// Not authenticated
|
|
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
|
|
|
$response->assertRedirect(route('login'));
|
|
}
|
|
|
|
/**
|
|
* Test user can access transactions list page
|
|
*
|
|
* @test
|
|
*/
|
|
public function user_can_access_transactions_list_page()
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user, 'web');
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user->id]);
|
|
|
|
$response = $this->get(route('transactions'));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test organization can access transactions list page
|
|
*
|
|
* @test
|
|
*/
|
|
public function organization_can_access_transactions_list_page()
|
|
{
|
|
$user = User::factory()->create();
|
|
$organization = Organization::factory()->create();
|
|
$organization->users()->attach($user->id);
|
|
|
|
$this->actingAs($organization, 'organization');
|
|
session(['activeProfileType' => Organization::class]);
|
|
session(['activeProfileId' => $organization->id]);
|
|
|
|
$response = $this->get(route('transactions'));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test bank can access transactions list page
|
|
*
|
|
* @test
|
|
*/
|
|
public function bank_can_access_transactions_list_page()
|
|
{
|
|
$user = User::factory()->create();
|
|
$bank = Bank::factory()->create();
|
|
$bank->managers()->attach($user->id);
|
|
|
|
$this->actingAs($bank, 'bank');
|
|
session(['activeProfileType' => Bank::class]);
|
|
session(['activeProfileId' => $bank->id]);
|
|
|
|
$response = $this->get(route('transactions'));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test non-existent transaction returns 403
|
|
*
|
|
* @test
|
|
*/
|
|
public function non_existent_transaction_returns_403()
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user, 'web');
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user->id]);
|
|
|
|
$response = $this->get(route('transaction.show', ['transactionId' => 99999]));
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* Test TransactionsTable Livewire component loads for user
|
|
*
|
|
* @test
|
|
*/
|
|
public function transactions_table_livewire_component_loads_for_user()
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
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\TransactionsTable::class);
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test TransactionsTable Livewire component loads for organization
|
|
*
|
|
* @test
|
|
*/
|
|
public function transactions_table_livewire_component_loads_for_organization()
|
|
{
|
|
$user = User::factory()->create();
|
|
$organization = Organization::factory()->create();
|
|
$organization->users()->attach($user->id);
|
|
|
|
Account::factory()->create([
|
|
'accountable_type' => Organization::class,
|
|
'accountable_id' => $organization->id,
|
|
]);
|
|
|
|
$this->actingAs($organization, 'organization');
|
|
session(['activeProfileType' => Organization::class]);
|
|
session(['activeProfileId' => $organization->id]);
|
|
|
|
$response = Livewire::test(\App\Http\Livewire\TransactionsTable::class);
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Test TransactionsTable filters transactions by active profile
|
|
*
|
|
* @test
|
|
*/
|
|
public function transactions_table_filters_by_active_profile()
|
|
{
|
|
$user1 = User::factory()->create();
|
|
$user2 = 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,
|
|
]);
|
|
|
|
// Transaction involving user1
|
|
Transaction::factory()->create([
|
|
'from_account_id' => $user1Account->id,
|
|
'to_account_id' => $user2Account->id,
|
|
'amount' => 60,
|
|
'description' => 'User1 transaction',
|
|
]);
|
|
|
|
// Transaction NOT involving user1
|
|
Transaction::factory()->create([
|
|
'from_account_id' => $user2Account->id,
|
|
'to_account_id' => $user2Account->id, // Self transaction for testing
|
|
'amount' => 30,
|
|
'description' => 'User2 only transaction',
|
|
]);
|
|
|
|
$this->actingAs($user1, 'web');
|
|
session(['activeProfileType' => User::class]);
|
|
session(['activeProfileId' => $user1->id]);
|
|
|
|
$response = Livewire::test(\App\Http\Livewire\TransactionsTable::class);
|
|
|
|
// Should see user1's transaction
|
|
$response->assertSee('User1 transaction');
|
|
|
|
// Should NOT see user2's private transaction
|
|
$response->assertDontSee('User2 only transaction');
|
|
}
|
|
}
|