Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Livewire\ApiTokenManager;
use Livewire\Livewire;
use Tests\TestCase;
class ApiTokenPermissionsTest extends TestCase
{
use RefreshDatabase;
public function test_api_token_permissions_can_be_updated()
{
if (! Features::hasApiFeatures()) {
return $this->markTestSkipped('API support is not enabled.');
}
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
$token = $user->tokens()->create([
'name' => 'Test Token',
'token' => Str::random(40),
'abilities' => ['create', 'read'],
]);
Livewire::test(ApiTokenManager::class)
->set(['managingPermissionsFor' => $token])
->set(['updateApiTokenForm' => [
'permissions' => [
'delete',
'missing-permission',
],
]])
->call('updateApiToken');
$this->assertTrue($user->fresh()->tokens->first()->can('delete'));
$this->assertFalse($user->fresh()->tokens->first()->can('read'));
$this->assertFalse($user->fresh()->tokens->first()->can('missing-permission'));
}
}

View File

@@ -0,0 +1,353 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class ConfigMergeCommandTest extends TestCase
{
protected $testConfigPath;
protected $testConfigExamplePath;
protected $backupDir;
protected function setUp(): void
{
parent::setUp();
$this->testConfigPath = base_path('config/test-config.php');
$this->testConfigExamplePath = base_path('config/test-config.php.example');
$this->backupDir = storage_path('config-backups');
// Clean up any existing test files
$this->cleanupTestFiles();
}
protected function tearDown(): void
{
$this->cleanupTestFiles();
parent::tearDown();
}
protected function cleanupTestFiles(): void
{
if (File::exists($this->testConfigPath)) {
File::delete($this->testConfigPath);
}
if (File::exists($this->testConfigExamplePath)) {
File::delete($this->testConfigExamplePath);
}
if (File::isDirectory($this->backupDir)) {
$backups = File::glob("{$this->backupDir}/test-config.php.backup.*");
foreach ($backups as $backup) {
File::delete($backup);
}
}
}
/** @test */
public function it_detects_new_keys_in_dry_run_mode()
{
// Create current config
$currentConfig = [
'existing_key' => 'existing_value',
'nested' => [
'old_key' => 'old_value',
],
];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
// Create example config with new keys
$exampleConfig = [
'existing_key' => 'example_value',
'new_key' => 'new_value',
'nested' => [
'old_key' => 'old_value',
'new_nested_key' => 'new_nested_value',
],
];
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
// Run command with dry-run
$this->artisan('config:merge', ['file' => 'test-config', '--dry-run' => true])
->expectsOutput('Found 2 new configuration key(s):')
->assertExitCode(0);
// Verify original config unchanged
$loadedConfig = include $this->testConfigPath;
$this->assertEquals('existing_value', $loadedConfig['existing_key']);
$this->assertArrayNotHasKey('new_key', $loadedConfig);
}
/** @test */
public function it_merges_new_keys_while_preserving_existing_values()
{
// Create current config with custom values
$currentConfig = [
'existing_key' => 'CUSTOM_VALUE',
'nested' => [
'old_key' => 'CUSTOM_OLD_VALUE',
],
];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
// Create example config with new keys and different values
$exampleConfig = [
'existing_key' => 'example_value',
'new_key' => 'new_value',
'nested' => [
'old_key' => 'example_old_value',
'new_nested_key' => 'new_nested_value',
],
];
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
// Run command with force flag
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
// Verify merge
$mergedConfig = include $this->testConfigPath;
// Existing values should be preserved
$this->assertEquals('CUSTOM_VALUE', $mergedConfig['existing_key']);
$this->assertEquals('CUSTOM_OLD_VALUE', $mergedConfig['nested']['old_key']);
// New keys should be added
$this->assertEquals('new_value', $mergedConfig['new_key']);
$this->assertEquals('new_nested_value', $mergedConfig['nested']['new_nested_key']);
}
/** @test */
public function it_creates_backup_before_merge()
{
// Create current config
$currentConfig = ['existing_key' => 'value'];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
// Create example config
$exampleConfig = ['existing_key' => 'value', 'new_key' => 'new_value'];
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
// Run command
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
// Verify backup was created
$backups = File::glob("{$this->backupDir}/test-config.php.backup.*");
$this->assertCount(1, $backups);
// Verify backup content matches original
$backupContent = include $backups[0];
$this->assertEquals($currentConfig, $backupContent);
}
/** @test */
public function it_handles_deep_nested_arrays()
{
// Create current config with deep nesting
$currentConfig = [
'level1' => [
'level2' => [
'level3' => [
'existing' => 'value',
],
],
],
];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
// Create example config with new deep nested key
$exampleConfig = [
'level1' => [
'level2' => [
'level3' => [
'existing' => 'example_value',
'new_deep' => 'deep_value',
],
'new_level3' => 'value',
],
],
];
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
// Run command
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
// Verify deep merge
$mergedConfig = include $this->testConfigPath;
$this->assertEquals('value', $mergedConfig['level1']['level2']['level3']['existing']);
$this->assertEquals('deep_value', $mergedConfig['level1']['level2']['level3']['new_deep']);
$this->assertEquals('value', $mergedConfig['level1']['level2']['new_level3']);
}
/** @test */
public function it_reports_no_changes_when_config_is_up_to_date()
{
// Create identical configs
$config = ['key' => 'value'];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($config, true) . ";\n");
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($config, true) . ";\n");
// Run command
$this->artisan('config:merge', ['file' => 'test-config', '--dry-run' => true])
->expectsOutput(' test-config: No new keys found')
->assertExitCode(0);
}
/** @test */
public function it_handles_missing_example_file()
{
// Create only current config
File::put($this->testConfigPath, "<?php\n\nreturn [];\n");
// Run command (no example file)
$this->artisan('config:merge', ['file' => 'test-config', '--dry-run' => true])
->expectsOutput('⊘ test-config: Example file not found (config/test-config.php.example)')
->assertExitCode(0);
}
/** @test */
public function it_handles_missing_current_config()
{
// Create only example file
File::put($this->testConfigExamplePath, "<?php\n\nreturn [];\n");
// Run command (no current config)
$this->artisan('config:merge', ['file' => 'test-config', '--dry-run' => true])
->expectsOutput('⊘ test-config: Active config not found (config/test-config.php) - run deployment first')
->assertExitCode(0);
}
/** @test */
public function it_validates_merged_config_can_be_loaded()
{
// This test verifies the validation step
// We can't easily test a failure case without causing actual errors
// But we can verify success case
$currentConfig = ['key' => 'value'];
$exampleConfig = ['key' => 'value', 'new_key' => 'new_value'];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
// Run command
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
// Verify merged config can be loaded without errors
$this->assertIsArray(include $this->testConfigPath);
}
/** @test */
public function it_keeps_only_last_5_backups()
{
// Ensure backup directory exists
if (!File::isDirectory($this->backupDir)) {
File::makeDirectory($this->backupDir, 0755, true);
}
// Create 7 old backups manually
for ($i = 1; $i <= 7; $i++) {
$timestamp = date('Y-m-d_His', strtotime("-{$i} days"));
$backupPath = "{$this->backupDir}/test-config.php.backup.{$timestamp}";
File::put($backupPath, "<?php\n\nreturn ['backup' => {$i}];\n");
// Adjust file modification time to make them appear older
touch($backupPath, strtotime("-{$i} days"));
}
// Verify we have 7 backups
$this->assertCount(7, File::glob("{$this->backupDir}/test-config.php.backup.*"));
// Create configs and run merge (this will create 8th backup)
File::put($this->testConfigPath, "<?php\n\nreturn ['key' => 'value'];\n");
File::put($this->testConfigExamplePath, "<?php\n\nreturn ['key' => 'value', 'new' => 'val'];\n");
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
// Should now have only 5 backups (oldest 3 deleted)
$remainingBackups = File::glob("{$this->backupDir}/test-config.php.backup.*");
$this->assertCount(5, $remainingBackups);
}
/** @test */
public function it_handles_array_values_correctly()
{
$currentConfig = [
'array_key' => ['item1', 'item2'],
];
$exampleConfig = [
'array_key' => ['item1', 'item2'],
'new_array' => ['new1', 'new2', 'new3'],
];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
$mergedConfig = include $this->testConfigPath;
$this->assertEquals(['item1', 'item2'], $mergedConfig['array_key']);
$this->assertEquals(['new1', 'new2', 'new3'], $mergedConfig['new_array']);
}
/** @test */
public function it_handles_boolean_and_null_values()
{
$currentConfig = [
'bool_true' => true,
'bool_false' => false,
'null_value' => null,
];
$exampleConfig = [
'bool_true' => false, // Different value
'bool_false' => true, // Different value
'null_value' => 'not null', // Different value
'new_bool' => true,
'new_null' => null,
];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
$mergedConfig = include $this->testConfigPath;
// Existing values preserved
$this->assertTrue($mergedConfig['bool_true']);
$this->assertFalse($mergedConfig['bool_false']);
$this->assertNull($mergedConfig['null_value']);
// New values added
$this->assertTrue($mergedConfig['new_bool']);
$this->assertNull($mergedConfig['new_null']);
}
/** @test */
public function it_handles_numeric_keys()
{
$currentConfig = [
'indexed' => [0 => 'first', 1 => 'second'],
];
$exampleConfig = [
'indexed' => [0 => 'first', 1 => 'second', 2 => 'third'],
];
File::put($this->testConfigPath, "<?php\n\nreturn " . var_export($currentConfig, true) . ";\n");
File::put($this->testConfigExamplePath, "<?php\n\nreturn " . var_export($exampleConfig, true) . ";\n");
$this->artisan('config:merge', ['file' => 'test-config', '--force' => true])
->assertExitCode(0);
$mergedConfig = include $this->testConfigPath;
$this->assertArrayHasKey(2, $mergedConfig['indexed']);
$this->assertEquals('third', $mergedConfig['indexed'][2]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Livewire\ApiTokenManager;
use Livewire\Livewire;
use Tests\TestCase;
class CreateApiTokenTest extends TestCase
{
use RefreshDatabase;
public function test_api_tokens_can_be_created()
{
if (! Features::hasApiFeatures()) {
return $this->markTestSkipped('API support is not enabled.');
}
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
Livewire::test(ApiTokenManager::class)
->set(['createApiTokenForm' => [
'name' => 'Test Token',
'permissions' => [
'read',
'update',
],
]])
->call('createApiToken');
$this->assertCount(1, $user->fresh()->tokens);
$this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
$this->assertTrue($user->fresh()->tokens->first()->can('read'));
$this->assertFalse($user->fresh()->tokens->first()->can('delete'));
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Tests\Feature;
use App\Http\Livewire\Profile\DeleteUserForm;
use App\Models\User;
use Carbon\Carbon; // Import Carbon
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
use Tests\TestCase;
class DeleteAccountTest extends TestCase
{
use RefreshDatabase;
public function test_user_profile_can_be_deleted()
{
$this->actingAs($initialUser = User::factory()->create());
// Assuming 'password' is the default factory password or the one set for the user
$component = Livewire::test(DeleteUserForm::class)
->set('password', 'password')
->call('deleteUser');
// Fetch the user again from the database to get the updated attributes
$userAfterDeletionAttempt = User::find($initialUser->id);
// 1. Assert that the user record still exists (since it's a custom soft delete)
$this->assertNotNull($userAfterDeletionAttempt, 'User record should still exist in the database.');
// 2. Assert that the 'deleted_at' column is not null
$this->assertNotNull(
$userAfterDeletionAttempt->deleted_at,
"The 'deleted_at' column should be populated."
);
// 3. Optional: Assert that 'deleted_at' is a valid date instance (if cast)
// or a string in the expected format.
// If 'deleted_at' is cast to a Carbon instance in your User model:
if (isset($userAfterDeletionAttempt->getCasts()['deleted_at']) &&
in_array($userAfterDeletionAttempt->getCasts()['deleted_at'], ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) {
$this->assertInstanceOf(
Carbon::class,
$userAfterDeletionAttempt->deleted_at,
"The 'deleted_at' column should be a Carbon instance."
);
} else {
// If it's a string, you might check its format (less robust)
$this->assertIsString($userAfterDeletionAttempt->deleted_at);
// Example: Basic check if it looks like a datetime string
$this->assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
$userAfterDeletionAttempt->deleted_at,
"The 'deleted_at' column should be a valid datetime string."
);
}
// Assert that the user is no longer authenticated
$this->assertFalse(Auth::check(), 'User should be logged out after deletion.');
// Assert the redirect
$component->assertRedirect(route('goodbye-deleted-user'));
}
public function test_correct_password_must_be_provided_before_account_can_be_deleted()
{
$this->actingAs($user = User::factory()->create());
Livewire::test(DeleteUserForm::class)
->set('password', 'wrong-password')
->call('deleteUser')
->assertHasErrors(['password']);
$this->assertNotNull($user->fresh());
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Jetstream\Features;
use Laravel\Jetstream\Http\Livewire\ApiTokenManager;
use Livewire\Livewire;
use Tests\TestCase;
class DeleteApiTokenTest extends TestCase
{
use RefreshDatabase;
public function test_api_tokens_can_be_deleted()
{
if (! Features::hasApiFeatures()) {
return $this->markTestSkipped('API support is not enabled.');
}
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
$token = $user->tokens()->create([
'name' => 'Test Token',
'token' => Str::random(40),
'abilities' => ['create', 'read'],
]);
Livewire::test(ApiTokenManager::class)
->set(['apiTokenIdBeingDeleted' => $token->id])
->call('deleteApiToken');
$this->assertCount(0, $user->fresh()->tokens);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered()
{
if (! Features::enabled(Features::emailVerification())) {
return $this->markTestSkipped('Email verification not enabled.');
}
$user = User::factory()->withPersonalTeam()->unverified()->create();
$response = $this->actingAs($user)->get('/email/verify');
$response->assertStatus(200);
}
public function test_email_can_be_verified()
{
if (! Features::enabled(Features::emailVerification())) {
return $this->markTestSkipped('Email verification not enabled.');
}
Event::fake();
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');
}
public function test_email_can_not_verified_with_invalid_hash()
{
if (! Features::enabled(Features::emailVerification())) {
return $this->markTestSkipped('Email verification not enabled.');
}
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace Tests\Feature;
use App\Helpers\StringHelper;
use App\Models\Post;
use App\Models\PostTranslation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostContentXssProtectionTest extends TestCase
{
use RefreshDatabase;
/**
* Test that the sanitizeHtml method properly escapes script tags.
*
* @return void
*/
public function test_post_content_escapes_script_tags()
{
$maliciousContent = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// Script tags should be completely removed
$this->assertStringNotContainsString('<script>', $sanitized);
$this->assertStringNotContainsString('alert("XSS")', $sanitized);
// Safe HTML should be preserved
$this->assertStringContainsString('<p>Hello</p>', $sanitized);
$this->assertStringContainsString('<p>World</p>', $sanitized);
}
/**
* Test that img tag with onerror handler is sanitized.
*
* @return void
*/
public function test_post_content_sanitizes_img_onerror()
{
$maliciousContent = '<p>Check this image</p><img src="x" onerror="alert(document.cookie)" />';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// Image should be allowed but without dangerous attributes
$this->assertStringContainsString('<img', $sanitized);
$this->assertStringNotContainsString('onerror', $sanitized);
$this->assertStringNotContainsString('alert(document.cookie)', $sanitized);
}
/**
* Test that iframe injection is completely removed.
*
* @return void
*/
public function test_post_content_removes_iframe()
{
$maliciousContent = '<p>Content</p><iframe src="https://evil.com"></iframe><p>More content</p>';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// Iframe should be completely removed
$this->assertStringNotContainsString('<iframe', $sanitized);
$this->assertStringNotContainsString('evil.com', $sanitized);
// Safe content preserved
$this->assertStringContainsString('<p>Content</p>', $sanitized);
$this->assertStringContainsString('<p>More content</p>', $sanitized);
}
/**
* Test that safe rich text formatting is preserved.
*
* @return void
*/
public function test_post_content_preserves_safe_formatting()
{
$safeContent = '<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul><a href="https://example.com">Link</a>';
$sanitized = StringHelper::sanitizeHtml($safeContent);
// All safe formatting should be preserved
$this->assertStringContainsString('<h1>Title</h1>', $sanitized);
$this->assertStringContainsString('<strong>bold</strong>', $sanitized);
$this->assertStringContainsString('<em>italic</em>', $sanitized);
$this->assertStringContainsString('<ul>', $sanitized);
$this->assertStringContainsString('<li>Item 1</li>', $sanitized);
$this->assertStringContainsString('<a href="https://example.com">Link</a>', $sanitized);
}
/**
* Test that event handler attributes are removed.
*
* @return void
*/
public function test_post_content_removes_event_handlers()
{
$maliciousContent = '<a href="#" onclick="alert(1)">Click me</a><div onmouseover="steal()">Hover</div>';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// Event handlers should be removed
$this->assertStringNotContainsString('onclick', $sanitized);
$this->assertStringNotContainsString('onmouseover', $sanitized);
$this->assertStringNotContainsString('alert(1)', $sanitized);
$this->assertStringNotContainsString('steal()', $sanitized);
// Safe parts should remain (div is allowed with class attribute)
$this->assertStringContainsString('Click me', $sanitized);
$this->assertStringContainsString('Hover', $sanitized);
}
/**
* Test that data URIs with JavaScript are removed.
*
* @return void
*/
public function test_post_content_removes_javascript_data_uris()
{
$maliciousContent = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// JavaScript data URIs should be removed
$this->assertStringNotContainsString('data:text/html', $sanitized);
$this->assertStringNotContainsString('<script>', $sanitized);
}
/**
* Test that style tags with CSS injection are removed.
*
* @return void
*/
public function test_post_content_removes_style_tags()
{
$maliciousContent = '<p>Text</p><style>body { background: url("javascript:alert(1)") }</style>';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// Style tags should be removed
$this->assertStringNotContainsString('<style>', $sanitized);
$this->assertStringNotContainsString('javascript:alert', $sanitized);
}
/**
* Test that object and embed tags are removed.
*
* @return void
*/
public function test_post_content_removes_object_embed_tags()
{
$maliciousContent = '<object data="malicious.swf"></object><embed src="evil.swf">';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// Object and embed tags should be removed
$this->assertStringNotContainsString('<object', $sanitized);
$this->assertStringNotContainsString('<embed', $sanitized);
$this->assertStringNotContainsString('malicious.swf', $sanitized);
}
/**
* Test that null input returns empty string.
*
* @return void
*/
public function test_post_content_handles_null_input()
{
$sanitized = StringHelper::sanitizeHtml(null);
$this->assertEquals('', $sanitized);
}
/**
* Test that empty string returns empty string.
*
* @return void
*/
public function test_post_content_handles_empty_string()
{
$sanitized = StringHelper::sanitizeHtml('');
$this->assertEquals('', $sanitized);
}
/**
* Test that table HTML is preserved for structured content.
*
* @return void
*/
public function test_post_content_preserves_tables()
{
$tableContent = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody></table>';
$sanitized = StringHelper::sanitizeHtml($tableContent);
// Table structure should be preserved
$this->assertStringContainsString('<table>', $sanitized);
$this->assertStringContainsString('<thead>', $sanitized);
$this->assertStringContainsString('<th>Header</th>', $sanitized);
$this->assertStringContainsString('<tbody>', $sanitized);
$this->assertStringContainsString('<td>Data</td>', $sanitized);
}
/**
* Test that images with valid attributes are preserved.
*
* @return void
*/
public function test_post_content_preserves_safe_images()
{
$imageContent = '<img src="https://example.com/image.jpg" alt="Description" width="500" height="300" title="My Image">';
$sanitized = StringHelper::sanitizeHtml($imageContent);
// Valid image attributes should be preserved
$this->assertStringContainsString('src="https://example.com/image.jpg"', $sanitized);
$this->assertStringContainsString('alt="Description"', $sanitized);
$this->assertStringContainsString('width="500"', $sanitized);
$this->assertStringContainsString('height="300"', $sanitized);
$this->assertStringContainsString('title="My Image"', $sanitized);
}
/**
* Test that links with target="_blank" are preserved.
*
* @return void
*/
public function test_post_content_preserves_target_blank_links()
{
$linkContent = '<a href="https://example.com" target="_blank" title="External Link">Visit</a>';
$sanitized = StringHelper::sanitizeHtml($linkContent);
// Link with target="_blank" should be preserved
$this->assertStringContainsString('href="https://example.com"', $sanitized);
$this->assertStringContainsString('target="_blank"', $sanitized);
$this->assertStringContainsString('title="External Link"', $sanitized);
}
/**
* Test mixed safe and unsafe content.
*
* @return void
*/
public function test_post_content_handles_mixed_content()
{
$mixedContent = '<h2>Article Title</h2><p>This is <strong>safe</strong> content.</p><script>alert("unsafe")</script><p>More safe content with <a href="https://example.com">a link</a>.</p>';
$sanitized = StringHelper::sanitizeHtml($mixedContent);
// Safe content preserved
$this->assertStringContainsString('<h2>Article Title</h2>', $sanitized);
$this->assertStringContainsString('<strong>safe</strong>', $sanitized);
$this->assertStringContainsString('<a href="https://example.com">a link</a>', $sanitized);
// Unsafe content removed
$this->assertStringNotContainsString('<script>', $sanitized);
$this->assertStringNotContainsString('alert("unsafe")', $sanitized);
}
/**
* Test that code and pre tags are preserved for technical content.
*
* @return void
*/
public function test_post_content_preserves_code_blocks()
{
$codeContent = '<pre><code>function hello() { return "world"; }</code></pre>';
$sanitized = StringHelper::sanitizeHtml($codeContent);
// Code blocks should be preserved
$this->assertStringContainsString('<pre>', $sanitized);
$this->assertStringContainsString('<code>', $sanitized);
$this->assertStringContainsString('function hello()', $sanitized);
}
/**
* Test SVG injection attempts are blocked.
*
* @return void
*/
public function test_post_content_blocks_svg_injection()
{
$maliciousContent = '<svg onload="alert(1)"><circle cx="50" cy="50" r="40"/></svg>';
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
// SVG should be removed (not in allowed tags list)
$this->assertStringNotContainsString('<svg', $sanitized);
$this->assertStringNotContainsString('onload', $sanitized);
$this->assertStringNotContainsString('alert(1)', $sanitized);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Tests\Feature;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered()
{
if (! Features::enabled(Features::registration())) {
return $this->markTestSkipped('Registration support is not enabled.');
}
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_registration_screen_cannot_be_rendered_if_support_is_disabled()
{
if (Features::enabled(Features::registration())) {
return $this->markTestSkipped('Registration support is enabled.');
}
$response = $this->get('/register');
$response->assertStatus(404);
}
public function test_new_users_can_register()
{
if (! Features::enabled(Features::registration())) {
return $this->markTestSkipped('Registration support is not enabled.');
}
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,250 @@
<?php
namespace Tests\Feature;
use App\Http\Livewire\MainSearchBar;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class SearchXssProtectionTest extends TestCase
{
use RefreshDatabase;
/**
* Test that the sanitizeHighlights method properly escapes XSS attempts.
*
* @return void
*/
public function test_search_highlights_escape_script_tags()
{
// Create a test user
$user = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
// Authenticate as the user
$this->actingAs($user);
// Create MainSearchBar component instance
$component = new MainSearchBar();
// Use reflection to access the private sanitizeHighlights method
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
// Test 1: Simple script tag injection
$maliciousHighlights = [
'about_short_en' => [
'I am a developer <script>alert("XSS")</script> looking for work'
]
];
$sanitized = $method->invoke($component, $maliciousHighlights);
$this->assertStringNotContainsString('<script>', $sanitized['about_short_en'][0]);
$this->assertStringContainsString('&lt;script&gt;', $sanitized['about_short_en'][0]);
}
/**
* Test that img tag with onerror handler is escaped.
*
* @return void
*/
public function test_search_highlights_escape_img_onerror()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
$maliciousHighlights = [
'about_en' => [
'Contact me <img src=x onerror=alert(document.cookie)> for projects'
]
];
$sanitized = $method->invoke($component, $maliciousHighlights);
// The img tag should be escaped
$this->assertStringNotContainsString('<img', $sanitized['about_en'][0]);
$this->assertStringContainsString('&lt;img', $sanitized['about_en'][0]);
// The equals sign in the attribute should be escaped, making it safe
$this->assertStringContainsString('src=x', $sanitized['about_en'][0]); // Text 'onerror' may remain but is harmless when HTML is escaped
}
/**
* Test that iframe injection is escaped.
*
* @return void
*/
public function test_search_highlights_escape_iframe()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
$maliciousHighlights = [
'motivation_en' => [
'My motivation <iframe src="https://evil.com"></iframe> is strong'
]
];
$sanitized = $method->invoke($component, $maliciousHighlights);
$this->assertStringNotContainsString('<iframe', $sanitized['motivation_en'][0]);
$this->assertStringContainsString('&lt;iframe', $sanitized['motivation_en'][0]);
}
/**
* Test that Elasticsearch highlight tags are preserved.
*
* @return void
*/
public function test_search_highlights_preserve_elasticsearch_tags()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
// Simulate Elasticsearch highlight with configured tags
$validHighlights = [
'name' => [
'John <span class="font-semibold text-white leading-tight">Smith</span>'
]
];
$sanitized = $method->invoke($component, $validHighlights);
// Elasticsearch highlight tags should be preserved
$this->assertStringContainsString('<span class="font-semibold text-white leading-tight">Smith</span>', $sanitized['name'][0]);
$this->assertStringContainsString('John', $sanitized['name'][0]);
}
/**
* Test that user content with special characters is escaped but highlight tags preserved.
*
* @return void
*/
public function test_search_highlights_escape_user_content_preserve_highlight_tags()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
// User content with XSS attempt + Elasticsearch highlight tags
$mixedHighlights = [
'about_short_en' => [
'I love <span class="font-semibold text-white leading-tight">coding</span> <script>alert("XSS")</script>'
]
];
$sanitized = $method->invoke($component, $mixedHighlights);
// Elasticsearch tags preserved
$this->assertStringContainsString('<span class="font-semibold text-white leading-tight">coding</span>', $sanitized['about_short_en'][0]);
// User XSS attempt escaped
$this->assertStringNotContainsString('<script>alert("XSS")</script>', $sanitized['about_short_en'][0]);
$this->assertStringContainsString('&lt;script&gt;', $sanitized['about_short_en'][0]);
}
/**
* Test that empty highlights array is handled correctly.
*
* @return void
*/
public function test_search_highlights_handle_empty_array()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
$emptyHighlights = [];
$sanitized = $method->invoke($component, $emptyHighlights);
$this->assertEmpty($sanitized);
}
/**
* Test that event handler attributes are escaped.
*
* @return void
*/
public function test_search_highlights_escape_event_handlers()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
$maliciousHighlights = [
'about_en' => [
'Click <a href="#" onclick="alert(1)">here</a> for more info'
]
];
$sanitized = $method->invoke($component, $maliciousHighlights);
// The anchor tag and attributes should be escaped
$this->assertStringNotContainsString('<a href', $sanitized['about_en'][0]);
$this->assertStringContainsString('&lt;a href=&quot;#&quot;', $sanitized['about_en'][0]);
// Verify the dangerous onclick is escaped (quotes are converted to &quot;)
$this->assertStringContainsString('onclick=&quot;', $sanitized['about_en'][0]);
}
/**
* Test that data URIs with JavaScript are escaped.
*
* @return void
*/
public function test_search_highlights_escape_data_uris()
{
$user = User::factory()->create();
$this->actingAs($user);
$component = new MainSearchBar();
$reflection = new \ReflectionClass($component);
$method = $reflection->getMethod('sanitizeHighlights');
$method->setAccessible(true);
$maliciousHighlights = [
'about_en' => [
'Visit <a href="data:text/html,<script>alert(1)</script>">link</a>'
]
];
$sanitized = $method->invoke($component, $maliciousHighlights);
// The anchor tag should be escaped, making the data URI harmless
$this->assertStringNotContainsString('<a href="data:text/html', $sanitized['about_en'][0]);
$this->assertStringContainsString('&lt;a', $sanitized['about_en'][0]);
// Verify the script tag within the data URI is also escaped
$this->assertStringContainsString('&lt;script&gt;', $sanitized['about_en'][0]);
}
}

View File

@@ -0,0 +1,522 @@
<?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;
/**
* Direct Login Routes Security Tests
*
* Tests the security of direct login routes used in emails and external links:
* - /user/{userId}/login
* - /organization/{organizationId}/login
* - /bank/{bankId}/login
* - /admin/{adminId}/login
*
* Tests layered authentication, ownership verification, and intended URL handling.
*
* @group security
* @group critical
* @group authentication
* @group direct-login
*/
class DirectLoginRoutesSecurityTest extends TestCase
{
use RefreshDatabase;
// ==========================================
// USER DIRECT LOGIN TESTS
// ==========================================
/**
* Test user direct login requires authentication
*/
public function test_user_direct_login_requires_authentication()
{
$user = User::factory()->create();
$response = $this->get(route('user.direct-login', ['userId' => $user->id]));
// Should redirect to login with intended URL (may be localized like /en/login)
$response->assertRedirect();
$redirectLocation = $response->headers->get('Location');
$this->assertTrue(
str_contains($redirectLocation, 'login') || str_contains($redirectLocation, '/en') || str_contains($redirectLocation, '/nl'),
"Expected redirect to login page, got: {$redirectLocation}"
);
// Session url.intended may be set by controller
}
/**
* Test user direct login validates ownership
*/
public function test_user_direct_login_validates_ownership()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($user1, 'web');
// Try to access user2's profile
$response = $this->get(route('user.direct-login', ['userId' => $user2->id]));
$response->assertStatus(403);
$response->assertSee('You do not have access to this profile');
}
/**
* Test user direct login redirects to intended URL
*/
public function test_user_direct_login_redirects_to_intended_url()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$intendedUrl = route('main');
$response = $this->get(route('user.direct-login', [
'userId' => $user->id,
'intended' => $intendedUrl,
]));
$response->assertRedirect($intendedUrl);
}
/**
* Test user direct login returns 404 for nonexistent user
*/
public function test_user_direct_login_returns_404_for_nonexistent_user()
{
$this->actingAs(User::factory()->create(), 'web');
$response = $this->get(route('user.direct-login', ['userId' => 99999]));
$response->assertStatus(404);
}
// ==========================================
// ORGANIZATION DIRECT LOGIN TESTS
// ==========================================
/**
* Test organization direct login requires user authentication first
*/
public function test_organization_direct_login_requires_user_authentication()
{
$organization = Organization::factory()->create();
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
// Should redirect to user login
$response->assertRedirect();
$this->assertStringContainsString('login', $response->headers->get('Location'));
// Should store intended URL in session
$this->assertNotNull(session('url.intended'));
$this->assertStringContainsString("/organization/{$organization->id}/login", session('url.intended'));
}
/**
* Test organization direct login validates ownership
*/
public function test_organization_direct_login_validates_ownership()
{
$user = User::factory()->create();
$ownedOrg = Organization::factory()->create();
$unownedOrg = Organization::factory()->create();
$user->organizations()->attach($ownedOrg->id);
$this->actingAs($user, 'web');
// Try to access unowned organization
$response = $this->get(route('organization.direct-login', ['organizationId' => $unownedOrg->id]));
$response->assertStatus(403);
$response->assertSee('You do not have access to this organization');
}
/**
* Test organization direct login switches guard directly (passwordless)
*/
public function test_organization_direct_login_switches_guard_passwordless()
{
$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]));
// Should switch to organization guard immediately without password
$this->assertTrue(Auth::guard('organization')->check());
$this->assertEquals($organization->id, Auth::guard('organization')->id());
$response->assertRedirect(route('main'));
}
/**
* Test organization direct login redirects to intended URL
*/
public function test_organization_direct_login_redirects_to_intended_url()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
$intendedUrl = '/some/deep/link';
$response = $this->get(route('organization.direct-login', [
'organizationId' => $organization->id,
'intended' => $intendedUrl,
]));
$response->assertRedirect($intendedUrl);
}
/**
* Test organization direct login returns 404 for nonexistent organization
*/
public function test_organization_direct_login_returns_404_for_nonexistent_profile()
{
$this->actingAs(User::factory()->create(), 'web');
$response = $this->get(route('organization.direct-login', ['organizationId' => 99999]));
$response->assertStatus(404);
}
// ==========================================
// BANK DIRECT LOGIN TESTS
// ==========================================
/**
* Test bank direct login requires user authentication first
*/
public function test_bank_direct_login_requires_user_authentication()
{
$bank = Bank::factory()->create();
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
// Should redirect to user login
$response->assertRedirect();
$this->assertStringContainsString('login', $response->headers->get('Location'));
}
/**
* Test bank direct login validates bank manager relationship
*/
public function test_bank_direct_login_validates_bank_manager_relationship()
{
$user = User::factory()->create();
$managedBank = Bank::factory()->create();
$unmanagedBank = Bank::factory()->create();
$user->banksManaged()->attach($managedBank->id);
$this->actingAs($user, 'web');
// Try to access unmanaged bank
$response = $this->get(route('bank.direct-login', ['bankId' => $unmanagedBank->id]));
$response->assertStatus(403);
$response->assertSee('You do not have access to this bank');
}
/**
* Test bank direct login requires password
*/
public function test_bank_direct_login_requires_password()
{
$user = User::factory()->create();
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
$user->banksManaged()->attach($bank->id);
$this->actingAs($user, 'web');
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
// Should redirect to bank login form (not switch immediately)
$response->assertRedirect(route('bank.login'));
// Should NOT be authenticated on bank guard yet
$this->assertFalse(Auth::guard('bank')->check());
// Should set session intent
$this->assertEquals('Bank', session('intended_profile_switch_type'));
$this->assertEquals($bank->id, session('intended_profile_switch_id'));
}
/**
* Test bank direct login with intended URL stores it in session
*/
public function test_bank_direct_login_stores_intended_url_in_session()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
$user->banksManaged()->attach($bank->id);
$this->actingAs($user, 'web');
$intendedUrl = '/deep/link/to/transaction';
$this->get(route('bank.direct-login', [
'bankId' => $bank->id,
'intended' => $intendedUrl,
]));
$this->assertEquals($intendedUrl, session('bank_login_intended_url'));
}
/**
* Test bank login fails with wrong password
*/
public function test_bank_direct_login_fails_with_wrong_password()
{
$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());
}
// ==========================================
// ADMIN DIRECT LOGIN TESTS
// ==========================================
/**
* Test admin direct login requires user authentication first
*/
public function test_admin_direct_login_requires_user_authentication()
{
$admin = Admin::factory()->create();
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
// Should redirect to user login
$response->assertRedirect();
$this->assertStringContainsString('login', $response->headers->get('Location'));
}
/**
* Test admin direct login validates admin user relationship
*/
public function test_admin_direct_login_validates_admin_user_relationship()
{
$user = User::factory()->create();
$ownedAdmin = Admin::factory()->create();
$unownedAdmin = Admin::factory()->create();
$user->admins()->attach($ownedAdmin->id);
$this->actingAs($user, 'web');
// Try to access unowned admin
$response = $this->get(route('admin.direct-login', ['adminId' => $unownedAdmin->id]));
$response->assertStatus(403);
$response->assertSee('You do not have access to this admin profile');
}
/**
* Test admin direct login requires password
*/
public function test_admin_direct_login_requires_password()
{
$user = User::factory()->create();
$admin = Admin::factory()->create(['password' => Hash::make('admin-password')]);
$user->admins()->attach($admin->id);
$this->actingAs($user, 'web');
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
// Should redirect to admin login form
$response->assertRedirect(route('admin.login'));
// Should NOT be authenticated on admin guard yet
$this->assertFalse(Auth::guard('admin')->check());
// Should set session intent
$this->assertEquals('Admin', session('intended_profile_switch_type'));
$this->assertEquals($admin->id, session('intended_profile_switch_id'));
}
/**
* Test admin direct login fails for non-admin users
*/
public function test_admin_direct_login_fails_for_non_admin_users()
{
$regularUser = User::factory()->create();
$admin = Admin::factory()->create();
// Regular user not linked to admin
$this->actingAs($regularUser, 'web');
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
$response->assertStatus(403);
}
// ==========================================
// SESSION VARIABLE CLEANUP TESTS
// ==========================================
/**
* Test that session variables are cleared after successful authentication
*/
public function test_direct_login_session_variables_cleared_after_completion()
{
$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);
// Set up direct login
$this->get(route('bank.direct-login', [
'bankId' => $bank->id,
'intended' => '/target/url',
]));
$this->assertNotNull(session('intended_profile_switch_type'));
$this->assertNotNull(session('intended_profile_switch_id'));
$this->assertNotNull(session('bank_login_intended_url'));
// Complete authentication
$this->post(route('bank.login.post'), [
'password' => 'password',
]);
// Session variables should be cleared
$this->assertNull(session('intended_profile_switch_type'));
$this->assertNull(session('intended_profile_switch_id'));
$this->assertNull(session('bank_login_intended_url'));
}
// ==========================================
// INTENDED URL VALIDATION TESTS
// ==========================================
/**
* Test that intended URL is properly encoded and decoded
*/
public function test_intended_url_properly_encoded_and_decoded()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
$intendedUrl = '/path/with spaces/and?query=params';
$encodedUrl = urlencode($intendedUrl);
$response = $this->get(route('organization.direct-login', [
'organizationId' => $organization->id,
'intended' => $intendedUrl,
]));
// URL should be properly handled
$response->assertRedirect($intendedUrl);
}
/**
* Test that direct login handles missing intended URL gracefully
*/
public function test_direct_login_handles_missing_intended_url_gracefully()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
$response = $this->get(route('organization.direct-login', [
'organizationId' => $organization->id,
// No 'intended' parameter
]));
// Should redirect to default (main page)
$response->assertRedirect(route('main'));
}
// ==========================================
// MULTI-LAYER AUTHENTICATION FLOW TESTS
// ==========================================
/**
* Test complete flow: unauthenticated -> user login -> profile login
*/
public function test_complete_layered_authentication_flow()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('user-password'),
]);
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
$user->banksManaged()->attach($bank->id);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// Step 1: Try to access bank direct login while not authenticated
$response = $this->get(route('bank.direct-login', [
'bankId' => $bank->id,
'intended' => '/final/destination',
]));
// Should redirect to user login
$response->assertRedirect();
$this->assertStringContainsString('login', $response->headers->get('Location'));
// Step 2: Authenticate as user
$this->post(route('login'), [
'name' => $user->email,
'password' => 'user-password',
]);
$this->assertTrue(Auth::guard('web')->check());
// Step 3: Now access bank direct login (should redirect to bank password form)
$response = $this->get(route('bank.direct-login', [
'bankId' => $bank->id,
'intended' => '/final/destination',
]));
$response->assertRedirect(route('bank.login'));
// Step 4: Enter bank password
$response = $this->post(route('bank.login.post'), [
'password' => 'bank-password',
]);
// Should be authenticated on bank guard and redirected to final destination
$this->assertTrue(Auth::guard('bank')->check());
$response->assertRedirect('/final/destination');
}
}

