diff --git a/TwoFactorAuth/Model/EmailUserNotifier.php b/TwoFactorAuth/Model/EmailUserNotifier.php index c53ef6a7..aa6c0484 100644 --- a/TwoFactorAuth/Model/EmailUserNotifier.php +++ b/TwoFactorAuth/Model/EmailUserNotifier.php @@ -8,7 +8,10 @@ namespace Magento\TwoFactorAuth\Model; +use Magento\Framework\App\Area; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Store\Model\App\Emulation; +use Magento\Store\Model\Store; use Magento\Store\Model\StoreManagerInterface; use Magento\TwoFactorAuth\Model\Config\UserNotifier as UserNotifierConfig; use Magento\User\Model\User; @@ -47,25 +50,34 @@ class EmailUserNotifier implements UserNotifierInterface */ private $userNotifierConfig; + /** + * @var Emulation + */ + private $appEmulation; + /** * @param ScopeConfigInterface $scopeConfig * @param TransportBuilder $transportBuilder * @param StoreManagerInterface $storeManager * @param LoggerInterface $logger * @param UserNotifierConfig $userNotifierConfig + * @param Emulation|null $appEmulation */ public function __construct( ScopeConfigInterface $scopeConfig, TransportBuilder $transportBuilder, StoreManagerInterface $storeManager, LoggerInterface $logger, - UserNotifierConfig $userNotifierConfig + UserNotifierConfig $userNotifierConfig, + ?Emulation $appEmulation = null ) { $this->scopeConfig = $scopeConfig; $this->transportBuilder = $transportBuilder; $this->storeManager = $storeManager; $this->logger = $logger; $this->userNotifierConfig = $userNotifierConfig; + $this->appEmulation = $appEmulation + ?? \Magento\Framework\App\ObjectManager::getInstance()->get(Emulation::class); } /** @@ -103,7 +115,16 @@ private function sendConfigRequired( ) ->addTo($user->getEmail(), $user->getFirstName() . ' ' . $user->getLastName()) ->getTransport(); - $transport->sendMessage(); + // The send-suppression check in TransportInterfacePlugin reads system/smtp/disable from + // whatever store the environment currently resolves to, not from the store this email was + // rendered for above. Emulate the default/admin store so it checks the same global scope + // that this notification is actually governed by, rather than an unrelated frontend store. + $this->appEmulation->startEnvironmentEmulation(Store::DEFAULT_STORE_ID, Area::AREA_ADMINHTML); + try { + $transport->sendMessage(); + } finally { + $this->appEmulation->stopEnvironmentEmulation(); + } } catch (\Throwable $exception) { $this->logger->critical($exception); throw new NotificationException('Failed to send 2FA E-mail to a user', 0, $exception); diff --git a/TwoFactorAuth/Test/Unit/Model/EmailUserNotifierTest.php b/TwoFactorAuth/Test/Unit/Model/EmailUserNotifierTest.php new file mode 100644 index 00000000..3a950de5 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/EmailUserNotifierTest.php @@ -0,0 +1,155 @@ +scopeConfigMock = $this->createMock(ScopeConfigInterface::class); + $this->transportBuilderMock = $this->createMock(TransportBuilder::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->userNotifierConfigMock = $this->createMock(UserNotifierConfig::class); + $this->appEmulationMock = $this->createMock(Emulation::class); + $this->transportMock = $this->createMock(TransportInterface::class); + + $this->transportBuilderMock->method('setTemplateIdentifier')->willReturnSelf(); + $this->transportBuilderMock->method('setTemplateOptions')->willReturnSelf(); + $this->transportBuilderMock->method('setTemplateVars')->willReturnSelf(); + $this->transportBuilderMock->method('setFromByScope')->willReturnSelf(); + $this->transportBuilderMock->method('addTo')->willReturnSelf(); + $this->transportBuilderMock->method('getTransport')->willReturn($this->transportMock); + + $storeMock = $this->createMock(Store::class); + $storeMock->method('getFrontendName')->willReturn('Main Website Store'); + $this->storeManagerMock->method('getStore')->willReturn($storeMock); + + $this->notifier = new EmailUserNotifier( + $this->scopeConfigMock, + $this->transportBuilderMock, + $this->storeManagerMock, + $this->createMock(LoggerInterface::class), + $this->userNotifierConfigMock, + $this->appEmulationMock + ); + } + + /** + * The email is built for store 0/adminhtml, but the send-suppression plugin reads + * system/smtp/disable from whatever store the environment currently resolves to. The send call + * must happen inside default-store/adminhtml emulation so that check is scoped correctly too. + */ + public function testSendMessageIsEmulatedAsDefaultStoreAdminhtml(): void + { + $callOrder = []; + $this->appEmulationMock->expects($this->once()) + ->method('startEnvironmentEmulation') + ->with(Store::DEFAULT_STORE_ID, Area::AREA_ADMINHTML) + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'start'; + }); + $this->transportMock->expects($this->once()) + ->method('sendMessage') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'send'; + }); + $this->appEmulationMock->expects($this->once()) + ->method('stopEnvironmentEmulation') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'stop'; + }); + + $user = $this->createMock(User::class); + $user->method('getEmail')->willReturn('admin@example.com'); + $user->method('getFirstName')->willReturn('Jane'); + $user->method('getLastName')->willReturn('Doe'); + + $this->userNotifierConfigMock->method('getPersonalRequestConfigUrl')->willReturn('http://example.com/token'); + + $this->notifier->sendUserConfigRequestMessage($user, 'token'); + + self::assertSame(['start', 'send', 'stop'], $callOrder); + } + + /** + * Emulation must be torn down even if sending itself throws, otherwise the admin store context + * leaks into the rest of the request. + */ + public function testEmulationIsStoppedEvenWhenSendMessageThrows(): void + { + $this->appEmulationMock->expects($this->once())->method('startEnvironmentEmulation'); + $this->transportMock->method('sendMessage')->willThrowException(new \RuntimeException('SMTP down')); + $this->appEmulationMock->expects($this->once())->method('stopEnvironmentEmulation'); + + $user = $this->createMock(User::class); + $user->method('getEmail')->willReturn('admin@example.com'); + $user->method('getFirstName')->willReturn('Jane'); + $user->method('getLastName')->willReturn('Doe'); + + $this->userNotifierConfigMock->method('getAppRequestConfigUrl')->willReturn('http://example.com/token'); + + $this->expectException(\Magento\TwoFactorAuth\Model\Exception\NotificationException::class); + $this->notifier->sendAppConfigRequestMessage($user, 'token'); + } +}