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..327dd4a6b 100644 --- a/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php +++ b/app/Jobs/Emails/PresentationSubmissions/SelectionProcess/PresentationSpeakerSelectionProcessEmail.php @@ -130,7 +130,11 @@ public function __construct } $speaker_email = $speaker->getEmail(); - $speaker_full_name = $speaker->getFullName(); + // 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/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/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/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/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/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/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/SpeakerSummitRegistrationDiscountCodeSerializer.php b/app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php index 778cd46fd..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(); + // 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 7446c2f2a..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(); + // 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/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/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php index 9593b2b5f..ff83eec80 100644 --- a/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/AdminPresentationSpeakerCSVSerializer.php @@ -48,6 +48,24 @@ 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($bypass_toggle); + } + + if(in_array('last_name', $fields)) { + $values['last_name'] = $speaker->getLastName($bypass_toggle); + } + + if(in_array('pic', $fields)) { + $values['pic'] = $speaker->getProfilePhotoUrl($bypass_toggle); + } + + if(in_array('big_pic', $fields)) { + $values['big_pic'] = $speaker->getBigProfilePhotoUrl($bypass_toggle); + } + if(in_array("email", $fields)) $values['email'] = JsonUtils::toJsonString($speaker->getEmail()); @@ -92,4 +110,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..415082f9d 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; } @@ -63,6 +63,23 @@ 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($bypass_toggle); + } + + if(in_array('last_name', $fields)) { + $values['last_name'] = $speaker->getLastName($bypass_toggle); + } + + if(in_array('pic', $fields)) { + $values['pic'] = $speaker->getProfilePhotoUrl($bypass_toggle); + } + + if(in_array('big_pic', $fields)) { + $values['big_pic'] = $speaker->getBigProfilePhotoUrl($bypass_toggle); + } if(in_array("email", $fields)) { $application_type = $this->resource_server_context->getApplicationType(); @@ -214,4 +231,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..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', @@ -83,23 +86,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 +97,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/PresentationSpeakerSerializer.php b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php index fdc018f3a..2d4975a1a 100644 --- a/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php +++ b/app/ModelSerializers/Summit/Speakers/PresentationSpeakerSerializer.php @@ -350,54 +350,15 @@ 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/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php b/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php new file mode 100644 index 000000000..b4907e7ad --- /dev/null +++ b/app/ModelSerializers/Traits/AccountVisibilityToggleBypass.php @@ -0,0 +1,48 @@ +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/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/Models/Foundation/Summit/Speakers/PresentationSpeaker.php b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php index cdcae06d5..0e5de49a4 100644 --- a/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php +++ b/app/Models/Foundation/Summit/Speakers/PresentationSpeaker.php @@ -205,12 +205,13 @@ class PresentationSpeaker extends SilverstripeBaseModel private $announcement_summit_emails; /** + * @param bool $override_permission * @return string|null */ - public function getFirstName():?string + public function getFirstName(bool $override_permission=false):?string { $res = $this->first_name; - if(empty($res) && $this->hasMember()){ + if(empty($res) && ($this->isPublicProfileShowFullname() || $override_permission) && $this->hasMember()){ $res = $this->member->getFirstName(); } return $res; @@ -225,12 +226,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->hasMember()){ + if(empty($res) && ($this->isPublicProfileShowFullname() || $override_permission) && $this->hasMember()){ $res = $this->member->getLastName(); } return $res; @@ -1792,16 +1794,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->hasMember()) { + if (empty($fullname) && ($this->isPublicProfileShowFullname() || $override_permission) && $this->hasMember()) { $fullname = $this->member->getFullName(); } @@ -2332,9 +2335,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 { @@ -2342,7 +2346,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() || $override_permission) && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { $photoUrl = $photo->getUrl(); } @@ -2357,9 +2361,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 { @@ -2367,7 +2372,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() || $override_permission) && $this->hasMember() && $this->member->hasPhoto() && $photo = $this->member->getPhoto()) { $photoUrl = $photo->getUrl(); } } catch (\Exception $ex) { 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/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."); 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"); diff --git a/tests/AdminCsvSpeakerFullNameFallbackTest.php b/tests/AdminCsvSpeakerFullNameFallbackTest.php new file mode 100644 index 000000000..15ce9f1a8 --- /dev/null +++ b/tests/AdminCsvSpeakerFullNameFallbackTest.php @@ -0,0 +1,181 @@ +shouldReceive('getApplicationType')->andReturn('JS_CLIENT'); + $context->shouldReceive('getCurrentUser')->andReturn($current_user); + return $context; + } + + 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); + $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); + + /** @var Presentation&\Mockery\MockInterface $presentation */ + $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']); + } +} diff --git a/tests/AdminPresentationSpeakerSerializerTest.php b/tests/AdminPresentationSpeakerSerializerTest.php new file mode 100644 index 000000000..3de096632 --- /dev/null +++ b/tests/AdminPresentationSpeakerSerializerTest.php @@ -0,0 +1,121 @@ +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 + { + /** @var IResourceServerContext&\Mockery\MockInterface $context */ + $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('isRegistrationAdmin')->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('isRegistrationAdmin')->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('isRegistrationAdmin')->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']); + } +} diff --git a/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php new file mode 100644 index 000000000..bd0afaf95 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SpeakerNameMemberFallbackAuditLogFormatterTest.php @@ -0,0 +1,153 @@ +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 + { + /** @var Summit&\Mockery\MockInterface $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/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(); 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/PresentationSpeakerSerializerTest.php b/tests/PresentationSpeakerSerializerTest.php index 192a02184..3425aa373 100644 --- a/tests/PresentationSpeakerSerializerTest.php +++ b/tests/PresentationSpeakerSerializerTest.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use models\main\Member; use models\oauth2\IResourceServerContext; use models\summit\PresentationSpeaker; use ModelSerializers\PresentationSpeakerSerializer; @@ -49,4 +50,111 @@ 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 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: 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); + + $resource_server_context = Mockery::mock(IResourceServerContext::class); + $serializer = new PresentationSpeakerSerializer($speaker, $resource_server_context); + + $values = $serializer->serialize(null, ['pic', 'big_pic'], ['none']); + + $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']); + } } 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)); + } +} diff --git a/tests/Unit/Entities/PresentationSpeakerTest.php b/tests/Unit/Entities/PresentationSpeakerTest.php index 04ca3eb08..14b6e82a6 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,149 @@ 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 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. + */ + 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()); + } + + /** + * 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 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()); + } +}