$overrides Keyed by dependency short name. */ private function makeService(UserRepository $userRepo, array $overrides = []): UserService { return new UserService( $userRepo, $overrides['language'] ?? $this->make(LanguageCore::class), $overrides['projectRepository'] ?? $this->make(ProjectRepository::class), $overrides['clientRepo'] ?? $this->make(ClientRepository::class), $overrides['authService'] ?? $this->make(AuthService::class), $overrides['fileService'] ?? $this->make(Files::class), $overrides['avatarcreator'] ?? $this->make(Avatarcreator::class), $overrides['settingsService'] ?? $this->make(SettingService::class), $overrides['themeCore'] ?? $this->make(ThemeCore::class), $overrides['projectService'] ?? $this->make(ProjectService::class), ); } protected function setUp(): void { parent::setUp(); session(['userdata.id' => 1]); session()->forget('usersettings'); } public function test_session_only_dismissal_records_session_without_persisting(): void { $persistCalls = 0; $repo = $this->make(UserRepository::class, [ 'patchUser' => function () use (&$persistCalls) { $persistCalls++; return true; }, ]); $result = $this->makeService($repo)->saveModalDismissal('welcomeModal', false); $this->assertTrue($result); $this->assertSame(1, session('usersettings.modals.welcomeModal')); $this->assertSame(0, $persistCalls, 'A non-permanent dismissal must not touch the repository'); } public function test_permanent_dismissal_persists_to_user_settings(): void { $persistCalls = 0; $repo = $this->make(UserRepository::class, [ 'patchUser' => function ($id, $params) use (&$persistCalls) { $persistCalls++; // The service must persist the serialized usersettings blob. $this->assertArrayHasKey('settings', $params); return true; }, ]); $result = $this->makeService($repo)->saveModalDismissal('welcomeModal', true); $this->assertTrue($result); $this->assertSame('1', session('usersettings.modals.welcomeModal')); $this->assertSame(1, $persistCalls, 'A permanent dismissal must persist via the repository'); } public function test_get_user_project_ids_flattens_relation_rows(): void { $repo = $this->make(UserRepository::class); $projectService = $this->make(ProjectService::class, [ 'getUserProjectRelation' => fn () => [ ['projectId' => 5], ['projectId' => 9], ['projectId' => 12], ], ]); $ids = $this->makeService($repo, ['projectService' => $projectService])->getUserProjectIds(3); $this->assertSame([5, 9, 12], $ids); } public function test_validate_user_update_rejects_empty_username(): void { $repo = $this->make(UserRepository::class); $service = $this->makeService($repo); $result = $service->validateUserUpdate( ['user' => ''], ['username' => 'old@example.com'], 7, [] ); $this->assertSame('passwords_dont_match', $result); } public function test_validate_user_update_rejects_invalid_email(): void { $repo = $this->make(UserRepository::class); $service = $this->makeService($repo); $result = $service->validateUserUpdate( ['user' => 'not-an-email'], ['username' => 'old@example.com'], 7, [] ); $this->assertSame('no_valid_email', $result); } public function test_validate_user_update_rejects_taken_email_on_change(): void { $repo = $this->make(UserRepository::class, [ 'usernameExist' => fn () => true, ]); $service = $this->makeService($repo); $result = $service->validateUserUpdate( ['user' => 'new@example.com'], ['username' => 'old@example.com'], 7, [] ); $this->assertSame('user_exists', $result); } public function test_validate_user_update_passes_for_unchanged_valid_email(): void { $repo = $this->make(UserRepository::class, [ 'usernameExist' => fn () => true, ]); $service = $this->makeService($repo); // Email unchanged, so usernameExist must NOT block it. $result = $service->validateUserUpdate( ['user' => 'same@example.com'], ['username' => 'same@example.com'], 7, [] ); $this->assertSame('valid', $result); } public function test_invite_new_user_rejects_invalid_email(): void { $repo = $this->make(UserRepository::class, [ 'usernameExist' => fn () => false, ]); $service = $this->makeService($repo); $result = $service->inviteNewUser( ['user' => 'nope'], sessionClientId: null, isManager: false ); $this->assertSame('no_valid_email', $result); } public function test_invite_new_user_rejects_existing_user(): void { $repo = $this->make(UserRepository::class, [ 'usernameExist' => fn () => true, ]); $service = $this->makeService($repo); $result = $service->inviteNewUser( ['user' => 'taken@example.com'], sessionClientId: null, isManager: false ); $this->assertSame('user_exists', $result); } public function test_change_own_password_rejects_wrong_current_password(): void { $repo = $this->make(UserRepository::class, [ 'getUser' => fn () => [ 'id' => 1, 'password' => password_hash('correct-horse', PASSWORD_DEFAULT), 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com', 'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0, ], ]); $service = $this->makeService($repo); $result = $service->changeOwnPassword(1, 'wrong', 'NewPass1!', 'NewPass1!'); $this->assertSame('previous_password_incorrect', $result); } public function test_change_own_password_rejects_mismatched_confirmation(): void { $repo = $this->make(UserRepository::class, [ 'getUser' => fn () => [ 'id' => 1, 'password' => password_hash('correct-horse', PASSWORD_DEFAULT), 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com', 'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0, ], ]); $service = $this->makeService($repo); $result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'Different1!'); $this->assertSame('passwords_dont_match', $result); } public function test_change_own_password_persists_on_success(): void { $savedValues = null; $repo = $this->make(UserRepository::class, [ 'getUser' => fn () => [ 'id' => 1, 'password' => password_hash('correct-horse', PASSWORD_DEFAULT), 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com', 'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0, ], 'editOwn' => function ($values) use (&$savedValues) { $savedValues = $values; return true; }, ]); $service = $this->makeService($repo); $result = $service->changeOwnPassword(1, 'correct-horse', 'NewPass1!', 'NewPass1!'); $this->assertSame('success', $result); $this->assertSame('NewPass1!', $savedValues['password']); } public function test_save_own_profile_blocks_duplicate_email(): void { $editCalls = 0; $repo = $this->make(UserRepository::class, [ 'getUser' => fn () => [ 'id' => 1, 'firstname' => 'A', 'lastname' => 'B', 'username' => 'old@example.com', 'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0, ], 'usernameExist' => fn () => true, 'editOwn' => function () use (&$editCalls) { $editCalls++; return true; }, ]); $service = $this->makeService($repo); $result = $service->saveOwnProfile(1, ['user' => 'taken@example.com']); $this->assertSame('user_exists', $result); $this->assertSame(0, $editCalls, 'A duplicate email must not be persisted'); } // --------------------------------------------------------------------- // searchProjectUsers() — JSON-RPC entry for the @mention autocomplete. // --------------------------------------------------------------------- public function test_search_project_users_filters_by_query(): void { session(['userdata' => ['id' => 1], 'currentProject' => 5]); $projectRepository = $this->make(ProjectRepository::class, [ 'isUserAssignedToProject' => fn () => true, 'getProject' => fn () => ['psettings' => 'restricted', 'clientId' => 0], 'getUsersAssignedToProject' => fn () => [ ['id' => 1, 'firstname' => 'Alice'], ['id' => 2, 'firstname' => 'Bob'], ], ]); $users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository]) ->searchProjectUsers(5, 'alice'); $this->assertCount(1, $users); $this->assertSame('Alice', $users[0]['firstname']); } public function test_search_project_users_returns_empty_without_project_access(): void { session(['userdata' => ['id' => 1], 'currentProject' => 5]); $projectRepository = $this->make(ProjectRepository::class, [ 'isUserAssignedToProject' => fn () => false, ]); $users = $this->makeService($this->make(UserRepository::class), ['projectRepository' => $projectRepository]) ->searchProjectUsers(5); $this->assertSame([], $users); } // --------------------------------------------------------------------- // Authorization. The company-wide manage-others methods // (editUser/updateUser/addUser/getAll/…) gate via the dispatch-time // #[RequiresPermission(global: true)] attribute (covered by PermissionEnforcerTest). // These two methods authorize in their own body, so they gate on direct calls too. // --------------------------------------------------------------------- private function denyingPermissions(): PermissionService { return $this->make(PermissionService::class, [ 'currentUserCan' => fn () => false, 'authorize' => function (): void { throw new AuthorizationException; }, ]); } private function allowingPermissions(): PermissionService { return $this->make(PermissionService::class, [ 'currentUserCan' => fn () => true, 'authorize' => fn () => null, ]); } public function test_delete_user_throws_without_delete_permission(): void { $service = $this->makeService($this->make(UserRepository::class)); $service->setPermissionService($this->denyingPermissions()); $this->expectException(AuthorizationException::class); $service->deleteUser(5); } public function test_patch_user_allows_self_with_limited_fields_without_edit_permission(): void { session(['userdata' => ['id' => 7]]); $patched = []; $service = $this->makeService($this->make(UserRepository::class, [ 'patchUser' => function ($id, $fields) use (&$patched) { $patched = ['id' => $id, 'fields' => $fields]; return true; }, ])); $service->setPermissionService($this->denyingPermissions()); // no users.edit // Editing OWN account (id === session user) is allowed even without users.edit... $result = $service->patchUser(7, ['firstname' => 'Bob', 'role' => '50']); $this->assertTrue($result); $this->assertSame(7, $patched['id']); $this->assertArrayHasKey('firstname', $patched['fields']); // ...but the privileged 'role' field is stripped — no self privilege-escalation. $this->assertArrayNotHasKey('role', $patched['fields']); } public function test_patch_user_denies_other_account_without_edit_permission(): void { session(['userdata' => ['id' => 7]]); $service = $this->makeService($this->make(UserRepository::class, [ 'patchUser' => fn () => true, ])); $service->setPermissionService($this->denyingPermissions()); // Patching ANOTHER account without users.edit must fail (closes the RPC escalation hole). $this->assertFalse($service->patchUser(99, ['role' => '50'])); } public function test_patch_user_allows_other_account_with_edit_permission(): void { session(['userdata' => ['id' => 7]]); $patched = []; $service = $this->makeService($this->make(UserRepository::class, [ 'patchUser' => function ($id, $fields) use (&$patched) { $patched = ['id' => $id, 'fields' => $fields]; return true; }, ])); $service->setPermissionService($this->allowingPermissions()); // has users.edit $result = $service->patchUser(99, ['role' => '20']); $this->assertTrue($result); $this->assertSame(99, $patched['id']); // A users.edit holder may set privileged fields on another account. $this->assertArrayHasKey('role', $patched['fields']); } public function test_self_service_methods_ignore_caller_supplied_id_and_pin_to_session(): void { // Self-service methods (editOwn/saveOwn*/getOwn*/changeOwnPassword) must operate on the // authenticated user only — over JSON-RPC a caller controls the $userId argument, so a // foreign id must NOT be honored (otherwise it is a cross-account IDOR). Representative // check via changeOwnPassword: the credential lookup must hit the SESSION user (7), not // the attacker-supplied id (99). session(['userdata' => ['id' => 7]]); $seenId = null; $repo = $this->make(UserRepository::class, [ 'getUser' => function ($id) use (&$seenId) { $seenId = $id; return [ 'id' => $id, 'password' => password_hash('correct-horse', PASSWORD_DEFAULT), 'firstname' => 'A', 'lastname' => 'B', 'username' => 'a@b.com', 'phone' => '', 'notifications' => 1, 'twoFAEnabled' => 0, ]; }, ]); $this->makeService($repo)->changeOwnPassword(99, 'wrong', 'NewPass1!', 'NewPass1!'); $this->assertSame(7, $seenId, 'self-service must pin to the session user, not the caller-supplied id'); } /** * Regression for #3556: getUser is @api and intentionally ungated, so any * authenticated client can request an arbitrary id. It must never return * credentials — password hash, plaintext 2FA seed, session token, or the * password-reset token/metadata — while keeping the safe profile fields * the view composers rely on. */ public function test_get_user_strips_sensitive_fields_from_api_response(): void { $fullRow = [ 'id' => 5, 'firstname' => 'Ada', 'lastname' => 'Lovelace', 'username' => 'ada@example.com', 'role' => '20', 'password' => '$2y$10$abcdefghijklmnopqrstuv', 'twoFASecret' => 'SECRET2FASEED', 'session' => 'sess-token-xyz', 'sessiontime' => '1700000000', 'pwReset' => 'reset-token', 'pwResetExpiration' => '2026-01-01 00:00:00', 'pwResetCount' => 2, ]; $repo = $this->make(UserRepository::class, [ 'getUser' => fn () => $fullRow, ]); $user = $this->makeService($repo)->getUser(5); $this->assertIsArray($user); // Safe profile fields survive so composers/avatars keep working. $this->assertSame('Ada', $user['firstname']); $this->assertSame('ada@example.com', $user['username']); // Every credential/session/reset field is stripped. foreach (['password', 'twoFASecret', 'session', 'sessiontime', 'pwReset', 'pwResetExpiration', 'pwResetCount'] as $secret) { $this->assertArrayNotHasKey($secret, $user, "getUser must not leak {$secret} over the API"); } } }