diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php index 64e57f33d1..5053fd7a0d 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php @@ -95,6 +95,7 @@ function getAddValidationRules(array $payload): array return [ 'qr_code' => 'required_without:attendee_email|string', 'attendee_email' => 'required_without:qr_code|email', + 'sponsor_id' => 'sometimes|integer', 'scan_date' => 'required|date_format:U|epoch_seconds', 'notes' => 'sometimes|string|max:1024', 'extra_questions' => 'sometimes|extra_question_dto_array', @@ -115,6 +116,9 @@ function getCheckInValidationRules(): array * @param Summit $summit * @param array $payload * @return IEntity + * @throws EntityNotFoundException + * @throws HTTP403ForbiddenException + * @throws ValidationException */ protected function addChild(Summit $summit, array $payload): IEntity { @@ -381,7 +385,7 @@ function($filter) use($summit, $current_member){ if (!is_null($current_member)){ if ($current_member->isAuthzFor($summit)) return $filter; // add filter for sponsor user - if ($current_member->isSponsorUser()) { + if ($current_member->isSponsorUser() || $current_member->isExternalSponsorUser()) { $sponsor_ids = $current_member->getSponsorMembershipIds($summit); // is allowed sponsors are empty, add dummy value if (!count($sponsor_ids)) $sponsor_ids[] = 0; @@ -506,7 +510,7 @@ function($filter) use($summit, $current_member){ if (!is_null($current_member)){ if ($current_member->isAuthzFor($summit)) return $filter; // add filter for sponsor user - if ($current_member->isSponsorUser()) { + if ($current_member->isSponsorUser() || $current_member->isExternalSponsorUser()) { $sponsor_ids = $current_member->getSponsorMembershipIds($summit); // is allowed sponsors are empty, add dummy value if (!count($sponsor_ids)) $sponsor_ids[] = 0; diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php index e99e12ba5f..2b0b96fde2 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php @@ -187,7 +187,7 @@ protected function applyExtraFilters(Filter $filter):Filter { // check AUTHZ for sponsors if($current_member->isAuthzFor($summit)) return $filter; // add filter for sponsor user - if ($current_member->isSponsorUser()) { + if ($current_member->isSponsorUser() || $current_member->isExternalSponsorUser()) { $sponsor_ids = $current_member->getSponsorMembershipIds($summit); // is allowed sponsors are empty, add dummy value if (!count($sponsor_ids)) $sponsor_ids[] = 0; diff --git a/app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php b/app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php index a014a17f7c..2442aa7327 100644 --- a/app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php +++ b/app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php @@ -60,13 +60,23 @@ public function handle(SponsorServicesMQJob $job): void Log::debug("UpdateSponsorMemberGroupsMQJob::handle payload {$json}"); $data = $payload['data']; + if (!isset($data['user_external_id'], $data['group_slug'], $data['sponsor_id'], $data['summit_id'])) { + throw new ValidationException('Invalid payload: user_external_id, group_slug, sponsor_id and summit_id are required.'); + } + $user_external_id = intval($data['user_external_id']); $group_slug = $data['group_slug']; + $sponsor_id = intval($data['sponsor_id']); + $summit_id = intval($data['summit_id']); + + if ($user_external_id <= 0 || $sponsor_id <= 0 || $summit_id <= 0 || trim((string)$group_slug) === '') { + throw new ValidationException('Invalid payload: identifiers must be positive and group_slug must be non-empty.'); + } if ($event_type === EventTypes::AUTH_USER_ADDED_TO_GROUP) { - $this->service->addSponsorUserToGroup($user_external_id, $group_slug); + $this->service->addSponsorUserToGroup($user_external_id, $group_slug, $sponsor_id, $summit_id); } else if ($event_type === EventTypes::AUTH_USER_REMOVED_FROM_GROUP) { - $this->service->removeSponsorUserFromGroup($user_external_id, $group_slug); + $this->service->removeSponsorUserFromGroup($user_external_id, $group_slug, $sponsor_id, $summit_id); } $job->delete(); } catch (\Exception $ex) { @@ -83,4 +93,4 @@ public function failed(array $data, Throwable $exception): void { Log::error("UpdateSponsorMemberGroupsMQJob::failed {$exception->getMessage()}"); } -} \ No newline at end of file +} diff --git a/app/Models/Foundation/Main/Member.php b/app/Models/Foundation/Main/Member.php index 30f99be79b..76c57f37c4 100644 --- a/app/Models/Foundation/Main/Member.php +++ b/app/Models/Foundation/Main/Member.php @@ -19,6 +19,7 @@ use App\Models\Foundation\Main\IGroup; use App\Models\Foundation\Main\Strategies\MemberSummitStrategyFactory; use App\Models\Foundation\Summit\Events\RSVP\RSVPInvitation; +use Doctrine\DBAL\Exception; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Illuminate\Support\Facades\Config; use LaravelDoctrine\ORM\Facades\EntityManager; @@ -1832,25 +1833,71 @@ public function getLastNSponsorMemberships($last_n = 2) */ public function getActiveSummitsSponsorMemberships() { - $dql = <<= :now -ORDER BY s.begin_date ASC -DQL; - - $query = $this->createQuery($dql); - return $query - ->setParameter('member_id', $this->getId()) - ->setParameter('now', new \DateTime('now', new \DateTimeZone('UTC'))) + // Step 1 — use native SQL (needed for JSON_CONTAINS) to collect IDs only. + $idSql = <<= :now + AND ( + JSON_CONTAINS(COALESCE(su.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) + OR JSON_CONTAINS(COALESCE(su.Permissions, '[]'), JSON_QUOTE(:slug_external)) + ) +ORDER BY s.SummitBeginDate ASC +SQL; + + $stmt = $this->prepareRawSQL($idSql, [ + 'member_id' => $this->getId(), + 'now' => (new \DateTime('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'), + 'slug_sponsors' => IGroup::Sponsors, + 'slug_external' => IGroup::SponsorExternalUsers, + ]); + $ids = $stmt->executeQuery()->fetchFirstColumn(); + + if (empty($ids)) { + return []; + } + + return $this->loadSponsorsByIds($ids); + } + + /** + * @param Summit $summit + * @return ArrayCollection + * @throws Exception + */ + public function getAccessibleSponsorsBySummit(Summit $summit): ArrayCollection + { + $ids = $this->getSponsorMembershipIds($summit); + + return new ArrayCollection($this->loadSponsorsByIds($ids)); + } + + /** + * Loads Sponsor entities by PK using DQL rather than native-query hydration. + * findBy()-style PK hydration avoids the ORM 3 assertion failure triggered by + * the OneToOne inverse associations on Sponsor (lead_report_setting, + * sponsorservices_statistics) that DQL/native-query hydration hits. + * + * @param int[] $ids + * @return Sponsor[] + */ + private function loadSponsorsByIds(array $ids): array + { + if (empty($ids)) return []; + + return $this->getEM() + ->createQuery('SELECT s FROM ' . Sponsor::class . ' s WHERE s.id IN (:ids) ORDER BY s.id ASC') + ->setParameter('ids', $ids) ->getResult(); } /** + * @param Summit $summit * @return array + * @throws Exception */ public function getSponsorMembershipIds(Summit $summit): array { @@ -1859,11 +1906,17 @@ public function getSponsorMembershipIds(Summit $summit): array FROM Sponsor_Users INNER JOIN Sponsor ON Sponsor.ID = Sponsor_Users.SponsorID WHERE MemberID = :member_id AND Sponsor.SummitID = :summit_id + AND ( + JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) + OR JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_external)) + ) SQL; - $stmt = $this->prepareRawSQL($sql, [ - 'member_id' => $this->getId(), - 'summit_id' => $summit->getId(), + $stmt = $this->prepareRawSQL($sql, [ + 'member_id' => $this->getId(), + 'summit_id' => $summit->getId(), + 'slug_sponsors' => IGroup::Sponsors, + 'slug_external' => IGroup::SponsorExternalUsers, ]); $res = $stmt->executeQuery(); return $res->fetchFirstColumn(); @@ -1872,8 +1925,9 @@ public function getSponsorMembershipIds(Summit $summit): array public function hasSponsorMembershipsFor(Summit $summit, Sponsor $sponsor = null): bool { try { - $canHaveSponsorMemberships = $this->isSponsorUser() || $this->isExternalSponsorUser(); - if(!$canHaveSponsorMemberships) return false; + $canHaveSponsorMemberships = $this->isSponsorUser() || $this->isExternalSponsorUser(); + if(!$canHaveSponsorMemberships) return false; + $sql = << $this->getId(), - 'summit_id' => $summit->getId(), + $params = [ + 'member_id' => $this->getId(), + 'summit_id' => $summit->getId(), + 'slug_sponsors' => IGroup::Sponsors, + 'slug_external' => IGroup::SponsorExternalUsers, ]; if(!is_null($sponsor)) { @@ -1954,19 +2014,6 @@ public function addSummitRegistrationOrder(SummitOrder $summit_order) $summit_order->setOwner($this); } - /** - * @param Summit $summit - * @return Sponsor|null - */ - public function getSponsorBySummit(Summit $summit): ?Sponsor - { - $sponsor = $this->sponsor_memberships->filter(function ($entity) use ($summit) { - return $entity->getSummitId() == $summit->getId(); - })->first(); - - return $sponsor === false ? null : $sponsor; - } - /** * @return string|null */ @@ -3412,6 +3459,97 @@ public function getIndividualMemberJoinDate(): ?\DateTime return $this->individual_member_join_date; } + /** + * Appends $group_slug to the Permissions JSON array on the Sponsor_Users row + * for this member and the given sponsor. Idempotent: the slug is only added + * when it is not already present. + * + * An exclusive row lock (SELECT … FOR UPDATE) is acquired first so that + * concurrent jobs for the same (member, sponsor, slug) serialize here and + * the second job always reads the post-first-job value, preventing duplicates. + * + * Returns the number of rows matched by the WHERE clause (0 when the + * Sponsor_Users row does not yet exist, 1 when it does). + */ + public function addSponsorPermission(int $sponsor_id, string $group_slug): int + { + // Lock the row before the read-modify-write so concurrent transactions + // serialize and the IF(JSON_CONTAINS) in the UPDATE sees the committed state. + $this->prepareRawSQL( + 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE', + ['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()] + )->executeQuery(); + + $sql = <<prepareRawSQL($sql, [ + 'group_slug' => $group_slug, + 'sponsor_id' => $sponsor_id, + 'member_id' => $this->getId(), + ])->executeStatement(); + } + + /** + * Removes $group_slug from the Permissions JSON array on the Sponsor_Users row + * for this member and the given sponsor, then returns how many other Sponsor_Users + * rows for this member still carry that slug. The caller uses the count to decide + * whether to also revoke the global group membership. + * + * An exclusive row lock is acquired first so the remove UPDATE and the + * remaining-count SELECT are not interleaved with concurrent operations. + * All occurrences of the slug are removed (via JSON_ARRAYAGG filter) to + * prevent stale entries if a prior race introduced duplicates. + */ + public function removeSponsorPermission(int $sponsor_id, string $group_slug): int + { + // Serialize concurrent removals for the same row. + $this->prepareRawSQL( + 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE', + ['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()] + )->executeQuery(); + + // Remove ALL occurrences (not just the first) so duplicate slugs + // introduced by any prior race cannot leave stale entries behind. + $removeSQL = <<prepareRawSQL($removeSQL, [ + 'group_slug' => $group_slug, + 'sponsor_id' => $sponsor_id, + 'member_id' => $this->getId(), + ])->executeStatement(); + + $countSQL = <<prepareRawSQL($countSQL, [ + 'member_id' => $this->getId(), + 'group_slug' => $group_slug, + ])->executeQuery()->fetchOne()); + } + public function addSponsorMembership(Sponsor $sponsor):void { if($this->sponsor_memberships->contains($sponsor)) return; diff --git a/app/Models/Foundation/Main/Strategies/SponsorMemberSummitStrategy.php b/app/Models/Foundation/Main/Strategies/SponsorMemberSummitStrategy.php index db51855c86..1911ef04bd 100644 --- a/app/Models/Foundation/Main/Strategies/SponsorMemberSummitStrategy.php +++ b/app/Models/Foundation/Main/Strategies/SponsorMemberSummitStrategy.php @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use App\Models\Foundation\Main\IGroup; use LaravelDoctrine\ORM\Facades\Registry; use Libs\Utils\Doctrine\DoctrineStatementValueBinder; use models\summit\Summit; @@ -41,13 +42,19 @@ public function getAllAllowedSummitIds(): array $sql = <<getConnection()->prepare($sql), [ - 'member_id' => $this->member_id, + 'member_id' => $this->member_id, + 'slug_sponsors' => IGroup::Sponsors, + 'slug_external' => IGroup::SponsorExternalUsers, ] ); $res = $stmt->executeQuery(); @@ -67,14 +74,20 @@ public function isSummitAllowed(Summit $summit): bool SELECT COUNT(Sponsor.SummitID) FROM Sponsor_Users INNER JOIN Sponsor ON Sponsor_Users.SponsorID = Sponsor.ID WHERE Sponsor_Users.MemberID = :member_id - AND Sponsor.SummitID = :summit_id + AND Sponsor.SummitID = :summit_id + AND ( + JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) + OR JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_external)) + ) SQL; $stmt = DoctrineStatementValueBinder::bind( $em->getConnection()->prepare($sql), [ - 'member_id' => $this->member_id, - 'summit_id' => $summit->getId(), + 'member_id' => $this->member_id, + 'summit_id' => $summit->getId(), + 'slug_sponsors' => IGroup::Sponsors, + 'slug_external' => IGroup::SponsorExternalUsers, ] ); $res = $stmt->executeQuery(); diff --git a/app/Models/Foundation/Summit/Sponsor.php b/app/Models/Foundation/Summit/Sponsor.php index 1cdad43ca9..ef397f6b67 100644 --- a/app/Models/Foundation/Summit/Sponsor.php +++ b/app/Models/Foundation/Summit/Sponsor.php @@ -392,16 +392,6 @@ public function addUser(Member $user) ); } - if($user->hasSponsorMembershipsFor($this->getSummit())) - throw new ValidationException - ( - sprintf - ( - "Member %s already belongs to an sponsor for summit %s", - $user->getId(), - $this->getSummit()->getId() - ) - ); // see https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/reference/working-with-associations.html#synchronizing-bidirectional-collections $this->members->add($user); $user->addSponsorMembership($this); diff --git a/app/Services/Model/ISponsorUserSyncService.php b/app/Services/Model/ISponsorUserSyncService.php index 32a8984d82..d95cb83023 100644 --- a/app/Services/Model/ISponsorUserSyncService.php +++ b/app/Services/Model/ISponsorUserSyncService.php @@ -47,20 +47,24 @@ public function removeSponsorUser(int $summit_id, int $user_id, ?int $sponsor_id /** * @param int $user_id * @param string $group_slug + * @param int $sponsor_id + * @param int $summit_id * @return void * @throws EntityNotFoundException * @throws ValidationException * @throws Exception */ - public function addSponsorUserToGroup(int $user_id, string $group_slug): void; + public function addSponsorUserToGroup(int $user_id, string $group_slug, int $sponsor_id, int $summit_id): void; /** * @param int $user_id * @param string $group_slug + * @param int $sponsor_id + * @param int $summit_id * @return void * @throws EntityNotFoundException * @throws ValidationException * @throws Exception */ - public function removeSponsorUserFromGroup(int $user_id, string $group_slug): void; + public function removeSponsorUserFromGroup(int $user_id, string $group_slug, int $sponsor_id, int $summit_id): void; } \ No newline at end of file diff --git a/app/Services/Model/Imp/SponsorUserInfoGrantService.php b/app/Services/Model/Imp/SponsorUserInfoGrantService.php index f1ea86ca18..eba41fb349 100644 --- a/app/Services/Model/Imp/SponsorUserInfoGrantService.php +++ b/app/Services/Model/Imp/SponsorUserInfoGrantService.php @@ -167,7 +167,7 @@ public function addBadgeScan(Summit $summit, Member $current_member, array $data /* if(!($scan_date >= $begin_date && $scan_date <= $end_date)) - throw new ValidationException("scan_date is does not belong to summit period."); + throw new ValidationException("scan_date does not belong to summit period."); */ if(empty($ticket_number)){ throw new ValidationException("Ticket not found."); @@ -178,10 +178,21 @@ public function addBadgeScan(Summit $summit, Member $current_member, array $data if(is_null($badge)) throw new EntityNotFoundException("badge not found."); - $sponsor = $current_member->getSponsorBySummit($summit); + $member_sponsors = $current_member->getAccessibleSponsorsBySummit($summit); - if(is_null($sponsor)) - throw new ValidationException("Current member does not belongs to any summit sponsor."); + if ($member_sponsors->isEmpty()) + throw new ValidationException("Current member does not have badge scan permissions for any sponsor of this summit."); + + if ($member_sponsors->count() === 1) { + $sponsor = $member_sponsors->first(); + } else { + if (empty($data['sponsor_id'])) + throw new ValidationException("sponsor_id is required when the member belongs to multiple sponsors."); + $sponsor_id = intval($data['sponsor_id']); + $sponsor = $member_sponsors->filter(fn($s) => $s->getId() === $sponsor_id)->first(); + if ($sponsor === false) + throw new ValidationException("Current member does not belong to the selected summit sponsor."); + } $scan = new SponsorBadgeScan(); $scan->setScanDate($scan_date); diff --git a/app/Services/Model/Imp/SponsorUserSyncService.php b/app/Services/Model/Imp/SponsorUserSyncService.php index 07890819ca..98708a4788 100644 --- a/app/Services/Model/Imp/SponsorUserSyncService.php +++ b/app/Services/Model/Imp/SponsorUserSyncService.php @@ -15,12 +15,14 @@ use App\Services\Model\AbstractService; use App\Services\Model\ISponsorUserSyncService; use Illuminate\Support\Facades\Log; +use LaravelDoctrine\ORM\Facades\Registry; use libs\utils\ITransactionService; use models\exceptions\EntityNotFoundException; use models\main\IGroupRepository; use models\main\IMemberRepository; use models\summit\ISummitRepository; use models\summit\Summit; +use models\utils\SilverstripeBaseModel; use services\model\ISummitSponsorService; /** @@ -142,13 +144,47 @@ public function removeSponsorUser(int $summit_id, int $user_id, ?int $sponsor_id /** * @inheritDoc */ - public function addSponsorUserToGroup(int $user_id, string $group_slug): void + public function addSponsorUserToGroup(int $user_id, string $group_slug, int $sponsor_id, int $summit_id): void { - $this->tx_service->transaction(function () use ($user_id, $group_slug) { + $this->tx_service->transaction(function () use ($user_id, $group_slug, $sponsor_id, $summit_id) { + Log::debug( + "SponsorUserSyncService::addSponsorUserToGroup user_id {$user_id} group_slug {$group_slug} sponsor_id {$sponsor_id} summit_id {$summit_id}"); + $member = $this->member_repository->getByExternalId($user_id); if (is_null($member)) { throw new EntityNotFoundException("Member with id {$user_id} not found"); } + + // Add permission entry to the Sponsor_Users JSON column for this sponsor-member pair. + // If the row does not exist yet (MQ ordering race: group event arrived before membership + // event), create it eagerly so the permission is never silently dropped. + if ($member->addSponsorPermission($sponsor_id, $group_slug) === 0) { + Log::warning( + "SponsorUserSyncService::addSponsorUserToGroup no Sponsor_Users row found for " . + "member {$member->getId()} / sponsor {$sponsor_id} — creating it eagerly"); + + $summit = $this->summit_repository->getById($summit_id); + if (!$summit instanceof Summit) { + throw new EntityNotFoundException("Summit {$summit_id} not found"); + } + + $this->summit_sponsor_service->addSponsorUser($summit, $sponsor_id, $member->getId()); + + // Flush the UoW so the INSERT is visible to the raw SQL retry + // on the same connection within the active transaction. + Registry::getManager(SilverstripeBaseModel::EntityManager)->flush(); + + // Retry now that the row exists. + $retryResult = $member->addSponsorPermission($sponsor_id, $group_slug); + if ($retryResult === 0) { + throw new \RuntimeException( + "Failed to write permission after eager Sponsor_Users creation " . + "for member {$member->getId()} / sponsor {$sponsor_id}" + ); + } + } + + // Add to global group only if not already a member. if (!$member->belongsToGroup($group_slug)) { $group = $this->group_repository->getBySlug($group_slug); if (is_null($group)) { @@ -156,26 +192,41 @@ public function addSponsorUserToGroup(int $user_id, string $group_slug): void } $member->add2Group($group); } + + Log::info( + "SponsorUserSyncService::addSponsorUserToGroup member {$member->getId()} added to group {$group_slug} via sponsor {$sponsor_id}"); }); } /** * @inheritDoc */ - public function removeSponsorUserFromGroup(int $user_id, string $group_slug): void + public function removeSponsorUserFromGroup(int $user_id, string $group_slug, int $sponsor_id, int $summit_id): void { - $this->tx_service->transaction(function () use ($user_id, $group_slug) { + $this->tx_service->transaction(function () use ($user_id, $group_slug, $sponsor_id, $summit_id) { + Log::debug( + "SponsorUserSyncService::removeSponsorUserFromGroup user_id {$user_id} group_slug {$group_slug} sponsor_id {$sponsor_id} summit_id {$summit_id}"); + $member = $this->member_repository->getByExternalId($user_id); if (is_null($member)) { throw new EntityNotFoundException("Member with id {$user_id} not found"); } - if ($member->belongsToGroup($group_slug)) { + + // Remove permission entry from JSON and get remaining sponsor count. + $remaining = $member->removeSponsorPermission($sponsor_id, $group_slug); + + if ($remaining === 0 && $member->belongsToGroup($group_slug)) { $group = $this->group_repository->getBySlug($group_slug); if (is_null($group)) { throw new EntityNotFoundException("Group {$group_slug} not found"); } $member->removeFromGroup($group); + Log::info( + "SponsorUserSyncService::removeSponsorUserFromGroup member {$member->getId()} removed from global group {$group_slug}"); + } else { + Log::info( + "SponsorUserSyncService::removeSponsorUserFromGroup member {$member->getId()} retains group {$group_slug} via {$remaining} other sponsor(s)"); } }); } -} \ No newline at end of file +} diff --git a/app/Services/Model/Imp/SummitSponsorService.php b/app/Services/Model/Imp/SummitSponsorService.php index d987f1bc39..fa9e9359f5 100644 --- a/app/Services/Model/Imp/SummitSponsorService.php +++ b/app/Services/Model/Imp/SummitSponsorService.php @@ -394,7 +394,9 @@ public function addSponsorUser(Summit $summit, int $sponsor_id, int $member_id): // due a member could be on 2 diff places at same time ... // (StartA <= EndB) and (EndA >= StartB) - if ($current_summit_begin_date <= $former_summit_end_date && $current_summit_end_date >= $former_summit_begin_date) { + if ($summit->getId() != $former_summit->getId() && + $current_summit_begin_date <= $former_summit_end_date && + $current_summit_end_date >= $former_summit_begin_date) { throw new ValidationException ( sprintf diff --git a/app/Swagger/SponsorBadgeScanSchemas.php b/app/Swagger/SponsorBadgeScanSchemas.php index 53d25498fa..b339517f27 100644 --- a/app/Swagger/SponsorBadgeScanSchemas.php +++ b/app/Swagger/SponsorBadgeScanSchemas.php @@ -77,6 +77,7 @@ class SponsorUserInfoGrantSchemas {} required: ['qr_code', 'scan_date'], properties: [ new OA\Property(property: 'qr_code', type: 'string', description: 'Attendee QR code'), + new OA\Property(property: 'sponsor_id', type: 'integer', description: 'Current Summit Sponsor ID (Multiple sponsors per summit)'), new OA\Property(property: 'scan_date', type: 'integer', description: 'Scan date (Unix timestamp)'), new OA\Property(property: 'notes', type: 'string', description: 'Optional notes', maxLength: 1024, nullable: true), new OA\Property( @@ -142,4 +143,4 @@ class BadgeScanCheckInRequestSchema {} ) ] )] -class PaginatedBadgeScansResponseSchema {} \ No newline at end of file +class PaginatedBadgeScansResponseSchema {} diff --git a/database/migrations/model/Version20260402153110.php b/database/migrations/model/Version20260402153110.php new file mode 100644 index 0000000000..46facd4339 --- /dev/null +++ b/database/migrations/model/Version20260402153110.php @@ -0,0 +1,51 @@ +hasTable(self::TableName) && !$builder->hasColumn(self::TableName, "Permissions")) { + $builder->table(self::TableName, function (Table $table) { + $table->json("Permissions")->setNotnull(false)->setDefault(null); + }); + } + } + + public function down(Schema $schema): void + { + $builder = new Builder($schema); + if($schema->hasTable(self::TableName) && $builder->hasColumn(self::TableName, "Permissions")) { + $builder->table(self::TableName, function (Table $table) { + $table->dropColumn("Permissions"); + }); + } + } +} diff --git a/database/migrations/model/Version20260408102410.php b/database/migrations/model/Version20260408102410.php new file mode 100644 index 0000000000..94ba90e10c --- /dev/null +++ b/database/migrations/model/Version20260408102410.php @@ -0,0 +1,60 @@ +addSql(<<addSql("UPDATE Sponsor_Users SET Permissions = NULL"); + } +} diff --git a/tests/Unit/Entities/SponsorPermissionTrackingTest.php b/tests/Unit/Entities/SponsorPermissionTrackingTest.php new file mode 100644 index 0000000000..55074b05cc --- /dev/null +++ b/tests/Unit/Entities/SponsorPermissionTrackingTest.php @@ -0,0 +1,416 @@ +addUser(self::$member); + + self::$em->flush(); + self::$em->clear(); + } + + public function tearDown(): void + { + self::clearSummitTestData(); + self::clearMemberTestData(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Sets Permissions to a JSON array containing a single slug for the given + * (SponsorID, MemberID) row. + */ + private function setPermissions(int $sponsor_id, int $member_id, string $slug): void + { + self::$em->getConnection()->executeStatement( + 'UPDATE Sponsor_Users SET Permissions = JSON_ARRAY(?) WHERE SponsorID = ? AND MemberID = ?', + [$slug, $sponsor_id, $member_id] + ); + } + + /** + * Inserts a raw Sponsor_Users row bypassing entity validation, with NULL Permissions. + */ + private function insertRawSponsorUser(int $sponsor_id, int $member_id): void + { + self::$em->getConnection()->executeStatement( + 'INSERT INTO Sponsor_Users (SponsorID, MemberID) VALUES (?, ?)', + [$sponsor_id, $member_id] + ); + } + + // ------------------------------------------------------------------------- + // 1. Member::hasSponsorMembershipsFor + // ------------------------------------------------------------------------- + + /** + * With NULL Permissions the member is in Sponsor_Users but has not been + * granted any group — hasSponsorMembershipsFor must return false. + */ + public function testHasSponsorMembershipsForReturnsFalseWhenPermissionsNull(): void + { + $member = self::$member_repository->find(self::$member->getId()); + // Permissions column is NULL (set up that way in setUp via addUser). + $this->assertFalse($member->hasSponsorMembershipsFor(self::$summit)); + } + + /** + * With the Sponsors slug in Permissions, hasSponsorMembershipsFor must return true. + */ + public function testHasSponsorMembershipsForReturnsTrueForSponsorsSlug(): void + { + $this->setPermissions(self::$sponsors[0]->getId(), self::$member->getId(), IGroup::Sponsors); + + $member = self::$member_repository->find(self::$member->getId()); + $this->assertTrue($member->hasSponsorMembershipsFor(self::$summit)); + } + + /** + * With the SponsorExternalUsers slug in Permissions, hasSponsorMembershipsFor must return true. + * The member is already in IGroup::Sponsors (set up via insertMemberTestData), so the + * isSponsorUser() guard passes even though we are testing the external slug path in the SQL. + */ + public function testHasSponsorMembershipsForReturnsTrueForExternalUsersSlug(): void + { + $this->setPermissions(self::$sponsors[0]->getId(), self::$member->getId(), IGroup::SponsorExternalUsers); + + $member = self::$member_repository->find(self::$member->getId()); + $this->assertTrue($member->hasSponsorMembershipsFor(self::$summit)); + } + + // ------------------------------------------------------------------------- + // 2. Member::getSponsorMembershipIds + // ------------------------------------------------------------------------- + + /** + * Only sponsor IDs whose Permissions JSON contains a recognised slug + * should be returned. A row with NULL Permissions must be excluded. + */ + public function testGetSponsorMembershipIdsExcludesRowsWithoutPermission(): void + { + $member_id = self::$member->getId(); + $sponsor0_id = self::$sponsors[0]->getId(); + $sponsor1_id = self::$sponsors[1]->getId(); + + // Give sponsor0 a valid permission; sponsor1 has NULL Permissions. + $this->setPermissions($sponsor0_id, $member_id, IGroup::Sponsors); + $this->insertRawSponsorUser($sponsor1_id, $member_id); + // sponsor1 deliberately left with NULL Permissions. + + $member = self::$member_repository->find($member_id); + $ids = $member->getSponsorMembershipIds(self::$summit); + + $this->assertContains($sponsor0_id, $ids); + $this->assertNotContains($sponsor1_id, $ids); + } + + // ------------------------------------------------------------------------- + // 3 & 4. SponsorMemberSummitStrategy + // ------------------------------------------------------------------------- + + /** + * getAllAllowedSummitIds must not include a summit when Permissions is NULL. + */ + public function testGetAllAllowedSummitIdsExcludesWhenPermissionsNull(): void + { + $strategy = new SponsorMemberSummitStrategy(self::$member->getId()); + $ids = $strategy->getAllAllowedSummitIds(); + + $this->assertNotContains(self::$summit->getId(), $ids); + } + + /** + * getAllAllowedSummitIds must include a summit when Permissions contains a recognised slug. + */ + public function testGetAllAllowedSummitIdsIncludesWhenPermissionsSet(): void + { + $this->setPermissions(self::$sponsors[0]->getId(), self::$member->getId(), IGroup::Sponsors); + + $strategy = new SponsorMemberSummitStrategy(self::$member->getId()); + $ids = $strategy->getAllAllowedSummitIds(); + + $this->assertContains(self::$summit->getId(), $ids); + } + + /** + * isSummitAllowed must return false when Permissions is NULL. + */ + public function testIsSummitAllowedReturnsFalseWhenPermissionsNull(): void + { + $strategy = new SponsorMemberSummitStrategy(self::$member->getId()); + $this->assertFalse($strategy->isSummitAllowed(self::$summit)); + } + + /** + * isSummitAllowed must return true when Permissions contains a recognised slug. + */ + public function testIsSummitAllowedReturnsTrueWhenPermissionsSet(): void + { + $this->setPermissions(self::$sponsors[0]->getId(), self::$member->getId(), IGroup::Sponsors); + + $strategy = new SponsorMemberSummitStrategy(self::$member->getId()); + $this->assertTrue($strategy->isSummitAllowed(self::$summit)); + } + + // ------------------------------------------------------------------------- + // 5. Member::getActiveSummitsSponsorMemberships + // ------------------------------------------------------------------------- + + /** + * A sponsor whose Permissions column is NULL must not appear in the result. + */ + public function testGetActiveSummitsSponsorMembershipsExcludesWithoutPermission(): void + { + // Permissions is NULL after setUp — no permission granted. + $member = self::$member_repository->find(self::$member->getId()); + $memberships = $member->getActiveSummitsSponsorMemberships(); + + $ids = array_map(fn(Sponsor $s) => $s->getId(), $memberships); + $this->assertNotContains(self::$sponsors[0]->getId(), $ids); + } + + /** + * A sponsor with a valid Permissions entry must appear in the result, + * provided the summit has not ended. + */ + public function testGetActiveSummitsSponsorMembershipsIncludesWithPermission(): void + { + $this->setPermissions(self::$sponsors[0]->getId(), self::$member->getId(), IGroup::Sponsors); + + $member = self::$member_repository->find(self::$member->getId()); + $memberships = $member->getActiveSummitsSponsorMemberships(); + + $ids = array_map(fn(Sponsor $s) => $s->getId(), $memberships); + $this->assertContains(self::$sponsors[0]->getId(), $ids); + } + + // ------------------------------------------------------------------------- + // 6. Member::addSponsorPermission — concurrency + // ------------------------------------------------------------------------- + + /** + * Concurrent calls to addSponsorPermission for the same (member, sponsor, slug) + * must not introduce duplicate entries in the Permissions JSON array. + * The SELECT … FOR UPDATE row lock serialises the writers so that the + * second caller reads the committed value and IF(JSON_CONTAINS(…)) is a no-op. + */ + public function testConcurrentAddSponsorPermissionProducesNoDuplicates(): void + { + if (!function_exists('pcntl_fork')) { + $this->markTestSkipped('pcntl_fork() is not available in this environment'); + } + + $sponsor_id = self::$sponsors[0]->getId(); + $member_id = self::$member->getId(); + $concurrency = 5; + + // Flush and disconnect the parent before forking so children each + // get a clean connection — inherited sockets are not fork-safe. + self::$em->flush(); + self::$em->clear(); + self::$em->getConnection()->close(); + + $pids = []; + for ($i = 0; $i < $concurrency; $i++) { + $pid = pcntl_fork(); + if ($pid === -1) { + $this->fail('pcntl_fork() failed'); + } + if ($pid === 0) { + // Child: DBAL auto-reconnects on the first query after close(). + try { + $conn = self::$em->getConnection(); + $conn->beginTransaction(); + $member = self::$member_repository->find($member_id); + $member->addSponsorPermission($sponsor_id, IGroup::Sponsors); + $conn->commit(); + exit(0); + } catch (\Throwable $e) { + exit(1); + } + } + $pids[] = $pid; + } + + // Parent: wait for all children and collect exit codes. + $failed = 0; + foreach ($pids as $pid) { + pcntl_waitpid($pid, $status); + if (pcntl_wexitstatus($status) !== 0) { + $failed++; + } + } + + // Reconnect the parent for the assertion query. + self::$em->getConnection()->close(); + + $raw = self::$em->getConnection()->executeQuery( + 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ?', + [$sponsor_id, $member_id] + )->fetchOne(); + + $permissions = json_decode($raw, true) ?? []; + $occurrences = array_filter($permissions, fn($p) => $p === IGroup::Sponsors); + + $this->assertSame(0, $failed, 'One or more concurrent workers exited with an error.'); + $this->assertCount( + 1, + $occurrences, + 'Concurrent addSponsorPermission calls must not produce duplicate slugs in Permissions.' + ); + } + + // ------------------------------------------------------------------------- + // 7. Member::removeSponsorPermission — concurrency + // ------------------------------------------------------------------------- + + /** + * Concurrent calls to removeSponsorPermission for the same (member, sponsor, slug) + * must leave the slug completely absent from the Permissions JSON array. + * The pre-loaded Permissions intentionally contains duplicate slugs to verify + * that the JSON_ARRAYAGG-based remove eliminates all occurrences in one shot + * and that concurrent workers do not leave stale entries behind. + */ + public function testConcurrentRemoveSponsorPermissionLeavesNoStaleEntries(): void + { + if (!function_exists('pcntl_fork')) { + $this->markTestSkipped('pcntl_fork() is not available in this environment'); + } + + $sponsor_id = self::$sponsors[0]->getId(); + $member_id = self::$member->getId(); + $concurrency = 5; + + // Seed Permissions with duplicate slugs to exercise the remove-all path. + self::$em->getConnection()->executeStatement( + 'UPDATE Sponsor_Users SET Permissions = ? WHERE SponsorID = ? AND MemberID = ?', + [ + json_encode([IGroup::Sponsors, IGroup::Sponsors, IGroup::Sponsors]), + $sponsor_id, + $member_id, + ] + ); + + self::$em->flush(); + self::$em->clear(); + self::$em->getConnection()->close(); + + $pids = []; + for ($i = 0; $i < $concurrency; $i++) { + $pid = pcntl_fork(); + if ($pid === -1) { + $this->fail('pcntl_fork() failed'); + } + if ($pid === 0) { + try { + $conn = self::$em->getConnection(); + $conn->beginTransaction(); + $member = self::$member_repository->find($member_id); + $member->removeSponsorPermission($sponsor_id, IGroup::Sponsors); + $conn->commit(); + exit(0); + } catch (\Throwable $e) { + exit(1); + } + } + $pids[] = $pid; + } + + $failed = 0; + foreach ($pids as $pid) { + pcntl_waitpid($pid, $status); + if (pcntl_wexitstatus($status) !== 0) { + $failed++; + } + } + + self::$em->getConnection()->close(); + + $raw = self::$em->getConnection()->executeQuery( + 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ?', + [$sponsor_id, $member_id] + )->fetchOne(); + + $permissions = json_decode($raw, true) ?? []; + $occurrences = array_filter($permissions, fn($p) => $p === IGroup::Sponsors); + + $this->assertSame(0, $failed, 'One or more concurrent workers exited with an error.'); + $this->assertCount( + 0, + $occurrences, + 'Concurrent removeSponsorPermission calls must leave no stale slug occurrences in Permissions.' + ); + } + + // ------------------------------------------------------------------------- + // 8. Sponsor::addUser — multi-sponsor membership + // ------------------------------------------------------------------------- + + /** + * A member may be added to more than one sponsor that belongs to the same + * summit. The previous single-sponsor-per-summit restriction has been + * removed and must not throw. + */ + public function testAddUserAllowsMemberInMultipleSponsorsForSameSummit(): void + { + // sponsors[0] was already linked in setUp; link sponsors[1] to the same member. + $sponsor1 = self::$em->find(Sponsor::class, self::$sponsors[1]->getId()); + $member = self::$member_repository->find(self::$member->getId()); + + $sponsor1->addUser($member); + self::$em->flush(); + self::$em->clear(); + + $sponsor1 = self::$em->find(Sponsor::class, self::$sponsors[1]->getId()); + $memberIds = array_map(fn($m) => $m->getId(), $sponsor1->getMembers()->toArray()); + $this->assertContains(self::$member->getId(), $memberIds); + } +} diff --git a/tests/Unit/Services/SponsorUserPermissionTrackingTest.php b/tests/Unit/Services/SponsorUserPermissionTrackingTest.php new file mode 100644 index 0000000000..68a3acef66 --- /dev/null +++ b/tests/Unit/Services/SponsorUserPermissionTrackingTest.php @@ -0,0 +1,251 @@ +addUser(self::$member); + + self::$em->flush(); + self::$em->clear(); + } + + public function tearDown(): void + { + self::clearSummitTestData(); + self::clearMemberTestData(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function getService(): ISponsorUserSyncService + { + return app(ISponsorUserSyncService::class); + } + + /** + * Returns the decoded Permissions JSON array for a given (SponsorID, MemberID) + * row in Sponsor_Users, or an empty array when the column is NULL. + */ + private function getPermissions(int $sponsor_id, int $member_id): array + { + $conn = self::$em->getConnection(); + $raw = $conn->executeQuery( + 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ?', + [$sponsor_id, $member_id] + )->fetchOne(); + + if (empty($raw)) { + return []; + } + return json_decode($raw, true) ?? []; + } + + // ------------------------------------------------------------------------- + // addSponsorUserToGroup + // ------------------------------------------------------------------------- + + /** + * MQ race: the group event arrives before the membership event, so there is + * no Sponsor_Users row yet when addSponsorUserToGroup is called. + * The service must create the row eagerly, flush the UoW so the INSERT is + * visible to the raw SQL retry, and then successfully write the permission. + */ + public function testAddSponsorUserToGroupEagerlyCreatesRowAndWritesPermissionOnRetry(): void + { + // sponsors[1] has no Sponsor_Users row — the member is not yet a user + // of this sponsor, simulating the race condition. + $sponsor_id = self::$sponsors[1]->getId(); + $member_id = self::$member->getId(); + $external_id = self::$member->getUserExternalId(); + $summit_id = self::$summit->getId(); + + $conn = self::$em->getConnection(); + + // Confirm no row exists before the call. + $exists = $conn->executeQuery( + 'SELECT COUNT(*) FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ?', + [$sponsor_id, $member_id] + )->fetchOne(); + $this->assertEquals(0, (int)$exists, 'Pre-condition: no Sponsor_Users row should exist'); + + $this->getService()->addSponsorUserToGroup($external_id, IGroup::Sponsors, $sponsor_id, $summit_id); + + // The row must have been created and the permission written. + $this->assertContains(IGroup::Sponsors, $this->getPermissions($sponsor_id, $member_id)); + } + + /** + * The group slug must be written into the Sponsor_Users.Permissions JSON + * column for the correct (SponsorID, MemberID) row. + */ + public function testAddSponsorUserToGroupWritesPermissionToJsonColumn(): void + { + $sponsor_id = self::$sponsors[0]->getId(); + $member_id = self::$member->getId(); + + $this->getService()->addSponsorUserToGroup( + self::$member->getUserExternalId(), + IGroup::Sponsors, + $sponsor_id, + self::$summit->getId() + ); + + $this->assertContains(IGroup::Sponsors, $this->getPermissions($sponsor_id, $member_id)); + } + + /** + * Calling addSponsorUserToGroup twice for the same sponsor must not + * produce duplicate entries in the JSON array. + */ + public function testAddSponsorUserToGroupIsIdempotent(): void + { + $sponsor_id = self::$sponsors[0]->getId(); + $member_id = self::$member->getId(); + $external_id = self::$member->getUserExternalId(); + $summit_id = self::$summit->getId(); + + $service = $this->getService(); + $service->addSponsorUserToGroup($external_id, IGroup::Sponsors, $sponsor_id, $summit_id); + $service->addSponsorUserToGroup($external_id, IGroup::Sponsors, $sponsor_id, $summit_id); + + $occurrences = array_filter( + $this->getPermissions($sponsor_id, $member_id), + fn($p) => $p === IGroup::Sponsors + ); + $this->assertCount(1, $occurrences); + } + + // ------------------------------------------------------------------------- + // removeSponsorUserFromGroup + // ------------------------------------------------------------------------- + + /** + * When this is the last sponsor holding the permission, removing it must + * clear the JSON entry and also remove the member from the global group. + */ + public function testRemoveSponsorUserFromGroupRemovesGlobalGroupWhenLastSponsor(): void + { + $external_id = self::$member->getUserExternalId(); + $sponsor_id = self::$sponsors[0]->getId(); + $member_id = self::$member->getId(); + $summit_id = self::$summit->getId(); + + $service = $this->getService(); + + // Write permission first so there is something to remove. + $service->addSponsorUserToGroup($external_id, IGroup::Sponsors, $sponsor_id, $summit_id); + $this->assertContains(IGroup::Sponsors, $this->getPermissions($sponsor_id, $member_id)); + + // Doctrine ORM 3 EXTRA_LAZY PersistentCollection::removeElement() delegates to + // parent::removeElement() on the in-memory ArrayCollection first. If the collection + // is still uninitialized (addSponsorUserToGroup leaves it that way), that call + // returns false and changed() is never called, so the flush issues no DELETE. + // Force-initialize through the same model EM so removeFromGroup works correctly. + self::$em->find(\models\main\Member::class, $member_id)->getGroups()->toArray(); + + // Remove — no other sponsor holds the permission. + $service->removeSponsorUserFromGroup($external_id, IGroup::Sponsors, $sponsor_id, $summit_id); + + // JSON entry must be gone. + $this->assertNotContains(IGroup::Sponsors, $this->getPermissions($sponsor_id, $member_id)); + + // Global group must have been removed too. + self::$em->clear(); + $member = self::$member_repository->find($member_id); + $this->assertFalse($member->belongsToGroup(IGroup::Sponsors)); + } + + /** + * When another sponsor still holds the same permission, removing it for + * one sponsor must only clear that sponsor's JSON entry — the member must + * retain the global group. + */ + public function testRemoveSponsorUserFromGroupRetainsGlobalGroupWhenAnotherSponsorHoldsPermission(): void + { + $external_id = self::$member->getUserExternalId(); + $sponsor0_id = self::$sponsors[0]->getId(); + $sponsor1_id = self::$sponsors[1]->getId(); + $member_id = self::$member->getId(); + $summit_id = self::$summit->getId(); + + // Create a second Sponsor_Users row so sponsor1 can also hold the permission. + // Inserted via raw SQL to bypass Sponsor::addUser's single-sponsor-per-summit guard, + // which is a service-layer concern unrelated to permission tracking. + self::$em->getConnection()->executeStatement( + 'INSERT INTO Sponsor_Users (SponsorID, MemberID) VALUES (?, ?)', + [$sponsor1_id, $member_id] + ); + + $service = $this->getService(); + + // Grant permission to both sponsors. + $service->addSponsorUserToGroup($external_id, IGroup::Sponsors, $sponsor0_id, $summit_id); + $service->addSponsorUserToGroup($external_id, IGroup::Sponsors, $sponsor1_id, $summit_id); + + // Same EXTRA_LAZY initialization as in the single-sponsor removal test. + self::$em->find(\models\main\Member::class, $member_id)->getGroups()->toArray(); + + // Remove permission only from sponsor0. + $service->removeSponsorUserFromGroup($external_id, IGroup::Sponsors, $sponsor0_id, $summit_id); + + // sponsor0's JSON entry must be cleared. + $this->assertNotContains(IGroup::Sponsors, $this->getPermissions($sponsor0_id, $member_id)); + + // sponsor1's JSON entry must still be present. + $this->assertContains(IGroup::Sponsors, $this->getPermissions($sponsor1_id, $member_id)); + + // Global group must be retained because sponsor1 still holds the permission. + self::$em->clear(); + $member = self::$member_repository->find($member_id); + $this->assertTrue($member->belongsToGroup(IGroup::Sponsors)); + } +} diff --git a/tests/oauth2/OAuth2SummitBadgeScanApiControllerTest.php b/tests/oauth2/OAuth2SummitBadgeScanApiControllerTest.php index 665ff870f4..b83ffdc7a8 100644 --- a/tests/oauth2/OAuth2SummitBadgeScanApiControllerTest.php +++ b/tests/oauth2/OAuth2SummitBadgeScanApiControllerTest.php @@ -49,6 +49,18 @@ protected function setUp():void $this->sponsor_group->setCode(IGroup::Sponsors); $this->sponsor_group->setTitle(IGroup::Sponsors); self::$em->persist($this->sponsor_group); + + // Pre-wire self::$member as a permissioned sponsor user for sponsors[0] so that + // tests which exercise the happy path do not need per-test boilerplate. + self::$member->add2Group($this->sponsor_group); + $sponsor0 = self::$sponsors[0]; + $sponsor0->addUser(self::$member); + self::$em->persist(self::$member); + self::$em->persist($sponsor0); + self::$em->flush(); + + // Write IGroup::Sponsors into Sponsor_Users.Permissions so hasSponsorMembershipsFor passes. + self::$member->addSponsorPermission($sponsor0->getId(), IGroup::Sponsors); } protected function tearDown():void @@ -60,6 +72,7 @@ protected function tearDown():void public function testAddEncryptedBadgeScan(){ // set test data self::$member->clearGroups(); + self::$member->add2Group($this->sponsor_group); self::$member->add2Group($this->external_sponsor_group); self::$em->persist(self::$member); self::$em->flush(); @@ -69,6 +82,8 @@ public function testAddEncryptedBadgeScan(){ self::$em->persist($sponsor); self::$em->flush(); + self::$member->addSponsorPermission($sponsor->getId(), IGroup::SponsorExternalUsers); + $attendee = self::$summit->getAttendees()[0]; self::$summit->setQRCodesEncKey('35NVOF4I5T6AAM28IJPKB8KRUW98KPDO'); @@ -76,7 +91,7 @@ public function testAddEncryptedBadgeScan(){ self::$em->flush(); $this->assertTrue($sponsor->hasUser(self::$member)); - $this->assertNotNull(self::$member->getSponsorBySummit(self::$summit)); + $this->assertGreaterThan(0, self::$member->getAccessibleSponsorsBySummit(self::$summit)->count()); $badge = $attendee->getFirstTicket()->getBadge(); $badge_qr_code = $badge->generateQRCode(); @@ -109,7 +124,7 @@ public function testAddEncryptedBadgeScan(){ $this->assertEquals($badge->getId(), $badge_scan->badge_id); } - public function testAddBadgeScan(){ + public function testAddBadgeScanWithOneSponsorPerMember(){ self::$member->clearGroups(); self::$member->add2Group($this->sponsor_group); self::$em->persist(self::$member); @@ -259,6 +274,52 @@ public function testAddBadgeScanMissingQrCodeAndEmailFails(){ $this->assertResponseStatus(412); } + public function testAddBadgeScanFailsWhenSponsorHasNoPermissionSlug() + { + // member2 is in the global sponsors group so hasSponsorMembershipsFor doesn't + // short-circuit, but the Sponsor_Users row has no permission slug + // (simulates the MQ group event never arriving). + self::$member2->add2Group($this->sponsor_group); + $sponsor = self::$sponsors[0]; + $sponsor->addUser(self::$member2); + self::$em->persist(self::$member2); + self::$em->persist($sponsor); + self::$em->flush(); + // Sponsor_Users row was created with Permissions = NULL — deliberately no addSponsorPermission call. + + // Impersonate member2 for this request. + self::$service->setUserId(self::$member2->getUserExternalId()); + self::$service->setUserExternalId(self::$member2->getUserExternalId()); + self::$service->setUserEmail(self::$member2->getEmail()); + self::$service->setUserFirstName(self::$member2->getFirstName()); + self::$service->setUserLastName(self::$member2->getLastName()); + + $params = [ + 'id' => self::$summit->getId(), + ]; + + $attendee = self::$summit->getAttendeeByMemberId(self::$defaultMember->getId()); + $badge = $attendee->getFirstTicket()->getBadge(); + + $data = [ + 'qr_code' => $badge->generateQRCode(), + 'scan_date' => 1572019200, + ]; + + $this->action( + "POST", + "OAuth2SummitBadgeScanApiController@add", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(412); + } + public function testAddBadgeScanByUnknownAttendeeEmailFails(){ self::$member->clearGroups(); self::$member->add2Group($this->sponsor_group); @@ -293,8 +354,111 @@ public function testAddBadgeScanByUnknownAttendeeEmailFails(){ $this->assertResponseStatus(404); } + public function testAddBadgeScanWithMultipleSponsorsWithoutSponsorId() + { + self::$member->clearGroups(); + self::$member->add2Group($this->sponsor_group); + self::$em->persist(self::$member); + self::$em->flush(); + + $sponsor1 = self::$sponsors[0]; + $sponsor1->addUser(self::$member); + self::$em->persist($sponsor1); + + $sponsor2 = self::$sponsors[1]; + $sponsor2->addUser(self::$member); + self::$em->persist($sponsor2); + + self::$em->flush(); + + self::$member->addSponsorPermission($sponsor1->getId(), IGroup::Sponsors); + self::$member->addSponsorPermission($sponsor2->getId(), IGroup::Sponsors); + + $this->assertGreaterThan(1, self::$member->getAccessibleSponsorsBySummit(self::$summit)->count()); + + $params = [ + 'id' => self::$summit->getId(), + ]; + + $attendee = self::$summit->getAttendeeByMemberId(self::$defaultMember->getId()); + $badge = $attendee->getFirstTicket()->getBadge(); + + $data = [ + 'qr_code' => $badge->generateQRCode(), + 'scan_date' => 1572019200, + ]; + + $this->action( + "POST", + "OAuth2SummitBadgeScanApiController@add", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $this->assertResponseStatus(412); + } + + public function testAddBadgeScanWithMultipleSponsorsWithSponsorId() + { + self::$member->clearGroups(); + self::$member->add2Group($this->sponsor_group); + self::$em->persist(self::$member); + self::$em->flush(); + + $sponsor1 = self::$sponsors[0]; + $sponsor1->addUser(self::$member); + self::$em->persist($sponsor1); + + $sponsor2 = self::$sponsors[1]; + $sponsor2->addUser(self::$member); + self::$em->persist($sponsor2); + + self::$em->flush(); + + self::$member->addSponsorPermission($sponsor1->getId(), IGroup::Sponsors); + self::$member->addSponsorPermission($sponsor2->getId(), IGroup::Sponsors); + + $this->assertGreaterThan(1, self::$member->getAccessibleSponsorsBySummit(self::$summit)->count()); + + $params = [ + 'id' => self::$summit->getId(), + ]; + + $attendee = self::$summit->getAttendeeByMemberId(self::$defaultMember->getId()); + $badge = $attendee->getFirstTicket()->getBadge(); + + $data = [ + 'qr_code' => $badge->generateQRCode(), + 'scan_date' => 1572019200, + 'sponsor_id' => $sponsor1->getId(), + ]; + + $response = $this->action( + "POST", + "OAuth2SummitBadgeScanApiController@add", + $params, + [], + [], + [], + $this->getAuthHeaders(), + json_encode($data) + ); + + $content = $response->getContent(); + $this->assertResponseStatus(201); + $scan = json_decode($content); + $this->assertNotNull($scan); + $this->assertEquals(self::$member->getId(), $scan->scanned_by_id); + $this->assertEquals($badge->getId(), $scan->badge_id); + $this->assertEquals($sponsor1->getId(), $scan->sponsor_id); + } + public function testUpdateBadgeScan(){ - $scan = $this->testAddBadgeScan(); + $scan = $this->testAddBadgeScanWithOneSponsorPerMember(); $params = [ 'id' => self::$summit->getId(), @@ -413,7 +577,7 @@ public function testGetAllSummitBadgeScans(){ } public function testGetSummitBadgeScan(){ - $badge_scan = $this->testAddBadgeScan(); + $badge_scan = $this->testAddBadgeScanWithOneSponsorPerMember(); $params = [ 'id' => self::$summit->getId(), @@ -440,7 +604,7 @@ public function testGetSummitBadgeScan(){ public function testExportSummitBadgeScans(){ - $this->testAddBadgeScan(); + $this->testAddBadgeScanWithOneSponsorPerMember(); $params = [ 'id' => self::$summit->getId(), @@ -464,7 +628,7 @@ public function testExportSummitBadgeScans(){ public function testExportSummitBadgeScansWithReportSettingsRestriction(){ - $this->testAddBadgeScan(); + $this->testAddBadgeScanWithOneSponsorPerMember(); $sponsor = self::$summit->getSummitSponsors()[0]; if (!$sponsor instanceof Sponsor) self::fail(); @@ -525,7 +689,7 @@ public function testExportSummitBadgeScansWithReportSettingsRestriction(){ public function testExportSummitBadgeScansWithAllReportSettingsRestriction(){ - $this->testAddBadgeScan(); + $this->testAddBadgeScanWithOneSponsorPerMember(); $sponsor = self::$summit->getSummitSponsors()[0]; if (!$sponsor instanceof Sponsor) self::fail(); @@ -578,4 +742,4 @@ public function testExportSummitBadgeScansWithAllReportSettingsRestriction(){ $this->assertResponseStatus(200); $this->assertNotEmpty($content); } -} \ No newline at end of file +}