Initial commit
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user