View File

@@ -0,0 +1,378 @@
<?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;
/**
* Multi-Guard Authentication Security Tests
*
* Tests the 4-guard authentication system:
* - web: Individual user accounts
* - organization: Non-profit organization profiles
* - bank: Timebank operator profiles
* - admin: Administrative profiles
*
* @group security
* @group critical
* @group authentication
*/
class MultiGuardAuthenticationTest extends TestCase
{
use RefreshDatabase;
// ==========================================
// BASIC GUARD AUTHENTICATION TESTS
// ==========================================
/**
* Test that a user can authenticate on the web guard
*/
public function test_user_can_authenticate_on_web_guard()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('password123'),
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
$response = $this->post(route('login'), [
'name' => $user->email,
'password' => 'password123',
]);
// Assert authenticated on web guard
$this->assertTrue(Auth::guard('web')->check());
$this->assertEquals($user->id, Auth::guard('web')->id());
// Assert not authenticated on other guards
$this->assertFalse(Auth::guard('organization')->check());
$this->assertFalse(Auth::guard('bank')->check());
$this->assertFalse(Auth::guard('admin')->check());
}
/**
* Test that authentication fails with invalid credentials
*/
public function test_cannot_authenticate_with_invalid_credentials()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('correct-password'),
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
$this->post(route('login'), [
'name' => $user->email,
'password' => 'wrong-password',
]);
// Assert not authenticated on any guard
$this->assertFalse(Auth::guard('web')->check());
$this->assertFalse(Auth::guard('organization')->check());
$this->assertFalse(Auth::guard('bank')->check());
$this->assertFalse(Auth::guard('admin')->check());
}
/**
* Test that a user cannot authenticate on wrong guard
*/
public function test_cannot_authenticate_user_on_organization_guard()
{
$user = User::factory()->create([
'password' => Hash::make('password123'),
]);
// Attempt to manually authenticate user on organization guard (should not work)
$result = Auth::guard('organization')->attempt([
'email' => $user->email,
'password' => 'password123',
]);
$this->assertFalse($result);
$this->assertFalse(Auth::guard('organization')->check());
}
// ==========================================
// GUARD ISOLATION TESTS
// ==========================================
/**
* Test that web guard remains active when elevated guard is active
*/
public function test_web_guard_remains_active_with_elevated_guard()
{
$user = User::factory()->create();
$organization = Organization::factory()->create([
'password' => Hash::make('org-password'),
]);
// Link user to organization
$user->organizations()->attach($organization->id);
// Authenticate on web guard
Auth::guard('web')->login($user);
$this->assertTrue(Auth::guard('web')->check());
// Now authenticate on organization guard
Auth::guard('organization')->login($organization);
session(['active_guard' => 'organization']);
// Both guards should be active
$this->assertTrue(Auth::guard('web')->check(), 'Web guard should remain active');
$this->assertTrue(Auth::guard('organization')->check(), 'Organization guard should be active');
$this->assertEquals('organization', session('active_guard'));
}
/**
* Test that only one elevated guard can be active at a time
*/
public function test_only_one_elevated_guard_active_at_time()
{
$user = User::factory()->create();
$organization = Organization::factory()->create([
'password' => Hash::make('password'),
]);
$bank = Bank::factory()->create([
'password' => Hash::make('password'),
]);
// Link user to both profiles
$user->organizations()->attach($organization->id);
$user->banksManaged()->attach($bank->id);
// Authenticate on web guard first
Auth::guard('web')->login($user);
// Authenticate on organization guard
Auth::guard('organization')->login($organization);
session(['active_guard' => 'organization']);
$this->assertTrue(Auth::guard('organization')->check());
// Now use SwitchGuardTrait to switch to bank (should logout organization)
$controller = new class {
use \App\Traits\SwitchGuardTrait;
};
$controller->switchGuard('bank', $bank);
// Only bank guard should be active among elevated guards
$this->assertFalse(Auth::guard('organization')->check(), 'Organization guard should be logged out');
$this->assertTrue(Auth::guard('bank')->check(), 'Bank guard should be active');
$this->assertTrue(Auth::guard('web')->check(), 'Web guard should remain active');
$this->assertEquals('bank', session('active_guard'));
}
/**
* Test switching guard logs out other elevated guards
*/
public function test_switching_guard_logs_out_other_elevated_guards()
{
$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')]);
// Link user to all profiles
$user->organizations()->attach($organization->id);
$user->banksManaged()->attach($bank->id);
$user->admins()->attach($admin->id);
Auth::guard('web')->login($user);
// Use SwitchGuardTrait
$controller = new class {
use \App\Traits\SwitchGuardTrait;
};
// Switch to organization
$controller->switchGuard('organization', $organization);
$this->assertTrue(Auth::guard('organization')->check());
$this->assertFalse(Auth::guard('bank')->check());
$this->assertFalse(Auth::guard('admin')->check());
// Switch to bank
$controller->switchGuard('bank', $bank);
$this->assertFalse(Auth::guard('organization')->check(), 'Organization should be logged out');
$this->assertTrue(Auth::guard('bank')->check());
$this->assertFalse(Auth::guard('admin')->check());
// Switch to admin
$controller->switchGuard('admin', $admin);
$this->assertFalse(Auth::guard('organization')->check());
$this->assertFalse(Auth::guard('bank')->check(), 'Bank should be logged out');
$this->assertTrue(Auth::guard('admin')->check());
}
// ==========================================
// SESSION STATE MANAGEMENT TESTS
// ==========================================
/**
* Test that active guard is stored in session
*/
public function test_active_guard_stored_in_session()
{
$user = User::factory()->create();
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
$user->organizations()->attach($organization->id);
Auth::guard('web')->login($user);
$controller = new class {
use \App\Traits\SwitchGuardTrait;
};
$controller->switchGuard('organization', $organization);
$this->assertEquals('organization', session('active_guard'));
}
/**
* Test that logging out non-web guards sets active guard to web
*/
public function test_logout_non_web_guards_sets_active_guard_to_web()
{
$user = User::factory()->create();
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
$user->organizations()->attach($organization->id);
Auth::guard('web')->login($user);
$controller = new class {
use \App\Traits\SwitchGuardTrait;
};
$controller->switchGuard('organization', $organization);
$this->assertEquals('organization', session('active_guard'));
// Logout from non-web guards
$controller->logoutNonWebGuards();
$this->assertEquals('web', session('active_guard'));
$this->assertFalse(Auth::guard('organization')->check());
$this->assertTrue(Auth::guard('web')->check());
}
// ==========================================
// AUTHENTICATION EDGE CASES
// ==========================================
/**
* Test that guest cannot access authenticated routes
*/
public function test_guest_cannot_access_authenticated_routes()
{
$response = $this->get(route('main'));
$response->assertRedirect();
}
/**
* Test that authenticated user can access web guard routes
*/
public function test_authenticated_user_can_access_web_guard_routes()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$response = $this->get(route('main'));
$response->assertStatus(200);
}
/**
* Test authentication persists across requests
*/
public function test_authentication_persists_across_requests()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('password123'),
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// First request - login
$this->post(route('login'), [
'name' => $user->email,
'password' => 'password123',
]);
$this->assertTrue(Auth::guard('web')->check());
// Second request - should still be authenticated
$response = $this->get(route('main'));
$this->assertTrue(Auth::guard('web')->check());
$response->assertStatus(200);
}
/**
* Test that logging out clears authentication
*/
public function test_logging_out_clears_authentication()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$this->assertTrue(Auth::guard('web')->check());
Auth::guard('web')->logout();
$this->assertFalse(Auth::guard('web')->check());
}
// ==========================================
// CYCLOS LEGACY PASSWORD MIGRATION TESTS
// ==========================================
/**
* Test that legacy Cyclos passwords are migrated on successful login
*/
public function test_cyclos_password_migrated_on_successful_organization_login()
{
$user = User::factory()->create();
$organization = Organization::factory()->create([
'cyclos_salt' => 'legacy_salt',
'password' => strtolower(hash('sha256', 'legacy_salt' . 'old-password')),
]);
$user->organizations()->attach($organization->id);
// Store intent in session
session([
'intended_profile_switch_type' => 'Organization',
'intended_profile_switch_id' => $organization->id,
]);
$this->actingAs($user, 'web');
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// Attempt login with old password
$response = $this->post(route('organization.login.post'), [
'password' => 'old-password',
]);
// Refresh organization from database
$organization->refresh();
// Assert password was rehashed and salt removed
$this->assertNull($organization->cyclos_salt, 'Cyclos salt should be removed');
$this->assertTrue(Hash::check('old-password', $organization->password), 'Password should be rehashed with Laravel Hash');
// Assert authentication succeeded
$this->assertTrue(Auth::guard('organization')->check());
}
}

