diff --git a/app/Services/Model/Imp/MemberService.php b/app/Services/Model/Imp/MemberService.php index ec9655e1f..60a57cff0 100644 --- a/app/Services/Model/Imp/MemberService.php +++ b/app/Services/Model/Imp/MemberService.php @@ -539,7 +539,7 @@ public function synchronizeGroups(Member $member, array $groups, bool $allow_rem continue; // skipping this groups bc are managed by SS side - if(in_array($group->getCode(), [IGroup::FoundationMembers, IGroup::CommunityMembers, IGroup::TrackChairs, IGroup::Sponsors])){ + if(in_array($group->getCode(), [IGroup::FoundationMembers, IGroup::CommunityMembers, IGroup::TrackChairs])){ Log::debug(sprintf("MemberService::synchronizeGroups skipping group %s removal", $group->getCode())); continue; } @@ -558,6 +558,15 @@ public function synchronizeGroups(Member $member, array $groups, bool $allow_rem $member->removeFromGroup($group); } } + // the IDP is the source of truth: a sponsor group that is gone takes its Sponsor_Users permission slug with it + if(in_array($externalGroup->getCode(), [IGroup::Sponsors, IGroup::SponsorExternalUsers])){ + Log::debug(sprintf("MemberService::synchronizeGroups member %s email %s stripping Sponsor_Users permission for group %s", $member->getId(), $member->getEmail(), $externalGroup->getCode())); + // same primitive the sponsor-users-api removal event uses; the Sponsor_Users rows stay + // (their lifecycle belongs to the access right events), only the slug is stripped + foreach ($member->getSponsorMemberships() as $sponsor) { + $member->removeSponsorPermission($sponsor->getId(), $externalGroup->getCode()); + } + } Log::debug(sprintf("MemberService::synchronizeGroups member %s email %s removing from group %s", $member->getId(), $member->getEmail(), $externalGroup->getCode())); $member->removeFromGroup($externalGroup); } diff --git a/tests/Unit/Services/MemberServiceSponsorGroupSyncTest.php b/tests/Unit/Services/MemberServiceSponsorGroupSyncTest.php new file mode 100644 index 000000000..427019400 --- /dev/null +++ b/tests/Unit/Services/MemberServiceSponsorGroupSyncTest.php @@ -0,0 +1,177 @@ +addUser(self::$member); + + self::$em->flush(); + self::$em->clear(); + } + + public function tearDown(): void + { + self::clearSummitTestData(); + self::clearMemberTestData(); + parent::tearDown(); + } + + private function setPermissions(int $sponsor_id, int $member_id, array $slugs): void + { + self::$em->getConnection()->executeStatement( + 'UPDATE Sponsor_Users SET Permissions = ? WHERE SponsorID = ? AND MemberID = ?', + [json_encode($slugs), $sponsor_id, $member_id] + ); + } + + /** + * @return array|null decoded Permissions of the row, null when the row does not exist + */ + private function getPermissions(int $sponsor_id, int $member_id): ?array + { + $res = self::$em->getConnection()->fetchOne( + 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ?', + [$sponsor_id, $member_id] + ); + return $res === false ? null : json_decode($res ?? '[]', true); + } + + private function sync(array $groups, bool $allow_removals): void + { + $member = self::$member_repository->find(self::$member->getId()); + App::make(IMemberService::class)->synchronizeGroups($member, $groups, $allow_removals); + self::$em->clear(); + } + + public function testFullSyncWithoutSponsorsGroupRemovesLocalGroupAndStripsSlugFromSponsorUsersRow(): void + { + $member_id = self::$member->getId(); + $sponsor_id = self::$sponsors[0]->getId(); + $this->setPermissions($sponsor_id, $member_id, [IGroup::Sponsors]); + + $this->sync([], true); + + $member = self::$member_repository->find($member_id); + $this->assertFalse($member->belongsToGroup(IGroup::Sponsors), "local sponsors group must be removed"); + // the membership itself is owned by the sponsor-users-api access right events, not by the group sync + $this->assertEquals([], $this->getPermissions($sponsor_id, $member_id), "slug must be stripped, row must stay"); + } + + public function testFullSyncKeepsSponsorsGroupAndSponsorUsersRowWhenPayloadStillHasIt(): void + { + $member_id = self::$member->getId(); + $sponsor_id = self::$sponsors[0]->getId(); + $this->setPermissions($sponsor_id, $member_id, [IGroup::Sponsors]); + + $this->sync([IGroup::Sponsors], true); + + $member = self::$member_repository->find($member_id); + $this->assertTrue($member->belongsToGroup(IGroup::Sponsors)); + $this->assertEquals([IGroup::Sponsors], $this->getPermissions($sponsor_id, $member_id)); + } + + public function testAdditiveSyncNeverRemovesSponsorsGroupNorSponsorUsersRow(): void + { + $member_id = self::$member->getId(); + $sponsor_id = self::$sponsors[0]->getId(); + $this->setPermissions($sponsor_id, $member_id, [IGroup::Sponsors]); + + $this->sync([], false); + + $member = self::$member_repository->find($member_id); + $this->assertTrue($member->belongsToGroup(IGroup::Sponsors)); + $this->assertEquals([IGroup::Sponsors], $this->getPermissions($sponsor_id, $member_id)); + } + + public function testFullSyncKeepsRowAndOtherSlugsWhenRowHoldsMoreThanTheRemovedGroup(): void + { + $member_id = self::$member->getId(); + $sponsor_id = self::$sponsors[0]->getId(); + $this->setPermissions($sponsor_id, $member_id, [IGroup::Sponsors, IGroup::SponsorServices]); + + $this->sync([], true); + + $member = self::$member_repository->find($member_id); + $this->assertFalse($member->belongsToGroup(IGroup::Sponsors)); + $this->assertEquals( + [IGroup::SponsorServices], + $this->getPermissions($sponsor_id, $member_id), + "only the removed slug must be stripped; the row keeps the other one" + ); + } + + public function testFullSyncWithoutExternalUsersGroupRemovesLocalGroupAndStripsSlugFromSponsorUsersRow(): void + { + $member_id = self::$member->getId(); + $sponsor_id = self::$sponsors[0]->getId(); + + $external = new Group(); + $external->setCode(IGroup::SponsorExternalUsers); + $external->setTitle(IGroup::SponsorExternalUsers); + $external->setDescription(IGroup::SponsorExternalUsers); + $external->setExternal(); + self::$em->persist($external); + $member = self::$member_repository->find($member_id); + $member->add2Group($external); + self::$em->flush(); + self::$em->clear(); + + $this->setPermissions($sponsor_id, $member_id, [IGroup::SponsorExternalUsers]); + + try { + // payload keeps sponsors (so the row's remaining access is judged on its own slug) but drops external users + $this->sync([IGroup::Sponsors], true); + + $member = self::$member_repository->find($member_id); + $this->assertTrue($member->belongsToGroup(IGroup::Sponsors)); + $this->assertFalse($member->belongsToGroup(IGroup::SponsorExternalUsers)); + $this->assertEquals([], $this->getPermissions($sponsor_id, $member_id)); + } finally { + $g = self::$em->getRepository(Group::class)->findOneBy(['code' => IGroup::SponsorExternalUsers]); + if (!is_null($g)) { + self::$em->remove($g); + self::$em->flush(); + } + } + } +}