CVE-2023-35938 (PHP, published 2023-06-29T20:15Z)
Description: value:  Tuleap is a Free & Open Source Suite to improve management of software developments and collaboration. When switching from a project visibility that allows restricted users to `Private without restricted`, restricted users that are project administrators keep this access right. Restricted users that were project administrators before the visibility switch keep the possibility to access the project and do some administration actions. This issue has been resolved in Tuleap version 14.9.99.63. Users are advised to upgrade. There are no known workarounds for this issue.
Severity: nan (CVSS v3 base score: 7.2)
Weakness: CWE-281 \u2014 Improper Preservation of Permissions
The product does not preserve permissions or incorrectly preserves permissions when copying, restoring, or sharing objects, which can cause them to have less restrictive permissions than intended.
Repository: https://github.com/Enalean/tuleap
Files changed:
plugins/ldap/bin/ldap_sync.php
plugins/ldap/include/LDAP_DirectorySynchronization.class.php
plugins/ldap/include/ldapPlugin.php
plugins/project_ownership/include/ProjectAdmin/UpdateVisibilityChecker.php
plugins/project_ownership/include/ProjectOwner/ProjectOwnerDAO.php
plugins/project_ownership/include/ProjectOwner/ProjectOwnerRemover.php
plugins/project_ownership/include/project_ownershipPlugin.php
plugins/project_ownership/site-content/fr_FR/LC_MESSAGES/tuleap-project_ownership.po
plugins/project_ownership/site-content/pt_BR/LC_MESSAGES/tuleap-project_ownership.po
plugins/project_ownership/tests/unit/ProjectAdmin/UpdateVisibilityCheckerTest.php
plugins/project_ownership/tests/unit/ProjectOwner/ProjectOwnerRemoverTest.php
site-content/fr_FR/LC_MESSAGES/tuleap-core.po
site-content/pt_BR/LC_MESSAGES/tuleap-core.po
src/common/Config/ForgeConfig.php
src/common/Project/Admin/ForceRemovalOfRestrictedAdministrator.php
src/common/Project/Admin/ProjectDetails/ProjectDetailsController.php
src/common/Project/Admin/ProjectMembers/ProjectMembersController.php
src/common/Project/Admin/ProjectUGroup/MemberRemovalController.php
src/common/Project/Admin/ProjectVisibilityOptionsForPresenterGenerator.php
src/common/Project/Admin/ProjectVisibilityPresenter.class.php
src/common/Project/Admin/ProjectVisibilityPresenterBuilder.php
src/common/Project/Admin/Visibility/UpdateVisibilityChecker.php
src/common/Project/Admin/Visibility/UpdateVisibilityIsAllowedEvent.php
src/common/Project/Admin/Visibility/UpdateVisibilityStatus.php
src/common/Project/Project.class.php
src/common/Project/ProjectXMLImporter.class.php
src/common/Project/Registration/ProjectRegistrationPresenter.php
src/common/Project/UGroupManager.class.php
src/common/Project/UserRemover.php
src/common/Project/UserRemoverDao.php
src/common/Request/RouteCollector.php
src/common/User/SwitchToPresenterBuilder.php
src/common/admin/ProjectCreation/ProjectVisibility/ProjectVisibilityConfigPresenter.php
src/common/system_event/SystemEventManager.class.php
src/common/system_event/include/SystemEvent_PROJECT_IS_PRIVATE.class.php
src/templates/project/project_visibility.mustache
src/www/project/admin/editgroupinfo.php
tests/unit/common/Project/Admin/ProjectVisibilityOptionsForPresenterGeneratorTest.php
tests/unit/common/Project/Admin/Visibility/UpdateVisibilityCheckerTest.php
tests/unit/common/Project/ProjectDetails/ProjectDetailsControllerTest.php
tests/unit/common/Project/UserRemoverTest.php
tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php
Commit message: Close request #32278 Removed restricted users are not remove at project visibility switch

When switching from a project visibility that allows restricted users to Private without restricted,
then restricted users that are project administrators keep this access right, meaning that they can
continue to go to project and do some administration actions.

At project visibility switch to Private without restricted, we have to remove the project administration
right to restricted users. If this will remove the last administrator, then the project visibility switch
must be refused.

If the visibility change is triggered and a system events is logged,
then restricted adminsitrators are always removed if restricted users
are no more part of the project.

Note: Nothing is done for Public visibility without restricted as the
current code and implementation does nothing special for this
visibility. This can be handled in a dedicated commit if needed.

Change-Id: I42e2ef7c06e5e5e8af186a19139c532624b6e761


Vulnerable code:
                new UGroupManager()
    private function getUserRemover()
            new UGroupManager()
    private function getUserRemover()
            new UGroupManager()