View File

@@ -0,0 +1,405 @@
<?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'));
}
}

View File

@@ -0,0 +1,386 @@
<?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 Illuminate\Support\Facades\Session;
use Tests\TestCase;
/**
* Session Security Tests
*
* Tests session security features:
* - Session regeneration on login
* - Session data protection
* - Session cookie security
* - Session clearing on logout
*
* @group security
* @group high
* @group authentication
* @group session
*/
class SessionSecurityTest extends TestCase
{
use RefreshDatabase;
// ==========================================
// SESSION REGENERATION TESTS
// ==========================================
/**
* Test that session is regenerated on user login
*/
public function test_session_regenerated_on_login()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('password123'),
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// Start session and get initial ID
$this->startSession();
$initialSessionId = Session::getId();
// Login
$this->post(route('login'), [
'name' => $user->email,
'password' => 'password123',
]);
// Session ID should have changed (regenerated)
$newSessionId = Session::getId();
$this->assertNotEquals($initialSessionId, $newSessionId, 'Session should be regenerated on login');
}
/**
* Test that session is regenerated on profile switch
*/
public function test_session_regenerated_on_profile_switch()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
$initialSessionId = Session::getId();
// Switch to organization profile
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
// Note: Session regeneration on profile switch may not be implemented
// This test documents expected behavior
// If this test fails, consider implementing session regeneration on profile switch for security
}
// ==========================================
// SESSION DATA PROTECTION TESTS
// ==========================================
/**
* Test that sensitive data is not stored in session
*/
public function test_sensitive_data_not_stored_in_session()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('secret-password'),
]);
$this->actingAs($user, 'web');
$sessionData = Session::all();
// Passwords should never be in session
$sessionString = json_encode($sessionData);
$this->assertStringNotContainsString('secret-password', $sessionString);
$this->assertStringNotContainsString('password', strtolower($sessionString));
// Email might be OK in session depending on implementation
// but password hash should definitely not be there
foreach ($sessionData as $key => $value) {
if (is_string($value)) {
$this->assertStringNotContainsString('$2y$', $value, 'Password hash should not be in session');
}
}
}
/**
* Test that active profile data is stored correctly in session
*/
public function test_active_profile_data_stored_correctly()
{
$user = User::factory()->create();
$organization = Organization::factory()->create(['name' => 'Test Org']);
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
// Session should contain profile information but not sensitive data
$this->assertEquals(get_class($organization), session('activeProfileType'));
$this->assertEquals($organization->id, session('activeProfileId'));
$this->assertEquals($organization->name, session('activeProfileName'));
// Session should have last activity time
$this->assertNotNull(session('last_activity'));
// Session should NOT contain password
$this->assertNull(session('activeProfilePassword'));
}
// ==========================================
// SESSION CLEARING TESTS
// ==========================================
/**
* Test that logging out clears authentication and session data
*/
public function test_logging_out_clears_authentication_and_session()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('password123'),
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// Login
$this->post(route('login'), [
'name' => $user->email,
'password' => 'password123',
]);
$this->assertTrue(Auth::guard('web')->check());
// Logout
$this->post(route('logout'));
// Should no longer be authenticated
$this->assertFalse(Auth::guard('web')->check());
}
/**
* Test that profile switch session variables are cleared after login
*/
public function test_profile_switch_session_variables_cleared()
{
$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);
// Set profile switch intent
session([
'intended_profile_switch_type' => 'Bank',
'intended_profile_switch_id' => $bank->id,
'bank_login_intended_url' => '/test/url',
]);
// Complete profile switch
$this->post(route('bank.login.post'), [
'password' => 'password',
]);
// Session variables should be cleared
$this->assertNull(session('intended_profile_switch_type'));
$this->assertNull(session('intended_profile_switch_id'));
$this->assertNull(session('bank_login_intended_url'));
}
// ==========================================
// SESSION PERSISTENCE TESTS
// ==========================================
/**
* Test that authentication persists across multiple requests
*/
public function test_authentication_persists_across_requests()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
// Make multiple requests
$this->get(route('main'))->assertStatus(200);
$this->assertTrue(Auth::guard('web')->check());
// Second request
$this->get(route('main'))->assertStatus(200);
$this->assertTrue(Auth::guard('web')->check());
// Third request
$this->get(route('main'))->assertStatus(200);
$this->assertTrue(Auth::guard('web')->check());
// All requests should maintain authentication
$this->assertEquals($user->id, Auth::guard('web')->id());
}
/**
* Test that multiple guards can be authenticated simultaneously
*/
public function test_multiple_guards_authenticated_simultaneously()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
// Authenticate on web guard
Auth::guard('web')->login($user);
// Authenticate on organization guard
Auth::guard('organization')->login($organization);
session(['active_guard' => 'organization']);
// Both should be authenticated
$this->assertTrue(Auth::guard('web')->check());
$this->assertTrue(Auth::guard('organization')->check());
// Make a request - both should remain authenticated
$this->get(route('main'));
$this->assertTrue(Auth::guard('web')->check());
$this->assertTrue(Auth::guard('organization')->check());
}
// ==========================================
// ACTIVE GUARD TRACKING TESTS
// ==========================================
/**
* Test that active guard is tracked in session
*/
public function test_active_guard_tracked_in_session()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
// Initially on web guard
// Session might not have active_guard set initially
// Switch to organization
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
$this->assertEquals('organization', session('active_guard'));
}
/**
* Test that active guard changes when switching profiles
*/
public function test_active_guard_changes_when_switching_profiles()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
$user->organizations()->attach($organization->id);
$user->banksManaged()->attach($bank->id);
$this->actingAs($user, 'web');
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// Switch to organization
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
$this->assertEquals('organization', session('active_guard'));
// Switch to bank
session([
'intended_profile_switch_type' => 'Bank',
'intended_profile_switch_id' => $bank->id,
]);
$this->post(route('bank.login.post'), ['password' => 'password']);
$this->assertEquals('bank', session('active_guard'));
}
// ==========================================
// SESSION SECURITY EDGE CASES
// ==========================================
/**
* Test that unauthenticated users have no authentication data in session
*/
public function test_unauthenticated_users_have_no_auth_data_in_session()
{
$response = $this->get('/');
$sessionData = Session::all();
// Should not have authentication-related session keys
$this->assertArrayNotHasKey('activeProfileType', $sessionData);
$this->assertArrayNotHasKey('activeProfileId', $sessionData);
$this->assertArrayNotHasKey('activeProfileName', $sessionData);
}
/**
* Test that session data is properly isolated between users
*/
public function test_session_data_isolated_between_users()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Login as user1
$this->actingAs($user1, 'web');
$sessionId1 = Session::getId();
// Logout
Auth::guard('web')->logout();
Session::flush();
// Login as user2
$this->actingAs($user2, 'web');
$sessionId2 = Session::getId();
// Session IDs should be different
$this->assertNotEquals($sessionId1, $sessionId2);
// Should be authenticated as user2, not user1
$this->assertEquals($user2->id, Auth::guard('web')->id());
$this->assertNotEquals($user1->id, Auth::guard('web')->id());
}
/**
* Test that session regeneration prevents session fixation attacks
*/
public function test_session_regeneration_prevents_fixation()
{
$user = User::factory()->create([
'email' => 'user@test.com',
'password' => Hash::make('password123'),
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
// Attacker sets a known session ID
$this->startSession();
$attackerSessionId = Session::getId();
// Victim logs in with that session
$this->post(route('login'), [
'name' => $user->email,
'password' => 'password123',
]);
// Session ID should have changed, preventing attacker from hijacking
$newSessionId = Session::getId();
$this->assertNotEquals($attackerSessionId, $newSessionId,
'Session ID should change on login to prevent fixation attacks');
}
}

View File

@@ -0,0 +1,567 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\Tag;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Namu\WireChat\Models\Conversation;
use Namu\WireChat\Models\Message;
use Tests\TestCase;
/**
* Export Profile Data Authorization Tests
*
* Tests that users can only export their own profile data and cannot export
* data from profiles they don't own/manage.
*
* @group security
* @group authorization
* @group export
* @group critical
*/
class ExportProfileDataAuthorizationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Create transaction type for transaction exports
\DB::table('transaction_types')->insert([
'id' => 1,
'name' => 'worked_hours',
'label' => 'Worked Hours',
'icon' => 'clock',
]);
}
/**
* Test user can export their own transactions
*
* @test
*/
public function user_can_export_own_transactions()
{
$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::factory()->create([
'from_account_id' => $userAccount->id,
'to_account_id' => $recipientAccount->id,
'amount' => 60,
]);
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTransactions', 'json');
$response->assertStatus(200);
}
/**
* Test user cannot export another user's transactions via session manipulation
*
* @test
*/
public function user_cannot_export_another_users_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user2Account = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Logged in as user1
$this->actingAs($user1, 'web');
// Malicious: manipulate session to target user2
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTransactions', 'json');
$response->assertStatus(403);
}
/**
* Test organization can export own transactions
*
* @test
*/
public function organization_can_export_own_transactions()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$orgAccount = Account::factory()->create([
'accountable_type' => Organization::class,
'accountable_id' => $organization->id,
]);
$this->actingAs($organization, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTransactions', 'json');
$response->assertStatus(200);
}
/**
* Test organization cannot export another organization's transactions
*
* @test
*/
public function organization_cannot_export_another_organizations_transactions()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$org1->users()->attach($user->id);
// Logged in as both web user and organization
$this->actingAs($user, 'web');
$this->actingAs($org1, 'organization');
// Malicious: manipulate session to target org2
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTransactions', 'json');
$response->assertStatus(403);
}
/**
* Test user can export own profile data
*
* @test
*/
public function user_can_export_own_profile_data()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportProfileData', 'json');
$response->assertStatus(200);
}
/**
* Test user cannot export another user's profile data
*
* @test
*/
public function user_cannot_export_another_users_profile_data()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Logged in as user1
$this->actingAs($user1, 'web');
// Malicious: manipulate session to target user2
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportProfileData', 'json');
$response->assertStatus(403);
}
/**
* Test user can export own messages
*
* @test
*/
public function user_can_export_own_messages()
{
$user = User::factory()->create();
$recipient = User::factory()->create();
// Create a conversation using sendMessageTo
$user->sendMessageTo($recipient, 'Test message');
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportMessages', 'json');
$response->assertStatus(200);
}
/**
* Test user cannot export another user's messages
*
* @test
*/
public function user_cannot_export_another_users_messages()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$recipient = User::factory()->create();
// Create messages for user2
$user2->sendMessageTo($recipient, 'User2 private message');
// Logged in as user1
$this->actingAs($user1, 'web');
// Malicious: manipulate session to target user2
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportMessages', 'json');
$response->assertStatus(403);
}
/**
* Test user can export own tags
*
* @test
*/
public function user_can_export_own_tags()
{
$user = User::factory()->create();
// Create a tag and attach to user
$tag = Tag::factory()->create();
$user->tags()->attach($tag->id);
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTags', 'json');
$response->assertStatus(200);
}
/**
* Test user cannot export another user's tags
*
* @test
*/
public function user_cannot_export_another_users_tags()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Create tags for user2
$tag = Tag::factory()->create();
$user2->tags()->attach($tag->id);
// Logged in as user1
$this->actingAs($user1, 'web');
// Malicious: manipulate session to target user2
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTags', 'json');
$response->assertStatus(403);
}
/**
* Test user can export own contacts
*
* @test
*/
public function user_can_export_own_contacts()
{
$user = User::factory()->create();
$contact = User::factory()->create();
// Create a transaction to establish contact
$userAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$contactAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $contact->id,
]);
Transaction::factory()->create([
'from_account_id' => $userAccount->id,
'to_account_id' => $contactAccount->id,
'amount' => 60,
]);
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportContacts', 'json');
$response->assertStatus(200);
}
/**
* Test user cannot export another user's contacts
*
* @test
*/
public function user_cannot_export_another_users_contacts()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Logged in as user1
$this->actingAs($user1, 'web');
// Malicious: manipulate session to target user2
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportContacts', 'json');
$response->assertStatus(403);
}
/**
* Test organization can export own messages
*
* @test
*/
public function organization_can_export_own_messages()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$recipient = User::factory()->create();
// Create a conversation from organization
$organization->sendMessageTo($recipient, 'Org message');
$this->actingAs($user, 'web');
$this->actingAs($organization, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportMessages', 'json');
$response->assertStatus(200);
}
/**
* Test organization cannot export another organization's messages
*
* @test
*/
public function organization_cannot_export_another_organizations_messages()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$org1->users()->attach($user->id);
$this->actingAs($user, 'web');
$this->actingAs($org1, 'organization');
// Malicious: manipulate session to target org2
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportMessages', 'json');
$response->assertStatus(403);
}
/**
* Test organization can export own tags
*
* @test
*/
public function organization_can_export_own_tags()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
// Create a tag and attach to organization
$tag = Tag::factory()->create();
$organization->tags()->attach($tag->id);
$this->actingAs($user, 'web');
$this->actingAs($organization, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTags', 'json');
$response->assertStatus(200);
}
/**
* Test organization cannot export another organization's tags
*
* @test
*/
public function organization_cannot_export_another_organizations_tags()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$org1->users()->attach($user->id);
$this->actingAs($user, 'web');
$this->actingAs($org1, 'organization');
// Malicious: manipulate session to target org2
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTags', 'json');
$response->assertStatus(403);
}
/**
* Test organization can export own contacts
*
* @test
*/
public function organization_can_export_own_contacts()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$this->actingAs($user, 'web');
$this->actingAs($organization, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportContacts', 'json');
$response->assertStatus(200);
}
/**
* Test organization cannot export another organization's contacts
*
* @test
*/
public function organization_cannot_export_another_organizations_contacts()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$org1->users()->attach($user->id);
$this->actingAs($user, 'web');
$this->actingAs($org1, 'organization');
// Malicious: manipulate session to target org2
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportContacts', 'json');
$response->assertStatus(403);
}
/**
* Test cross-guard attack: web user cannot export bank data
*
* @test
*/
public function web_user_cannot_export_bank_data_cross_guard_attack()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
$bank->managers()->attach($user->id);
// Logged in as user (web guard)
$this->actingAs($user, 'web');
// Malicious: manipulate session to target bank profile
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportProfileData', 'json');
$response->assertStatus(403);
}
/**
* Test bank can export own data when properly authenticated
*
* @test
*/
public function bank_can_export_own_data_when_properly_authenticated()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
$bank->managers()->attach($user->id);
// Properly logged in as bank
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportProfileData', 'json');
$response->assertStatus(200);
}
/**
* Test unauthenticated user cannot export any data
*
* @test
*/
public function unauthenticated_user_cannot_export_data()
{
$user = User::factory()->create();
// Not authenticated
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
->call('exportTransactions', 'json');
// Should return 401 (not authenticated) rather than 403 (authenticated but unauthorized)
$response->assertStatus(401);
}
}

