406 lines
14 KiB
PHP
406 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Security\Authentication;
|
|
|
|
use App\Models\Admin;
|
|
use App\Models\Bank;
|
|
use App\Models\Organization;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Profile Switching Security Tests
|
|
*
|
|
* Tests the security of profile switching in the multi-guard system:
|
|
* - Ownership verification
|
|
* - Password requirements (organization: no password, bank/admin: requires password)
|
|
* - Session state management
|
|
* - Relationship validation
|
|
*
|
|
* @group security
|
|
* @group critical
|
|
* @group authentication
|
|
* @group profile-switching
|
|
*/
|
|
class ProfileSwitchingSecurityTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
// ==========================================
|
|
// OWNERSHIP VERIFICATION TESTS
|
|
// ==========================================
|
|
|
|
/**
|
|
* Test that user can only switch to owned organization profiles
|
|
*/
|
|
public function test_user_can_only_switch_to_owned_organization()
|
|
{
|
|
$user = User::factory()->create();
|
|
$ownedOrganization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
$unownedOrganization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
|
|
// Link user to owned organization only
|
|
$user->organizations()->attach($ownedOrganization->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Set intent for owned organization - should work
|
|
session([
|
|
'intended_profile_switch_type' => 'Organization',
|
|
'intended_profile_switch_id' => $ownedOrganization->id,
|
|
]);
|
|
|
|
$response = $this->get(route('organization.login'));
|
|
$response->assertStatus(200);
|
|
$response->assertViewIs('profile-organization.login');
|
|
|
|
// Set intent for unowned organization - should fail
|
|
session([
|
|
'intended_profile_switch_type' => 'Organization',
|
|
'intended_profile_switch_id' => $unownedOrganization->id,
|
|
]);
|
|
|
|
$response = $this->get(route('organization.login'));
|
|
// Should show nothing or error because getTargetProfileByTypeAndId returns null
|
|
$this->assertNull(session('intended_profile_switch_id') ?
|
|
Organization::find(session('intended_profile_switch_id')) : null);
|
|
}
|
|
|
|
/**
|
|
* Test that user cannot switch to unowned bank profile
|
|
*/
|
|
public function test_cannot_switch_to_unowned_bank()
|
|
{
|
|
$user = User::factory()->create();
|
|
$ownedBank = Bank::factory()->create(['password' => Hash::make('password')]);
|
|
$unownedBank = Bank::factory()->create(['password' => Hash::make('password')]);
|
|
|
|
// Link user to owned bank only
|
|
$user->banksManaged()->attach($ownedBank->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Try to access unowned bank
|
|
session([
|
|
'intended_profile_switch_type' => 'Bank',
|
|
'intended_profile_switch_id' => $unownedBank->id,
|
|
]);
|
|
|
|
$response = $this->get(route('bank.login'));
|
|
|
|
// The view will receive $profile = null from getTargetProfileByTypeAndId
|
|
// This might cause an error or show an empty form
|
|
$response->assertStatus(200); // The route exists but profile will be null
|
|
}
|
|
|
|
/**
|
|
* Test that user cannot switch to unowned admin profile
|
|
*/
|
|
public function test_cannot_switch_to_unowned_admin()
|
|
{
|
|
$user = User::factory()->create();
|
|
$ownedAdmin = Admin::factory()->create(['password' => Hash::make('password')]);
|
|
$unownedAdmin = Admin::factory()->create(['password' => Hash::make('password')]);
|
|
|
|
// Link user to owned admin only
|
|
$user->admins()->attach($ownedAdmin->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Try to access unowned admin
|
|
session([
|
|
'intended_profile_switch_type' => 'Admin',
|
|
'intended_profile_switch_id' => $unownedAdmin->id,
|
|
]);
|
|
|
|
$response = $this->get(route('admin.login'));
|
|
|
|
$response->assertStatus(200); // Route exists but profile will be null
|
|
}
|
|
|
|
/**
|
|
* Test profile switch validates relationship pivot tables
|
|
*/
|
|
public function test_profile_switch_validates_relationship_pivot_tables()
|
|
{
|
|
$user = User::factory()->create();
|
|
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
|
$admin = Admin::factory()->create(['password' => Hash::make('password')]);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Try organization without pivot entry
|
|
session([
|
|
'intended_profile_switch_type' => 'Organization',
|
|
'intended_profile_switch_id' => $organization->id,
|
|
]);
|
|
$response = $this->get(route('organization.login'));
|
|
// getTargetProfileByTypeAndId will return null (no relationship)
|
|
|
|
// Try bank without pivot entry
|
|
session([
|
|
'intended_profile_switch_type' => 'Bank',
|
|
'intended_profile_switch_id' => $bank->id,
|
|
]);
|
|
$response = $this->get(route('bank.login'));
|
|
// getTargetProfileByTypeAndId will return null
|
|
|
|
// Try admin without pivot entry
|
|
session([
|
|
'intended_profile_switch_type' => 'Admin',
|
|
'intended_profile_switch_id' => $admin->id,
|
|
]);
|
|
$response = $this->get(route('admin.login'));
|
|
// getTargetProfileByTypeAndId will return null
|
|
|
|
// Now add relationships and verify they work
|
|
$user->organizations()->attach($organization->id);
|
|
$user->banksManaged()->attach($bank->id);
|
|
$user->admins()->attach($admin->id);
|
|
|
|
session([
|
|
'intended_profile_switch_type' => 'Organization',
|
|
'intended_profile_switch_id' => $organization->id,
|
|
]);
|
|
$response = $this->get(route('organization.login'));
|
|
$response->assertStatus(200);
|
|
$response->assertViewHas('profile', $organization);
|
|
}
|
|
|
|
// ==========================================
|
|
// PASSWORD REQUIREMENT TESTS
|
|
// ==========================================
|
|
|
|
/**
|
|
* Test that organization switch does not require password (via direct login)
|
|
*/
|
|
public function test_organization_switch_does_not_require_password_via_direct_login()
|
|
{
|
|
$user = User::factory()->create();
|
|
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
$user->organizations()->attach($organization->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Direct login for organization should switch immediately
|
|
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
|
|
|
// Should redirect to main page, not to password form
|
|
$response->assertRedirect(route('main'));
|
|
|
|
// Should be authenticated on organization guard
|
|
$this->assertTrue(Auth::guard('organization')->check());
|
|
$this->assertEquals($organization->id, Auth::guard('organization')->id());
|
|
}
|
|
|
|
/**
|
|
* Test that bank switch requires password
|
|
*/
|
|
public function test_bank_switch_requires_password()
|
|
{
|
|
$user = User::factory()->create();
|
|
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
|
$user->banksManaged()->attach($bank->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Direct login for bank should redirect to password form
|
|
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
|
|
|
$response->assertRedirect(route('bank.login'));
|
|
|
|
// Should NOT be authenticated on bank guard yet
|
|
$this->assertFalse(Auth::guard('bank')->check());
|
|
|
|
// Verify session intent was set
|
|
$this->assertEquals('Bank', session('intended_profile_switch_type'));
|
|
$this->assertEquals($bank->id, session('intended_profile_switch_id'));
|
|
}
|
|
|
|
/**
|
|
* Test that admin switch requires password
|
|
*/
|
|
public function test_admin_switch_requires_password()
|
|
{
|
|
$user = User::factory()->create();
|
|
$admin = Admin::factory()->create(['password' => Hash::make('admin-password')]);
|
|
$user->admins()->attach($admin->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Direct login for admin should redirect to password form
|
|
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
|
|
|
$response->assertRedirect(route('admin.login'));
|
|
|
|
// Should NOT be authenticated on admin guard yet
|
|
$this->assertFalse(Auth::guard('admin')->check());
|
|
|
|
// Verify session intent was set
|
|
$this->assertEquals('Admin', session('intended_profile_switch_type'));
|
|
$this->assertEquals($admin->id, session('intended_profile_switch_id'));
|
|
}
|
|
|
|
/**
|
|
* Test that invalid password prevents bank profile switch
|
|
*/
|
|
public function test_invalid_password_prevents_bank_profile_switch()
|
|
{
|
|
$user = User::factory()->create();
|
|
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
|
$user->banksManaged()->attach($bank->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
|
|
|
session([
|
|
'intended_profile_switch_type' => 'Bank',
|
|
'intended_profile_switch_id' => $bank->id,
|
|
]);
|
|
|
|
$response = $this->post(route('bank.login.post'), [
|
|
'password' => 'wrong-password',
|
|
]);
|
|
|
|
$response->assertSessionHasErrors(['password']);
|
|
$this->assertFalse(Auth::guard('bank')->check());
|
|
}
|
|
|
|
/**
|
|
* Test that valid password allows bank profile switch
|
|
*/
|
|
public function test_valid_password_allows_bank_profile_switch()
|
|
{
|
|
$user = User::factory()->create();
|
|
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
|
$user->banksManaged()->attach($bank->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
|
|
|
session([
|
|
'intended_profile_switch_type' => 'Bank',
|
|
'intended_profile_switch_id' => $bank->id,
|
|
]);
|
|
|
|
$response = $this->post(route('bank.login.post'), [
|
|
'password' => 'correct-password',
|
|
]);
|
|
|
|
$response->assertRedirect(route('main'));
|
|
$this->assertTrue(Auth::guard('bank')->check());
|
|
$this->assertEquals($bank->id, Auth::guard('bank')->id());
|
|
}
|
|
|
|
// ==========================================
|
|
// SESSION STATE MANAGEMENT TESTS
|
|
// ==========================================
|
|
|
|
/**
|
|
* Test that profile switch clears session variables after successful authentication
|
|
*/
|
|
public function test_profile_switch_clears_session_variables()
|
|
{
|
|
$user = User::factory()->create();
|
|
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
|
$user->banksManaged()->attach($bank->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
|
|
|
session([
|
|
'intended_profile_switch_type' => 'Bank',
|
|
'intended_profile_switch_id' => $bank->id,
|
|
]);
|
|
|
|
$this->assertNotNull(session('intended_profile_switch_type'));
|
|
$this->assertNotNull(session('intended_profile_switch_id'));
|
|
|
|
$this->post(route('bank.login.post'), [
|
|
'password' => 'password',
|
|
]);
|
|
|
|
// Session variables should be cleared after successful login
|
|
$this->assertNull(session('intended_profile_switch_type'));
|
|
$this->assertNull(session('intended_profile_switch_id'));
|
|
}
|
|
|
|
/**
|
|
* Test that active profile information is stored in session
|
|
*/
|
|
public function test_active_profile_stored_in_session()
|
|
{
|
|
$user = User::factory()->create();
|
|
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
$user->organizations()->attach($organization->id);
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
|
|
|
// Check session contains profile information
|
|
$this->assertEquals(get_class($organization), session('activeProfileType'));
|
|
$this->assertEquals($organization->id, session('activeProfileId'));
|
|
$this->assertEquals($organization->name, session('activeProfileName'));
|
|
$this->assertNotNull(session('last_activity'));
|
|
}
|
|
|
|
// ==========================================
|
|
// EDGE CASES
|
|
// ==========================================
|
|
|
|
/**
|
|
* Test that switching to nonexistent profile fails gracefully
|
|
*/
|
|
public function test_cannot_switch_to_nonexistent_profile()
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Try to access nonexistent organization
|
|
$response = $this->get(route('organization.direct-login', ['organizationId' => 99999]));
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* Test that switching to soft-deleted profile fails
|
|
*/
|
|
public function test_cannot_switch_to_soft_deleted_profile()
|
|
{
|
|
$user = User::factory()->create();
|
|
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
$user->organizations()->attach($organization->id);
|
|
|
|
// Soft delete the organization
|
|
$organization->delete();
|
|
|
|
$this->actingAs($user, 'web');
|
|
|
|
// Try to access deleted organization
|
|
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* Test that profile switch requires user to be authenticated
|
|
*/
|
|
public function test_profile_switch_requires_authentication()
|
|
{
|
|
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
|
|
|
// Try to access organization without being authenticated
|
|
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
|
|
|
// Should redirect to login
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
|
}
|
|
}
|