msgstr "Privé incl. les utilisateurs restreints"
    public static function areRestrictedUsersAllowed()
                    $this->project_manager->setAccess($project, $request->get('project_visibility'));
                    $this->project_manager->clear($project->getID());
                    $this->ugroup_binding->reloadUgroupBindingInProject($project);
                $ugroup_manager
                $ugroup_manager
     * @psalm-return array<array{value: string, label: string, selected: string}>
        $platform_allows_restricted,
        $project_visibility,
        $this->platform_allows_restricted       = (bool) $platform_allows_restricted;
        $this->options                               = $project_visibility_options_generator->generateVisibilityOptions(
     * @return array of User admin of the project
    public function getAdmins(?UGroupManager $ugm = null)
                new UGroupManager()
        $this->are_restricted_users_allowed = (bool) ForgeConfig::areRestrictedUsersAllowed();
    /**
     * @return UserRemover
     */
    private function getUserRemover()
            new UGroupManager()
        if (! $this->dao->removeUserFromProject($project_id, $user_id)) {
    public function removeUserFromProject($project_id, $user_id)
                new UGroupManager()
            (bool) \ForgeConfig::areRestrictedUsersAllowed(),
                        new UGroupManager()
                        $ugroup_manager
*
    /**
     * @var UserRemover
     */
    private $user_remover;
    /**
     * @var UGroupManager
     */
    private $ugroup_manager;
        if (! ForgeConfig::areRestrictedUsersAllowed() || $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED) {
            <option value="{{ value }}" {{ selected }}>{{ label }}</option>
    new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator()
    $project_icons_retriever
        $options   = $generator->generateVisibilityOptions(false, Project::ACCESS_PUBLIC);
        $this->assertEqualsCanonicalizing(
        $this->assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
        $options   = $generator->generateVisibilityOptions(true, Project::ACCESS_PRIVATE_WO_RESTRICTED);
        $this->assertEqualsCanonicalizing(
        $this->assertEquals(Project::ACCESS_PRIVATE_WO_RESTRICTED, $this->getSelectedAccess($options));
            new ProjectIconRetriever()
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
    use MockeryPHPUnitIntegration;
    /**
     * @var \EventManager|\Mockery\MockInterface
     */
    private $event_manager;
    private $project_manager;
    private $tv3_tracker_factory;
    private $dao;
    private $user_manager;
    private $project_history_dao;
    private $ugroup_manager;
    private $tracker_v3;
        $this->project_manager     = \Mockery::spy(\ProjectManager::class);
        $this->event_manager       = \Mockery::spy(\EventManager::class);
        $this->tv3_tracker_factory = \Mockery::spy(\ArtifactTypeFactory::class);
        $this->dao                 = \Mockery::spy(\Tuleap\Project\UserRemoverDao::class);
        $this->user_manager        = \Mockery::spy(\UserManager::class);
        $this->project_history_dao = \Mockery::spy(\ProjectHistoryDao::class);
        $this->ugroup_manager      = \Mockery::spy(\UGroupManager::class);
            $this->ugroup_manager
        $this->tracker_v3 = \Mockery::spy(\ArtifactType::class);
        $this->dao->shouldReceive('removeUserFromProject')->once()->andReturns(true);
        $this->dao->shouldReceive('removeUserFromProjectUgroups')->once()->andReturns(true);
        $this->tracker_v3->shouldReceive('deleteUser')->once()->with(102)->andReturns(true);
        $this->project_manager->shouldReceive('getProject')->with(101)->andReturns($this->project);
        $this->user_manager->shouldReceive('getUserById')->with(102)->andReturns($this->user);
        $this->ugroup_manager->shouldReceive('getStaticUGroups')->with($this->project)->andReturns([]);
        $this->tv3_tracker_factory->shouldReceive('getArtifactTypesFromId')->with(101)->andReturns([$this->tracker_v3]);
        $this->project_history_dao->shouldReceive('groupAddHistory')->once();
        $this->event_manager->shouldReceive('processEvent')->twice();
        $this->project_manager->shouldReceive('getProject')->with(101)->andReturns($this->project);
        $this->dao->shouldReceive('removeUserFromProject')->once();
        $this->dao->shouldReceive('removeUserFromProjectUgroups')->never();
        $this->project_history_dao->shouldReceive('groupAddHistory')->never();
        $this->tracker_v3->shouldReceive('deleteUser')->never();
        $this->event_manager->shouldReceive('processEvent')->never();
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
    use MockeryPHPUnitIntegration;
        $project_id   = 102;
        $system_event = new SystemEvent_PROJECT_IS_PRIVATE(
            1,
            SystemEvent_PROJECT_IS_PRIVATE::TYPE_PROJECT_IS_PRIVATE,
            SystemEvent_PROJECT_IS_PRIVATE::APP_OWNER_QUEUE,
            $project_id . '::1',
            SystemEvent_PROJECT_IS_PRIVATE::PRIORITY_MEDIUM,
            SystemEvent_PROJECT_IS_PRIVATE::STATUS_NEW,
            '',
            '',
            '',
            ''
        $user_remover   = Mockery::mock(UserRemover::class);
        $ugroup_manager = Mockery::mock(\UGroupManager::class);
            $ugroup_manager
        $project_manager = Mockery::mock(ProjectManager::class);
        $project = Mockery::mock(Project::class);
        $project->shouldReceive('getID')->andReturn($project_id);
        $project_manager->shouldReceive('getProject')->andReturn($project);
        $project->shouldReceive('usesCVS')->andReturn(false);
        $project->shouldReceive('usesSVN')->andReturn(false);
        ForgeConfig::set(ProjectVisibilityConfigManager::SEND_MAIL_ON_PROJECT_VISIBILITY_CHANGE, false);
        EventManager::setInstance(Mockery::spy(EventManager::class));
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        $project->shouldReceive('getAccess')->andReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
        $restricted_member = Mockery::mock(PFUser::class);
        $restricted_member->shouldReceive('isRestricted')->andReturn(true);
        $restricted_member->shouldReceive('getId')->andReturn($restricted_member_id);
        $member = UserTestBuilder::anActiveUser()->build();
        $project->shouldReceive('getMembers')->andReturn([$restricted_member, $member]);
        $user_remover->shouldReceive('removeUserFromProject')->with($project_id, $restricted_member_id)->once();
        $ugroup_with_restricted         = Mockery::mock(ProjectUGroup::class);
        $ugroup_with_restricted->shouldReceive('getMembers')->andReturn([$restricted_user_in_ugroup_only, $member]);
        $ugroup_with_restricted->shouldReceive('removeUser')->with(
            \Mockery::on(
        )->once();
        $ugroup_without_restricted = Mockery::mock(ProjectUGroup::class);
        $ugroup_without_restricted->shouldReceive('getMembers')->andReturn([$member]);
        $ugroup_manager->shouldReceive('getStaticUGroups')->with($project)->andReturn(

Fixed code:
use Tuleap\Project\UserPermissionsDao;

                new UGroupManager(),
                new UserPermissionsDao(),
use Tuleap\Project\UserPermissionsDao;
    private function getUserRemover(): UserRemover
            new UGroupManager(),
            new UserPermissionsDao(),
use Tuleap\Project\UserPermissionsDao;
    private function getUserRemover(): UserRemover
            new UGroupManager(),
            new UserPermissionsDao(),
<?php
/**
 * Copyright (c) Enalean, 2023 - Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\ProjectOwnership\ProjectAdmin;

use Project;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRetriever;

final class UpdateVisibilityChecker
{
    public function __construct(private readonly ProjectOwnerRetriever $project_owner_retriever)
    {
    }

    public function canUpdateVisibilityRegardingRestrictedUsers(Project $project): UpdateVisibilityStatus
    {
        $project_owner = $this->project_owner_retriever->getProjectOwner($project);
        if ($project_owner && $project_owner->isRestricted()) {
            return UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(
                dgettext('tuleap-project_ownership', 'Cannot switch the project visibility because it will remove the project owner which is restricted.')
            );
        }

        return UpdateVisibilityStatus::buildStatusSwitchIsAllowed();
    }
}

    public function delete(int $project_id, int $user_id): void
    {
        $this->getDB()->delete(
            'plugin_project_ownership_project_owner',
            [
                'project_id' => $project_id,
                'user_id'    => $user_id,
            ],
        );
    }
<?php
/**
 * Copyright (c) Enalean, 2023 - Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\ProjectOwnership\ProjectOwner;

use Project;
use Psr\Log\LoggerInterface;

class ProjectOwnerRemover
{
    public function __construct(
        private readonly ProjectOwnerDAO $dao,
        private readonly ProjectOwnerRetriever $project_owner_retriever,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function forceRemovalOfRestrictedProjectOwner(\Project $project, \PFUser $removed_user): void
    {
        if ($project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED) {
            return;
        }

        if (! $removed_user->isRestricted()) {
            return;
        }

        $project_owner = $this->project_owner_retriever->getProjectOwner($project);
        if ($project_owner === null) {
            return;
        }

        if ($project_owner->getId() === $removed_user->getId()) {
            $this->dao->delete(
                (int) $project->getID(),
                (int) $removed_user->getId(),
            );

            $this->logger->info(
                "Project owner #" . $removed_user->getId() . " in project # " . $project->getID() . " removed by system event at project visibility switch."
            );
        }
    }
}
use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityIsAllowedEvent;
use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRemover;

    #[\Tuleap\Plugin\ListeningToEventClass]
    public function updateVisibilityIsAllowedEvent(UpdateVisibilityIsAllowedEvent $event): void
    {
        $update_status = (new Tuleap\ProjectOwnership\ProjectAdmin\UpdateVisibilityChecker(
            new ProjectOwnerRetriever(new ProjectOwnerDAO(), UserManager::instance()),
        ))->canUpdateVisibilityRegardingRestrictedUsers($event->getProject());

        $event->setUpdateVisibilityStatus($update_status);
    }

    #[\Tuleap\Plugin\ListeningToEventClass]
    public function forceRemovalOfRestrictedAdministrator(ForceRemovalOfRestrictedAdministrator $event): void
    {
        (new ProjectOwnerRemover(
            new ProjectOwnerDAO(),
            new ProjectOwnerRetriever(new ProjectOwnerDAO(), UserManager::instance()),
            BackendLogger::getDefaultLogger(),
        ))->forceRemovalOfRestrictedProjectOwner(
            $event->project,
            $event->user,
        );
    }
msgid ""
"Cannot switch the project visibility because it will remove the project "
"owner which is restricted."
msgstr ""
"La visiblité de projet ne peut pas être modifiée car cela va supprimer le "
"responsable du projet qui est restreint."

msgid ""
"Cannot switch the project visibility because it will remove the project "
"owner which is restricted."
msgstr ""

<?php
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\ProjectOwnership\ProjectAdmin;

use PHPUnit\Framework\MockObject\MockObject;
use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRetriever;
use Tuleap\Test\Builders\ProjectTestBuilder;
use Tuleap\Test\Builders\UserTestBuilder;
use Tuleap\Test\PHPUnit\TestCase;

final class UpdateVisibilityCheckerTest extends TestCase
{
    private UpdateVisibilityChecker $checker;
    private MockObject&ProjectOwnerRetriever $project_owner_retriever;

    protected function setUp(): void
    {
        parent::setUp();

        $this->project_owner_retriever = $this->createMock(ProjectOwnerRetriever::class);
        $this->checker                 = new UpdateVisibilityChecker($this->project_owner_retriever);
    }

    public function testItAllowsProjectVisibilitySwitchIfThereIsNoOwnerInProject(): void
    {
        $project = ProjectTestBuilder::aProject()->build();

        $this->project_owner_retriever->method('getProjectOwner')->with($project)->willReturn(null);

        self::assertTrue(
            $this->checker->canUpdateVisibilityRegardingRestrictedUsers($project)->canSwitch()
        );
    }

    public function testItAllowsProjectVisibilitySwitchIfOwnerInProjectIsNotRestricted(): void
    {
        $project = ProjectTestBuilder::aProject()->build();

        $this->project_owner_retriever->method('getProjectOwner')->with($project)->willReturn(
            UserTestBuilder::anActiveUser()->build(),
        );

        self::assertTrue(
            $this->checker->canUpdateVisibilityRegardingRestrictedUsers($project)->canSwitch()
        );
    }

    public function testItDoesNotAllowProjectVisibilitySwitchIfOwnerInProjectIsRestricted(): void
    {
        $project = ProjectTestBuilder::aProject()->build();

        $this->project_owner_retriever->method('getProjectOwner')->with($project)->willReturn(
            UserTestBuilder::aRestrictedUser()->build(),
        );

        self::assertFalse(
            $this->checker->canUpdateVisibilityRegardingRestrictedUsers($project)->canSwitch()
        );
    }
}
<?php
/**
 * Copyright (c) Enalean, 2023 - Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\ProjectOwnership\ProjectOwner;

use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\NullLogger;
use Tuleap\Test\Builders\ProjectTestBuilder;
use Tuleap\Test\Builders\UserTestBuilder;
use Tuleap\Test\PHPUnit\TestCase;

final class ProjectOwnerRemoverTest extends TestCase
{
    private ProjectOwnerRemover $remover;
    private MockObject&ProjectOwnerDAO $dao;
    private MockObject&ProjectOwnerRetriever $project_owner_retriever;

    protected function setUp(): void
    {
        parent::setUp();

        $this->dao                     = $this->createMock(ProjectOwnerDAO::class);
        $this->project_owner_retriever = $this->createMock(ProjectOwnerRetriever::class);

        $this->remover = new ProjectOwnerRemover(
            $this->dao,
            $this->project_owner_retriever,
            new NullLogger(),
        );
    }

    public function testItDoesNothingIfProjectIsNotPrivateWithoutRestricted(): void
    {
        $public_project                 = ProjectTestBuilder::aProject()->withAccessPublic()->build();
        $private_project                = ProjectTestBuilder::aProject()->withAccessPrivate()->build();
        $public_project_with_restricted = ProjectTestBuilder::aProject()->withAccessPublicIncludingRestricted()->build();

        $this->dao->expects(self::never())->method('delete');

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $public_project,
            UserTestBuilder::aRestrictedUser()->build(),
        );

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $private_project,
            UserTestBuilder::aRestrictedUser()->build(),
        );

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $public_project_with_restricted,
            UserTestBuilder::aRestrictedUser()->build(),
        );
    }

    public function testItDoesNothingIfUserIsNotRestricted(): void
    {
        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();

        $this->dao->expects(self::never())->method('delete');

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $project,
            UserTestBuilder::anActiveUser()->build(),
        );
    }

    public function testItDoesNothingIfThereIsNoOwnerInProject(): void
    {
        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();

        $this->project_owner_retriever->method('getProjectOwner')->willReturn(null);
        $this->dao->expects(self::never())->method('delete');

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $project,
            UserTestBuilder::aRestrictedUser()->build(),
        );
    }

    public function testItDoesNothingIfTheRemovedUserIsNotTheOwnerInProject(): void
    {
        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();

        $this->project_owner_retriever->method('getProjectOwner')->willReturn(
            UserTestBuilder::anActiveUser()->withId(103)->build()
        );
        $this->dao->expects(self::never())->method('delete');

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $project,
            UserTestBuilder::aRestrictedUser()->withId(102)->build(),
        );
    }

    public function testItForcesTheRemovalOfTheOwnerInProject(): void
    {
        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();

        $this->project_owner_retriever->method('getProjectOwner')->willReturn(
            UserTestBuilder::anActiveUser()->withId(102)->build()
        );
        $this->dao->expects(self::once())->method('delete');

        $this->remover->forceRemovalOfRestrictedProjectOwner(
            $project,
            UserTestBuilder::aRestrictedUser()->withId(102)->build(),
        );
    }
}
msgid ""
"Cannot switch the project visibility because it will remove every restricted "
"users from the project, and after that no administrator will be left."
msgstr ""
"La visibilité ne peut être changée car elle va supprimer tous les "
"utilisateurs restreints du projet, et par conséquent aucun administrateur de "
"projet ne sera présent."

msgstr "Privé, incluant les utilisateurs restreints"
msgid ""
"Cannot switch the project visibility because it will remove every restricted "
"users from the project, and after that no administrator will be left."
msgstr ""

    public static function areRestrictedUsersAllowed(): bool
<?php
/**
 * Copyright (c) Enalean, 2018 - Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

namespace Tuleap\Project\Admin;

use PFUser;
use Project;
use Tuleap\Event\Dispatchable;

/**
 * @psalm-immutable
 */
final class ForceRemovalOfRestrictedAdministrator implements Dispatchable
{
    public function __construct(
        public readonly Project $project,
        public readonly PFUser $user,
    ) {
    }
}
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
        private readonly UpdateVisibilityChecker $update_visibility_checker,
                    $update_visibility_status = $this->update_visibility_checker->canUpdateVisibilityRegardingRestrictedUsers(
                        $project,
                        $request->get('project_visibility'),
                    );

                    if ($update_visibility_status->canSwitch()) {
                        $this->project_manager->setAccess($project, $request->get('project_visibility'));
                        $this->project_manager->clear($project->getID());
                        $this->ugroup_binding->reloadUgroupBindingInProject($project);
                    } else {
                        $GLOBALS['Response']->addFeedback(
                            Feedback::ERROR,
                            $update_visibility_status->getReason(),
                        );
                    }
                $ugroup_manager,
                new UserPermissionsDao(),
                $ugroup_manager,
                new UserPermissionsDao(),
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
     * @psalm-return array<array{value: string, label: string, selected: string, disabled: bool, title: string}>
        UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
                    'disabled' => ! $private_without_restricted_visibility_switch_status->canSwitch(),
                    'title'    => ! $private_without_restricted_visibility_switch_status->canSwitch() ? $private_without_restricted_visibility_switch_status->getReason() : '',
                    'disabled' => false,
                    'title'    => '',
                    'disabled' => false,
                    'title'    => '',
                    'disabled' => false,
                    'title'    => '',
                'disabled' => false,
                'title'    => '',
                'disabled' => false,
                'title'    => '',
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
        bool $platform_allows_restricted,
        UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
        string $project_visibility,
        $this->platform_allows_restricted       = $platform_allows_restricted;
        $this->options = $project_visibility_options_generator->generateVisibilityOptions(
            $private_without_restricted_visibility_switch_status,

use Project;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
        private readonly UpdateVisibilityChecker $update_visibility_checker,
            $this->update_visibility_checker->canUpdateVisibilityRegardingRestrictedUsers(
                $project,
                Project::ACCESS_PRIVATE_WO_RESTRICTED,
            ),
<?php
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 *  This file is a part of Tuleap.
 *
 *  Tuleap is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  Tuleap is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\Project\Admin\Visibility;

use ForgeConfig;
use Project;
use Psr\EventDispatcher\EventDispatcherInterface;

final class UpdateVisibilityChecker
{
    public function __construct(
        private readonly EventDispatcherInterface $event_dispatcher,
    ) {
    }

    public function canUpdateVisibilityRegardingRestrictedUsers(Project $project, string $visibility): UpdateVisibilityStatus
    {
        if (
            ! ForgeConfig::areRestrictedUsersAllowed() ||
            $visibility !== Project::ACCESS_PRIVATE_WO_RESTRICTED
        ) {
            return UpdateVisibilityStatus::buildStatusSwitchIsAllowed();
        }

        if (! $this->atLeastOneAdministratorIsActive($project)) {
            return UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(
                _("Cannot switch the project visibility because it will remove every restricted users from the project, and after that no administrator will be left."),
            );
        }

        $event = $this->event_dispatcher->dispatch(
            new UpdateVisibilityIsAllowedEvent($project)
        );

        return $event->getUpdateVisibilityStatus();
    }

    private function atLeastOneAdministratorIsActive(Project $project): bool
    {
        foreach ($project->getAdmins() as $project_admin) {
            if ($project_admin->isActive()) {
                return true;
            }
        }

        return false;
    }
}
<?php
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 *  This file is a part of Tuleap.
 *
 *  Tuleap is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  Tuleap is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\Project\Admin\Visibility;

use Project;
use Tuleap\Event\Dispatchable;

final class UpdateVisibilityIsAllowedEvent implements Dispatchable
{
    private UpdateVisibilityStatus $update_visibility_status;

    public function __construct(private readonly Project $project)
    {
        $this->update_visibility_status = UpdateVisibilityStatus::buildStatusSwitchIsAllowed();
    }

    public function getUpdateVisibilityStatus(): UpdateVisibilityStatus
    {
        return $this->update_visibility_status;
    }

    public function setUpdateVisibilityStatus(UpdateVisibilityStatus $update_visibility_status): void
    {
        $this->update_visibility_status = $update_visibility_status;
    }

    public function getProject(): Project
    {
        return $this->project;
    }
}
<?php
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 *  This file is a part of Tuleap.
 *
 *  Tuleap is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  Tuleap is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\Project\Admin\Visibility;

final class UpdateVisibilityStatus
{
    private function __construct(
        private readonly bool $can_switch,
        private readonly string $reason,
    ) {
    }

    public static function buildStatusSwitchIsAllowed(): self
    {
        return new self(true, '');
    }

    public static function buildStatusSwitchIsNotAllowed(string $reason): self
    {
        return new self(false, $reason);
    }

    public function canSwitch(): bool
    {
        return $this->can_switch;
    }

    public function getReason(): string
    {
        return $this->reason;
    }
}
     * @return PFUser[] of User admin of the project
    public function getAdmins(?UGroupManager $ugm = null): array
use Tuleap\Project\UserPermissionsDao;
                new UGroupManager(),
                new UserPermissionsDao(),
        $this->are_restricted_users_allowed = ForgeConfig::areRestrictedUsersAllowed();
    private function getUserRemover(): UserRemover
            new UGroupManager(),
            new UserPermissionsDao(),
use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectAdminHistoryEntry;
        private readonly UserPermissionsDao $user_permissions_dao,
    public function forceRemoveAdminRestrictedUserFromProject(Project $project, \PFUser $removed_user): void
    {
        if (! $removed_user->isRestricted()) {
            return;
        }

        $this->user_permissions_dao->removeUserFromProjectAdmin($project->getID(), $removed_user->getId());
        $this->project_history_dao->addHistory(
            $project,
            $this->user_manager->getUserAnonymous(),
            new \DateTimeImmutable(),
            ProjectAdminHistoryEntry::Remove->value,
            $removed_user->getUserName() . " (" . $removed_user->getId() . ")",
        );

        $this->event_manager->dispatch(
            new ForceRemovalOfRestrictedAdministrator($project, $removed_user),
        );

        $this->removeUserFromProject(
            $project->getID(),
            $removed_user->getID(),
        );
    }

        if (! $this->dao->removeNonAdminUserFromProject($project_id, $user_id)) {
    public function removeNonAdminUserFromProject($project_id, $user_id): bool
use Tuleap\Project\UserPermissionsDao;
                new UGroupManager(),
                new UserPermissionsDao(),
            \ForgeConfig::areRestrictedUsersAllowed(),
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
            UpdateVisibilityStatus::buildStatusSwitchIsAllowed(),
use Tuleap\Project\UserPermissionsDao;
                        new UGroupManager(),
                        new UserPermissionsDao(),
                        $ugroup_manager,
                        new UserPermissionsDao(),
    private UserRemover $user_remover;
    private UGroupManager $ugroup_manager;
        if (
            ! ForgeConfig::areRestrictedUsersAllowed() ||
            $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED
        ) {

        $project_admins = $project->getAdmins();
        foreach ($project_admins as $project_admin) {
            if ($project_admin->isRestricted()) {
                $this->user_remover->forceRemoveAdminRestrictedUserFromProject(
                    $project,
                    $project_admin,
                );
            }
        }

            <option value="{{ value }}"
                {{# disabled }} disabled {{/ disabled }}
                {{# title }} title="{{ . }}" {{/ title }}
                {{ selected }}
            >{{ label }}</option>
    new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator(),
    new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
    $project_icons_retriever,
    new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
        $options   = $generator->generateVisibilityOptions(false, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PUBLIC);
        self::assertEqualsCanonicalizing(
        self::assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
        $options   = $generator->generateVisibilityOptions(true, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PRIVATE_WO_RESTRICTED);
        self::assertEqualsCanonicalizing(
        self::assertEquals(Project::ACCESS_PRIVATE_WO_RESTRICTED, $this->getSelectedAccess($options));
    }

    public function testGeneratedOptionsWhenRestrictedUsersAreAllowedButDisabled(): void
    {
        $generator = new ProjectVisibilityOptionsForPresenterGenerator();
        $options   = $generator->generateVisibilityOptions(true, UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(''), Project::ACCESS_PUBLIC);

        self::assertEqualsCanonicalizing(
            [Project::ACCESS_PUBLIC, Project::ACCESS_PRIVATE, Project::ACCESS_PRIVATE_WO_RESTRICTED, Project::ACCESS_PUBLIC_UNRESTRICTED],
            $this->getAvailableAccesses($options)
        );

        self::assertTrue($this->getPrivateWithoutRestrictedOptionDisabledValue($options));
    }

    private function getPrivateWithoutRestrictedOptionDisabledValue(array $options): bool
    {
        foreach ($options as $option) {
            if ($option['value'] == Project::ACCESS_PRIVATE_WO_RESTRICTED) {
                return $option['disabled'];
            }
        }

        return false;
<?php
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Tuleap\Project\Admin\Visibility;

use ForgeAccess;
use ForgeConfig;
use Project;
use Psr\EventDispatcher\EventDispatcherInterface;
use Tuleap\ForgeConfigSandbox;
use Tuleap\Test\Builders\ProjectTestBuilder;
use Tuleap\Test\Builders\UserTestBuilder;
use Tuleap\Test\PHPUnit\TestCase;

class UpdateVisibilityCheckerTest extends TestCase
{
    use ForgeConfigSandbox;

    public function testVisibilitySwitchIsAllowedIfPlatformDoesNotAllowRestrictedUsers(): void
    {
        ForgeConfig::set(ForgeAccess::CONFIG, "whatever");
        $project = ProjectTestBuilder::aProject()->build();

        self::assertTrue(
            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, 'whatever')->canSwitch()
        );
    }

    public function testVisibilitySwitchIsAllowedIfPlatformAllowRestrictedUsersButVisibilityIsNotPrivateWithoutRestricted(): void
    {
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        $project = ProjectTestBuilder::aProject()->build();

        self::assertTrue(
            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PUBLIC)->canSwitch()
        );

        self::assertTrue(
            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE)->canSwitch()
        );

        self::assertTrue(
            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PUBLIC_UNRESTRICTED)->canSwitch()
        );
    }

    public function testVisibilitySwitchIsAllowedIfPlatformAllowRestrictedUsersVisibilityIsPrivateWithoutRestrictedWithActiveAdminsAndExternalPluginsAllowTheChange(): void
    {
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        $project = $this->createMock(Project::class);
        $project->method('getAdmins')->willReturn([
            UserTestBuilder::anActiveUser()->build(),
        ]);

        $checker = new UpdateVisibilityChecker(
            new class implements EventDispatcherInterface {
                /**
                 * @return UpdateVisibilityIsAllowedEvent
                 */
                public function dispatch(object $event)
                {
                    return new UpdateVisibilityIsAllowedEvent(ProjectTestBuilder::aProject()->build());
                }
            }
        );

        self::assertTrue(
            $checker->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE_WO_RESTRICTED)->canSwitch()
        );
    }

    public function testVisibilitySwitchIsNotAllowedIfPlatformAllowRestrictedUsersVisibilityIsPrivateWithoutRestrictedWithActiveAdminsAndExternalPluginsDoesNotAllowTheChange(): void
    {
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        $project = $this->createMock(Project::class);
        $project->method('getAdmins')->willReturn([
            UserTestBuilder::anActiveUser()->build(),
        ]);

        $checker = new UpdateVisibilityChecker(
            new class implements EventDispatcherInterface {
                /**
                 * @return UpdateVisibilityIsAllowedEvent
                 */
                public function dispatch(object $event)
                {
                    $event = new UpdateVisibilityIsAllowedEvent(ProjectTestBuilder::aProject()->build());
                    $event->setUpdateVisibilityStatus(
                        UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(""),
                    );

                    return $event;
                }
            }
        );

        self::assertFalse(
            $checker->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE_WO_RESTRICTED)->canSwitch()
        );
    }

    public function testVisibilitySwitchIsNotAllowedIfPlatformAllowRestrictedUsersVisibilityIsPrivateWithoutRestrictedWithoutActiveAdmins(): void
    {
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        $project = $this->createMock(Project::class);
        $project->method('getAdmins')->willReturn([
            UserTestBuilder::aRestrictedUser()->build(),
        ]);

        self::assertFalse(
            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE_WO_RESTRICTED)->canSwitch()
        );
    }

    private function buildDefaultUpdateVisibilityChecker(): UpdateVisibilityChecker
    {
        return new UpdateVisibilityChecker(
            new class implements EventDispatcherInterface {
                /**
                 * @return void
                 */
                public function dispatch(object $event)
                {
                    return;
                }
            }
        );
    }
}
use ForgeAccess;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
use Tuleap\Test\Builders\UserTestBuilder;
            new ProjectIconRetriever(),
            new UpdateVisibilityChecker($this->event_manager),
    public function testVisibilityUpdateIsNotValidWhenNotMatchingRestrictedUsersConstraints(): void
    {
        ForgeConfig::set('feature_flag_project_icon_display', '1');
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);

        $this->csrf_token->shouldReceive('check')->once();

        $request = Mockery::mock(HTTPRequest::class);
        $request->shouldReceive('get')->twice()->withArgs(['form_group_name'])->andReturn('project_name');
        $request->shouldReceive('get')->twice()->withArgs(['form_shortdesc'])->andReturn('decription');
        $request->shouldReceive('get')->atLeast()->once()->withArgs(['form-group-name-icon'])->andReturn("");
        $request->shouldReceive('get')->atLeast()->once()->withArgs(['group_id'])->andReturn(102);
        $request->shouldReceive('get')->withArgs(['project_visibility'])->andReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
        $request->shouldReceive('get')->withArgs(['term_of_service'])->andReturn(true);
        $current_user = $this->createMock(PFUser::class);
        $request->shouldReceive('getCurrentUser')->atLeast()->once()->andReturn($current_user);
        $request->shouldReceive('existAndNonEmpty')->atLeast()->once()->andReturnFalse();

        $project = $this->createMock(Project::class);
        $project->method('getAdmins')->willReturn([
            UserTestBuilder::aRestrictedUser()->build(),
        ]);
        $project->method('getAccess')->willReturn(Project::ACCESS_PUBLIC);
        $project->method('getID')->willReturn(101);
        $current_user->method('isAdmin')->with(101)->willReturn(true);
        $request->shouldReceive('getProject')->twice()->andReturn($project);

        $this->description_fields_factory->shouldReceive('getAllDescriptionFields')->twice()->andReturn([]);

        $this->current_project->shouldReceive('getProjectsDescFieldsValue')->once()->andReturn([]);

        $this->project_details_dao->shouldReceive('updateGroupNameAndDescription')->once();
        $this->project_history_dao->shouldReceive('groupAddHistory')->once();
        $this->event_manager->shouldReceive('processEvent')->once();
        $this->project_visibility_configuration->shouldReceive('canUserConfigureProjectVisibility')->once(
        )->andReturnTrue();
        $this->project_visibility_configuration->shouldReceive('canUserConfigureTruncatedMail')->once()->andReturnFalse(
        );

        $GLOBALS['Response']->shouldReceive('addFeedback')->once()->withArgs(
            [Feedback::ERROR, _('Cannot switch the project visibility because it will remove every restricted users from the project, and after that no administrator will be left.')]
        );

        $GLOBALS['Response']->shouldReceive('addFeedback')->once()->withArgs([Feedback::INFO, _('Update successful')]);

        $this->controller->update($request);
    }

use PHPUnit\Framework\MockObject\MockObject;
use Tuleap\Test\Builders\UserTestBuilder;
    private MockObject&\EventManager $event_manager;
    private MockObject&\ProjectManager $project_manager;
    private MockObject&\ArtifactTypeFactory $tv3_tracker_factory;
    private MockObject&UserRemoverDao $dao;
    private MockObject&\UserManager $user_manager;
    private MockObject&\ProjectHistoryDao $project_history_dao;
    private MockObject&\UGroupManager $ugroup_manager;
    private MockObject&\ArtifactType $tracker_v3;
    private MockObject&UserPermissionsDao $user_permissions_dao;
        $this->project_manager      = $this->createMock(\ProjectManager::class);
        $this->event_manager        = $this->createMock(\EventManager::class);
        $this->tv3_tracker_factory  = $this->createMock(\ArtifactTypeFactory::class);
        $this->dao                  = $this->createMock(UserRemoverDao::class);
        $this->user_manager         = $this->createMock(\UserManager::class);
        $this->project_history_dao  = $this->createMock(\ProjectHistoryDao::class);
        $this->ugroup_manager       = $this->createMock(\UGroupManager::class);
        $this->user_permissions_dao = $this->createMock(UserPermissionsDao::class);
            $this->ugroup_manager,
            $this->user_permissions_dao,
        $this->tracker_v3 = $this->createMock(\ArtifactType::class);
        $this->dao->expects(self::once())->method('removeNonAdminUserFromProject')->willReturn(true);
        $this->dao->expects(self::once())->method('removeUserFromProjectUgroups')->willReturn(true);
        $this->tracker_v3->expects(self::once())->method('deleteUser')->with(102)->willReturn(true);
        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
        $this->user_manager->method('getUserById')->with(102)->willReturn($this->user);
        $this->ugroup_manager->method('getStaticUGroups')->with($this->project)->willReturn([]);
        $this->tv3_tracker_factory->method('getArtifactTypesFromId')->with(101)->willReturn([$this->tracker_v3]);
        $this->project_history_dao->expects(self::once())->method('groupAddHistory');
        $this->event_manager->expects(self::exactly(2))->method('processEvent');
    public function testItForcesRemovalOfRestrictedUserFromProjectAdminAndUgroups(): void
    {
        $project = ProjectTestBuilder::aProject()->build();
        $user    = UserTestBuilder::aRestrictedUser()->withId(102)->build();

        $this->user_manager->method('getUserAnonymous')->willReturn(UserTestBuilder::anAnonymousUser()->build());

        $this->user_permissions_dao->expects(self::once())->method('removeUserFromProjectAdmin');
        $this->dao->expects(self::once())->method('removeNonAdminUserFromProject')->willReturn(true);
        $this->dao->expects(self::once())->method('removeUserFromProjectUgroups')->willReturn(true);
        $this->tracker_v3->expects(self::once())->method('deleteUser')->with(102)->willReturn(true);
        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
        $this->user_manager->method('getUserById')->with(102)->willReturn($this->user);
        $this->ugroup_manager->method('getStaticUGroups')->with($this->project)->willReturn([]);
        $this->tv3_tracker_factory->method('getArtifactTypesFromId')->with(101)->willReturn([$this->tracker_v3]);

        $this->project_history_dao->expects(self::once())->method('groupAddHistory');
        $this->project_history_dao->expects(self::once())->method('addHistory');
        $this->event_manager->expects(self::exactly(2))->method('processEvent');
        $this->event_manager->expects(self::once())->method('dispatch');

        $this->remover->forceRemoveAdminRestrictedUserFromProject($project, $user);
    }

    public function testItDoesNotForceRemovalOfUserFromProjectAdminAndUgroupsIfNotRestricted(): void
    {
        $project = ProjectTestBuilder::aProject()->build();
        $user    = UserTestBuilder::anActiveUser()->withId(102)->build();

        $this->user_permissions_dao->expects(self::never())->method('removeUserFromProjectAdmin');
        $this->dao->expects(self::never())->method('removeNonAdminUserFromProject');
        $this->dao->expects(self::never())->method('removeUserFromProjectUgroups');
        $this->project_history_dao->expects(self::never())->method('groupAddHistory');
        $this->tracker_v3->expects(self::never())->method('deleteUser');
        $this->event_manager->expects(self::never())->method('processEvent');
        $this->event_manager->expects(self::never())->method('dispatch');

        $this->remover->forceRemoveAdminRestrictedUserFromProject($project, $user);
    }

        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
        $this->dao->expects(self::once())->method('removeNonAdminUserFromProject');
        $this->dao->expects(self::never())->method('removeUserFromProjectUgroups');
        $this->project_history_dao->expects(self::never())->method('groupAddHistory');
        $this->tracker_v3->expects(self::never())->method('deleteUser');
        $this->event_manager->expects(self::never())->method('processEvent');
    private int $project_id = 102;

    protected function setUp(): void
    {
        parent::setUp();

        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        ForgeConfig::set(ProjectVisibilityConfigManager::SEND_MAIL_ON_PROJECT_VISIBILITY_CHANGE, false);

        $event_manager = new class extends EventManager {
            public function processEvent($event, $params = [])
            {
                return;
            }
        };
        EventManager::setInstance($event_manager);
    }

        $system_event = $this->buildSystemEvent();

        $user_remover   = $this->createMock(UserRemover::class);
        $ugroup_manager = $this->createMock(\UGroupManager::class);
        $system_event->injectDependencies(
            $user_remover,
            $ugroup_manager,
        );

        $project_manager = $this->createMock(ProjectManager::class);
        ProjectManager::setInstance($project_manager);
        $project = $this->createMock(Project::class);
        $project->method('getID')->willReturn($this->project_id);
        $project_manager->method('getProject')->willReturn($project);

        $project->method('usesCVS')->willReturn(false);
        $project->method('usesSVN')->willReturn(false);

        $project->method('getAccess')->willReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);

        $restricted_member_id = 456;
        $restricted_admin     = UserTestBuilder::aRestrictedUser()->build();
        $restricted_member    = UserTestBuilder::aRestrictedUser()->withId($restricted_member_id)->build();
        $member               = UserTestBuilder::anActiveUser()->build();

        $project->method('getAdmins')->willReturn([$restricted_admin, $member]);
        $user_remover->expects(self::once())->method('forceRemoveAdminRestrictedUserFromProject')->with($project, $restricted_admin);
        $project->method('getMembers')->willReturn([$restricted_member, $member]);
        $user_remover->expects(self::once())->method('removeUserFromProject')->with($this->project_id, $restricted_member_id);

        $restricted_user_in_ugroup_only = UserTestBuilder::aRestrictedUser()->build();
        $ugroup_with_restricted         = $this->createMock(ProjectUGroup::class);
        $ugroup_with_restricted->method('getMembers')->willReturn([$restricted_user_in_ugroup_only, $member]);
        $ugroup_with_restricted->expects(self::once())->method('removeUser')->with(
            $restricted_user_in_ugroup_only,
            self::callback(
                function (PFUser $user) {
                    return (int) $user->getId() === 0;
                }
            )
        );
        $ugroup_without_restricted = $this->createMock(ProjectUGroup::class);
        $ugroup_without_restricted->method('getMembers')->willReturn([$member]);
        $ugroup_manager->method('getStaticUGroups')->with($project)->willReturn(
            [$ugroup_with_restricted, $ugroup_without_restricted]
        self::assertTrue($system_event->process());
    }

    public function testRestrictedUsersAreRemovedFromAllUserGroupsWhenProjectBecomesPrivateWithoutRestrictedIfAllAdministratorsAreRestricted(): void
    {
        $system_event = $this->buildSystemEvent();

        $user_remover   = $this->createMock(UserRemover::class);
        $ugroup_manager = $this->createMock(\UGroupManager::class);
            $ugroup_manager,
        $project_manager = $this->createMock(ProjectManager::class);
        $project = $this->createMock(Project::class);
        $project->method('getID')->willReturn($this->project_id);
        $project_manager->method('getProject')->willReturn($project);
        $project->method('usesCVS')->willReturn(false);
        $project->method('usesSVN')->willReturn(false);
        $project->method('getAccess')->willReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
        $restricted_admin     = UserTestBuilder::aRestrictedUser()->build();
        $restricted_member    = UserTestBuilder::aRestrictedUser()->withId($restricted_member_id)->build();
        $member               = UserTestBuilder::anActiveUser()->build();

        $project->method('getAdmins')->willReturn([$restricted_admin]);
        $project->method('getMembers')->willReturn([$restricted_member, $member]);

        $user_remover->expects(self::once())->method('forceRemoveAdminRestrictedUserFromProject')->with($project, $restricted_admin);
        $user_remover->expects(self::once())->method('removeUserFromProject')->with($this->project_id, $restricted_member_id);
        $ugroup_with_restricted         = $this->createMock(ProjectUGroup::class);
        $ugroup_with_restricted->method('getMembers')->willReturn([$restricted_user_in_ugroup_only, $member]);
        $ugroup_with_restricted->expects(self::once())->method('removeUser')->with(
            self::callback(
        );
        $ugroup_without_restricted = $this->createMock(ProjectUGroup::class);
        $ugroup_without_restricted->method('getMembers')->willReturn([$member]);
        $ugroup_manager->method('getStaticUGroups')->with($project)->willReturn(

    private function buildSystemEvent(): SystemEvent_PROJECT_IS_PRIVATE
    {
        return new SystemEvent_PROJECT_IS_PRIVATE(
            1,
            SystemEvent_PROJECT_IS_PRIVATE::TYPE_PROJECT_IS_PRIVATE,
            SystemEvent_PROJECT_IS_PRIVATE::APP_OWNER_QUEUE,
            $this->project_id . '::1',
            SystemEvent_PROJECT_IS_PRIVATE::PRIORITY_MEDIUM,
            SystemEvent_PROJECT_IS_PRIVATE::STATUS_NEW,
            '',
            '',
            '',
            ''
        );
    }