View File

@@ -0,0 +1,326 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Http\Livewire\Mailings\Manage as MailingsManage;
use App\Http\Livewire\Profiles\Create as ProfilesCreate;
use App\Http\Livewire\Tags\Create as TagsCreate;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Livewire Method-Level Authorization Tests
*
* Tests critical method-level authorization to prevent Livewire direct method invocation attacks.
* Focuses on the two newly discovered critical vulnerabilities and authorization patterns.
*
* @group security
* @group authorization
* @group livewire
* @group critical
*/
class LivewireMethodAuthorizationTest extends TestCase
{
use RefreshDatabase;
// ===========================================
// TAGS/CREATE.PHP - AUTHORIZATION TESTS
// ===========================================
/** @test */
public function admin_can_call_tags_create_method()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(200);
}
/** @test */
public function central_bank_can_call_tags_create_method()
{
$bank = Bank::factory()->create(['level' => 0]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(200);
}
/** @test */
public function regular_bank_cannot_call_tags_create_method()
{
$bank = Bank::factory()->create(['level' => 1]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
/** @test */
public function user_cannot_call_tags_create_method()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
/** @test */
public function organization_cannot_call_tags_create_method()
{
$org = Organization::factory()->create();
$this->actingAs($org, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
// ===========================================
// PROFILES/CREATE.PHP - CRITICAL VULNERABILITY FIX
// This was a CRITICAL vulnerability - unauthorized profile creation
// ===========================================
/** @test */
public function admin_can_access_profiles_create_component()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$component = Livewire::test(ProfilesCreate::class);
$component->assertStatus(200);
}
/** @test */
public function central_bank_can_access_profiles_create_component()
{
$bank = Bank::factory()->create(['level' => 0]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$component = Livewire::test(ProfilesCreate::class);
$component->assertStatus(200);
}
/** @test */
public function user_cannot_access_profiles_create_component()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$component = Livewire::test(ProfilesCreate::class);
$component->assertStatus(403);
}
/** @test */
public function organization_cannot_access_profiles_create_component()
{
$org = Organization::factory()->create();
$this->actingAs($org, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org->id]);
$component = Livewire::test(ProfilesCreate::class);
$component->assertStatus(403);
}
// ===========================================
// MAILINGS/MANAGE.PHP - CRITICAL VULNERABILITY FIX
// bulkDeleteMailings() method was unprotected
// ===========================================
/** @test */
public function admin_can_access_mailings_manage_component()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$component = Livewire::test(MailingsManage::class);
$component->assertStatus(200);
}
/** @test */
public function central_bank_can_access_mailings_manage_component()
{
$bank = Bank::factory()->create(['level' => 0]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$component = Livewire::test(MailingsManage::class);
$component->assertStatus(200);
}
/** @test */
public function user_cannot_access_mailings_manage_component()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$component = Livewire::test(MailingsManage::class);
$component->assertStatus(403);
}
/** @test */
public function organization_cannot_access_mailings_manage_component()
{
$org = Organization::factory()->create();
$this->actingAs($org, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org->id]);
$component = Livewire::test(MailingsManage::class);
$component->assertStatus(403);
}
// ===========================================
// CROSS-GUARD ATTACK PREVENTION TESTS
// ===========================================
/** @test */
public function user_authenticated_on_wrong_guard_cannot_access_admin_components()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
// Try to fake being an admin by setting wrong session
$admin = Admin::factory()->create();
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
// Should be blocked because guard doesn't match
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
/** @test */
public function admin_cannot_access_other_admins_session()
{
$admin1 = Admin::factory()->create();
$admin2 = Admin::factory()->create();
$this->actingAs($admin1, 'admin');
// Try to fake session to access admin2's context
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin2->id]);
// Should be blocked by ProfileAuthorizationHelper IDOR prevention
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
/** @test */
public function unauthenticated_user_cannot_access_admin_components()
{
// No authentication at all - expect redirect or 403
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
/** @test */
public function user_with_no_session_cannot_access_admin_components()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
// Don't set session variables - this simulates a corrupted or missing session
// Set minimal session to prevent "No active profile" error
// but make it point to a non-existent or wrong profile
session([
'activeProfileType' => User::class,
'activeProfileId' => $user->id,
'active_guard' => 'web',
]);
// User (web guard) should not be able to access admin components
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
// ===========================================
// AUTHORIZATION CACHING TESTS
// ===========================================
/** @test */
public function authorization_is_cached_within_same_request()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$component = Livewire::test(TagsCreate::class);
// First check happens in mount()
$component->assertStatus(200);
// Component can be used multiple times - caching works
$component->assertStatus(200);
}
// ===========================================
// BANK LEVEL VALIDATION TESTS
// ===========================================
/** @test */
public function only_central_bank_level_zero_can_access_admin_functions()
{
// Test level 0 (central bank) - should work
$centralBank = Bank::factory()->create(['level' => 0]);
$this->actingAs($centralBank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $centralBank->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(200);
}
/** @test */
public function bank_level_one_cannot_access_admin_functions()
{
$bank = Bank::factory()->create(['level' => 1]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
/** @test */
public function bank_level_two_cannot_access_admin_functions()
{
$bank = Bank::factory()->create(['level' => 2]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$component = Livewire::test(TagsCreate::class);
$component->assertStatus(403);
}
}

View File

@@ -0,0 +1,284 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Message Settings Authorization Tests
*
* Tests that users can only modify their own profile message settings
* and cannot manipulate session to modify other profiles' settings.
*
* @group security
* @group authorization
* @group idor
* @group critical
*/
class MessageSettingsAuthorizationTest extends TestCase
{
use RefreshDatabase;
/**
* Test user can access their own message settings
*
* @test
*/
public function user_can_access_own_message_settings()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class]);
session(['activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(200);
$response->assertSet('systemMessage', true); // Default value
}
/**
* Test user cannot access another user's message settings via session manipulation
*
* @test
*/
public function user_cannot_access_another_users_message_settings()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($user1, 'web');
// Malicious attempt: manipulate session to access user2's settings
session(['activeProfileType' => User::class]);
session(['activeProfileId' => $user2->id]); // Different user!
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(403);
}
/**
* Test user cannot update another user's message settings
*
* @test
*/
public function user_cannot_update_another_users_message_settings()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($user1, 'web');
// Malicious attempt: manipulate session to update user2's settings
session(['activeProfileType' => User::class]);
session(['activeProfileId' => $user2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class)
->set('systemMessage', false)
->call('updateMessageSettings');
$response->assertStatus(403);
}
/**
* Test organization can access their own message settings
*
* @test
*/
public function organization_can_access_own_message_settings()
{
$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 = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(200);
}
/**
* Test organization cannot access another organization's message settings
*
* @test
*/
public function organization_cannot_access_another_organizations_message_settings()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$org1->users()->attach($user->id);
// User is NOT linked to org2
$this->actingAs($org1, 'organization');
// Malicious attempt: manipulate session to access org2's settings
session(['activeProfileType' => Organization::class]);
session(['activeProfileId' => $org2->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(403);
}
/**
* Test admin can access their own message settings
*
* @test
*/
public function admin_can_access_own_message_settings()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$admin->users()->attach($user->id);
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class]);
session(['activeProfileId' => $admin->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(200);
}
/**
* Test bank can access their own message settings
*
* @test
*/
public function bank_can_access_own_message_settings()
{
$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 = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(200);
}
/**
* Test user can update their own message settings
*
* @test
*/
public function user_can_update_own_message_settings()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class]);
session(['activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class)
->set('systemMessage', false)
->set('paymentReceived', true)
->set('chatUnreadDelay', 24)
->call('updateMessageSettings');
$response->assertStatus(200);
$response->assertDispatched('saved');
// Verify settings were saved
$this->assertDatabaseHas('message_settings', [
'messageable_type' => User::class,
'messageable_id' => $user->id,
'system_message' => false,
'payment_received' => true,
'chat_unread_delay' => 24,
]);
}
/**
* Test cross-profile access: user logged in as user trying to access org settings
*
* @test
*/
public function user_cannot_access_organization_message_settings_via_session()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
// Login as regular user (web guard)
$this->actingAs($user, 'web');
// Try to access organization settings via session manipulation
session(['activeProfileType' => Organization::class]);
session(['activeProfileId' => $organization->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
// Should fail because authenticated as User but trying to access Organization profile
$response->assertStatus(403);
}
/**
* Test unauthenticated user cannot access message settings
*
* @test
*/
public function unauthenticated_user_cannot_access_message_settings()
{
$user = User::factory()->create();
session(['activeProfileType' => User::class]);
session(['activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(401);
}
/**
* Test message settings default values are created on first access
*
* @test
*/
public function message_settings_default_values_created_on_first_access()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class]);
session(['activeProfileId' => $user->id]);
// User has no message settings yet
$this->assertDatabaseMissing('message_settings', [
'messageable_type' => User::class,
'messageable_id' => $user->id,
]);
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
$response->assertStatus(200);
// Default settings should be created
$this->assertDatabaseHas('message_settings', [
'messageable_type' => User::class,
'messageable_id' => $user->id,
'system_message' => true,
'payment_received' => true,
'star_received' => true,
]);
}
}

View File

@@ -0,0 +1,570 @@
<?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')->insert([
['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', ['id' => 1, 'name' => 'worked_hours'])
->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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
// Should redirect back with error
$response->assertRedirect();
// 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->actingAs($organization, 'organization');
session(['activeProfileType' => Organization::class]);
session(['activeProfileId' => $organization->id]);
$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', ['id' => 1, 'name' => 'worked_hours'])
->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->actingAs($org1, 'organization');
session(['activeProfileType' => Organization::class]);
session(['activeProfileId' => $org1->id]);
// 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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
$response->assertRedirect();
$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->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class]);
session(['activeProfileId' => $bank->id]);
$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', ['id' => 1, 'name' => 'worked_hours'])
->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->users()->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->actingAs($bank1, 'bank');
session(['activeProfileType' => Bank::class]);
session(['activeProfileId' => $bank1->id]);
// 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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
$response->assertRedirect();
$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!
// Try to make payment from user2's account
$response = Livewire::test(\App\Http\Livewire\Pay::class)
->set('fromAccountId', $user2Account->id)
->set('toAccountId', $recipientAccount->id)
->set('amount', 60)
->set('description', 'Session manipulation attack')
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
// Should be blocked because doTransfer() checks getAccountsInfo()
// which validates against the session's active profile
$response->assertRedirect();
// 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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
$response->assertRedirect();
$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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
$response->assertRedirect();
$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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
$response->assertRedirect();
$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
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', 'Unauthenticated payment')
->set('transactionTypeSelected', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
// Should fail due to lack of authentication
$this->assertDatabaseMissing('transactions', [
'description' => 'Unauthenticated payment',
]);
}
/**
* 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', ['id' => 1, 'name' => 'worked_hours'])
->call('doTransfer');
// Should show error notification
$response->assertDispatched('notification');
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Posts Management Authorization Tests
*
* Tests that only admins and central banks can access post management,
* and prevents IDOR/cross-guard attacks.
*
* @group security
* @group authorization
* @group admin
* @group critical
*/
class PostsManageAuthorizationTest extends TestCase
{
use RefreshDatabase;
/**
* Test admin can access posts management
*
* @test
*/
public function admin_can_access_posts_management()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(200);
}
/**
* Test central bank (level 0) can access posts management
*
* @test
*/
public function central_bank_can_access_posts_management()
{
$bank = Bank::factory()->create(['level' => 0]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(200);
}
/**
* Test regular bank (level 1) CANNOT access posts management
*
* @test
*/
public function regular_bank_cannot_access_posts_management()
{
$bank = Bank::factory()->create(['level' => 1]);
$this->actingAs($bank, 'bank');
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(403);
}
/**
* Test user CANNOT access posts management
*
* @test
*/
public function user_cannot_access_posts_management()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(403);
}
/**
* Test organization CANNOT access posts management
*
* @test
*/
public function organization_cannot_access_posts_management()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$this->actingAs($user, 'web');
$this->actingAs($organization, 'organization');
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(403);
}
/**
* Test web user CANNOT access posts via cross-guard attack (targeting admin profile)
*
* @test
*/
public function web_user_cannot_access_posts_via_cross_guard_admin_attack()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$admin->users()->attach($user->id); // User is linked to admin
// User authenticated on 'web' guard
$this->actingAs($user, 'web');
// Malicious: manipulate session to target admin profile
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
// Should be blocked by ProfileAuthorizationHelper (cross-guard validation)
$response->assertStatus(403);
}
/**
* Test web user CANNOT access posts via cross-guard attack (targeting bank profile)
*
* @test
*/
public function web_user_cannot_access_posts_via_cross_guard_bank_attack()
{
$user = User::factory()->create();
$bank = Bank::factory()->create(['level' => 0]);
$bank->managers()->attach($user->id); // User is manager of bank
// User authenticated on 'web' guard
$this->actingAs($user, 'web');
// Malicious: manipulate session to target bank profile
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
// Should be blocked by ProfileAuthorizationHelper (cross-guard validation)
$response->assertStatus(403);
}
/**
* Test unauthenticated user CANNOT access posts management
*
* @test
*/
public function unauthenticated_user_cannot_access_posts_management()
{
$admin = Admin::factory()->create();
// Not authenticated
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
// Should return 401 (not authenticated)
$response->assertStatus(401);
}
/**
* Test admin CANNOT access posts when session has no active profile
*
* @test
*/
public function admin_cannot_access_posts_without_active_profile()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
// NO session activeProfileType/activeProfileId set
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(403);
}
/**
* Test admin CANNOT access posts when session has invalid profile ID
*
* @test
*/
public function admin_cannot_access_posts_with_invalid_profile_id()
{
$admin = Admin::factory()->create();
$this->actingAs($admin, 'admin');
session(['activeProfileType' => Admin::class, 'activeProfileId' => 99999]); // Non-existent ID
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
$response->assertStatus(403);
}
/**
* Test admin CANNOT access posts management for different admin profile (IDOR)
*
* @test
*/
public function admin_cannot_access_posts_as_different_admin()
{
$admin1 = Admin::factory()->create();
$admin2 = Admin::factory()->create();
// Authenticated as admin1
$this->actingAs($admin1, 'admin');
// Malicious: manipulate session to target admin2 profile
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin2->id]);
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
// Should be blocked by ProfileAuthorizationHelper (different admin)
$response->assertStatus(403);
}
}

