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