diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e6bf6..16c67c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Changelog +5.1.0 - 2025-05-25 +- Replaced inline Microsoft Teams report expiry note with emails notices for client secret expiry. + 5.0.6 - 2025-04-27 - Added code to harden the URL check for Microsoft Teams diff --git a/ClientSecretExpiryNotifier.php b/ClientSecretExpiryNotifier.php new file mode 100644 index 0000000..90d6e5b --- /dev/null +++ b/ClientSecretExpiryNotifier.php @@ -0,0 +1,278 @@ +settings = $settings; + $this->scheduledReportsApi = $scheduledReportsApi; + $this->usersManagerApi = $usersManagerApi; + $this->logger = $logger; + } + + /** + * Sends expiry notifications when today matches one of the configured notice days. + */ + public function sendNotificationsIfDue(): void + { + $expiryDate = $this->getExpiryDate(); + if ($expiryDate === null) { + return; + } + + // The task is stateless: only exact notice days result in email. + $daysUntilExpiry = $this->getDaysUntilExpiry($expiryDate); + if (!$this->isNoticeDay($daysUntilExpiry)) { + return; + } + + $superUserRecipients = $this->getSuperUserRecipients(); + $this->sendEmails($superUserRecipients, $expiryDate, $daysUntilExpiry, 'SuperUser'); + + $ownerRecipients = $this->getReportOwnerRecipients($superUserRecipients); + $this->sendEmails($ownerRecipients, $expiryDate, $daysUntilExpiry, 'Owner'); + } + + /** + * Calculates full UTC days remaining until the configured client secret expiry date. + */ + public function getDaysUntilExpiry(Date $expiryDate): int + { + return (int)(($expiryDate->getTimestamp() - Date::today()->getTimestamp()) / Date::NUM_SECONDS_IN_DAY); + } + + /** + * Checks whether the number of days remaining should trigger a notification. + */ + public function isNoticeDay(int $daysUntilExpiry): bool + { + return in_array($daysUntilExpiry, self::NOTICE_DAYS, true); + } + + /** + * Reads and parses the configured expiry date, returning null when it cannot be used. + */ + private function getExpiryDate(): ?Date + { + $expiryDate = $this->settings->clientSecretExpiryDate->getValue(); + if (empty($expiryDate)) { + return null; + } + + try { + return Date::factory($expiryDate); + } catch (\Exception $e) { + return null; + } + } + + /** + * Sends the selected template to each email address in the recipient list. + * + * @param array $emails + */ + private function sendEmails(array $emails, Date $expiryDate, int $daysUntilExpiry, string $recipientType): void + { + foreach ($emails as $email) { + $this->sendEmail($email, $expiryDate, $daysUntilExpiry, $recipientType); + } + } + + /** + * Collects all superuser email addresses, de-duplicated by email address. + * + * @return array + */ + private function getSuperUserRecipients(): array + { + $recipients = []; + + foreach ($this->usersManagerApi->getUsersHavingSuperUserAccess() as $user) { + $this->addRecipient($recipients, $user); + } + + return $recipients; + } + + /** + * Collects non-superuser owners of active Microsoft Teams scheduled reports. + * + * @param array $excludedEmails + * @return array + */ + private function getReportOwnerRecipients(array $excludedEmails): array + { + $recipients = []; + $superUserLogins = $this->getSuperUserLogins(); + + foreach ($this->getMicrosoftTeamsReports() as $report) { + $this->addReportOwnerRecipient($recipients, $report, $superUserLogins, $excludedEmails); + } + + return $recipients; + } + + /** + * Builds a lookup table for logins that already receive the superuser email. + * + * @return array + */ + private function getSuperUserLogins(): array + { + $logins = []; + + foreach ($this->usersManagerApi->getUsersHavingSuperUserAccess() as $user) { + if (!empty($user['login'])) { + $logins[$user['login']] = true; + } + } + + return $logins; + } + + /** + * Returns active scheduled reports that use Microsoft Teams delivery. + * + * @return array> + */ + private function getMicrosoftTeamsReports(): array + { + $reports = []; + + foreach ($this->getScheduledReports() as $report) { + if ($report['type'] === MicrosoftTeams::MS_TEAMS_TYPE && $report['period'] !== Schedule::PERIOD_NEVER) { + $reports[] = $report; + } + } + + return $reports; + } + + /** + * Returns scheduled reports, logging and skipping owner emails if reports cannot be loaded. + * + * @return array> + */ + private function getScheduledReports(): array + { + try { + return $this->scheduledReportsApi->getReports(); + } catch (\Exception $e) { + $this->logger->warning('Could not load scheduled reports for Microsoft Teams expiry emails: {exception}', [ + 'exception' => $e, + ]); + + return []; + } + } + + /** + * Adds a report owner email unless the owner is a superuser or already notified. + * + * @param array $recipients + * @param array $report + * @param array $superUserLogins + * @param array $excludedEmails + */ + private function addReportOwnerRecipient( + array &$recipients, + array $report, + array $superUserLogins, + array $excludedEmails + ): void { + $login = $report['login'] ?? ''; + if (empty($login) || isset($superUserLogins[$login])) { + return; + } + + try { + $this->addRecipient($recipients, $this->usersManagerApi->getUser($login), $excludedEmails); + } catch (\Exception $e) { + return; + } + } + + /** + * Adds one user email to the recipient list unless it is empty or excluded. + * + * @param array $recipients + * @param array $user + * @param array $excludedEmails + */ + private function addRecipient(array &$recipients, array $user, array $excludedEmails = []): void + { + if (empty($user['email']) || isset($excludedEmails[$user['email']])) { + return; + } + + $recipients[$user['email']] = $user['email']; + } + + /** + * Creates and sends a single expiry notification email. + */ + private function sendEmail(string $email, Date $expiryDate, int $daysUntilExpiry, string $recipientType): void + { + try { + /** @var ClientSecretExpiryNotificationEmail $mail */ + $mail = StaticContainer::getContainer()->make(ClientSecretExpiryNotificationEmail::class, [ + 'recipientEmail' => $email, + 'recipientType' => $recipientType, + 'daysUntilExpiry' => $daysUntilExpiry, + 'expiryDate' => $expiryDate->toString(), + ]); + $mail->safeSend(); + } catch (\Exception $e) { + $this->logger->warning('Could not send Microsoft Teams client secret expiry email: {exception}', [ + 'exception' => $e, + ]); + } + } +} diff --git a/Emails/ClientSecretExpiryNotificationEmail.php b/Emails/ClientSecretExpiryNotificationEmail.php new file mode 100644 index 0000000..288b448 --- /dev/null +++ b/Emails/ClientSecretExpiryNotificationEmail.php @@ -0,0 +1,86 @@ + 'FourWeeks', + 21 => 'ThreeWeeks', + 14 => 'TwoWeeks', + 7 => 'OneWeek', + 0 => 'Expired', + ]; + + /** + * @var string + */ + private $recipientType; + + /** + * @var int + */ + private $daysUntilExpiry; + + /** + * @var string + */ + private $expiryDate; + + public function __construct(string $recipientEmail, string $recipientType, int $daysUntilExpiry, string $expiryDate) + { + parent::__construct(); + + $this->recipientType = $recipientType; + $this->daysUntilExpiry = $daysUntilExpiry; + $this->expiryDate = $expiryDate; + + $this->setUpEmail($recipientEmail); + } + + private function setUpEmail(string $recipientEmail): void + { + $this->setDefaultFromPiwik(); + $this->addTo($recipientEmail); + $this->addReplyTo($this->getFrom(), $this->getFromName()); + $this->setSubject($this->getEmailSubject()); + $this->setBodyText($this->getEmailBodyText()); + $this->setWrappedHtmlBody($this->getEmailBodyView()); + } + + private function getEmailSubject(): string + { + return Piwik::translate($this->getTranslationKey('Subject')); + } + + private function getEmailBodyText(): string + { + return Piwik::translate($this->getTranslationKey('Body'), [$this->expiryDate]); + } + + private function getEmailBodyView(): View + { + $view = new View('@MicrosoftTeams/_clientSecretExpiryNotificationHtmlEmail'); + $view->bodyText = $this->getEmailBodyText(); + + return $view; + } + + private function getTranslationKey(string $templatePart): string + { + return 'MicrosoftTeams_ClientSecretExpiryEmail' . $this->recipientType . self::NOTICE_KEYS[$this->daysUntilExpiry] . $templatePart; + } +} diff --git a/ScheduleReportMicrosoftTeams.php b/ScheduleReportMicrosoftTeams.php index 6e014b4..205e49c 100644 --- a/ScheduleReportMicrosoftTeams.php +++ b/ScheduleReportMicrosoftTeams.php @@ -11,10 +11,6 @@ namespace Piwik\Plugins\MicrosoftTeams; -use Piwik\Container\StaticContainer; -use Piwik\Log\LoggerInterface; -use Piwik\Piwik; - class ScheduleReportMicrosoftTeams { /** @@ -60,32 +56,6 @@ public function __construct( public function send(): bool { $microsoftTeamsApi = new MicrosoftTeamsApi($this->webhookUrl); - return $microsoftTeamsApi->uploadFile($this->subject, $this->fileName, $this->fileContents, $this->requiredFields, $this->getTokenExpiryNoteIfNearExpiring()); - } - - private function getTokenExpiryNoteIfNearExpiring(): string - { - $note = ''; - $systemSettings = StaticContainer::get(SystemSettings::class); - $expiryDate = $systemSettings->clientSecretExpiryDate->getValue(); - if (!$expiryDate) { - return $note; - } - $today = new \DateTime(); - $expiry = new \DateTime($expiryDate); - $interval = $today->diff($expiry); - - if ($expiry < $today) { - $logger = StaticContainer::get(LoggerInterface::class); - $logger->error('Client Secret Expired.'); - - return $note; - } - - if ($interval->days <= 31) { - $note = Piwik::translate('MicrosoftTeams_ClientSecretExpiryNote', ['', '', $expiryDate]); - } - - return $note; + return $microsoftTeamsApi->uploadFile($this->subject, $this->fileName, $this->fileContents, $this->requiredFields); } } diff --git a/Tasks.php b/Tasks.php new file mode 100644 index 0000000..652133a --- /dev/null +++ b/Tasks.php @@ -0,0 +1,33 @@ +notifier = $notifier; + } + + public function schedule() + { + $this->daily('sendClientSecretExpiryNotifications'); + } + + public function sendClientSecretExpiryNotifications(): void + { + $this->notifier->sendNotificationsIfDue(); + } +} diff --git a/lang/en.json b/lang/en.json index 56fd494..8cdb921 100644 --- a/lang/en.json +++ b/lang/en.json @@ -10,6 +10,26 @@ "ClientSecretExpiryDateTitle": "Microsoft Client Secret Expiry Date", "ClientSecretExpiryDateDescription": "Optional. Set the expiry date in YYYY-MM-DD format to receive alert on expiry.", "ClientSecretDescription": "Enter your Microsoft Client Secret defined in the client application's Certificates and secrets page. Recommended expiry is 24 months. %1$sLearn more%2$s.", + "ClientSecretExpiryEmailOwnerExpiredBody": "Hi there,\n\nYour scheduled reports sent to Microsoft Teams will no longer be delivered.\n\nThe Microsoft Teams client secret has expired. A Matomo superuser must generate and configure a new client secret to restore the integration.\n\nPlease contact your Matomo superuser.\n\nThe Matomo Team", + "ClientSecretExpiryEmailOwnerExpiredSubject": "Microsoft Teams integration has expired", + "ClientSecretExpiryEmailOwnerFourWeeksBody": "Hi there,\n\nYour scheduled reports sent to Microsoft Teams rely on a client secret that is due to expire on %1$s.\n\nIf this secret is not updated, report delivery to Microsoft Teams will discontinue.\n\nOnly a Matomo superuser can renew this. We recommend contacting your Matomo superuser to confirm that the client secret will be updated before the expiry date.\n\nThe Matomo Team", + "ClientSecretExpiryEmailOwnerFourWeeksSubject": "Microsoft Teams integration expires in 4 weeks", + "ClientSecretExpiryEmailOwnerOneWeekBody": "Hi there,\n\nThe Microsoft Teams client secret used for your scheduled reports will expire in one week (%1$s).\n\nThis will prevent scheduled reports being delivered to Microsoft Teams.\n\nPlease contact your Matomo superuser to confirm the update.\n\nThe Matomo Team", + "ClientSecretExpiryEmailOwnerOneWeekSubject": "Final reminder: Microsoft Teams integration expires soon", + "ClientSecretExpiryEmailOwnerThreeWeeksBody": "Hi there,\n\nThis is a reminder that the Microsoft Teams client secret used for your scheduled reports will expire on %1$s.\n\nThis will prevent scheduled reports being delivered to Microsoft Teams.\n\nPlease check with your Matomo superuser to ensure the client secret is renewed in time.\n\nThe Matomo Team", + "ClientSecretExpiryEmailOwnerThreeWeeksSubject": "Reminder: Microsoft Teams integration may require update", + "ClientSecretExpiryEmailOwnerTwoWeeksBody": "Hi there,\n\nThe Microsoft Teams client secret used for your scheduled reports will expire on %1$s.\n\nTo avoid any interruption, confirm with your Matomo administrator that the client secret will be updated before this date.\n\nThe Matomo Team", + "ClientSecretExpiryEmailOwnerTwoWeeksSubject": "Upcoming expiry: Check Microsoft Teams integration", + "ClientSecretExpiryEmailSuperUserExpiredBody": "Hi there,\n\nYour Microsoft Teams client secret has now expired.\n\nMatomo can no longer send alerts or scheduled reports to Microsoft Teams.\n\nTo restore the integration, generate a new client secret and update your configuration.\n\nThe Matomo Team", + "ClientSecretExpiryEmailSuperUserExpiredSubject": "Action required: Microsoft Teams client secret has expired", + "ClientSecretExpiryEmailSuperUserFourWeeksBody": "Hi there,\n\nYour Microsoft Teams client secret is due to expire on %1$s.\n\nTo maintain the connection between Matomo and Microsoft Teams, generate and configure a new client secret before this date.\n\nIf the secret expires, Matomo will stop sending alerts and scheduled reports to Microsoft Teams.\n\nWe recommend updating the client secret now to avoid disruption.\n\nThe Matomo Team", + "ClientSecretExpiryEmailSuperUserFourWeeksSubject": "Microsoft Teams client secret expires in 4 weeks", + "ClientSecretExpiryEmailSuperUserOneWeekBody": "Hi there,\n\nYour Microsoft Teams client secret will expire in one week (%1$s).\n\nTo prevent disruption to alerts and scheduled reports, update the client secret before the expiry date.\n\nThe Matomo Team", + "ClientSecretExpiryEmailSuperUserOneWeekSubject": "Final reminder: Microsoft Teams client secret expires soon", + "ClientSecretExpiryEmailSuperUserThreeWeeksBody": "Hi there,\n\nThis is a reminder that your Microsoft Teams client secret will expire on %1$s.\n\nUpdate the client secret before this date to ensure Matomo continues sending alerts and scheduled reports.\n\nIf no action is taken, the integration will stop working after expiry.\n\nThe Matomo Team", + "ClientSecretExpiryEmailSuperUserThreeWeeksSubject": "Microsoft Teams client secret expires in 3 weeks", + "ClientSecretExpiryEmailSuperUserTwoWeeksBody": "Hi there,\n\nYour Microsoft Teams client secret will expire on %1$s.\n\nIf you have not updated it yet, generate and configure a new client secret to avoid interruptions to your Microsoft Teams integration.\n\nThe Matomo Team", + "ClientSecretExpiryEmailSuperUserTwoWeeksSubject": "Upcoming expiry: Microsoft Teams client secret", "TenantIdTitle": "Microsoft Tenant ID", "TenantIdDescription": "Enter your Microsoft Tenant ID found in the client application's Overview page. %1$sLearn more%2$s.", "TeamIdTitle": "Microsoft Teams ID", @@ -18,7 +38,6 @@ "RequiredFieldsNotSet": "The required fields to send a file are not set. Please set them %1$shere%2$s.", "PleaseFindYourReport": "Here is your %1$s report for %2$s.", "MicrosoftTeamsAlertContent": "%1$s has been triggered for website %2$s as the metric %3$s in report %4$s %5$s.", - "ClientSecretExpiryNote": "%1$sNote:%2$s The client secret is scheduled to expire on %3$s. Please generate a new token and update it in Matomo to ensure uninterrupted scheduled reports.", "MicrosoftTeamsWebhookUrlDeprecatedNoticeText": "Your Microsoft Teams report is using an outdated webhook URL. Follow this %1$sguide%2$s to create a new one.", "MicrosoftTeamsWebhookUrlDeprecatedNoticeTextCustomAlerts": "Your Microsoft Teams alert is using an outdated webhook URL. Follow this %1$sguide%2$s to create a new one." } diff --git a/plugin.json b/plugin.json index 0853265..b6c6e7f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "MicrosoftTeams", "description": "Send Matomo reports and alerts to Microsoft Team channels, keeping your team informed and ready to act in real time.", - "version": "5.0.6", + "version": "5.1.0", "theme": false, "require": { "matomo": ">=5.7.0-alpha,<6.0.0-b1" diff --git a/templates/_clientSecretExpiryNotificationHtmlEmail.twig b/templates/_clientSecretExpiryNotificationHtmlEmail.twig new file mode 100644 index 0000000..b08927b --- /dev/null +++ b/templates/_clientSecretExpiryNotificationHtmlEmail.twig @@ -0,0 +1 @@ +

{{ bodyText|e|nl2br }}

diff --git a/tests/Integration/ClientSecretExpiryNotifierTest.php b/tests/Integration/ClientSecretExpiryNotifierTest.php new file mode 100644 index 0000000..a34c288 --- /dev/null +++ b/tests/Integration/ClientSecretExpiryNotifierTest.php @@ -0,0 +1,359 @@ +> + */ + private $sentEmails = []; + + /** + * @var string + */ + private $suffix; + + public function setUp(): void + { + parent::setUp(); + + $this->suffix = substr(md5((string)microtime(true) . (string)mt_rand()), 0, 8); + FakeAccess::$identity = $this->login('root'); + FakeAccess::$superUser = true; + Config::getInstance()->General['emails_enabled'] = 1; + + \Piwik\Plugin\Manager::getInstance()->loadPlugins(['UsersManager', 'ScheduledReports', 'MicrosoftTeams']); + \Piwik\Plugin\Manager::getInstance()->installLoadedPlugins(); + Fixture::loadAllTranslations(); + + SitesManagerApi::getInstance()->addSite('Test', ['http://example.org']); + FakeAccess::setIdSitesView([1]); + FakeAccess::$superUser = true; + + $this->createUsers(); + $this->setRequiredFields(); + + Piwik::addAction('Mail.send', function (Mail $mail): void { + $this->sentEmails[] = [ + 'body' => $mail->getBodyText(), + 'html' => $mail->getBodyHtml(), + 'from' => $mail->getFrom(), + 'recipients' => array_keys($mail->getRecipients()), + 'subject' => $mail->getSubject(), + ]; + }); + } + + public function tearDown(): void + { + Date::$now = null; + ScheduledReportsApi::$cache = []; + + parent::tearDown(); + } + + /** + * @dataProvider getNoticeDays + */ + public function testShouldSendEmailsOnlyOnNoticeDays(int $daysUntilExpiry): void + { + $this->setExpiryDaysFromNow($daysUntilExpiry); + + $this->notifier()->sendNotificationsIfDue(); + + $this->assertCount(2, $this->sentEmails); + } + + public function getNoticeDays(): array + { + return [ + [28], + [21], + [14], + [7], + [0], + ]; + } + + /** + * @dataProvider getNonNoticeDays + */ + public function testShouldSkipEmailsWhenNotOnNoticeDay(int $daysUntilExpiry): void + { + $this->setExpiryDaysFromNow($daysUntilExpiry); + + $this->notifier()->sendNotificationsIfDue(); + + $this->assertSame([], $this->sentEmails); + } + + public function testShouldSkipEmailsWhenExpiryDateIsNotConfigured(): void + { + StaticContainer::get(SystemSettings::class)->clientSecretExpiryDate->setValue(''); + + $this->notifier()->sendNotificationsIfDue(); + + $this->assertSame([], $this->sentEmails); + } + + public function getNonNoticeDays(): array + { + return [ + [31], + [27], + [6], + [-1], + ]; + } + + public function testShouldSendOneEmailPerUserWithRecipientSpecificBody(): void + { + $this->setExpiryDaysFromNow(7); + $this->addTeamsReport($this->login('owner1')); + $this->addTeamsReport($this->login('owner1')); + $this->addTeamsReport($this->login('superuser1')); + $this->addEmailReport($this->login('owner2')); + + $this->notifier()->sendNotificationsIfDue(); + + $emailsByRecipient = $this->indexEmailsByRecipient(); + $this->assertSame([$this->email('owner1'), $this->email('super1'), $this->email('super2')], array_keys($emailsByRecipient)); + $this->assertSame('Final reminder: Microsoft Teams client secret expires soon', $emailsByRecipient[$this->email('super1')]['subject']); + $this->assertSame('Final reminder: Microsoft Teams integration expires soon', $emailsByRecipient[$this->email('owner1')]['subject']); + $this->assertStringContainsString('update the client secret before the expiry date', $emailsByRecipient[$this->email('super1')]['body']); + $this->assertStringContainsString('update the client secret before the expiry date', $emailsByRecipient[$this->email('super2')]['body']); + $this->assertStringContainsString('Please contact your Matomo superuser', $emailsByRecipient[$this->email('owner1')]['body']); + $this->assertStringContainsString('2025-01-08', $emailsByRecipient[$this->email('owner1')]['body']); + $this->assertStringContainsString('

', $emailsByRecipient[$this->email('owner1')]['html']); + $this->assertStringContainsString('
', $emailsByRecipient[$this->email('owner1')]['html']); + $this->assertStringContainsString('Please contact your Matomo superuser', $emailsByRecipient[$this->email('owner1')]['html']); + } + + public function testShouldSkipDisabledTeamsReportOwners(): void + { + $this->setExpiryDaysFromNow(7); + $this->addTeamsReport($this->login('owner1'), Schedule::PERIOD_NEVER); + $this->addTeamsReport($this->login('owner2')); + + $this->notifier()->sendNotificationsIfDue(); + + $emailsByRecipient = $this->indexEmailsByRecipient(); + $this->assertSame([$this->email('owner2'), $this->email('super1'), $this->email('super2')], array_keys($emailsByRecipient)); + } + + public function testHtmlEmailEscapesExpiryDatePlaceholder(): void + { + $email = new ClientSecretExpiryNotificationEmail( + $this->email('owner1'), + 'Owner', + 7, + '' + ); + + $this->assertStringContainsString('', $email->getBodyText()); + $this->assertStringNotContainsString('', $email->getBodyHtml()); + $this->assertStringContainsString('<script>alert("xss")</script>', $email->getBodyHtml()); + } + + public function testTeamsReportSenderShouldNotHaveClientSecretExpiryNoteHook(): void + { + $this->assertFalse(method_exists(ScheduleReportMicrosoftTeams::class, 'getTokenExpiryNoteIfNearExpiring')); + } + + public function testTeamsReportSenderShouldUseUploadFileWithoutAdditionalExpiryNote(): void + { + $method = new \ReflectionMethod(MicrosoftTeamsApi::class, 'uploadFile'); + $parameters = $method->getParameters(); + + $this->assertSame('additionalNote', $parameters[4]->getName()); + $this->assertTrue($parameters[4]->isDefaultValueAvailable()); + $this->assertSame('', $parameters[4]->getDefaultValue()); + } + + public function testShouldRegisterDailyClientSecretExpiryTask(): void + { + $tasks = new MicrosoftTeamsTasks($this->notifier()); + + $tasks->schedule(); + + $scheduledTasks = $tasks->getScheduledTasks(); + $this->assertCount(1, $scheduledTasks); + $this->assertSame('sendClientSecretExpiryNotifications', $scheduledTasks[0]->getMethodName()); + $this->assertNull($scheduledTasks[0]->getMethodParameter()); + $this->assertInstanceOf(Daily::class, $scheduledTasks[0]->getScheduledTime()); + } + + public function testScheduledTaskShouldSendDueNotifications(): void + { + $this->setExpiryDaysFromNow(28); + $tasks = new MicrosoftTeamsTasks($this->notifier()); + + $tasks->sendClientSecretExpiryNotifications(); + + $this->assertCount(2, $this->sentEmails); + } + + /** + * @dataProvider getNoticeSubjects + */ + public function testShouldUseNoticeSpecificSubjects( + int $daysUntilExpiry, + string $expectedSuperUserSubject, + string $expectedOwnerSubject + ): void { + $this->setExpiryDaysFromNow($daysUntilExpiry); + $this->addTeamsReport($this->login('owner1')); + + $this->notifier()->sendNotificationsIfDue(); + + $emailsByRecipient = $this->indexEmailsByRecipient(); + $this->assertSame($expectedSuperUserSubject, $emailsByRecipient[$this->email('super1')]['subject']); + $this->assertSame($expectedOwnerSubject, $emailsByRecipient[$this->email('owner1')]['subject']); + } + + public function getNoticeSubjects(): array + { + return [ + [28, 'Microsoft Teams client secret expires in 4 weeks', 'Microsoft Teams integration expires in 4 weeks'], + [21, 'Microsoft Teams client secret expires in 3 weeks', 'Reminder: Microsoft Teams integration may require update'], + [14, 'Upcoming expiry: Microsoft Teams client secret', 'Upcoming expiry: Check Microsoft Teams integration'], + [7, 'Final reminder: Microsoft Teams client secret expires soon', 'Final reminder: Microsoft Teams integration expires soon'], + [0, 'Action required: Microsoft Teams client secret has expired', 'Microsoft Teams integration has expired'], + ]; + } + + private function createUsers(): void + { + $api = UsersManagerApi::getInstance(); + $api->addUser($this->login('superuser1'), 'password1', $this->email('super1'), false); + $api->addUser($this->login('superuser2'), 'password2', $this->email('super2'), false); + $api->addUser($this->login('owner1'), 'password3', $this->email('owner1'), false); + $api->addUser($this->login('owner2'), 'password4', $this->email('owner2'), false); + + $userModel = new UsersManagerModel(); + $userModel->setSuperUserAccess($this->login('superuser1'), true); + $userModel->setSuperUserAccess($this->login('superuser2'), true); + } + + private function setRequiredFields(): void + { + $settings = StaticContainer::get(SystemSettings::class); + $settings->clientID->setValue('clientID'); + $settings->clientSecret->setValue('clientSecret'); + $settings->tenantID->setValue('tenantID'); + $settings->teamID->setValue('teamID'); + } + + private function setExpiryDaysFromNow(int $daysUntilExpiry): void + { + Date::$now = Date::factory('2025-01-01')->getTimestamp(); + StaticContainer::get(SystemSettings::class) + ->clientSecretExpiryDate + ->setValue(Date::today()->addDay($daysUntilExpiry)->toString()); + } + + private function addTeamsReport(string $login, string $period = 'day'): void + { + $this->addReport($login, $period, MicrosoftTeams::MS_TEAMS_TYPE, [ + MicrosoftTeams::MS_TEAMS_INCOMING_WEBHOOK_URL_PARAMETER => 'https://example.org/webhook', + ScheduledReports::DISPLAY_FORMAT_PARAMETER => ScheduledReports::DEFAULT_DISPLAY_FORMAT, + ScheduledReports::EVOLUTION_GRAPH_PARAMETER => ScheduledReports::EVOLUTION_GRAPH_PARAMETER_DEFAULT_VALUE, + ]); + } + + private function addEmailReport(string $login): void + { + $this->addReport($login, 'day', ScheduledReports::EMAIL_TYPE, [ + ScheduledReports::EMAIL_ME_PARAMETER => true, + ScheduledReports::ADDITIONAL_EMAILS_PARAMETER => [], + ScheduledReports::DISPLAY_FORMAT_PARAMETER => ScheduledReports::DEFAULT_DISPLAY_FORMAT, + ScheduledReports::EVOLUTION_GRAPH_PARAMETER => ScheduledReports::EVOLUTION_GRAPH_PARAMETER_DEFAULT_VALUE, + ]); + } + + private function addReport(string $login, string $period, string $type, array $parameters): void + { + FakeAccess::$identity = $login; + ScheduledReportsApi::$cache = []; + + ScheduledReportsApi::getInstance()->addReport(1, 'description', $period, 3, $type, 'pdf', [], $parameters); + + FakeAccess::$identity = $this->login('root'); + } + + /** + * @return array> + */ + private function indexEmailsByRecipient(): array + { + $emails = []; + + foreach ($this->sentEmails as $email) { + $emails[$email['recipients'][0]] = $email; + } + + ksort($emails); + + return $emails; + } + + private function notifier(): ClientSecretExpiryNotifier + { + return StaticContainer::get(ClientSecretExpiryNotifier::class); + } + + private function login(string $login): string + { + return $login . '_' . $this->suffix; + } + + private function email(string $login): string + { + return $login . '_' . $this->suffix . '@example.org'; + } + + public function provideContainerConfig(): array + { + return [ + 'Piwik\Access' => new FakeAccess(), + ]; + } +}