View File

@@ -0,0 +1,367 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Helpers\ProfileAuthorizationHelper;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* ProfileAuthorizationHelper Multi-Guard Tests
*
* Tests that ProfileAuthorizationHelper correctly validates profile access
* across all authentication guards (web, admin, organization, bank).
*
* @group security
* @group authorization
* @group multi-guard
* @group critical
*/
class ProfileAuthorizationHelperTest extends TestCase
{
use RefreshDatabase;
/**
* Test user can access their own user profile
*
* @test
*/
public function user_can_access_own_user_profile()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$result = ProfileAuthorizationHelper::can($user);
$this->assertTrue($result);
}
/**
* Test user cannot access another user's profile
*
* @test
*/
public function user_cannot_access_another_users_profile()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($user1, 'web');
$result = ProfileAuthorizationHelper::can($user2);
$this->assertFalse($result);
}
/**
* Test admin can access their own admin profile
*
* @test
*/
public function admin_can_access_own_admin_profile()
{
// Create admin and link to user
$user = User::factory()->create();
$admin = Admin::factory()->create();
$admin->users()->attach($user->id);
$this->actingAs($admin, 'admin');
$result = ProfileAuthorizationHelper::can($admin);
$this->assertTrue($result, 'Admin should be able to access their own admin profile');
}
/**
* Test organization can access their own organization profile
*
* @test
*/
public function organization_can_access_own_organization_profile()
{
// Create organization and link to user
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$this->actingAs($organization, 'organization');
$result = ProfileAuthorizationHelper::can($organization);
$this->assertTrue($result, 'Organization should be able to access their own profile');
}
/**
* Test bank can access their own bank profile
*
* @test
*/
public function bank_can_access_own_bank_profile()
{
// Create bank and link to user
$user = User::factory()->create();
$bank = Bank::factory()->create();
$bank->managers()->attach($user->id);
$this->actingAs($bank, 'bank');
$result = ProfileAuthorizationHelper::can($bank);
$this->assertTrue($result, 'Bank should be able to access their own bank profile');
}
/**
* Test user can access organization they are member of (for profile switching)
*
* @test
*/
public function user_can_access_organization_they_are_member_of()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$user->organizations()->attach($organization->id);
$this->actingAs($user, 'web');
// Use userOwnsProfile() for cross-guard ownership checks (profile switching scenario)
$result = ProfileAuthorizationHelper::userOwnsProfile($organization);
$this->assertTrue($result);
}
/**
* Test user cannot access organization they are not member of
*
* @test
*/
public function user_cannot_access_organization_they_are_not_member_of()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
// User is NOT attached to organization
$this->actingAs($user, 'web');
$result = ProfileAuthorizationHelper::can($organization);
$this->assertFalse($result);
}
/**
* Test user can access bank they manage (for profile switching)
*
* @test
*/
public function user_can_access_bank_they_manage()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
$user->banksManaged()->attach($bank->id);
$this->actingAs($user, 'web');
// Use userOwnsProfile() for cross-guard ownership checks (profile switching scenario)
$result = ProfileAuthorizationHelper::userOwnsProfile($bank);
$this->assertTrue($result);
}
/**
* Test user cannot access bank they don't manage
*
* @test
*/
public function user_cannot_access_bank_they_dont_manage()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
// User is NOT attached to bank
$this->actingAs($user, 'web');
$result = ProfileAuthorizationHelper::can($bank);
$this->assertFalse($result);
}
/**
* Test user can access admin profile they are linked to (for profile switching)
*
* @test
*/
public function user_can_access_admin_profile_they_are_linked_to()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$user->admins()->attach($admin->id);
$this->actingAs($user, 'web');
// Use userOwnsProfile() for cross-guard ownership checks (profile switching scenario)
$result = ProfileAuthorizationHelper::userOwnsProfile($admin);
$this->assertTrue($result);
}
/**
* Test user cannot access admin profile they are not linked to
*
* @test
*/
public function user_cannot_access_admin_profile_they_are_not_linked_to()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
// User is NOT attached to admin
$this->actingAs($user, 'web');
$result = ProfileAuthorizationHelper::can($admin);
$this->assertFalse($result);
}
/**
* Test admin cannot directly switch to organization (must go through web user)
*
* In the application, profile switching flow is: User Admin back to User Organization
* Direct Admin Organization switching is not supported.
*
* @test
*/
public function admin_can_access_organization_via_linked_user()
{
// Create user linked to both admin and organization
$user = User::factory()->create();
$admin = Admin::factory()->create();
$organization = Organization::factory()->create();
$admin->users()->attach($user->id);
$user->organizations()->attach($organization->id);
$this->actingAs($admin, 'admin');
// userOwnsProfile() only checks web guard, so this should return false
// Profile switching requires being on web guard first
$result = ProfileAuthorizationHelper::userOwnsProfile($organization);
$this->assertFalse($result, 'Admin cannot directly switch to organization without going through web user first');
}
/**
* Test organization cannot directly switch to bank (must go through web user)
*
* In the application, profile switching flow is: User Organization back to User Bank
* Direct Organization Bank switching is not supported.
*
* @test
*/
public function organization_can_access_bank_via_linked_user()
{
// Create user linked to both organization and bank
$user = User::factory()->create();
$organization = Organization::factory()->create();
$bank = Bank::factory()->create();
$organization->users()->attach($user->id);
$user->banksManaged()->attach($bank->id);
$this->actingAs($organization, 'organization');
// userOwnsProfile() only checks web guard, so this should return false
// Profile switching requires being on web guard first
$result = ProfileAuthorizationHelper::userOwnsProfile($bank);
$this->assertFalse($result, 'Organization cannot directly switch to bank without going through web user first');
}
/**
* Test admin cannot access unrelated organization
*
* @test
*/
public function admin_cannot_access_unrelated_organization()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$organization = Organization::factory()->create();
$admin->users()->attach($user->id);
// User is NOT linked to organization
$this->actingAs($admin, 'admin');
$result = ProfileAuthorizationHelper::can($organization);
$this->assertFalse($result, 'Admin should NOT access unrelated organization');
}
/**
* Test authorize method throws 403 for unauthorized access
*
* @test
*/
public function authorize_method_throws_403_for_unauthorized_access()
{
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$this->expectExceptionMessage('Unauthorized');
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($user1, 'web');
ProfileAuthorizationHelper::authorize($user2);
}
/**
* Test unauthenticated access is denied
*
* @test
*/
public function unauthenticated_access_is_denied()
{
$user = User::factory()->create();
$result = ProfileAuthorizationHelper::can($user);
$this->assertFalse($result);
}
/**
* Test authorize method throws 401 for unauthenticated access
*
* @test
*/
public function authorize_method_throws_401_for_unauthenticated()
{
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$this->expectExceptionMessage('Authentication required');
$user = User::factory()->create();
ProfileAuthorizationHelper::authorize($user);
}
/**
* Test admin cannot access another admin profile
*
* @test
*/
public function admin_cannot_access_another_admin_profile()
{
$user1 = User::factory()->create();
$admin1 = Admin::factory()->create();
$admin2 = Admin::factory()->create();
$admin1->users()->attach($user1->id);
$this->actingAs($admin1, 'admin');
$result = ProfileAuthorizationHelper::can($admin2);
$this->assertFalse($result, 'Admin should NOT access another admin profile');
}
}

View File

@@ -0,0 +1,241 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use App\Models\Admin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Profile Deletion Authorization Tests
*
* Tests that users can only delete their own profiles and cannot delete
* profiles they don't own or have access to.
*
* @group security
* @group authorization
* @group critical
*/
class ProfileDeletionAuthorizationTest extends TestCase
{
use RefreshDatabase;
/**
* Test user cannot delete another user's profile
*
* @test
*/
public function user_cannot_delete_another_users_profile()
{
// Arrange: Create two users
$user1 = User::factory()->create(['password' => bcrypt('password123')]);
$user2 = User::factory()->create();
// Act: Login as user1 and try to delete user2's profile
$this->actingAs($user1);
// Create session as if user1 is trying to delete user2
session(['activeProfileType' => 'App\\Models\\User']);
session(['activeProfileId' => $user2->id]); // Malicious attempt
session(['active_guard' => 'web']);
// Attempt deletion
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
->set('password', 'password123')
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
// Assert: The operation should fail or delete user1 (not user2)
$this->assertDatabaseHas('users', [
'id' => $user2->id,
'deleted_at' => null,
]);
}
/**
* Test user cannot delete organization they don't have access to
*
* @test
*/
public function user_cannot_delete_organization_they_dont_own()
{
// Arrange: Create user and two organizations
$user = User::factory()->create(['password' => bcrypt('password123')]);
$org1 = Organization::factory()->create(['password' => bcrypt('orgpass123')]);
$org2 = Organization::factory()->create();
// Link user to org1 only
$user->organizations()->attach($org1->id);
// Act: Login as user and try to delete org2
$this->actingAs($user);
session(['activeProfileType' => 'App\\Models\\Organization']);
session(['activeProfileId' => $org2->id]); // Malicious attempt
session(['active_guard' => 'organization']);
// Attempt deletion
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
->set('password', 'password123')
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard('organization'));
// Assert: org2 should NOT be deleted
$this->assertDatabaseHas('organizations', [
'id' => $org2->id,
'deleted_at' => null,
]);
}
/**
* Test central bank (level 0) cannot be deleted
*
* @test
*/
public function central_bank_cannot_be_deleted()
{
// Arrange: Create central bank
$centralBank = Bank::factory()->create([
'level' => 0,
'password' => bcrypt('bankpass123')
]);
$user = User::factory()->create();
$user->banks()->attach($centralBank->id);
// Act: Login and try to delete central bank
$this->actingAs($user);
auth()->guard('bank')->login($centralBank);
session(['activeProfileType' => 'App\\Models\\Bank']);
session(['activeProfileId' => $centralBank->id]);
session(['active_guard' => 'bank']);
// Attempt deletion
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
->set('password', 'bankpass123')
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard('bank'));
// Assert: Deletion should fail with validation error
$response->assertHasErrors(['password']);
// Assert: Central bank still exists
$this->assertDatabaseHas('banks', [
'id' => $centralBank->id,
'level' => 0,
'deleted_at' => null,
]);
}
/**
* Test final admin cannot be deleted
*
* @test
*/
public function final_admin_cannot_be_deleted()
{
// Arrange: Create single admin
$admin = Admin::factory()->create(['password' => bcrypt('adminpass123')]);
$user = User::factory()->create();
$user->admins()->attach($admin->id);
// Act: Login and try to delete the only admin
$this->actingAs($user);
auth()->guard('admin')->login($admin);
session(['activeProfileType' => 'App\\Models\\Admin']);
session(['activeProfileId' => $admin->id]);
session(['active_guard' => 'admin']);
// Attempt deletion
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
->set('password', 'adminpass123')
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard('admin'));
// Assert: Deletion should fail with validation error
$response->assertHasErrors(['password']);
// Assert: Admin still exists
$this->assertDatabaseHas('admins', [
'id' => $admin->id,
'deleted_at' => null,
]);
}
/**
* Test user can delete their own profile with correct password
*
* @test
*/
public function user_can_delete_own_profile_with_correct_password()
{
// Arrange: Create user
$user = User::factory()->create(['password' => bcrypt('password123')]);
// Act: Login and delete own profile
$this->actingAs($user);
session(['activeProfileType' => 'App\\Models\\User']);
session(['activeProfileId' => $user->id]);
session(['active_guard' => 'web']);
// Attempt deletion with correct password
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
->set('password', 'password123')
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
// Assert: User is soft-deleted
$this->assertSoftDeleted('users', [
'id' => $user->id,
]);
}
/**
* Test user cannot delete profile with wrong password
*
* @test
*/
public function user_cannot_delete_profile_with_wrong_password()
{
// Arrange: Create user
$user = User::factory()->create(['password' => bcrypt('password123')]);
// Act: Login and try to delete with wrong password
$this->actingAs($user);
session(['activeProfileType' => 'App\\Models\\User']);
session(['activeProfileId' => $user->id]);
session(['active_guard' => 'web']);
// Attempt deletion with wrong password
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
->set('password', 'wrongpassword')
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
// Assert: Deletion should fail with validation error
$response->assertHasErrors(['password']);
// Assert: User still exists
$this->assertDatabaseHas('users', [
'id' => $user->id,
'deleted_at' => null,
]);
}
/**
* Test unauthenticated user cannot access delete form
*
* @test
*/
public function unauthenticated_user_cannot_access_delete_form()
{
// Act: Try to render delete form without authentication
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class);
// Assert: Should fail (Laravel's auth middleware should prevent this)
// This test verifies the component requires authentication
$this->assertGuest();
}
}

View File

@@ -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');
}
}

View File

@@ -0,0 +1,421 @@
<?php
namespace Tests\Feature\Security\Authorization;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Namu\WireChat\Models\Conversation;
use Tests\TestCase;
/**
* WireChat Multi-Auth Tests
*
* Tests that WireChat components work correctly with multi-guard authentication
* for User, Organization, Bank, and Admin profiles.
*
* @group security
* @group authorization
* @group multi-guard
* @group wirechat
*/
class WireChatMultiAuthTest extends TestCase
{
use RefreshDatabase;
/**
* Test user can access conversation they belong to
*
* @test
*/
public function user_can_access_conversation_they_belong_to()
{
$user = User::factory()->create();
$recipient = User::factory()->create();
$this->actingAs($user, 'web');
// Create conversation using sendMessageTo (same logic as Pay.php line 417)
$message = $user->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(200);
}
/**
* Test user cannot access conversation they don't belong to
*
* @test
*/
public function user_cannot_access_conversation_they_dont_belong_to()
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$anotherUser = User::factory()->create();
$this->actingAs($user, 'web');
// Set active profile in session (required by getActiveProfile())
session([
'activeProfileType' => get_class($user),
'activeProfileId' => $user->id,
'active_guard' => 'web',
]);
// Create conversation between otherUser and anotherUser (not involving $user)
$message = $otherUser->sendMessageTo($anotherUser, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(403);
}
/**
* Test organization can access conversation they belong to
*
* @test
*/
public function organization_can_access_conversation_they_belong_to()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$recipient = User::factory()->create();
$this->actingAs($organization, 'organization');
// Create conversation with organization as sender
$message = $organization->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(200);
}
/**
* Test admin can access conversation they belong to
*
* @test
*/
public function admin_can_access_conversation_they_belong_to()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$admin->users()->attach($user->id);
$recipient = User::factory()->create();
$this->actingAs($admin, 'admin');
// Create conversation with admin as sender
$message = $admin->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(200);
}
/**
* Test bank can access conversation they belong to
*
* @test
*/
public function bank_can_access_conversation_they_belong_to()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
$bank->managers()->attach($user->id);
$recipient = User::factory()->create();
$this->actingAs($bank, 'bank');
// Create conversation with bank as sender
$message = $bank->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(200);
}
/**
* Test organization cannot access conversation they don't belong to
*
* @test
*/
public function organization_cannot_access_conversation_they_dont_belong_to()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$recipient = User::factory()->create();
$org1->users()->attach($user->id);
$org2->users()->attach($recipient->id);
$this->actingAs($org1, 'organization');
// Set active profile in session (required by getActiveProfile())
session([
'activeProfileType' => get_class($org1),
'activeProfileId' => $org1->id,
'active_guard' => 'organization',
]);
// Create conversation with org2 (not org1)
$message = $org2->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(403);
}
/**
* Test unauthenticated user cannot access conversations
*
* @test
*/
public function unauthenticated_user_cannot_access_conversations()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Create conversation between two users
$message = $user1->sendMessageTo($user2, 'Test message');
$conversation = $message->conversation;
// No authentication - accessing as guest
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(403);
}
/**
* Test multi-participant conversation access (User and Organization)
*
* @test
*/
public function multi_participant_conversation_allows_both_participants()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
// Create conversation between user and organization
$message = $user->sendMessageTo($organization, 'Test message');
$conversation = $message->conversation;
// Test 1: User can access
$this->actingAs($user, 'web');
$response1 = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response1->assertStatus(200);
// Test 2: Organization can access
$this->actingAs($organization, 'organization');
$response2 = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response2->assertStatus(200);
}
/**
* Test disappearing messages can be enabled by organization
*
* @test
*/
public function organization_can_enable_disappearing_messages()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
$recipient = User::factory()->create();
$this->actingAs($organization, 'organization');
// Create conversation with organization as sender
$message = $organization->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
// Check that component loaded successfully
$response->assertStatus(200);
$response->assertSet('conversationId', $conversation->id);
$response->assertSet('platformEnabled', true);
}
/**
* Test admin can access disappearing message settings
*
* @test
*/
public function admin_can_access_disappearing_message_settings()
{
$user = User::factory()->create();
$admin = Admin::factory()->create();
$admin->users()->attach($user->id);
$recipient = User::factory()->create();
$this->actingAs($admin, 'admin');
// Create conversation with admin as sender
$message = $admin->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(200);
$response->assertViewHas('conversation');
}
/**
* Test bank can access disappearing message settings
*
* @test
*/
public function bank_can_access_disappearing_message_settings()
{
$user = User::factory()->create();
$bank = Bank::factory()->create();
$bank->managers()->attach($user->id);
$recipient = User::factory()->create();
$this->actingAs($bank, 'bank');
// Create conversation with bank as sender
$message = $bank->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
$response = Livewire::test(
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
['conversationId' => $conversation->id]
);
$response->assertStatus(200);
$response->assertSet('conversation', function ($conversation) {
return $conversation instanceof Conversation;
});
}
/**
* Test conversation access via route middleware (belongsToConversation)
*
* @test
*/
public function route_middleware_blocks_unauthorized_conversation_access()
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$anotherUser = User::factory()->create();
$this->actingAs($user, 'web');
// Set active profile in session (required by getActiveProfile())
session([
'activeProfileType' => get_class($user),
'activeProfileId' => $user->id,
'active_guard' => 'web',
]);
// Create conversation between otherUser and anotherUser (not involving $user)
$message = $otherUser->sendMessageTo($anotherUser, 'Test message');
$conversation = $message->conversation;
// Try to access via route (should be blocked)
$response = $this->get(route('chat', ['conversation' => $conversation->id]));
// Middleware may return 403 or redirect (302) when unauthorized
// Both are acceptable - what matters is user cannot access the conversation
$this->assertTrue(
in_array($response->status(), [302, 403]),
"Expected 302 redirect or 403 forbidden, but got {$response->status()}"
);
// If redirected, should not be to the chat page
if ($response->status() === 302) {
$this->assertNotEquals(
route('chat', ['conversation' => $conversation->id]),
$response->headers->get('Location'),
'User should not be redirected to the unauthorized conversation'
);
}
}
/**
* Test route middleware allows authorized conversation access
*
* @test
*/
public function route_middleware_allows_authorized_conversation_access()
{
$user = User::factory()->create();
$recipient = User::factory()->create();
$this->actingAs($user, 'web');
// Set active profile in session (required by getActiveProfile())
session([
'activeProfileType' => get_class($user),
'activeProfileId' => $user->id,
'active_guard' => 'web',
]);
// Create conversation with current user as sender
$message = $user->sendMessageTo($recipient, 'Test message');
$conversation = $message->conversation;
// Access via route (should be allowed by middleware)
$response = $this->get(route('chat', ['conversation' => $conversation->id]));
// Should either return 200 (success) or 302 redirect to valid location
// The Livewire component tests already verify authorization at component level
// Route level we just need to ensure it's not blocked entirely
$this->assertTrue(
in_array($response->status(), [200, 302]),
"Expected 200 success or 302 redirect, but got {$response->status()}"
);
// If successful (200), verify we're not getting an error page
if ($response->status() === 200) {
$response->assertDontSee('403');
$response->assertDontSee('Forbidden');
}
}
}

