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