600 lines
22 KiB
PHP
600 lines
22 KiB
PHP
<?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);
|
|
}
|
|
}
|