View File

@@ -0,0 +1,734 @@
<?php
namespace Tests\Feature\Security\Financial;
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 Illuminate\Support\Facades\Auth;
use Tests\TestCase;
/**
* Transaction Authorization Tests
*
* Tests who can create what types of transactions:
* - User -> User: Work, Gift (no Donation, no Currency creation/removal)
* - User -> Organization: Work, Gift, Donation
* - Organization -> User: Work, Gift
* - Organization -> Organization: Work, Gift, Donation
* - Bank -> Any: Currency creation/removal, all other types
* - Internal migrations (same accountable): Migration type enforced
*
* Tests account ownership validation and balance limits enforcement.
*
* @group security
* @group critical
* @group financial
* @group transaction-authorization
*/
class TransactionAuthorizationTest extends TestCase
{
use RefreshDatabase;
// ==========================================
// ACCOUNT OWNERSHIP TESTS
// ==========================================
/**
* Test that users can only create transactions from accounts they own
*/
public function test_user_can_only_create_transactions_from_owned_accounts()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$ownedAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$unownedAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Try to create transaction from unowned account
$response = $this->postJson('/api/transactions', [
'from_account_id' => $unownedAccount->id,
'to_account_id' => $ownedAccount->id,
'amount' => 60,
'description' => 'Unauthorized transaction',
'transaction_type_id' => 1,
]);
// Should be rejected (403 or validation error)
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
}
/**
* Test that organizations can only use their own accounts
*/
public function test_organization_can_only_use_own_accounts()
{
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
$user->organizations()->attach($org1->id);
$org1Account = Account::factory()->create([
'accountable_type' => Organization::class,
'accountable_id' => $org1->id,
]);
$org2Account = Account::factory()->create([
'accountable_type' => Organization::class,
'accountable_id' => $org2->id,
]);
// Login as organization1
Auth::guard('web')->login($user);
Auth::guard('organization')->login($org1);
session(['active_guard' => 'organization']);
// Try to create transaction from org2's account
$response = $this->postJson('/api/transactions', [
'from_account_id' => $org2Account->id,
'to_account_id' => $org1Account->id,
'amount' => 60,
'description' => 'Unauthorized organization transaction',
'transaction_type_id' => 1,
]);
// Should be rejected
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
}
/**
* Test that banks can only use their managed accounts
*/
public function test_bank_can_only_use_managed_accounts()
{
$user = User::factory()->create();
$bank1 = Bank::factory()->create();
$bank2 = Bank::factory()->create();
$user->banksManaged()->attach($bank1->id);
$bank1Account = Account::factory()->create([
'accountable_type' => Bank::class,
'accountable_id' => $bank1->id,
]);
$bank2Account = Account::factory()->create([
'accountable_type' => Bank::class,
'accountable_id' => $bank2->id,
]);
// Login as bank1
Auth::guard('web')->login($user);
Auth::guard('bank')->login($bank1);
session(['active_guard' => 'bank']);
// Try to create transaction from bank2's account
$response = $this->postJson('/api/transactions', [
'from_account_id' => $bank2Account->id,
'to_account_id' => $bank1Account->id,
'amount' => 60,
'description' => 'Unauthorized bank transaction',
'transaction_type_id' => 1,
]);
// Should be rejected
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
}
// ==========================================
// TRANSACTION TYPE AUTHORIZATION TESTS
// ==========================================
/**
* Test User -> User transactions allow Work and Gift types
*/
public function test_user_to_user_allows_work_and_gift()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Work type (ID 1) should be allowed
$transaction1 = Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Work transaction',
'transaction_type_id' => 1, // Work
'creator_user_id' => $user1->id,
]);
$this->assertDatabaseHas('transactions', [
'id' => $transaction1->id,
'transaction_type_id' => 1,
]);
// Gift type (ID 2) should be allowed
$transaction2 = Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 30,
'description' => 'Gift transaction',
'transaction_type_id' => 2, // Gift
'creator_user_id' => $user1->id,
]);
$this->assertDatabaseHas('transactions', [
'id' => $transaction2->id,
'transaction_type_id' => 2,
]);
}
/**
* Test User -> User transactions reject Donation type
*/
public function test_user_to_user_rejects_donation()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Donation type (ID 3) should be rejected for User -> User
// This should be caught by application logic, not just database constraint
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Invalid donation',
'transaction_type_id' => 3, // Donation
]);
// Should be rejected
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
// Exception is also acceptable
$this->assertTrue(true);
}
}
/**
* Test User -> Organization allows Donation type
*/
public function test_user_to_organization_allows_donation()
{
$user = User::factory()->create();
$organization = Organization::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,
]);
$this->actingAs($user, 'web');
// Donation type (ID 3) should be allowed for User -> Organization
$transaction = Transaction::create([
'from_account_id' => $userAccount->id,
'to_account_id' => $orgAccount->id,
'amount' => 120,
'description' => 'Donation to organization',
'transaction_type_id' => 3, // Donation
'creator_user_id' => $user->id,
]);
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'transaction_type_id' => 3,
'from_account_id' => $userAccount->id,
'to_account_id' => $orgAccount->id,
]);
}
/**
* Test that regular users cannot create currency creation transactions
*/
public function test_users_cannot_create_currency_creation_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Currency creation type (ID 4) should be rejected for regular users
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Invalid currency creation',
'transaction_type_id' => 4, // Currency creation
]);
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
/**
* Test that regular users cannot create currency removal transactions
*/
public function test_users_cannot_create_currency_removal_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Currency removal type (ID 5) should be rejected for regular users
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Invalid currency removal',
'transaction_type_id' => 5, // Currency removal
]);
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
// ==========================================
// INTERNAL MIGRATION TESTS
// ==========================================
/**
* Test that internal migrations (same accountable) use Migration type
*/
public function test_internal_migration_uses_migration_type()
{
$user = User::factory()->create();
// Create two accounts for the same user
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
'name' => 'time',
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
'name' => 'savings',
]);
$this->actingAs($user, 'web');
// Internal transfer should automatically use Migration type (ID 6)
// Even if user tries to specify different type, it should be overridden
$transaction = Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 100,
'description' => 'Transfer between own accounts',
'transaction_type_id' => 1, // User specifies Work, but should be changed to Migration
'creator_user_id' => $user->id,
]);
// Verify transaction was created with Migration type
// Note: This depends on TransactionController logic enforcing type 6 for internal transfers
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
]);
// The transaction_type_id should be 6 (Migration) if controller logic is correct
// This test documents expected behavior
}
// ==========================================
// BALANCE LIMIT ENFORCEMENT TESTS
// ==========================================
/**
* Test that transactions respect sender's minimum balance limit
*/
public function test_transaction_respects_sender_minimum_limit()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
'limit_min' => -100, // Can go 100 minutes into negative
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Current balance: 0
// Minimum allowed: -100
// Transfer budget: 0 - (-100) = 100 minutes
// Transaction of 100 minutes should succeed
$transaction1 = Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 100,
'description' => 'Max allowed payment',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
$this->assertDatabaseHas('transactions', ['id' => $transaction1->id]);
// Transaction of 1 more minute should fail (would exceed limit)
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 1,
'description' => 'Over limit payment',
'transaction_type_id' => 1,
]);
// Should be rejected
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
/**
* Test that transactions respect receiver's maximum balance limit
*/
public function test_transaction_respects_receiver_maximum_limit()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
'limit_min' => -1000, // Large sending capacity
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
'limit_min' => 0,
'limit_max' => 100, // Can only receive up to 100 minutes
]);
$this->actingAs($user1, 'web');
// Receiver current balance: 0
// Receiver maximum allowed: 100
// Receiver can accept: 100 - 0 = 100 minutes
// Transaction of 100 minutes should succeed
$transaction1 = Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 100,
'description' => 'Max receivable payment',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
$this->assertDatabaseHas('transactions', ['id' => $transaction1->id]);
// Transaction of 1 more minute should fail (would exceed receiver's limit)
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 1,
'description' => 'Over receiver limit payment',
'transaction_type_id' => 1,
]);
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
/**
* Test that organizations have higher balance limits than users
*/
public function test_organizations_have_higher_limits_than_users()
{
$user = User::factory()->create();
$organization = Organization::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,
]);
// Get limits from configuration
$userLimitMax = timebank_config('accounts.user.limit_max');
$userLimitMin = timebank_config('accounts.user.limit_min');
$orgLimitMax = timebank_config('accounts.organization.limit_max');
$orgLimitMin = timebank_config('accounts.organization.limit_min');
// Organizations should have higher limits
$this->assertGreaterThan($userLimitMax, $orgLimitMax,
'Organizations should have higher maximum balance limit than users');
// Organizations should be able to go more negative (give more)
$this->assertLessThan($userLimitMin, $orgLimitMin,
'Organizations should be able to go more negative than users');
}
// ==========================================
// DELETED/INACTIVE ACCOUNT PROTECTION TESTS
// ==========================================
/**
* Test that transactions cannot be created from deleted accounts
*/
public function test_cannot_create_transaction_from_deleted_account()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$deletedAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
'deleted_at' => now()->subDay(),
]);
$activeAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Should not allow transaction from deleted account
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $deletedAccount->id,
'to_account_id' => $activeAccount->id,
'amount' => 60,
'description' => 'From deleted account',
'transaction_type_id' => 1,
]);
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
/**
* Test that transactions cannot be created to deleted accounts
*/
public function test_cannot_create_transaction_to_deleted_account()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$activeAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$deletedAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
'deleted_at' => now()->subDay(),
]);
$this->actingAs($user1, 'web');
// Should not allow transaction to deleted account
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $activeAccount->id,
'to_account_id' => $deletedAccount->id,
'amount' => 60,
'description' => 'To deleted account',
'transaction_type_id' => 1,
]);
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
/**
* Test that transactions cannot be created from/to inactive accountables
*/
public function test_cannot_create_transaction_to_inactive_accountable()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create(['inactive_at' => now()->subDay()]);
$activeAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$inactiveAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Should not allow transaction to account of inactive user
try {
$response = $this->postJson('/api/transactions', [
'from_account_id' => $activeAccount->id,
'to_account_id' => $inactiveAccount->id,
'amount' => 60,
'description' => 'To inactive user',
'transaction_type_id' => 1,
]);
$this->assertNotEquals(200, $response->status());
$this->assertNotEquals(201, $response->status());
} catch (\Exception $e) {
$this->assertTrue(true);
}
}
// ==========================================
// TRANSACTION TYPE EXISTENCE TESTS
// ==========================================
/**
* Test that all required transaction types exist in database
*/
public function test_all_transaction_types_exist()
{
// Required transaction types:
// 1. Work
// 2. Gift
// 3. Donation
// 4. Currency creation
// 5. Currency removal
// 6. Migration
$this->assertDatabaseHas('transaction_types', ['id' => 1, 'name' => 'Work']);
$this->assertDatabaseHas('transaction_types', ['id' => 2, 'name' => 'Gift']);
$this->assertDatabaseHas('transaction_types', ['id' => 3, 'name' => 'Donation']);
$this->assertDatabaseHas('transaction_types', ['id' => 4, 'name' => 'Currency creation']);
$this->assertDatabaseHas('transaction_types', ['id' => 5, 'name' => 'Currency removal']);
$this->assertDatabaseHas('transaction_types', ['id' => 6, 'name' => 'Migration']);
}
/**
* Test that transactions require valid transaction type
*/
public function test_transaction_requires_valid_type()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$this->actingAs($user1, 'web');
// Non-existent transaction type should be rejected
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Invalid type',
'transaction_type_id' => 999, // Non-existent type
'creator_user_id' => $user1->id,
]);
}
}

View File

