Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?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(),
);
}
}
10 changes: 9 additions & 1 deletion site-content/fr_FR/LC_MESSAGES/tuleap-core.po
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,14 @@ msgstr "Impossible d'enlever l'utilisateur du groupe"
msgid "Cannot remove users from bound groups"
msgstr "Impossible d'enlever des utilisateurs d'un group lié"

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."

msgid "Cannot update the requested dashboard."
msgstr "Impossible d'éditer le tableau de bord demandé."

Expand Down Expand Up @@ -2787,7 +2795,7 @@ msgid "Private archives"
msgstr "Archives privées"

msgid "Private incl. restricted"
msgstr "Privé incl. les utilisateurs restreints"
msgstr "Privé, incluant les utilisateurs restreints"

msgid "Private including restricted"
msgstr "Privé incluant les utilisateurs restreints"
Expand Down
5 changes: 5 additions & 0 deletions site-content/pt_BR/LC_MESSAGES/tuleap-core.po
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,11 @@ msgstr "Não é possível remover o usuário do grupo"
msgid "Cannot remove users from bound groups"
msgstr "Não é possível remover usuários de grupos vinculados"

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 ""

msgid "Cannot update the requested dashboard."
msgstr "Não é possível atualizar o painel solicitado."

Expand Down
2 changes: 1 addition & 1 deletion src/common/Config/ForgeConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ public static function areAnonymousAllowed()
return self::get(ForgeAccess::CONFIG) === ForgeAccess::ANONYMOUS;
}

