From 26eee070ab2327e02db366480e9e9c2a4cdf7b6f Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 3 Sep 2026 17:05:59 -0300 Subject: [PATCH 1/4] fix(speakers): stop gating populated speaker-profile fields on the account bio/social toggle PresentationSpeakerSerializer::checkDataPermissions() masked bio, gender, company, state, country, title, affiliations, languages, other_presentation_links, areas_of_expertise, travel_preferences, active_involvements, organizational_roles and badge_features behind isPublicProfileShowBio(), and irc/twitter behind isPublicProfileShowSocialMediaInfo() - both of which just proxy the linked Member's account-level visibility toggle. None of these are borrowed account data; they are speaker-profile fields the speaker populated directly, so per policy Rule 2 they must be public regardless of the account toggle. Leaves phone_number, email and pic/big_pic masking untouched: phone_number is already unconditionally masked per Rule 4, and pic/big_pic stays gated by isPublicProfileShowPhoto() since PresentationSpeaker::getProfilePhotoUrl() still falls back to the linked Member's photo unconditionally - fixing that fallback is tracked separately (ClickUp 86bbmbm0f). See policy/profile-data-handling.md Rules 2, 5, 9. Closes https://app.clickup.com/t/86bbkh5hq --- .../PresentationSpeakerSerializer.php | 35 +-------- tests/PresentationSpeakerSerializerTest.php | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php index fdc018f3a..e936b832d 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php @@ -350,54 +350,21 @@ public function serialize($expand = null, array $fields = [], array $relations = protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ // permissions check - if(!$speaker->isPublicProfileShowBio()) - { - if(isset($values['bio'])) $values['bio'] = ''; - if(isset($values['gender'])) $values['gender'] = ''; - if(isset($values['company'])) $values['company'] = ''; - if(isset($values['state'])) $values['state'] = ''; - if(isset($values['country'])) $values['country'] = ''; - if(isset($values['title'])) $values['title'] = ''; - - if(isset($values['affiliations'])) $values['affiliations'] = []; - if(isset($values['languages'])) $values['languages'] = []; - if(isset($values['other_presentation_links'])) $values['other_presentation_links'] = []; - if(isset($values['areas_of_expertise'])) $values['areas_of_expertise'] = []; - if(isset($values['travel_preferences'])) $values['travel_preferences'] = []; - if(isset($values['active_involvements'])) $values['active_involvements'] = []; - if(isset($values['organizational_roles'])) $values['organizational_roles'] = []; - if(isset($values['badge_features'])) $values['badge_features'] = []; - } - if(!$speaker->isPublicProfileShowEmail()) { if(isset($values['email'])) $values['email'] = ''; } - if(!$speaker->isPublicProfileShowSocialMediaInfo()) - { - if(isset($values['irc'])) $values['irc'] = ''; - if(isset($values['twitter'])) $values['twitter'] = ''; - } - if(!$speaker->isPublicProfileShowPhoto()) { if(isset($values['pic'])) $values['pic'] = Config::get("app.default_profile_image", null); if(isset($values['big_pic'])) $values['big_pic'] = Config::get("app.default_profile_image", null); } - /* - * relax full name restriction for signage - if(!$speaker->isPublicProfileShowFullname()) - { - if(isset($values['last_name'])) $values['last_name'] = ''; - } - */ - // phone_number is never public regardless of the target speaker's own account // visibility toggle - see policy/profile-data-handling.md Rule 4. if(isset($values['phone_number'])) $values['phone_number'] = ''; return $values; } -} \ No newline at end of file +} diff --git a/tests/PresentationSpeakerSerializerTest.php b/tests/PresentationSpeakerSerializerTest.php index 192a02184..3242968d3 100644 --- a/tests/PresentationSpeakerSerializerTest.php +++ b/tests/PresentationSpeakerSerializerTest.php @@ -12,6 +12,8 @@ * limitations under the License. **/ +use Illuminate\Support\Facades\Config; +use models\main\Member; use models\oauth2\IResourceServerContext; use models\summit\PresentationSpeaker; use ModelSerializers\PresentationSpeakerSerializer; @@ -49,4 +51,78 @@ public function testPhoneNumberIsMaskedInPublicContextEvenWhenSpeakerToggleIsOn( $this->assertSame('', $values['phone_number']); } + + public function testBioGatedSpeakerFieldsAreNotMaskedWhenAccountBioToggleIsOff() + { + $member = Mockery::mock(Member::class); + $member->shouldReceive('getGender')->andReturn('Female'); + + $speaker = Mockery::mock(PresentationSpeaker::class)->makePartial(); + $speaker->shouldReceive('hasMember')->andReturn(true); + $speaker->shouldReceive('getMember')->andReturn($member); + $speaker->shouldReceive('getBio')->andReturn('A speaker bio'); + $speaker->shouldReceive('getCompany')->andReturn('Acme Corp'); + $speaker->shouldReceive('getCountry')->andReturn('AR'); + $speaker->shouldReceive('getTitle')->andReturn('CTO'); + // the target speaker's own account-level "show bio" toggle is OFF - populated + // speaker-profile fields (policy Rule 2) must stay public regardless. + $speaker->shouldReceive('isPublicProfileShowBio')->andReturn(false); + $speaker->shouldReceive('isPublicProfileShowEmail')->andReturn(true); + $speaker->shouldReceive('isPublicProfileShowPhoto')->andReturn(true); + + $resource_server_context = Mockery::mock(IResourceServerContext::class); + $serializer = new PresentationSpeakerSerializer($speaker, $resource_server_context); + + $values = $serializer->serialize(null, ['bio', 'gender', 'company', 'country', 'title'], ['none']); + + $this->assertSame('A speaker bio', $values['bio']); + $this->assertSame('Female', $values['gender']); + $this->assertSame('Acme Corp', $values['company']); + $this->assertSame('AR', $values['country']); + $this->assertSame('CTO', $values['title']); + } + + public function testSocialMediaFieldsAreNotMaskedWhenAccountSocialToggleIsOff() + { + $speaker = Mockery::mock(PresentationSpeaker::class)->makePartial(); + $speaker->shouldReceive('hasMember')->andReturn(false); + $speaker->shouldReceive('getIRCHandle')->andReturn('speaker_nick'); + $speaker->shouldReceive('getTwitterName')->andReturn('@speaker_nick'); + // the target speaker's own account-level "show social media" toggle is OFF - irc/twitter + // are populated speaker-profile fields (policy Rule 2) and must stay public regardless. + $speaker->shouldReceive('isPublicProfileShowSocialMediaInfo')->andReturn(false); + $speaker->shouldReceive('isPublicProfileShowEmail')->andReturn(true); + $speaker->shouldReceive('isPublicProfileShowPhoto')->andReturn(true); + + $resource_server_context = Mockery::mock(IResourceServerContext::class); + $serializer = new PresentationSpeakerSerializer($speaker, $resource_server_context); + + $values = $serializer->serialize(null, ['irc', 'twitter'], ['none']); + + $this->assertSame('speaker_nick', $values['irc']); + $this->assertSame('@speaker_nick', $values['twitter']); + } + + public function testPhotoFallbackIsStillMaskedWhenAccountPhotoToggleIsOff() + { + $speaker = Mockery::mock(PresentationSpeaker::class)->makePartial(); + $speaker->shouldReceive('hasMember')->andReturn(false); + $speaker->shouldReceive('getProfilePhotoUrl')->andReturn('https://example.com/pic.jpg'); + $speaker->shouldReceive('getBigProfilePhotoUrl')->andReturn('https://example.com/big_pic.jpg'); + // policy Rule 9: a borrowed-from-account photo fallback must still honor the account's + // own visibility toggle - unlike Rule 2's populated speaker fields. Fixing that fallback + // to stop being unconditional is tracked separately (ClickUp 86bbmbm0f); this masking + // must not regress while that sibling ticket is still pending. + $speaker->shouldReceive('isPublicProfileShowPhoto')->andReturn(false); + $speaker->shouldReceive('isPublicProfileShowEmail')->andReturn(true); + + $resource_server_context = Mockery::mock(IResourceServerContext::class); + $serializer = new PresentationSpeakerSerializer($speaker, $resource_server_context); + + $values = $serializer->serialize(null, ['pic', 'big_pic'], ['none']); + + $default_pic = Config::get("app.default_profile_image", null); + $this->assertSame($default_pic, $values['pic']); + $this->assertSame($default_pic, $values['big_pic']); + } } From b64a6b99a97ca5e0336981033be502562d78844f Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 3 Sep 2026 17:38:09 -0300 Subject: [PATCH 2/4] fix(speakers): honor account visibility toggle on name/photo Member fallback PresentationSpeaker's getFirstName()/getLastName()/getFullName() and getProfilePhotoUrl()/getBigProfilePhotoUrl() fell back to the linked Member's name/photo whenever the speaker's own field was empty, unconditionally. Per policy Rule 9, a fallback that borrows account data must honor that account's own visibility toggle at the point of borrowing, since it is genuinely displaying account data at that moment - unlike a populated speaker field, which stays public unconditionally per Rule 2. Gate the Member fallback on isPublicProfileShowFullname()/ isPublicProfileShowPhoto() so a toggle-off skips the Member's value and continues to the next fallback (default image or blank name) instead. Removed PresentationSpeakerSerializer's post-hoc pic/big_pic masking, which re-masked populated speaker photos too (violating Rule 2) and is now redundant with the model-level gate. ClickUp: https://app.clickup.com/t/86bbmbm0f --- .../PresentationSpeakerSerializer.php | 6 -- .../Summit/Speakers/PresentationSpeaker.php | 10 +- tests/PresentationSpeakerSerializerTest.php | 16 ++- .../Unit/Entities/PresentationSpeakerTest.php | 100 ++++++++++++++++++ 4 files changed, 112 insertions(+), 20 deletions(-) diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php index e936b832d..2d4975a1a 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php @@ -355,12 +355,6 @@ protected function checkDataPermissions(PresentationSpeaker $speaker, array $val if(isset($values['email'])) $values['email'] = ''; } - if(!$speaker->isPublicProfileShowPhoto()) - { - if(isset($values['pic'])) $values['pic'] = Config::get("app.default_profile_image", null); - if(isset($values['big_pic'])) $values['big_pic'] = Config::get("app.default_profile_image", null); - } - // phone_number is never public regardless of the target speaker's own account // visibility toggle - see policy/profile-data-handling.md Rule 4. if(isset($values['phone_number'])) $values['phone_number'] = ''; diff --git a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php index cdcae06d5..c1bf75baf 100644 --- a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php +++ b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php @@ -210,7 +210,7 @@ class PresentationSpeaker extends SilverstripeBaseModel public function getFirstName():?string { $res = $this->first_name; - if(empty($res) && $this->hasMember()){ + if(empty($res) && $this->isPublicProfileShowFullname() && $this->hasMember()){ $res = $this->member->getFirstName(); } return $res; @@ -230,7 +230,7 @@ public function setFirstName(string $first_name):void public function getLastName():?string { $res = $this->last_name; - if(empty($res) && $this->hasMember()){ + if(empty($res) && $this->isPublicProfileShowFullname() && $this->hasMember()){ $res = $this->member->getLastName(); } return $res; @@ -1801,7 +1801,7 @@ public function getFullName(): ?string if (!empty($fullname)) $fullname .= ' '; $fullname .= $this->last_name; } - if (empty($fullname) && $this->hasMember()) { + if (empty($fullname) && $this->isPublicProfileShowFullname() && $this->hasMember()) { $fullname = $this->member->getFullName(); } @@ -2342,7 +2342,7 @@ public function getBigProfilePhotoUrl(): ?string if ($this->hasBigPhoto() && $photo = $this->getBigPhoto()) { $photoUrl = $photo->getUrl(); } - if (empty($photoUrl) && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { + if (empty($photoUrl) && $this->isPublicProfileShowPhoto() && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { $photoUrl = $photo->getUrl(); } @@ -2367,7 +2367,7 @@ public function getProfilePhotoUrl(): ?string if ($this->hasPhoto() && $photo = $this->getPhoto()) { $photoUrl = $photo->getUrl(); } - if (empty($photoUrl) && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { + if (empty($photoUrl) && $this->isPublicProfileShowPhoto() && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { $photoUrl = $photo->getUrl(); } } catch (\Exception $ex) { diff --git a/tests/PresentationSpeakerSerializerTest.php b/tests/PresentationSpeakerSerializerTest.php index 3242968d3..587250b63 100644 --- a/tests/PresentationSpeakerSerializerTest.php +++ b/tests/PresentationSpeakerSerializerTest.php @@ -12,7 +12,6 @@ * limitations under the License. **/ -use Illuminate\Support\Facades\Config; use models\main\Member; use models\oauth2\IResourceServerContext; use models\summit\PresentationSpeaker; @@ -103,16 +102,16 @@ public function testSocialMediaFieldsAreNotMaskedWhenAccountSocialToggleIsOff() $this->assertSame('@speaker_nick', $values['twitter']); } - public function testPhotoFallbackIsStillMaskedWhenAccountPhotoToggleIsOff() + public function testPhotoUrlsPassThroughSerializerRegardlessOfAccountPhotoToggle() { $speaker = Mockery::mock(PresentationSpeaker::class)->makePartial(); $speaker->shouldReceive('hasMember')->andReturn(false); $speaker->shouldReceive('getProfilePhotoUrl')->andReturn('https://example.com/pic.jpg'); $speaker->shouldReceive('getBigProfilePhotoUrl')->andReturn('https://example.com/big_pic.jpg'); - // policy Rule 9: a borrowed-from-account photo fallback must still honor the account's - // own visibility toggle - unlike Rule 2's populated speaker fields. Fixing that fallback - // to stop being unconditional is tracked separately (ClickUp 86bbmbm0f); this masking - // must not regress while that sibling ticket is still pending. + // policy Rule 9: the account-toggle gate on the borrowed-from-account photo fallback now + // lives inside getProfilePhotoUrl()/getBigProfilePhotoUrl() themselves (ClickUp 86bbmbm0f), + // not in this serializer - so the serializer must pass their result through unmasked even + // when the toggle is off, instead of re-applying its own masking on top. $speaker->shouldReceive('isPublicProfileShowPhoto')->andReturn(false); $speaker->shouldReceive('isPublicProfileShowEmail')->andReturn(true); @@ -121,8 +120,7 @@ public function testPhotoFallbackIsStillMaskedWhenAccountPhotoToggleIsOff() $values = $serializer->serialize(null, ['pic', 'big_pic'], ['none']); - $default_pic = Config::get("app.default_profile_image", null); - $this->assertSame($default_pic, $values['pic']); - $this->assertSame($default_pic, $values['big_pic']); + $this->assertSame('https://example.com/pic.jpg', $values['pic']); + $this->assertSame('https://example.com/big_pic.jpg', $values['big_pic']); } } diff --git a/tests/Unit/Entities/PresentationSpeakerTest.php b/tests/Unit/Entities/PresentationSpeakerTest.php index 04ca3eb08..f1706edbe 100644 --- a/tests/Unit/Entities/PresentationSpeakerTest.php +++ b/tests/Unit/Entities/PresentationSpeakerTest.php @@ -16,7 +16,10 @@ **/ use App\Models\Foundation\Main\Language; +use Illuminate\Support\Facades\Config; +use Mockery; use models\main\File; +use models\main\Member; use models\summit\Presentation; use models\summit\PresentationSpeaker; use models\summit\SpeakerExpertise; @@ -42,6 +45,7 @@ protected function setUp():void public function tearDown():void { + Mockery::close(); self::clearSummitTestData(); parent::tearDown(); } @@ -142,4 +146,100 @@ public function testPersistPresentationSpeaker(){ $this->assertEmpty($found_speaker->getLanguages()->toArray()); } + + /** + * Policy Rule 9: the name fallback to the linked Member must skip that Member's value + * (leaving the speaker's own field blank) when the Member's own visibility toggle is off. + */ + public function testNameFallbackSkipsMemberWhenAccountFullnameToggleIsOff() + { + $member = Mockery::mock(Member::class); + $member->shouldReceive('getId')->andReturn(42); + $member->shouldReceive('setSpeaker')->andReturnNull(); + $member->shouldReceive('isPublicProfileShowFullname')->andReturn(false); + // stubbed (not just omitted) so a missing gate surfaces this value instead of an + // uncaught Mockery exception, which would falsely look like the gate held. + $member->shouldReceive('getFirstName')->andReturn('Ada'); + $member->shouldReceive('getLastName')->andReturn('Lovelace'); + $member->shouldReceive('getFullName')->andReturn('Ada Lovelace'); + + $speaker = new PresentationSpeaker(); + $speaker->setMember($member); + + $this->assertEmpty($speaker->getFirstName()); + $this->assertEmpty($speaker->getLastName()); + $this->assertEmpty($speaker->getFullName()); + } + + /** + * Policy Rule 9: the name fallback still applies the Member's own value when that + * Member's visibility toggle is on. + */ + public function testNameFallbackUsesMemberWhenAccountFullnameToggleIsOn() + { + $member = Mockery::mock(Member::class); + $member->shouldReceive('getId')->andReturn(42); + $member->shouldReceive('setSpeaker')->andReturnNull(); + $member->shouldReceive('isPublicProfileShowFullname')->andReturn(true); + $member->shouldReceive('getFirstName')->andReturn('Ada'); + $member->shouldReceive('getLastName')->andReturn('Lovelace'); + $member->shouldReceive('getFullName')->andReturn('Ada Lovelace'); + + $speaker = new PresentationSpeaker(); + $speaker->setMember($member); + + $this->assertSame('Ada', $speaker->getFirstName()); + $this->assertSame('Lovelace', $speaker->getLastName()); + $this->assertSame('Ada Lovelace', $speaker->getFullName()); + } + + /** + * Policy Rule 9: the photo fallback to the linked Member must skip that Member's photo + * (continuing to the configured default image) when the Member's own visibility toggle is off. + */ + public function testPhotoFallbackSkipsMemberWhenAccountPhotoToggleIsOff() + { + $photo = Mockery::mock(File::class); + $photo->shouldReceive('getUrl')->andReturn('https://example.com/member-photo.jpg'); + + $member = Mockery::mock(Member::class); + $member->shouldReceive('getId')->andReturn(42); + $member->shouldReceive('setSpeaker')->andReturnNull(); + $member->shouldReceive('isPublicProfileShowPhoto')->andReturn(false); + // stubbed (not just omitted) so a missing gate surfaces this photo instead of silently + // passing: both getProfilePhotoUrl()/getBigProfilePhotoUrl() wrap this branch in a + // try/catch that would swallow an unstubbed-call exception and mask a missing gate. + $member->shouldReceive('hasPhoto')->andReturn(true); + $member->shouldReceive('getPhoto')->andReturn($photo); + + $speaker = new PresentationSpeaker(); + $speaker->setMember($member); + + $default_pic = Config::get("app.default_profile_image", null); + $this->assertSame($default_pic, $speaker->getProfilePhotoUrl()); + $this->assertSame($default_pic, $speaker->getBigProfilePhotoUrl()); + } + + /** + * Policy Rule 9: the photo fallback still applies the Member's own photo when that + * Member's visibility toggle is on. + */ + public function testPhotoFallbackUsesMemberWhenAccountPhotoToggleIsOn() + { + $photo = Mockery::mock(File::class); + $photo->shouldReceive('getUrl')->andReturn('https://example.com/member-photo.jpg'); + + $member = Mockery::mock(Member::class); + $member->shouldReceive('getId')->andReturn(42); + $member->shouldReceive('setSpeaker')->andReturnNull(); + $member->shouldReceive('isPublicProfileShowPhoto')->andReturn(true); + $member->shouldReceive('hasPhoto')->andReturn(true); + $member->shouldReceive('getPhoto')->andReturn($photo); + + $speaker = new PresentationSpeaker(); + $speaker->setMember($member); + + $this->assertSame('https://example.com/member-photo.jpg', $speaker->getProfilePhotoUrl()); + $this->assertSame('https://example.com/member-photo.jpg', $speaker->getBigProfilePhotoUrl()); + } } \ No newline at end of file From 654ca0e79cf22cea0855375ba711e2ddb752279b Mon Sep 17 00:00:00 2001 From: smarcet Date: Sat, 5 Sep 2026 02:59:58 -0300 Subject: [PATCH 3/4] fix(speakers): remove residual unconditional name fallback in base serializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresentationSpeakerBaseSerializer::serialize() carried its own copy of the first_name/last_name Member fallback that ran after the generic attribute-mapping pass, overwriting an already-correct (toggle-respecting) value whenever it was empty - reintroducing the exact Rule 9 violation the model-level getFirstName()/getLastName() gate (commit b64a6b99a) had just fixed, since this block called $member->getFirstName()/getLastName() directly instead of going through the gated model getters. Removed the block; the generic reflection-based mapping already calls PresentationSpeaker::getFirstName()/getLastName() with no override, which correctly returns empty when the account's isPublicProfileShowFullname() toggle is off. AdminPresentationSpeakerSerializer and AdminPresentationSpeakerCSVSerializer now explicitly call getFirstName(true)/getLastName(true) to keep showing real names in the admin/self-view contexts they're scoped to (Private/Admin serializer types, resolved only for admins or a speaker's own record per BaseSerializerTypeSelector and the CheckSpeakerStrategyFactory::Me / getSpeakerByMember($current_member) call sites) - both out of scope of Rule 9 per policy/profile-data-handling.md ยง2. --- .../AdminPresentationSpeakerCSVSerializer.php | 11 ++++++++++- .../AdminPresentationSpeakerSerializer.php | 13 +++++++++++-- .../PresentationSpeakerBaseSerializer.php | 19 +------------------ .../Summit/Speakers/PresentationSpeaker.php | 16 +++++++++------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php index 9593b2b5f..9eea4586c 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php @@ -48,6 +48,15 @@ public function serialize($expand = null, array $fields = [], array $relations = if(!$speaker instanceof PresentationSpeaker) return []; $values = parent::serialize($expand, $fields, $relations, $params); + + if(in_array('first_name', $fields)) { + $values['first_name'] = $speaker->getFirstName(true); + } + + if(in_array('last_name', $fields)) { + $values['last_name'] = $speaker->getLastName(true); + } + if(in_array("email", $fields)) $values['email'] = JsonUtils::toJsonString($speaker->getEmail()); @@ -92,4 +101,4 @@ public function serialize($expand = null, array $fields = [], array $relations = return $values; } -} \ No newline at end of file +} diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 9fa4c52f4..314d4a4ec 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -39,7 +39,7 @@ final class AdminPresentationSpeakerSerializer extends PresentationSpeakerSerial 'summit_assistance', 'registration_code', ]; - + protected function checkDataPermissions(PresentationSpeaker $speaker, array $values):array{ return $values; } @@ -64,6 +64,15 @@ public function serialize($expand = null, array $fields = [], array $relations = $values = parent::serialize($expand, $fields, $relations, $params); $summit = isset($params['summit'])? $params['summit']:null; + + if(in_array('first_name', $fields)) { + $values['first_name'] = $speaker->getFirstName(true); + } + + if(in_array('last_name', $fields)) { + $values['last_name'] = $speaker->getLastName(true); + } + if(in_array("email", $fields)) { $application_type = $this->resource_server_context->getApplicationType(); // choose email serializer depending on user permissions @@ -214,4 +223,4 @@ public function serialize($expand = null, array $fields = [], array $relations = } return $values; } -} \ No newline at end of file +} diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php index c9d91b91e..11341c4d6 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php @@ -83,23 +83,6 @@ public function serialize($expand = null, array $fields = [], array $relations = $values = parent::serialize($expand, $fields, $relations, $params); - if ( - (empty($values['first_name']) || empty($values['last_name'])) - && in_array('first_name', $fields) && in_array('last_name', $fields) - ) { - - $first_name = ''; - $last_name = ''; - if ($speaker->hasMember()) { - $member = $speaker->getMember(); - $first_name = $member->getFirstName(); - $last_name = $member->getLastName(); - } - $values['first_name'] = $first_name; - $values['last_name'] = $last_name; - } - - if(in_array("email", $fields)) { $application_type = $this->resource_server_context->getApplicationType(); // choose email serializer depending on user permissions @@ -111,4 +94,4 @@ public function serialize($expand = null, array $fields = [], array $relations = return $values; } -} \ No newline at end of file +} diff --git a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php index c1bf75baf..f7b5a18ee 100644 --- a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php +++ b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php @@ -207,10 +207,10 @@ class PresentationSpeaker extends SilverstripeBaseModel /** * @return string|null */ - public function getFirstName():?string + public function getFirstName(bool $override_permission=false):?string { $res = $this->first_name; - if(empty($res) && $this->isPublicProfileShowFullname() && $this->hasMember()){ + if(empty($res) && ($this->isPublicProfileShowFullname() || $override_permission) && $this->hasMember()){ $res = $this->member->getFirstName(); } return $res; @@ -225,12 +225,13 @@ public function setFirstName(string $first_name):void } /** + * @param bool $override_permission * @return string|null */ - public function getLastName():?string + public function getLastName(bool $override_permission=false):?string { $res = $this->last_name; - if(empty($res) && $this->isPublicProfileShowFullname() && $this->hasMember()){ + if(empty($res) && ($this->isPublicProfileShowFullname() || $override_permission) && $this->hasMember()){ $res = $this->member->getLastName(); } return $res; @@ -1792,16 +1793,17 @@ public function setRegistrationRequest($registration_request) } /** - * @return string + * @param bool $override_permission + * @return string|null */ - public function getFullName(): ?string + public function getFullName(bool $override_permission=false): ?string { $fullname = $this->first_name; if (!empty($this->last_name)) { if (!empty($fullname)) $fullname .= ' '; $fullname .= $this->last_name; } - if (empty($fullname) && $this->isPublicProfileShowFullname() && $this->hasMember()) { + if (empty($fullname) && ($this->isPublicProfileShowFullname() || $override_permission) && $this->hasMember()) { $fullname = $this->member->getFullName(); } From 6cb6647f1fd902fdbea327b59dccf9755b2d1a9c Mon Sep 17 00:00:00 2001 From: smarcet Date: Sat, 5 Sep 2026 03:00:41 -0300 Subject: [PATCH 4/4] fix(presentations): bypass account visibility toggle for admin/track-chair CSV name exports AdminPresentationCSVSerializer, TrackChairPresentationCSVSerializer, SpeakersRegistrationDiscountCodeCSVSerializer and SpeakersSummitRegistrationPromoCodeCSVSerializer all called PresentationSpeaker::getFullName() with no argument for moderator, co-speaker, submitter and promo-code-owner names. Since commit b64a6b99a added the isPublicProfileShowFullname() gate to that method, these admin/track-chair-only CSV exports started silently blanking a speaker's name whenever their own first_name/last_name were empty and their linked account's "show full name" toggle was off - the same Rule 9 fallback gap already fixed for AdminPresentationSpeakerSerializer/CSV, just not yet applied to these sibling exports. Switched all four call sites to getFullName(true): each is reachable only through SerializerType_CSV, gated behind admin/track-chair-only routes, so this is out of scope of the policy's account-visibility rule per policy/profile-data-handling.md section 2 ("internal admin-only tooling views... may show unmasked data to admins by design"). Left SpeakerPresentationEmailSerializer untouched: its full_name fields surface co-speaker/moderator names to a different recipient (the presentation's other speaker via the selection-process email), which is exactly the cross-person display Rule 9 protects - not an admin view or a self-view. Added AdminCsvSpeakerFullNameFallbackTest with one regression test per serializer, each asserting the exported name only ever comes from getFullName(true) (verified red/green by reverting each call site in turn and confirming Mockery NoMatchingExpectationException on the bare getFullName() call). --- .../AdminPresentationCSVSerializer.php | 6 +- .../TrackChairPresentationCSVSerializer.php | 6 +- ...sRegistrationDiscountCodeCSVSerializer.php | 2 +- ...mmitRegistrationPromoCodeCSVSerializer.php | 2 +- tests/AdminCsvSpeakerFullNameFallbackTest.php | 178 ++++++++++++++++++ 5 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 tests/AdminCsvSpeakerFullNameFallbackTest.php diff --git a/app/ModelSerializers/Summit/Presentation/AdminPresentationCSVSerializer.php b/app/ModelSerializers/Summit/Presentation/AdminPresentationCSVSerializer.php index c21c481f5..a98740341 100644 --- a/app/ModelSerializers/Summit/Presentation/AdminPresentationCSVSerializer.php +++ b/app/ModelSerializers/Summit/Presentation/AdminPresentationCSVSerializer.php @@ -88,7 +88,7 @@ public function serialize($expand = null, array $fields = [], array $relations = if(in_array("moderator_id",$fields)) $values['moderator_id'] = $presentation->getModerator()->getId(); if(in_array("moderator_full_name",$fields)) - $values['moderator_full_name'] = $presentation->getModerator()->getFullName(); + $values['moderator_full_name'] = $presentation->getModerator()->getFullName(true); if(in_array("moderator_email",$fields)) $values['moderator_email'] = $presentation->getModerator()->getEmail(); if(in_array("moderator_title",$fields)) @@ -122,7 +122,7 @@ public function serialize($expand = null, array $fields = [], array $relations = foreach ($presentation->getSpeakers() as $speaker) { $speaker_ids[] = $speaker->getId(); - $speaker_fullnames[] = $speaker->getFullName(); + $speaker_fullnames[] = $speaker->getFullName(true); $speaker_emails[] = $speaker->getEmail(); $speaker_titles[] = trim($speaker->getTitle()); $speaker_companies[] = trim($speaker->getCompany()); @@ -164,7 +164,7 @@ public function serialize($expand = null, array $fields = [], array $relations = if(in_array("submitter_id",$fields)) $values['submitter_id'] = $submitter->getId(); if(in_array("submitter_full_name",$fields)) - $values['submitter_full_name'] = $submitter->getFullName(); + $values['submitter_full_name'] = $submitter->getFullName(true); if(in_array("submitter_email",$fields)) $values['submitter_email'] = $submitter->getEmail(); if(in_array("submitter_title",$fields)) diff --git a/app/ModelSerializers/Summit/Presentation/TrackChairPresentationCSVSerializer.php b/app/ModelSerializers/Summit/Presentation/TrackChairPresentationCSVSerializer.php index 0aac8fb23..9801bb144 100644 --- a/app/ModelSerializers/Summit/Presentation/TrackChairPresentationCSVSerializer.php +++ b/app/ModelSerializers/Summit/Presentation/TrackChairPresentationCSVSerializer.php @@ -95,7 +95,7 @@ public function serialize($expand = null, array $fields = [], array $relations = if ($presentation->hasModerator()) { $values['moderator_id'] = $presentation->getModerator()->getId(); - $values['moderator_full_name'] = $presentation->getModerator()->getFullName(); + $values['moderator_full_name'] = $presentation->getModerator()->getFullName(true); $values['moderator_email'] = $presentation->getModerator()->getEmail(); $values['moderator_title'] = trim($presentation->getModerator()->getTitle()); $values['moderator_company'] = trim($presentation->getModerator()->getCompany()); @@ -119,7 +119,7 @@ public function serialize($expand = null, array $fields = [], array $relations = foreach ($presentation->getSpeakers() as $speaker) { $speaker_ids[] = $speaker->getId(); - $speaker_fullnames[] = $speaker->getFullName(); + $speaker_fullnames[] = $speaker->getFullName(true); $speaker_emails[] = $speaker->getEmail(); $speaker_titles[] = trim($speaker->getTitle()); $speaker_companies[] = trim($speaker->getCompany()); @@ -145,7 +145,7 @@ public function serialize($expand = null, array $fields = [], array $relations = if ($creator->hasSpeaker()) { $submitter = $creator->getSpeaker(); $values['submitter_id'] = $submitter->getId(); - $values['submitter_full_name'] = $submitter->getFullName(); + $values['submitter_full_name'] = $submitter->getFullName(true); $values['submitter_email'] = $submitter->getEmail(); $values['submitter_title'] = $submitter->getTitle(); $values['submitter_company'] = $submitter->getCompany(); diff --git a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersRegistrationDiscountCodeCSVSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersRegistrationDiscountCodeCSVSerializer.php index 6f7d4a32d..64bf8cde5 100644 --- a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersRegistrationDiscountCodeCSVSerializer.php +++ b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersRegistrationDiscountCodeCSVSerializer.php @@ -44,7 +44,7 @@ public function serialize($expand = null, array $fields = [], array $relations = $owner_name = []; $owner_email = []; foreach($code->getOwners() as $owner){ - $owner_name[] = $owner->getSpeaker()->getFullName(); + $owner_name[] = $owner->getSpeaker()->getFullName(true); $owner_email[] = $owner->getSpeaker()->getEmail(); } diff --git a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersSummitRegistrationPromoCodeCSVSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersSummitRegistrationPromoCodeCSVSerializer.php index 9bfab8fe5..e2d162159 100644 --- a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersSummitRegistrationPromoCodeCSVSerializer.php +++ b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakersSummitRegistrationPromoCodeCSVSerializer.php @@ -43,7 +43,7 @@ public function serialize($expand = null, array $fields = [], array $relations = $owner_name = []; $owner_email = []; foreach($code->getOwners() as $owner){ - $owner_name[] = $owner->getSpeaker()->getFullName(); + $owner_name[] = $owner->getSpeaker()->getFullName(true); $owner_email[] = $owner->getSpeaker()->getEmail(); } diff --git a/tests/AdminCsvSpeakerFullNameFallbackTest.php b/tests/AdminCsvSpeakerFullNameFallbackTest.php new file mode 100644 index 000000000..5ba5f268c --- /dev/null +++ b/tests/AdminCsvSpeakerFullNameFallbackTest.php @@ -0,0 +1,178 @@ +shouldReceive('getApplicationType')->andReturn('JS_CLIENT'); + $context->shouldReceive('getCurrentUser')->andReturn($current_user); + return $context; + } + + private function buildSpeaker(string $full_name): PresentationSpeaker + { + $speaker = Mockery::mock(PresentationSpeaker::class); + $speaker->shouldReceive('getFullName')->once()->with(true)->andReturn($full_name); + $speaker->shouldReceive('getId')->andReturn(1); + $speaker->shouldReceive('getEmail')->andReturn('speaker@example.com'); + $speaker->shouldReceive('getTitle')->andReturn(''); + $speaker->shouldReceive('getCompany')->andReturn(''); + $speaker->shouldReceive('getCountry')->andReturn(''); + return $speaker; + } + + private function buildPresentation + ( + int $id, + PresentationSpeaker $moderator, + PresentationSpeaker $speaker, + PresentationSpeaker $submitter + ): Presentation + { + $creator = Mockery::mock(Member::class); + $creator->shouldReceive('hasSpeaker')->andReturn(true); + $creator->shouldReceive('getSpeaker')->andReturn($submitter); + + $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getTrackChairByMember')->andReturn(null); + + $presentation = Mockery::mock(Presentation::class); + $presentation->shouldReceive('getId')->andReturn($id); + $presentation->shouldReceive('getLastEditedUTC')->andReturn(null); + $presentation->shouldReceive('hasModerator')->andReturn(true); + $presentation->shouldReceive('getModerator')->andReturn($moderator); + $presentation->shouldReceive('getSpeakers')->andReturn(new ArrayCollection([$speaker])); + $presentation->shouldReceive('hasCreatedBy')->andReturn(true); + $presentation->shouldReceive('getCreatedBy')->andReturn($creator); + $presentation->shouldReceive('getMediaUploads')->andReturn(new ArrayCollection([])); + $presentation->shouldReceive('getSummit')->andReturn($summit); + $presentation->shouldReceive('getExtraQuestionAnswers')->andReturn(new ArrayCollection([])); + $presentation->shouldReceive('hasCategory')->andReturn(false); + $presentation->shouldReceive('getPresentationActions')->andReturn(new ArrayCollection([])); + + return $presentation; + } + + public function testAdminPresentationCSVSerializerBypassesToggleForModeratorSpeakerAndSubmitter() + { + $moderator = $this->buildSpeaker('Moderator Real Name'); + $speaker = $this->buildSpeaker('Co-Speaker Real Name'); + $submitter = $this->buildSpeaker('Submitter Real Name'); + $presentation = $this->buildPresentation(80101, $moderator, $speaker, $submitter); + + $serializer = new AdminPresentationCSVSerializer($presentation, $this->buildResourceServerContext()); + $values = $serializer->serialize( + null, + ['moderator_full_name', 'speaker_fullnames', 'submitter_full_name'], + ['none'] + ); + + $this->assertSame('Moderator Real Name', $values['moderator_full_name']); + $this->assertSame('Co-Speaker Real Name', $values['speaker_fullnames']); + $this->assertSame('Submitter Real Name', $values['submitter_full_name']); + } + + public function testTrackChairPresentationCSVSerializerBypassesToggleForModeratorSpeakerAndSubmitter() + { + $moderator = $this->buildSpeaker('Moderator Real Name'); + $speaker = $this->buildSpeaker('Co-Speaker Real Name'); + $submitter = $this->buildSpeaker('Submitter Real Name'); + $presentation = $this->buildPresentation(80102, $moderator, $speaker, $submitter); + + $serializer = new TrackChairPresentationCSVSerializer($presentation, $this->buildResourceServerContext()); + $values = $serializer->serialize(null, ['id'], ['none']); + + $this->assertSame('Moderator Real Name', $values['moderator_full_name']); + $this->assertSame('Co-Speaker Real Name', $values['speaker_fullnames']); + $this->assertSame('Submitter Real Name', $values['submitter_full_name']); + } + + public function testSpeakersRegistrationDiscountCodeCSVSerializerBypassesToggleForOwner() + { + $owner_speaker = $this->buildSpeaker('Owner Real Name'); + $owner = Mockery::mock(AssignedPromoCodeSpeaker::class); + $owner->shouldReceive('getSpeaker')->andReturn($owner_speaker); + + $code = Mockery::mock(SpeakersRegistrationDiscountCode::class); + $code->shouldReceive('getOwners')->andReturn(new ArrayCollection([$owner])); + $code->shouldReceive('getBadgeFeatures')->andReturn(new ArrayCollection([])); + $code->shouldReceive('getTicketTypesRules')->andReturn(new ArrayCollection([])); + $code->shouldReceive('getTags')->andReturn(new ArrayCollection([])); + $code->shouldReceive('isInfinite')->andReturn(false); + + $serializer = new SpeakersRegistrationDiscountCodeCSVSerializer($code, $this->buildResourceServerContext()); + $values = $serializer->serialize(null, ['owner_name'], ['none']); + + $this->assertSame('Owner Real Name', $values['owner_name']); + } + + public function testSpeakersSummitRegistrationPromoCodeCSVSerializerBypassesToggleForOwner() + { + $owner_speaker = $this->buildSpeaker('Owner Real Name'); + $owner = Mockery::mock(AssignedPromoCodeSpeaker::class); + $owner->shouldReceive('getSpeaker')->andReturn($owner_speaker); + + $code = Mockery::mock(SpeakersSummitRegistrationPromoCode::class); + $code->shouldReceive('getOwners')->andReturn(new ArrayCollection([$owner])); + $code->shouldReceive('getBadgeFeatures')->andReturn(new ArrayCollection([])); + $code->shouldReceive('getAllowedTicketTypes')->andReturn(new ArrayCollection([])); + $code->shouldReceive('getTags')->andReturn(new ArrayCollection([])); + $code->shouldReceive('isInfinite')->andReturn(false); + + $serializer = new SpeakersSummitRegistrationPromoCodeCSVSerializer($code, $this->buildResourceServerContext()); + $values = $serializer->serialize(null, ['owner_name'], ['none']); + + $this->assertSame('Owner Real Name', $values['owner_name']); + } +}