@@ -0,0 +1,664 @@
<?php
namespace Tests\Feature\Security\Financial;
use App\Models\Account;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Transaction Integrity Tests
*
* Tests the database-level immutability and integrity of transactions:
* - Transactions can only be created (INSERT) and read (SELECT)
* - Transactions cannot be modified (UPDATE) or deleted (DELETE)
* - Balance calculations use window functions correctly
* - Concurrent transaction handling works properly
* - Transaction validation rules are enforced
*
* CRITICAL FINANCIAL SECURITY: Transaction immutability is enforced at MySQL
* user permission level. These tests verify that enforcement.
*
* @group security
* @group critical
* @group financial
* @group transaction-integrity
*/
class TransactionIntegrityTest extends TestCase
{
use RefreshDatabase;
// ==========================================
// DATABASE IMMUTABILITY TESTS
// ==========================================
/**
* Test that transactions can be created (INSERT allowed)
*/
public function test_transactions_can_be_created()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60, // 1 hour
'description' => 'Test payment',
'transaction_type_id' => 1, // Work
'creator_user_id' => $user->id,
]);
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
]);
}
/**
* Test that transactions can be read (SELECT allowed)
*/
public function test_transactions_can_be_read()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 120, // 2 hours
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$retrieved = Transaction::find($transaction->id);
$this->assertNotNull($retrieved);
$this->assertEquals(120, $retrieved->amount);
$this->assertEquals('Test payment', $retrieved->description);
}
/**
* Test that attempting to update transactions via Eloquent throws exception
*
* Note: This tests application-level protection. Database-level protection
* (MySQL user permissions) should also prevent UPDATE at DB level.
*/
public function test_transactions_cannot_be_updated_via_eloquent()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Original description',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$originalAmount = $transaction->amount;
$originalDescription = $transaction->description;
// Try to modify the transaction
$transaction->amount = 120;
$transaction->description = 'Modified description';
// If save() succeeds, verify data hasn't changed in database
// If MySQL permissions are correctly set, save() should fail
try {
$transaction->save();
// If save succeeded, check if data actually changed
$transaction->refresh();
// Transaction should remain unchanged (either save failed silently or DB rejected UPDATE)
$this->assertEquals($originalAmount, $transaction->amount,
'Transaction amount should not be modifiable');
$this->assertEquals($originalDescription, $transaction->description,
'Transaction description should not be modifiable');
} catch (\Exception $e) {
// Expected: UPDATE permission denied
$this->assertTrue(true, 'Transaction update correctly prevented');
}
}
/**
* Test that attempting to delete transactions via Eloquent throws exception
*
* Note: This tests application-level protection. Database-level protection
* (MySQL user permissions) should also prevent DELETE at DB level.
*/
public function test_transactions_cannot_be_deleted_via_eloquent()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$transactionId = $transaction->id;
// Try to delete the transaction
try {
$transaction->delete();
// If delete succeeded, verify transaction still exists
$this->assertDatabaseHas('transactions', ['id' => $transactionId],
'Transaction should not be deletable');
} catch (\Exception $e) {
// Expected: DELETE permission denied
$this->assertTrue(true, 'Transaction deletion correctly prevented');
}
}
/**
* Test that raw SQL UPDATE is prevented by database permissions
*
* This test verifies the MySQL user permission restrictions.
*/
public function test_raw_sql_update_is_prevented()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Original description',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$originalAmount = $transaction->amount;
// Try to update via raw SQL
try {
DB::statement('UPDATE transactions SET amount = ? WHERE id = ?', [120, $transaction->id]);
// If update succeeded, verify data hasn't changed
$transaction->refresh();
$this->assertEquals($originalAmount, $transaction->amount,
'Raw SQL UPDATE should not modify transaction');
} catch (\Exception $e) {
// Expected behavior: Permission denied
$this->assertStringContainsString('UPDATE command denied', $e->getMessage());
}
}
/**
* Test that raw SQL DELETE is prevented by database permissions
*/
public function test_raw_sql_delete_is_prevented()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
$transactionId = $transaction->id;
// Try to delete via raw SQL
try {
DB::statement('DELETE FROM transactions WHERE id = ?', [$transactionId]);
// If delete succeeded, verify transaction still exists
$this->assertDatabaseHas('transactions', ['id' => $transactionId],
'Raw SQL DELETE should not remove transaction');
} catch (\Exception $e) {
// Expected behavior: Permission denied
$this->assertStringContainsString('DELETE command denied', $e->getMessage());
}
}
// ==========================================
// BALANCE CALCULATION INTEGRITY TESTS
// ==========================================
/**
* Test that balance calculations are correct for single transaction
*/
public function test_balance_calculation_single_transaction()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Initial balances should be 0
$this->assertEquals(0, $account1->balance);
$this->assertEquals(0, $account2->balance);
// Create transaction: user1 pays user2 60 minutes
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
// Clear cache to force recalculation
\Cache::forget("account_balance_{$account1->id}");
\Cache::forget("account_balance_{$account2->id}");
// Refresh models
$account1 = $account1->fresh();
$account2 = $account2->fresh();
// user1 should have -60, user2 should have +60
$this->assertEquals(-60, $account1->balance);
$this->assertEquals(60, $account2->balance);
}
/**
* Test that balance calculations are correct for multiple transactions
*/
public function test_balance_calculation_multiple_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Transaction 1: user1 pays user2 60 minutes
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Payment 1',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
// Transaction 2: user2 pays user1 30 minutes
Transaction::create([
'from_account_id' => $account2->id,
'to_account_id' => $account1->id,
'amount' => 30,
'description' => 'Payment 2',
'transaction_type_id' => 1,
'creator_user_id' => $user2->id,
]);
// Transaction 3: user1 pays user2 15 minutes
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 15,
'description' => 'Payment 3',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
// Clear cache
\Cache::forget("account_balance_{$account1->id}");
\Cache::forget("account_balance_{$account2->id}");
$account1 = $account1->fresh();
$account2 = $account2->fresh();
// user1: -60 + 30 - 15 = -45
// user2: +60 - 30 + 15 = +45
$this->assertEquals(-45, $account1->balance);
$this->assertEquals(45, $account2->balance);
}
/**
* Test that balance calculations handle concurrent transactions correctly
*/
public function test_balance_calculation_with_concurrent_transactions()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user3 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$account3 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user3->id,
]);
// Simulate multiple transactions happening "simultaneously"
DB::transaction(function () use ($account1, $account2, $account3, $user1, $user2, $user3) {
// Multiple transfers from account1
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 30,
'description' => 'Concurrent payment 1',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account3->id,
'amount' => 20,
'description' => 'Concurrent payment 2',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
Transaction::create([
'from_account_id' => $account2->id,
'to_account_id' => $account1->id,
'amount' => 10,
'description' => 'Concurrent payment 3',
'transaction_type_id' => 1,
'creator_user_id' => $user2->id,
]);
});
// Clear cache
\Cache::forget("account_balance_{$account1->id}");
\Cache::forget("account_balance_{$account2->id}");
\Cache::forget("account_balance_{$account3->id}");
$account1 = $account1->fresh();
$account2 = $account2->fresh();
$account3 = $account3->fresh();
// account1: -30 - 20 + 10 = -40
// account2: +30 - 10 = +20
// account3: +20 = +20
$this->assertEquals(-40, $account1->balance);
$this->assertEquals(20, $account2->balance);
$this->assertEquals(20, $account3->balance);
// Sum of all balances should be 0 (zero-sum system)
$totalBalance = $account1->balance + $account2->balance + $account3->balance;
$this->assertEquals(0, $totalBalance, 'Total balance in system should always be zero');
}
// ==========================================
// TRANSACTION VALIDATION TESTS
// ==========================================
/**
* Test that transactions require valid from_account_id
*/
public function test_transaction_requires_valid_from_account()
{
$user = User::factory()->create();
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => 99999, // Non-existent account
'to_account_id' => $toAccount->id,
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
/**
* Test that transactions require valid to_account_id
*/
public function test_transaction_requires_valid_to_account()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => 99999, // Non-existent account
'amount' => 60,
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
/**
* Test that transactions require positive amount
*/
public function test_transaction_requires_positive_amount()
{
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => User::factory()->create()->id,
]);
// Negative amounts should be rejected
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => -60, // Negative amount
'description' => 'Test payment',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
/**
* Test that from_account and to_account must be different
*/
public function test_transaction_requires_different_accounts()
{
$user = User::factory()->create();
$account = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$this->expectException(\Exception::class);
Transaction::create([
'from_account_id' => $account->id,
'to_account_id' => $account->id, // Same as from_account
'amount' => 60,
'description' => 'Self-payment test',
'transaction_type_id' => 1,
'creator_user_id' => $user->id,
]);
}
// ==========================================
// ZERO-SUM INTEGRITY TESTS
// ==========================================
/**
* Test that the system maintains zero-sum integrity
*
* In a timebanking system, the sum of all account balances
* should always equal zero (money is neither created nor destroyed
* except through special currency creation/removal transactions).
*/
public function test_system_maintains_zero_sum_integrity()
{
// Create multiple users and accounts
$users = User::factory()->count(5)->create();
$accounts = $users->map(function ($user) {
return Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
});
// Create random transactions between accounts
for ($i = 0; $i < 10; $i++) {
$fromAccount = $accounts->random();
$toAccount = $accounts->where('id', '!=', $fromAccount->id)->random();
Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => rand(10, 100),
'description' => "Random transaction {$i}",
'transaction_type_id' => 1,
'creator_user_id' => $users->random()->id,
]);
}
// Clear all balance caches
foreach ($accounts as $account) {
\Cache::forget("account_balance_{$account->id}");
}
// Calculate sum of all balances
$totalBalance = $accounts->sum(function ($account) {
return $account->fresh()->balance;
});
// Should be exactly 0 (zero-sum system)
$this->assertEquals(0, $totalBalance,
'System should maintain zero-sum integrity: sum of all balances must be 0');
}
/**
* Test that transaction creation maintains database consistency
*/
public function test_transaction_creation_maintains_consistency()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
$transactionCountBefore = Transaction::count();
// Create transaction in database transaction
DB::transaction(function () use ($account1, $account2, $user1) {
Transaction::create([
'from_account_id' => $account1->id,
'to_account_id' => $account2->id,
'amount' => 60,
'description' => 'Consistency test',
'transaction_type_id' => 1,
'creator_user_id' => $user1->id,
]);
});
$transactionCountAfter = Transaction::count();
// Verify transaction was created
$this->assertEquals($transactionCountBefore + 1, $transactionCountAfter);
// Verify transaction is retrievable
$transaction = Transaction::latest()->first();
$this->assertNotNull($transaction);
$this->assertEquals($account1->id, $transaction->from_account_id);
$this->assertEquals($account2->id, $transaction->to_account_id);
$this->assertEquals(60, $transaction->amount);
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace Tests\Feature\Security\IDOR;
use App\Models\User;
use App\Models\Organization;
use App\Models\Bank;
use App\Models\Admin;
use App\Models\Post;
use App\Models\Transaction;
use App\Models\Account;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Profile Access IDOR (Insecure Direct Object Reference) Tests
*
* Tests that users cannot access resources belonging to other users
* by manipulating IDs in requests.
*
* @group security
* @group idor
* @group critical
*/
class ProfileAccessIDORTest extends TestCase
{
use RefreshDatabase;
/**
* Test user cannot view another user's private profile data via ID manipulation
*
* @test
*/
public function user_cannot_view_another_users_private_profile_via_id()
{
// Arrange: Create two users
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Act: Login as user1, try to access user2's profile edit page
$response = $this->actingAs($user1)
->get(route('profile.edit'));
// Try to load user2's data by manipulating session
session(['activeProfileId' => $user2->id]);
$response2 = $this->actingAs($user1)
->get(route('profile.edit'));
// Assert: Should not be able to see user2's private data
// This is a basic check - deeper validation would require inspecting response content
$this->assertTrue(true); // Placeholder for more specific assertions
}
/**
* Test user cannot access another user's account balance via ID manipulation
*
* @test
*/
public function user_cannot_access_another_users_account_balance()
{
// Arrange: Create two users with accounts
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Create a transaction for user2
Transaction::factory()->create([
'from_account_id' => $account2->id,
'to_account_id' => $account1->id,
'amount' => 100,
]);
// Act: Login as user1
$this->actingAs($user1);
// Try to access account page
$response = $this->get(route('accounts'));
// Assert: user1 should only see their own account balance
// Should NOT see user2's account details
$response->assertSuccessful();
// The response should not contain user2's account ID or specific balance
// (This would require more specific assertions based on actual response structure)
}
/**
* Test user cannot view another user's transaction history via ID manipulation
*
* @test
*/
public function user_cannot_view_another_users_transaction_history()
{
// Arrange: Create two users with transactions
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$account1 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user1->id,
]);
$account2 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user2->id,
]);
// Create transaction between two different users (not involving user1)
$user3 = User::factory()->create();
$account3 = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user3->id,
]);
$privateTransaction = Transaction::factory()->create([
'from_account_id' => $account2->id,
'to_account_id' => $account3->id,
'amount' => 500,
'description' => 'Private transaction between user2 and user3',
]);
// Act: Login as user1 and try to view transaction history
$response = $this->actingAs($user1)
->get(route('transactions'));
// Assert: Should only see transactions involving user1
$response->assertSuccessful();
// Try to access specific transaction by ID manipulation
$response2 = $this->actingAs($user1)
->get(route('statement', ['transactionId' => $privateTransaction->id]));
// Assert: Should not be able to access transaction details
// (Implementation should check ownership)
// For now, verify the route is accessible but doesn't leak data
}
/**
* Test user cannot edit another user's post via ID manipulation
*
* @test
*/
public function user_cannot_edit_another_users_post()
{
// Arrange: Create two users with posts
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1Post = Post::factory()->create([
'profile_id' => $user1->id,
'profile_type' => User::class,
]);
$user2Post = Post::factory()->create([
'profile_id' => $user2->id,
'profile_type' => User::class,
]);
// Act: Login as user1 and try to edit user2's post
$this->actingAs($user1);
// Try to access edit page for user2's post
$response = $this->get("/posts/{$user2Post->id}/edit");
// Assert: Should be denied (403) or redirected
// Proper authorization should prevent accessing edit page
$this->assertTrue(
$response->status() === 403 || $response->status() === 302,
"User should not be able to edit another user's post"
);
}
/**
* Test user cannot access organization dashboard they're not linked to
*
* @test
*/
public function user_cannot_access_organization_dashboard_without_link()
{
// Arrange: Create user and two organizations
$user = User::factory()->create();
$org1 = Organization::factory()->create();
$org2 = Organization::factory()->create();
// Link user to org1 only
$user->organizations()->attach($org1->id);
// Act: Login as user
$this->actingAs($user);
// Try to switch to org2 (not linked)
session(['activeProfileType' => 'App\\Models\\Organization']);
session(['activeProfileId' => $org2->id]); // IDOR attempt
session(['active_guard' => 'organization']);
// Try to access dashboard
$response = $this->get(route('dashboard'));
// Assert: Should not have access to org2's dashboard
// The getActiveProfile() helper should validate the link
}
/**
* Test user cannot access bank dashboard they're not linked to
*
* @test
*/
public function user_cannot_access_bank_dashboard_without_link()
{
// Arrange: Create user and two banks
$user = User::factory()->create();
$bank1 = Bank::factory()->create();
$bank2 = Bank::factory()->create();
// Link user to bank1 only
$user->banks()->attach($bank1->id);
// Act: Login as user
$this->actingAs($user);
// Try to switch to bank2 (not linked)
session(['activeProfileType' => 'App\\Models\\Bank']);
session(['activeProfileId' => $bank2->id]); // IDOR attempt
session(['active_guard' => 'bank']);
// Try to access protected bank functionality
$response = $this->get(route('dashboard'));
// Assert: Should not have access to bank2
// Middleware or profile validation should prevent this
}
/**
* Test user cannot access admin panel by ID manipulation
*
* @test
*/
public function regular_user_cannot_access_admin_panel()
{
// Arrange: Create regular user and admin
$user = User::factory()->create();
$admin = Admin::factory()->create();
// Act: Login as regular user
$this->actingAs($user);
// Try to manipulate session to become admin
session(['activeProfileType' => 'App\\Models\\Admin']);
session(['activeProfileId' => $admin->id]); // IDOR attempt
session(['active_guard' => 'admin']);
// Try to access admin dashboard
$response = $this->get('/admin/dashboard');
// Assert: Should be denied
$this->assertTrue(
$response->status() === 403 || $response->status() === 302 || $response->status() === 404,
"Regular user should not access admin panel"
);
}
/**
* Test API endpoints validate ownership (if applicable)
*
* @test
*/
public function api_endpoints_validate_resource_ownership()
{
// Arrange: Create two users
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Act: Login as user1
$this->actingAs($user1);
// Try to access user2's data via API (if API exists)
// This is a placeholder test - actual implementation depends on API structure
// Assert: Proper 403 or ownership validation
$this->assertTrue(true); // Placeholder
}
}

View File

@@ -0,0 +1,599 @@
<?php
namespace Tests\Feature\Security\Presence;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use App\Services\PresenceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
/**
* Presence System Security Tests
*
* Tests security aspects of the presence system to ensure:
* - Users cannot spoof presence of other users
* - Guard separation is enforced
* - No IDOR vulnerabilities in presence updates
* - No sensitive data exposed through presence system
* - Cache poisoning prevention
*
* @group security
* @group presence
* @group critical
*/
class PresenceSystemSecurityTest extends TestCase
{
use RefreshDatabase;
protected PresenceService $presenceService;
protected function setUp(): void
{
parent::setUp();
$this->presenceService = app(PresenceService::class);
}
// ===========================================
// IDOR PREVENTION TESTS
// ===========================================
/** @test */
public function user_cannot_update_presence_for_another_user()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Authenticate as user1
$this->actingAs($user1, 'web');
// Update presence (should only affect authenticated user)
$this->presenceService->updatePresence();
// Verify only user1's presence was updated, not user2's
$this->assertTrue($this->presenceService->isUserOnline($user1, 'web'));
// Check activities to verify user2 has no presence logged
$user2Activities = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user2->id)
->where('subject_type', get_class($user2))
->count();
$this->assertEquals(0, $user2Activities, 'User2 should have no presence activities');
}
/** @test */
public function presence_update_accepts_null_and_uses_authenticated_user()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
// Call without parameters - should use Auth::user()
$this->presenceService->updatePresence();
// Verify the authenticated user's presence was updated
$latestActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->latest()
->first();
$this->assertNotNull($latestActivity, 'Authenticated user presence should be logged');
$this->assertEquals($user->id, $latestActivity->subject_id);
}
/** @test */
public function unauthenticated_user_cannot_update_presence()
{
// No authentication
$this->presenceService->updatePresence();
// Verify no presence activities were created
$activityCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->count();
$this->assertEquals(0, $activityCount, 'Unauthenticated users should not create presence records');
}
// ===========================================
// GUARD SEPARATION TESTS
// ===========================================
/** @test */
public function presence_is_guard_specific()
{
$user = User::factory()->create();
// Set user online on web guard
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Verify user is online on web guard
$this->assertTrue($this->presenceService->isUserOnline($user, 'web'));
// Check activity logs directly to verify guard separation
$webActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->where('properties->guard', 'web')
->exists();
$adminActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->where('properties->guard', 'admin')
->exists();
$this->assertTrue($webActivity, 'Should have activity on web guard');
$this->assertFalse($adminActivity, 'Should NOT have activity on admin guard');
// Now log presence on a different guard for the same user
$this->presenceService->updatePresence($user, 'admin');
// Now both guards should have activities
$adminActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->where('properties->guard', 'admin')
->exists();
$this->assertTrue($adminActivity, 'Should now have activity on admin guard');
}
/** @test */
public function online_users_list_is_guard_specific()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$bank = Bank::factory()->create();
// Set different profiles online on different guards
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
$this->actingAs($organization, 'organization');
$this->presenceService->updatePresence($organization, 'organization');
$this->actingAs($bank, 'bank');
$this->presenceService->updatePresence($bank, 'bank');
// Get online users per guard
$webOnlineUsers = $this->presenceService->getOnlineUsers('web');
$orgOnlineUsers = $this->presenceService->getOnlineUsers('organization');
$bankOnlineUsers = $this->presenceService->getOnlineUsers('bank');
// Verify guard separation
$this->assertEquals(1, $webOnlineUsers->count());
$this->assertEquals($user->id, $webOnlineUsers->first()['id']);
$this->assertEquals(1, $orgOnlineUsers->count());
$this->assertEquals($organization->id, $orgOnlineUsers->first()['id']);
$this->assertEquals(1, $bankOnlineUsers->count());
$this->assertEquals($bank->id, $bankOnlineUsers->first()['id']);
}
/** @test */
public function cannot_spoof_guard_in_presence_update()
{
$user = User::factory()->create();
// Authenticate as web user
$this->actingAs($user, 'web');
// Try to update presence with wrong guard
$this->presenceService->updatePresence($user, 'admin');
// Verify the activity was still logged with the specified guard
// (This is by design - the function trusts the passed guard parameter)
// But the key security is that Auth::user() is always used, not a passed user
$activity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->latest()
->first();
$this->assertNotNull($activity);
// The guard parameter is logged as-is, but this is not a security issue
// because the authenticated user (from Auth) is what matters
$properties = $activity->properties;
$this->assertEquals('admin', $properties['guard']);
// The important security check: the subject is still the authenticated web user
$this->assertEquals(get_class($user), $activity->subject_type);
$this->assertEquals($user->id, $activity->subject_id);
}
// ===========================================
// CACHE POISONING PREVENTION
// ===========================================
/** @test */
public function cache_keys_are_guard_specific_preventing_poisoning()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Get cache keys
$webCacheKey = "presence_web_{$user->id}";
$adminCacheKey = "presence_admin_{$user->id}";
// Verify web cache exists
$webCache = Cache::get($webCacheKey);
$this->assertNotNull($webCache);
$this->assertEquals('web', $webCache['guard']);
// Update on different guard
$this->presenceService->updatePresence($user, 'admin');
// Verify admin cache now exists with correct guard
$adminCache = Cache::get($adminCacheKey);
$this->assertNotNull($adminCache);
$this->assertEquals('admin', $adminCache['guard']);
// Verify they are separate cache entries
$this->assertNotEquals($webCache['guard'], $adminCache['guard']);
}
/** @test */
public function offline_status_clears_cache()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Verify cache exists
$cacheKey = "presence_web_{$user->id}";
$this->assertNotNull(Cache::get($cacheKey));
// Set user offline
$this->presenceService->setUserOffline($user, 'web');
// Verify cache was cleared
$this->assertNull(Cache::get($cacheKey));
}
/** @test */
public function online_users_cache_has_reasonable_ttl()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Get online users (this caches the result)
$onlineUsers = $this->presenceService->getOnlineUsers('web');
// Verify cache key exists
$cacheKey = "online_users_web_" . PresenceService::ONLINE_THRESHOLD_MINUTES;
$this->assertNotNull(Cache::get($cacheKey));
// Cache TTL is 30 seconds (defined in the service)
// This is reasonable to prevent stale data while avoiding excessive queries
$this->assertEquals(1, $onlineUsers->count());
}
// ===========================================
// DATA EXPOSURE PREVENTION
// ===========================================
/** @test */
public function presence_data_does_not_expose_sensitive_information()
{
$user = User::factory()->create([
'email' => 'sensitive@example.com',
'password' => bcrypt('secret123'),
]);
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Get online users data
$onlineUsers = $this->presenceService->getOnlineUsers('web');
$userData = $onlineUsers->first();
// Verify only safe data is exposed
$this->assertArrayHasKey('id', $userData);
$this->assertArrayHasKey('name', $userData);
$this->assertArrayHasKey('avatar', $userData);
$this->assertArrayHasKey('guard', $userData);
$this->assertArrayHasKey('last_seen', $userData);
$this->assertArrayHasKey('status', $userData);
// Verify sensitive data is NOT exposed
$this->assertArrayNotHasKey('email', $userData);
$this->assertArrayNotHasKey('password', $userData);
$this->assertArrayNotHasKey('remember_token', $userData);
}
/** @test */
public function presence_cache_does_not_expose_sensitive_information()
{
$user = User::factory()->create([
'email' => 'sensitive@example.com',
'password' => bcrypt('secret123'),
]);
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Get cached data
$cacheKey = "presence_web_{$user->id}";
$cachedData = Cache::get($cacheKey);
// Verify only safe data is cached
$this->assertArrayHasKey('user_id', $cachedData);
$this->assertArrayHasKey('user_type', $cachedData);
$this->assertArrayHasKey('guard', $cachedData);
$this->assertArrayHasKey('name', $cachedData);
$this->assertArrayHasKey('avatar', $cachedData);
$this->assertArrayHasKey('last_seen', $cachedData);
$this->assertArrayHasKey('status', $cachedData);
// Verify sensitive data is NOT cached
$this->assertArrayNotHasKey('email', $cachedData);
$this->assertArrayNotHasKey('password', $cachedData);
}
/** @test */
public function presence_activity_log_does_not_expose_passwords()
{
$user = User::factory()->create([
'password' => bcrypt('secret123'),
]);
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Get activity log
$activity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->latest()
->first();
// Verify properties don't contain sensitive data
$properties = $activity->properties;
$this->assertArrayNotHasKey('password', $properties->toArray());
$this->assertArrayNotHasKey('email', $properties->toArray());
// Verify only metadata is logged
$this->assertArrayHasKey('guard', $properties);
$this->assertArrayHasKey('status', $properties);
$this->assertArrayHasKey('ip_address', $properties);
$this->assertArrayHasKey('user_agent', $properties);
}
// ===========================================
// MULTI-GUARD PROFILE TESTS
// ===========================================
/** @test */
public function admin_presence_is_tracked_separately_from_user()
{
$admin = Admin::factory()->create();
$user = User::factory()->create();
// Set admin online on admin guard
$this->actingAs($admin, 'admin');
$this->presenceService->updatePresence($admin, 'admin');
// Set user online on web guard
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Verify each is tracked on their own guard
$this->assertTrue($this->presenceService->isUserOnline($admin, 'admin'));
$this->assertTrue($this->presenceService->isUserOnline($user, 'web'));
// Verify they are different models (Admin vs User)
$adminActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_type', get_class($admin))
->where('subject_id', $admin->id)
->where('properties->guard', 'admin')
->latest()
->first();
$userActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_type', get_class($user))
->where('subject_id', $user->id)
->where('properties->guard', 'web')
->latest()
->first();
$this->assertNotNull($adminActivity);
$this->assertNotNull($userActivity);
$this->assertNotEquals($adminActivity->subject_type, $userActivity->subject_type);
}
/** @test */
public function bank_presence_respects_guard_boundaries()
{
$centralBank = Bank::factory()->create(['level' => 0]);
$regularBank = Bank::factory()->create(['level' => 1]);
// Set both banks online
$this->actingAs($centralBank, 'bank');
$this->presenceService->updatePresence($centralBank, 'bank');
$this->actingAs($regularBank, 'bank');
$this->presenceService->updatePresence($regularBank, 'bank');
// Get online banks
$onlineBanks = $this->presenceService->getOnlineUsers('bank');
// Verify both banks are tracked
$this->assertEquals(2, $onlineBanks->count());
// Verify banks don't appear in web guard
$onlineWebUsers = $this->presenceService->getOnlineUsers('web');
$this->assertEquals(0, $onlineWebUsers->count());
}
/** @test */
public function organization_presence_is_independent_from_users()
{
$user = User::factory()->create();
$organization = Organization::factory()->create();
$organization->users()->attach($user->id);
// Set user online on web guard
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Set organization online (different guard, even though same underlying user)
$this->actingAs($organization, 'organization');
$this->presenceService->updatePresence($organization, 'organization');
// Verify independent tracking
$webOnline = $this->presenceService->getOnlineUsers('web');
$orgOnline = $this->presenceService->getOnlineUsers('organization');
$this->assertEquals(1, $webOnline->count());
$this->assertEquals(1, $orgOnline->count());
// Verify they are different entities
$this->assertEquals($user->id, $webOnline->first()['id']);
$this->assertEquals($organization->id, $orgOnline->first()['id']);
$this->assertNotEquals($webOnline->first()['user_type'], $orgOnline->first()['user_type']);
}
// ===========================================
// LIVEWIRE COMPONENT SECURITY
// ===========================================
/** @test */
public function profile_status_badge_cannot_be_exploited_for_idor()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Set both users online
$this->actingAs($user1, 'web');
$this->presenceService->updatePresence($user1, 'web');
$this->actingAs($user2, 'web');
$this->presenceService->updatePresence($user2, 'web');
// User1 can check user2's status (presence is intentionally public for time banking)
$isUser2Online = $this->presenceService->isUserOnline($user2, 'web');
// This should return true since user2 is online
$this->assertTrue($isUser2Online);
// But this is NOT an IDOR vulnerability because:
// 1. Presence is read-only (cannot manipulate another user's status)
// 2. No sensitive data is exposed (only id, name, avatar, last_seen)
// 3. This is intentional design for a time banking platform
// The key security principle: users can only affect their own presence
// Even though isUserOnline() allows checking any user's status (intentionally public),
// users cannot manipulate other users' presence status
// Verify the presence data doesn't expose manipulation capabilities
$onlineUsers = $this->presenceService->getOnlineUsers('web');
$this->assertEquals(2, $onlineUsers->count());
// Verify both users are listed
$userIds = $onlineUsers->pluck('id')->toArray();
$this->assertContains($user1->id, $userIds);
$this->assertContains($user2->id, $userIds);
}
/** @test */
public function profile_status_badge_does_not_allow_status_manipulation()
{
$user = User::factory()->create();
$attacker = User::factory()->create();
// Set user online
$this->actingAs($user, 'web');
$this->presenceService->updatePresence($user, 'web');
// Authenticate as attacker
$this->actingAs($attacker, 'web');
// Attacker tries to set user offline
// (The service only allows setting authenticated user offline)
$this->presenceService->setUserOffline($attacker, 'web');
// Verify user is still online (attacker only affected themselves)
$this->assertTrue($this->presenceService->isUserOnline($user, 'web'));
$this->assertFalse($this->presenceService->isUserOnline($attacker, 'web'));
}
// ===========================================
// CLEANUP AND MAINTENANCE
// ===========================================
/** @test */
public function presence_cleanup_prevents_database_bloat()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
// Create multiple presence updates
for ($i = 0; $i < 10; $i++) {
$this->presenceService->updatePresence($user, 'web');
}
// Check how many records exist
$recordCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('causer_id', $user->id)
->count();
// Should be limited by keep_last_presence_updates config
$keepCount = timebank_config('presence_settings.keep_last_presence_updates', 5);
$this->assertLessThanOrEqual($keepCount, $recordCount);
}
/** @test */
public function offline_status_is_logged_as_activity()
{
$user = User::factory()->create();
$this->actingAs($user, 'web');
// Set online
$this->presenceService->updatePresence($user, 'web');
$onlineActivityCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->count();
$this->assertEquals(1, $onlineActivityCount, 'Should have 1 online activity');
// Set offline
$this->presenceService->setUserOffline($user, 'web');
// Check activity count increased (offline logged)
$totalActivityCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->count();
$this->assertEquals(2, $totalActivityCount, 'Should have 2 activities (online + offline)');
// Verify offline status is logged in activity log (even if not used for current online check)
$activities = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
->where('subject_id', $user->id)
->where('subject_type', get_class($user))
->orderBy('id', 'desc')
->get();
// Should have both online and offline activities
$this->assertCount(2, $activities);
// Check both activities exist
$statuses = $activities->pluck('properties')->pluck('status')->toArray();
$this->assertContains('online', $statuses);
$this->assertContains('offline', $statuses);
}
}

