From 73ef31d34105377cbd5d0ee488f0845a56461715 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 11 Nov 2026 15:35:10 +0100 Subject: [PATCH 1/4] fix(security): stop exposing attendee ip, browser and origin via expand=current_attendance on the public API Serialize the current_attendance collection of the public SummitEvent serializer with a dedicated public serializer that omits ip, browser, origin and created_by_id. The admin serializer keeps the full one. --- app/ModelSerializers/SerializerRegistry.php | 7 +- ...cSummitEventAttendanceMetricSerializer.php | 54 +++++++++++ .../Summit/SummitEventSerializer.php | 13 +++ ...mitEventAttendanceMetricSerializerTest.php | 97 +++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 app/ModelSerializers/Summit/Metrics/PublicSummitEventAttendanceMetricSerializer.php create mode 100644 tests/PublicSummitEventAttendanceMetricSerializerTest.php diff --git a/app/ModelSerializers/SerializerRegistry.php b/app/ModelSerializers/SerializerRegistry.php index 15760f0966..286be9afdb 100644 --- a/app/ModelSerializers/SerializerRegistry.php +++ b/app/ModelSerializers/SerializerRegistry.php @@ -164,6 +164,7 @@ final class SerializerRegistry const SerializerType_Public = 'PUBLIC'; const SerializerType_PublicV2 = 'PUBLIC_V2'; + const SerializerType_PublicAttendance = 'PUBLIC_ATTENDANCE'; const SerializerType_Private = 'PRIVATE'; const SerializerType_Admin = 'ADMIN'; const SerializerType_Admin_Voteable = 'ADMIN_VOTEABLE'; @@ -224,7 +225,11 @@ private function __construct() $this->registry['SummitMetric'] = SummitMetricSerializer::class; $this->registry['SummitSponsorMetric'] = SummitSponsorMetricSerializer::class; - $this->registry['SummitEventAttendanceMetric'] = SummitEventAttendanceMetricSerializer::class; + $this->registry['SummitEventAttendanceMetric'] = [ + self::SerializerType_Public => SummitEventAttendanceMetricSerializer::class, + self::SerializerType_Private => SummitEventAttendanceMetricSerializer::class, + self::SerializerType_PublicAttendance => PublicSummitEventAttendanceMetricSerializer::class, + ]; // stripe $this->registry['StripePaymentProfile'] = [ diff --git a/app/ModelSerializers/Summit/Metrics/PublicSummitEventAttendanceMetricSerializer.php b/app/ModelSerializers/Summit/Metrics/PublicSummitEventAttendanceMetricSerializer.php new file mode 100644 index 0000000000..6fea84c451 --- /dev/null +++ b/app/ModelSerializers/Summit/Metrics/PublicSummitEventAttendanceMetricSerializer.php @@ -0,0 +1,54 @@ + 'member_first_name:json_string', + 'MemberLastName' => 'member_last_name:json_string', + 'MemberProfilePhotoUrl' => 'member_pic:json_url', + 'Type' => 'type:json_string', + 'OutgressDate' => 'outgress_date:datetime_epoch', + 'IngressDate' => 'ingress_date:datetime_epoch', + 'EventId' => 'event_id:json_int', + 'RoomId' => 'room_id:json_int', + 'SubType' => 'sub_type:json_string', + 'AttendeeId' => 'attendee_id:json_int', + ]; + + public function serialize($expand = null, array $fields = [], array $relations = [], array $params = []) + { + $metric = $this->object; + if (!$metric instanceof SummitEventAttendanceMetric) return []; + $values = parent::serialize($expand, $fields, $relations, $params); + + $member = $metric->hasMember() ? $metric->getMember() : null; + if (is_null($member)) return $values; + + if (!$member->isPublicProfileShowPhoto() && isset($values['member_pic'])) + $values['member_pic'] = Config::get("app.default_profile_image", null); + + if (!$member->isPublicProfileShowFullname() && isset($values['member_last_name'])) + $values['member_last_name'] = ''; + + return $values; + } +} diff --git a/app/ModelSerializers/Summit/SummitEventSerializer.php b/app/ModelSerializers/Summit/SummitEventSerializer.php index 0000549c3f..9cb07bbbd2 100644 --- a/app/ModelSerializers/Summit/SummitEventSerializer.php +++ b/app/ModelSerializers/Summit/SummitEventSerializer.php @@ -121,6 +121,19 @@ class SummitEventSerializer extends SilverStripeSerializer 'updated_by', ]; + /** + * The public serializer must never expose technical data (ip, browser, origin) of the + * attendance metrics; admins get the full one through AdminSummitEventSerializer. + * @param string|null $relation + * @return string + */ + protected function getSerializerType(?string $relation = null): string + { + if (!is_null($relation) && trim($relation) === 'current_attendance') + return SerializerRegistry::SerializerType_PublicAttendance; + return parent::getSerializerType($relation); + } + /** * @param string|null $expand * @param array $fields diff --git a/tests/PublicSummitEventAttendanceMetricSerializerTest.php b/tests/PublicSummitEventAttendanceMetricSerializerTest.php new file mode 100644 index 0000000000..d1c99d255a --- /dev/null +++ b/tests/PublicSummitEventAttendanceMetricSerializerTest.php @@ -0,0 +1,97 @@ +shouldReceive('isPublicProfileShowPhoto')->andReturn(true); + $member->shouldReceive('isPublicProfileShowFullname')->andReturn(true); + + $metric = Mockery::mock(SummitEventAttendanceMetric::class); + $metric->shouldReceive('hasMember')->andReturn(true); + $metric->shouldReceive('getMember')->andReturn($member); + $metric->shouldReceive('getIp')->andReturn('10.1.2.3'); + $metric->shouldReceive('getBrowser')->andReturn('Firefox'); + $metric->shouldReceive('getOrigin')->andReturn('https://example.org'); + $metric->shouldReceive('getCreatedById')->andReturn(7); + $metric->shouldReceive('getAttendeeId')->andReturn(5); + $metric->shouldReceive('getMemberFirstName')->andReturn('Jane'); + $metric->shouldReceive('getMemberLastName')->andReturn('Doe'); + $metric->shouldReceive('getMemberProfilePhotoUrl')->andReturn('https://example.org/p.png'); + $metric->shouldReceive('getType')->andReturn('EVENT'); + $metric->shouldReceive('getEventId')->andReturn(1); + $metric->shouldReceive('getRoomId')->andReturn(2); + $metric->shouldReceive('getSubType')->andReturn('IN_PERSON'); + $metric->shouldReceive('getOutgressDate')->andReturn(null); + $metric->shouldReceive('getIngressDate')->andReturn(new \DateTime()); + return $metric; + } + + private function inner($decorator) + { + $prop = new \ReflectionProperty($decorator, 'serializer_imp'); + $prop->setAccessible(true); + return $prop->getValue($decorator); + } + + public function testPublicSerializerOmitsSensitiveFields() + { + $values = (new PublicSummitEventAttendanceMetricSerializer( + $this->buildMetric(), Mockery::mock(IResourceServerContext::class) + ))->serialize(); + foreach (['ip', 'browser', 'origin', 'created_by_id'] as $key) { + $this->assertArrayNotHasKey($key, $values); + } + $this->assertArrayHasKey('member_first_name', $values); + } + + public function testRegistryKeepsFullSerializerForPrivateAndSelectsPublicOneForAttendance() + { + $registry = SerializerRegistry::getInstance(); + // the registry resolves by short class name, so it needs a real entity (not a mock) + $metric = new SummitEventAttendanceMetric(); + $this->assertInstanceOf( + PublicSummitEventAttendanceMetricSerializer::class, + $this->inner($registry->getSerializer($metric, SerializerRegistry::SerializerType_PublicAttendance)) + ); + $this->assertInstanceOf( + SummitEventAttendanceMetricSerializer::class, + $this->inner($registry->getSerializer($metric, SerializerRegistry::SerializerType_Private)) + ); + $this->assertInstanceOf( + SummitEventAttendanceMetricSerializer::class, + $this->inner($registry->getSerializer($metric)) + ); + } +} From a08f3e6287d792d5ef63f9a1b6e31eeac1b8e4a6 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 11 Nov 2026 15:43:08 +0100 Subject: [PATCH 2/4] fix(security): stop exposing rejected presentations via expand=rejected_presentations on the public speakers API The public PresentationSpeakerSerializer no longer returns rejected_presentations, neither as ids nor expanded. AdminPresentationSpeakerSerializer (admins and the speaker's own profile) opts back in, so the private output is unchanged. --- .../AdminPresentationSpeakerSerializer.php | 5 ++ .../PresentationSpeakerSerializer.php | 13 +++- ...tationSpeakerRejectedPresentationsTest.php | 66 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/PresentationSpeakerRejectedPresentationsTest.php diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 087801bd42..6c2d148d9b 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -41,6 +41,11 @@ final class AdminPresentationSpeakerSerializer extends PresentationSpeakerSerial 'registration_code', ]; + protected function canExposeRejectedPresentations(): bool + { + return true; + } + protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ if(array_key_exists("email", $values)) { $application_type = $this->resource_server_context->getApplicationType(); diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php index 2d4975a1a2..a136c0b276 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php @@ -40,6 +40,16 @@ class PresentationSpeakerSerializer extends PresentationSpeakerBaseSerializer 'badge_features', ]; + /** + * Rejected submissions are not public data. Only the private/admin serializers + * (AdminPresentationSpeakerSerializer) override this to expose them. + * @return bool + */ + protected function canExposeRejectedPresentations(): bool + { + return false; + } + protected function getMemberSerializerType():string { return SerializerRegistry::SerializerType_Public; } @@ -130,7 +140,7 @@ public function serialize($expand = null, array $fields = [], array $relations = $values['alternate_presentations'] = array_merge($alternate_presentation_ids, $moderated_alternate_presentation_ids); } - if (in_array('rejected_presentations', $relations) && !is_null($summit)) { + if ($this->canExposeRejectedPresentations() && in_array('rejected_presentations', $relations) && !is_null($summit)) { $rejected_presentation_ids = $speaker->getRejectedPresentationIds($summit, PresentationSpeaker::RoleSpeaker, false, [], $params['filter'] ?? null); $moderated_rejected_presentation_ids = $speaker->getRejectedPresentationIds($summit, PresentationSpeaker::RoleModerator, false, [], $params['filter'] ?? null); $values['rejected_presentations'] = array_merge($rejected_presentation_ids, $moderated_rejected_presentation_ids); @@ -314,6 +324,7 @@ public function serialize($expand = null, array $fields = [], array $relations = } break; case 'rejected_presentations': { + if (!$this->canExposeRejectedPresentations()) break; $rejected_presentations = []; foreach ($speaker->getRejectedPresentations($summit, PresentationSpeaker::RoleSpeaker, false, [], $params['filter'] ?? null) as $p) { $rejected_presentations[] = SerializerRegistry::getInstance()->getSerializer($p)->serialize diff --git a/tests/PresentationSpeakerRejectedPresentationsTest.php b/tests/PresentationSpeakerRejectedPresentationsTest.php new file mode 100644 index 0000000000..b3f8b6feea --- /dev/null +++ b/tests/PresentationSpeakerRejectedPresentationsTest.php @@ -0,0 +1,66 @@ +shouldIgnoreMissing(); + $speaker->shouldReceive('hasMember')->andReturn(false); + $speaker->shouldReceive('getRejectedPresentationIds')->andReturn([11, 12]); + $speaker->shouldReceive('getRejectedPresentations')->andReturn([]); + return $speaker; + } + + private function serialize(string $class): array + { + $serializer = new $class($this->buildSpeaker(), Mockery::mock(IResourceServerContext::class)->shouldIgnoreMissing()); + return $serializer->serialize( + 'rejected_presentations', + [], + ['rejected_presentations'], + ['summit' => Mockery::mock(Summit::class)->shouldIgnoreMissing()] + ); + } + + public function testPublicSerializerDoesNotExposeRejectedPresentations() + { + $values = $this->serialize(PresentationSpeakerSerializer::class); + $this->assertArrayNotHasKey('rejected_presentations', $values); + } + + public function testAdminSerializerStillExposesRejectedPresentations() + { + $values = $this->serialize(AdminPresentationSpeakerSerializer::class); + $this->assertArrayHasKey('rejected_presentations', $values); + } +} From 455b1ca7724628e194a06eec4353b1b1231263b3 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 11 Nov 2026 15:58:18 +0100 Subject: [PATCH 3/4] fix(security): hide alternate and unpublished accepted presentations from the public speakers API The public PresentationSpeakerSerializer no longer returns alternate_presentations and only returns published accepted_presentations. AdminPresentationSpeakerSerializer opts back in, so the private output is unchanged. --- .../AdminPresentationSpeakerSerializer.php | 10 ++++ .../PresentationSpeakerSerializer.php | 29 +++++++++-- .../Summit/Speakers/PresentationSpeaker.php | 12 +++-- ...tationSpeakerRejectedPresentationsTest.php | 49 +++++++++++++++++-- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 6c2d148d9b..c88cd46689 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -46,6 +46,16 @@ protected function canExposeRejectedPresentations(): bool return true; } + protected function canExposeAlternatePresentations(): bool + { + return true; + } + + protected function canExposeUnpublishedAcceptedPresentations(): bool + { + return true; + } + protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ if(array_key_exists("email", $values)) { $application_type = $this->resource_server_context->getApplicationType(); diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php index a136c0b276..13634a52c8 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php @@ -50,6 +50,24 @@ protected function canExposeRejectedPresentations(): bool return false; } + /** + * Alternate presentations are never published, so they are not public data. + * @return bool + */ + protected function canExposeAlternatePresentations(): bool + { + return false; + } + + /** + * When false, only published accepted presentations are returned. + * @return bool + */ + protected function canExposeUnpublishedAcceptedPresentations(): bool + { + return false; + } + protected function getMemberSerializerType():string { return SerializerRegistry::SerializerType_Public; } @@ -129,12 +147,12 @@ public function serialize($expand = null, array $fields = [], array $relations = } if (in_array('accepted_presentations', $relations) && !is_null($summit)) { - $accepted_presentation_ids = $speaker->getAcceptedPresentationIds($summit, PresentationSpeaker::RoleSpeaker, false, [], $params['filter'] ?? null); - $moderated_accepted_presentation_ids = $speaker->getAcceptedPresentationIds($summit, PresentationSpeaker::RoleModerator, false, [], $params['filter'] ?? null); + $accepted_presentation_ids = $speaker->getAcceptedPresentationIds($summit, PresentationSpeaker::RoleSpeaker, false, [], $params['filter'] ?? null, !$this->canExposeUnpublishedAcceptedPresentations()); + $moderated_accepted_presentation_ids = $speaker->getAcceptedPresentationIds($summit, PresentationSpeaker::RoleModerator, false, [], $params['filter'] ?? null, !$this->canExposeUnpublishedAcceptedPresentations()); $values['accepted_presentations'] = array_merge($accepted_presentation_ids, $moderated_accepted_presentation_ids); } - if (in_array('alternate_presentations', $relations) && !is_null($summit)) { + if ($this->canExposeAlternatePresentations() && in_array('alternate_presentations', $relations) && !is_null($summit)) { $alternate_presentation_ids = $speaker->getAlternatePresentationIds($summit, PresentationSpeaker::RoleSpeaker, false, [], false, $params['filter'] ?? null); $moderated_alternate_presentation_ids = $speaker->getAlternatePresentationIds($summit, PresentationSpeaker::RoleModerator,false, [], false, $params['filter'] ?? null); $values['alternate_presentations'] = array_merge($alternate_presentation_ids, $moderated_alternate_presentation_ids); @@ -281,7 +299,7 @@ public function serialize($expand = null, array $fields = [], array $relations = break; case 'accepted_presentations': { $accepted_presentations = []; - foreach ($speaker->getAcceptedPresentations($summit, PresentationSpeaker::RoleSpeaker, true, [], $params['filter'] ?? null) as $p) { + foreach ($speaker->getAcceptedPresentations($summit, PresentationSpeaker::RoleSpeaker, true, [], $params['filter'] ?? null, !$this->canExposeUnpublishedAcceptedPresentations()) as $p) { $accepted_presentations[] = SerializerRegistry::getInstance()->getSerializer($p)->serialize ( AbstractSerializer::filterExpandByPrefix($expand, $relation), @@ -290,7 +308,7 @@ public function serialize($expand = null, array $fields = [], array $relations = ); } $moderated_accepted_presentations = []; - foreach ($speaker->getAcceptedPresentations($summit, PresentationSpeaker::RoleModerator, true, [], $params['filter'] ?? null) as $p) { + foreach ($speaker->getAcceptedPresentations($summit, PresentationSpeaker::RoleModerator, true, [], $params['filter'] ?? null, !$this->canExposeUnpublishedAcceptedPresentations()) as $p) { $moderated_accepted_presentations[] = SerializerRegistry::getInstance()->getSerializer($p)->serialize ( AbstractSerializer::filterExpandByPrefix($expand, $relation), @@ -302,6 +320,7 @@ public function serialize($expand = null, array $fields = [], array $relations = } break; case 'alternate_presentations': { + if (!$this->canExposeAlternatePresentations()) break; $alternate_presentations = []; foreach ($speaker->getAlternatePresentations($summit, PresentationSpeaker::RoleSpeaker, false, [], false, $params['filter'] ?? null) as $p) { $alternate_presentations[] = SerializerRegistry::getInstance()->getSerializer($p)->serialize diff --git a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php index 7f69ae347d..685f551981 100644 --- a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php +++ b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php @@ -743,7 +743,8 @@ public function getAcceptedPresentations string $role = PresentationSpeaker::RoleSpeaker, bool $exclude_privates_tracks = true, array $excluded_tracks = [], - ?Filter $filter = null + ?Filter $filter = null, + bool $published_only = false ) { $private_tracks = []; @@ -771,6 +772,10 @@ public function getAcceptedPresentations $exclude_category_dql = ' AND p.category NOT IN (:exclude_tracks)'; } $extraWhere = ''; + if ($published_only) { + // accepted but not yet published presentations are not public data + $extraWhere .= " AND p.published = 1"; + } if(!is_null($filter)){ if($filter->hasFilter("presentations_selection_plan_id")) { @@ -939,11 +944,12 @@ public function getAcceptedPresentationIds string $role = PresentationSpeaker::RoleSpeaker, bool $include_sub_roles = false, array $excluded_tracks = [], - ?Filter $filter = null + ?Filter $filter = null, + bool $published_only = false ) { $ids = []; - $acceptedPresentations = $this->getAcceptedPresentations($summit, $role, $include_sub_roles, $excluded_tracks, $filter); + $acceptedPresentations = $this->getAcceptedPresentations($summit, $role, $include_sub_roles, $excluded_tracks, $filter, $published_only); foreach ($acceptedPresentations as $p) { $ids[] = intval($p->getId()); } diff --git a/tests/PresentationSpeakerRejectedPresentationsTest.php b/tests/PresentationSpeakerRejectedPresentationsTest.php index b3f8b6feea..4bff4083ba 100644 --- a/tests/PresentationSpeakerRejectedPresentationsTest.php +++ b/tests/PresentationSpeakerRejectedPresentationsTest.php @@ -38,16 +38,27 @@ private function buildSpeaker(): PresentationSpeaker $speaker->shouldReceive('hasMember')->andReturn(false); $speaker->shouldReceive('getRejectedPresentationIds')->andReturn([11, 12]); $speaker->shouldReceive('getRejectedPresentations')->andReturn([]); + $speaker->shouldReceive('getAlternatePresentationIds')->andReturn([21]); + $speaker->shouldReceive('getAlternatePresentations')->andReturn([]); + // records the "published_only" flag (6th argument) of every accepted lookup + $speaker->shouldReceive('getAcceptedPresentationIds')->andReturnUsing( + function () use ($speaker) { + $speaker->published_only_flags[] = func_get_arg(5); + return [31]; + } + ); + $speaker->shouldReceive('getAcceptedPresentations')->andReturn([]); return $speaker; } - private function serialize(string $class): array + private function serialize(string $class, ?PresentationSpeaker $speaker = null): array { - $serializer = new $class($this->buildSpeaker(), Mockery::mock(IResourceServerContext::class)->shouldIgnoreMissing()); + $speaker = $speaker ?? $this->buildSpeaker(); + $serializer = new $class($speaker, Mockery::mock(IResourceServerContext::class)->shouldIgnoreMissing()); return $serializer->serialize( - 'rejected_presentations', + 'rejected_presentations,alternate_presentations,accepted_presentations', [], - ['rejected_presentations'], + ['rejected_presentations', 'alternate_presentations', 'accepted_presentations'], ['summit' => Mockery::mock(Summit::class)->shouldIgnoreMissing()] ); } @@ -63,4 +74,34 @@ public function testAdminSerializerStillExposesRejectedPresentations() $values = $this->serialize(AdminPresentationSpeakerSerializer::class); $this->assertArrayHasKey('rejected_presentations', $values); } + + public function testPublicSerializerDoesNotExposeAlternatePresentations() + { + $values = $this->serialize(PresentationSpeakerSerializer::class); + $this->assertArrayNotHasKey('alternate_presentations', $values); + } + + public function testAdminSerializerStillExposesAlternatePresentations() + { + $values = $this->serialize(AdminPresentationSpeakerSerializer::class); + $this->assertArrayHasKey('alternate_presentations', $values); + } + + public function testPublicSerializerAsksOnlyForPublishedAcceptedPresentations() + { + $speaker = $this->buildSpeaker(); + $speaker->published_only_flags = []; + $this->serialize(PresentationSpeakerSerializer::class, $speaker); + $this->assertNotEmpty($speaker->published_only_flags); + $this->assertNotContains(false, $speaker->published_only_flags); + } + + public function testAdminSerializerKeepsUnpublishedAcceptedPresentations() + { + $speaker = $this->buildSpeaker(); + $speaker->published_only_flags = []; + $this->serialize(AdminPresentationSpeakerSerializer::class, $speaker); + $this->assertNotEmpty($speaker->published_only_flags); + $this->assertNotContains(true, $speaker->published_only_flags); + } } From 254b89298d7d72442b0100858ed5a08133a0ce50 Mon Sep 17 00:00:00 2001 From: romanetar Date: Wed, 11 Nov 2026 16:18:51 +0100 Subject: [PATCH 4/4] fix(security): keep full attendance metrics for admin event-with-file and group event serializers SummitEventSerializer selected PUBLIC_ATTENDANCE for current_attendance in every subclass without its own getSerializerType(), so admins lost ip, browser and origin on SummitEventWithFile and SummitGroupEvent events. Add a canExposeAttendanceTechnicalData() hook, enable it on the admin and CSV event serializers, and add AdminSummitGroupEventSerializer for the PRIVATE and CSV group event types. --- app/ModelSerializers/SerializerRegistry.php | 4 +- .../AdminSummitEventWithFileSerializer.php | 8 +++ .../AdminSummitGroupEventSerializer.php | 29 ++++++++ .../AdminSummitEventCSVSerializer.php | 8 +++ .../Summit/SummitEventSerializer.php | 15 +++- ...mitEventAttendanceMetricSerializerTest.php | 69 +++++++++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 app/ModelSerializers/Summit/AdminSummitGroupEventSerializer.php diff --git a/app/ModelSerializers/SerializerRegistry.php b/app/ModelSerializers/SerializerRegistry.php index 286be9afdb..40165e1583 100644 --- a/app/ModelSerializers/SerializerRegistry.php +++ b/app/ModelSerializers/SerializerRegistry.php @@ -325,8 +325,8 @@ private function __construct() $this->registry['SummitGroupEvent'] = [ self::SerializerType_Public => SummitGroupEventSerializer::class, - self::SerializerType_Private => SummitGroupEventSerializer::class, - self::SerializerType_CSV => SummitGroupEventSerializer::class, + self::SerializerType_Private => AdminSummitGroupEventSerializer::class, + self::SerializerType_CSV => AdminSummitGroupEventSerializer::class, IPresentationSerializerTypes::SecureStream => SummitEventSecureStreamSerializer::class, IPresentationSerializerTypes::OverflowStream => SummitEventOverflowStreamingSerializer::class, IPresentationSerializerTypes::StreamingInfo => SummitEventStreamingInfoSerializer::class, diff --git a/app/ModelSerializers/Summit/AdminSummitEventWithFileSerializer.php b/app/ModelSerializers/Summit/AdminSummitEventWithFileSerializer.php index 338b8381ea..2b14fb5aea 100644 --- a/app/ModelSerializers/Summit/AdminSummitEventWithFileSerializer.php +++ b/app/ModelSerializers/Summit/AdminSummitEventWithFileSerializer.php @@ -25,4 +25,12 @@ class AdminSummitEventWithFileSerializer extends SummitEventWithFileSerializer protected static $allowed_fields = [ 'occupancy', ]; + + /** + * @return bool + */ + protected function canExposeAttendanceTechnicalData(): bool + { + return true; + } } \ No newline at end of file diff --git a/app/ModelSerializers/Summit/AdminSummitGroupEventSerializer.php b/app/ModelSerializers/Summit/AdminSummitGroupEventSerializer.php new file mode 100644 index 0000000000..46d8a12e4e --- /dev/null +++ b/app/ModelSerializers/Summit/AdminSummitGroupEventSerializer.php @@ -0,0 +1,29 @@ +canExposeAttendanceTechnicalData() && !is_null($relation) && trim($relation) === 'current_attendance') return SerializerRegistry::SerializerType_PublicAttendance; return parent::getSerializerType($relation); } diff --git a/tests/PublicSummitEventAttendanceMetricSerializerTest.php b/tests/PublicSummitEventAttendanceMetricSerializerTest.php index d1c99d255a..06413b6a6a 100644 --- a/tests/PublicSummitEventAttendanceMetricSerializerTest.php +++ b/tests/PublicSummitEventAttendanceMetricSerializerTest.php @@ -15,10 +15,20 @@ use Mockery; use models\oauth2\IResourceServerContext; use models\main\Member; +use models\summit\SummitEvent; use models\summit\SummitEventAttendanceMetric; +use models\summit\SummitGroupEvent; +use ModelSerializers\AdminSummitEventCSVSerializer; +use ModelSerializers\AdminSummitEventSerializer; +use ModelSerializers\AdminSummitEventWithFileCSVSerializer; +use ModelSerializers\AdminSummitEventWithFileSerializer; +use ModelSerializers\AdminSummitGroupEventSerializer; use ModelSerializers\PublicSummitEventAttendanceMetricSerializer; use ModelSerializers\SerializerRegistry; use ModelSerializers\SummitEventAttendanceMetricSerializer; +use ModelSerializers\SummitEventSerializer; +use ModelSerializers\SummitEventWithFileSerializer; +use ModelSerializers\SummitGroupEventSerializer; /** * The "current_attendance" expand of the public events API must not leak ip/browser/origin. @@ -94,4 +104,63 @@ public function testRegistryKeepsFullSerializerForPrivateAndSelectsPublicOneForA $this->inner($registry->getSerializer($metric)) ); } + + private function attendanceTypeFor(string $serializer_class): string + { + $serializer = new $serializer_class( + Mockery::mock(SummitEvent::class), Mockery::mock(IResourceServerContext::class) + ); + $method = new \ReflectionMethod($serializer, 'getSerializerType'); + $method->setAccessible(true); + return $method->invoke($serializer, 'current_attendance'); + } + + public function testPublicEventSerializersSelectPublicAttendance() + { + foreach ([ + SummitEventSerializer::class, + SummitEventWithFileSerializer::class, + SummitGroupEventSerializer::class, + ] as $serializer_class) { + $this->assertEquals( + SerializerRegistry::SerializerType_PublicAttendance, + $this->attendanceTypeFor($serializer_class), + $serializer_class + ); + } + } + + public function testAdminEventSerializersKeepFullAttendance() + { + foreach ([ + AdminSummitEventSerializer::class, + AdminSummitEventCSVSerializer::class, + AdminSummitEventWithFileSerializer::class, + AdminSummitEventWithFileCSVSerializer::class, + AdminSummitGroupEventSerializer::class, + ] as $serializer_class) { + $this->assertNotEquals( + SerializerRegistry::SerializerType_PublicAttendance, + $this->attendanceTypeFor($serializer_class), + $serializer_class + ); + } + } + + public function testRegistryMapsAdminGroupEventSerializerForPrivateAndCsv() + { + $registry = SerializerRegistry::getInstance(); + $event = new SummitGroupEvent(); + $this->assertInstanceOf( + AdminSummitGroupEventSerializer::class, + $this->inner($registry->getSerializer($event, SerializerRegistry::SerializerType_Private)) + ); + $this->assertInstanceOf( + AdminSummitGroupEventSerializer::class, + $this->inner($registry->getSerializer($event, SerializerRegistry::SerializerType_CSV)) + ); + $inner = $this->inner($registry->getSerializer($event, SerializerRegistry::SerializerType_Public)); + $this->assertInstanceOf(SummitGroupEventSerializer::class, $inner); + $this->assertNotInstanceOf(AdminSummitGroupEventSerializer::class, $inner); + } }