From 805ef72aaa0fb597aa977541743b6d0e2ba2d46b Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Mon, 8 Dec 2025 12:49:25 +0100 Subject: [PATCH 01/19] IONOS(ionos-mail): update IONOS Mail API client reference to 2.0.0-20251208083401 composer update ionos-productivity/ionos-mail-configuration-api-client https://github.com/IONOS-Productivity/ionos-mail-configuration-api-client/releases/tag/2.0.0-20251208083401 Signed-off-by: Misha M.-Kupriyanov --- composer.json | 2 +- composer.lock | 4 ++-- lib/Service/IONOS/IonosMailService.php | 7 ++++--- tests/Unit/Service/IONOS/IonosMailServiceTest.php | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 90a68468f4..03c8fabdc5 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251110130214" + "reference": "2.0.0-20251208083401" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 301d6b3bf5..d99348d985 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5341c5725717dffc9990db93ad12ab21", + "content-hash": "fb553591efe3fd5dbaed693076de60c6", "packages": [ { "name": "amphp/amp", @@ -1860,7 +1860,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251110130214" + "reference": "2.0.0-20251208083401" }, "type": "library", "autoload": { diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 59279b6ee9..551d1aa1e0 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -11,6 +11,7 @@ use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; +use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; @@ -164,7 +165,7 @@ public function createEmailAccount(string $userName): MailAccountConfig { ]); throw new ServiceException('Failed to create ionos mail', $result->getStatus()); } - if ($result instanceof MailAccountResponse) { + if ($result instanceof MailAccountCreatedResponse) { $this->logger->info('Successfully created IONOS mail account', [ 'email' => $result->getEmail(), 'userId' => $userId, @@ -257,10 +258,10 @@ private function normalizeSslMode(string $apiSslMode): string { /** * Build success response with mail configuration * - * @param MailAccountResponse $response + * @param MailAccountCreatedResponse $response * @return MailAccountConfig */ - private function buildSuccessResponse(MailAccountResponse $response): MailAccountConfig { + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { $smtpServer = $response->getServer()->getSmtp(); $imapServer = $response->getServer()->getImap(); diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 75ba54a88d..35a9fac9b0 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -13,6 +13,7 @@ use GuzzleHttp\ClientInterface; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\Model\Imap; +use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailServer; @@ -106,7 +107,7 @@ public function testCreateEmailAccountSuccess(): void { $mailServer->method('getImap')->willReturn($imapServer); $mailServer->method('getSmtp')->willReturn($smtpServer); - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + $mailAccountResponse = $this->getMockBuilder(MailAccountCreatedResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); @@ -369,7 +370,7 @@ public function testSslModeNormalization(string $apiSslMode, string $expectedSec $mailServer->method('getImap')->willReturn($imapServer); $mailServer->method('getSmtp')->willReturn($smtpServer); - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + $mailAccountResponse = $this->getMockBuilder(MailAccountCreatedResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); From 3055744654fd5cdbcaf4b573656ea33815a5a9cb Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 5 Dec 2025 11:54:43 +0100 Subject: [PATCH 02/19] test(service): add unit tests for SetupService Added comprehensive unit tests for the SetupService class, covering account creation with various authentication methods, handling of connectivity tests, and validation of authentication methods. This enhances test coverage and ensures the reliability of the account setup functionality. Signed-off-by: Misha M.-Kupriyanov --- tests/Unit/Service/SetupServiceTest.php | 331 ++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 tests/Unit/Service/SetupServiceTest.php diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php new file mode 100644 index 0000000000..d687a399ee --- /dev/null +++ b/tests/Unit/Service/SetupServiceTest.php @@ -0,0 +1,331 @@ +accountService = $this->createMock(AccountService::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); + $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->tagMapper = $this->createMock(TagMapper::class); + + $this->setupService = new SetupService( + $this->accountService, + $this->crypto, + $this->smtpClientFactory, + $this->imapClientFactory, + $this->logger, + $this->tagMapper + ); + } + + private function mockSuccessfulImapConnection(): Horde_Imap_Client_Socket&MockObject { + $imapClient = $this->createMock(Horde_Imap_Client_Socket::class); + $imapClient->expects(self::once())->method('login'); + $imapClient->expects(self::once())->method('logout'); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->willReturn($imapClient); + + return $imapClient; + } + + private function mockSuccessfulSmtpConnection(): Horde_Mail_Transport_Smtphorde&MockObject { + $smtpTransport = $this->createMock(Horde_Mail_Transport_Smtphorde::class); + $smtpTransport->expects(self::once())->method('getSMTPObject'); + + $this->smtpClientFactory->expects(self::once()) + ->method('create') + ->willReturn($smtpTransport); + + return $smtpTransport; + } + + private function mockPasswordEncryption(): void { + $this->crypto->expects(self::exactly(2)) + ->method('encrypt') + ->willReturnOnConsecutiveCalls('encrypted-imap-password', 'encrypted-smtp-password'); + } + + private function assertAccountPropertiesMatch( + MailAccount $account, + string $accountName, + string $emailAddress, + string $imapHost, + int $imapPort, + string $imapSslMode, + string $imapUser, + string $smtpHost, + int $smtpPort, + string $smtpSslMode, + string $smtpUser, + string $uid, + string $authMethod, + ): void { + self::assertSame($accountName, $account->getName(), 'Account name does not match'); + self::assertSame($emailAddress, $account->getEmail(), 'Email address does not match'); + self::assertSame($imapHost, $account->getInboundHost(), 'IMAP host does not match'); + self::assertSame($imapPort, $account->getInboundPort(), 'IMAP port does not match'); + self::assertSame($imapSslMode, $account->getInboundSslMode(), 'IMAP SSL mode does not match'); + self::assertSame($imapUser, $account->getInboundUser(), 'IMAP user does not match'); + self::assertSame($smtpHost, $account->getOutboundHost(), 'SMTP host does not match'); + self::assertSame($smtpPort, $account->getOutboundPort(), 'SMTP port does not match'); + self::assertSame($smtpSslMode, $account->getOutboundSslMode(), 'SMTP SSL mode does not match'); + self::assertSame($smtpUser, $account->getOutboundUser(), 'SMTP user does not match'); + self::assertSame($uid, $account->getUserId(), 'User ID does not match'); + self::assertSame($authMethod, $account->getAuthMethod(), 'Auth method does not match'); + } + + public function testCreateNewAccountWithPasswordAuth(): void { + $this->mockPasswordEncryption(); + + $this->logger->expects(self::once()) + ->method('info') + ->with('Setting up manually configured account'); + + $debugCalls = []; + $this->logger->expects(self::exactly(2)) + ->method('debug') + ->willReturnCallback(function (string $message, array $context = []) use (&$debugCalls): void { + $debugCalls[] = ['message' => $message, 'context' => $context]; + }); + + $this->mockSuccessfulImapConnection(); + $this->mockSuccessfulSmtpConnection(); + + $this->accountService->expects(self::once()) + ->method('save') + ->with(self::callback(function (MailAccount $account): bool { + $this->assertAccountPropertiesMatch( + $account, + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + return true; + })); + + $this->tagMapper->expects(self::once()) + ->method('createDefaultTags') + ->with(self::isInstanceOf(MailAccount::class)); + + $result = $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + + self::assertInstanceOf(Account::class, $result); + + // Verify debug log calls + self::assertCount(2, $debugCalls); + self::assertSame('Connecting to account {account}', $debugCalls[0]['message']); + self::assertSame(['account' => self::EMAIL_ADDRESS], $debugCalls[0]['context']); + self::assertStringContainsString('account created ', $debugCalls[1]['message']); + self::assertSame([], $debugCalls[1]['context']); + } + + public function testCreateNewAccountWithOAuth2(): void { + $this->crypto->expects(self::never())->method('encrypt'); + + $this->logger->expects(self::once()) + ->method('info') + ->with('Setting up manually configured account'); + $this->logger->expects(self::once()) + ->method('debug') + ->with(self::stringContains('account created ')); + + $this->imapClientFactory->expects(self::never())->method('getClient'); + $this->smtpClientFactory->expects(self::never())->method('create'); + + $this->accountService->expects(self::once()) + ->method('save') + ->with(self::callback(function (MailAccount $account): bool { + return $account->getAuthMethod() === self::AUTH_METHOD_OAUTH2; + })); + + $this->tagMapper->expects(self::once())->method('createDefaultTags'); + + $result = $this->setupService->createNewAccount( + 'OAuth2 Account', + 'oauth@example.com', + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + 'oauth@example.com', + null, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + 'oauth@example.com', + null, + 'user456', + self::AUTH_METHOD_OAUTH2 + ); + + self::assertInstanceOf(Account::class, $result); + } + + public function testCreateNewAccountWithInvalidAuthMethod(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid auth method invalid'); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + 'invalid' + ); + } + + public function testCreateNewAccountImapConnectionFailure(): void { + $this->expectException(CouldNotConnectException::class); + + $this->mockPasswordEncryption(); + + $imapClient = $this->createMock(Horde_Imap_Client_Socket::class); + $imapClient->expects(self::once()) + ->method('login') + ->willThrowException(new Horde_Imap_Client_Exception('Connection failed')); + $imapClient->expects(self::once()) + ->method('logout'); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->willReturn($imapClient); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + } + + public function testCreateNewAccountSmtpConnectionFailure(): void { + $this->expectException(CouldNotConnectException::class); + + $this->mockPasswordEncryption(); + $this->mockSuccessfulImapConnection(); + + $smtpTransport = $this->createMock(Horde_Mail_Transport_Smtphorde::class); + $smtpTransport->expects(self::once()) + ->method('getSMTPObject') + ->willThrowException(new Horde_Mail_Exception('SMTP connection failed')); + + $this->smtpClientFactory->expects(self::once()) + ->method('create') + ->willReturn($smtpTransport); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + } +} From 7b628e6ef99b48e673787957e8e77c7b5e1d7c97 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Thu, 4 Dec 2025 18:24:04 +0100 Subject: [PATCH 03/19] IONOS(ionos-mail): SetupService: add skipConnectivityTest parameter to createNewAccount Add optional parameter to allow skipping IMAP/SMTP connectivity tests during account creation. This is useful when account credentials are already validated by external systems or when immediate connectivity cannot be guaranteed due to DNS propagation delays. The parameter defaults to false to maintain backward compatibility. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/SetupService.php | 5 ++- tests/Unit/Service/SetupServiceTest.php | 55 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index 40df87afd9..cc07821f7b 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -77,7 +77,8 @@ public function createNewAccount(string $accountName, ?string $smtpPassword, string $uid, string $authMethod, - ?int $accountId = null): Account { + ?int $accountId = null, + bool $skipConnectivityTest = false): Account { $this->logger->info('Setting up manually configured account'); $newAccount = new MailAccount([ 'accountId' => $accountId, @@ -105,7 +106,7 @@ public function createNewAccount(string $accountName, $newAccount->setAuthMethod($authMethod); $account = new Account($newAccount); - if ($authMethod === 'password' && $imapPassword !== null) { + if (!$skipConnectivityTest && $authMethod === 'password' && $imapPassword !== null) { $this->logger->debug('Connecting to account {account}', ['account' => $newAccount->getEmail()]); $this->testConnectivity($account); } diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php index d687a399ee..8637b2c1ff 100644 --- a/tests/Unit/Service/SetupServiceTest.php +++ b/tests/Unit/Service/SetupServiceTest.php @@ -240,6 +240,61 @@ public function testCreateNewAccountWithOAuth2(): void { self::assertInstanceOf(Account::class, $result); } + public function testCreateNewAccountWithSkipConnectivityTest(): void { + $accountName = 'Skip Test Account'; + $emailAddress = 'skip@example.com'; + $imapHost = 'imap.example.com'; + $imapPort = 993; + $imapSslMode = 'ssl'; + $imapUser = 'skip@example.com'; + $imapPassword = 'password'; + $smtpHost = 'smtp.example.com'; + $smtpPort = 465; + $smtpSslMode = 'ssl'; + $smtpUser = 'skip@example.com'; + $smtpPassword = 'password'; + $uid = 'user789'; + $authMethod = 'password'; + $skipConnectivityTest = true; + + $this->crypto->expects(self::exactly(2)) + ->method('encrypt') + ->willReturnOnConsecutiveCalls('encrypted1', 'encrypted2'); + + $this->imapClientFactory->expects(self::never()) + ->method('getClient'); + + $this->smtpClientFactory->expects(self::never()) + ->method('create'); + + $this->accountService->expects(self::once()) + ->method('save'); + + $this->tagMapper->expects(self::once()) + ->method('createDefaultTags'); + + $result = $this->setupService->createNewAccount( + $accountName, + $emailAddress, + $imapHost, + $imapPort, + $imapSslMode, + $imapUser, + $imapPassword, + $smtpHost, + $smtpPort, + $smtpSslMode, + $smtpUser, + $smtpPassword, + $uid, + $authMethod, + null, + $skipConnectivityTest + ); + + self::assertInstanceOf(Account::class, $result); + } + public function testCreateNewAccountWithInvalidAuthMethod(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid auth method invalid'); From 0b28d3ea54d223bd88a363e702f552e1422c9596 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Thu, 4 Dec 2025 18:36:36 +0100 Subject: [PATCH 04/19] IONOS(ionos-mail): AccountsController: add skipConnectivityTest parameter to create method Add optional parameter to AccountsController::create() to allow skipping IMAP/SMTP connectivity tests during account creation. This parameter is passed through to SetupService::createNewAccount(). This enables external systems (e.g., IONOS API) that have already validated credentials to skip redundant connectivity tests, particularly useful when DNS propagation delays might cause immediate connectivity tests to fail. The parameter defaults to false to maintain backward compatibility. --- lib/Controller/AccountsController.php | 5 +-- .../Controller/AccountsControllerTest.php | 33 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index ea7c02de14..e2e2552564 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -346,7 +346,8 @@ public function create(string $accountName, ?string $smtpSslMode = null, ?string $smtpUser = null, ?string $smtpPassword = null, - string $authMethod = 'password'): JSONResponse { + string $authMethod = 'password', + bool $skipConnectivityTest = false): JSONResponse { if ($this->config->getAppValue(Application::APP_ID, 'allow_new_mail_accounts', 'yes') === 'no') { $this->logger->info('Creating account disabled by admin.'); return MailJsonResponse::error('Could not create account'); @@ -378,7 +379,7 @@ public function create(string $accountName, ); } try { - $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId, $authMethod); + $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId, $authMethod, null, $skipConnectivityTest); } catch (CouldNotConnectException $e) { $data = [ 'error' => $e->getReason(), diff --git a/tests/Unit/Controller/AccountsControllerTest.php b/tests/Unit/Controller/AccountsControllerTest.php index c37af7da55..4f311bc1c7 100644 --- a/tests/Unit/Controller/AccountsControllerTest.php +++ b/tests/Unit/Controller/AccountsControllerTest.php @@ -236,7 +236,7 @@ public function testCreateManualSuccess(): void { $account = $this->createMock(Account::class); $this->setupService->expects(self::once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password') + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password', null, false) ->willReturn($account); $response = $this->controller->create($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword); @@ -246,6 +246,35 @@ public function testCreateManualSuccess(): void { self::assertEquals($expectedResponse, $response); } + public function testCreateManualSuccessWithSkipConnectivityTest(): void { + $this->config->expects(self::once()) + ->method('getAppValue') + ->willReturn('yes'); + $email = 'user@domain.tld'; + $accountName = 'Mail'; + $imapHost = 'localhost'; + $imapPort = 993; + $imapSslMode = 'ssl'; + $imapUser = 'user@domain.tld'; + $imapPassword = 'mypassword'; + $smtpHost = 'localhost'; + $smtpPort = 465; + $smtpSslMode = 'none'; + $smtpUser = 'user@domain.tld'; + $smtpPassword = 'mypassword'; + $account = $this->createMock(Account::class); + $this->setupService->expects(self::once()) + ->method('createNewAccount') + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password', null, true) + ->willReturn($account); + + $response = $this->controller->create($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, 'password', true); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::success($account, Http::STATUS_CREATED); + + self::assertEquals($expectedResponse, $response); + } + public function testCreateManualNotAllowed(): void { $email = 'user@domain.tld'; $accountName = 'Mail'; @@ -289,7 +318,7 @@ public function testCreateManualFailure(): void { $smtpPassword = 'mypassword'; $this->setupService->expects(self::once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password') + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password', null, false) ->willThrowException(new ClientException()); $this->expectException(ClientException::class); From 7ded9da942cc5127d5a46e93c72f26bb8f62b9c7 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 5 Dec 2025 13:29:36 +0100 Subject: [PATCH 05/19] IONOS(ionos-mail): refactor IonosMailServiceTest with consistent test data and improved mock setups Refactor test methods to utilize constants for user and email details, ensuring consistency and readability. This change improves maintainability and reduces duplication across test cases. Signed-off-by: Misha M.-Kupriyanov --- .../Service/IONOS/IonosMailServiceTest.php | 776 ++++++------------ 1 file changed, 251 insertions(+), 525 deletions(-) diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 35a9fac9b0..ea9baeaeef 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -29,6 +29,20 @@ use Psr\Log\LoggerInterface; class IonosMailServiceTest extends TestCase { + private const TEST_USER_ID = 'testuser123'; + private const TEST_USER_NAME = 'test'; + private const TEST_DOMAIN = 'example.com'; + private const TEST_EMAIL = self::TEST_USER_NAME . '@' . self::TEST_DOMAIN; + private const TEST_PASSWORD = 'test-password'; + private const TEST_EXT_REF = 'test-ext-ref'; + private const TEST_API_BASE_URL = 'https://api.example.com'; + private const TEST_BASIC_AUTH_USER = 'testuser'; + private const TEST_BASIC_AUTH_PASSWORD = 'testpass'; + private const IMAP_HOST = 'imap.example.com'; + private const IMAP_PORT = 993; + private const SMTP_HOST = 'smtp.example.com'; + private const SMTP_PORT = 587; + private ApiMailConfigClientService&MockObject $apiClientService; private IonosConfigService&MockObject $configService; private IUserSession&MockObject $userSession; @@ -51,54 +65,102 @@ protected function setUp(): void { ); } - public function testCreateEmailAccountSuccess(): void { - $userName = 'test'; - $domain = 'example.com'; - $emailAddress = $userName . '@' . $domain; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session + /** + * Setup standard config mocks with default values + */ + private function setupConfigMocks( + string $externalReference = self::TEST_EXT_REF, + string $apiBaseUrl = self::TEST_API_BASE_URL, + bool $allowInsecure = false, + string $basicAuthUser = self::TEST_BASIC_AUTH_USER, + string $basicAuthPassword = self::TEST_BASIC_AUTH_PASSWORD, + string $mailDomain = self::TEST_DOMAIN, + ): void { + $this->configService->method('getExternalReference')->willReturn($externalReference); + $this->configService->method('getApiBaseUrl')->willReturn($apiBaseUrl); + $this->configService->method('getAllowInsecure')->willReturn($allowInsecure); + $this->configService->method('getBasicAuthUser')->willReturn($basicAuthUser); + $this->configService->method('getBasicAuthPassword')->willReturn($basicAuthPassword); + $this->configService->method('getMailDomain')->willReturn($mailDomain); + } + + /** + * Setup user session with mock user + */ + private function setupUserSession(string $userId): IUser&MockObject { $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); + $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); + return $user; + } - // Mock API client + /** + * Setup API client mocks and return API instance + */ + private function setupApiClient(bool $verifySSL = true): MailConfigurationAPIApi&MockObject { $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], + 'verify' => $verifySSL, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API response - use getMockBuilder with onlyMethods for existing methods + return $apiInstance; + } + + /** + * Create a mock IMAP server + */ + private function createMockImapServer( + string $host = self::IMAP_HOST, + int $port = self::IMAP_PORT, + string $sslMode = 'ssl', + ): Imap&MockObject { $imapServer = $this->getMockBuilder(Imap::class) ->disableOriginalConstructor() ->onlyMethods(['getHost', 'getPort', 'getSslMode']) ->getMock(); - $imapServer->method('getHost')->willReturn('imap.example.com'); - $imapServer->method('getPort')->willReturn(993); - $imapServer->method('getSslMode')->willReturn('ssl'); + $imapServer->method('getHost')->willReturn($host); + $imapServer->method('getPort')->willReturn($port); + $imapServer->method('getSslMode')->willReturn($sslMode); + return $imapServer; + } + /** + * Create a mock SMTP server + */ + private function createMockSmtpServer( + string $host = self::SMTP_HOST, + int $port = self::SMTP_PORT, + string $sslMode = 'tls', + ): Smtp&MockObject { $smtpServer = $this->getMockBuilder(Smtp::class) ->disableOriginalConstructor() ->onlyMethods(['getHost', 'getPort', 'getSslMode']) ->getMock(); - $smtpServer->method('getHost')->willReturn('smtp.example.com'); - $smtpServer->method('getPort')->willReturn(587); - $smtpServer->method('getSslMode')->willReturn('tls'); + $smtpServer->method('getHost')->willReturn($host); + $smtpServer->method('getPort')->willReturn($port); + $smtpServer->method('getSslMode')->willReturn($sslMode); + return $smtpServer; + } + + /** + * Create a mock MailAccountResponse + */ + private function createMockMailAccountResponse( + string $email = self::TEST_EMAIL, + string $password = self::TEST_PASSWORD, + ?string $imapSslMode = 'ssl', + ?string $smtpSslMode = 'tls', + ): MailAccountCreatedResponse&MockObject { + $imapServer = $this->createMockImapServer(self::IMAP_HOST, self::IMAP_PORT, $imapSslMode); + $smtpServer = $this->createMockSmtpServer(self::SMTP_HOST, self::SMTP_PORT, $smtpSslMode); $mailServer = $this->getMockBuilder(MailServer::class) ->disableOriginalConstructor() @@ -111,112 +173,85 @@ public function testCreateEmailAccountSuccess(): void { ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); + $mailAccountResponse->method('getEmail')->willReturn($email); + $mailAccountResponse->method('getPassword')->willReturn($password); $mailAccountResponse->method('getServer')->willReturn($mailServer); - $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + return $mailAccountResponse; + } - // Expect logging calls - $this->logger->expects($this->exactly(4)) - ->method('debug'); + public function testCreateEmailAccountSuccess(): void { + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); + $apiInstance = $this->setupApiClient(); + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $this->logger->expects($this->exactly(4))->method('debug'); $this->logger->expects($this->once()) ->method('info') - ->with('Successfully created IONOS mail account', $this->callback(function ($context) use ($emailAddress) { - return $context['email'] === $emailAddress - && $context['userId'] === 'testuser123' - && $context['userName'] === 'test'; + ->with('Successfully created IONOS mail account', $this->callback(function ($context) { + return $context['email'] === self::TEST_EMAIL + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); - $result = $this->service->createEmailAccount($userName); + $result = $this->service->createEmailAccount(self::TEST_USER_NAME); $this->assertInstanceOf(MailAccountConfig::class, $result); - $this->assertEquals($emailAddress, $result->getEmail()); - $this->assertEquals('imap.example.com', $result->getImap()->getHost()); - $this->assertEquals(993, $result->getImap()->getPort()); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); $this->assertEquals('ssl', $result->getImap()->getSecurity()); - $this->assertEquals($emailAddress, $result->getImap()->getUsername()); - $this->assertEquals('test-password', $result->getImap()->getPassword()); - $this->assertEquals('smtp.example.com', $result->getSmtp()->getHost()); - $this->assertEquals(587, $result->getSmtp()->getPort()); + $this->assertEquals(self::TEST_EMAIL, $result->getImap()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); $this->assertEquals('tls', $result->getSmtp()->getSecurity()); - $this->assertEquals($emailAddress, $result->getSmtp()->getUsername()); - $this->assertEquals('test-password', $result->getSmtp()->getPassword()); + $this->assertEquals(self::TEST_EMAIL, $result->getSmtp()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getSmtp()->getPassword()); } public function testCreateEmailAccountWithApiException(): void { - $userName = 'test'; - $domain = 'example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw exception $apiInstance->method('createMailbox') ->willThrowException(new \Exception('API call failed')); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->callback(function ($context) use ($userName) { + ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(500); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): void { - $userName = 'test'; - $domain = 'example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock MailAddonErrorMessage response $errorMessage = $this->getMockBuilder(MailAddonErrorMessage::class) ->disableOriginalConstructor() ->onlyMethods(['getStatus', 'getMessage']) @@ -226,86 +261,55 @@ public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): void $apiInstance->method('createMailbox')->willReturn($errorMessage); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Failed to create ionos mail', $this->callback(function ($context) use ($userName) { + ->with('Failed to create ionos mail', $this->callback(function ($context) { return $context['status code'] === MailAddonErrorMessage::STATUS__400_BAD_REQUEST && $context['message'] === 'Bad Request' - && $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(400); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithUnknownResponseType(): void { - $userName = 'test'; - $domain = 'example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock unknown response type (return a stdClass instead of expected types) $unknownResponse = new \stdClass(); $apiInstance->method('createMailbox')->willReturn($unknownResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Failed to create ionos mail: Unknown response type', $this->callback(function ($context) use ($userName) { - return $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + ->with('Failed to create ionos mail: Unknown response type', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(500); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithNoUserSession(): void { - $userName = 'test'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock no user session + $this->setupConfigMocks(); $this->userSession->method('getUser')->willReturn(null); - // Expect logging call $this->logger->expects($this->once()) ->method('error') ->with('No user session found when attempting to create IONOS mail account'); @@ -313,7 +317,7 @@ public function testCreateEmailAccountWithNoUserSession(): void { $this->expectException(ServiceException::class); $this->expectExceptionMessage('No user session found'); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } /** @@ -322,65 +326,20 @@ public function testCreateEmailAccountWithNoUserSession(): void { * @dataProvider sslModeNormalizationProvider */ public function testSslModeNormalization(string $apiSslMode, string $expectedSecurity): void { - $userName = 'test'; - $domain = 'example.com'; - $emailAddress = $userName . '@' . $domain; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); - - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient')->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - - // Mock API response with specific SSL mode - $imapServer = $this->getMockBuilder(Imap::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHost', 'getPort', 'getSslMode']) - ->getMock(); - $imapServer->method('getHost')->willReturn('imap.example.com'); - $imapServer->method('getPort')->willReturn(993); - $imapServer->method('getSslMode')->willReturn($apiSslMode); - - $smtpServer = $this->getMockBuilder(Smtp::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHost', 'getPort', 'getSslMode']) - ->getMock(); - $smtpServer->method('getHost')->willReturn('smtp.example.com'); - $smtpServer->method('getPort')->willReturn(587); - $smtpServer->method('getSslMode')->willReturn($apiSslMode); - - $mailServer = $this->getMockBuilder(MailServer::class) - ->disableOriginalConstructor() - ->onlyMethods(['getImap', 'getSmtp']) - ->getMock(); - $mailServer->method('getImap')->willReturn($imapServer); - $mailServer->method('getSmtp')->willReturn($smtpServer); - - $mailAccountResponse = $this->getMockBuilder(MailAccountCreatedResponse::class) - ->disableOriginalConstructor() - ->onlyMethods(['getEmail', 'getPassword', 'getServer']) - ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); - $mailAccountResponse->method('getServer')->willReturn($mailServer); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); + $apiInstance = $this->setupApiClient(); + + $mailAccountResponse = $this->createMockMailAccountResponse( + self::TEST_EMAIL, + self::TEST_PASSWORD, + $apiSslMode, + $apiSslMode + ); $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); - $result = $this->service->createEmailAccount($userName); + $result = $this->service->createEmailAccount(self::TEST_USER_NAME); $this->assertEquals($expectedSecurity, $result->getImap()->getSecurity()); $this->assertEquals($expectedSecurity, $result->getSmtp()->getSecurity()); @@ -433,39 +392,26 @@ public static function sslModeNormalizationProvider(): array { } public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn('testuser@example.com'); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willReturn($mailAccountResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -473,26 +419,15 @@ public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists( } public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -501,12 +436,10 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -514,26 +447,15 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { } public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -542,18 +464,15 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error'; })); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -562,39 +481,25 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi } public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralException(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException(new \Exception('Unexpected error')); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('Exception when getting IONOS mail account', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123'; + && $context['userId'] === self::TEST_USER_ID; })); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -604,73 +509,42 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralExceptio public function testDeleteEmailAccountSuccess(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion (returns void) $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Expect logging calls $callCount = 0; $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId, &$callCount) { + ->willReturnCallback(function ($message, $context) use (&$callCount) { $callCount++; if ($callCount === 1) { $this->assertEquals('Attempting to delete IONOS email account', $message); - $this->assertEquals($userId, $context['userId']); - $this->assertEquals('test-ext-ref', $context['extRef']); + $this->assertEquals(self::TEST_USER_ID, $context['userId']); + $this->assertEquals(self::TEST_EXT_REF, $context['extRef']); } elseif ($callCount === 2) { $this->assertEquals('Successfully deleted IONOS email account', $message); - $this->assertEquals($userId, $context['userId']); + $this->assertEquals(self::TEST_USER_ID, $context['userId']); } }); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { - $userId = 'testuser123'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception (mailbox doesn't exist) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -680,48 +554,37 @@ public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('debug') - ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId + ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID && $context['statusCode'] === 404; })); - // Should return true for 404 (treat as success) - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -731,295 +594,206 @@ public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('error') - ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) { return $context['statusCode'] === 500 && $context['message'] === 'Internal Server Error' - && $context['userId'] === $userId; + && $context['userId'] === self::TEST_USER_ID; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to delete IONOS mail: Internal Server Error'); $this->expectExceptionCode(500); - $this->service->deleteEmailAccount($userId); + $this->service->deleteEmailAccount(self::TEST_USER_ID); } public function testDeleteEmailAccountThrowsExceptionOnGeneralError(): void { - $userId = 'testuser123'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $generalException = new \Exception('Unexpected error'); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($generalException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('error') - ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === $userId; + && $context['userId'] === self::TEST_USER_ID; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to delete IONOS mail'); $this->expectExceptionCode(500); - $this->service->deleteEmailAccount($userId); + $this->service->deleteEmailAccount(self::TEST_USER_ID); } public function testDeleteEmailAccountWithInsecureConnection(): void { - $userId = 'testuser123'; - - // Mock config with insecure connection allowed - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(true); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client - verify should be false - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => false, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); + $this->setupConfigMocks(allowInsecure: true); + $apiInstance = $this->setupApiClient(verifySSL: false); - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - $this->logger->expects($this->exactly(2)) - ->method('info'); + $this->logger->expects($this->exactly(2))->method('info'); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testTryDeleteEmailAccountWhenIntegrationDisabled(): void { - $userId = 'testuser123'; - - // Mock integration as disabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(false); - // Should log that integration is not enabled $this->logger->expects($this->once()) ->method('debug') ->with( 'IONOS integration is not enabled, skipping email account deletion', - ['userId' => $userId] + ['userId' => self::TEST_USER_ID] ); - // Should not attempt to create API client - $this->apiClientService->expects($this->never()) - ->method('newClient'); + $this->apiClientService->expects($this->never())->method('newClient'); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledSuccess(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Should log success at info level (from deleteEmailAccount only) $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'Attempting to delete IONOS email account') { - $this->assertSame($userId, $context['userId']); - $this->assertSame('test-ext-ref', $context['extRef']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); + $this->assertSame(self::TEST_EXT_REF, $context['extRef']); } elseif ($message === 'Successfully deleted IONOS email account') { - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); } }); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledButDeletionFails(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('API Error', 500); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log the error from deleteEmailAccount and then from tryDeleteEmailAccount $this->logger->expects($this->exactly(2)) ->method('error') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'API Exception when calling MailConfigurationAPIApi->deleteMailbox') { - // This is from deleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertSame(500, $context['statusCode']); } elseif ($message === 'Failed to delete IONOS mailbox for user') { - // This is from tryDeleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertInstanceOf(ServiceException::class, $context['exception']); } }); - // Call tryDeleteEmailAccount - should NOT throw exception (fire and forget) - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock 404 API exception (mailbox already deleted or never existed) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('Not Found', 404); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log at info level (from deleteEmailAccount) and debug (404 is treated as success) $this->logger->expects($this->once()) ->method('info') ->with( 'Attempting to delete IONOS email account', [ - 'userId' => $userId, - 'extRef' => 'test-ext-ref', + 'userId' => self::TEST_USER_ID, + 'extRef' => self::TEST_EXT_REF, ] ); @@ -1028,73 +802,51 @@ public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { ->with( 'IONOS mailbox does not exist (already deleted or never created)', [ - 'userId' => $userId, + 'userId' => self::TEST_USER_ID, 'statusCode' => 404 ] ); - // Call tryDeleteEmailAccount - should NOT throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testGetIonosEmailForUserReturnsEmailWhenAccountExists(): void { - $userId = 'testuser123'; - $expectedEmail = 'testuser@example.com'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($expectedEmail); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willReturn($mailAccountResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); - $this->assertEquals($expectedEmail, $result); + $this->assertEquals(self::TEST_EMAIL, $result); } public function testGetIonosEmailForUserReturnsNullWhen404(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -1103,36 +855,25 @@ public function testGetIonosEmailForUserReturnsNullWhen404(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } public function testGetIonosEmailForUserReturnsNullOnApiError(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -1141,59 +882,44 @@ public function testGetIonosEmailForUserReturnsNullOnApiError(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error'; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } public function testGetIonosEmailForUserReturnsNullOnGeneralException(): void { - $userId = 'testuser123'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException(new \Exception('Unexpected error')); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('Exception when getting IONOS mail account', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123'; + && $context['userId'] === self::TEST_USER_ID; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } From 8e927678661aaaea8dff6dabe5e20338d820a9eb Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Thu, 4 Dec 2025 17:03:45 +0100 Subject: [PATCH 06/19] IONOS(ionos-mail): IonosMailService: add method to create email account for specific user This change introduces a new method to create an IONOS email account for a specified user, allowing for account creation without relying on the user session. This is particularly useful for OCC commands or administrative operations. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailService.php | 22 ++++++++++-- .../Service/IONOS/IonosMailServiceTest.php | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 551d1aa1e0..fe8b3a84fb 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -120,21 +120,39 @@ private function getMailAccountResponse(string $userId): ?MailAccountResponse { } /** - * Create an IONOS email account via API + * Create an IONOS email account via API for the current logged-in user * + * @param string $userName The local part of the email address (before @domain) * @return MailAccountConfig Mail account configuration * @throws ServiceException * @throws AppConfigException */ public function createEmailAccount(string $userName): MailAccountConfig { $userId = $this->getCurrentUserId(); + return $this->createEmailAccountForUser($userId, $userName); + } + + /** + * Create an IONOS email account via API for a specific user + * + * This method allows creating email accounts without relying on the user session, + * making it suitable for use in OCC commands or admin operations. + * + * @param string $userId The Nextcloud user ID + * @param string $userName The local part of the email address (before @domain) + * @return MailAccountConfig Mail account configuration + * @throws ServiceException + * @throws AppConfigException + */ + public function createEmailAccountForUser(string $userId, string $userName): MailAccountConfig { $domain = $this->configService->getMailDomain(); $this->logger->debug('Sending request to mailconfig service', [ 'extRef' => $this->configService->getExternalReference(), 'userName' => $userName, 'domain' => $domain, - 'apiBaseUrl' => $this->configService->getApiBaseUrl() + 'apiBaseUrl' => $this->configService->getApiBaseUrl(), + 'userId' => $userId ]); $apiInstance = $this->createApiInstance(); diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index ea9baeaeef..e6d1a433e8 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -320,6 +320,41 @@ public function testCreateEmailAccountWithNoUserSession(): void { $this->service->createEmailAccount(self::TEST_USER_NAME); } + public function testCreateEmailAccountForUserSuccess(): void { + $userId = 'admin123'; + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + + // No user session needed for this method + + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $this->logger->expects($this->exactly(4))->method('debug'); + $this->logger->expects($this->once()) + ->method('info') + ->with('Successfully created IONOS mail account', $this->callback(function ($context) use ($userId) { + return $context['email'] === self::TEST_EMAIL + && $context['userId'] === $userId + && $context['userName'] === self::TEST_USER_NAME; + })); + + $result = $this->service->createEmailAccountForUser($userId, self::TEST_USER_NAME); + + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + $this->assertEquals(self::TEST_EMAIL, $result->getImap()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals(self::TEST_EMAIL, $result->getSmtp()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getSmtp()->getPassword()); + } + /** * Test SSL mode normalization with various API response values * From 4b0f456fe67f4052b93b4f29b68ef5ad5cddab81 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:46:01 +0100 Subject: [PATCH 07/19] IONOS(ionos-mail): feat(dto): add withPassword method to MailServerConfig Add immutable withPassword method to create new MailServerConfig instances with updated passwords while preserving other configuration values. This supports password reset scenarios where we need to update credentials without modifying the original configuration object. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/Dto/MailServerConfig.php | 16 ++++++++++++++++ .../IONOS/Dto/MailServerConfigTest.php | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/Service/IONOS/Dto/MailServerConfig.php b/lib/Service/IONOS/Dto/MailServerConfig.php index 2e06d9073d..27b5cb7aed 100644 --- a/lib/Service/IONOS/Dto/MailServerConfig.php +++ b/lib/Service/IONOS/Dto/MailServerConfig.php @@ -42,6 +42,22 @@ public function getPassword(): string { return $this->password; } + /** + * Create a new instance with a different password + * + * @param string $newPassword The new password to use + * @return self New instance with updated password + */ + public function withPassword(string $newPassword): self { + return new self( + host: $this->host, + port: $this->port, + security: $this->security, + username: $this->username, + password: $newPassword, + ); + } + /** * Convert to array format for backwards compatibility * diff --git a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php b/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php index 9c45508352..a5a9b6625b 100644 --- a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php +++ b/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php @@ -165,4 +165,23 @@ public function testDifferentSecurityTypes(): void { $this->assertEquals('tls', $tlsConfig->getSecurity()); $this->assertEquals('none', $noneConfig->getSecurity()); } + + public function testWithPassword(): void { + $newPassword = 'new-secure-password'; + + // Create a new config with updated password + $updatedConfig = $this->config->withPassword($newPassword); + + // Original config should remain unchanged (immutable) + $this->assertEquals('secret123', $this->config->getPassword()); + + // New config should have the new password + $this->assertEquals($newPassword, $updatedConfig->getPassword()); + + // Other properties should remain the same + $this->assertEquals($this->config->getHost(), $updatedConfig->getHost()); + $this->assertEquals($this->config->getPort(), $updatedConfig->getPort()); + $this->assertEquals($this->config->getSecurity(), $updatedConfig->getSecurity()); + $this->assertEquals($this->config->getUsername(), $updatedConfig->getUsername()); + } } From 98c13e2452d678bf74daef60fb3dbfb9204e8a10 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:49:15 +0100 Subject: [PATCH 08/19] IONOS(ionos-mail): feat(dto): add withPassword method to MailAccountConfig Add immutable withPassword method to create new MailAccountConfig instances with updated passwords for both IMAP and SMTP configurations. This leverages the MailServerConfig.withPassword method added previously. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/Dto/MailAccountConfig.php | 14 ++++++++++ .../IONOS/Dto/MailAccountConfigTest.php | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/Service/IONOS/Dto/MailAccountConfig.php b/lib/Service/IONOS/Dto/MailAccountConfig.php index 2a4c3a5fa5..3ac69a446f 100644 --- a/lib/Service/IONOS/Dto/MailAccountConfig.php +++ b/lib/Service/IONOS/Dto/MailAccountConfig.php @@ -32,6 +32,20 @@ public function getSmtp(): MailServerConfig { return $this->smtp; } + /** + * Create a new instance with updated passwords for both IMAP and SMTP + * + * @param string $newPassword The new password to use + * @return self New instance with updated passwords + */ + public function withPassword(string $newPassword): self { + return new self( + email: $this->email, + imap: $this->imap->withPassword($newPassword), + smtp: $this->smtp->withPassword($newPassword), + ); + } + /** * Convert to array format for backwards compatibility * diff --git a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php b/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php index 377e7c59ff..5b7418a70d 100644 --- a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php +++ b/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php @@ -245,4 +245,30 @@ public function testNestedObjectAccess(): void { $this->assertEquals('imap.example.com', $imapHost); $this->assertEquals('smtp.example.com', $smtpHost); } + + public function testWithPassword(): void { + $newPassword = 'new-secure-password'; + + // Create a new config with updated password + $updatedConfig = $this->accountConfig->withPassword($newPassword); + + // Original config should remain unchanged (immutable) + $this->assertEquals('imap-password', $this->accountConfig->getImap()->getPassword()); + $this->assertEquals('smtp-password', $this->accountConfig->getSmtp()->getPassword()); + + // New config should have the new password for both IMAP and SMTP + $this->assertEquals($newPassword, $updatedConfig->getImap()->getPassword()); + $this->assertEquals($newPassword, $updatedConfig->getSmtp()->getPassword()); + + // Other properties should remain the same + $this->assertEquals($this->accountConfig->getEmail(), $updatedConfig->getEmail()); + $this->assertEquals($this->accountConfig->getImap()->getHost(), $updatedConfig->getImap()->getHost()); + $this->assertEquals($this->accountConfig->getImap()->getPort(), $updatedConfig->getImap()->getPort()); + $this->assertEquals($this->accountConfig->getImap()->getSecurity(), $updatedConfig->getImap()->getSecurity()); + $this->assertEquals($this->accountConfig->getImap()->getUsername(), $updatedConfig->getImap()->getUsername()); + $this->assertEquals($this->accountConfig->getSmtp()->getHost(), $updatedConfig->getSmtp()->getHost()); + $this->assertEquals($this->accountConfig->getSmtp()->getPort(), $updatedConfig->getSmtp()->getPort()); + $this->assertEquals($this->accountConfig->getSmtp()->getSecurity(), $updatedConfig->getSmtp()->getSecurity()); + $this->assertEquals($this->accountConfig->getSmtp()->getUsername(), $updatedConfig->getSmtp()->getUsername()); + } } From 2792fa82a9be0eaac1fe4cc3af991cf142cb145f Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:53:10 +0100 Subject: [PATCH 09/19] IONOS(ionos-mail): feat(config): add APP_NAME constant to IonosConfigService Add NEXTCLOUD_WORKSPACE constant for consistent app password management across IONOS API calls. This ensures the same application name is used when setting or resetting app passwords. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosConfigService.php | 5 +++++ tests/Unit/Service/IONOS/IonosConfigServiceTest.php | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php index 3b5ff579ee..d72f57f99d 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Service/IONOS/IonosConfigService.php @@ -22,6 +22,11 @@ * Service for managing IONOS API configuration */ class IonosConfigService { + /** + * Application name used for IONOS app password management + */ + public const APP_NAME = 'NEXTCLOUD_WORKSPACE'; + public function __construct( private readonly IConfig $config, private readonly IAppConfig $appConfig, diff --git a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php index 9a3f6ea83e..8951981ecf 100644 --- a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php @@ -38,6 +38,10 @@ protected function setUp(): void { ); } + public function testAppNameConstantExists(): void { + $this->assertSame('NEXTCLOUD_WORKSPACE', IonosConfigService::APP_NAME); + } + public function testGetExternalReferenceSuccess(): void { $this->config->method('getSystemValue') ->with('ncw.ext_ref') From 4f32400e9f8899602231be5ec638320066baf87c Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:54:53 +0100 Subject: [PATCH 10/19] IONOS(ionos-mail): feat(service): add ConflictResolutionResult value object Add immutable result class for conflict resolution scenarios: - retry(): Account can be retried with existing config - noExistingAccount(): No IONOS account exists for conflict resolution - emailMismatch(): Existing account has different email than expected This provides a clean API for handling account creation conflicts. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/ConflictResolutionResult.php | 81 +++++++++ .../IONOS/ConflictResolutionResultTest.php | 158 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 lib/Service/IONOS/ConflictResolutionResult.php create mode 100644 tests/Unit/Service/IONOS/ConflictResolutionResultTest.php diff --git a/lib/Service/IONOS/ConflictResolutionResult.php b/lib/Service/IONOS/ConflictResolutionResult.php new file mode 100644 index 0000000000..f1a402c10e --- /dev/null +++ b/lib/Service/IONOS/ConflictResolutionResult.php @@ -0,0 +1,81 @@ +canRetry; + } + + public function getAccountConfig(): ?MailAccountConfig { + return $this->accountConfig; + } + + public function hasEmailMismatch(): bool { + return $this->expectedEmail !== null && $this->existingEmail !== null; + } + + public function getExpectedEmail(): ?string { + return $this->expectedEmail; + } + + public function getExistingEmail(): ?string { + return $this->existingEmail; + } +} diff --git a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php new file mode 100644 index 0000000000..a140e4bca1 --- /dev/null +++ b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php @@ -0,0 +1,158 @@ +accountConfig = new MailAccountConfig( + email: 'user@example.com', + imap: $imapConfig, + smtp: $smtpConfig, + ); + } + + public function testRetryFactoryMethod(): void { + $result = ConflictResolutionResult::retry($this->accountConfig); + + $this->assertInstanceOf(ConflictResolutionResult::class, $result); + $this->assertTrue($result->canRetry()); + $this->assertInstanceOf(MailAccountConfig::class, $result->getAccountConfig()); + $this->assertSame($this->accountConfig, $result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + $this->assertNull($result->getExpectedEmail()); + $this->assertNull($result->getExistingEmail()); + } + + public function testNoExistingAccountFactoryMethod(): void { + $result = ConflictResolutionResult::noExistingAccount(); + + $this->assertInstanceOf(ConflictResolutionResult::class, $result); + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + $this->assertNull($result->getExpectedEmail()); + $this->assertNull($result->getExistingEmail()); + } + + public function testEmailMismatchFactoryMethod(): void { + $expectedEmail = 'expected@example.com'; + $existingEmail = 'existing@example.com'; + + $result = ConflictResolutionResult::emailMismatch($expectedEmail, $existingEmail); + + $this->assertInstanceOf(ConflictResolutionResult::class, $result); + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals($expectedEmail, $result->getExpectedEmail()); + $this->assertEquals($existingEmail, $result->getExistingEmail()); + } + + public function testRetryResultHasCorrectState(): void { + $result = ConflictResolutionResult::retry($this->accountConfig); + + // Verify all state is correct for retry scenario + $this->assertTrue($result->canRetry(), 'Should be able to retry'); + $this->assertNotNull($result->getAccountConfig(), 'Should have account config'); + $this->assertEquals('user@example.com', $result->getAccountConfig()->getEmail()); + } + + public function testNoExistingAccountResultHasCorrectState(): void { + $result = ConflictResolutionResult::noExistingAccount(); + + // Verify all state is correct for no existing account scenario + $this->assertFalse($result->canRetry(), 'Should not be able to retry'); + $this->assertNull($result->getAccountConfig(), 'Should not have account config'); + $this->assertFalse($result->hasEmailMismatch(), 'Should not have email mismatch'); + } + + public function testEmailMismatchResultHasCorrectState(): void { + $result = ConflictResolutionResult::emailMismatch('user1@example.com', 'user2@example.com'); + + // Verify all state is correct for email mismatch scenario + $this->assertFalse($result->canRetry(), 'Should not be able to retry'); + $this->assertNull($result->getAccountConfig(), 'Should not have account config'); + $this->assertTrue($result->hasEmailMismatch(), 'Should have email mismatch'); + $this->assertNotNull($result->getExpectedEmail(), 'Should have expected email'); + $this->assertNotNull($result->getExistingEmail(), 'Should have existing email'); + } + + public function testEmailMismatchWithSameEmail(): void { + // Even with same email, if using emailMismatch() factory, it should still mark as mismatch + $email = 'same@example.com'; + $result = ConflictResolutionResult::emailMismatch($email, $email); + + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals($email, $result->getExpectedEmail()); + $this->assertEquals($email, $result->getExistingEmail()); + } + + public function testRetryResultPreservesAccountConfigData(): void { + $result = ConflictResolutionResult::retry($this->accountConfig); + $retrievedConfig = $result->getAccountConfig(); + + $this->assertNotNull($retrievedConfig); + $this->assertEquals('user@example.com', $retrievedConfig->getEmail()); + $this->assertEquals('imap.example.com', $retrievedConfig->getImap()->getHost()); + $this->assertEquals('smtp.example.com', $retrievedConfig->getSmtp()->getHost()); + } + + public function testEmailMismatchWithEmptyStrings(): void { + $result = ConflictResolutionResult::emailMismatch('', ''); + + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals('', $result->getExpectedEmail()); + $this->assertEquals('', $result->getExistingEmail()); + } + + public function testMultipleInstancesAreIndependent(): void { + $result1 = ConflictResolutionResult::retry($this->accountConfig); + $result2 = ConflictResolutionResult::noExistingAccount(); + $result3 = ConflictResolutionResult::emailMismatch('a@test.com', 'b@test.com'); + + // Each instance should maintain its own state + $this->assertTrue($result1->canRetry()); + $this->assertFalse($result2->canRetry()); + $this->assertFalse($result3->canRetry()); + + $this->assertNotNull($result1->getAccountConfig()); + $this->assertNull($result2->getAccountConfig()); + $this->assertNull($result3->getAccountConfig()); + + $this->assertFalse($result1->hasEmailMismatch()); + $this->assertFalse($result2->hasEmailMismatch()); + $this->assertTrue($result3->hasEmailMismatch()); + } +} From 3fa5d8095bd02ace17c3296930c2749fc5d515c1 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:02:04 +0100 Subject: [PATCH 11/19] IONOS(ionos-mail): feat(service): add account config retrieval and password reset to IonosMailService Add new methods to IonosMailService: - getAccountConfigForUser: Retrieve existing IONOS account configuration - getAccountConfigForCurrentUser: Retrieve config for logged-in user - resetAppPassword: Reset/regenerate app password for a user - getMailDomain: Expose mail domain from config service Refactor buildSuccessResponse to use shared buildMailAccountConfig helper for consistent configuration building across different response types. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailService.php | 163 ++++++++++++++++-- .../Service/IONOS/IonosMailServiceTest.php | 142 +++++++++++++++ 2 files changed, 295 insertions(+), 10 deletions(-) diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index fe8b3a84fb..f0e29f5719 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -11,10 +11,12 @@ use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; +use IONOS\MailConfigurationAPI\Client\Model\Imap; use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; +use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; use OCA\Mail\Service\IONOS\Dto\MailServerConfig; @@ -218,6 +220,45 @@ public function createEmailAccountForUser(string $userId, string $userName): Mai } } + /** + * Get IONOS account configuration for a specific user + * + * This method retrieves the configuration of an existing IONOS mail account. + * Useful when an account was previously created but Nextcloud account creation failed. + * + * @param string $userId The Nextcloud user ID + * @return MailAccountConfig|null Mail account configuration if exists, null otherwise + * @throws ServiceException + */ + public function getAccountConfigForUser(string $userId): ?MailAccountConfig { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + $this->logger->debug('No existing IONOS account found for user', [ + 'userId' => $userId + ]); + return null; + } + + $this->logger->info('Retrieved existing IONOS account configuration', [ + 'email' => $response->getEmail(), + 'userId' => $userId + ]); + + return $this->buildConfigFromAccountResponse($response); + } + + /** + * Get IONOS account configuration for the current logged-in user + * + * @return MailAccountConfig|null Mail account configuration if exists, null otherwise + * @throws ServiceException + */ + public function getAccountConfigForCurrentUser(): ?MailAccountConfig { + $userId = $this->getCurrentUserId(); + return $this->getAccountConfigForUser($userId); + } + /** * Get the current user ID * @@ -274,38 +315,71 @@ private function normalizeSslMode(string $apiSslMode): string { } /** - * Build success response with mail configuration + * Build success response with mail configuration from MailAccountCreatedResponse (newly created account) * - * @param MailAccountCreatedResponse $response - * @return MailAccountConfig + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password */ private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { - $smtpServer = $response->getServer()->getSmtp(); - $imapServer = $response->getServer()->getImap(); + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + $response->getPassword() + ); + } + /** + * Build mail account configuration from server details + * + * @param Imap $imapServer IMAP server configuration object + * @param Smtp $smtpServer SMTP server configuration object + * @param string $email Email address + * @param string $password Account password + * @return MailAccountConfig Complete mail account configuration + */ + private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { $imapConfig = new MailServerConfig( host: $imapServer->getHost(), port: $imapServer->getPort(), security: $this->normalizeSslMode($imapServer->getSslMode()), - username: $response->getEmail(), - password: $response->getPassword(), + username: $email, + password: $password, ); $smtpConfig = new MailServerConfig( host: $smtpServer->getHost(), port: $smtpServer->getPort(), security: $this->normalizeSslMode($smtpServer->getSslMode()), - username: $response->getEmail(), - password: $response->getPassword(), + username: $email, + password: $password, ); return new MailAccountConfig( - email: $response->getEmail(), + email: $email, imap: $imapConfig, smtp: $smtpConfig, ); } + /** + * Build configuration from MailAccountResponse (existing account) + * Note: MailAccountResponse does not include password for security reasons + * + * @param MailAccountResponse $response The account response from getFunctionalAccount + * @return MailAccountConfig The mail account configuration with empty password + */ + private function buildConfigFromAccountResponse(MailAccountResponse $response): MailAccountConfig { + // Password is not available when retrieving existing accounts + // It should be retrieved from Nextcloud's credential store separately + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + '' + ); + } + /** * Delete an IONOS email account via API * @@ -409,4 +483,73 @@ public function tryDeleteEmailAccount(string $userId): void { // Don't throw - this is a fire and forget operation } } + + /** + * Reset app password for the IONOS mail account (generates a new password) + * + * @param string $userId The Nextcloud user ID + * @param string $appName The application name for the password + * @return string The new password + * @throws ServiceException + */ + public function resetAppPassword(string $userId, string $appName): string { + $this->logger->debug('Resetting IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->setAppPassword( + self::BRAND, + $this->configService->getExternalReference(), + $userId, + $appName + ); + + if (is_string($result)) { + $this->logger->info('Successfully reset IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName + ]); + return $result; + } + + $this->logger->error('Failed to reset IONOS app password: Unexpected response type', [ + 'userId' => $userId, + 'appName' => $appName, + 'result' => $result + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'exception' => $e, + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Get the configured mail domain for IONOS accounts + * + * @return string The mail domain (e.g., "example.com") + */ + public function getMailDomain(): string { + return $this->configService->getMailDomain(); + } } diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index e6d1a433e8..2af70670cb 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -958,4 +958,146 @@ public function testGetIonosEmailForUserReturnsNullOnGeneralException(): void { $this->assertNull($result); } + + public function testGetAccountConfigForUserReturnsConfigWhenAccountExists(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Create mock response for existing account + $imapServer = $this->createMockImapServer(); + $smtpServer = $this->createMockSmtpServer(); + + $mailServer = $this->getMockBuilder(MailServer::class) + ->disableOriginalConstructor() + ->onlyMethods(['getImap', 'getSmtp']) + ->getMock(); + $mailServer->method('getImap')->willReturn($imapServer); + $mailServer->method('getSmtp')->willReturn($smtpServer); + + $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEmail', 'getServer']) + ->getMock(); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); + $mailAccountResponse->method('getServer')->willReturn($mailServer); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willReturn($mailAccountResponse); + + $result = $this->service->getAccountConfigForUser(self::TEST_USER_ID); + + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + // Password is empty when retrieving existing accounts + $this->assertEquals('', $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals('', $result->getSmtp()->getPassword()); + } + + public function testGetAccountConfigForUserReturnsNullWhenAccountDoesNotExist(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Mock API to throw 404 exception + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Not Found"}' + ); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willThrowException($apiException); + + $result = $this->service->getAccountConfigForUser(self::TEST_USER_ID); + + $this->assertNull($result); + } + + public function testResetAppPasswordSuccess(): void { + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + $appName = 'NEXTCLOUD_WORKSPACE'; + $expectedPassword = 'new-app-password-123'; + + $apiInstance->expects($this->once()) + ->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willReturn($expectedPassword); + + $result = $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + + $this->assertEquals($expectedPassword, $result); + } + + public function testResetAppPasswordWithApiException(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + $appName = 'NEXTCLOUD_WORKSPACE'; + + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Mailbox not found"}' + ); + + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willThrowException($apiException); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password: Not Found'); + $this->expectExceptionCode(404); + + $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + } + + public function testResetAppPasswordWithUnexpectedResponse(): void { + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + $appName = 'NEXTCLOUD_WORKSPACE'; + + // API returns unexpected response type (not a string) + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willReturn(['unexpected' => 'response']); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password'); + $this->expectExceptionCode(500); + + $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + } + + public function testGetMailDomain(): void { + $this->configService->method('getMailDomain')->willReturn(self::TEST_DOMAIN); + + $result = $this->service->getMailDomain(); + + $this->assertEquals(self::TEST_DOMAIN, $result); + } } From 5a2877bd688dcdd1ba965ad0d702b294c23e48ed Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:04:55 +0100 Subject: [PATCH 12/19] IONOS(ionos-mail): feat(service): add IonosAccountConflictResolver Add service to handle conflict resolution when IONOS account creation fails: - Check if existing IONOS account matches requested email - Reset app password for existing accounts to enable retry - Report email mismatch when existing account has different email This enables retry scenarios where IONOS account was created but Nextcloud account creation failed due to DNS propagation delays. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/IonosAccountConflictResolver.php | 74 +++++++ .../IonosAccountConflictResolverTest.php | 182 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 lib/Service/IONOS/IonosAccountConflictResolver.php create mode 100644 tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php diff --git a/lib/Service/IONOS/IonosAccountConflictResolver.php b/lib/Service/IONOS/IonosAccountConflictResolver.php new file mode 100644 index 0000000000..9b77f3913a --- /dev/null +++ b/lib/Service/IONOS/IonosAccountConflictResolver.php @@ -0,0 +1,74 @@ +ionosMailService->getAccountConfigForUser($userId); + + if ($ionosConfig === null) { + $this->logger->debug('No existing IONOS account found for conflict resolution', [ + 'userId' => $userId + ]); + return ConflictResolutionResult::noExistingAccount(); + } + + // Construct full email address from username to compare with existing account + $domain = $this->ionosConfigService->getMailDomain(); + $expectedEmail = $emailUser . '@' . $domain; + + // Ensure the retrieved email matches the requested email + if ($ionosConfig->getEmail() === $expectedEmail) { + $this->logger->info('IONOS account already exists, retrieving new password for retry', [ + 'emailAddress' => $ionosConfig->getEmail(), + 'userId' => $userId + ]); + + // Get fresh password via resetAppPassword API since getAccountConfigForUser + // does not return password for security reasons + $newPassword = $this->ionosMailService->resetAppPassword($userId, IonosConfigService::APP_NAME); + + // Create new config with the fresh password + $configWithPassword = $ionosConfig->withPassword($newPassword); + + return ConflictResolutionResult::retry($configWithPassword); + } + + $this->logger->warning('IONOS account exists but email mismatch', [ + 'requestedEmail' => $expectedEmail, + 'existingEmail' => $ionosConfig->getEmail(), + 'userId' => $userId + ]); + + return ConflictResolutionResult::emailMismatch($expectedEmail, $ionosConfig->getEmail()); + } +} diff --git a/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php b/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php new file mode 100644 index 0000000000..5c8379f2b8 --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php @@ -0,0 +1,182 @@ +ionosMailService = $this->createMock(IonosMailService::class); + $this->ionosConfigService = $this->createMock(IonosConfigService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->resolver = new IonosAccountConflictResolver( + $this->ionosMailService, + $this->ionosConfigService, + $this->logger, + ); + } + + public function testResolveConflictWithNoExistingAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + + $this->ionosMailService->method('getAccountConfigForUser') + ->with($userId) + ->willReturn(null); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('No existing IONOS account found for conflict resolution', ['userId' => $userId]); + + $result = $this->resolver->resolveConflict($userId, $emailUser); + + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + } + + public function testResolveConflictWithMatchingEmail(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-app-password-123'; + + // Create MailAccountConfig DTO without password (as API returns) + $imapConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1143, + security: 'none', + username: $emailAddress, + password: '', // Empty password from getAccountConfigForUser + ); + + $smtpConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1587, + security: 'none', + username: $emailAddress, + password: '', // Empty password from getAccountConfigForUser + ); + + $mailAccountConfig = new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + + $this->ionosMailService->method('getAccountConfigForUser') + ->with($userId) + ->willReturn($mailAccountConfig); + + $this->ionosConfigService->method('getMailDomain') + ->willReturn($domain); + + // Expect resetAppPassword to be called + $this->ionosMailService + ->expects($this->once()) + ->method('resetAppPassword') + ->with($userId, 'NEXTCLOUD_WORKSPACE') + ->willReturn($newPassword); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with( + 'IONOS account already exists, retrieving new password for retry', + ['emailAddress' => $emailAddress, 'userId' => $userId] + ); + + $result = $this->resolver->resolveConflict($userId, $emailUser); + + $this->assertTrue($result->canRetry()); + $this->assertNotNull($result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + + // Verify the returned config has the new password + $resultConfig = $result->getAccountConfig(); + $this->assertEquals($newPassword, $resultConfig->getImap()->getPassword()); + $this->assertEquals($newPassword, $resultConfig->getSmtp()->getPassword()); + } + + public function testResolveConflictWithEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $domain = 'example.com'; + $expectedEmail = 'test@example.com'; + $existingEmail = 'different@example.com'; + + // Create MailAccountConfig DTO with different email + $imapConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1143, + security: 'none', + username: $existingEmail, + password: 'tmp', + ); + + $smtpConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1587, + security: 'none', + username: $existingEmail, + password: 'tmp', + ); + + $mailAccountConfig = new MailAccountConfig( + email: $existingEmail, + imap: $imapConfig, + smtp: $smtpConfig, + ); + + $this->ionosMailService->method('getAccountConfigForUser') + ->with($userId) + ->willReturn($mailAccountConfig); + + $this->ionosConfigService->method('getMailDomain') + ->willReturn($domain); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with( + 'IONOS account exists but email mismatch', + ['requestedEmail' => $expectedEmail, 'existingEmail' => $existingEmail, 'userId' => $userId] + ); + + $result = $this->resolver->resolveConflict($userId, $emailUser); + + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals($expectedEmail, $result->getExpectedEmail()); + $this->assertEquals($existingEmail, $result->getExistingEmail()); + } +} From eca8bb53a12f515a4c8c05597ea193d30e9376b3 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 14:06:08 +0100 Subject: [PATCH 13/19] IONOS(ionos-mail): feat(exception): add IonosServiceException for enhanced error handling Introduce IonosServiceException to provide a structured way to handle exceptions related to IONOS services, including additional data storage for context. Signed-off-by: Misha M.-Kupriyanov --- lib/Exception/IonosServiceException.php | 38 ++++++++++ .../Exception/IonosServiceExceptionTest.php | 72 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 lib/Exception/IonosServiceException.php create mode 100644 tests/Unit/Exception/IonosServiceExceptionTest.php diff --git a/lib/Exception/IonosServiceException.php b/lib/Exception/IonosServiceException.php new file mode 100644 index 0000000000..c3dfbdffb3 --- /dev/null +++ b/lib/Exception/IonosServiceException.php @@ -0,0 +1,38 @@ + $data [optional] Additional data to pass with the exception. + */ + public function __construct( + $message = '', + $code = 0, + ?Throwable $previous = null, + private readonly array $data = [], + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Get additional data associated with the exception + * + * @return array + */ + public function getData(): array { + return $this->data; + } +} diff --git a/tests/Unit/Exception/IonosServiceExceptionTest.php b/tests/Unit/Exception/IonosServiceExceptionTest.php new file mode 100644 index 0000000000..390209e9df --- /dev/null +++ b/tests/Unit/Exception/IonosServiceExceptionTest.php @@ -0,0 +1,72 @@ +assertEquals('Test message', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + $this->assertEquals([], $exception->getData()); + } + + public function testConstructorWithData(): void { + $data = [ + 'errorCode' => 'DUPLICATE_EMAIL', + 'email' => 'test@example.com', + 'userId' => 'user123', + ]; + + $exception = new IonosServiceException('Duplicate email', 409, null, $data); + + $this->assertEquals('Duplicate email', $exception->getMessage()); + $this->assertEquals(409, $exception->getCode()); + $this->assertEquals($data, $exception->getData()); + } + + public function testConstructorWithPreviousException(): void { + $previous = new \Exception('Original error'); + $data = ['context' => 'test']; + + $exception = new IonosServiceException('Wrapped error', 500, $previous, $data); + + $this->assertEquals('Wrapped error', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + $this->assertEquals($previous, $exception->getPrevious()); + $this->assertEquals($data, $exception->getData()); + } + + public function testGetDataReturnsEmptyArrayByDefault(): void { + $exception = new IonosServiceException(); + + $this->assertEquals([], $exception->getData()); + } + + public function testGetDataPreservesComplexData(): void { + $data = [ + 'errorCode' => 'VALIDATION_ERROR', + 'fields' => ['email', 'password'], + 'metadata' => [ + 'timestamp' => 1234567890, + 'requestId' => 'req-123', + ], + ]; + + $exception = new IonosServiceException('Validation failed', 400, null, $data); + + $this->assertEquals($data, $exception->getData()); + $this->assertIsArray($exception->getData()['fields']); + $this->assertIsArray($exception->getData()['metadata']); + } +} From de62be36960b97405f6eec560eae77191da64e8f Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:06:34 +0100 Subject: [PATCH 14/19] IONOS(ionos-mail): feat(service): add IonosAccountCreationService Add unified service for creating and updating IONOS mail accounts: - Check for existing Nextcloud accounts before creation - Handle conflict resolution with existing IONOS accounts - Create new accounts with encrypted credentials - Update existing accounts with fresh credentials This service provides consistent account creation logic for both CLI and web interfaces, with proper retry handling for DNS propagation delays. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/IonosAccountCreationService.php | 220 +++++++ lib/Service/IONOS/IonosMailService.php | 1 + .../IONOS/IonosAccountCreationServiceTest.php | 591 ++++++++++++++++++ 3 files changed, 812 insertions(+) create mode 100644 lib/Service/IONOS/IonosAccountCreationService.php create mode 100644 tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php diff --git a/lib/Service/IONOS/IonosAccountCreationService.php b/lib/Service/IONOS/IonosAccountCreationService.php new file mode 100644 index 0000000000..482ebc0db0 --- /dev/null +++ b/lib/Service/IONOS/IonosAccountCreationService.php @@ -0,0 +1,220 @@ +buildEmailAddress($emailUser); + + // Check if Nextcloud account already exists + $existingAccounts = $this->accountService->findByUserIdAndAddress($userId, $expectedEmail); + + if (!empty($existingAccounts)) { + return $this->handleExistingAccount($userId, $emailUser, $accountName, $existingAccounts[0]); + } + + // No existing account - create new one + return $this->handleNewAccount($userId, $emailUser, $accountName); + } + + /** + * Handle the case where a Nextcloud mail account already exists + */ + private function handleExistingAccount(string $userId, string $emailUser, string $accountName, $existingAccount): Account { + $this->logger->info('Nextcloud mail account already exists, resetting credentials', [ + 'accountId' => $existingAccount->getId(), + 'emailAddress' => $existingAccount->getEmail(), + 'userId' => $userId, + ]); + + try { + $resolutionResult = $this->conflictResolver->resolveConflict($userId, $emailUser); + + if (!$resolutionResult->canRetry()) { + if ($resolutionResult->hasEmailMismatch()) { + throw new IonosServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + IonosMailService::STATUS__409_CONFLICT, + null, + [ + 'expectedEmail' => $resolutionResult->getExpectedEmail(), + 'existingEmail' => $resolutionResult->getExistingEmail(), + ] + ); + } + throw new ServiceException('Nextcloud account exists but no IONOS account found', 500); + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->updateAccount($existingAccount->getMailAccount(), $accountName, $mailConfig); + } catch (IonosServiceException $e) { + // Re-throw IonosServiceException as-is + throw $e; + } catch (ServiceException $e) { + throw new ServiceException('Failed to reset IONOS account credentials: ' . $e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Handle the case where no Nextcloud account exists yet + */ + private function handleNewAccount(string $userId, string $emailUser, string $accountName): Account { + try { + $this->logger->info('Creating new IONOS email account', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'accountName' => $accountName + ]); + + $mailConfig = $this->ionosMailService->createEmailAccountForUser($userId, $emailUser); + + $this->logger->info('IONOS email account created successfully', [ + 'emailAddress' => $mailConfig->getEmail() + ]); + + return $this->createAccount($userId, $accountName, $mailConfig); + } catch (ServiceException $e) { + // Try to resolve conflict - IONOS account might already exist + $this->logger->info('IONOS account creation failed, attempting conflict resolution', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'error' => $e->getMessage() + ]); + + $resolutionResult = $this->conflictResolver->resolveConflict($userId, $emailUser); + + if (!$resolutionResult->canRetry()) { + if ($resolutionResult->hasEmailMismatch()) { + throw new IonosServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + IonosMailService::STATUS__409_CONFLICT, + $e, + [ + 'expectedEmail' => $resolutionResult->getExpectedEmail(), + 'existingEmail' => $resolutionResult->getExistingEmail(), + ] + ); + } + // No existing IONOS account found - re-throw original error + throw $e; + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->createAccount($userId, $accountName, $mailConfig); + } + } + + /** + * Create a new Nextcloud mail account + */ + private function createAccount(string $userId, string $accountName, MailAccountConfig $mailConfig): Account { + $account = new MailAccount(); + $account->setUserId($userId); + $account->setName($accountName); + $account->setEmail($mailConfig->getEmail()); + $account->setAuthMethod('password'); + + $this->setAccountCredentials($account, $mailConfig); + + $account = $this->accountService->save($account); + + $this->logger->info('Created new Nextcloud mail account', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $userId, + ]); + + return new Account($account); + } + + /** + * Update an existing Nextcloud mail account + */ + private function updateAccount(MailAccount $account, string $accountName, MailAccountConfig $mailConfig): Account { + $account->setName($accountName); + $this->setAccountCredentials($account, $mailConfig); + + $account = $this->accountService->update($account); + + $this->logger->info('Updated existing Nextcloud mail account with new credentials', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $account->getUserId(), + ]); + + return new Account($account); + } + + /** + * Set IMAP and SMTP credentials on a mail account + */ + private function setAccountCredentials(MailAccount $account, MailAccountConfig $mailConfig): void { + $imap = $mailConfig->getImap(); + $account->setInboundHost($imap->getHost()); + $account->setInboundPort($imap->getPort()); + $account->setInboundSslMode($imap->getSecurity()); + $account->setInboundUser($imap->getUsername()); + $account->setInboundPassword($this->crypto->encrypt($imap->getPassword())); + + $smtp = $mailConfig->getSmtp(); + $account->setOutboundHost($smtp->getHost()); + $account->setOutboundPort($smtp->getPort()); + $account->setOutboundSslMode($smtp->getSecurity()); + $account->setOutboundUser($smtp->getUsername()); + $account->setOutboundPassword($this->crypto->encrypt($smtp->getPassword())); + } + + /** + * Build full email address from username + */ + private function buildEmailAddress(string $emailUser): string { + $domain = $this->ionosMailService->getMailDomain(); + return $emailUser . '@' . $domain; + } +} diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index f0e29f5719..84c9027271 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -30,6 +30,7 @@ class IonosMailService { private const BRAND = 'IONOS'; private const HTTP_NOT_FOUND = 404; + public const STATUS__409_CONFLICT = 409; private const HTTP_INTERNAL_SERVER_ERROR = 500; public function __construct( diff --git a/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php new file mode 100644 index 0000000000..3eb96d89c3 --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php @@ -0,0 +1,591 @@ +ionosMailService = $this->createMock(IonosMailService::class); + $this->conflictResolver = $this->createMock(IonosAccountConflictResolver::class); + $this->accountService = $this->createMock(AccountService::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new IonosAccountCreationService( + $this->ionosMailService, + $this->conflictResolver, + $this->accountService, + $this->crypto, + $this->logger, + ); + } + + public function testCreateOrUpdateAccountNewAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'test-password-123'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willReturn($mailConfig); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(1); + $savedAccount->setUserId($userId); + $savedAccount->setEmail($emailAddress); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account) use ($savedAccount) { + $this->assertEquals('testuser', $account->getUserId()); + $this->assertEquals('Test Account', $account->getName()); + $this->assertEquals('test@example.com', $account->getEmail()); + $this->assertEquals('password', $account->getAuthMethod()); + return $savedAccount; + }); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(1, $result->getId()); + } + + public function testCreateOrUpdateAccountExistingNextcloudAccountSuccess(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-password-456'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $mailConfig = $this->createMailAccountConfig($emailAddress, $newPassword); + + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($newPassword) + ->willReturn('encrypted-' . $newPassword); + + $this->accountService->expects($this->once()) + ->method('update') + ->with($existingAccount) + ->willReturn($existingAccount); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(5, $result->getId()); + } + + public function testCreateOrUpdateAccountExistingAccountEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $existingEmail = 'different@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $resolutionResult = ConflictResolutionResult::emailMismatch($emailAddress, $existingEmail); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Nextcloud mail account already exists, resetting credentials', $this->anything()); + + try { + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + $this->fail('Expected IonosServiceException to be thrown'); + } catch (IonosServiceException $e) { + $this->assertEquals(409, $e->getCode()); + $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); + + $data = $e->getData(); + $this->assertArrayHasKey('expectedEmail', $data); + $this->assertArrayHasKey('existingEmail', $data); + $this->assertEquals($emailAddress, $data['expectedEmail']); + $this->assertEquals($existingEmail, $data['existingEmail']); + } + } + + public function testCreateOrUpdateAccountNewAccountWithConflictResolution(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'reset-password-789'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + // First attempt to create fails + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException(new ServiceException('Account already exists', 409)); + + // Conflict resolution succeeds + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(2); + $savedAccount->setUserId($userId); + $savedAccount->setEmail($emailAddress); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturn($savedAccount); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(2, $result->getId()); + } + + public function testCreateOrUpdateAccountSetsCorrectCredentials(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'secret-password'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willReturn($mailConfig); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(10); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account) use ($savedAccount, $emailAddress) { + // Verify IMAP settings + $this->assertEquals('imap.example.com', $account->getInboundHost()); + $this->assertEquals(993, $account->getInboundPort()); + $this->assertEquals('ssl', $account->getInboundSslMode()); + $this->assertEquals($emailAddress, $account->getInboundUser()); + $this->assertEquals('encrypted-secret-password', $account->getInboundPassword()); + + // Verify SMTP settings + $this->assertEquals('smtp.example.com', $account->getOutboundHost()); + $this->assertEquals(465, $account->getOutboundPort()); + $this->assertEquals('ssl', $account->getOutboundSslMode()); + $this->assertEquals($emailAddress, $account->getOutboundUser()); + $this->assertEquals('encrypted-secret-password', $account->getOutboundPassword()); + + return $savedAccount; + }); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + } + + private function createMailAccountConfig(string $emailAddress, string $password): MailAccountConfig { + $imapConfig = new MailServerConfig( + host: 'imap.example.com', + port: 993, + security: 'ssl', + username: $emailAddress, + password: $password, + ); + + $smtpConfig = new MailServerConfig( + host: 'smtp.example.com', + port: 465, + security: 'ssl', + username: $emailAddress, + password: $password, + ); + + return new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + } + + /** + * Helper to create an account object with a MailAccount + * This simulates the structure returned by AccountService::findByUserIdAndAddress + */ + public function testCreateOrUpdateAccountExistingAccountNoIonosAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Nextcloud account exists but no IONOS account found'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountExistingAccountConflictResolverThrows(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $originalException = new ServiceException('IONOS API error', 503); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(503); + $this->expectExceptionMessage('Failed to reset IONOS account credentials: IONOS API error'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionFails(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Account creation failed', 500); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Account creation failed'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $existingEmail = 'other@example.com'; + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Account already exists', 409); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $resolutionResult = ConflictResolutionResult::emailMismatch($emailAddress, $existingEmail); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + try { + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + $this->fail('Expected IonosServiceException to be thrown'); + } catch (IonosServiceException $e) { + $this->assertEquals(409, $e->getCode()); + $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); + $this->assertStringContainsString($emailAddress, $e->getMessage()); + $this->assertStringContainsString($existingEmail, $e->getMessage()); + + $data = $e->getData(); + $this->assertArrayHasKey('expectedEmail', $data); + $this->assertArrayHasKey('existingEmail', $data); + $this->assertEquals($emailAddress, $data['expectedEmail']); + $this->assertEquals($existingEmail, $data['existingEmail']); + + // Verify the previous exception is set + $this->assertSame($originalException, $e->getPrevious()); + } + } + + public function testUpdateAccountSetsCorrectCredentials(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Updated Account Name'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-password-xyz'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(7); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + $existingAccount->setName('Old Account Name'); + + $mailConfig = $this->createMailAccountConfig($emailAddress, $newPassword); + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($newPassword) + ->willReturn('encrypted-' . $newPassword); + + $this->accountService->expects($this->once()) + ->method('update') + ->willReturnCallback(function (MailAccount $account) use ($existingAccount, $emailAddress, $accountName) { + // Verify account name is updated + $this->assertEquals($accountName, $account->getName()); + + // Verify IMAP settings + $this->assertEquals('imap.example.com', $account->getInboundHost()); + $this->assertEquals(993, $account->getInboundPort()); + $this->assertEquals('ssl', $account->getInboundSslMode()); + $this->assertEquals($emailAddress, $account->getInboundUser()); + $this->assertEquals('encrypted-new-password-xyz', $account->getInboundPassword()); + + // Verify SMTP settings + $this->assertEquals('smtp.example.com', $account->getOutboundHost()); + $this->assertEquals(465, $account->getOutboundPort()); + $this->assertEquals('ssl', $account->getOutboundSslMode()); + $this->assertEquals($emailAddress, $account->getOutboundUser()); + $this->assertEquals('encrypted-new-password-xyz', $account->getOutboundPassword()); + + return $existingAccount; + }); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(7, $result->getId()); + } + + private function createAccountWithMailAccount(MailAccount $mailAccount): object { + return new class($mailAccount) { + public function __construct( + private MailAccount $mailAccount, + ) { + } + + public function getId(): int { + return $this->mailAccount->getId(); + } + + public function getEmail(): string { + return $this->mailAccount->getEmail(); + } + + public function getMailAccount(): MailAccount { + return $this->mailAccount; + } + }; + } +} From af100366189cc8e286d1da5b75f90949273d5bce Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:08:05 +0100 Subject: [PATCH 15/19] IONOS(ionos-mail): refactor(service): enhance IonosMailConfigService availability check Update isMailConfigAvailable to check for local account configuration: - Add AccountService and IUserSession dependencies - Check if user has remote IONOS account - Check if remote account is configured locally in mail app - Show configuration if remote exists but local doesn't (retry scenario) This allows users to complete account setup if the initial Nextcloud account creation failed but the IONOS account was created. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailConfigService.php | 46 +++++- .../IONOS/IonosMailConfigServiceTest.php | 143 ++++++++++++++++-- 2 files changed, 175 insertions(+), 14 deletions(-) diff --git a/lib/Service/IONOS/IonosMailConfigService.php b/lib/Service/IONOS/IonosMailConfigService.php index 77e9b8126e..9e4b7a6706 100644 --- a/lib/Service/IONOS/IonosMailConfigService.php +++ b/lib/Service/IONOS/IonosMailConfigService.php @@ -9,6 +9,8 @@ namespace OCA\Mail\Service\IONOS; +use OCA\Mail\Service\AccountService; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -18,6 +20,8 @@ class IonosMailConfigService { public function __construct( private IonosConfigService $ionosConfigService, private IonosMailService $ionosMailService, + private AccountService $accountService, + private IUserSession $userSession, private LoggerInterface $logger, ) { } @@ -27,7 +31,8 @@ public function __construct( * * The configuration is available only if: * 1. The IONOS integration is enabled and properly configured - * 2. The user does NOT already have an IONOS mail account + * 2. The user does NOT already have an IONOS mail account configured remotely + * 3. OR the user has a remote IONOS account but it's NOT configured locally in the mail app * * @return bool True if mail configuration should be shown, false otherwise */ @@ -38,14 +43,45 @@ public function isMailConfigAvailable(): bool { return false; } - // Check if user already has an account - $userHasAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); + // Get current user + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->debug('IONOS mail config not available - no user session'); + return false; + } + $userId = $user->getUID(); + + // Check if user already has a remote IONOS account + $userHasRemoteAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); + + if (!$userHasRemoteAccount) { + // No remote account exists, configuration should be available + return true; + } - if ($userHasAccount) { - $this->logger->debug('IONOS mail config not available - user already has an account'); + // User has a remote account, check if it's configured locally + $ionosEmail = $this->ionosMailService->getIonosEmailForUser($userId); + if ($ionosEmail === null) { + // This shouldn't happen if userHasRemoteAccount is true, but handle it gracefully + $this->logger->warning('IONOS remote account exists but email could not be retrieved'); return false; } + // Check if the IONOS email is configured in the local mail app + $localAccounts = $this->accountService->findByUserIdAndAddress($userId, $ionosEmail); + $hasLocalAccount = count($localAccounts) > 0; + + if ($hasLocalAccount) { + $this->logger->debug('IONOS mail config not available - user already has account configured locally', [ + 'email' => $ionosEmail, + ]); + return false; + } + + // Remote account exists but not configured locally - show configuration + $this->logger->debug('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => $ionosEmail, + ]); return true; } catch (\Exception $e) { $this->logger->error('Error checking IONOS mail config availability', [ diff --git a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php index a05bdd9e82..e1fdcf980b 100644 --- a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php @@ -10,15 +10,20 @@ namespace OCA\Mail\Tests\Unit\Service\IONOS; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Service\AccountService; use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\IONOS\IonosMailService; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; class IonosMailConfigServiceTest extends TestCase { private IonosConfigService&MockObject $ionosConfigService; private IonosMailService&MockObject $ionosMailService; + private AccountService&MockObject $accountService; + private IUserSession&MockObject $userSession; private LoggerInterface&MockObject $logger; private IonosMailConfigService $service; @@ -27,11 +32,15 @@ protected function setUp(): void { $this->ionosConfigService = $this->createMock(IonosConfigService::class); $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->accountService = $this->createMock(AccountService::class); + $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->service = new IonosMailConfigService( $this->ionosConfigService, $this->ionosMailService, + $this->accountService, + $this->userSession, $this->logger, ); } @@ -41,58 +50,174 @@ public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void ->method('isIonosIntegrationEnabled') ->willReturn(false); - $this->ionosMailService->expects($this->never()) - ->method('mailAccountExistsForCurrentUser'); + $this->userSession->expects($this->never()) + ->method('getUser'); $result = $this->service->isMailConfigAvailable(); $this->assertFalse($result); } - public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void { + public function testIsMailConfigAvailableReturnsFalseWhenNoUserSession(): void { $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config not available - no user session'); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertFalse($result); + } + + public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoRemoteAccount(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') ->willReturn(false); - $this->logger->expects($this->never()) - ->method('debug'); + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); $result = $this->service->isMailConfigAvailable(); $this->assertTrue($result); } - public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void { + public function testIsMailConfigAvailableReturnsFalseWhenUserHasRemoteAndLocalAccount(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') ->willReturn(true); + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('testuser@ionos.com'); + + // Return a non-empty array to simulate that a local account exists + $mockAccount = $this->createMock(\OCA\Mail\Account::class); + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@ionos.com') + ->willReturn([$mockAccount]); + $this->logger->expects($this->once()) ->method('debug') - ->with('IONOS mail config not available - user already has an account'); + ->with('IONOS mail config not available - user already has account configured locally', [ + 'email' => 'testuser@ionos.com', + ]); $result = $this->service->isMailConfigAvailable(); $this->assertFalse($result); } - public function testIsMailConfigAvailableReturnsFalseOnException(): void { + public function testIsMailConfigAvailableReturnsTrueWhenUserHasRemoteAccountButNotLocal(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - $exception = new \Exception('Test exception'); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('testuser@ionos.com'); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@ionos.com') + ->willReturn([]); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => 'testuser@ionos.com', + ]); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertTrue($result); + } + + public function testIsMailConfigAvailableReturnsFalseWhenEmailCannotBeRetrieved(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->ionosMailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('warning') + ->with('IONOS remote account exists but email could not be retrieved'); + + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertFalse($result); + } + + public function testIsMailConfigAvailableReturnsFalseOnException(): void { + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $exception = new \Exception('Test exception'); + + $this->userSession->expects($this->once()) + ->method('getUser') ->willThrowException($exception); $this->logger->expects($this->once()) From 9f1ede6872ff9b1a110bdc3b1af72e5cd690c01d Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 10:01:12 +0100 Subject: [PATCH 16/19] IONOS(ionos-mail): refactor(controller): improve error handling and logging in IonosAccountsController Enhanced error handling by introducing a dedicated method for building service error responses. Improved logging messages for better clarity during account creation process. Signed-off-by: Misha M.-Kupriyanov --- lib/Controller/IonosAccountsController.php | 43 ++++++++++++---- .../IonosAccountsControllerTest.php | 50 +++++++++++++++++-- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 60b647296e..5bf37185f1 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -54,20 +54,30 @@ public function create(string $accountName, string $emailUser): JSONResponse { } try { - $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailUser, 'accountName' => $accountName ]); + $this->logger->info('Starting IONOS email account creation', [ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ]); $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); - $this->logger->info('IONOS email account created successfully', [ 'emailAddress' => $ionosResponse->getEmail() ]); - return $this->createNextcloudMailAccount($accountName, $ionosResponse); - } catch (ServiceException $e) { - $data = [ - 'error' => self::ERR_IONOS_API_ERROR, - 'statusCode' => $e->getCode(), - ]; - $this->logger->error('IONOS service error: ' . $e->getMessage(), $data); + $this->logger->info('IONOS email account created successfully', [ + 'emailAddress' => $ionosResponse->getEmail(), + ]); + + $response = $this->createNextcloudMailAccount($accountName, $ionosResponse); - return MailJsonResponse::fail($data); + $this->logger->info('Account creation completed successfully', [ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ]); + + return $response; + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, 'account creation'); } catch (\Exception $e) { + $this->logger->error('Unexpected error during account creation: ' . $e->getMessage(), [ + 'exception' => $e, + ]); return MailJsonResponse::error('Could not create account'); } } @@ -91,4 +101,17 @@ private function createNextcloudMailAccount(string $accountName, MailAccountConf $smtp->getPassword(), ); } + + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $context): JSONResponse { + $data = [ + 'error' => self::ERR_IONOS_API_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); + return MailJsonResponse::fail($data); + } } diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index ad43b0085d..8584313a62 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -104,6 +104,34 @@ public function testCreateSuccess(): void { ->with($emailUser) ->willReturn($mailAccountConfig); + // Verify logging calls + $this->logger + ->expects($this->exactly(3)) + ->method('info') + ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + $this->assertEquals('Starting IONOS email account creation', $message); + $this->assertEquals([ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ], $context); + } elseif ($callCount === 2) { + $this->assertEquals('IONOS email account created successfully', $message); + $this->assertEquals([ + 'emailAddress' => $emailAddress, + ], $context); + } elseif ($callCount === 3) { + $this->assertEquals('Account creation completed successfully', $message); + $this->assertEquals([ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ], $context); + } + }); + // Mock account creation response $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; $accountResponse = $this->createMock(JSONResponse::class); @@ -146,16 +174,18 @@ public function testCreateWithServiceException(): void { ->expects($this->once()) ->method('error') ->with( - 'IONOS service error: Failed to create email account', + 'IONOS service error during account creation: Failed to create email account', [ 'error' => 'IONOS_API_ERROR', 'statusCode' => 0, + 'message' => 'Failed to create email account', ] ); $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ 'error' => 'IONOS_API_ERROR', 'statusCode' => 0, + 'message' => 'Failed to create email account', ]); $response = $this->controller->create($accountName, $emailUser); @@ -175,16 +205,18 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { ->expects($this->once()) ->method('error') ->with( - 'IONOS service error: Duplicate email account', + 'IONOS service error during account creation: Duplicate email account', [ 'error' => 'IONOS_API_ERROR', 'statusCode' => 409, + 'message' => 'Duplicate email account', ] ); $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ 'error' => 'IONOS_API_ERROR', 'statusCode' => 409, + 'message' => 'Duplicate email account', ]); $response = $this->controller->create($accountName, $emailUser); @@ -196,9 +228,21 @@ public function testCreateWithGenericException(): void { $emailUser = 'test'; // Mock IONOS mail service to throw a generic exception + $exception = new \Exception('Generic error'); $this->ionosMailService->method('createEmailAccount') ->with($emailUser) - ->willThrowException(new \Exception('Generic error')); + ->willThrowException($exception); + + // Verify error logging for unexpected exceptions + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Unexpected error during account creation: Generic error', + [ + 'exception' => $exception, + ] + ); $expectedResponse = \OCA\Mail\Http\JsonResponse::error('Could not create account', 500, From da32d37bec3c3b4d031d19e11d49872f48aa4a45 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 11:33:59 +0100 Subject: [PATCH 17/19] IONOS(ionos-mail): feat(controller): add user session handling for account creation Enhances the IonosAccountsController by integrating user session management, allowing for user identification during email account creation. This improves error handling by ensuring that a valid user session is present, providing more informative logging and responses in case of session absence. Signed-off-by: Misha M.-Kupriyanov --- lib/Controller/IonosAccountsController.php | 21 +++++- .../IonosAccountsControllerTest.php | 66 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 5bf37185f1..91e620a8db 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -17,6 +17,7 @@ use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; use Psr\Log\LoggerInterface; #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] @@ -31,6 +32,7 @@ public function __construct( IRequest $request, private IonosMailService $ionosMailService, private AccountsController $accountsController, + private IUserSession $userSession, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -54,7 +56,10 @@ public function create(string $accountName, string $emailUser): JSONResponse { } try { - $this->logger->info('Starting IONOS email account creation', [ + $userId = $this->getUserIdOrFail(); + + $this->logger->info('Starting IONOS email account creation from web', [ + 'userId' => $userId, 'emailAddress' => $emailUser, 'accountName' => $accountName, ]); @@ -102,6 +107,20 @@ private function createNextcloudMailAccount(string $accountName, MailAccountConf ); } + /** + * Get the current user ID + * + * @return string User ID string + * @throws ServiceException + */ + private function getUserIdOrFail(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found during account creation', 401); + } + return $user->getUID(); + } + /** * Build service error response */ diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index 8584313a62..4f0655b43b 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -18,6 +18,8 @@ use OCA\Mail\Service\IONOS\IonosMailService; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use ReflectionClass; @@ -31,6 +33,8 @@ class IonosAccountsControllerTest extends TestCase { private AccountsController&MockObject $accountsController; + private IUserSession&MockObject $userSession; + private LoggerInterface|MockObject $logger; private IonosAccountsController $controller; @@ -42,6 +46,7 @@ protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->ionosMailService = $this->createMock(IonosMailService::class); $this->accountsController = $this->createMock(AccountsController::class); + $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new IonosAccountsController( @@ -49,10 +54,20 @@ protected function setUp(): void { $this->request, $this->ionosMailService, $this->accountsController, + $this->userSession, $this->logger, ); } + /** + * Helper method to setup user session mock + */ + private function setupUserSession(string $userId): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($userId); + $this->userSession->method('getUser')->willReturn($user); + } + public function testCreateWithMissingFields(): void { // Test with empty account name $response = $this->controller->create('', 'testuser'); @@ -75,6 +90,10 @@ public function testCreateSuccess(): void { $accountName = 'Test Account'; $emailUser = 'test'; $emailAddress = 'test@example.com'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Create MailAccountConfig DTO $imapConfig = new MailServerConfig( @@ -108,13 +127,14 @@ public function testCreateSuccess(): void { $this->logger ->expects($this->exactly(3)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress) { + ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { static $callCount = 0; $callCount++; if ($callCount === 1) { - $this->assertEquals('Starting IONOS email account creation', $message); + $this->assertEquals('Starting IONOS email account creation from web', $message); $this->assertEquals([ + 'userId' => $userId, 'emailAddress' => $emailUser, 'accountName' => $accountName, ], $context); @@ -164,6 +184,10 @@ public function testCreateSuccess(): void { public function testCreateWithServiceException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Mock IONOS mail service to throw ServiceException $this->ionosMailService->method('createEmailAccount') @@ -195,6 +219,10 @@ public function testCreateWithServiceException(): void { public function testCreateWithServiceExceptionWithStatusCode(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) $this->ionosMailService->method('createEmailAccount') @@ -226,6 +254,10 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { public function testCreateWithGenericException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Mock IONOS mail service to throw a generic exception $exception = new \Exception('Generic error'); @@ -254,6 +286,36 @@ public function testCreateWithGenericException(): void { self::assertEquals($expectedResponse, $response); } + public function testCreateWithNoUserSession(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + + // Mock user session to return null (no user logged in) + $this->userSession->method('getUser')->willReturn(null); + + // Should catch the ServiceException thrown by getUserIdOrFail + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error during account creation: No user session found during account creation', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 401, + 'message' => 'No user session found during account creation', + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 401, + 'message' => 'No user session found during account creation', + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + public function testCreateNextcloudMailAccount(): void { $accountName = 'Test Account'; From efdbb6c538b81e1420eb7df36289fb347f5c4d7b Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:09:56 +0100 Subject: [PATCH 18/19] IONOS(ionos-mail): refactor(controller): use IonosAccountCreationService in IonosAccountsController Refactor IonosAccountsController to use the shared IonosAccountCreationService: - Replace IonosMailService and AccountsController with IonosAccountCreationService - Add IUserSession for user ID retrieval - Simplify create method by delegating to IonosAccountCreationService - Add error message to service exception responses This ensures consistent account creation logic between web and CLI interfaces. Signed-off-by: Misha M.-Kupriyanov --- lib/Controller/IonosAccountsController.php | 47 ++-- .../IonosAccountsControllerTest.php | 222 +++++++----------- 2 files changed, 102 insertions(+), 167 deletions(-) diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 91e620a8db..761d6fd5c0 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -8,12 +8,13 @@ */ namespace OCA\Mail\Controller; +use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\JsonResponse as MailJsonResponse; use OCA\Mail\Http\TrapError; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\IonosMailService; +use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; @@ -30,8 +31,7 @@ class IonosAccountsController extends Controller { public function __construct( string $appName, IRequest $request, - private IonosMailService $ionosMailService, - private AccountsController $accountsController, + private IonosAccountCreationService $accountCreationService, private IUserSession $userSession, private LoggerInterface $logger, ) { @@ -63,20 +63,17 @@ public function create(string $accountName, string $emailUser): JSONResponse { 'emailAddress' => $emailUser, 'accountName' => $accountName, ]); - $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); - $this->logger->info('IONOS email account created successfully', [ - 'emailAddress' => $ionosResponse->getEmail(), - ]); - - $response = $this->createNextcloudMailAccount($accountName, $ionosResponse); + $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $accountName); $this->logger->info('Account creation completed successfully', [ - 'emailAddress' => $emailUser, + 'emailAddress' => $account->getEmail(), 'accountName' => $accountName, + 'accountId' => $account->getId(), + 'userId' => $userId, ]); - return $response; + return MailJsonResponse::success($account, Http::STATUS_CREATED); } catch (ServiceException $e) { return $this->buildServiceErrorResponse($e, 'account creation'); } catch (\Exception $e) { @@ -87,26 +84,6 @@ public function create(string $accountName, string $emailUser): JSONResponse { } } - private function createNextcloudMailAccount(string $accountName, MailAccountConfig $mailConfig): JSONResponse { - $imap = $mailConfig->getImap(); - $smtp = $mailConfig->getSmtp(); - - return $this->accountsController->create( - $accountName, - $mailConfig->getEmail(), - $imap->getHost(), - $imap->getPort(), - $imap->getSecurity(), - $imap->getUsername(), - $imap->getPassword(), - $smtp->getHost(), - $smtp->getPort(), - $smtp->getSecurity(), - $smtp->getUsername(), - $smtp->getPassword(), - ); - } - /** * Get the current user ID * @@ -130,6 +107,12 @@ private function buildServiceErrorResponse(ServiceException $e, string $context) 'statusCode' => $e->getCode(), 'message' => $e->getMessage(), ]; + + // If it's an IonosServiceException, merge in the additional data + if ($e instanceof IonosServiceException) { + $data = array_merge($data, $e->getData()); + } + $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); return MailJsonResponse::fail($data); } diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index 4f0655b43b..3a0186c66b 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -10,28 +10,24 @@ namespace OCA\Mail\Tests\Unit\Controller; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Controller\AccountsController; +use OCA\Mail\Account; use OCA\Mail\Controller\IonosAccountsController; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; -use OCA\Mail\Service\IONOS\IonosMailService; -use OCP\AppFramework\Http\JSONResponse; +use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\IRequest; use OCP\IUser; use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -use ReflectionClass; class IonosAccountsControllerTest extends TestCase { private string $appName; private IRequest&MockObject $request; - private IonosMailService&MockObject $ionosMailService; - - private AccountsController&MockObject $accountsController; + private IonosAccountCreationService&MockObject $accountCreationService; private IUserSession&MockObject $userSession; @@ -44,16 +40,14 @@ protected function setUp(): void { $this->appName = 'mail'; $this->request = $this->createMock(IRequest::class); - $this->ionosMailService = $this->createMock(IonosMailService::class); - $this->accountsController = $this->createMock(AccountsController::class); + $this->accountCreationService = $this->createMock(IonosAccountCreationService::class); $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new IonosAccountsController( $this->appName, $this->request, - $this->ionosMailService, - $this->accountsController, + $this->accountCreationService, $this->userSession, $this->logger, ); @@ -95,37 +89,27 @@ public function testCreateSuccess(): void { // Setup user session $this->setupUserSession($userId); - // Create MailAccountConfig DTO - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + // Create a real MailAccount instance and wrap it in Account + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId($userId); + $mailAccount->setName($accountName); + $mailAccount->setEmail($emailAddress); - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + $account = new Account($mailAccount); - $mailAccountConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); + // Verify response matches the expected MailJsonResponse::success() format + $accountResponse = \OCA\Mail\Http\JsonResponse::success($account, 201); - // Mock successful IONOS mail service response - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willReturn($mailAccountConfig); + // Mock account creation service to return a successful account + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); // Verify logging calls $this->logger - ->expects($this->exactly(3)) + ->expects($this->exactly(2)) ->method('info') ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { static $callCount = 0; @@ -139,46 +123,19 @@ public function testCreateSuccess(): void { 'accountName' => $accountName, ], $context); } elseif ($callCount === 2) { - $this->assertEquals('IONOS email account created successfully', $message); - $this->assertEquals([ - 'emailAddress' => $emailAddress, - ], $context); - } elseif ($callCount === 3) { $this->assertEquals('Account creation completed successfully', $message); $this->assertEquals([ - 'emailAddress' => $emailUser, + 'emailAddress' => $emailAddress, 'accountName' => $accountName, + 'accountId' => 1, + 'userId' => $userId, ], $context); } }); - // Mock account creation response - $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; - $accountResponse = $this->createMock(JSONResponse::class); - $accountResponse->method('getData')->willReturn($accountData); - - $this->accountsController - ->method('create') - ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($accountResponse); - $response = $this->controller->create($accountName, $emailUser); - // The controller now directly returns the AccountsController response - $this->assertSame($accountResponse, $response); + $this->assertEquals($accountResponse, $response); } public function testCreateWithServiceException(): void { @@ -189,9 +146,10 @@ public function testCreateWithServiceException(): void { // Setup user session $this->setupUserSession($userId); - // Mock IONOS mail service to throw ServiceException - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw ServiceException + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Failed to create email account')); $this->logger @@ -224,9 +182,10 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { // Setup user session $this->setupUserSession($userId); - // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw ServiceException with status code + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Duplicate email account', 409)); $this->logger @@ -251,6 +210,55 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { self::assertEquals($expectedResponse, $response); } + public function testCreateWithIonosServiceExceptionWithAdditionalData(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); + + // Create IonosServiceException with additional data + $additionalData = [ + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ]; + + // Mock account creation service to throw IonosServiceException with additional data + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willThrowException(new IonosServiceException('Email already exists', 409, null, $additionalData)); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error during account creation: Email already exists', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + 'message' => 'Email already exists', + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + 'message' => 'Email already exists', + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + public function testCreateWithGenericException(): void { $accountName = 'Test Account'; $emailUser = 'test'; @@ -259,10 +267,11 @@ public function testCreateWithGenericException(): void { // Setup user session $this->setupUserSession($userId); - // Mock IONOS mail service to throw a generic exception + // Mock account creation service to throw a generic exception $exception = new \Exception('Generic error'); - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException($exception); // Verify error logging for unexpected exceptions @@ -315,61 +324,4 @@ public function testCreateWithNoUserSession(): void { self::assertEquals($expectedResponse, $response); } - - - public function testCreateNextcloudMailAccount(): void { - $accountName = 'Test Account'; - $emailAddress = 'test@example.com'; - - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); - - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); - - $mailConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); - - $expectedResponse = $this->createMock(JSONResponse::class); - - $this->accountsController - ->expects($this->once()) - ->method('create') - ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($expectedResponse); - - $reflection = new ReflectionClass($this->controller); - $method = $reflection->getMethod('createNextcloudMailAccount'); - $method->setAccessible(true); - - $result = $method->invoke($this->controller, $accountName, $mailConfig); - - $this->assertSame($expectedResponse, $result); - } } From d4022b9df92bef80c33acde5865a876710017c72 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 13:47:00 +0100 Subject: [PATCH 19/19] IONOS(ionos-mail): fix(NewEmailAddressTab): improve error message for existing email address conflict Updated the feedback message for the 409 error code to provide clearer guidance on existing IONOS email addresses, including the conflicting email address. Signed-off-by: Misha M.-Kupriyanov --- src/components/ionos/NewEmailAddressTab.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue index cb4c290f49..5c65716ab3 100644 --- a/src/components/ionos/NewEmailAddressTab.vue +++ b/src/components/ionos/NewEmailAddressTab.vue @@ -128,6 +128,7 @@ export default { if (error.data?.error === 'IONOS_API_ERROR') { const statusCode = error.data?.statusCode + const existingEmail = error.data?.existingEmail || '' switch (statusCode) { case 400: @@ -137,7 +138,7 @@ export default { this.feedback = t('mail', 'Email service not found. Please contact support') break case 409: - this.feedback = t('mail', 'This email address already exists') + this.feedback = t('mail', 'You can only have one IONOS email address. Please use your existing account {email} or delete it first', { email: existingEmail }) break case 412: this.feedback = t('mail', 'Account state conflict. Please try again later')