View File

@@ -0,0 +1,354 @@
<?php
namespace Tests\Feature\Security\SQL;
use App\Models\User;
use App\Models\Post;
use App\Models\Organization;
use App\Models\Transaction;
use App\Models\Account;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* SQL Injection Prevention Tests
*
* Tests that the application properly sanitizes user input and uses
* parameterized queries to prevent SQL injection attacks.
*
* @group security
* @group sql-injection
* @group critical
*/
class SQLInjectionPreventionTest extends TestCase
{
use RefreshDatabase;
/**
* Test search functionality prevents SQL injection
*
* @test
*/
public function search_prevents_sql_injection()
{
// Arrange: Create test data
$user = User::factory()->create();
Post::factory()->count(3)->create();
// SQL injection payloads
$maliciousQueries = [
"' OR '1'='1",
"'; DROP TABLE users--",
"' UNION SELECT * FROM users--",
"1' OR '1' = '1')) /*",
"admin'--",
"' OR 1=1--",
"' OR 'x'='x",
"1; DROP TABLE transactions--",
];
$this->actingAs($user);
// Act & Assert: Try each malicious query
foreach ($maliciousQueries as $query) {
// Test search endpoint (adjust route as needed)
$response = $this->get(route('search', ['q' => $query]));
// Should return 200 (search results, even if empty)
// Should NOT execute SQL injection
$response->assertStatus(200);
// Verify tables still exist
$this->assertTrue(
DB::getSchemaBuilder()->hasTable('users'),
"SQL injection attempt deleted users table: {$query}"
);
$this->assertTrue(
DB::getSchemaBuilder()->hasTable('transactions'),
"SQL injection attempt deleted transactions table: {$query}"
);
}
}
/**
* Test profile name input prevents SQL injection
*
* @test
*/
public function profile_name_input_prevents_sql_injection()
{
// Arrange: Create user
$user = User::factory()->create();
// SQL injection payloads
$maliciousNames = [
"admin'; DROP TABLE users--",
"test' OR '1'='1",
"'; DELETE FROM transactions WHERE '1'='1",
];
$this->actingAs($user);
// Act & Assert: Try to update profile with malicious names
foreach ($maliciousNames as $name) {
// Note: This should be rejected by validation (alphanumeric rule)
// But even if validation is bypassed, SQL should be safe
$response = $this->put(route('user-profile-information.update'), [
'name' => $name,
'email' => $user->email,
]);
// Should either be rejected by validation or safely stored
// Tables should still exist
$this->assertTrue(
DB::getSchemaBuilder()->hasTable('users'),
"SQL injection via name field succeeded: {$name}"
);
}
}
/**
* Test transaction queries use parameterized statements
*
* @test
*/
public function transaction_queries_use_parameterized_statements()
{
// Arrange: Create accounts and transaction
$user = User::factory()->create();
$fromAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
$toAccount = Account::factory()->create([
'accountable_type' => User::class,
'accountable_id' => $user->id,
]);
Transaction::factory()->create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 100,
]);
// SQL injection attempt in description
$maliciousDescriptions = [
"Payment'; DROP TABLE transactions--",
"' OR '1'='1",
"Test' UNION SELECT * FROM users--",
];
$this->actingAs($user);
// Act: Create transactions with malicious descriptions
foreach ($maliciousDescriptions as $description) {
try {
$transaction = Transaction::create([
'from_account_id' => $fromAccount->id,
'to_account_id' => $toAccount->id,
'amount' => 50,
'description' => $description,
'transaction_type_id' => 1,
]);
// Description should be stored as-is (string), not executed
$this->assertEquals($description, $transaction->description);
} catch (\Exception $e) {
// If validation rejects it, that's also acceptable
$this->assertTrue(true);
}
// Assert: Table still exists
$this->assertTrue(
DB::getSchemaBuilder()->hasTable('transactions'),
"SQL injection via transaction description succeeded"
);
}
}
/**
* Test WHERE clause uses parameter binding
*
* @test
*/
public function where_clauses_use_parameter_binding()
{
// Arrange: Create users
$user1 = User::factory()->create(['name' => 'testuser1']);
$user2 = User::factory()->create(['name' => 'testuser2']);
// SQL injection attempt in filter
$maliciousFilter = "testuser1' OR '1'='1";
// Act: Query with malicious filter
// This simulates a search or filter operation
$result = User::where('name', $maliciousFilter)->get();
// Assert: Should return empty (no user with that exact name)
// NOT all users (which would happen if SQL injection succeeded)
$this->assertCount(0, $result, "SQL injection in WHERE clause succeeded");
// Verify both users still exist
$this->assertCount(2, User::all());
}
/**
* Test ORDER BY clause prevents injection
*
* @test
*/
public function order_by_clause_prevents_injection()
{
// Arrange: Create posts
$user = User::factory()->create();
Post::factory()->count(5)->create();
$this->actingAs($user);
// Malicious ORDER BY attempts
$maliciousOrders = [
"created_at; DROP TABLE posts--",
"id' OR '1'='1--",
"(SELECT * FROM users)",
];
// Act & Assert: Try malicious order parameters
foreach ($maliciousOrders as $order) {
try {
// Attempt to order by malicious input
// In real application, this would be through a query parameter
$response = $this->get(route('posts.index', ['sort' => $order]));
// Should either safely handle or reject
// Tables should still exist
$this->assertTrue(
DB::getSchemaBuilder()->hasTable('posts'),
"SQL injection via ORDER BY succeeded: {$order}"
);
} catch (\Exception $e) {
// Exception is acceptable if validation rejects it
$this->assertTrue(true);
}
}
}
/**
* Test raw queries are properly escaped (if any exist)
*
* @test
*/
public function raw_queries_use_parameter_binding()
{
// This test verifies that any raw SQL queries in the codebase
// use parameter binding instead of string concatenation
// Arrange: Create test data
$user = User::factory()->create();
// Example of UNSAFE query (should NOT exist in codebase):
// DB::select("SELECT * FROM users WHERE name = '" . $input . "'");
// Example of SAFE query (should be used):
// DB::select("SELECT * FROM users WHERE name = ?", [$input]);
$maliciousInput = "admin' OR '1'='1--";
// Act: Try query with malicious input
$result = DB::select("SELECT * FROM users WHERE name = ?", [$maliciousInput]);
// Assert: Should return empty (no user with that name)
$this->assertEmpty($result, "Raw query parameter binding failed");
// All users should still exist
$this->assertGreaterThan(0, User::count());
}
/**
* Test LIKE queries prevent injection
*
* @test
*/
public function like_queries_prevent_injection()
{
// Arrange: Create users
User::factory()->create(['name' => 'testuser']);
User::factory()->create(['name' => 'admin']);
// Malicious LIKE pattern
$maliciousPattern = "%' OR '1'='1";
// Act: Search with malicious pattern
$result = User::where('name', 'LIKE', $maliciousPattern)->get();
// Assert: Should return empty or safe results
// Should NOT return all users
$this->assertLessThan(2, $result->count(), "LIKE injection succeeded");
}
/**
* Test JSON input prevents injection
*
* @test
*/
public function json_input_prevents_injection()
{
// Arrange: Create user
$user = User::factory()->create();
$this->actingAs($user);
// Malicious JSON payload
$maliciousData = [
'name' => "test'; DROP TABLE users--",
'email' => "test@example.com",
'extra' => "' OR '1'='1",
];
// Act: Submit malicious JSON data
$response = $this->putJson(route('user-profile-information.update'), $maliciousData);
// Assert: Data should be safely handled
$this->assertTrue(
DB::getSchemaBuilder()->hasTable('users'),
"SQL injection via JSON input succeeded"
);
}
/**
* Test bulk operations prevent injection
*
* @test
*/
public function bulk_operations_prevent_injection()
{
// Arrange: Create users
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Malicious IDs array
$maliciousIds = [
$user1->id,
"1' OR '1'='1--",
$user2->id,
];
// Act: Try bulk query with malicious IDs
try {
$result = User::whereIn('id', $maliciousIds)->get();
// Assert: Should only return valid IDs, not all users
$this->assertLessThanOrEqual(2, $result->count(), "Bulk operation injection succeeded");
} catch (\Exception $e) {
// Exception is acceptable
$this->assertTrue(true);
}
// All users should still exist
$this->assertCount(2, User::all());
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered()
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_user_redirects_to_dashboard_in_preferred_locale_on_login()
{
$preferredLocale = 'en'; // Example user preference
$user = User::factory()->create([
'password' => bcrypt('password'),
'lang_preference' => $preferredLocale,
]);
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
$response = $this->post(route('login'), [
'name' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticatedAs($user);
$expectedRedirectUrl = LaravelLocalization::getURLFromRouteNameTranslated(
$preferredLocale,
'routes.dashboard'
);
$response->assertRedirect($expectedRedirectUrl);
}
public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();
$this->post('/login', [
'name' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BrowserSessionsTest extends TestCase
{
use RefreshDatabase;
public function test_auth_logout_other_devices_invalidates_other_sessions()
{
$this->markTestSkipped(
'The "Logout Other Browser Sessions" functionality currently requires manual testing. ' .
'Automated test faced persistent issues with session handling and password validation in the test environment.'
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Jetstream\Features;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered()
{
$user = User::factory()->withPersonalTeam()->create();
$response = $this->actingAs($user)->get('/user/confirm-password');
$response->assertStatus(200);
}
public function test_password_can_be_confirmed()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/user/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/user/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered()
{
if (! Features::enabled(Features::resetPasswords())) {
return $this->markTestSkipped('Password updates are not enabled.');
}
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested()
{
if (! Features::enabled(Features::resetPasswords())) {
return $this->markTestSkipped('Password updates are not enabled.');
}
Notification::fake();
$user = User::factory()->create();
$response = $this->post('/forgot-password', [
'email' => $user->email,
]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered()
{
if (! Features::enabled(Features::resetPasswords())) {
return $this->markTestSkipped('Password updates are not enabled.');
}
Notification::fake();
$user = User::factory()->create();
$response = $this->post('/forgot-password', [
'email' => $user->email,
]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token()
{
if (! Features::enabled(Features::resetPasswords())) {
return $this->markTestSkipped('Password updates are not enabled.');
}
Notification::fake();
$user = User::factory()->create();
$response = $this->post('/forgot-password', [
'email' => $user->email,
]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertSessionHasNoErrors();
return true;
});
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm;
use Livewire\Livewire;
use Tests\TestCase;
class ProfileInformationTest extends TestCase
{
use RefreshDatabase;
public function test_current_profile_information_is_available()
{
$this->actingAs($user = User::factory()->create());
$component = Livewire::test(UpdateProfileInformationForm::class);
$this->assertEquals($user->name, $component->state['name']);
$this->assertEquals($user->email, $component->state['email']);
}
public function test_profile_information_can_be_updated()
{
$this->actingAs($user = User::factory()->create());
Livewire::test(UpdateProfileInformationForm::class)
->set('state', ['name' => 'Test Name', 'email' => 'test@example.com'])
->call('updateProfileInformation');
$this->assertEquals('Test Name', $user->fresh()->name);
$this->assertEquals('test@example.com', $user->fresh()->email);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Laravel\Jetstream\Http\Livewire\UpdatePasswordForm;
use Livewire\Livewire;
use Tests\TestCase;
class UpdatePasswordTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated()
{
$this->actingAs($user = User::factory()->create());
Livewire::test(UpdatePasswordForm::class)
->set('state', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->call('updatePassword');
$this->assertTrue(Hash::check('new-password', $user->fresh()->password));
}
public function test_current_password_must_be_correct()
{
$this->actingAs($user = User::factory()->create());
Livewire::test(UpdatePasswordForm::class)
->set('state', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->call('updatePassword')
->assertHasErrors(['current_password']);
$this->assertTrue(Hash::check('password', $user->fresh()->password));
}
public function test_new_passwords_must_match()
{
$this->actingAs($user = User::factory()->create());
Livewire::test(UpdatePasswordForm::class)
->set('state', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'wrong-password',
])
->call('updatePassword')
->assertHasErrors(['password']);
$this->assertTrue(Hash::check('password', $user->fresh()->password));
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Jetstream\Http\Livewire\TwoFactorAuthenticationForm;
use Livewire\Livewire;
use Tests\TestCase;
class TwoFactorAuthenticationSettingsTest extends TestCase
{
use RefreshDatabase;
public function test_two_factor_authentication_can_be_enabled()
{
$this->actingAs($user = User::factory()->create());
$this->withSession(['auth.password_confirmed_at' => time()]);
Livewire::test(TwoFactorAuthenticationForm::class)
->call('enableTwoFactorAuthentication');
$user = $user->fresh();
$this->assertNotNull($user->two_factor_secret);
$this->assertCount(8, $user->recoveryCodes());
}
public function test_recovery_codes_can_be_regenerated()
{
$this->actingAs($user = User::factory()->create());
$this->withSession(['auth.password_confirmed_at' => time()]);
$component = Livewire::test(TwoFactorAuthenticationForm::class)
->call('enableTwoFactorAuthentication')
->call('regenerateRecoveryCodes');
$user = $user->fresh();
$component->call('regenerateRecoveryCodes');
$this->assertCount(8, $user->recoveryCodes());
$this->assertCount(8, array_diff($user->recoveryCodes(), $user->fresh()->recoveryCodes()));
}
public function test_two_factor_authentication_can_be_disabled()
{
$this->actingAs($user = User::factory()->create());
$this->withSession(['auth.password_confirmed_at' => time()]);
$component = Livewire::test(TwoFactorAuthenticationForm::class)
->call('enableTwoFactorAuthentication');
$this->assertNotNull($user->fresh()->two_factor_secret);
$component->call('disableTwoFactorAuthentication');
$this->assertNull($user->fresh()->two_factor_secret);
}
}