diff --git a/lib/Api/v1/Circles.php b/lib/Api/v1/Circles.php index 11a018215..c11a9f311 100644 --- a/lib/Api/v1/Circles.php +++ b/lib/Api/v1/Circles.php @@ -42,6 +42,7 @@ class Circles { public const LEVEL_NONE = Member::LEVEL_NONE; public const LEVEL_MEMBER = Member::LEVEL_MEMBER; + public const LEVEL_VISITOR = Member::LEVEL_VISITOR; public const LEVEL_MODERATOR = Member::LEVEL_MODERATOR; public const LEVEL_ADMIN = Member::LEVEL_ADMIN; public const LEVEL_OWNER = Member::LEVEL_OWNER; diff --git a/lib/AppInfo/Capabilities.php b/lib/AppInfo/Capabilities.php index 0190d2b53..8443a2ed1 100644 --- a/lib/AppInfo/Capabilities.php +++ b/lib/AppInfo/Capabilities.php @@ -145,6 +145,7 @@ private function getCapabilitiesCircleConfig(): array { private function getCapabilitiesMemberConstants(): array { return [ 'level' => [ + Member::LEVEL_VISITOR => $this->l10n->t('Visitor'), Member::LEVEL_MEMBER => $this->l10n->t('Member'), Member::LEVEL_MODERATOR => $this->l10n->t('Moderator'), Member::LEVEL_ADMIN => $this->l10n->t('Admin'), diff --git a/lib/CirclesQueryHelper.php b/lib/CirclesQueryHelper.php index 375c59c77..daf3bdf2e 100644 --- a/lib/CirclesQueryHelper.php +++ b/lib/CirclesQueryHelper.php @@ -127,10 +127,14 @@ public function limitToMemberships( string $alias, string $field, IFederatedUser $federatedUser, + string $levelAlias = '', ): void { $this->queryBuilder->setDefaultSelectAlias($alias); $expr = $this->queryBuilder->expr(); $aliasMembership = $this->queryBuilder->generateAlias(CoreQueryBuilder::HELPER, CoreQueryBuilder::MEMBERSHIPS, $options); + if ($levelAlias !== '') { + $this->queryBuilder->selectAlias($aliasMembership . '.level', $levelAlias); + } $this->queryBuilder->innerJoin( $alias, diff --git a/lib/Controller/LocalController.php b/lib/Controller/LocalController.php index 5c2125f9c..ea6db3634 100644 --- a/lib/Controller/LocalController.php +++ b/lib/Controller/LocalController.php @@ -154,9 +154,7 @@ public function memberAdd(string $circleId, string $userId, int $type): DataResp $this->setCurrentFederatedUser(); $memberUser = $this->permissionService->userMustBeMember($this->userSession->getUser()->getUID(), $circleId); - if (!$this->circleService->getCircle($circleId)->isConfig(Circle::CFG_FRIEND)) { - $this->permissionService->memberMustBeAtLeastModerator($memberUser); - } + $this->mustBeAllowedToAddMembers($memberUser, $circleId); if ($type === Member::TYPE_CONTACT) { $currentUser = $this->federatedUserService->getCurrentUser(); @@ -192,9 +190,7 @@ public function membersAdd(string $circleId, array $members): DataResponse { $this->setCurrentFederatedUser(); $memberUser = $this->permissionService->userMustBeMember($this->userSession->getUser()->getUID(), $circleId); - if (!$this->circleService->getCircle($circleId)->isConfig(Circle::CFG_FRIEND)) { - $this->permissionService->memberMustBeAtLeastModerator($memberUser); - } + $this->mustBeAllowedToAddMembers($memberUser, $circleId); $federatedUsers = []; foreach ($members as $member) { @@ -308,9 +304,7 @@ public function memberConfirm(string $circleId, string $memberId): DataResponse $this->setCurrentFederatedUser(); $memberUser = $this->permissionService->userMustBeMember($this->userSession->getUser()->getUID(), $circleId); - if (!$this->circleService->getCircle($circleId)->isConfig(Circle::CFG_FRIEND)) { - $this->permissionService->memberMustBeAtLeastModerator($memberUser); - } + $this->mustBeAllowedToAddMembers($memberUser, $circleId); $member = $this->memberService->getMemberById($memberId, $circleId); $federatedUser = new FederatedUser(); @@ -712,6 +706,13 @@ public function joinInvitation(string $invitationCode): DataResponse { } } + private function mustBeAllowedToAddMembers(Member $memberUser, string $circleId): void { + if (!$this->circleService->getCircle($circleId)->isConfig(Circle::CFG_FRIEND) + || $memberUser->getLevel() === Member::LEVEL_VISITOR) { + $this->permissionService->memberMustBeAtLeastModerator($memberUser); + } + } + /** * @throws FederatedUserException * @throws FederatedUserNotFoundException diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 93ff586c2..417e96f40 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -168,7 +168,7 @@ public function getCircles(?IFederatedUser $initiator, CircleProbe $probe): arra $qb->filterCircles(CoreQueryBuilder::CIRCLE, $probe); if (!is_null($initiator)) { $qb->limitToInitiator(CoreQueryBuilder::CIRCLE, $initiator); - $qb->orderBy($qb->generateAlias(CoreQueryBuilder::CIRCLE, CoreQueryBuilder::INITIATOR) . '.level', 'desc'); + $qb->orderByMemberLevel($qb->generateAlias(CoreQueryBuilder::CIRCLE, CoreQueryBuilder::INITIATOR) . '.level'); $qb->addOrderBy(CoreQueryBuilder::CIRCLE . '.display_name', 'asc'); $qb->leftJoinCircleInvitation(CoreQueryBuilder::CIRCLE); } diff --git a/lib/Db/CoreQueryBuilder.php b/lib/Db/CoreQueryBuilder.php index 9630b4324..e90a15bbb 100644 --- a/lib/Db/CoreQueryBuilder.php +++ b/lib/Db/CoreQueryBuilder.php @@ -223,6 +223,21 @@ public function __construct() { $this->configService = Server::get(ConfigService::class); } + public function orderByMemberLevel(string $column): void { + $ranks = Member::LEVEL_RANKS; + arsort($ranks); + + $cases = []; + foreach ($ranks as $level => $rank) { + $cases[] = 'WHEN ' . $level . ' THEN ' . $rank; + } + + $this->orderBy( + $this->createFunction('CASE ' . $column . ' ' . implode(' ', $cases) . ' ELSE 0 END'), + 'desc' + ); + } + /** * @param IFederatedModel $federatedModel * @@ -693,10 +708,16 @@ public function filterDirectMembership(string $aliasMember, Member $member): voi $this->limitToInstance($this->getInstance($member)); - if ($member->getLevel() > 0) { + $minLevel = $member->getLevel(); + if ($minLevel === Member::LEVEL_VISITOR) { + // Member ranks above Visitor despite having a lower numeric value. + $minLevel = Member::LEVEL_MEMBER; + } + + if ($minLevel > 0) { $andX[] = $expr->gte( $aliasMember . '.level', - $this->createNamedParameter($member->getLevel(), IQueryBuilder::PARAM_INT) + $this->createNamedParameter($minLevel, IQueryBuilder::PARAM_INT) ); } @@ -885,6 +906,10 @@ public function innerJoinMembership( // limit on membership level if requested $minLevel = $probe?->getMinimumLevel() ?? 0; + if ($minLevel === Member::LEVEL_VISITOR) { + // Member ranks above Visitor despite having a lower numeric value. + $minLevel = Member::LEVEL_MEMBER; + } if ($minLevel > Member::LEVEL_MEMBER) { $on->add($this->exprGt('level', $minLevel, true, $aliasMembership)); } @@ -1010,6 +1035,10 @@ public function limitToMembersByInheritance(string $alias, string $singleId, int $expr = $this->expr(); $aliasMembership = $this->generateAlias($alias, self::MEMBERSHIPS); $this->andWhere($expr->eq($aliasMembership . '.circle_id', $this->createNamedParameter($singleId))); + if ($level === Member::LEVEL_VISITOR) { + // Member ranks above Visitor despite having a lower numeric value. + $level = Member::LEVEL_MEMBER; + } if ($level > 1) { $this->andWhere( $expr->gte( diff --git a/lib/Db/MemberRequest.php b/lib/Db/MemberRequest.php index df29cd0db..54337f4b4 100644 --- a/lib/Db/MemberRequest.php +++ b/lib/Db/MemberRequest.php @@ -303,7 +303,7 @@ public function getMembers( $qb->andWhere($qb->expr()->eq($qb->getDefaultSelectAlias() . '.level', $qb->createNamedParameter($role))); } - $qb->orderBy($qb->getDefaultSelectAlias() . '.level', 'desc'); + $qb->orderByMemberLevel($qb->getDefaultSelectAlias() . '.level'); $qb->addOrderBy($qb->getDefaultSelectAlias() . '.cached_name', 'asc'); return $this->getItemsFromRequest($qb); diff --git a/lib/Db/MembershipRequest.php b/lib/Db/MembershipRequest.php index 5f9fcc080..ec6618168 100644 --- a/lib/Db/MembershipRequest.php +++ b/lib/Db/MembershipRequest.php @@ -119,6 +119,10 @@ public function getInherited(string $singleId, int $level = 0): array { $qb->limitToCircleId($singleId); $qb->leftJoinCircleConfig(self::TABLE_MEMBERSHIP); + if ($level === Member::LEVEL_VISITOR) { + // Member ranks above Visitor despite having a lower numeric value. + $level = Member::LEVEL_MEMBER; + } if ($level > 1) { $expr = $qb->expr(); $qb->andWhere($expr->gte('level', $qb->createNamedParameter($level, IQueryBuilder::PARAM_INT))); @@ -137,6 +141,10 @@ public function getInheritedExcludingCircles(string $singleId, int $level = 0): $qb = $this->getMembershipSelectSql(); $qb->limitToCircleId($singleId); + if ($level === Member::LEVEL_VISITOR) { + // Member ranks above Visitor despite having a lower numeric value. + $level = Member::LEVEL_MEMBER; + } $expr = $qb->expr(); if ($level > 1) { $qb->andWhere($expr->gte('level', $qb->createNamedParameter($level, IQueryBuilder::PARAM_INT))); diff --git a/lib/FederatedItems/CircleConfig.php b/lib/FederatedItems/CircleConfig.php index 3f0115193..4b27de30c 100644 --- a/lib/FederatedItems/CircleConfig.php +++ b/lib/FederatedItems/CircleConfig.php @@ -10,6 +10,7 @@ namespace OCA\Circles\FederatedItems; use OCA\Circles\Db\CircleRequest; +use OCA\Circles\Db\MemberRequest; use OCA\Circles\Exceptions\FederatedItemBadRequestException; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\RequestBuilderException; @@ -17,6 +18,7 @@ use OCA\Circles\Model\Circle; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Helpers\MemberHelper; +use OCA\Circles\Model\Member; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\PermissionService; use OCA\Circles\Tools\Traits\TDeserialize; @@ -41,6 +43,7 @@ public function __construct( private CircleRequest $circleRequest, private PermissionService $permissionService, private ConfigService $configService, + private MemberRequest $memberRequest, ) { } @@ -121,6 +124,14 @@ public function verify(FederatedEvent $event): void { throw new FederatedItemBadRequestException('Configuration value is not valid'); } + if (!$circle->isConfig(Circle::CFG_FEDERATED) + && $circle->isConfig(Circle::CFG_FEDERATED, $config) + && $this->memberRequest->getMembers( + $circle->getSingleId(), limit: 1, role: Member::LEVEL_VISITOR + ) !== []) { + throw new FederatedItemBadRequestException('Remove Visitor roles before enabling federation'); + } + $new = clone $circle; $new->setConfig($config); $this->permissionService->confirmAllowedCircleTypes($new, $circle); diff --git a/lib/FederatedItems/CircleLeave.php b/lib/FederatedItems/CircleLeave.php index b82ca073f..8741b70f6 100644 --- a/lib/FederatedItems/CircleLeave.php +++ b/lib/FederatedItems/CircleLeave.php @@ -185,9 +185,10 @@ private function selectNewOwner(Circle $circle): Member { continue; } - if ($member->getLevel() > $newOwner->getLevel()) { + $levelComparison = Member::compareLevels($member->getLevel(), $newOwner->getLevel()); + if ($levelComparison > 0) { $newOwner = $member; - } elseif ($member->getLevel() === $newOwner->getLevel() + } elseif ($levelComparison === 0 && ($member->getJoined() < $newOwner->getJoined() || ($this->configService->isLocalInstance($member->getInstance()) && !$this->configService->isLocalInstance($newOwner->getInstance())) diff --git a/lib/FederatedItems/MassiveMemberAdd.php b/lib/FederatedItems/MassiveMemberAdd.php index d12b07cf9..4c09c3304 100644 --- a/lib/FederatedItems/MassiveMemberAdd.php +++ b/lib/FederatedItems/MassiveMemberAdd.php @@ -37,7 +37,8 @@ public function verify(FederatedEvent $event): void { $initiator = $circle->getInitiator(); $initiatorHelper = new MemberHelper($initiator); - if (!$circle->isConfig(Circle::CFG_FRIEND)) { + if (!$circle->isConfig(Circle::CFG_FRIEND) + || $initiator->getLevel() === Member::LEVEL_VISITOR) { $initiatorHelper->mustBeModerator(); } diff --git a/lib/FederatedItems/MemberLevel.php b/lib/FederatedItems/MemberLevel.php index 888db5c83..b387202f3 100644 --- a/lib/FederatedItems/MemberLevel.php +++ b/lib/FederatedItems/MemberLevel.php @@ -13,6 +13,7 @@ use OCA\Circles\Exceptions\FederatedItemBadRequestException; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\MemberLevelException; +use OCA\Circles\Exceptions\ParseMemberLevelException; use OCA\Circles\Exceptions\RequestBuilderException; use OCA\Circles\IFederatedItem; use OCA\Circles\IFederatedItemHighSeverity; @@ -70,11 +71,18 @@ public function verify(FederatedEvent $event): void { throw new FederatedItemBadRequestException('This level cannot be edited'); } - if ($level === 0) { - // TODO check all level + try { + $level = Member::parseLevelInt($level); + } catch (ParseMemberLevelException) { throw new FederatedItemBadRequestException('invalid level'); } + if ($level === Member::LEVEL_VISITOR + && ($circle->isConfig(Circle::CFG_FEDERATED) + || !$this->configService->isLocalInstance($member->getInstance()))) { + throw new FederatedItemBadRequestException('Visitor requires a local team and member'); + } + if ($member->getLevel() === $level) { throw new FederatedItemBadRequestException('This member already have the selected level'); } @@ -137,7 +145,7 @@ private function verifyMemberLevel(Member $member, Member $initiator, int $level $initiatorHelper = new MemberHelper($initiator); $memberHelper = new MemberHelper($member); - $memberHelper->mustBeMember(); + $memberHelper->mustHaveLevelAboveOrEqual(Member::LEVEL_VISITOR); $memberHelper->cannotBeOwner(); $initiatorHelper->mustBeModerator(); @@ -167,7 +175,7 @@ private function verifySwitchOwner(Member $member, Member $initiator) { $memberHelper = new MemberHelper($member); $initiatorHelper->mustBeOwner(); - $memberHelper->mustBeMember(); + $memberHelper->mustHaveLevelAboveOrEqual(Member::LEVEL_VISITOR); $memberHelper->cannotBeOwner(); } } diff --git a/lib/FederatedItems/SingleMemberAdd.php b/lib/FederatedItems/SingleMemberAdd.php index 7e122cd20..fc473cd04 100644 --- a/lib/FederatedItems/SingleMemberAdd.php +++ b/lib/FederatedItems/SingleMemberAdd.php @@ -104,7 +104,8 @@ public function verify(FederatedEvent $event): void { $initiator = $circle->getInitiator(); $initiatorHelper = new MemberHelper($initiator); - if (!$circle->isConfig(Circle::CFG_FRIEND)) { + if (!$circle->isConfig(Circle::CFG_FRIEND) + || $initiator->getLevel() === Member::LEVEL_VISITOR) { $initiatorHelper->mustBeModerator(); } diff --git a/lib/Model/BaseMember.php b/lib/Model/BaseMember.php index 1fe6326cb..84847a794 100644 --- a/lib/Model/BaseMember.php +++ b/lib/Model/BaseMember.php @@ -20,6 +20,7 @@ class BaseMember implements JsonSerializable { public const LEVEL_NONE = 0; public const LEVEL_MEMBER = 1; + public const LEVEL_VISITOR = 2; public const LEVEL_MODERATOR = 4; public const LEVEL_ADMIN = 8; public const LEVEL_OWNER = 9; @@ -386,6 +387,7 @@ public function getLevelString() { return match ($this->getLevel()) { self::LEVEL_NONE => 'Not a member', self::LEVEL_MEMBER => 'Member', + self::LEVEL_VISITOR => 'Visitor', self::LEVEL_MODERATOR => 'Moderator', self::LEVEL_ADMIN => 'Admin', self::LEVEL_OWNER => 'Owner', diff --git a/lib/Model/Helpers/MemberHelper.php b/lib/Model/Helpers/MemberHelper.php index 51047df61..2ddebf2fd 100644 --- a/lib/Model/Helpers/MemberHelper.php +++ b/lib/Model/Helpers/MemberHelper.php @@ -65,6 +65,8 @@ public function __call(string $name, array $arguments): void { } /** + * Rank-based: unlike CircleProbe::mustBeMember(), mustBeMember() here rejects Visitors. + * * @param string $levelString * @param array $arguments * @@ -95,7 +97,7 @@ private function cannotBe(string $levelString, array $arguments): void { throw new MemberHelperException('method ' . $levelString . ' not found'); } - if ($this->member->getLevel() >= $level) { + if (Member::compareLevels($this->member->getLevel(), $level) >= 0) { throw new MemberLevelException('Member cannot be ' . $levelString); } } @@ -106,7 +108,7 @@ private function cannotBe(string $levelString, array $arguments): void { * @throws MemberLevelException */ public function mustHaveLevelAbove(int $level) { - if ($this->member->getLevel() <= $level) { + if (Member::compareLevels($this->member->getLevel(), $level) <= 0) { throw new MemberLevelException('Insufficient rights'); } } @@ -117,7 +119,7 @@ public function mustHaveLevelAbove(int $level) { * @throws MemberLevelException */ public function mustHaveLevelAboveOrEqual(int $level) { - if ($this->member->getLevel() < $level) { + if (Member::compareLevels($this->member->getLevel(), $level) < 0) { throw new MemberLevelException('Insufficient rights'); } } @@ -128,7 +130,7 @@ public function mustHaveLevelAboveOrEqual(int $level) { * @throws MemberLevelException */ public function mustHaveLevelEqualOrAbove(int $level) { - if ($this->member->getLevel() < $level) { + if (Member::compareLevels($this->member->getLevel(), $level) < 0) { throw new MemberLevelException('Insufficient rights'); } } diff --git a/lib/Model/Member.php b/lib/Model/Member.php index 27adaabc8..1839c7494 100644 --- a/lib/Model/Member.php +++ b/lib/Model/Member.php @@ -43,10 +43,20 @@ class Member extends ManagedModel implements public const LEVEL_NONE = 0; public const LEVEL_MEMBER = 1; + public const LEVEL_VISITOR = 2; public const LEVEL_MODERATOR = 4; public const LEVEL_ADMIN = 8; public const LEVEL_OWNER = 9; + public const LEVEL_RANKS = [ + self::LEVEL_NONE => 0, + self::LEVEL_VISITOR => 1, + self::LEVEL_MEMBER => 2, + self::LEVEL_MODERATOR => 3, + self::LEVEL_ADMIN => 4, + self::LEVEL_OWNER => 5, + ]; + public const TYPE_SINGLE = 0; public const TYPE_USER = 1; public const TYPE_GROUP = 2; @@ -91,6 +101,7 @@ class Member extends ManagedModel implements * @var array */ public static $DEF_LEVEL = [ + 2 => 'Visitor', 1 => 'Member', 4 => 'Moderator', 8 => 'Admin', @@ -915,6 +926,14 @@ public static function parseLevelInt(int $level): int { return $level; } + public static function getLevelRank(int $level): int { + return self::LEVEL_RANKS[$level] ?? 0; + } + + public static function compareLevels(int $leftLevel, int $rightLevel): int { + return self::getLevelRank($leftLevel) <=> self::getLevelRank($rightLevel); + } + /** * @param string $levelString * diff --git a/lib/Service/FederatedEventService.php b/lib/Service/FederatedEventService.php index 6a8a2396f..77f6e3dba 100644 --- a/lib/Service/FederatedEventService.php +++ b/lib/Service/FederatedEventService.php @@ -187,7 +187,7 @@ public function confirmInitiator(FederatedEvent $event, bool $local = false): vo } if (!$event->canBypass(FederatedEvent::BYPASS_INITIATORMEMBERSHIP) - && $circle->getInitiator()->getLevel() < Member::LEVEL_MEMBER) { + && Member::compareLevels($circle->getInitiator()->getLevel(), Member::LEVEL_MEMBER) < 0) { throw new InitiatorNotConfirmedException('Initiator must be a member of the Team'); } } diff --git a/lib/Service/MembershipService.php b/lib/Service/MembershipService.php index 79f5dcf7f..7b2dc5220 100644 --- a/lib/Service/MembershipService.php +++ b/lib/Service/MembershipService.php @@ -158,6 +158,7 @@ private function generateMemberships( continue; } + // The effective role comes from this sub-team's parent-team role, not the user's role inside it. $membership = new Membership($singleId, count($path) > 1 ? $path[1] : '', $member); $membership->setInheritancePath(array_reverse($path)) ->setInheritanceDepth(sizeof($path)); @@ -254,8 +255,9 @@ private function getChildrenMemberships(string $id, array &$knownIds = []): arra private function fillMemberships(Membership $membership, array &$memberships) { foreach ($memberships as $known) { if ($known->getCircleId() === $membership->getCircleId()) { - if ($known->getLevel() < $membership->getLevel() - || ($known->getLevel() === $membership->getLevel() + $levelComparison = Member::compareLevels($known->getLevel(), $membership->getLevel()); + if ($levelComparison < 0 + || ($levelComparison === 0 && $known->getInheritanceDepth() > $membership->getInheritanceDepth()) ) { $known->setLevel($membership->getLevel()); diff --git a/lib/Service/SearchService.php b/lib/Service/SearchService.php index 8595b35ac..456c257d1 100644 --- a/lib/Service/SearchService.php +++ b/lib/Service/SearchService.php @@ -81,6 +81,7 @@ public function unifiedSearch(string $term, array $options): array { private function generateSearchProbe(string $term, array $options): CircleProbe { $probe = new CircleProbe(); switch ($this->getInt('level', $options)) { + case Member::LEVEL_VISITOR: case Member::LEVEL_MEMBER: $probe->mustBeMember(); break; diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index e85ec1408..effa1be61 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -25,6 +25,7 @@ vi.mock('../logger.ts', () => ({ vi.mock('./team-page/models/constants.ts', () => ({ SHARES_TYPES_MEMBER_MAP: {}, + MemberLevels: { VISITOR: 2 }, })) vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ @@ -32,9 +33,18 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') +const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams, fetchTeamMembers } = await import('./api.ts') describe('fetchTeams', () => { + it('retains visitor for the current user', async () => { + vi.mocked(axios.get) + .mockResolvedValueOnce({ data: { ocs: { data: [{ id: 'team1', name: 'Team', initiator: { level: 2 } }] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) + + expect((await fetchTeams())[0].myRole).toBe('visitor') + }) + it('drops circles the current user is not a member of', async () => { const circles = [ { id: 'team1', name: 'team1', displayName: 'Team One', population: 3, initiator: { level: 9 } }, @@ -62,6 +72,13 @@ describe('fetchTeams', () => { }) }) +describe('fetchTeamMembers', () => { + it('retains visitor for a full member', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: [{ singleId: 'member1', userId: 'user1', displayName: 'User', userType: 1, level: 2 }] } } }) + expect((await fetchTeamMembers('team1'))[0].role).toBe('visitor') + }) +}) + describe('createTeam', () => { beforeEach(() => { vi.mocked(axios.post).mockResolvedValue({ diff --git a/src/teams/api.ts b/src/teams/api.ts index f5d66fe7d..b35e90731 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -10,7 +10,7 @@ import { FileType } from '@nextcloud/files' import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav' import { generateOcsUrl } from '@nextcloud/router' import { logger } from '../logger.ts' -import { SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts' +import { MemberLevels, SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts' import { getRecommendations, getSuggestions } from './team-page/services/collaborationAutocompletion.js' /** `SHARES_TYPES_MEMBER_MAP` is built dynamically, so type its shape explicitly. */ @@ -86,6 +86,9 @@ function levelToRole(level: number | undefined): TeamRole { if (level >= 4) { return 'moderator' } + if (level === MemberLevels.VISITOR) { + return 'visitor' + } return 'member' } diff --git a/src/teams/team-page/components/MemberList/MemberGridItem.spec.ts b/src/teams/team-page/components/MemberList/MemberGridItem.spec.ts new file mode 100644 index 000000000..4fee70bf2 --- /dev/null +++ b/src/teams/team-page/components/MemberList/MemberGridItem.spec.ts @@ -0,0 +1,133 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Circle from '../../models/circle.ts' +import Member from '../../models/member.ts' +import { CircleConfigs, MemberLevels, MemberStatus } from '../../models/constants.ts' +import MemberGridItem from './MemberGridItem.vue' +import { changeMemberLevel } from '../../services/circles.ts' + +vi.mock('@nextcloud/vue', () => ({ + NcActionButton: { name: 'NcActionButton', template: '' }, + NcActions: { name: 'NcActions', template: '
' }, + NcActionSeparator: { name: 'NcActionSeparator', template: '
' }, + NcActionText: { name: 'NcActionText', template: '
' }, + NcAvatar: { name: 'NcAvatar', template: '
' }, + NcButton: { name: 'NcButton', template: '' }, +})) +vi.mock('@nextcloud/dialogs', () => ({ DialogBuilder: vi.fn(), showError: vi.fn(), showSuccess: vi.fn() })) +vi.mock('../../services/circles.ts', () => ({ changeMemberLevel: vi.fn() })) +vi.mock('../CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue', () => ({ default: {} })) +vi.mock('../CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue', () => ({ default: {} })) + +function mountItem(actorLevel: number, targetLevel: number, config = 0, targetId = 'target', targetLocal = true) { + const circle = new Circle({ id: 'team', displayName: 'Team', config, owner: { id: 'owner', level: 9 }, initiator: { id: 'actor', singleId: 'actor', level: actorLevel, local: true } }) + const status = targetLevel === MemberLevels.NONE ? MemberStatus.REQUESTING : MemberStatus.MEMBER + const member = new Member({ id: targetId, singleId: targetId, displayName: 'Target', level: targetLevel, status, local: targetLocal }, circle) + const dispatch = vi.fn().mockResolvedValue(undefined) + const wrapper = shallowMount(MemberGridItem, { + props: { member }, + global: { + mocks: { + $route: { params: { teamId: 'team' } }, + $store: { getters: { getCircle: () => circle }, dispatch }, + logger: { error: vi.fn(), debug: vi.fn() }, + t: (_app: string, text: string) => text, + }, + }, + }) + return { wrapper, member, dispatch } +} + +describe('MemberGridItem Visitor actions', () => { + beforeEach(() => vi.mocked(changeMemberLevel).mockReset()) + + it('offers Visitor to an Admin targeting another Admin without a rank restriction', () => { + const { wrapper } = mountItem(8, 8) + expect(wrapper.vm.availableLevelsChange).toContain(MemberLevels.VISITOR) + expect(wrapper.vm.levelChangeLabel(MemberLevels.VISITOR)).toBe('Demote to Visitor') + }) + + it('hides Visitor in federated teams', () => { + const { wrapper } = mountItem(8, 8, CircleConfigs.FEDERATED) + expect(wrapper.vm.availableLevelsChange).not.toContain(MemberLevels.VISITOR) + }) + + it('hides Visitor for remote members', () => { + const { wrapper } = mountItem(8, 8, 0, 'target', false) + expect(wrapper.vm.availableLevelsChange).not.toContain(MemberLevels.VISITOR) + }) + + it('offers Member as a promotion from Visitor and never offers the selected role', () => { + const { wrapper } = mountItem(8, 2) + expect(wrapper.vm.availableLevelsChange).not.toContain(MemberLevels.VISITOR) + expect(wrapper.vm.availableLevelsChange).toContain(MemberLevels.MEMBER) + expect(wrapper.vm.levelChangeLabel(MemberLevels.MEMBER)).toBe('Promote to Member') + }) + + it('keeps the Moderator menu unchanged', () => { + const { wrapper } = mountItem(4, 1) + expect(wrapper.vm.availableLevelsChange).toEqual([]) + }) + + it.each([MemberLevels.OWNER, MemberLevels.NONE])('protects target level %i', (level) => { + const { wrapper } = mountItem(9, level) + expect(wrapper.vm.availableLevelsChange).toEqual([]) + expect(wrapper.vm.canChangeLevel).toBe(false) + }) + + it('protects the current user', () => { + const { wrapper } = mountItem(9, 9, 0, 'actor') + expect(wrapper.vm.availableLevelsChange).toEqual([]) + }) + + it('prevents Visitor removal in FRIEND teams but retains leaving', () => { + const { wrapper } = mountItem(2, 1, CircleConfigs.FRIEND) + expect(wrapper.vm.canDelete).toBe(false) + expect(wrapper.vm.canChangeLevel).toBe(false) + }) + + it('does not allow a Member to delete a Visitor without member-management rights', () => { + const { wrapper } = mountItem(MemberLevels.MEMBER, MemberLevels.VISITOR) + expect(wrapper.vm.canDelete).toBe(false) + }) + + it('uses role rank when a Member can manage a Visitor in a FRIEND team', () => { + const { wrapper } = mountItem(MemberLevels.MEMBER, MemberLevels.VISITOR, CircleConfigs.FRIEND) + expect(wrapper.vm.canDelete).toBe(true) + }) + + it('updates only after a successful request', async () => { + const { wrapper, member } = mountItem(8, 1) + vi.mocked(changeMemberLevel).mockResolvedValue([]) + await wrapper.vm.changeLevel(MemberLevels.VISITOR) + expect(changeMemberLevel).toHaveBeenCalledWith('team', 'target', 2) + expect(member.level).toBe(2) + expect(wrapper.vm.loading).toBe(false) + }) + + it.each(['backend rejection', 'network failure'])('%s leaves the role unchanged and permits retry', async () => { + const { wrapper, member } = mountItem(8, 1) + vi.mocked(changeMemberLevel).mockRejectedValueOnce(new Error('unavailable')).mockResolvedValueOnce([]) + await wrapper.vm.changeLevel(MemberLevels.VISITOR) + expect(member.level).toBe(1) + expect(wrapper.vm.loading).toBe(false) + await wrapper.vm.changeLevel(MemberLevels.VISITOR) + expect(member.level).toBe(2) + expect(changeMemberLevel).toHaveBeenCalledTimes(2) + }) + + it('refetches both circle and members on owner transfer', async () => { + const { wrapper, member, dispatch } = mountItem(9, 2) + vi.mocked(changeMemberLevel).mockResolvedValue([]) + await wrapper.vm.changeLevel(MemberLevels.OWNER) + expect(dispatch).toHaveBeenCalledWith('getCircle', 'team') + expect(dispatch).toHaveBeenCalledWith('getCircleMembers', { circleId: 'team' }) + expect(member.level).toBe(2) + expect(wrapper.vm.loading).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/teams/team-page/components/MemberList/MemberGridItem.vue b/src/teams/team-page/components/MemberList/MemberGridItem.vue index 234e833f4..982885b1f 100644 --- a/src/teams/team-page/components/MemberList/MemberGridItem.vue +++ b/src/teams/team-page/components/MemberList/MemberGridItem.vue @@ -91,7 +91,7 @@ import IconShieldCheckOutline from 'vue-material-design-icons/ShieldCheckOutline import IconDeleteOutline from 'vue-material-design-icons/TrashCanOutline.vue' import RouterMixin from '../../mixins/RouterMixin.js' import Circle from '../../models/circle.ts' -import { CIRCLES_MEMBER_LEVELS, MemberLevels, MemberStatus } from '../../models/constants.ts' +import { CIRCLES_MEMBER_LEVELS, compareLevels, MemberLevels, MemberStatus } from '../../models/constants.ts' import { changeMemberLevel } from '../../services/circles.ts' export default { @@ -166,7 +166,7 @@ export default { availableLevelsChange() { const levels = [] // Can't change level of owner - if (this.member.level === MemberLevels.OWNER) { + if (this.member.level === MemberLevels.OWNER || this.member.level === MemberLevels.NONE) { return levels } @@ -188,6 +188,11 @@ export default { if (this.member.level !== MemberLevels.MEMBER) { levels.push(MemberLevels.MEMBER) } + if (this.member.level !== MemberLevels.VISITOR + && !this.circle.isFederated + && this.member.isLocal) { + levels.push(MemberLevels.VISITOR) + } } // Owners can transfer ownership to another member @@ -235,7 +240,7 @@ export default { */ canDelete() { return this.circle.canManageMembers - && this.member.level < this.currentUserLevel + && compareLevels(this.member.level, this.currentUserLevel) < 0 && !this.isCurrentUser && this.member.level !== MemberLevels.OWNER }, @@ -265,7 +270,7 @@ export default { return t('circles', 'Promote as sole owner') } - if (this.member.level < level) { + if (compareLevels(this.member.level, level) < 0) { return t('circles', 'Promote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] }) } return t('circles', 'Demote to {level}', { level: CIRCLES_MEMBER_LEVELS[level] }) diff --git a/src/teams/team-page/components/MemberList/MemberList.spec.ts b/src/teams/team-page/components/MemberList/MemberList.spec.ts new file mode 100644 index 000000000..b55b06743 --- /dev/null +++ b/src/teams/team-page/components/MemberList/MemberList.spec.ts @@ -0,0 +1,48 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { flushPromises, shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import Circle from '../../models/circle.ts' +import { MemberLevels } from '../../models/constants.ts' +import MemberList from './MemberList.vue' + +vi.mock('@nextcloud/vue', () => ({ + NcEmptyContent: {}, NcLoadingIcon: {}, NcSelect: {}, NcTextField: {}, +})) +vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showWarning: vi.fn() })) +vi.mock('@nextcloud/event-bus', () => ({ subscribe: vi.fn() })) +vi.mock('@vueuse/core', () => ({ refDebounced: (value: unknown) => value })) +vi.mock('virtua/vue', () => ({ VList: {} })) +vi.mock('../../mixins/IsMobileMixin.ts', () => ({ default: { computed: { isMobile: () => false } } })) +vi.mock('../../services/collaborationAutocompletion.js', () => ({ getRecommendations: vi.fn(), getSuggestions: vi.fn() })) +vi.mock('../EntityPicker/EntityPicker.vue', () => ({ default: {} })) +vi.mock('./MemberGridItem.vue', () => ({ default: {} })) +vi.mock('../CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue', () => ({ default: {} })) +vi.mock('../CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue', () => ({ default: {} })) + +describe('member role filter', () => { + it('orders semantic roles and requests visitor using equality role 2', async () => { + const circle = new Circle({ id: 'team', config: 0, owner: { level: 9 }, initiator: { level: 9 } }) + const dispatch = vi.fn().mockResolvedValue(undefined) + const wrapper = shallowMount(MemberList, { + props: { list: [] }, + global: { mocks: { + $route: { params: { teamId: 'team' } }, + $store: { getters: { getCircle: () => circle, isLoadingCircleMembers: false }, dispatch }, + logger: { debug: vi.fn(), error: vi.fn() }, + isMobile: false, + t: (_app: string, text: string) => text, + } }, + }) + expect(wrapper.vm.roles.map(({ id }: { id: number }) => id)).toEqual([ + MemberLevels.NONE, MemberLevels.VISITOR, MemberLevels.MEMBER, + MemberLevels.MODERATOR, MemberLevels.ADMIN, MemberLevels.OWNER, + ]) + ;(wrapper.vm as unknown as { searchRole: unknown }).searchRole = wrapper.vm.roles[1] + await flushPromises() + expect(dispatch).toHaveBeenCalledWith('getCircleMembers', { circleId: 'team', search: null, role: 2 }) + }) +}) \ No newline at end of file diff --git a/src/teams/team-page/components/MemberList/MemberList.vue b/src/teams/team-page/components/MemberList/MemberList.vue index 8e983e15c..a2edd7ae0 100644 --- a/src/teams/team-page/components/MemberList/MemberList.vue +++ b/src/teams/team-page/components/MemberList/MemberList.vue @@ -138,14 +138,12 @@ export default defineComponent({ const searchQueryDebounced = refDebounced(searchQuery, 500) const searchRole = ref(null) - const roles = Object.entries(CIRCLES_MEMBER_LEVELS).map(([id, label]) => ({ - id: Number(id), - label, - })) - roles.unshift({ - id: Number(MemberLevels.NONE), - label: t('circles', 'Pending'), - }) + const roles = [ + { id: MemberLevels.NONE, label: t('circles', 'Pending') }, + // Keep this explicit; JavaScript sorts integer object keys numerically. + ...[MemberLevels.VISITOR, MemberLevels.MEMBER, MemberLevels.MODERATOR, MemberLevels.ADMIN, MemberLevels.OWNER] + .map((id) => ({ id, label: CIRCLES_MEMBER_LEVELS[id] })), + ] return { searchQuery, diff --git a/src/teams/team-page/models/circle.spec.ts b/src/teams/team-page/models/circle.spec.ts new file mode 100644 index 000000000..65bbc2f00 --- /dev/null +++ b/src/teams/team-page/models/circle.spec.ts @@ -0,0 +1,34 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, vi } from 'vitest' +import Circle from './circle.ts' +import { CircleConfigs, MemberLevels } from './constants.ts' + +vi.mock('../components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue', () => ({ default: {} })) +vi.mock('../components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue', () => ({ default: {} })) + +describe('team member controls', () => { + it('identifies federated circles', () => { + const federated = new Circle({ id: 'federated', config: CircleConfigs.FEDERATED, owner: {}, initiator: {} }) + const local = new Circle({ id: 'local', config: 0, owner: {}, initiator: {} }) + expect(federated.isFederated).toBe(true) + expect(local.isFederated).toBe(false) + }) + + it.each([0, CircleConfigs.FRIEND])('keeps visitor active but unable to manage members with config %i', (config) => { + const circle = new Circle({ id: 'team', config, owner: { level: 9 }, initiator: { level: MemberLevels.VISITOR } }) + expect(circle.isMember).toBe(true) + expect(circle.canLeave).toBe(true) + expect(circle.canManageMembers).toBe(false) + }) + + it('retains Member FRIEND invites and Moderator management', () => { + const member = new Circle({ id: 'team', config: CircleConfigs.FRIEND, owner: {}, initiator: { level: MemberLevels.MEMBER } }) + const moderator = new Circle({ id: 'team', config: 0, owner: {}, initiator: { level: MemberLevels.MODERATOR } }) + expect(member.canManageMembers).toBe(true) + expect(moderator.canManageMembers).toBe(true) + }) +}) \ No newline at end of file diff --git a/src/teams/team-page/models/circle.ts b/src/teams/team-page/models/circle.ts index e51320ef6..a795ed77d 100644 --- a/src/teams/team-page/models/circle.ts +++ b/src/teams/team-page/models/circle.ts @@ -205,6 +205,10 @@ export default class Circle { return this._data.config } + get isFederated(): boolean { + return (this.config & CircleConfigs.FEDERATED) !== 0 + } + /** * Define circle config */ @@ -296,6 +300,9 @@ export default class Circle { * Can the initiator add/remove members to this circle? */ get canManageMembers() { + if (this.initiator?.level === MemberLevels.VISITOR) { + return false + } return (this.initiator?.level && this.initiator?.level >= MemberLevels.MODERATOR) || (this.config & CircleConfigs.FRIEND) !== 0 diff --git a/src/teams/team-page/models/constants.spec.ts b/src/teams/team-page/models/constants.spec.ts new file mode 100644 index 000000000..072313eb0 --- /dev/null +++ b/src/teams/team-page/models/constants.spec.ts @@ -0,0 +1,29 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, vi } from 'vitest' +import { CIRCLES_MEMBER_LEVELS, compareLevels, MemberLevels } from './constants.ts' + +vi.mock('../components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue', () => ({ default: {} })) +vi.mock('../components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue', () => ({ default: {} })) + +describe('member level ranks', () => { + it('labels visitor separately from Member', () => { + expect(MemberLevels.VISITOR).toBe(2) + expect(CIRCLES_MEMBER_LEVELS[MemberLevels.VISITOR]).toBe('Visitor') + }) + + it('places visitor below Member while retaining the other role order', () => { + const levels = [MemberLevels.NONE, MemberLevels.VISITOR, MemberLevels.MEMBER, MemberLevels.MODERATOR, MemberLevels.ADMIN, MemberLevels.OWNER] + for (const [index, level] of levels.entries()) { + if (index > 0) { + expect(compareLevels(level, levels[index - 1])).toBeGreaterThan(0) + expect(compareLevels(levels[index - 1], level)).toBeLessThan(0) + } + } + expect(compareLevels(123, MemberLevels.NONE)).toBe(0) + expect(compareLevels(MemberLevels.NONE, -1)).toBe(0) + }) +}) \ No newline at end of file diff --git a/src/teams/team-page/models/constants.ts b/src/teams/team-page/models/constants.ts index ff9acfcce..918267f9e 100644 --- a/src/teams/team-page/models/constants.ts +++ b/src/teams/team-page/models/constants.ts @@ -41,6 +41,7 @@ export const ELLIPSIS_COUNT = 5 // Circles member levels const MEMBER_LEVEL_NONE: MemberLevel = 0 const MEMBER_LEVEL_MEMBER: MemberLevel = 1 +const MEMBER_LEVEL_VISITOR: MemberLevel = 2 const MEMBER_LEVEL_MODERATOR: MemberLevel = 4 const MEMBER_LEVEL_ADMIN: MemberLevel = 8 const MEMBER_LEVEL_OWNER: MemberLevel = 9 @@ -86,6 +87,7 @@ export const CIRCLES_MEMBER_TYPES = { // Available circles promote/demote levels export const CIRCLES_MEMBER_LEVELS = { // [MEMBER_LEVEL_NONE]: t('circles', 'Pending'), + [MEMBER_LEVEL_VISITOR]: t('circles', 'Visitor'), [MEMBER_LEVEL_MEMBER]: t('circles', 'Member'), [MEMBER_LEVEL_MODERATOR]: t('circles', 'Moderator'), [MEMBER_LEVEL_ADMIN]: t('circles', 'Admin'), @@ -207,11 +209,25 @@ export const SHARES_TYPES_MEMBER_MAP = CIRCLES_MEMBER_GROUPING.reduce((list, ent export enum MemberLevels { NONE = MEMBER_LEVEL_NONE, MEMBER = MEMBER_LEVEL_MEMBER, + VISITOR = MEMBER_LEVEL_VISITOR, MODERATOR = MEMBER_LEVEL_MODERATOR, ADMIN = MEMBER_LEVEL_ADMIN, OWNER = MEMBER_LEVEL_OWNER, } +export const MEMBER_LEVEL_RANKS: Record = { + [MemberLevels.NONE]: 0, + [MemberLevels.VISITOR]: 1, + [MemberLevels.MEMBER]: 2, + [MemberLevels.MODERATOR]: 3, + [MemberLevels.ADMIN]: 4, + [MemberLevels.OWNER]: 5, +} + +export function compareLevels(leftLevel: MemberLevel, rightLevel: MemberLevel): number { + return (MEMBER_LEVEL_RANKS[leftLevel] ?? 0) - (MEMBER_LEVEL_RANKS[rightLevel] ?? 0) +} + export enum MemberTypes { CIRCLE = MEMBER_TYPE_CIRCLE, USER = MEMBER_TYPE_USER, diff --git a/src/teams/team-page/models/member.ts b/src/teams/team-page/models/member.ts index f6a486044..9335799d5 100644 --- a/src/teams/team-page/models/member.ts +++ b/src/teams/team-page/models/member.ts @@ -42,6 +42,10 @@ export default class Member { return this._circle } + get isLocal(): boolean { + return this._data.local === true + } + /** * Set the circle of this member */ diff --git a/src/teams/team-page/services/circles.spec.ts b/src/teams/team-page/services/circles.spec.ts new file mode 100644 index 000000000..15d5d29c6 --- /dev/null +++ b/src/teams/team-page/services/circles.spec.ts @@ -0,0 +1,33 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { describe, expect, it, vi } from 'vitest' +import Member from '../models/member.ts' +import { changeMemberLevel } from './circles.ts' + +vi.mock('@nextcloud/axios', () => ({ default: { put: vi.fn() } })) +vi.mock('@nextcloud/router', () => ({ generateOcsUrl: (path: string, params: Record) => path.replace(/\{(\w+)\}/g, (_, key: string) => params[key]) })) +vi.mock('../components/CircleDetails/CircleConfigs/CircleConfigCheckboxesList.vue', () => ({ default: {} })) +vi.mock('../components/CircleDetails/CircleConfigs/CircleConfigInvitationLink.vue', () => ({ default: {} })) + +describe('visitor level changes', () => { + it('accepts numeric 2 in the member model and sends it to the level endpoint', async () => { + const member = new Member({ id: 'member1', level: 1 }, {} as ConstructorParameters[1]) + member.level = 2 + expect(member.level).toBe(2) + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { level: 2 } } } }) + await expect(changeMemberLevel('team1', 'member1', 2)).resolves.toEqual([2]) + expect(axios.put).toHaveBeenCalledWith('apps/circles/circles/team1/members/member1/level', { level: 2 }) + }) + + it.each([3, 5])('rejects unsupported numeric level %i before sending', async (level) => { + const member = new Member({ id: 'member1', level: 1 }, {} as ConstructorParameters[1]) + expect(() => { member.level = level }).toThrow('Invalid level') + vi.mocked(axios.put).mockClear() + await expect(changeMemberLevel('team1', 'member1', level)).rejects.toThrow('Invalid level') + expect(axios.put).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/src/teams/types.ts b/src/teams/types.ts index 4747b053f..40a57348c 100644 --- a/src/teams/types.ts +++ b/src/teams/types.ts @@ -4,7 +4,7 @@ */ /** Permission level inside a team, derived from the circles member level. */ -export type TeamRole = 'owner' | 'admin' | 'moderator' | 'member' +export type TeamRole = 'owner' | 'admin' | 'moderator' | 'member' | 'visitor' /** * A member of a team. Maps to a circles Member: `id` is the member's single id, diff --git a/tests/unit/CirclesManagerTest.php b/tests/unit/CirclesManagerTest.php index c1216c096..9d0aa675b 100644 --- a/tests/unit/CirclesManagerTest.php +++ b/tests/unit/CirclesManagerTest.php @@ -12,6 +12,7 @@ use OCA\Circles\Model\FederatedUser; use OCA\Circles\Model\Member; use OCA\Circles\Model\Probes\DataProbe; +use OCA\Circles\Service\MemberService; use OCP\IGroupManager; use OCP\IUserManager; use OCP\Server; @@ -85,6 +86,20 @@ public function testCreateCircle(): void { $this->assertCount(0, array_filter($circles, fn (Circle $c) => $c->getName() === $this->circleName)); } + public function testLevelMemberAcceptsVisitor(): void { + $this->startSession('admin'); + $circle = $this->circlesManager->createCircle($this->circleName); + try { + $this->startSession('admin'); + $member = $this->circlesManager->addMember($circle->getSingleId(), $this->circlesManager->getLocalFederatedUser($this->userId)); + $this->assertSame(Member::LEVEL_VISITOR, $this->circlesManager->levelMember($member->getId(), Member::LEVEL_VISITOR)->getLevel()); + $this->assertSame(Member::LEVEL_VISITOR, Server::get(MemberService::class)->getMemberById($member->getId(), $circle->getSingleId())->getLevel()); + } finally { + $this->startSession('admin'); + $this->circlesManager->destroyCircle($circle->getSingleId()); + } + } + public function testGetCirclesWithInitiator(): void { // Create circle as user 'admin' and add test user as member $this->startSession('admin'); diff --git a/tests/unit/CirclesQueryHelperTest.php b/tests/unit/CirclesQueryHelperTest.php new file mode 100644 index 000000000..727be5646 --- /dev/null +++ b/tests/unit/CirclesQueryHelperTest.php @@ -0,0 +1,134 @@ +container = (new Application())->getContainer(); + $this->userManager = $this->container->get(IUserManager::class); + $this->userSession = $this->container->get(IUserSession::class); + $this->circleService = $this->container->get(CircleService::class); + $this->memberService = $this->container->get(MemberService::class); + $this->federatedUserService = $this->container->get(FederatedUserService::class); + $this->circlesQueryHelper = $this->container->get(CirclesQueryHelper::class); + $this->token = bin2hex(random_bytes(6)); + $this->creatorId = $this->createUser('creator'); + $this->targetId = $this->createUser('target'); + $this->setCurrentUser($this->creatorId); + } + + protected function tearDown(): void { + foreach ($this->circlesToCleanup as $circleId => $ownerId) { + try { + $this->setCurrentUser($ownerId); + $this->circleService->destroy($circleId); + } catch (\Throwable) { + } + } + + foreach ($this->createdUsers as $userId) { + $this->userManager->get($userId)?->delete(); + } + + parent::tearDown(); + } + + public function testLimitToMembershipsSelectsStoredVisitorAndMemberLevels(): void { + $visitorCircleId = $this->createCircle('visitor'); + $memberCircleId = $this->createCircle('member'); + $this->addMemberAtLevel($visitorCircleId, Member::LEVEL_VISITOR); + $this->addMemberAtLevel($memberCircleId, Member::LEVEL_MEMBER); + $federatedUser = $this->federatedUserService->getLocalFederatedUser($this->targetId); + + $this->assertSame(Member::LEVEL_VISITOR, $this->getSelectedLevel($visitorCircleId, $federatedUser)); + $this->assertSame(Member::LEVEL_MEMBER, $this->getSelectedLevel($memberCircleId, $federatedUser)); + } + + private function getSelectedLevel(string $circleId, FederatedUser $federatedUser): int { + $queryBuilder = $this->circlesQueryHelper->getQueryBuilder(); + $queryBuilder->select('a.unique_id')->from(CoreRequestBuilder::TABLE_CIRCLE, 'a'); + $this->circlesQueryHelper->limitToMemberships('a', 'unique_id', $federatedUser, 'circle_level'); + $queryBuilder->andWhere( + $queryBuilder->expr()->eq('a.unique_id', $queryBuilder->createNamedParameter($circleId)) + ); + + $row = $queryBuilder->executeQuery()->fetchAssociative(); + $this->assertIsArray($row); + + return (int)$row['circle_level']; + } + + private function createUser(string $name): string { + $userId = 'circles-query-helper-' . $this->token . '-' . $name; + $this->userManager->createUser($userId, 'test-pwd'); + $this->createdUsers[] = $userId; + + return $userId; + } + + private function setCurrentUser(string $userId): void { + $user = $this->userManager->get($userId); + if ($user === null) { + throw new \RuntimeException('Test user not found'); + } + + $this->userSession->setUser($user); + $this->federatedUserService->setLocalCurrentUser($user); + } + + private function createCircle(string $name): string { + $this->setCurrentUser($this->creatorId); + $circle = $this->circleService->create($name . '-' . $this->token, createTeamFolder: false); + $this->circlesToCleanup[$circle['id']] = $this->creatorId; + + return $circle['id']; + } + + private function addMemberAtLevel(string $circleId, int $level): void { + $this->setCurrentUser($this->creatorId); + $memberData = $this->memberService->addMember( + $circleId, + $this->federatedUserService->generateFederatedUser($this->targetId, Member::TYPE_USER), + ); + if ($level !== Member::LEVEL_MEMBER) { + $this->memberService->memberLevel($memberData['id'], $level); + } + } +} diff --git a/tests/unit/lib/Controller/AdminControllerTest.php b/tests/unit/lib/Controller/AdminControllerTest.php index 23db9ed1e..f2ba76de2 100644 --- a/tests/unit/lib/Controller/AdminControllerTest.php +++ b/tests/unit/lib/Controller/AdminControllerTest.php @@ -20,6 +20,7 @@ use OCA\Circles\Service\MembershipService; use OCA\Circles\Service\SearchService; use OCA\Circles\Service\TeamFolderPolicy; +use OCP\AppFramework\OCS\OCSException; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; @@ -196,6 +197,26 @@ public function testMemberLevel(): void { $this->assertSame($result['level'], Member::LEVEL_MODERATOR); } + public function testVisitorLevelRoundTripAndWrongTeamTarget(): void { + $first = $this->createCircle('visitor-team'); + $second = $this->createCircle('other-team'); + $this->circlesToCleanup[] = $first['id']; + $this->circlesToCleanup[] = $second['id']; + + $member = $this->adminController->memberAdd(self::TEST_USER_1, $first['id'], self::TEST_USER_2, Member::TYPE_USER)->getData(); + $visitor = $this->adminController->memberLevel(self::TEST_USER_1, $first['id'], $member['id'], Member::LEVEL_VISITOR)->getData(); + $this->assertSame(Member::LEVEL_VISITOR, $visitor['level']); + $this->assertSame(Member::LEVEL_VISITOR, $this->container->get(MemberService::class)->getMemberById($member['id'], $first['id'])->getLevel()); + + try { + $this->adminController->memberLevel(self::TEST_USER_1, $second['id'], $member['id'], Member::LEVEL_ADMIN); + $this->fail('A target from a different team must be rejected'); + } catch (OCSException $e) { + } catch (OCSException) { + } + $this->assertSame(Member::LEVEL_VISITOR, $this->container->get(MemberService::class)->getMemberById($member['id'], $first['id'])->getLevel()); + } + public function testCircles(): void { $circleService = $this->container->get(CircleService::class); diff --git a/tests/unit/lib/Controller/LocalControllerTest.php b/tests/unit/lib/Controller/LocalControllerTest.php index f2f2e0387..3616f2de1 100644 --- a/tests/unit/lib/Controller/LocalControllerTest.php +++ b/tests/unit/lib/Controller/LocalControllerTest.php @@ -9,6 +9,7 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Controller\LocalController; +use OCA\Circles\Db\MemberRequest; use OCA\Circles\Model\Circle; use OCA\Circles\Model\Member; use OCA\Circles\Service\AvatarService; @@ -107,6 +108,12 @@ public static function tearDownAfterClass(): void { protected function tearDown(): void { parent::tearDown(); + $user = $this->userManager->get(self::TEST_USER_1); + if ($user !== null) { + $this->userSession->setUser($user); + $this->container->get(FederatedUserService::class)->setLocalCurrentUser($user); + } + $circleService = $this->container->get(CircleService::class); foreach ($this->circlesToCleanup as $circleId) { @@ -238,4 +245,134 @@ public function testMembersAddPermissionDenied(): void { $response = $this->localController->membersAdd($this->circleId, $members); $this->assertEquals($response->getData()['message'], 'Insufficient permissions to perform this action'); } + + /** + * @dataProvider friendConfigCases + */ + public function testVisitorCannotSingleAddWithFriendEnabledOrDisabled(bool $friend): void { + $this->prepareVisitor($friend); + $before = $this->memberSnapshot(); + + $response = $this->localController->memberAdd($this->circleId, self::TEST_USER_3, Member::TYPE_USER); + + $this->assertSame(403, $response->getStatus()); + $this->assertSame('Insufficient permissions to perform this action', $response->getData()['message']); + $this->assertSame($before, $this->memberSnapshot()); + } + + /** + * @dataProvider friendConfigCases + */ + public function testVisitorCannotBulkAddWithFriendEnabledOrDisabled(bool $friend): void { + $this->prepareVisitor($friend); + $before = $this->memberSnapshot(); + $members = [ + ['id' => self::TEST_USER_3, 'type' => Member::TYPE_USER], + ['id' => self::TEST_USER_4, 'type' => Member::TYPE_USER], + ]; + + $response = $this->localController->membersAdd($this->circleId, $members); + + $this->assertSame(403, $response->getStatus()); + $this->assertSame('Insufficient permissions to perform this action', $response->getData()['message']); + $this->assertSame($before, $this->memberSnapshot()); + } + + /** + * @dataProvider friendConfigCases + */ + public function testVisitorCannotConfirmPendingMemberWithFriendEnabledOrDisabled(bool $friend): void { + $this->prepareVisitor($friend); + $pendingMemberId = $this->createPendingMember(self::TEST_USER_3); + $this->userSession->setUser($this->userManager->get(self::TEST_USER_2)); + $before = $this->memberSnapshot(); + + $response = $this->localController->memberConfirm($this->circleId, $pendingMemberId); + + $this->assertSame(403, $response->getStatus()); + $this->assertSame('Insufficient permissions to perform this action', $response->getData()['message']); + $this->assertSame($before, $this->memberSnapshot()); + $pendingMember = $this->findStoredMember($pendingMemberId); + $this->assertSame(Member::STATUS_REQUEST, $pendingMember->getStatus()); + $this->assertSame(Member::LEVEL_NONE, $pendingMember->getLevel()); + } + + public static function friendConfigCases(): array { + return [ + 'friend enabled' => [true], + 'friend disabled' => [false], + ]; + } + + public function testMemberCanBulkAddAndConfirmInFriendCircle(): void { + $this->localController->editConfig($this->circleId, Circle::CFG_FRIEND); + $this->localController->memberAdd($this->circleId, self::TEST_USER_2, Member::TYPE_USER); + $this->userSession->setUser($this->userManager->get(self::TEST_USER_2)); + + $added = $this->localController->membersAdd($this->circleId, [ + ['id' => self::TEST_USER_3, 'type' => Member::TYPE_USER], + ])->getData(); + $this->assertCount(1, $added); + $this->assertSame(self::TEST_USER_3, $added[0]['userId']); + + $pendingMemberId = $this->createPendingMember(self::TEST_USER_4); + $this->userSession->setUser($this->userManager->get(self::TEST_USER_2)); + $confirmed = $this->localController->memberConfirm($this->circleId, $pendingMemberId)->getData(); + + $this->assertSame(self::TEST_USER_4, $confirmed['userId']); + $member = $this->findStoredMember($pendingMemberId); + $this->assertSame(Member::STATUS_MEMBER, $member->getStatus()); + $this->assertSame(Member::LEVEL_MEMBER, $member->getLevel()); + } + + public function testModeratorCannotChangeMemberLevelThroughLocalController(): void { + $moderator = $this->localController->memberAdd($this->circleId, self::TEST_USER_2, Member::TYPE_USER)->getData(); + $target = $this->localController->memberAdd($this->circleId, self::TEST_USER_3, Member::TYPE_USER)->getData(); + $this->localController->memberLevel($this->circleId, $moderator['id'], Member::LEVEL_MODERATOR); + $before = $this->memberSnapshot(); + + $this->userSession->setUser($this->userManager->get(self::TEST_USER_2)); + $response = $this->localController->memberLevel($this->circleId, $target['id'], Member::LEVEL_VISITOR); + + $this->assertSame(403, $response->getStatus()); + $this->assertSame($before, $this->memberSnapshot()); + $this->assertSame(Member::LEVEL_MEMBER, $this->findStoredMember($target['id'])->getLevel()); + } + + private function prepareVisitor(bool $friend): void { + $this->localController->editConfig($this->circleId, $friend ? Circle::CFG_FRIEND : Circle::CFG_CIRCLE); + $visitor = $this->localController->memberAdd($this->circleId, self::TEST_USER_2, Member::TYPE_USER)->getData(); + $this->localController->memberLevel($this->circleId, $visitor['id'], Member::LEVEL_VISITOR); + $this->userSession->setUser($this->userManager->get(self::TEST_USER_2)); + } + + private function createPendingMember(string $userId): string { + $this->userSession->setUser($this->userManager->get(self::TEST_USER_1)); + $memberData = $this->localController->memberAdd($this->circleId, $userId, Member::TYPE_USER)->getData(); + $member = $this->container->get(MemberService::class)->getMemberById($memberData['id'], $this->circleId); + $member->setLevel(Member::LEVEL_NONE)->setStatus(Member::STATUS_REQUEST); + $this->container->get(MemberRequest::class)->update($member); + + return $memberData['id']; + } + + private function memberSnapshot(): array { + $snapshot = []; + foreach ($this->container->get(MemberRequest::class)->getMembers($this->circleId) as $member) { + $snapshot[$member->getId()] = [$member->getUserId(), $member->getLevel(), $member->getStatus()]; + } + ksort($snapshot); + + return $snapshot; + } + + private function findStoredMember(string $memberId): Member { + foreach ($this->container->get(MemberRequest::class)->getMembers($this->circleId) as $member) { + if ($member->getId() === $memberId) { + return $member; + } + } + + $this->fail('Member not found in database'); + } } diff --git a/tests/unit/lib/Db/MemberLevelOrderingTest.php b/tests/unit/lib/Db/MemberLevelOrderingTest.php new file mode 100644 index 000000000..89da0fb7f --- /dev/null +++ b/tests/unit/lib/Db/MemberLevelOrderingTest.php @@ -0,0 +1,269 @@ +container = (new Application())->getContainer(); + $this->userManager = $this->container->get(IUserManager::class); + $this->userSession = $this->container->get(IUserSession::class); + $this->circleService = $this->container->get(CircleService::class); + $this->memberService = $this->container->get(MemberService::class); + $this->memberRequest = $this->container->get(MemberRequest::class); + $this->membershipService = $this->container->get(MembershipService::class); + $this->token = bin2hex(random_bytes(6)); + $this->creatorId = $this->createUser('creator'); + $this->actorId = $this->createUser('actor'); + $this->setCurrentUser($this->creatorId); + } + + protected function tearDown(): void { + foreach ($this->circlesToCleanup as $circleId => $ownerId) { + try { + $this->setCurrentUser($ownerId); + $this->circleService->destroy($circleId); + } catch (\Throwable) { + } + } + + foreach ($this->createdUsers as $userId) { + $this->userManager->get($userId)?->delete(); + } + + parent::tearDown(); + } + + public function testRequestSqlUsesRankCaseAndExpectedAliases(): void { + $memberRequest = new class($this->container->get(IDBConnection::class), $this->container->get(TimezoneService::class), $this->container->get(ConfigService::class), ) extends MemberRequest { + public string $capturedSql = ''; + + public function getItemsFromRequest(CoreQueryBuilder $qb, bool $asFederatedUser = false): array { + $this->capturedSql = $qb->getSQL(); + + return []; + } + }; + $memberRequest->getMembers('ordering-test-circle', limit: 1); + $normalizedMemberSql = strtolower(str_replace(['`', '"'], '', $memberRequest->capturedSql)); + + $this->assertStringContainsString( + 'order by case c.level ' . strtolower(self::CASE_BODY) . ' desc, c.cached_name asc', + $normalizedMemberSql + ); + + $circleRequest = new class($this->container->get(TeamFavoriteRepository::class), $this->container->get(TimezoneService::class), $this->container->get(ConfigService::class), ) extends CircleRequest { + public string $capturedSql = ''; + + public function getItemsFromRequest(CoreQueryBuilder $qb): array { + $this->capturedSql = $qb->getSQL(); + + return []; + } + }; + $initiator = (new FederatedUser())->set( + $this->actorId, + '', + Member::TYPE_USER, + ); + $circleRequest->getCircles($initiator, new CircleProbe()); + + $normalizedCircleSql = strtolower(str_replace(['`', '"'], '', $circleRequest->capturedSql)); + $this->assertSame(1, preg_match('/order by case ([a-z0-9_]+)\.level /', $normalizedCircleSql, $matches)); + $this->assertNotSame(CoreQueryBuilder::CIRCLE, $matches[1]); + $this->assertStringContainsString( + 'order by case ' . $matches[1] . '.level ' . strtolower(self::CASE_BODY) . ' desc, b.display_name asc', + $normalizedCircleSql + ); + } + + public function testMemberOrderingAndLimitPlaceVisitorBelowMember(): void { + $circle = $this->createCircle('member-order'); + $roles = [ + 'none' => Member::LEVEL_NONE, + 'member' => Member::LEVEL_MEMBER, + 'visitor' => Member::LEVEL_VISITOR, + 'moderator' => Member::LEVEL_MODERATOR, + 'admin' => Member::LEVEL_ADMIN, + 'unknown' => 73, + ]; + + foreach ($roles as $name => $level) { + $this->addMemberAtLevel($circle['id'], $this->createUser('member-' . $name), $level); + } + + $members = $this->memberRequest->getMembers($circle['id']); + $this->assertSame([ + Member::LEVEL_OWNER, + Member::LEVEL_ADMIN, + Member::LEVEL_MODERATOR, + Member::LEVEL_MEMBER, + Member::LEVEL_VISITOR, + ], array_map(static fn (Member $member): int => $member->getLevel(), array_slice($members, 0, 5))); + + $rankZero = array_slice($members, 5); + $this->assertEqualsCanonicalizing( + [Member::LEVEL_NONE, 73], + array_map(static fn (Member $member): int => $member->getLevel(), $rankZero) + ); + $names = array_map(static fn (Member $member): string => $member->getDisplayName(), $rankZero); + $sortedNames = $names; + sort($sortedNames, SORT_STRING); + $this->assertSame($sortedNames, $names); + + $limited = $this->memberRequest->getMembers($circle['id'], limit: 5); + $this->assertSame([ + Member::LEVEL_OWNER, + Member::LEVEL_ADMIN, + Member::LEVEL_MODERATOR, + Member::LEVEL_MEMBER, + Member::LEVEL_VISITOR, + ], array_map(static fn (Member $member): int => $member->getLevel(), $limited)); + } + + public function testCircleOrderingAndOffsetUseSemanticInitiatorRank(): void { + $roleCases = [ + 'owner' => Member::LEVEL_OWNER, + 'admin' => Member::LEVEL_ADMIN, + 'moderator' => Member::LEVEL_MODERATOR, + 'member' => Member::LEVEL_MEMBER, + 'visitor' => Member::LEVEL_VISITOR, + 'none' => Member::LEVEL_NONE, + 'unknown' => 73, + ]; + $createdCircleIds = []; + + foreach ($roleCases as $name => $level) { + $circle = $this->createCircle('circle-order-' . $name); + $createdCircleIds[$circle['id']] = true; + $member = $this->addMemberAtLevel($circle['id'], $this->actorId, $level); + if ($level === Member::LEVEL_OWNER) { + $this->circlesToCleanup[$circle['id']] = $this->actorId; + } + } + + $this->setCurrentUser($this->actorId); + $initiator = $this->container->get(FederatedUserService::class)->getLocalFederatedUser($this->actorId); + $circles = array_values(array_filter( + $this->container->get(CircleRequest::class)->getCircles($initiator, new CircleProbe()), + static fn (Circle $circle): bool => isset($createdCircleIds[$circle->getSingleId()]) + )); + $this->assertCount(count($roleCases), $circles); + + $initiatorLevels = array_map(static fn (Circle $circle): int => $circle->getInitiator()->getLevel(), $circles); + $this->assertSame([ + Member::LEVEL_OWNER, + Member::LEVEL_ADMIN, + Member::LEVEL_MODERATOR, + Member::LEVEL_MEMBER, + Member::LEVEL_VISITOR, + ], array_slice($initiatorLevels, 0, 5)); + $this->assertEqualsCanonicalizing([Member::LEVEL_NONE, 73], array_slice($initiatorLevels, 5)); + + $rankZeroNames = array_map(static fn (Circle $circle): string => $circle->getDisplayName(), array_slice($circles, 5)); + $sortedRankZeroNames = $rankZeroNames; + sort($sortedRankZeroNames, SORT_STRING); + $this->assertSame($sortedRankZeroNames, $rankZeroNames); + + $offsetCircles = $this->container->get(CircleRequest::class)->getCircles( + $initiator, + (new CircleProbe())->setItemsOffset(1)->setItemsLimit(2), + ); + $this->assertSame([ + Member::LEVEL_ADMIN, + Member::LEVEL_MODERATOR, + ], array_map(static fn (Circle $circle): int => $circle->getInitiator()->getLevel(), $offsetCircles)); + } + + private function createUser(string $name): string { + $userId = 'circles-order-' . $this->token . '-' . $name; + $this->userManager->createUser($userId, 'test-pwd'); + $this->createdUsers[] = $userId; + + return $userId; + } + + private function setCurrentUser(string $userId): void { + $user = $this->userManager->get($userId); + if ($user === null) { + throw new \RuntimeException('Test user not found'); + } + + $this->userSession->setUser($user); + $this->container->get(FederatedUserService::class)->setLocalCurrentUser($user); + } + + private function createCircle(string $name): array { + $this->setCurrentUser($this->creatorId); + $circle = $this->circleService->create($name . '-' . $this->token, createTeamFolder: false); + $this->circlesToCleanup[$circle['id']] = $this->creatorId; + + return $circle; + } + + private function addMemberAtLevel(string $circleId, string $userId, int $level): array { + $this->setCurrentUser($this->creatorId); + $federatedUserService = $this->container->get(FederatedUserService::class); + $memberData = $this->memberService->addMember( + $circleId, + $federatedUserService->generateFederatedUser($userId, Member::TYPE_USER), + ); + + if ($level === Member::LEVEL_NONE || $level === 73) { + $member = $this->memberService->getMemberById($memberData['id'], $circleId); + $member->setLevel($level); + $this->memberRequest->updateLevel($member); + $this->membershipService->onUpdate($member->getSingleId()); + } elseif ($level !== Member::LEVEL_MEMBER) { + $this->memberService->memberLevel($memberData['id'], $level); + } + + return $memberData; + } +} diff --git a/tests/unit/lib/FederatedItems/CircleConfigTest.php b/tests/unit/lib/FederatedItems/CircleConfigTest.php new file mode 100644 index 000000000..c8ff6a84f --- /dev/null +++ b/tests/unit/lib/FederatedItems/CircleConfigTest.php @@ -0,0 +1,120 @@ +createSubject(); + $circle = $this->createCircle($currentConfig); + $event = $this->createEvent($circle, $requestedConfig); + $visitor = (new Member())->setLevel(Member::LEVEL_VISITOR); + + $memberRequest->expects($this->once()) + ->method('getMembers') + ->with('circle-single', null, null, 1, false, '', Member::LEVEL_VISITOR) + ->willReturn([$visitor]); + $circleRequest->expects($this->never())->method('updateConfig'); + $permissionService->expects($this->never())->method('confirmAllowedCircleTypes'); + $this->expectException(FederatedItemBadRequestException::class); + $this->expectExceptionMessage('Remove Visitor roles before enabling federation'); + + $handler->verify($event); + } + + public static function federationEnablementCases(): array { + return [ + 'normalization adds root' => [Circle::CFG_CIRCLE, Circle::CFG_FEDERATED], + 'root already set' => [Circle::CFG_ROOT, Circle::CFG_ROOT | Circle::CFG_FEDERATED], + ]; + } + + /** + * @dataProvider federationEnablementCases + */ + public function testFederationEnablementWithoutVisitorsSucceeds(int $currentConfig, int $requestedConfig): void { + [$handler, , $memberRequest, $permissionService] = $this->createSubject(); + $circle = $this->createCircle($currentConfig); + $event = $this->createEvent($circle, $requestedConfig); + + $memberRequest->expects($this->once()) + ->method('getMembers') + ->with('circle-single', null, null, 1, false, '', Member::LEVEL_VISITOR) + ->willReturn([]); + $permissionService->expects($this->once())->method('confirmAllowedCircleTypes'); + + $handler->verify($event); + + $expectedConfig = $requestedConfig; + if (!$circle->isConfig(Circle::CFG_ROOT)) { + $expectedConfig |= Circle::CFG_ROOT; + } + $this->assertSame($expectedConfig, $event->getData()->gInt('config')); + $this->assertSame($expectedConfig, $event->getOutcome()['config']); + } + + public function testDisablingFederationDoesNotRunVisitorGuard(): void { + [$handler, , $memberRequest, $permissionService] = $this->createSubject(); + $event = $this->createEvent( + $this->createCircle(Circle::CFG_ROOT | Circle::CFG_FEDERATED), + Circle::CFG_ROOT + ); + + $memberRequest->expects($this->never())->method('getMembers'); + $permissionService->expects($this->once())->method('confirmAllowedCircleTypes'); + + $handler->verify($event); + + $this->assertSame(Circle::CFG_ROOT, $event->getData()->gInt('config')); + } + + private function createSubject(): array { + $circleRequest = $this->createMock(CircleRequest::class); + $memberRequest = $this->createMock(MemberRequest::class); + $permissionService = $this->createMock(PermissionService::class); + $configService = $this->createMock(ConfigService::class); + + return [ + new CircleConfig($circleRequest, $permissionService, $configService, $memberRequest), + $circleRequest, + $memberRequest, + $permissionService, + ]; + } + + private function createCircle(int $config): Circle { + $initiator = (new Member())->setLevel(Member::LEVEL_ADMIN); + + return (new Circle()) + ->setSingleId('circle-single') + ->setConfig($config) + ->setInitiator($initiator); + } + + private function createEvent(Circle $circle, int $config): FederatedEvent { + return (new FederatedEvent()) + ->setCircle($circle) + ->setParams(new SimpleDataStore(['config' => $config])); + } +} diff --git a/tests/unit/lib/FederatedItems/CircleLeaveTest.php b/tests/unit/lib/FederatedItems/CircleLeaveTest.php new file mode 100644 index 000000000..13b8397e3 --- /dev/null +++ b/tests/unit/lib/FederatedItems/CircleLeaveTest.php @@ -0,0 +1,106 @@ +createMember(Member::LEVEL_VISITOR, 1); + $member = $this->createMember(Member::LEVEL_MEMBER, 10); + + foreach ([[$visitor, $member], [$member, $visitor]] as $candidates) { + $this->assertSame($member, $this->selectNewOwner($candidates)); + } + } + + public function testModeratorAndAdminOutrankVisitorAndMember(): void { + $visitor = $this->createMember(Member::LEVEL_VISITOR, 1); + $member = $this->createMember(Member::LEVEL_MEMBER, 2); + $moderator = $this->createMember(Member::LEVEL_MODERATOR, 3); + $admin = $this->createMember(Member::LEVEL_ADMIN, 4); + + $this->assertSame($moderator, $this->selectNewOwner([$visitor, $member, $moderator])); + $this->assertSame($admin, $this->selectNewOwner([$visitor, $member, $moderator, $admin])); + } + + public function testEqualRoleKeepsEarlierJoinedMember(): void { + $later = $this->createMember(Member::LEVEL_MEMBER, 20); + $earlier = $this->createMember(Member::LEVEL_MEMBER, 10); + + $this->assertSame($earlier, $this->selectNewOwner([$later, $earlier])); + } + + public function testEqualRolePrefersLocalMemberWhenJoinedDatesMatch(): void { + $remote = $this->createMember(Member::LEVEL_MEMBER, 10, 'remote.example'); + $local = $this->createMember(Member::LEVEL_MEMBER, 10, 'local.example'); + + $this->assertSame($local, $this->selectNewOwner([$remote, $local], ['local.example'])); + } + + public function testVisitorCanSucceedWhenItIsTheOnlyRemainingMember(): void { + $owner = $this->createMember(Member::LEVEL_OWNER, 1); + $visitor = $this->createMember(Member::LEVEL_VISITOR, 2); + + $this->assertSame($visitor, $this->selectNewOwner([$owner, $visitor])); + } + + public function testUnknownRoleRanksBelowVisitorAndMember(): void { + $unknown = $this->createMember(73, 1); + $visitor = $this->createMember(Member::LEVEL_VISITOR, 2); + $member = $this->createMember(Member::LEVEL_MEMBER, 3); + + $this->assertSame($visitor, $this->selectNewOwner([$unknown, $visitor])); + $this->assertSame($member, $this->selectNewOwner([$unknown, $member])); + } + + public function testNoCandidateStillThrows(): void { + $this->expectException(MemberNotFoundException::class); + $this->selectNewOwner([$this->createMember(Member::LEVEL_OWNER, 1)]); + } + + private function selectNewOwner(array $members, array $localInstances = []): Member { + $memberRequest = $this->createMock(MemberRequest::class); + $memberRequest->method('getMembers')->with('circle-single')->willReturn($members); + $configService = $this->createMock(ConfigService::class); + $configService->method('isLocalInstance')->willReturnCallback( + static fn (string $instance): bool => in_array($instance, $localInstances, true) + ); + $handler = new CircleLeave( + $memberRequest, + $this->createMock(CircleRequest::class), + $this->createMock(MembershipService::class), + $this->createMock(EventService::class), + $configService, + ); + $method = (new ReflectionClass(CircleLeave::class))->getMethod('selectNewOwner'); + + return $method->invoke($handler, (new Circle())->setSingleId('circle-single')); + } + + private function createMember(int $level, int $joined, string $instance = ''): Member { + return (new Member()) + ->setId('member-' . $level . '-' . $joined . '-' . $instance) + ->setSingleId('single-' . $level . '-' . $joined . '-' . $instance) + ->setLevel($level) + ->setJoined($joined) + ->setInstance($instance); + } +} diff --git a/tests/unit/lib/FederatedItems/MemberAddFriendTest.php b/tests/unit/lib/FederatedItems/MemberAddFriendTest.php new file mode 100644 index 000000000..bb79fa60b --- /dev/null +++ b/tests/unit/lib/FederatedItems/MemberAddFriendTest.php @@ -0,0 +1,143 @@ +createEvent(Member::LEVEL_VISITOR, $friend); + $eventService = $this->createMock(EventService::class); + $eventService->expects($this->never())->method('memberPreparing'); + $handler = $this->singleHandler($target, $eventService); + + $this->expectException(MemberLevelException::class); + $handler->verify($event); + } + + public function testSingleAddStillAllowsMemberInFriendCircle(): void { + [$event, $target] = $this->createEvent(Member::LEVEL_MEMBER, true); + $eventService = $this->createMock(EventService::class); + $eventService->expects($this->once())->method('memberPreparing')->with($event); + $handler = $this->singleHandler($target, $eventService); + + $handler->verify($event); + + $this->assertSame($target, $event->getMember()); + } + + /** + * @dataProvider friendModes + */ + public function testBulkAddRejectsvisitorInBothFriendModes(bool $friend): void { + [$event, $target] = $this->createEvent(Member::LEVEL_VISITOR, $friend); + $event->setMembers([$target]); + $eventService = $this->createMock(EventService::class); + $eventService->expects($this->never())->method('memberPreparing'); + $handler = $this->massiveHandler($target, $eventService); + + $this->expectException(MemberLevelException::class); + $handler->verify($event); + } + + public function testBulkAddStillAllowsMemberInFriendCircle(): void { + [$event, $target] = $this->createEvent(Member::LEVEL_MEMBER, true); + $event->setMembers([$target]); + $eventService = $this->createMock(EventService::class); + $eventService->expects($this->once())->method('memberPreparing')->with($event); + $handler = $this->massiveHandler($target, $eventService); + + $handler->verify($event); + + $this->assertSame([$target], $event->getMembers()); + } + + public static function friendModes(): array { + return [ + 'friend enabled' => [true], + 'friend disabled' => [false], + ]; + } + + private function singleHandler(Member $generated, EventService $eventService): SingleMemberAdd { + return new class($generated, $eventService) extends SingleMemberAdd { + public function __construct( + private Member $generated, + EventService $eventService, + ) { + $this->eventService = $eventService; + } + + protected function generateMember(FederatedEvent $event, Circle $circle, Member $member): Member { + return $this->generated; + } + + public function serialize(JsonSerializable $model): array { + return []; + } + }; + } + + private function massiveHandler(Member $generated, EventService $eventService): MassiveMemberAdd { + return new class($generated, $eventService) extends MassiveMemberAdd { + public function __construct( + private Member $generated, + EventService $eventService, + ) { + $this->eventService = $eventService; + } + + protected function generateMember(FederatedEvent $event, Circle $circle, Member $member): Member { + return $this->generated; + } + + public function serialize(JsonSerializable $model): array { + return []; + } + + public function serializeArray(array $data): array { + return []; + } + }; + } + + private function createEvent(int $initiatorLevel, bool $friend): array { + $initiator = (new Member()) + ->setId('initiator') + ->setSingleId('initiator-single') + ->setLevel($initiatorLevel); + $target = (new Member()) + ->setId('target') + ->setSingleId('target-single') + ->setUserId('target-user') + ->setLevel(Member::LEVEL_MEMBER) + ->setStatus(Member::STATUS_MEMBER); + $circle = (new Circle()) + ->setSingleId('circle-single') + ->setConfig($friend ? Circle::CFG_FRIEND : Circle::CFG_CIRCLE) + ->setInitiator($initiator); + $event = (new FederatedEvent()) + ->setCircle($circle) + ->setMember($target); + + return [$event, $target]; + } +} diff --git a/tests/unit/lib/FederatedItems/MemberLevelTest.php b/tests/unit/lib/FederatedItems/MemberLevelTest.php new file mode 100644 index 000000000..d42390206 --- /dev/null +++ b/tests/unit/lib/FederatedItems/MemberLevelTest.php @@ -0,0 +1,250 @@ +createSubject(); + $configService->method('getAppValueInt') + ->with(ConfigService::HARD_MODERATION) + ->willReturn(0); + $event = $this->createEvent(Member::LEVEL_ADMIN, Member::LEVEL_VISITOR, Member::LEVEL_MEMBER); + + $handler->verify($event); + + $this->assertSame(Member::LEVEL_MEMBER, $event->getData()->gInt('level')); + $this->assertSame(Member::LEVEL_MEMBER, $event->getOutcome()['level']); + } + + public function testLocalVisitorAssignmentSucceeds(): void { + [$handler, , , , $configService] = $this->createSubject(); + $configService->method('getAppValueInt') + ->with(ConfigService::HARD_MODERATION) + ->willReturn(0); + $configService->method('isLocalInstance')->with('')->willReturn(true); + $event = $this->createEvent(Member::LEVEL_ADMIN, Member::LEVEL_MEMBER, Member::LEVEL_VISITOR); + + $handler->verify($event); + + $this->assertSame(Member::LEVEL_VISITOR, $event->getData()->gInt('level')); + $this->assertSame(Member::LEVEL_VISITOR, $event->getOutcome()['level']); + } + + /** + * @dataProvider disallowedVisitorTargets + */ + public function testVisitorAssignmentRequiresLocalTeamAndMember(int $circleConfig, string $instance, bool $local): void { + [$handler, $memberRequest, $membershipService, $eventService, $configService] = $this->createSubject(); + $event = $this->createEvent( + Member::LEVEL_ADMIN, + Member::LEVEL_MEMBER, + Member::LEVEL_VISITOR, + $circleConfig, + $instance + ); + + if ($local) { + $configService->expects($this->never())->method('isLocalInstance'); + } else { + $configService->expects($this->once())->method('isLocalInstance')->with($instance)->willReturn(false); + } + $memberRequest->expects($this->never())->method('updateLevel'); + $membershipService->expects($this->never())->method('onUpdate'); + $eventService->expects($this->never())->method('memberLevelEditing'); + $this->expectException(FederatedItemBadRequestException::class); + $this->expectExceptionMessage('Visitor requires a local team and member'); + + $handler->verify($event); + } + + public static function disallowedVisitorTargets(): array { + return [ + 'federated team' => [Circle::CFG_FEDERATED, '', true], + 'remote member' => [Circle::CFG_CIRCLE, 'remote.example', false], + ]; + } + + /** + * @dataProvider moderationModeCases + */ + public function testModerationModesUseActorAndTargetLevels(int $mode, int $targetLevel, int $requestedLevel, bool $allowed): void { + [$handler, , , , $configService] = $this->createSubject(); + $configService->method('getAppValueInt') + ->with(ConfigService::HARD_MODERATION) + ->willReturn($mode); + $configService->method('isLocalInstance')->willReturn(true); + $event = $this->createEvent(Member::LEVEL_MODERATOR, $targetLevel, $requestedLevel); + + if (!$allowed) { + $this->expectException(MemberLevelException::class); + } + + $handler->verify($event); + + if ($allowed) { + $this->assertSame($requestedLevel, $event->getData()->gInt('level')); + } + } + + public static function moderationModeCases(): array { + return [ + 'mode 0 accepts a lower role and lower target' => [0, Member::LEVEL_MEMBER, Member::LEVEL_VISITOR, true], + 'mode 1 accepts a lower role and lower target' => [1, Member::LEVEL_MEMBER, Member::LEVEL_VISITOR, true], + 'mode 2 accepts a lower role and lower target' => [2, Member::LEVEL_MEMBER, Member::LEVEL_VISITOR, true], + 'mode 0 accepts a peer target' => [0, Member::LEVEL_MODERATOR, Member::LEVEL_VISITOR, true], + 'mode 1 rejects a peer target' => [1, Member::LEVEL_MODERATOR, Member::LEVEL_VISITOR, false], + 'mode 2 rejects a peer target' => [2, Member::LEVEL_MODERATOR, Member::LEVEL_VISITOR, false], + 'mode 0 accepts an equal requested role' => [0, Member::LEVEL_MEMBER, Member::LEVEL_MODERATOR, true], + 'mode 1 accepts an equal requested role' => [1, Member::LEVEL_MEMBER, Member::LEVEL_MODERATOR, true], + 'mode 2 rejects an equal requested role' => [2, Member::LEVEL_MEMBER, Member::LEVEL_MODERATOR, false], + ]; + } + + /** + * @dataProvider nonModeratorInitiators + */ + public function testNonModeratorAndPendingInitiatorsCannotChangeRoles(int $level, string $status): void { + [$handler, , , , $configService] = $this->createSubject(); + $configService->method('getAppValueInt')->willReturn(0); + $configService->method('isLocalInstance')->willReturn(true); + $event = $this->createEvent($level, Member::LEVEL_MEMBER, Member::LEVEL_VISITOR, 0, '', $status); + + $this->expectException(MemberLevelException::class); + $handler->verify($event); + } + + public static function nonModeratorInitiators(): array { + return [ + 'visitor' => [Member::LEVEL_VISITOR, Member::STATUS_MEMBER], + 'member' => [Member::LEVEL_MEMBER, Member::STATUS_MEMBER], + 'pending' => [Member::LEVEL_NONE, Member::STATUS_REQUEST], + ]; + } + + /** + * @dataProvider invalidLevels + */ + public function testUnknownAndNoneLevelsAreRejected(int $level): void { + [$handler] = $this->createSubject(); + $event = $this->createEvent(Member::LEVEL_ADMIN, Member::LEVEL_MEMBER, $level); + + $this->expectException(FederatedItemBadRequestException::class); + $this->expectExceptionMessage('invalid level'); + $handler->verify($event); + } + + public static function invalidLevels(): array { + $levels = [0, -1, 3, 5, 6, 7, 10]; + + return array_combine( + array_map(static fn (int $level): string => (string)$level, $levels), + array_map(static fn (int $level): array => [$level], $levels) + ); + } + + public function testCurrentOwnerCannotBeDemoted(): void { + [$handler, , , , $configService] = $this->createSubject(); + $configService->method('getAppValueInt')->willReturn(0); + $event = $this->createEvent(Member::LEVEL_OWNER, Member::LEVEL_OWNER, Member::LEVEL_MEMBER); + + $this->expectException(MemberLevelException::class); + $handler->verify($event); + } + + public function testOwnerCanPromoteExistingVisitorToOwner(): void { + [$handler, , , , $configService] = $this->createSubject(); + $event = $this->createEvent(Member::LEVEL_OWNER, Member::LEVEL_VISITOR, Member::LEVEL_OWNER); + $configService->method('isLocalInstance')->willReturn(true); + + $handler->verify($event); + + $this->assertSame(Member::LEVEL_OWNER, $event->getData()->gInt('level')); + $this->assertSame(Member::LEVEL_OWNER, $event->getOutcome()['level']); + } + + public function testManagePreservesUnknownLevelAndInvalidatesMembershipCache(): void { + [$handler, $memberRequest, $membershipService, $eventService] = $this->createSubject(); + $member = (new Member()) + ->setId('target') + ->setSingleId('target-single') + ->setLevel(Member::LEVEL_MEMBER); + $event = (new FederatedEvent()) + ->setMember($member) + ->setData(new SimpleDataStore(['level' => 73])); + + $memberRequest->expects($this->once()) + ->method('updateLevel') + ->with($this->callback(static fn (Member $updated): bool => $updated->getLevel() === 73)); + $membershipService->expects($this->once())->method('onUpdate')->with('target-single'); + $eventService->expects($this->once())->method('memberLevelEditing')->with($event); + + $handler->manage($event); + + $this->assertSame(Member::LEVEL_MEMBER, $event->getMember()->getLevel()); + } + + private function createSubject(): array { + $memberRequest = $this->createMock(MemberRequest::class); + $membershipService = $this->createMock(MembershipService::class); + $eventService = $this->createMock(EventService::class); + $configService = $this->createMock(ConfigService::class); + + return [ + new MemberLevel($memberRequest, $membershipService, $eventService, $configService), + $memberRequest, + $membershipService, + $eventService, + $configService, + ]; + } + + private function createEvent( + int $initiatorLevel, + int $targetLevel, + int $requestedLevel, + int $circleConfig = Circle::CFG_CIRCLE, + string $targetInstance = '', + string $initiatorStatus = Member::STATUS_MEMBER, + ): FederatedEvent { + $initiator = (new Member()) + ->setId('initiator') + ->setSingleId('initiator-single') + ->setLevel($initiatorLevel) + ->setStatus($initiatorStatus); + $target = (new Member()) + ->setId('target') + ->setSingleId('target-single') + ->setInstance($targetInstance) + ->setLevel($targetLevel) + ->setStatus(Member::STATUS_MEMBER); + $circle = (new Circle()) + ->setSingleId('circle-single') + ->setConfig($circleConfig) + ->setInitiator($initiator); + + return (new FederatedEvent()) + ->setCircle($circle) + ->setMember($target) + ->setParams(new SimpleDataStore(['level' => $requestedLevel])); + } +} diff --git a/tests/unit/lib/Model/MemberTest.php b/tests/unit/lib/Model/MemberTest.php new file mode 100644 index 000000000..65b34cbfc --- /dev/null +++ b/tests/unit/lib/Model/MemberTest.php @@ -0,0 +1,70 @@ +assertSame(Member::LEVEL_VISITOR, Member::parseLevelInt(2)); + $this->assertSame(Member::LEVEL_VISITOR, Member::parseLevelString('Visitor')); + $this->assertSame(Member::LEVEL_VISITOR, Member::parseLevelString('visitor')); + + foreach (Member::$DEF_LEVEL as $level => $label) { + $this->assertSame($level, Member::parseLevelInt($level)); + $this->assertSame($level, Member::parseLevelString($label)); + } + } + + public function testMemberSatisfiesVisitorMinimum(): void { + $member = (new Member())->setLevel(Member::LEVEL_MEMBER); + + $this->assertNull((new MemberHelper($member))->mustBeVisitor()); + } + + public function testVisitorFailsMemberHelperMemberCheck(): void { + $visitor = (new Member())->setLevel(Member::LEVEL_VISITOR); + + $this->expectException(MemberLevelException::class); + (new MemberHelper($visitor))->mustBeMember(); + } + + public function testNoneAndUnknownLevelsHaveRankZero(): void { + $this->assertSame(0, Member::getLevelRank(Member::LEVEL_NONE)); + foreach ([-1, 3, 5, 6, 7, 10, 99] as $level) { + $this->assertSame(0, Member::getLevelRank($level)); + $this->assertSame(0, Member::compareLevels($level, Member::LEVEL_NONE)); + } + } + + public function testCompareLevelsUsesSemanticRoleOrder(): void { + $this->assertGreaterThan(0, Member::compareLevels(Member::LEVEL_MEMBER, Member::LEVEL_VISITOR)); + $this->assertLessThan(0, Member::compareLevels(Member::LEVEL_VISITOR, Member::LEVEL_MEMBER)); + } + + public function testRankMapKeysMatchAssignableLevelsAndNone(): void { + $levelKeys = array_keys(Member::$DEF_LEVEL); + $levelKeys[] = Member::LEVEL_NONE; + $rankKeys = array_keys(Member::LEVEL_RANKS); + sort($levelKeys); + sort($rankKeys); + + $this->assertSame($levelKeys, $rankKeys); + } + + public function testNoneIsNotAnAssignableLevel(): void { + $this->expectException(ParseMemberLevelException::class); + Member::parseLevelInt(Member::LEVEL_NONE); + } +} diff --git a/tests/unit/lib/Service/FederatedEventServiceTest.php b/tests/unit/lib/Service/FederatedEventServiceTest.php new file mode 100644 index 000000000..c1bb7c1a9 --- /dev/null +++ b/tests/unit/lib/Service/FederatedEventServiceTest.php @@ -0,0 +1,67 @@ +createSubject(); + $event = $this->createEvent(Member::LEVEL_VISITOR); + + $this->expectException(InitiatorNotConfirmedException::class); + $service->confirmInitiator($event); + } + + public function testMemberCanConfirmAsInitiator(): void { + $service = $this->createSubject(); + $event = $this->createEvent(Member::LEVEL_MEMBER); + + $service->confirmInitiator($event); + $this->addToAssertionCount(1); + } + + private function createEvent(int $level): FederatedEvent { + $initiator = (new Member()) + ->setInstance('test-instance') + ->setLevel($level); + $circle = (new Circle())->setInitiator($initiator); + + return (new FederatedEvent()) + ->setSender('test-instance') + ->setCircle($circle); + } + + private function createSubject(): FederatedEventService { + return new FederatedEventService( + $this->createMock(IAppConfig::class), + $this->createMock(EventWrapperRequest::class), + $this->createMock(RemoteRequest::class), + $this->createMock(MemberRequest::class), + $this->createMock(RemoteUpstreamService::class), + $this->createMock(EventService::class), + $this->createMock(InterfaceService::class), + $this->createMock(ConfigService::class), + ); + } +} diff --git a/tests/unit/lib/Service/MembershipServiceTest.php b/tests/unit/lib/Service/MembershipServiceTest.php new file mode 100644 index 000000000..6348a65d0 --- /dev/null +++ b/tests/unit/lib/Service/MembershipServiceTest.php @@ -0,0 +1,105 @@ +createMembership(Member::LEVEL_VISITOR, 0, 'visitor-path'); + + $memberships = $this->fill([$visitor]); + + $this->assertSame($visitor, $memberships['destination']); + } + + public function testMemberWinsOverVisitorRegardlessOfTraversalOrder(): void { + $visitor = $this->createMembership(Member::LEVEL_VISITOR, 0, 'direct-visitor'); + $member = $this->createMembership(Member::LEVEL_MEMBER, 2, 'inherited-member'); + + foreach ([[$visitor, $member], [$member, $visitor]] as $traversal) { + $memberships = $this->fill($traversal); + + $this->assertSame(Member::LEVEL_MEMBER, $memberships['destination']->getLevel()); + } + } + + public function testEqualRolePrefersShorterInheritancePath(): void { + $longPath = $this->createMembership(Member::LEVEL_MEMBER, 3, 'long-path'); + $shortPath = $this->createMembership(Member::LEVEL_MEMBER, 1, 'short-path'); + + $memberships = $this->fill([$longPath, $shortPath]); + + $this->assertSame($shortPath->getInheritanceFirst(), $memberships['destination']->getInheritanceFirst()); + $this->assertSame(1, $memberships['destination']->getInheritanceDepth()); + } + + public function testHigherRolesRemainPreferred(): void { + foreach ([Member::LEVEL_MODERATOR, Member::LEVEL_ADMIN, Member::LEVEL_OWNER] as $level) { + $higher = $this->createMembership($level, 4, 'higher-role'); + $visitor = $this->createMembership(Member::LEVEL_VISITOR, 0, 'visitor'); + + $memberships = $this->fill([$higher, $visitor]); + + $this->assertSame($level, $memberships['destination']->getLevel()); + } + } + + public function testUnknownMembershipRanksBelowVisitorAndMember(): void { + foreach ([Member::LEVEL_VISITOR, Member::LEVEL_MEMBER] as $knownLevel) { + $unknown = $this->createMembership(73, 0, 'unknown'); + $known = $this->createMembership($knownLevel, 4, 'known'); + + $memberships = $this->fill([$unknown, $known]); + + $this->assertSame($knownLevel, $memberships['destination']->getLevel()); + } + } + + public function testUnknownMembershipDoesNotReplaceVisitorOrMember(): void { + foreach ([Member::LEVEL_VISITOR, Member::LEVEL_MEMBER] as $knownLevel) { + $known = $this->createMembership($knownLevel, 0, 'known'); + $unknown = $this->createMembership(73, 4, 'unknown'); + + $memberships = $this->fill([$known, $unknown]); + + $this->assertSame($knownLevel, $memberships['destination']->getLevel()); + } + } + + private function fill(array $incoming): array { + $service = (new ReflectionClass(MembershipService::class))->newInstanceWithoutConstructor(); + $method = (new ReflectionClass(MembershipService::class))->getMethod('fillMemberships'); + $memberships = []; + + foreach ($incoming as $membership) { + $arguments = [$membership]; + $arguments[] = &$memberships; + $method->invokeArgs($service, $arguments); + } + + return $memberships; + } + + private function createMembership(int $level, int $depth, string $path): Membership { + return (new Membership()) + ->setSingleId('member-single') + ->setCircleId('destination') + ->setLevel($level) + ->setInheritanceDepth($depth) + ->setInheritanceFirst($path) + ->setInheritanceLast($path) + ->setInheritancePath([$path]); + } +}