From 26eee070ab2327e02db366480e9e9c2a4cdf7b6f Mon Sep 17 00:00:00 2001 From: smarcet Date: Thu, 3 Sep 2026 17:05:59 -0300 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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']); + } +} From ba5636b7ffcfd438f9e81d46ecad535db22b5599 Mon Sep 17 00:00:00 2001 From: smarcet Date: Sun, 6 Sep 2026 23:55:11 -0300 Subject: [PATCH 05/15] fix(speakers): add override_permission to the photo fallback and fix self-view email getProfilePhotoUrl()/getBigProfilePhotoUrl() gained the isPublicProfileShowPhoto() gate in commit b64a6b99a but, unlike getFirstName()/getLastName()/getFullName(), had no override_permission parameter to opt back out of it. AdminPresentationSpeakerSerializer and AdminPresentationSpeakerCSVSerializer serve pic/big_pic purely through the generic array_mappings reflection (no argument passed), so admin callers and a speaker viewing their own profile (SerializerType_Private/Admin, same routes already fixed for name) lost the real photo and silently fell back to the default image whenever the linked account's toggle was off. Added the override_permission parameter to both photo getters, mirroring the name getters, and wired pic/big_pic overrides into both admin speaker serializers the same way first_name/last_name already are. Also switched SpeakerCreationEmail's speaker_full_name payload to getFullName(true): it is a self-addressed welcome email (sent to the newly created speaker's own address), not a masked public view, so it should behave like the other self-view call sites. Its dispatch is currently commented out in SpeakerService::addSpeaker, so this has no live effect today - only avoids a landmine if that email is re-enabled. Added a regression test for the photo override (verified red/green: the override_permission param was ignored before the model fix, still returning the default image instead of the Member's photo). --- .../SpeakerCreationEmail.php | 2 +- .../AdminPresentationSpeakerCSVSerializer.php | 8 ++++++ .../AdminPresentationSpeakerSerializer.php | 8 ++++++ .../Summit/Speakers/PresentationSpeaker.php | 10 +++++--- .../Unit/Entities/PresentationSpeakerTest.php | 25 +++++++++++++++++++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/app/Jobs/Emails/PresentationSubmissions/SpeakerCreationEmail.php b/app/Jobs/Emails/PresentationSubmissions/SpeakerCreationEmail.php index eaf5f2404..aebc29c2e 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SpeakerCreationEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SpeakerCreationEmail.php @@ -52,7 +52,7 @@ public function __construct(PresentationSpeaker $speaker) throw new \InvalidArgumentException('cfp.support_email is null.'); $payload = []; - $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(); + $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(true); $payload[IMailTemplatesConstants::speaker_email] = $speaker->getEmail(); $payload[IMailTemplatesConstants::speaker_management_link] = $speaker_management_base_url; $bio_edit_link = sprintf("%s/app/profile", $speaker_management_base_url); diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php index 9eea4586c..bc95de170 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php @@ -57,6 +57,14 @@ public function serialize($expand = null, array $fields = [], array $relations = $values['last_name'] = $speaker->getLastName(true); } + if(in_array('pic', $fields)) { + $values['pic'] = $speaker->getProfilePhotoUrl(true); + } + + if(in_array('big_pic', $fields)) { + $values['big_pic'] = $speaker->getBigProfilePhotoUrl(true); + } + if(in_array("email", $fields)) $values['email'] = JsonUtils::toJsonString($speaker->getEmail()); diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 314d4a4ec..53179b93f 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -73,6 +73,14 @@ public function serialize($expand = null, array $fields = [], array $relations = $values['last_name'] = $speaker->getLastName(true); } + if(in_array('pic', $fields)) { + $values['pic'] = $speaker->getProfilePhotoUrl(true); + } + + if(in_array('big_pic', $fields)) { + $values['big_pic'] = $speaker->getBigProfilePhotoUrl(true); + } + if(in_array("email", $fields)) { $application_type = $this->resource_server_context->getApplicationType(); // choose email serializer depending on user permissions diff --git a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php index f7b5a18ee..2be1e6517 100644 --- a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php +++ b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php @@ -2334,9 +2334,10 @@ public function getPhotoId() } /** + * @param bool $override_permission * @return string|null */ - public function getBigProfilePhotoUrl(): ?string + public function getBigProfilePhotoUrl(bool $override_permission = false): ?string { $default_pic = Config::get("app.default_profile_image", null); try { @@ -2344,7 +2345,7 @@ public function getBigProfilePhotoUrl(): ?string if ($this->hasBigPhoto() && $photo = $this->getBigPhoto()) { $photoUrl = $photo->getUrl(); } - if (empty($photoUrl) && $this->isPublicProfileShowPhoto() && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { + if (empty($photoUrl) && ($this->isPublicProfileShowPhoto() || $override_permission) && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { $photoUrl = $photo->getUrl(); } @@ -2359,9 +2360,10 @@ public function getBigProfilePhotoUrl(): ?string } /** + * @param bool $override_permission * @return string|null */ - public function getProfilePhotoUrl(): ?string + public function getProfilePhotoUrl(bool $override_permission = false): ?string { $default_pic = Config::get("app.default_profile_image", null); try { @@ -2369,7 +2371,7 @@ public function getProfilePhotoUrl(): ?string if ($this->hasPhoto() && $photo = $this->getPhoto()) { $photoUrl = $photo->getUrl(); } - if (empty($photoUrl) && $this->isPublicProfileShowPhoto() && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { + if (empty($photoUrl) && ($this->isPublicProfileShowPhoto() || $override_permission) && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { $photoUrl = $photo->getUrl(); } } catch (\Exception $ex) { diff --git a/tests/Unit/Entities/PresentationSpeakerTest.php b/tests/Unit/Entities/PresentationSpeakerTest.php index f1706edbe..605e93920 100644 --- a/tests/Unit/Entities/PresentationSpeakerTest.php +++ b/tests/Unit/Entities/PresentationSpeakerTest.php @@ -242,4 +242,29 @@ public function testPhotoFallbackUsesMemberWhenAccountPhotoToggleIsOn() $this->assertSame('https://example.com/member-photo.jpg', $speaker->getProfilePhotoUrl()); $this->assertSame('https://example.com/member-photo.jpg', $speaker->getBigProfilePhotoUrl()); } + + /** + * Policy Rule 9 scope: admin/self-view callers (AdminPresentationSpeakerSerializer and its + * CSV sibling) are out of scope of the account visibility toggle, so the photo fallback must + * still surface the Member's photo via the override_permission parameter even when that + * Member's own toggle is off. + */ + public function testPhotoFallbackUsesMemberWhenOverridePermissionIsTrueEvenWithToggleOff() + { + $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); + $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(true)); + $this->assertSame('https://example.com/member-photo.jpg', $speaker->getBigProfilePhotoUrl(true)); + } } \ No newline at end of file From 01ac6a27c6c8d8e25167d6627cb781c63441dbd8 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 00:01:25 -0300 Subject: [PATCH 06/15] test(speakers): cover the base-serializer name-fallback regression at the serializer boundary Adds the test PR #597 review flagged as missing: PresentationSpeakerSerializer must surface first_name/last_name exactly as the (Rule 9-gated) getters return them, not re-derive its own value. Verified red against the removed PresentationSpeakerBaseSerializer block (a speaker with first_name set and last_name empty got both overwritten with the Member's names) and green against the current code. --- tests/PresentationSpeakerSerializerTest.php | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/PresentationSpeakerSerializerTest.php b/tests/PresentationSpeakerSerializerTest.php index 587250b63..3425aa373 100644 --- a/tests/PresentationSpeakerSerializerTest.php +++ b/tests/PresentationSpeakerSerializerTest.php @@ -123,4 +123,38 @@ public function testPhotoUrlsPassThroughSerializerRegardlessOfAccountPhotoToggle $this->assertSame('https://example.com/pic.jpg', $values['pic']); $this->assertSame('https://example.com/big_pic.jpg', $values['big_pic']); } + + /** + * Policy Rule 9 regression (PR #597): PresentationSpeakerBaseSerializer::serialize() used to + * redo its own Member-name fallback whenever EITHER first_name or last_name came back empty, + * overwriting BOTH keys with the Member's names - clobbering an already-populated field. The + * flagged scenario: speaker with first_name set, last_name empty, account toggle off. The + * getters already gate that fallback (Rule 9, asserted directly in PresentationSpeakerTest); + * this asserts the serializer surfaces exactly what they return instead of re-deriving its + * own unmasked value from the Member. + */ + public function testFirstAndLastNameComeFromTheGatedGettersNotARedoneMemberFallback() + { + $member = Mockery::mock(Member::class); + $member->shouldReceive('getFirstName')->andReturn('MemberFirst'); + $member->shouldReceive('getLastName')->andReturn('MemberLast'); + + $speaker = Mockery::mock(PresentationSpeaker::class)->makePartial(); + $speaker->shouldReceive('hasMember')->andReturn(true); + $speaker->shouldReceive('getMember')->andReturn($member); + // The speaker's own first_name is populated; last_name is empty with the account toggle + // off, so the model-level gate has already decided getLastName() returns '' rather than + // falling back to the Member's surname. + $speaker->shouldReceive('getFirstName')->with()->andReturn('John'); + $speaker->shouldReceive('getLastName')->with()->andReturn(''); + $speaker->shouldReceive('isPublicProfileShowEmail')->andReturn(true); + + $resource_server_context = Mockery::mock(IResourceServerContext::class); + $serializer = new PresentationSpeakerSerializer($speaker, $resource_server_context); + + $values = $serializer->serialize(null, ['first_name', 'last_name'], ['none']); + + $this->assertSame('John', $values['first_name']); + $this->assertSame('', $values['last_name']); + } } From 417c68b7a6247541d0d8b4ca6c950a3cddd0b824 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 00:56:12 -0300 Subject: [PATCH 07/15] fix(speakers): scope the Rule 9 toggle bypass to admin and self, not every Private caller AdminPresentationSpeakerSerializer/CSV unconditionally passed override_permission=true to getFirstName()/getLastName()/getProfilePhotoUrl()/ getBigProfilePhotoUrl(), but that serializer is selected for three distinct callers per OAuth2SummitSpeakersApiController: an Admin/SummitAdmin, the speaker viewing/editing their own record (getMySpeaker/getMySummitSpeaker/ createMySpeaker/updateMySpeaker), and a submitter who only holds an approved edit-permission request on someone else's speaker profile (PresentationSpeaker::canBeEditedBy(), used by getSpeaker($id)). Per policy/profile-data-handling.md Sec 2 Scope, only the first two may bypass the account visibility toggle - the third is neither an admin nor the account owner and must see exactly what a Public caller sees. The unconditional true let that submitter see a co-speaker's real linked-account name/photo even when the account's own visibility toggle is off, which is also a Rule 5/8 violation (the same entity returning a real value to one non-admin caller and a masked one to another, for the same field, at the same moment). Added PresentationSpeakerBaseSerializer::canBypassAccountVisibilityToggle(), shared by both admin speaker serializers, which resolves the caller's actual identity (isAdmin()/isSummitAdmin(), or the speaker's own linked member id) instead of assuming every caller reaching this serializer class is exempt. Self-view resolved as exempt: the alternative (masking a speaker's own name from themselves while editing) has no basis in the policy's stated rules and is a hostile UX with no compensating benefit. Added AdminPresentationSpeakerSerializerTest covering all three callers (admin, self, edit-permission-only) - verified red against the removed unconditional true (the edit-permission-grantee case failed; admin/self happened to still pass since both expect the bypass). --- .../AdminPresentationSpeakerCSVSerializer.php | 9 +- .../AdminPresentationSpeakerSerializer.php | 10 +- .../PresentationSpeakerBaseSerializer.php | 18 +++ ...AdminPresentationSpeakerSerializerTest.php | 116 ++++++++++++++++++ 4 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 tests/AdminPresentationSpeakerSerializerTest.php diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php index bc95de170..ff83eec80 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php @@ -48,21 +48,22 @@ public function serialize($expand = null, array $fields = [], array $relations = if(!$speaker instanceof PresentationSpeaker) return []; $values = parent::serialize($expand, $fields, $relations, $params); + $bypass_toggle = $this->canBypassAccountVisibilityToggle($speaker); if(in_array('first_name', $fields)) { - $values['first_name'] = $speaker->getFirstName(true); + $values['first_name'] = $speaker->getFirstName($bypass_toggle); } if(in_array('last_name', $fields)) { - $values['last_name'] = $speaker->getLastName(true); + $values['last_name'] = $speaker->getLastName($bypass_toggle); } if(in_array('pic', $fields)) { - $values['pic'] = $speaker->getProfilePhotoUrl(true); + $values['pic'] = $speaker->getProfilePhotoUrl($bypass_toggle); } if(in_array('big_pic', $fields)) { - $values['big_pic'] = $speaker->getBigProfilePhotoUrl(true); + $values['big_pic'] = $speaker->getBigProfilePhotoUrl($bypass_toggle); } if(in_array("email", $fields)) diff --git a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php index 53179b93f..415082f9d 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerSerializer.php @@ -63,22 +63,22 @@ public function serialize($expand = null, array $fields = [], array $relations = $values = parent::serialize($expand, $fields, $relations, $params); $summit = isset($params['summit'])? $params['summit']:null; - + $bypass_toggle = $this->canBypassAccountVisibilityToggle($speaker); if(in_array('first_name', $fields)) { - $values['first_name'] = $speaker->getFirstName(true); + $values['first_name'] = $speaker->getFirstName($bypass_toggle); } if(in_array('last_name', $fields)) { - $values['last_name'] = $speaker->getLastName(true); + $values['last_name'] = $speaker->getLastName($bypass_toggle); } if(in_array('pic', $fields)) { - $values['pic'] = $speaker->getProfilePhotoUrl(true); + $values['pic'] = $speaker->getProfilePhotoUrl($bypass_toggle); } if(in_array('big_pic', $fields)) { - $values['big_pic'] = $speaker->getBigProfilePhotoUrl(true); + $values['big_pic'] = $speaker->getBigProfilePhotoUrl($bypass_toggle); } if(in_array("email", $fields)) { diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php index 11341c4d6..56b8a3e7e 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php @@ -94,4 +94,22 @@ public function serialize($expand = null, array $fields = [], array $relations = return $values; } + + /** + * Policy Rule 9 (policy/profile-data-handling.md Sec 2 Scope): only an Admin/SummitAdmin, or + * the speaker viewing/editing their own record, may bypass the account visibility toggle on + * the Member name/photo fallback. A submitter who only holds an approved edit-permission + * request on someone else's speaker profile (PresentationSpeaker::canBeEditedBy(), which also + * resolves to this serializer's SerializerType_Private/Admin) is neither, and must see + * exactly what a Public caller sees. + * @param PresentationSpeaker $speaker + * @return bool + */ + protected function canBypassAccountVisibilityToggle(PresentationSpeaker $speaker): bool + { + $current_member = $this->resource_server_context->getCurrentUser(); + if (is_null($current_member)) return false; + if ($current_member->isAdmin() || $current_member->isSummitAdmin()) return true; + return $speaker->hasMember() && $speaker->getMemberId() == $current_member->getId(); + } } diff --git a/tests/AdminPresentationSpeakerSerializerTest.php b/tests/AdminPresentationSpeakerSerializerTest.php new file mode 100644 index 000000000..195f3f176 --- /dev/null +++ b/tests/AdminPresentationSpeakerSerializerTest.php @@ -0,0 +1,116 @@ +shouldReceive('hasMember')->andReturn(true); + $speaker->shouldReceive('getMember')->andReturn(Mockery::mock(Member::class)); + $speaker->shouldReceive('getMemberId')->andReturn(self::OwnerMemberId); + // The generic array_mappings reflection in AbstractSerializer::serialize() calls + // getFirstName() with no arguments first (its return value is discarded - overwritten + // below by the admin-serializer's explicit call); the value under test is the second, + // explicit call with the computed override. + $speaker->shouldReceive('getFirstName')->with()->andReturn(''); + $speaker->shouldReceive('getFirstName')->once()->with($expectOverride)->andReturn('Jane'); + return $speaker; + } + + private function buildContext(Member $current_member): IResourceServerContext + { + $context = Mockery::mock(IResourceServerContext::class); + $context->shouldReceive('getCurrentUser')->andReturn($current_member); + return $context; + } + + public function testAdminCallerBypassesAccountVisibilityToggle() + { + $admin = Mockery::mock(Member::class); + $admin->shouldReceive('isAdmin')->andReturn(true); + $admin->shouldReceive('isSummitAdmin')->andReturn(false); + $admin->shouldReceive('getId')->andReturn(self::OtherMemberId); + + $speaker = $this->buildSpeaker(true); + $serializer = new AdminPresentationSpeakerSerializer($speaker, $this->buildContext($admin)); + + $values = $serializer->serialize(null, ['first_name'], ['none']); + + $this->assertSame('Jane', $values['first_name']); + } + + public function testSpeakerViewingOwnRecordBypassesAccountVisibilityToggle() + { + $owner = Mockery::mock(Member::class); + $owner->shouldReceive('isAdmin')->andReturn(false); + $owner->shouldReceive('isSummitAdmin')->andReturn(false); + $owner->shouldReceive('getId')->andReturn(self::OwnerMemberId); + + $speaker = $this->buildSpeaker(true); + $serializer = new AdminPresentationSpeakerSerializer($speaker, $this->buildContext($owner)); + + $values = $serializer->serialize(null, ['first_name'], ['none']); + + $this->assertSame('Jane', $values['first_name']); + } + + /** + * The regression this pins: PresentationSpeaker::canBeEditedBy() grants this same serializer + * to a submitter with an approved edit-permission request who is neither the account owner + * nor an admin. Before the fix, AdminPresentationSpeakerSerializer bypassed the toggle + * unconditionally for every caller reaching it - this submitter must NOT get that bypass. + */ + public function testEditPermissionGranteeWhoIsNotOwnerDoesNotBypassAccountVisibilityToggle() + { + $submitter = Mockery::mock(Member::class); + $submitter->shouldReceive('isAdmin')->andReturn(false); + $submitter->shouldReceive('isSummitAdmin')->andReturn(false); + $submitter->shouldReceive('getId')->andReturn(self::OtherMemberId); + + $speaker = $this->buildSpeaker(false); + $serializer = new AdminPresentationSpeakerSerializer($speaker, $this->buildContext($submitter)); + + $values = $serializer->serialize(null, ['first_name'], ['none']); + + $this->assertSame('Jane', $values['first_name']); + } +} From 0caf27c5ecbcb0b6d9b89dac9227bef49ccc2f1c Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 01:04:53 -0300 Subject: [PATCH 08/15] style(speakers): normalize whitespace in PresentationSpeakerBaseSerializer --- .../Summit/Speakers/PresentationSpeakerBaseSerializer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php index 56b8a3e7e..ac35ae0fa 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php @@ -105,7 +105,7 @@ public function serialize($expand = null, array $fields = [], array $relations = * @param PresentationSpeaker $speaker * @return bool */ - protected function canBypassAccountVisibilityToggle(PresentationSpeaker $speaker): bool + protected function canBypassAccountVisibilityToggle(PresentationSpeaker $speaker): bool { $current_member = $this->resource_server_context->getCurrentUser(); if (is_null($current_member)) return false; From c54152cd3fe989e22b949cdd6e3c4fb21e64eb19 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 16:06:56 -0300 Subject: [PATCH 09/15] fix(speakers): pass override_permission at every internal caller of the gated name getters Moving the Rule 9 gate into PresentationSpeaker::getFirstName()/getLastName()/ getFullName() with override_permission=false as the default changed the return value for every existing caller, not only the public serializers. Only the serializer and CSV call sites had been migrated; the rest of the codebase silently started getting a blank name for any speaker whose own name is empty and whose linked Member has public_profile_show_fullname off. Per policy/profile-data-handling.md Sec 2 Scope (admin-only tooling is out of scope of the account visibility toggle) and the self-view exemption already applied by canBypassAccountVisibilityToggle(), the following now pass true: - SpeakerService::merge(): the admin explicitly picks which record's name to keep and the value is persisted via setFirstName()/setLastName(); with the gate on, a merge silently persisted an empty name instead of the Member fallback the admin chose. - AdminSummitSchedulePreFilterElementConfigSerializer (admin-only schedule filter chips). - The seven audit-log formatters that read a PresentationSpeaker name (SpeakerAssistance, FeaturedSpeaker, SubmissionInvitation, SpeakerRegistrationRequest, PresentationSpeaker, PresentationSpeakerSummitAssistanceConfirmation, EntityUpdate) - the audit trail is an internal compliance record, unrelated to public visibility. - Self-addressed emails, where the recipient is the speaker whose name is rendered: PresentationSpeakerSelectionProcessEmail, PresentationSubmitterSelectionProcessEmail (speaker-level fallback), SpeakerEditPermissionRequestedEmail, PromoCodeEmail via SpeakerSummitRegistrationPromoCode/DiscountCode::getOwnerFullname(). - SpeakerSummitRegistrationPromoCodeSerializer / SpeakerSummitRegistrationDiscountCodeSerializer owner_name (admin promo code endpoints), matching their CSV siblings migrated earlier in this PR. - AbstractPublishService speaker-collision error message (admin publish). PresentationSpeakerNotificationEmail, PresentationModeratorNotificationEmail, ImportEventSpeakerEmail and PresentationActivitySpeakerChangeEmail were calling getFullName(" ") - a leftover argument the old no-parameter signature ignored, which PHP now coerces to bool(true) against the new parameter. They are self-addressed, so true is the intended outcome; made it explicit instead of relying on string-to-bool coercion. Deliberately left gated, with a comment stating why: co-speaker/moderator names in SpeakerPresentationEmailSerializer (rendered to the submitter and the other speakers) and the speaker name in SpeakerEditPermissionApproved/RejectedEmail (rendered to the requester). Those recipients are non-admin third parties, so Rule 9 applies, and Rule 5 requires the same speaker to resolve the same way the CFP portal's public serializer does. Checked and unaffected: SummitService::processEventData() only reaches getFirstName()/getLastName() when the speaker has no Member, so the fallback never fires there. --- .../ConcreteFormatters/EntityUpdateAuditLogFormatter.php | 2 +- .../FeaturedSpeakerAuditLogFormatter.php | 2 +- .../PresentationSpeakerAuditLogFormatter.php | 2 +- ...peakerSummitAssistanceConfirmationAuditLogFormatter.php | 2 +- .../SpeakerAssistanceAuditLogFormatter.php | 2 +- .../SpeakerRegistrationRequestAuditLogFormatter.php | 2 +- .../SubmissionInvitationAuditLogFormatter.php | 2 +- .../PresentationSubmissions/ImportEventSpeakerEmail.php | 2 +- .../PresentationModeratorNotificationEmail.php | 2 +- .../PresentationSpeakerNotificationEmail.php | 2 +- .../PresentationSpeakerSelectionProcessEmail.php | 4 +++- .../PresentationSubmitterSelectionProcessEmail.php | 2 +- .../SpeakerEditPermissionApprovedEmail.php | 3 +++ .../SpeakerEditPermissionRejectedEmail.php | 3 +++ .../SpeakerEditPermissionRequestedEmail.php | 4 +++- .../Schedule/PresentationActivitySpeakerChangeEmail.php | 2 +- ...AdminSummitSchedulePreFilterElementConfigSerializer.php | 4 ++-- .../Presentation/SpeakerPresentationEmailSerializer.php | 5 +++++ .../SpeakerSummitRegistrationDiscountCodeSerializer.php | 2 +- .../SpeakerSummitRegistrationPromoCodeSerializer.php | 2 +- .../PromoCodes/SpeakerSummitRegistrationDiscountCode.php | 2 +- .../PromoCodes/SpeakerSummitRegistrationPromoCode.php | 2 +- app/Services/Model/Imp/AbstractPublishService.php | 2 +- app/Services/Model/Imp/SpeakerService.php | 7 ++++--- 24 files changed, 40 insertions(+), 24 deletions(-) diff --git a/app/Audit/ConcreteFormatters/EntityUpdateAuditLogFormatter.php b/app/Audit/ConcreteFormatters/EntityUpdateAuditLogFormatter.php index e9d0ffa88..16319e193 100644 --- a/app/Audit/ConcreteFormatters/EntityUpdateAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/EntityUpdateAuditLogFormatter.php @@ -132,7 +132,7 @@ public function format($subject, $change_set): ?string }); } else if ($old_value instanceof PresentationSpeaker || $new_value instanceof PresentationSpeaker) { $res[] = self::formatEntity($class_name, $prop_name, $old_value, $new_value, function ($value) { - return " \"{$value->getFullName()} ({$value->getEmail()})\" "; + return " \"{$value->getFullName(true)} ({$value->getEmail()})\" "; }); } else if ($old_value instanceof File || $new_value instanceof File) { $res[] = self::formatEntity($class_name, $prop_name, $old_value, $new_value, function ($value) { diff --git a/app/Audit/ConcreteFormatters/FeaturedSpeakerAuditLogFormatter.php b/app/Audit/ConcreteFormatters/FeaturedSpeakerAuditLogFormatter.php index e4c2fabbe..bcd9a7524 100644 --- a/app/Audit/ConcreteFormatters/FeaturedSpeakerAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/FeaturedSpeakerAuditLogFormatter.php @@ -31,7 +31,7 @@ public function format($subject, array $change_set): ?string try { $speaker = $subject->getSpeaker(); $speaker_email = $speaker ? ($speaker->getEmail() ?? 'unknown') : 'unknown'; - $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName() ?? '', $speaker->getLastName() ?? '') : 'Unknown'; + $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName(true) ?? '', $speaker->getLastName(true) ?? '') : 'Unknown'; $speaker_name = trim($speaker_name) ?: $speaker_name; $speaker_id = $speaker ? ($speaker->getId() ?? 'unknown') : 'unknown'; diff --git a/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerAuditLogFormatter.php b/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerAuditLogFormatter.php index f772d130e..d671a6f4c 100644 --- a/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerAuditLogFormatter.php @@ -29,7 +29,7 @@ public function format($subject, array $change_set): ?string } try { - $full_name = sprintf("%s %s", $subject->getFirstName() ?? 'Unknown', $subject->getLastName() ?? 'Unknown'); + $full_name = sprintf("%s %s", $subject->getFirstName(true) ?? 'Unknown', $subject->getLastName(true) ?? 'Unknown'); $email = $subject->getEmail() ?? 'unknown'; $speaker_id = $subject->getId() ?? 'unknown'; diff --git a/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerSummitAssistanceConfirmationAuditLogFormatter.php b/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerSummitAssistanceConfirmationAuditLogFormatter.php index 48a58fdba..a15a9f90b 100644 --- a/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerSummitAssistanceConfirmationAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/PresentationFormatters/PresentationSpeakerSummitAssistanceConfirmationAuditLogFormatter.php @@ -32,7 +32,7 @@ public function format($subject, array $change_set): ?string $id = $subject->getId() ?? 'unknown'; $speaker = $subject->getSpeaker(); - $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName() ?? '', $speaker->getLastName() ?? '') : 'Unknown Speaker'; + $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName(true) ?? '', $speaker->getLastName(true) ?? '') : 'Unknown Speaker'; $speaker_email = $speaker ? ($speaker->getEmail() ?? 'unknown') : 'unknown'; $speaker_name = trim($speaker_name) ?: $speaker_email; diff --git a/app/Audit/ConcreteFormatters/SpeakerAssistanceAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SpeakerAssistanceAuditLogFormatter.php index c46827e55..ebf0a9bb4 100644 --- a/app/Audit/ConcreteFormatters/SpeakerAssistanceAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SpeakerAssistanceAuditLogFormatter.php @@ -30,7 +30,7 @@ public function format($subject, array $change_set): ?string try { $speaker = $subject->getSpeaker(); - $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName() ?? '', $speaker->getLastName() ?? '') : 'Unknown'; + $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName(true) ?? '', $speaker->getLastName(true) ?? '') : 'Unknown'; $speaker_email = $speaker ? ($speaker->getEmail() ?? 'unknown') : 'unknown'; $speaker_name = trim($speaker_name) ?: $speaker_email; $speaker_id = $speaker ? ($speaker->getId() ?? 'unknown') : 'unknown'; diff --git a/app/Audit/ConcreteFormatters/SpeakerRegistrationRequestAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SpeakerRegistrationRequestAuditLogFormatter.php index 4a3f263cb..86b076fc9 100644 --- a/app/Audit/ConcreteFormatters/SpeakerRegistrationRequestAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SpeakerRegistrationRequestAuditLogFormatter.php @@ -31,7 +31,7 @@ public function format($subject, array $change_set): ?string try { $email = $subject->getEmail() ?? 'unknown'; $speaker = $subject->getSpeaker(); - $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName() ?? '', $speaker->getLastName() ?? '') : 'Unknown'; + $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName(true) ?? '', $speaker->getLastName(true) ?? '') : 'Unknown'; $speaker_name = trim($speaker_name) ?: 'Unknown'; $is_confirmed = $subject->isConfirmed(); diff --git a/app/Audit/ConcreteFormatters/SubmissionInvitationAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SubmissionInvitationAuditLogFormatter.php index ef8d4e316..b0aa98300 100644 --- a/app/Audit/ConcreteFormatters/SubmissionInvitationAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SubmissionInvitationAuditLogFormatter.php @@ -35,7 +35,7 @@ public function format($subject, array $change_set): ?string $full_name = trim(sprintf("%s %s", $first_name, $last_name)) ?: 'Unknown'; $is_sent = $subject->isSent(); $speaker = $subject->getSpeaker(); - $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName() ?? '', $speaker->getLastName() ?? '') : 'None'; + $speaker_name = $speaker ? sprintf("%s %s", $speaker->getFirstName(true) ?? '', $speaker->getLastName(true) ?? '') : 'None'; $speaker_name = trim($speaker_name) ?: 'None'; $id = $subject->getId() ?? 'unknown'; diff --git a/app/Jobs/Emails/PresentationSubmissions/ImportEventSpeakerEmail.php b/app/Jobs/Emails/PresentationSubmissions/ImportEventSpeakerEmail.php index 382751420..029142c31 100644 --- a/app/Jobs/Emails/PresentationSubmissions/ImportEventSpeakerEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/ImportEventSpeakerEmail.php @@ -80,7 +80,7 @@ public function __construct(Presentation $presentation, PresentationSpeaker $spe } $payload[IMailTemplatesConstants::reset_password_link] = sprintf("%s/auth/password/reset", $idp_base_url); $payload[IMailTemplatesConstants::support_email] = $support_email; - $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(' '); + $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(true); if(empty($payload[IMailTemplatesConstants::speaker_full_name])){ $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getEmail(); } diff --git a/app/Jobs/Emails/PresentationSubmissions/PresentationModeratorNotificationEmail.php b/app/Jobs/Emails/PresentationSubmissions/PresentationModeratorNotificationEmail.php index fde54046f..6ee2c652a 100644 --- a/app/Jobs/Emails/PresentationSubmissions/PresentationModeratorNotificationEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/PresentationModeratorNotificationEmail.php @@ -64,7 +64,7 @@ public function __construct(PresentationSpeaker $moderator, Presentation $presen $payload = []; - $payload[IMailTemplatesConstants::speaker_full_name] = $moderator->getFullName(" "); + $payload[IMailTemplatesConstants::speaker_full_name] = $moderator->getFullName(true); $payload[IMailTemplatesConstants::speaker_email] = $moderator->getEmail(); $payload[IMailTemplatesConstants::creator_full_name] = $creator->getFullName(); $payload[IMailTemplatesConstants::creator_email] = $creator->getEmail(); diff --git a/app/Jobs/Emails/PresentationSubmissions/PresentationSpeakerNotificationEmail.php b/app/Jobs/Emails/PresentationSubmissions/PresentationSpeakerNotificationEmail.php index 196b2e8b9..1602da4d2 100644 --- a/app/Jobs/Emails/PresentationSubmissions/PresentationSpeakerNotificationEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/PresentationSpeakerNotificationEmail.php @@ -66,7 +66,7 @@ public function __construct(PresentationSpeaker $speaker, Presentation $presenta $payload = []; - $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(" "); + $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(true); $payload[IMailTemplatesConstants::speaker_email] = $speaker->getEmail(); $payload[IMailTemplatesConstants::creator_full_name] = $creator->getFullName(); $payload[IMailTemplatesConstants::creator_email] = $creator->getEmail(); diff --git a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php index 76ff7f795..9f769d643 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php @@ -130,7 +130,9 @@ public function __construct } $speaker_email = $speaker->getEmail(); - $speaker_full_name = $speaker->getFullName(); + // self-addressed: the recipient is the speaker, so the Member name fallback bypasses the + // account visibility toggle (policy/profile-data-handling.md Rule 9 scope) + $speaker_full_name = $speaker->getFullName(true); // set to email if fullname is empty if(empty($speaker_full_name)){ diff --git a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSubmitterSelectionProcessEmail.php b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSubmitterSelectionProcessEmail.php index 7d208af21..8616cb0f4 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSubmitterSelectionProcessEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSubmitterSelectionProcessEmail.php @@ -75,7 +75,7 @@ public function __construct if(empty($submitter_full_name)){ // check if we have a first and last name at speaker level if($submitter->hasSpeaker()){ - $submitter_full_name = $submitter->getSpeaker()->getFullName(); + $submitter_full_name = $submitter->getSpeaker()->getFullName(true); } if(empty($submitter_full_name)) { // fallback to email diff --git a/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionApprovedEmail.php b/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionApprovedEmail.php index 94084a300..4b35d0125 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionApprovedEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionApprovedEmail.php @@ -41,6 +41,9 @@ public function __construct(SpeakerEditPermissionRequest $request) { $payload = []; $payload[IMailTemplatesConstants::requested_by_full_name] = $request->getRequestedBy()->getFullName(); + // the recipient is the requester (a non-admin third party), so the speaker's Member name + // fallback must honor the account visibility toggle (policy/profile-data-handling.md + // Rule 9). Deliberately NOT passing override_permission. $payload[IMailTemplatesConstants::speaker_full_name] = $request->getSpeaker()->getFullName(); $payload[IMailTemplatesConstants::speaker_management_link] = EmailUtils::getSpeakerManagementLink(); $payload[IMailTemplatesConstants::tenant_name] = Config::get("app.tenant_name"); diff --git a/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRejectedEmail.php b/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRejectedEmail.php index d3fdffdc2..47d1d30f1 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRejectedEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRejectedEmail.php @@ -41,6 +41,9 @@ public function __construct(SpeakerEditPermissionRequest $request) { $payload = []; $payload[IMailTemplatesConstants::requested_by_full_name] = $request->getRequestedBy()->getFullName(); + // the recipient is the requester (a non-admin third party), so the speaker's Member name + // fallback must honor the account visibility toggle (policy/profile-data-handling.md + // Rule 9). Deliberately NOT passing override_permission. $payload[IMailTemplatesConstants::speaker_full_name] = $request->getSpeaker()->getFullName(); $payload[IMailTemplatesConstants::speaker_management_link] = EmailUtils::getSpeakerManagementLink(); $payload[IMailTemplatesConstants::tenant_name] = Config::get("app.tenant_name"); diff --git a/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRequestedEmail.php b/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRequestedEmail.php index 65a5f91ef..f9063400a 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRequestedEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SpeakerEditPermissionRequestedEmail.php @@ -40,7 +40,9 @@ public function __construct(SpeakerEditPermissionRequest $request, string $token { $payload = []; $payload[IMailTemplatesConstants::requested_by_full_name] = $request->getRequestedBy()->getFullName(); - $payload[IMailTemplatesConstants::speaker_full_name] = $request->getSpeaker()->getFullName(); + // self-addressed: the recipient is the speaker, so the Member name fallback bypasses the + // account visibility toggle (policy/profile-data-handling.md Rule 9 scope) + $payload[IMailTemplatesConstants::speaker_full_name] = $request->getSpeaker()->getFullName(true); $payload[IMailTemplatesConstants::token] = $token; $payload[IMailTemplatesConstants::link] = $request->getConfirmationLink($request->getSpeaker()->getId(), $token); $payload[IMailTemplatesConstants::tenant_name] = Config::get("app.tenant_name"); diff --git a/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php b/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php index 943dcc487..f3020b24e 100644 --- a/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php +++ b/app/Jobs/Emails/Schedule/PresentationActivitySpeakerChangeEmail.php @@ -63,7 +63,7 @@ public function __construct(Presentation $presentation, PresentationSpeaker $spe $summit = $presentation->getSummit(); $payload = []; - $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(" "); + $payload[IMailTemplatesConstants::speaker_full_name] = $speaker->getFullName(true); $payload[IMailTemplatesConstants::speaker_email] = $speaker->getEmail(); $payload[IMailTemplatesConstants::presentation_title] = $presentation->getTitle(); $payload[IMailTemplatesConstants::presentation_id] = $presentation->getId(); diff --git a/app/ModelSerializers/Summit/AdminSummitSchedulePreFilterElementConfigSerializer.php b/app/ModelSerializers/Summit/AdminSummitSchedulePreFilterElementConfigSerializer.php index c4ca71f2b..8442ec45d 100644 --- a/app/ModelSerializers/Summit/AdminSummitSchedulePreFilterElementConfigSerializer.php +++ b/app/ModelSerializers/Summit/AdminSummitSchedulePreFilterElementConfigSerializer.php @@ -77,8 +77,8 @@ public function serialize($expand = null, array $fields = [], array $relations = if(is_null($speaker)) continue; $res[] = [ 'id' => $speaker->getId(), - 'first_name' => $speaker->getFirstName(), - 'last_name' => $speaker->getLastName(), + 'first_name' => $speaker->getFirstName(true), + 'last_name' => $speaker->getLastName(true), 'email' => $speaker->getEmail(), ]; } diff --git a/app/ModelSerializers/Summit/Presentation/SpeakerPresentationEmailSerializer.php b/app/ModelSerializers/Summit/Presentation/SpeakerPresentationEmailSerializer.php index 10b98f61a..dbcc49e51 100644 --- a/app/ModelSerializers/Summit/Presentation/SpeakerPresentationEmailSerializer.php +++ b/app/ModelSerializers/Summit/Presentation/SpeakerPresentationEmailSerializer.php @@ -56,6 +56,11 @@ public function serialize($expand = null, array $fields = [], array $relations = ]; } + // Co-speaker / moderator names below are rendered to non-admin recipients (the submitter + // and the other speakers of this presentation), so the Member name fallback must honor the + // account visibility toggle here (policy/profile-data-handling.md Rule 9), exactly as the + // CFP portal's public serializer does for the same speaker (Rule 5). Deliberately NOT + // passing override_permission to getFullName(). $speakers = []; foreach ($presentation->getSpeakers() as $speaker){ $speakers[] = [ diff --git a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php index 778cd46fd..35001d93f 100644 --- a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php +++ b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php @@ -64,7 +64,7 @@ public function serialize($expand = null, array $fields = [], array $relations = break; case 'owner_name': { if($code->hasSpeaker()){ - $values['owner_name'] = $code->getSpeaker()->getFullName(); + $values['owner_name'] = $code->getSpeaker()->getFullName(true); } } break; diff --git a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php index 7446c2f2a..d0714215f 100644 --- a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php +++ b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php @@ -64,7 +64,7 @@ public function serialize($expand = null, array $fields = [], array $relations = break; case 'owner_name': { if($code->hasSpeaker()){ - $values['owner_name'] = $code->getSpeaker()->getFullName(); + $values['owner_name'] = $code->getSpeaker()->getFullName(true); } } break; diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.php b/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.php index e78a014a9..771257ec8 100644 --- a/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.php +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.php @@ -59,7 +59,7 @@ public function hasOwner(): bool public function getOwnerFullname(): string { if(!$this->hasOwner()) return ''; - return $this->getSpeaker()->getFullName(); + return $this->getSpeaker()->getFullName(true); } public function getOwnerEmail(): string diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.php b/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.php index b6faf6069..e461232a9 100644 --- a/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.php +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.php @@ -56,7 +56,7 @@ public function hasOwner(): bool public function getOwnerFullname(): string { if(!$this->hasOwner()) return ''; - return $this->getSpeaker()->getFullName(); + return $this->getSpeaker()->getFullName(true); } public function getOwnerEmail(): string diff --git a/app/Services/Model/Imp/AbstractPublishService.php b/app/Services/Model/Imp/AbstractPublishService.php index ed981c8d3..5e0b8854f 100644 --- a/app/Services/Model/Imp/AbstractPublishService.php +++ b/app/Services/Model/Imp/AbstractPublishService.php @@ -361,7 +361,7 @@ protected function validateBlackOutTimesAndTimes(IPublishableEvent $publishable_ $publishable_event->getSummitEventId(), $publishable_event->getStartDateNice(), $publishable_event->getEndDateNice(), - $current_speaker->getFullName(), + $current_speaker->getFullName(true), $current_speaker->getId(), $c_event->getLocationName(), $c_event->getSummitEventId(), diff --git a/app/Services/Model/Imp/SpeakerService.php b/app/Services/Model/Imp/SpeakerService.php index e47018633..6ba96f7ac 100644 --- a/app/Services/Model/Imp/SpeakerService.php +++ b/app/Services/Model/Imp/SpeakerService.php @@ -639,15 +639,16 @@ public function merge(PresentationSpeaker $speaker_from, PresentationSpeaker $sp $speaker_id = intval($data['bio']); $speaker_to->setBio($speaker_id == $speaker_from->getId() ? $speaker_from->getBio() : $speaker_to->getBio()); - // first_name + // first_name / last_name: admin tooling, out of scope of the account visibility toggle + // (policy/profile-data-handling.md Sec 2) - persist the Member fallback the admin picked if (!isset($data['first_name'])) throw new ValidationException("first_name field is required"); $speaker_id = intval($data['first_name']); - $speaker_to->setFirstName($speaker_id == $speaker_from->getId() ? $speaker_from->getFirstName() : $speaker_to->getFirstName()); + $speaker_to->setFirstName($speaker_id == $speaker_from->getId() ? $speaker_from->getFirstName(true) : $speaker_to->getFirstName(true)); // last_name if (!isset($data['last_name'])) throw new ValidationException("last_name field is required"); $speaker_id = intval($data['last_name']); - $speaker_to->setLastName($speaker_id == $speaker_from->getId() ? $speaker_from->getLastName() : $speaker_to->getLastName()); + $speaker_to->setLastName($speaker_id == $speaker_from->getId() ? $speaker_from->getLastName(true) : $speaker_to->getLastName(true)); // title if (!isset($data['title'])) throw new ValidationException("title field is required"); From 91190cb0ad02489362498d4836254f077af303df Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 16:07:03 -0300 Subject: [PATCH 10/15] test(speakers): pin the Member name fallback for admin merge and audit-log formatters SpeakerServiceMergeTest: a speaker with no name of its own and a linked Member whose full-name toggle is off, merged into a record with no Member so the assertion reads the persisted column and not a fallback. Verified red against the un-overridden getFirstName()/getLastName() in merge() (persisted '' instead of the Member's name) and green with override. SpeakerNameMemberFallbackAuditLogFormatterTest: the same speaker pushed through the five formatters that print the name (SpeakerAssistance, FeaturedSpeaker, PresentationSpeaker, PresentationSpeakerSummitAssistance Confirmation, EntityUpdate). Verified red against the bare getters (blank, email fallback or "Unknown Unknown" in the audit line) and green with override. SubmissionInvitation and SpeakerRegistrationRequest formatters compute the name but never print it, so they have no observable behavior to pin. --- ...ameMemberFallbackAuditLogFormatterTest.php | 152 ++++++++++++++++++ .../Unit/Services/SpeakerServiceMergeTest.php | 134 +++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php create mode 100644 tests/Unit/Services/SpeakerServiceMergeTest.php diff --git a/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php new file mode 100644 index 000000000..5bd35f3f5 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php @@ -0,0 +1,152 @@ +shouldReceive('getId')->andReturn(42); + $member->shouldReceive('setSpeaker')->andReturnNull(); + $member->shouldReceive('isPublicProfileShowFullname')->andReturn(false); + $member->shouldReceive('getFirstName')->andReturn(self::MemberFirstName); + $member->shouldReceive('getLastName')->andReturn(self::MemberLastName); + $member->shouldReceive('getFullName')->andReturn(self::MemberFirstName . ' ' . self::MemberLastName); + $member->shouldReceive('getEmail')->andReturn(self::MemberEmail); + + $speaker = new PresentationSpeaker(); + $speaker->setMember($member); + return $speaker; + } + + private function buildSummit(): Summit + { + $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getName')->andReturn('Test Summit'); + return $summit; + } + + public function testSpeakerAssistanceFormatterRecordsMemberNameWhenToggleIsOff() + { + $subject = Mockery::mock(PresentationSpeakerSummitAssistanceConfirmationRequest::class); + $subject->shouldReceive('getSpeaker')->andReturn($this->buildSpeakerRelyingOnMemberFallback()); + $subject->shouldReceive('getSummit')->andReturn($this->buildSummit()); + $subject->shouldReceive('isConfirmed')->andReturn(true); + $subject->shouldReceive('isRegistered')->andReturn(false); + + $formatter = new SpeakerAssistanceAuditLogFormatter(IAuditStrategy::EVENT_ENTITY_CREATION); + $result = $formatter->format($subject, []); + + $this->assertNotNull($result); + $this->assertStringContainsString("for 'Ada Lovelace'", $result); + } + + public function testFeaturedSpeakerFormatterRecordsMemberNameWhenToggleIsOff() + { + $subject = Mockery::mock(FeaturedSpeaker::class); + $subject->shouldReceive('getSpeaker')->andReturn($this->buildSpeakerRelyingOnMemberFallback()); + $subject->shouldReceive('getSummit')->andReturn($this->buildSummit()); + $subject->shouldReceive('getOrder')->andReturn(1); + + $formatter = new FeaturedSpeakerAuditLogFormatter(IAuditStrategy::EVENT_ENTITY_CREATION); + $result = $formatter->format($subject, []); + + $this->assertNotNull($result); + $this->assertStringContainsString("Speaker 'Ada Lovelace'", $result); + } + + public function testPresentationSpeakerFormatterRecordsMemberNameWhenToggleIsOff() + { + $formatter = new PresentationSpeakerAuditLogFormatter(IAuditStrategy::EVENT_ENTITY_CREATION); + $result = $formatter->format($this->buildSpeakerRelyingOnMemberFallback(), []); + + $this->assertNotNull($result); + $this->assertStringContainsString("Speaker 'Ada Lovelace'", $result); + } + + public function testAssistanceConfirmationFormatterRecordsMemberNameWhenToggleIsOff() + { + $subject = Mockery::mock(PresentationSpeakerSummitAssistanceConfirmationRequest::class); + $subject->shouldReceive('getId')->andReturn(7); + $subject->shouldReceive('getSpeaker')->andReturn($this->buildSpeakerRelyingOnMemberFallback()); + $subject->shouldReceive('getSummit')->andReturn($this->buildSummit()); + + $formatter = new PresentationSpeakerSummitAssistanceConfirmationAuditLogFormatter(IAuditStrategy::EVENT_ENTITY_CREATION); + $result = $formatter->format($subject, []); + + $this->assertNotNull($result); + $this->assertStringContainsString("for 'Ada Lovelace'", $result); + } + + public function testEntityUpdateFormatterRecordsMemberNameWhenToggleIsOff() + { + $presentation = Mockery::mock(Presentation::class); + $presentation->shouldReceive('getId')->andReturn(99); + + $formatter = new EntityUpdateAuditLogFormatter(); + $result = $formatter->format( + $presentation, + ['moderator' => [null, $this->buildSpeakerRelyingOnMemberFallback()]] + ); + + $this->assertNotNull($result); + $this->assertStringContainsString('Ada Lovelace (' . self::MemberEmail . ')', $result); + } +} diff --git a/tests/Unit/Services/SpeakerServiceMergeTest.php b/tests/Unit/Services/SpeakerServiceMergeTest.php new file mode 100644 index 000000000..b6f8a7b3d --- /dev/null +++ b/tests/Unit/Services/SpeakerServiceMergeTest.php @@ -0,0 +1,134 @@ +setAccessible(true); + $prop->setValue($entity, $id); + } + + private function buildService(): SpeakerService + { + $tx_service = Mockery::mock(ITransactionService::class); + $tx_service->shouldReceive('transaction')->andReturnUsing(function (callable $callback) { + return $callback(); + }); + + $speaker_repository = Mockery::mock(ISpeakerRepository::class); + $speaker_repository->shouldReceive('delete')->once(); + + return new SpeakerService( + $speaker_repository, + Mockery::mock(IMemberRepository::class), + Mockery::mock(ISpeakerRegistrationRequestRepository::class), + Mockery::mock(ISpeakerSummitRegistrationPromoCodeRepository::class), + Mockery::mock(IFolderService::class), + Mockery::mock(IPresentationSpeakerSummitAssistanceConfirmationRequestRepository::class), + Mockery::mock(ILanguageRepository::class), + Mockery::mock(ISpeakerOrganizationalRoleRepository::class), + Mockery::mock(ISpeakerActiveInvolvementRepository::class), + Mockery::mock(IFileUploader::class), + Mockery::mock(ISpeakerEditPermissionRequestRepository::class), + Mockery::mock(ISummitRepository::class), + Mockery::mock(IPromoCodeStrategyFactory::class), + $tx_service + ); + } + + public function testMergePersistsMemberNameFallbackEvenWhenAccountFullnameToggleIsOff() + { + $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 override persists a blank instead of raising + $member->shouldReceive('getFirstName')->andReturn('Ada'); + $member->shouldReceive('getLastName')->andReturn('Lovelace'); + + // the record the admin is merging away: no name of its own, relies on the Member fallback + $speaker_from = new PresentationSpeaker(); + self::setEntityId($speaker_from, 1); + $speaker_from->setFirstName(''); + $speaker_from->setLastName(''); + $speaker_from->setMember($member); + + // the surviving record: stale name of its own and NO linked Member, so whatever lands in + // its name columns is exactly what getFirstName()/getLastName() read back afterwards + $speaker_to = new PresentationSpeaker(); + self::setEntityId($speaker_to, 2); + $speaker_to->setFirstName('Stale'); + $speaker_to->setLastName('Name'); + $speaker_to->setBio(''); + $speaker_to->setTitle(''); + $speaker_to->setIrcHandle(''); + $speaker_to->setTwitterName(''); + + $this->buildService()->merge($speaker_from, $speaker_to, [ + 'bio' => 2, + 'first_name' => 1, + 'last_name' => 1, + 'title' => 2, + 'irc' => 2, + 'twitter' => 2, + 'pic' => 2, + 'registration_request' => 2, + 'member' => 2, + ]); + + $this->assertSame('Ada', $speaker_to->getFirstName()); + $this->assertSame('Lovelace', $speaker_to->getLastName()); + } +} From dcb227aa59138b52e6d91207bd28294a2f2fa44c Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 16:17:53 -0300 Subject: [PATCH 11/15] test(speakers): annotate Mockery helpers for PHPStan and pin the name-override path on the entity Addresses the CodeRabbit findings on PR #597: - Intersection-type annotations (`Domain&\Mockery\MockInterface`) on the helpers that return a Mockery mock as a domain type in AdminCsvSpeakerFullNameFallbackTest, AdminPresentationSpeakerSerializerTest and SpeakerNameMemberFallbackAuditLogFormatterTest, matching the form already used in DoctrineTransactionServiceTest. This repo does not run PHPStan in CI; the annotations only silence the return.type check CodeRabbit runs on the diff. Verified locally with `vendor/bin/phpstan analyse --level=5` on the three files: zero "should return" findings left. - PresentationSpeakerTest: added the name-override counterpart of testPhotoFallbackUsesMemberWhenOverridePermissionIsTrueEvenWithToggleOff - getFirstName(true)/getLastName(true)/getFullName(true) surface the Member's name when the Member's full-name toggle is off. The same path is already exercised through SpeakerServiceMergeTest and the EntityUpdate formatter test; this pins the getter contract at the entity level. --- tests/AdminCsvSpeakerFullNameFallbackTest.php | 3 +++ ...AdminPresentationSpeakerSerializerTest.php | 2 ++ ...ameMemberFallbackAuditLogFormatterTest.php | 1 + .../Unit/Entities/PresentationSpeakerTest.php | 24 +++++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/tests/AdminCsvSpeakerFullNameFallbackTest.php b/tests/AdminCsvSpeakerFullNameFallbackTest.php index 5ba5f268c..15ce9f1a8 100644 --- a/tests/AdminCsvSpeakerFullNameFallbackTest.php +++ b/tests/AdminCsvSpeakerFullNameFallbackTest.php @@ -54,6 +54,7 @@ private function buildResourceServerContext(): IResourceServerContext // about track-chair identity. $current_user = Mockery::mock(Member::class); + /** @var IResourceServerContext&\Mockery\MockInterface $context */ $context = Mockery::mock(IResourceServerContext::class); $context->shouldReceive('getApplicationType')->andReturn('JS_CLIENT'); $context->shouldReceive('getCurrentUser')->andReturn($current_user); @@ -62,6 +63,7 @@ private function buildResourceServerContext(): IResourceServerContext private function buildSpeaker(string $full_name): PresentationSpeaker { + /** @var PresentationSpeaker&\Mockery\MockInterface $speaker */ $speaker = Mockery::mock(PresentationSpeaker::class); $speaker->shouldReceive('getFullName')->once()->with(true)->andReturn($full_name); $speaker->shouldReceive('getId')->andReturn(1); @@ -87,6 +89,7 @@ private function buildPresentation $summit = Mockery::mock(Summit::class); $summit->shouldReceive('getTrackChairByMember')->andReturn(null); + /** @var Presentation&\Mockery\MockInterface $presentation */ $presentation = Mockery::mock(Presentation::class); $presentation->shouldReceive('getId')->andReturn($id); $presentation->shouldReceive('getLastEditedUTC')->andReturn(null); diff --git a/tests/AdminPresentationSpeakerSerializerTest.php b/tests/AdminPresentationSpeakerSerializerTest.php index 195f3f176..38fdbedc2 100644 --- a/tests/AdminPresentationSpeakerSerializerTest.php +++ b/tests/AdminPresentationSpeakerSerializerTest.php @@ -43,6 +43,7 @@ public function tearDown(): void private function buildSpeaker(bool $expectOverride): PresentationSpeaker { + /** @var PresentationSpeaker&\Mockery\MockInterface $speaker */ $speaker = Mockery::mock(PresentationSpeaker::class); $speaker->shouldReceive('hasMember')->andReturn(true); $speaker->shouldReceive('getMember')->andReturn(Mockery::mock(Member::class)); @@ -58,6 +59,7 @@ private function buildSpeaker(bool $expectOverride): PresentationSpeaker private function buildContext(Member $current_member): IResourceServerContext { + /** @var IResourceServerContext&\Mockery\MockInterface $context */ $context = Mockery::mock(IResourceServerContext::class); $context->shouldReceive('getCurrentUser')->andReturn($current_member); return $context; diff --git a/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php index 5bd35f3f5..bd0afaf95 100644 --- a/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php +++ b/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php @@ -78,6 +78,7 @@ private function buildSpeakerRelyingOnMemberFallback(): PresentationSpeaker private function buildSummit(): Summit { + /** @var Summit&\Mockery\MockInterface $summit */ $summit = Mockery::mock(Summit::class); $summit->shouldReceive('getName')->andReturn('Test Summit'); return $summit; diff --git a/tests/Unit/Entities/PresentationSpeakerTest.php b/tests/Unit/Entities/PresentationSpeakerTest.php index 605e93920..14b6e82a6 100644 --- a/tests/Unit/Entities/PresentationSpeakerTest.php +++ b/tests/Unit/Entities/PresentationSpeakerTest.php @@ -193,6 +193,30 @@ public function testNameFallbackUsesMemberWhenAccountFullnameToggleIsOn() $this->assertSame('Ada Lovelace', $speaker->getFullName()); } + /** + * Policy Rule 9 scope: admin/self-view callers and internal tooling (admin serializers, + * merge, audit log, self-addressed emails) are out of scope of the account visibility + * toggle, so the name fallback must still surface the Member's name via the + * override_permission parameter even when that Member's own toggle is off. + */ + public function testNameFallbackUsesMemberWhenOverridePermissionIsTrueEvenWithToggleOff() + { + $member = Mockery::mock(Member::class); + $member->shouldReceive('getId')->andReturn(42); + $member->shouldReceive('setSpeaker')->andReturnNull(); + $member->shouldReceive('isPublicProfileShowFullname')->andReturn(false); + $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(true)); + $this->assertSame('Lovelace', $speaker->getLastName(true)); + $this->assertSame('Ada Lovelace', $speaker->getFullName(true)); + } + /** * 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. From b0246a3fe527781177fa61fa247874e5ac79c3cb Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 17:22:14 -0300 Subject: [PATCH 12/15] fix(speakers): resolve the caller before bypassing the name toggle in shared email and promo-code payloads Two of the override_permission=true sites from c54152cd3 reach non-admin third parties and must not carry the Member name fallback to them (policy/profile-data-handling.md Rule 9): - PresentationSpeakerSelectionProcessEmail: with shouldSendCopy2Submitter on, the same payload is CC'd to the creators of the speaker's presentations. The override now applies only when the delivery is self-only (getFullName(count($cc_email) === 0)); when copies go out the greeting falls back to the speaker's email, the pre-existing "no name anywhere" behavior. - SpeakerSummitRegistrationPromoCodeSerializer / SpeakerSummitRegistrationDiscountCodeSerializer owner_name: the promo-code endpoints themselves require admin groups, but the serializers are also reached indirectly - registration_codes.owner_name through AdminPresentationSpeakerSerializer (served to edit-permission grantees since 417c68b7a) and promo_code.owner_name on a ticket whose attendee is not the code owner. owner_name now resolves the caller the same way the speaker serializers do instead of an unconditional override. Extracted PresentationSpeakerBaseSerializer::canBypassAccountVisibilityToggle() into the shared App\ModelSerializers\Traits\AccountVisibilityToggleBypass trait so the caller-identity rule lives in one place. The rule now also honors SummitRegistrationAdmins, which is the admin population the promo-code endpoints admit (required-groups on OAuth2SummitPromoCodesApiController); the speaker endpoints never route a registration-admin-only caller to the admin serializer, so their behavior is unchanged. --- ...esentationSpeakerSelectionProcessEmail.php | 8 ++-- ...mmitRegistrationDiscountCodeSerializer.php | 9 +++- ...rSummitRegistrationPromoCodeSerializer.php | 9 +++- .../PresentationSpeakerBaseSerializer.php | 21 ++------- .../Traits/AccountVisibilityToggleBypass.php | 44 +++++++++++++++++++ ...AdminPresentationSpeakerSerializerTest.php | 3 ++ 6 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php diff --git a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php index 9f769d643..327dd4a6b 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php @@ -130,9 +130,11 @@ public function __construct } $speaker_email = $speaker->getEmail(); - // self-addressed: the recipient is the speaker, so the Member name fallback bypasses the - // account visibility toggle (policy/profile-data-handling.md Rule 9 scope) - $speaker_full_name = $speaker->getFullName(true); + // The Member name fallback may bypass the account visibility toggle only when the delivery + // is self-addressed. With submitter copies on, this same payload is CC'd to the creators of + // the speaker's presentations - non-admin third parties - so the fallback must honor the + // toggle (policy/profile-data-handling.md Rule 9) and the greeting falls back to the email. + $speaker_full_name = $speaker->getFullName(count($cc_email) === 0); // set to email if fullname is empty if(empty($speaker_full_name)){ diff --git a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php index 35001d93f..1a7b41856 100644 --- a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php +++ b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\ModelSerializers\Traits\AccountVisibilityToggleBypass; use Libs\ModelSerializers\AbstractSerializer; use models\summit\SpeakerSummitRegistrationDiscountCode; /** @@ -21,6 +22,8 @@ class SpeakerSummitRegistrationDiscountCodeSerializer extends SummitRegistrationDiscountCodeSerializer { + use AccountVisibilityToggleBypass; + protected static $array_mappings = [ 'Type' => 'type:json_string', 'SpeakerId' => 'speaker_id:json_int', @@ -64,7 +67,11 @@ public function serialize($expand = null, array $fields = [], array $relations = break; case 'owner_name': { if($code->hasSpeaker()){ - $values['owner_name'] = $code->getSpeaker()->getFullName(true); + // admin tooling or the owner themself may bypass the account + // visibility toggle on the Member name fallback; anyone else who + // reaches this serializer indirectly must not (policy Rule 9) + $speaker = $code->getSpeaker(); + $values['owner_name'] = $speaker->getFullName($this->canBypassAccountVisibilityToggle($speaker)); } } break; diff --git a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php index d0714215f..85f2825cb 100644 --- a/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php +++ b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\ModelSerializers\Traits\AccountVisibilityToggleBypass; use Libs\ModelSerializers\AbstractSerializer; use models\summit\SpeakerSummitRegistrationPromoCode; /** @@ -21,6 +22,8 @@ class SpeakerSummitRegistrationPromoCodeSerializer extends SummitRegistrationPromoCodeSerializer { + use AccountVisibilityToggleBypass; + protected static $array_mappings = [ 'Type' => 'type:json_string', 'SpeakerId' => 'speaker_id:json_int', @@ -64,7 +67,11 @@ public function serialize($expand = null, array $fields = [], array $relations = break; case 'owner_name': { if($code->hasSpeaker()){ - $values['owner_name'] = $code->getSpeaker()->getFullName(true); + // admin tooling or the owner themself may bypass the account + // visibility toggle on the Member name fallback; anyone else who + // reaches this serializer indirectly must not (policy Rule 9) + $speaker = $code->getSpeaker(); + $values['owner_name'] = $speaker->getFullName($this->canBypassAccountVisibilityToggle($speaker)); } } break; diff --git a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php index ac35ae0fa..fd170d3f5 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerBaseSerializer.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\ModelSerializers\Traits\AccountVisibilityToggleBypass; use libs\utils\JsonUtils; use models\oauth2\IResourceServerContext; use models\summit\PresentationSpeaker; @@ -22,6 +23,8 @@ */ abstract class PresentationSpeakerBaseSerializer extends SilverStripeSerializer { + use AccountVisibilityToggleBypass; + protected static $array_mappings = [ 'FirstName' => 'first_name:json_string', 'LastName' => 'last_name:json_string', @@ -94,22 +97,4 @@ public function serialize($expand = null, array $fields = [], array $relations = return $values; } - - /** - * Policy Rule 9 (policy/profile-data-handling.md Sec 2 Scope): only an Admin/SummitAdmin, or - * the speaker viewing/editing their own record, may bypass the account visibility toggle on - * the Member name/photo fallback. A submitter who only holds an approved edit-permission - * request on someone else's speaker profile (PresentationSpeaker::canBeEditedBy(), which also - * resolves to this serializer's SerializerType_Private/Admin) is neither, and must see - * exactly what a Public caller sees. - * @param PresentationSpeaker $speaker - * @return bool - */ - protected function canBypassAccountVisibilityToggle(PresentationSpeaker $speaker): bool - { - $current_member = $this->resource_server_context->getCurrentUser(); - if (is_null($current_member)) return false; - if ($current_member->isAdmin() || $current_member->isSummitAdmin()) return true; - return $speaker->hasMember() && $speaker->getMemberId() == $current_member->getId(); - } } diff --git a/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php b/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php new file mode 100644 index 000000000..34360dcd8 --- /dev/null +++ b/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php @@ -0,0 +1,44 @@ +resource_server_context + * (AbstractSerializer does). + * + * @package App\ModelSerializers\Traits + */ +trait AccountVisibilityToggleBypass +{ + /** + * @param PresentationSpeaker $speaker + * @return bool + */ + protected function canBypassAccountVisibilityToggle(PresentationSpeaker $speaker): bool + { + $current_member = $this->resource_server_context->getCurrentUser(); + if (is_null($current_member)) return false; + if ($current_member->isAdmin() || $current_member->isSummitAdmin() || $current_member->isRegistrationAdmin()) return true; + return $speaker->hasMember() && $speaker->getMemberId() == $current_member->getId(); + } +} diff --git a/tests/AdminPresentationSpeakerSerializerTest.php b/tests/AdminPresentationSpeakerSerializerTest.php index 38fdbedc2..3de096632 100644 --- a/tests/AdminPresentationSpeakerSerializerTest.php +++ b/tests/AdminPresentationSpeakerSerializerTest.php @@ -70,6 +70,7 @@ public function testAdminCallerBypassesAccountVisibilityToggle() $admin = Mockery::mock(Member::class); $admin->shouldReceive('isAdmin')->andReturn(true); $admin->shouldReceive('isSummitAdmin')->andReturn(false); + $admin->shouldReceive('isRegistrationAdmin')->andReturn(false); $admin->shouldReceive('getId')->andReturn(self::OtherMemberId); $speaker = $this->buildSpeaker(true); @@ -85,6 +86,7 @@ public function testSpeakerViewingOwnRecordBypassesAccountVisibilityToggle() $owner = Mockery::mock(Member::class); $owner->shouldReceive('isAdmin')->andReturn(false); $owner->shouldReceive('isSummitAdmin')->andReturn(false); + $owner->shouldReceive('isRegistrationAdmin')->andReturn(false); $owner->shouldReceive('getId')->andReturn(self::OwnerMemberId); $speaker = $this->buildSpeaker(true); @@ -106,6 +108,7 @@ public function testEditPermissionGranteeWhoIsNotOwnerDoesNotBypassAccountVisibi $submitter = Mockery::mock(Member::class); $submitter->shouldReceive('isAdmin')->andReturn(false); $submitter->shouldReceive('isSummitAdmin')->andReturn(false); + $submitter->shouldReceive('isRegistrationAdmin')->andReturn(false); $submitter->shouldReceive('getId')->andReturn(self::OtherMemberId); $speaker = $this->buildSpeaker(false); From b3c4823fba26d2a8d01d347b39f5255ced63c898 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 7 Sep 2026 17:22:20 -0300 Subject: [PATCH 13/15] test(speakers): pin the CC and owner_name caller rules for the Member name fallback PresentationSpeakerSelectionProcessEmailCcTest: builds the real job against summit test data with a speaker whose gated getFullName() is empty. With submitter copies on, the payload greets by email and carries the creator in cc_email; with copies off, it greets by the Member's name. Verified red against an unconditional override (the copies-on case greeted by name). SpeakerPromoCodeOwnerNameSerializerTest: both speaker promo-code serializers x four callers (admin, registration admin, code owner, unrelated member). Verified red against the unconditional override (the unrelated-caller case received the Member name). --- ...tionSpeakerSelectionProcessEmailCcTest.php | 127 ++++++++++++++++++ ...peakerPromoCodeOwnerNameSerializerTest.php | 121 +++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 tests/PresentationSpeakerSelectionProcessEmailCcTest.php create mode 100644 tests/SpeakerPromoCodeOwnerNameSerializerTest.php diff --git a/tests/PresentationSpeakerSelectionProcessEmailCcTest.php b/tests/PresentationSpeakerSelectionProcessEmailCcTest.php new file mode 100644 index 000000000..51e4b3300 --- /dev/null +++ b/tests/PresentationSpeakerSelectionProcessEmailCcTest.php @@ -0,0 +1,127 @@ +setTitle("SELECTION PROCESS CC TEST"); + self::$presentation->setType(self::$defaultPresentationType); + self::$presentation->setSelectionPlan(self::$default_selection_plan); + // the creator is who gets CC'd; must not be the speaker themself + self::$presentation->setCreatedBy(self::$member); + self::$presentation->setCategory(self::$defaultTrack); + self::$summit->addEvent(self::$presentation); + + self::$em->persist(self::$summit); + self::$em->flush(); + } + + protected function tearDown(): void + { + Mockery::close(); + self::clearSummitTestData(); + parent::tearDown(); + } + + /** + * A speaker with no name of its own whose linked Member has the full-name toggle OFF: + * the gated getter comes back empty, only the override surfaces the Member's name. + */ + private function buildSpeakerRelyingOnMemberFallback(): PresentationSpeaker + { + /** @var PresentationSpeaker&\Mockery\MockInterface $speaker */ + $speaker = Mockery::mock(PresentationSpeaker::class); + $speaker->shouldReceive('getId')->andReturn(1); + $speaker->shouldReceive('getEmail')->andReturn(self::SpeakerEmail); + $speaker->shouldReceive('getAcceptedPresentations')->andReturn([self::$presentation]); + $speaker->shouldReceive('getAlternatePresentations')->andReturn([]); + $speaker->shouldReceive('getRejectedPresentations')->andReturn([]); + $speaker->shouldReceive('getFullName')->with(false)->andReturn(''); + $speaker->shouldReceive('getFullName')->with(true)->andReturn(self::MemberFullName); + return $speaker; + } + + private function buildJob(bool $send_copy_to_submitter): PresentationSpeakerSelectionProcessAcceptedOnlyEmail + { + $config = new SpeakersAnnouncementEmailConfigDTO(); + $config->setShouldSendCopy2Submitter($send_copy_to_submitter); + + return new PresentationSpeakerSelectionProcessAcceptedOnlyEmail( + self::$summit, + null, + $this->buildSpeakerRelyingOnMemberFallback(), + null, + $config + ); + } + + private function readPayload(PresentationSpeakerSelectionProcessAcceptedOnlyEmail $job): array + { + $prop = new ReflectionProperty($job, 'payload'); + $prop->setAccessible(true); + return $prop->getValue($job); + } + + public function testMemberNameIsNotSharedWithSubmitterCopies() + { + $payload = $this->readPayload($this->buildJob(true)); + + $this->assertStringContainsString(self::$member->getEmail(), $payload[IMailTemplatesConstants::cc_email]); + $this->assertSame(self::SpeakerEmail, $payload[IMailTemplatesConstants::speaker_full_name]); + } + + public function testMemberNameIsUsedWhenDeliveryIsSelfOnly() + { + $payload = $this->readPayload($this->buildJob(false)); + + $this->assertEmpty($payload[IMailTemplatesConstants::cc_email] ?? ''); + $this->assertSame(self::MemberFullName, $payload[IMailTemplatesConstants::speaker_full_name]); + } +} diff --git a/tests/SpeakerPromoCodeOwnerNameSerializerTest.php b/tests/SpeakerPromoCodeOwnerNameSerializerTest.php new file mode 100644 index 000000000..fc5be0224 --- /dev/null +++ b/tests/SpeakerPromoCodeOwnerNameSerializerTest.php @@ -0,0 +1,121 @@ + [SpeakerSummitRegistrationPromoCodeSerializer::class, SpeakerSummitRegistrationPromoCode::class], + 'discount code' => [SpeakerSummitRegistrationDiscountCodeSerializer::class, SpeakerSummitRegistrationDiscountCode::class], + ]; + } + + /** + * The speaker mock only answers getFullName() for the expected override value; the other + * value has no expectation and fails the test. + */ + private function buildCode(string $code_class, bool $expectOverride) + { + $speaker = Mockery::mock(PresentationSpeaker::class); + $speaker->shouldReceive('hasMember')->andReturn(true); + $speaker->shouldReceive('getMemberId')->andReturn(self::OwnerMemberId); + $speaker->shouldReceive('getFullName')->once()->with($expectOverride)->andReturn('Jane Doe'); + + $code = Mockery::mock($code_class); + $code->shouldReceive('getId')->andReturn(7); + $code->shouldReceive('hasSpeaker')->andReturn(true); + $code->shouldReceive('getSpeaker')->andReturn($speaker); + return $code; + } + + private function buildContext(bool $is_admin, bool $is_registration_admin, int $member_id): IResourceServerContext + { + $member = Mockery::mock(Member::class); + $member->shouldReceive('isAdmin')->andReturn($is_admin); + $member->shouldReceive('isSummitAdmin')->andReturn(false); + $member->shouldReceive('isRegistrationAdmin')->andReturn($is_registration_admin); + $member->shouldReceive('getId')->andReturn($member_id); + + /** @var IResourceServerContext&\Mockery\MockInterface $context */ + $context = Mockery::mock(IResourceServerContext::class); + $context->shouldReceive('getCurrentUser')->andReturn($member); + return $context; + } + + private function serializeOwnerName(string $serializer_class, $code, IResourceServerContext $context): string + { + $serializer = new $serializer_class($code, $context); + $values = $serializer->serialize('owner_name', ['id'], ['none']); + return $values['owner_name']; + } + + #[DataProvider('serializers')] + public function testAdminCallerBypassesAccountVisibilityToggle(string $serializer_class, string $code_class) + { + $context = $this->buildContext(true, false, self::OtherMemberId); + $this->assertSame('Jane Doe', $this->serializeOwnerName($serializer_class, $this->buildCode($code_class, true), $context)); + } + + #[DataProvider('serializers')] + public function testRegistrationAdminCallerBypassesAccountVisibilityToggle(string $serializer_class, string $code_class) + { + $context = $this->buildContext(false, true, self::OtherMemberId); + $this->assertSame('Jane Doe', $this->serializeOwnerName($serializer_class, $this->buildCode($code_class, true), $context)); + } + + #[DataProvider('serializers')] + public function testCodeOwnerBypassesAccountVisibilityToggle(string $serializer_class, string $code_class) + { + $context = $this->buildContext(false, false, self::OwnerMemberId); + $this->assertSame('Jane Doe', $this->serializeOwnerName($serializer_class, $this->buildCode($code_class, true), $context)); + } + + #[DataProvider('serializers')] + public function testUnrelatedCallerHonorsAccountVisibilityToggle(string $serializer_class, string $code_class) + { + $context = $this->buildContext(false, false, self::OtherMemberId); + $this->assertSame('Jane Doe', $this->serializeOwnerName($serializer_class, $this->buildCode($code_class, false), $context)); + } +} From 47a90802510f2788ae39594f56cf90c58a7091da Mon Sep 17 00:00:00 2001 From: smarcet Date: Tue, 8 Sep 2026 10:19:00 -0300 Subject: [PATCH 14/15] fix(speakers): pass override_permission at the reopen-notification recipient name, missed by the caller sweep PresentationSubmissionReopenService::notify() collects each selected speaker's name with getFullName() and mails it to that same speaker's own address (PresentationSubmissionReopenedEmail($presentation, $email, $name)). It is self-addressed and belongs with the override group from c54152cd3, which missed it because the receiver is $allowed[$id] rather than a $speaker-named variable. Without the override a speaker with empty own first_name/last_name and public_profile_show_fullname off was greeted by a blank name in their own reopen notification. Also, per the same review round: - AccountVisibilityToggleBypass docblock now states that the admin / track-chair CSV exports hardcode getFullName(true) by decision (6cb6647f1) instead of resolving the caller, so the "one place" claim does not read as if those exports were a bug. - PresentationSpeaker::getFirstName() gets the @param line its siblings already carry. --- .../Traits/AccountVisibilityToggleBypass.php | 8 ++++++-- .../Foundation/Summit/Speakers/PresentationSpeaker.php | 1 + .../Model/Imp/PresentationSubmissionReopenService.php | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php b/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php index 34360dcd8..b4907e7ad 100644 --- a/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php +++ b/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php @@ -22,8 +22,12 @@ * edit-permission request on someone else's profile, an attendee whose ticket carries a speaker's * promo code - must see exactly what a Public caller sees. * - * Shared by every serializer that renders that fallback so the caller-identity rule is resolved - * in exactly one place. Requires the using class to expose $this->resource_server_context + * Shared by every serializer that has to resolve the caller at request time, so that rule lives + * in exactly one place. Serializers that are only ever reached through an admin-tooling endpoint + * (the admin / track-chair CSV exports: AdminPresentationCSVSerializer, + * TrackChairPresentationCSVSerializer, the Speakers*PromoCodeCSVSerializer pair) do not use it and + * hardcode getFullName(true) instead - track chairs count as admin tooling for those exports by + * decision, see 6cb6647f1. Requires the using class to expose $this->resource_server_context * (AbstractSerializer does). * * @package App\ModelSerializers\Traits diff --git a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php index 2be1e6517..0e5de49a4 100644 --- a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php +++ b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php @@ -205,6 +205,7 @@ class PresentationSpeaker extends SilverstripeBaseModel private $announcement_summit_emails; /** + * @param bool $override_permission * @return string|null */ public function getFirstName(bool $override_permission=false):?string diff --git a/app/Services/Model/Imp/PresentationSubmissionReopenService.php b/app/Services/Model/Imp/PresentationSubmissionReopenService.php index 27f6ec34c..c03d3e286 100644 --- a/app/Services/Model/Imp/PresentationSubmissionReopenService.php +++ b/app/Services/Model/Imp/PresentationSubmissionReopenService.php @@ -212,8 +212,11 @@ function () use ($summit, $presentation_id, $speaker_ids, $include_submitter) { $add($submitter->getEmail(), $submitter->getFullName(), sprintf('submitter (member %s)', $submitter->getId())); } + // self-addressed: each name is mailed to that same speaker's own address, so the + // Member name fallback bypasses the account visibility toggle + // (policy/profile-data-handling.md Rule 9 scope) foreach ($speaker_ids as $id) - $add($allowed[$id]->getEmail(), $allowed[$id]->getFullName(), sprintf('%s %s', $roles[$id], $id)); + $add($allowed[$id]->getEmail(), $allowed[$id]->getFullName(true), sprintf('%s %s', $roles[$id], $id)); if (empty($recipients)) throw new ValidationException("None of the selected recipients has an email address."); From 33dcb24d35ee8e463d50f73b08acad090c459b16 Mon Sep 17 00:00:00 2001 From: smarcet Date: Tue, 8 Sep 2026 10:19:07 -0300 Subject: [PATCH 15/15] test(speakers): pin the Member name fallback in the reopen notification recipient PresentationReopenApiTest: a speaker whose name lives only on the linked Member, with that Member's full-name toggle off, selected as the reopen notification recipient. The queued PresentationSubmissionReopenedEmail must carry the Member's name as full_name. Verified red against the bare getFullName() (the job carried a blank name and the callback assertion failed) and green with the override. --- tests/PresentationReopenApiTest.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/PresentationReopenApiTest.php b/tests/PresentationReopenApiTest.php index 2a004e3d9..f97196cd3 100644 --- a/tests/PresentationReopenApiTest.php +++ b/tests/PresentationReopenApiTest.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\Jobs\Emails\IMailTemplatesConstants; use App\Jobs\Emails\PresentationSubmissions\PresentationSubmissionReopenedEmail; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Queue; @@ -1116,6 +1117,31 @@ public function testNotifyBlankEmailRecipientIsSkippedWhileOthersAreDispatched() Queue::assertPushed(PresentationSubmissionReopenedEmail::class, 1); } + /** + * Policy Rule 9 scope: the reopen notification is mailed to the speaker's own address, so a + * speaker with no name of their own must still be greeted by the linked Member's name even + * when that Member's public_profile_show_fullname toggle is off. speakerWithEmail() builds + * exactly that fixture: the name lives on the Member only. + */ + public function testNotifyGreetsASpeakerRelyingOnTheMemberNameFallbackEvenWithTheToggleOff() + { + Queue::fake(); + $this->grantWindow(24); + $speaker = $this->speakerWithEmail("Ada", "Lovelace"); + $speaker->getMember()->setPublicProfileShowFullname(false); + self::$presentation->addSpeaker($speaker); + self::$em->flush(); + + $response = $this->notify(['speaker_ids' => [$speaker->getId()]]); + + $this->assertResponseStatus(200); + Queue::assertPushed(PresentationSubmissionReopenedEmail::class, function (PresentationSubmissionReopenedEmail $job) { + $prop = new \ReflectionProperty($job, 'payload'); + $prop->setAccessible(true); + return $prop->getValue($job)[IMailTemplatesConstants::full_name] === 'Ada Lovelace'; + }); + } + public function testNotifyRejectsASpeakerIdNotOnThisPresentationAndQueuesNothing() { Queue::fake();