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/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/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 60b647296e..761d6fd5c0 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -8,15 +8,17 @@ */ 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; +use OCP\IUserSession; use Psr\Log\LoggerInterface; #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] @@ -29,8 +31,8 @@ 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, ) { parent::__construct($appName, $request); @@ -54,41 +56,64 @@ public function create(string $accountName, string $emailUser): JSONResponse { } try { - $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailUser, 'accountName' => $accountName ]); - $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); + $userId = $this->getUserIdOrFail(); - $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('Starting IONOS email account creation from web', [ + 'userId' => $userId, + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ]); + + $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $accountName); - return MailJsonResponse::fail($data); + $this->logger->info('Account creation completed successfully', [ + 'emailAddress' => $account->getEmail(), + 'accountName' => $accountName, + 'accountId' => $account->getId(), + 'userId' => $userId, + ]); + + return MailJsonResponse::success($account, Http::STATUS_CREATED); + } 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'); } } - 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 + * + * @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 + */ + private function buildServiceErrorResponse(ServiceException $e, string $context): JSONResponse { + $data = [ + 'error' => self::ERR_IONOS_API_ERROR, + '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/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/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/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/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/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/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/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/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/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 59279b6ee9..84c9027271 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -11,9 +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; @@ -27,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( @@ -119,21 +123,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(); @@ -164,7 +186,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, @@ -199,6 +221,45 @@ public function createEmailAccount(string $userName): MailAccountConfig { } } + /** + * 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 * @@ -255,38 +316,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 MailAccountResponse $response - * @return MailAccountConfig + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password */ - private function buildSuccessResponse(MailAccountResponse $response): MailAccountConfig { - $smtpServer = $response->getServer()->getSmtp(); - $imapServer = $response->getServer()->getImap(); + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { + 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 * @@ -390,4 +484,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/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/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') 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); diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index ad43b0085d..3a0186c66b 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -10,26 +10,26 @@ 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 IonosAccountCreationService&MockObject $accountCreationService; - private AccountsController&MockObject $accountsController; + private IUserSession&MockObject $userSession; private LoggerInterface|MockObject $logger; @@ -40,19 +40,28 @@ 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, ); } + /** + * 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,87 +84,90 @@ public function testCreateSuccess(): void { $accountName = 'Test Account'; $emailUser = 'test'; $emailAddress = 'test@example.com'; + $userId = 'test-user-123'; - // Create MailAccountConfig DTO - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + // Setup user session + $this->setupUserSession($userId); - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - 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); - $mailAccountConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); + $account = new Account($mailAccount); - // Mock successful IONOS mail service response - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willReturn($mailAccountConfig); + // Verify response matches the expected MailJsonResponse::success() format + $accountResponse = \OCA\Mail\Http\JsonResponse::success($account, 201); - // Mock account creation response - $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; - $accountResponse = $this->createMock(JSONResponse::class); - $accountResponse->method('getData')->willReturn($accountData); + // Mock account creation service to return a successful account + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); - $this->accountsController - ->method('create') - ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($accountResponse); + // Verify logging calls + $this->logger + ->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + $this->assertEquals('Starting IONOS email account creation from web', $message); + $this->assertEquals([ + 'userId' => $userId, + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ], $context); + } elseif ($callCount === 2) { + $this->assertEquals('Account creation completed successfully', $message); + $this->assertEquals([ + 'emailAddress' => $emailAddress, + 'accountName' => $accountName, + 'accountId' => 1, + 'userId' => $userId, + ], $context); + } + }); $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 { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; - // Mock IONOS mail service to throw ServiceException - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Setup user session + $this->setupUserSession($userId); + + // 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 ->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); @@ -165,26 +177,82 @@ 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') - ->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 ->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); + + 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); @@ -194,11 +262,28 @@ 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 - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willThrowException(new \Exception('Generic error')); + // Mock account creation service to throw a generic exception + $exception = new \Exception('Generic error'); + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->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, @@ -210,60 +295,33 @@ public function testCreateWithGenericException(): void { self::assertEquals($expectedResponse, $response); } - - public function testCreateNextcloudMailAccount(): void { + public function testCreateWithNoUserSession(): 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, - ); + $emailUser = 'test'; - $expectedResponse = $this->createMock(JSONResponse::class); + // Mock user session to return null (no user logged in) + $this->userSession->method('getUser')->willReturn(null); - $this->accountsController + // Should catch the ServiceException thrown by getUserIdOrFail + $this->logger ->expects($this->once()) - ->method('create') + ->method('error') ->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); + '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); } } 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']); + } +} 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()); + } +} 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()); + } } 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()); + } } 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()); + } +} 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; + } + }; + } +} 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') 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()) diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 75ba54a88d..2af70670cb 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; @@ -28,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; @@ -50,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() @@ -106,116 +169,89 @@ 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(); - $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']) @@ -225,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'); @@ -312,74 +317,64 @@ 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); } - /** - * Test SSL mode normalization with various API response values - * - * @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); + public function testCreateEmailAccountForUserSuccess(): void { + $userId = 'admin123'; + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient')->willReturn($client); + // No user session needed for this method - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); - // 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); + $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; + })); - $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); + $result = $this->service->createEmailAccountForUser($userId, self::TEST_USER_NAME); - $mailServer = $this->getMockBuilder(MailServer::class) - ->disableOriginalConstructor() - ->onlyMethods(['getImap', 'getSmtp']) - ->getMock(); - $mailServer->method('getImap')->willReturn($imapServer); - $mailServer->method('getSmtp')->willReturn($smtpServer); + $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()); + } - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) - ->disableOriginalConstructor() - ->onlyMethods(['getEmail', 'getPassword', 'getServer']) - ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); - $mailAccountResponse->method('getServer')->willReturn($mailServer); + /** + * Test SSL mode normalization with various API response values + * + * @dataProvider sslModeNormalizationProvider + */ + public function testSslModeNormalization(string $apiSslMode, string $expectedSecurity): void { + $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()); @@ -432,39 +427,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(); @@ -472,26 +454,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, @@ -500,12 +471,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(); @@ -513,26 +482,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, @@ -541,18 +499,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(); @@ -561,39 +516,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(); @@ -603,73 +544,42 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralExceptio public function testDeleteEmailAccountSuccess(): 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'); - - // 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); + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // 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'; + $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 (mailbox doesn't exist) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -679,48 +589,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'; - - // 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 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -730,295 +629,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'; + $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 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'); + $this->setupConfigMocks(allowInsecure: true); + $apiInstance = $this->setupApiClient(verifySSL: false); - // 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); - - // 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, ] ); @@ -1027,73 +837,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'; - - // 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 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'; - - // 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 $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -1102,36 +890,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, @@ -1140,60 +917,187 @@ 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'; + $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 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); + } + + 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); + } } diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php new file mode 100644 index 0000000000..8637b2c1ff --- /dev/null +++ b/tests/Unit/Service/SetupServiceTest.php @@ -0,0 +1,386 @@ +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 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'); + + $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 + ); + } +}