public static function areRestrictedUsersAllowed()
public static function areRestrictedUsersAllowed(): bool
{
return self::get(ForgeAccess::CONFIG) === ForgeAccess::RESTRICTED;
}
Expand Down
37 changes: 37 additions & 0 deletions src/common/Project/Admin/ForceRemovalOfRestrictedAdministrator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?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,
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use Tuleap\Project\Admin\Navigation\HeaderNavigationDisplayer;
use Tuleap\Project\Admin\ProjectVisibilityPresenterBuilder;
use Tuleap\Project\Admin\ProjectVisibilityUserConfigurationPermissions;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
use Tuleap\Project\DescriptionFieldsFactory;
use Tuleap\Project\HierarchyDisplayer;
use Tuleap\Project\Icons\EmojiCodepointConverter;
Expand Down Expand Up @@ -126,6 +127,7 @@ public function __construct(
CSRFSynchronizerToken $csrf_token,
TemplateFactory $template_factory,
private ProjectIconRetriever $project_icon_retriever,
private readonly UpdateVisibilityChecker $update_visibility_checker,
) {
$this->description_fields_factory = $description_fields_factory;
$this->current_project = $current_project;
Expand Down Expand Up @@ -507,9 +509,21 @@ private function updateProjectVisibility(PFUser $user, Project $project, HTTPReq
if ($this->project_visibility_configuration->canUserConfigureProjectVisibility($user, $project)) {
if ($project->getAccess() !== $request->get('project_visibility')) {
if ($request->get('term_of_service')) {
$this->project_manager->setAccess($project, $request->get('project_visibility'));
$this->project_manager->clear($project->getID());
$this->ugroup_binding->reloadUgroupBindingInProject($project);
$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(),
);
}
} else {
$GLOBALS['Response']->addFeedback(Feedback::ERROR, _("Please accept term of service"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ public static function buildSelf(): self
new UserRemoverDao(),
$user_manager,
new ProjectHistoryDao(),
$ugroup_manager
$ugroup_manager,
new UserPermissionsDao(),
),
$event_manager,
$ugroup_manager,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ public static function buildSelf(): self
new UserRemoverDao(),
$user_manager,
new ProjectHistoryDao(),
$ugroup_manager
$ugroup_manager,
new UserPermissionsDao(),
),
UGroupRouter::getCSRFTokenSynchronizer()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@
namespace Tuleap\Project\Admin;

use Project;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;

final class ProjectVisibilityOptionsForPresenterGenerator
{
/**
* @psalm-return array<array{value: string, label: string, selected: string}>
* @psalm-return array<array{value: string, label: string, selected: string, disabled: bool, title: string}>
*/
public function generateVisibilityOptions(
bool $does_platform_allow_restricted_users,
UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
string $current_project_visibility,
): array {
if ($does_platform_allow_restricted_users) {
Expand All @@ -39,21 +41,29 @@ public function generateVisibilityOptions(
'value' => Project::ACCESS_PRIVATE_WO_RESTRICTED,
'label' => _('Private'),
'selected' => ($current_project_visibility === Project::ACCESS_PRIVATE_WO_RESTRICTED) ? 'selected = "selected"' : '',
'disabled' => ! $private_without_restricted_visibility_switch_status->canSwitch(),
'title' => ! $private_without_restricted_visibility_switch_status->canSwitch() ? $private_without_restricted_visibility_switch_status->getReason() : '',
],
[
'value' => Project::ACCESS_PRIVATE,
'label' => _('Private incl. restricted'),
'selected' => ($current_project_visibility === Project::ACCESS_PRIVATE) ? 'selected = "selected"' : '',
'disabled' => false,
'title' => '',
],
[
'value' => Project::ACCESS_PUBLIC,
'label' => _('Public'),
'selected' => ($current_project_visibility === Project::ACCESS_PUBLIC) ? 'selected = "selected"' : '',
'disabled' => false,
'title' => '',
],
[
'value' => Project::ACCESS_PUBLIC_UNRESTRICTED,
'label' => _('Public incl. restricted'),
'selected' => ($current_project_visibility === Project::ACCESS_PUBLIC_UNRESTRICTED) ? 'selected = "selected"' : '',
'disabled' => false,
'title' => '',
],
];
}
Expand All @@ -62,11 +72,15 @@ public function generateVisibilityOptions(
'value' => Project::ACCESS_PRIVATE,
'label' => _('Private'),
'selected' => ($current_project_visibility === Project::ACCESS_PRIVATE) ? 'selected = "selected"' : '',
'disabled' => false,
'title' => '',
],
[
'value' => Project::ACCESS_PUBLIC,
'label' => _('Public'),
'selected' => ($current_project_visibility === Project::ACCESS_PUBLIC) ? 'selected = "selected"' : '',
'disabled' => false,
'title' => '',
],
];
}
Expand Down
12 changes: 8 additions & 4 deletions src/common/Project/Admin/ProjectVisibilityPresenter.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

use BaseLanguage;
use Codendi_HTMLPurifier;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;

class ProjectVisibilityPresenter
{
Expand Down Expand Up @@ -54,12 +55,13 @@ class ProjectVisibilityPresenter

public function __construct(
BaseLanguage $language,
$platform_allows_restricted,
$project_visibility,
bool $platform_allows_restricted,
UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
string $project_visibility,
int $number_of_restricted_users_in_project,
ProjectVisibilityOptionsForPresenterGenerator $project_visibility_options_generator,
) {
$this->platform_allows_restricted = (bool) $platform_allows_restricted;
$this->platform_allows_restricted = $platform_allows_restricted;
$this->restricted_warning_message = $language->getText(
'project_admin_editgroupinfo',
'restricted_warning'
Expand All @@ -76,10 +78,12 @@ public function __construct(
$this->project_visibility_label = _('Project visibility');
$this->accept_tos_message = _("Please accept term of service");

$this->options = $project_visibility_options_generator->generateVisibilityOptions(
$this->options = $project_visibility_options_generator->generateVisibilityOptions(
$this->platform_allows_restricted,
$private_without_restricted_visibility_switch_status,
$project_visibility
);

$this->number_of_restricted_users_in_project = $number_of_restricted_users_in_project;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@

use ForgeConfig;
use HTTPRequest;
use Project;
use ProjectTruncatedEmailsPresenter;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
use Tuleap\Project\ProjectAccessPresenter;

class ProjectVisibilityPresenterBuilder
Expand Down Expand Up @@ -54,6 +56,7 @@ public function __construct(
ServicesUsingTruncatedMailRetriever $service_truncated_mails_retriever,
RestrictedUsersProjectCounter $restricted_users_project_counter,
ProjectVisibilityOptionsForPresenterGenerator $project_visibility_options_generator,
private readonly UpdateVisibilityChecker $update_visibility_checker,
) {
$this->project_visibility_configuration = $project_visibility_configuration;
$this->service_truncated_mails_retriever = $service_truncated_mails_retriever;
Expand All @@ -68,6 +71,10 @@ public function build(HTTPRequest $request)
$visibility_presenter = new ProjectVisibilityPresenter(
$GLOBALS['Language'],
ForgeConfig::areRestrictedUsersAllowed(),
$this->update_visibility_checker->canUpdateVisibilityRegardingRestrictedUsers(
$project,
Project::ACCESS_PRIVATE_WO_RESTRICTED,
),
$project->getAccess(),
$this->restricted_users_project_counter->getNumberOfRestrictedUsersInProject($project),
$this->project_visibility_options_generator
Expand Down
68 changes: 68 additions & 0 deletions src/common/Project/Admin/Visibility/UpdateVisibilityChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?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;
}
}
52 changes: 52 additions & 0 deletions src/common/Project/Admin/Visibility/UpdateVisibilityStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?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;
}
}
4 changes: 2 additions & 2 deletions src/common/Project/Project.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -517,9 +517,9 @@ private function getUGroupManager()
}

/**
* @return array of User admin of the project
* @return PFUser[] of User admin of the project
*/
public function getAdmins(?UGroupManager $ugm = null)
public function getAdmins(?UGroupManager $ugm = null): array
{
if (is_null($ugm)) {
$ugm = $this->getUGroupManager();
Expand Down
4 changes: 3 additions & 1 deletion src/common/Project/ProjectXMLImporter.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectMemberAdder;
use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectMemberAdderWithoutStatusCheckAndNotifications;
use Tuleap\Project\UGroups\SynchronizedProjectMembershipDao;
use Tuleap\Project\UserPermissionsDao;
use Tuleap\Project\UserRemover;
use Tuleap\Project\UserRemoverDao;
use Tuleap\Project\XML\Import\ArchiveInterface;
Expand Down Expand Up @@ -193,7 +194,8 @@ public static function build(\User\XML\Import\IFindUserFromXMLReference $user_fi
new UserRemoverDao(),
$user_manager,
new ProjectHistoryDao(),
new UGroupManager()
new UGroupManager(),
new UserPermissionsDao(),
),
ProjectMemberAdderWithoutStatusCheckAndNotifications::build(),
$project_creator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public function __construct(
array $external_templates,
) {
$this->tuleap_templates = json_encode($tuleap_templates);
$this->are_restricted_users_allowed = (bool) ForgeConfig::areRestrictedUsersAllowed();
$this->are_restricted_users_allowed = ForgeConfig::areRestrictedUsersAllowed();
$this->project_default_visibility = $project_default_visibility;
$this->projects_must_be_approved = (bool) ForgeConfig::get(
ProjectManager::CONFIG_PROJECT_APPROVAL,
Expand Down
8 changes: 3 additions & 5 deletions src/common/Project/UGroupManager.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -604,10 +604,7 @@ private function removeUserFromUserGroup(ProjectUGroup $user_group, PFUser $user
}
}

/**
* @return UserRemover
*/
private function getUserRemover()
private function getUserRemover(): UserRemover
{
return new UserRemover(
ProjectManager::instance(),
Expand All @@ -616,7 +613,8 @@ private function getUserRemover()
new UserRemoverDao(),
UserManager::instance(),
new ProjectHistoryDao(),
new UGroupManager()
new UGroupManager(),
new UserPermissionsDao(),
);
}
}
30 changes: 29 additions & 1 deletion src/common/Project/UserRemover.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
use EventManager;
use ArtifactTypeFactory;
use Feedback;
use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectAdminHistoryEntry;
use UserManager;
use ProjectHistoryDao;
use Project;
Expand Down Expand Up @@ -74,6 +76,7 @@ public function __construct(
UserManager $user_manager,
ProjectHistoryDao $project_history_dao,
UGroupManager $ugroup_manager,
private readonly UserPermissionsDao $user_permissions_dao,
) {
$this->project_manager = $project_manager;
$this->event_manager = $event_manager;
Expand All @@ -84,11 +87,36 @@ public function __construct(
$this->ugroup_manager = $ugroup_manager;
}

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

public function removeUserFromProject($project_id, $user_id, $admin_action = true)
{
$project = $this->getProject($project_id);

if (! $this->dao->removeUserFromProject($project_id, $user_id)) {
if (! $this->dao->removeNonAdminUserFromProject($project_id, $user_id)) {
$GLOBALS['Response']->addFeedback(
Feedback::ERROR,
$GLOBALS['Language']->getText('project_admin_index', 'user_not_removed')
Expand Down
2 changes: 1 addition & 1 deletion src/common/Project/UserRemoverDao.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

class UserRemoverDao extends DataAccessObject
{
public function removeUserFromProject($project_id, $user_id)
public function removeNonAdminUserFromProject($project_id, $user_id): bool
{
$project_id = $this->da->escapeInt($project_id);
$user_id = $this->da->escapeInt($user_id);
Expand Down
4 changes: 3 additions & 1 deletion src/common/Request/RouteCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
use Tuleap\Project\Service\EditController;
use Tuleap\Project\Service\IndexController;
use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectMemberAdderWithStatusCheckAndNotifications;
use Tuleap\Project\UserPermissionsDao;
use Tuleap\REST\BasicAuthentication;
use Tuleap\REST\RESTCurrentUserMiddleware;
use Tuleap\REST\TuleapRESTCORSMiddleware;
Expand Down Expand Up @@ -583,7 +584,8 @@ public static function postAccountRemoveFromProject(): RemoveFromProjectControll
new \Tuleap\Project\UserRemoverDao(),
$user_manager,
new ProjectHistoryDao(),
new UGroupManager()
new UGroupManager(),
new UserPermissionsDao(),
),
new ProjectAdministratorsIncludingDelegationDAO(),
new SapiEmitter()
Expand Down
2 changes: 1 addition & 1 deletion src/common/User/SwitchToPresenterBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function build(CurrentUserWithLoggedInInformation $current_user): ?Switch

return new SwitchToPresenter(
$this->project_presenters_builder->getProjectPresenters($user),
(bool) \ForgeConfig::areRestrictedUsersAllowed(),
\ForgeConfig::areRestrictedUsersAllowed(),
(bool) \ForgeConfig::get('sys_use_trove'),
$user->isAlive() && ! $user->isRestricted(),
$this->search_form_presenter_builder->build(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use ForgeConfig;
use Tuleap\Admin\ProjectCreationNavBarPresenter;
use Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;

class ProjectVisibilityConfigPresenter
{
Expand Down Expand Up @@ -63,6 +64,7 @@ public function __construct(
$this->send_mail_on_project_visibility_change = (bool) ForgeConfig::get(ProjectVisibilityConfigManager::SEND_MAIL_ON_PROJECT_VISIBILITY_CHANGE);
$this->default_project_visibility_options = $project_visibility_options_generator->generateVisibilityOptions(
ForgeConfig::areRestrictedUsersAllowed(),
UpdateVisibilityStatus::buildStatusSwitchIsAllowed(),
$current_default_project_visibility_retriever
);
}
Expand Down
7 changes: 5 additions & 2 deletions src/common/system_event/SystemEventManager.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

use Tuleap\admin\ProjectEdit\ProjectStatusUpdate;
use Tuleap\Project\UserPermissionsDao;
use Tuleap\Project\UserRemover;
use Tuleap\Project\UserRemoverDao;
use Tuleap\SVNCore\Event\UpdateProjectAccessFilesScheduler;
Expand Down Expand Up @@ -488,7 +489,8 @@ public function getInstanceFromRow($row)
new UserRemoverDao(),
$user_manager,
new ProjectHistoryDao(),
new UGroupManager()
new UGroupManager(),
new UserPermissionsDao(),
),
];
break;
Expand Down Expand Up @@ -532,7 +534,8 @@ public function getInstanceFromRow($row)
new UserRemoverDao(),
UserManager::instance(),
new ProjectHistoryDao(),
$ugroup_manager
$ugroup_manager,
new UserPermissionsDao(),
),
$ugroup_manager,
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,11 @@

/**
* System Event classes
*
*/
class SystemEvent_PROJECT_IS_PRIVATE extends SystemEvent
{
/**
* @var UserRemover
*/
private $user_remover;
/**
* @var UGroupManager
*/
private $ugroup_manager;
private UserRemover $user_remover;
private UGroupManager $ugroup_manager;

public function injectDependencies(
UserRemover $user_remover,
Expand Down Expand Up @@ -115,9 +108,23 @@ public function process()

private function cleanRestrictedUsersIfNecessary(Project $project): void
{
if (! ForgeConfig::areRestrictedUsersAllowed() || $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED) {
if (
! ForgeConfig::areRestrictedUsersAllowed() ||
$project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED
) {
return;
}

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

$project_members = $project->getMembers();
foreach ($project_members as $project_member) {
if ($project_member->isRestricted()) {
Expand Down
6 changes: 5 additions & 1 deletion src/templates/project/project_visibility.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
</label>
<select class="tlp-select" name="project_visibility" id="project_visibility" data-test="project_visibility">
{{# options }}
<option value="{{ value }}" {{ selected }}>{{ label }}</option>
<option value="{{ value }}"
{{# disabled }} disabled {{/ disabled }}
{{# title }} title="{{ . }}" {{/ title }}
{{ selected }}
>{{ label }}</option>
{{/ options }}
</select>
</div>
Expand Down
6 changes: 4 additions & 2 deletions src/www/project/admin/editgroupinfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
$project_visibility_configuration,
$service_truncated_mails_retriever,
new RestrictedUsersProjectCounter(new UserDao()),
new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator()
new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator(),
new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
);

$csrf_token = new CSRFSynchronizerToken($request->getFromServer('REQUEST_URI'));
Expand All @@ -84,7 +85,8 @@
$trove_cat_link_dao,
$csrf_token,
TemplateFactory::build(),
$project_icons_retriever
$project_icons_retriever,
new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
);

$project_details_router = new ProjectDetailsRouter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,33 +23,58 @@
namespace Tuleap\Project\Admin;

use Project;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;

final class ProjectVisibilityOptionsForPresenterGeneratorTest extends \Tuleap\Test\PHPUnit\TestCase
{
public function testGeneratedOptionsWhenRestrictedUsersAreNotAllowed(): void
{
$generator = new ProjectVisibilityOptionsForPresenterGenerator();
$options = $generator->generateVisibilityOptions(false, Project::ACCESS_PUBLIC);
$options = $generator->generateVisibilityOptions(false, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PUBLIC);

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

$this->assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
self::assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
}

public function testGeneratedOptionsWhenRestrictedUsersAreAllowed(): void
{
$generator = new ProjectVisibilityOptionsForPresenterGenerator();
$options = $generator->generateVisibilityOptions(true, Project::ACCESS_PRIVATE_WO_RESTRICTED);
$options = $generator->generateVisibilityOptions(true, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PRIVATE_WO_RESTRICTED);

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

$this->assertEquals(Project::ACCESS_PRIVATE_WO_RESTRICTED, $this->getSelectedAccess($options));
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;
}

private function getAvailableAccesses(array $options): array
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?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;
}
}
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use CSRFSynchronizerToken;
use EventManager;
use Feedback;
use ForgeAccess;
use ForgeConfig;
use HTTPRequest;
use Mockery;
Expand All @@ -39,9 +40,11 @@
use Tuleap\Project\Admin\ProjectDetails\ProjectDetailsDAO;
use Tuleap\Project\Admin\ProjectVisibilityPresenterBuilder;
use Tuleap\Project\Admin\ProjectVisibilityUserConfigurationPermissions;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
use Tuleap\Project\DescriptionFieldsFactory;
use Tuleap\Project\Icons\ProjectIconRetriever;
use Tuleap\Project\Registration\Template\TemplateFactory;
use Tuleap\Test\Builders\UserTestBuilder;
use Tuleap\TroveCat\TroveCatLinkDao;
use UGroupBinding;

Expand Down Expand Up @@ -115,7 +118,8 @@ protected function setUp(): void
$trove_cat_link_dao,
$this->csrf_token,
Mockery::mock(TemplateFactory::class),
new ProjectIconRetriever()
new ProjectIconRetriever(),
new UpdateVisibilityChecker($this->event_manager),
);

$GLOBALS['Response'] = Mockery::mock(BaseLayout::class);
Expand Down Expand Up @@ -202,6 +206,54 @@ public function testUpdateIsValidWhenDescriptionIsNotProvidedAndDescriptionIsNOT
$this->controller->update($request);
}

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

public function testItUpdatesProject(): void
{
ForgeConfig::set('feature_flag_project_icon_display', '1');
Expand Down
114 changes: 77 additions & 37 deletions tests/unit/common/Project/UserRemoverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,44 @@

require_once __DIR__ . '/../../../../src/www/include/exit.php';

use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PFUser;
use PHPUnit\Framework\MockObject\MockObject;
use Tuleap\GlobalLanguageMock;
use Tuleap\GlobalResponseMock;
use Tuleap\Test\Builders\ProjectTestBuilder;
use Tuleap\Test\Builders\UserTestBuilder;

final class UserRemoverTest extends \Tuleap\Test\PHPUnit\TestCase
{
use MockeryPHPUnitIntegration;
use GlobalResponseMock;
use GlobalLanguageMock;

private UserRemover $remover;
/**
* @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 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 \Project $project;
private PFUser $user;
private $tracker_v3;
private MockObject&\ArtifactType $tracker_v3;
private MockObject&UserPermissionsDao $user_permissions_dao;


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

$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->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->remover = new UserRemover(
$this->project_manager,
Expand All @@ -69,48 +68,89 @@ protected function setUp(): void
$this->dao,
$this->user_manager,
$this->project_history_dao,
$this->ugroup_manager
$this->ugroup_manager,
$this->user_permissions_dao,
);

$this->project = ProjectTestBuilder::aProject()->withId(101)->withUnixName("")->withAccess(\Project::ACCESS_PRIVATE)->build();
$this->user = new PFUser([
'language_id' => 'en',
'user_id' => 102,
]);
$this->tracker_v3 = \Mockery::spy(\ArtifactType::class);
$this->tracker_v3 = $this->createMock(\ArtifactType::class);
}

public function testItRemovesUserFromProjectMembersAndUgroups(): void
{
$project_id = 101;
$user_id = 102;

$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->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->shouldReceive('groupAddHistory')->once();
$this->event_manager->shouldReceive('processEvent')->twice();
$this->project_history_dao->expects(self::once())->method('groupAddHistory');
$this->event_manager->expects(self::exactly(2))->method('processEvent');

$this->remover->removeUserFromProject($project_id, $user_id);
}

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

public function testItDoesNothingIfTheUserIsNotRemovedFromProjectMembers(): void
{
$project_id = 101;
$user_id = 102;

$this->project_manager->shouldReceive('getProject')->with(101)->andReturns($this->project);
$this->project_manager->method('getProject')->with(101)->willReturn($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();
$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');

$this->remover->removeUserFromProject($project_id, $user_id);
}
Expand Down
155 changes: 113 additions & 42 deletions tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
use EventManager;
use ForgeAccess;
use ForgeConfig;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PFUser;
use Project;
use ProjectManager;
Expand All @@ -40,9 +38,26 @@

final class SystemEventPROJECTISPRIVATETest extends \Tuleap\Test\PHPUnit\TestCase
{
use MockeryPHPUnitIntegration;
use ForgeConfigSandbox;

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

protected function tearDown(): void
{
ProjectManager::clearInstance();
Expand All @@ -52,66 +67,122 @@ protected function tearDown(): void

public function testRestrictedUsersAreRemovedFromAllUserGroupsWhenProjectBecomesPrivateWithoutRestricted(): void
{
$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,
'',
'',
'',
''
$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]
);

$user_remover = Mockery::mock(UserRemover::class);
$ugroup_manager = Mockery::mock(\UGroupManager::class);
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);
$system_event->injectDependencies(
$user_remover,
$ugroup_manager
$ugroup_manager,
);

$project_manager = Mockery::mock(ProjectManager::class);
$project_manager = $this->createMock(ProjectManager::class);
ProjectManager::setInstance($project_manager);
$project = Mockery::mock(Project::class);
$project->shouldReceive('getID')->andReturn($project_id);
$project_manager->shouldReceive('getProject')->andReturn($project);
$project = $this->createMock(Project::class);
$project->method('getID')->willReturn($this->project_id);
$project_manager->method('getProject')->willReturn($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));
$project->method('usesCVS')->willReturn(false);
$project->method('usesSVN')->willReturn(false);

ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
$project->shouldReceive('getAccess')->andReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
$project->method('getAccess')->willReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);

$restricted_member = Mockery::mock(PFUser::class);
$restricted_member->shouldReceive('isRestricted')->andReturn(true);
$restricted_member_id = 456;
$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();
$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);

$restricted_user_in_ugroup_only = UserTestBuilder::aRestrictedUser()->build();
$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(
$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,
\Mockery::on(
self::callback(
function (PFUser $user) {
return (int) $user->getId() === 0;
}
)
)->once();
$ugroup_without_restricted = Mockery::mock(ProjectUGroup::class);
$ugroup_without_restricted->shouldReceive('getMembers')->andReturn([$member]);
$ugroup_manager->shouldReceive('getStaticUGroups')->with($project)->andReturn(
);
$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());
}

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