diff --git a/appinfo/info.xml b/appinfo/info.xml index 92f33f3427..98ec76f017 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -85,6 +85,7 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\Mail\Command\DiagnoseAccount OCA\Mail\Command\ExportAccount OCA\Mail\Command\ExportAccountThreads + OCA\Mail\Command\IonosCreateAccount OCA\Mail\Command\PredictImportance OCA\Mail\Command\SyncAccount OCA\Mail\Command\Thread 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/Command/IonosCreateAccount.php b/lib/Command/IonosCreateAccount.php new file mode 100644 index 0000000000..da9d495007 --- /dev/null +++ b/lib/Command/IonosCreateAccount.php @@ -0,0 +1,212 @@ +setName('mail:ionos:create'); + $this->setDescription('Creates IONOS mail account and configure it in Nextcloud'); + $this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED, 'User ID'); + $this->addArgument(self::ARGUMENT_EMAIL_USER, InputArgument::REQUIRED, 'IONOS Email user. (The local part of the email address before @domain)'); + $this->addOption(self::OPTION_NAME, '', InputOption::VALUE_REQUIRED, 'Account name'); + $this->addOption(self::OPTION_OUTPUT, '', InputOption::VALUE_OPTIONAL, 'Output format (json, json_pretty)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument(self::ARGUMENT_USER_ID); + $emailUser = $input->getArgument(self::ARGUMENT_EMAIL_USER); + $name = $input->getOption(self::OPTION_NAME); + $outputFormat = $input->getOption(self::OPTION_OUTPUT); + $isJsonOutput = in_array($outputFormat, ['json', 'json_pretty'], true); + + // Preflight checks + if (!$isJsonOutput) { + $output->writeln('Running preflight checks...'); + } + + // Check if IONOS integration is enabled and configured + if (!$this->configService->isIonosIntegrationEnabled()) { + if ($isJsonOutput) { + $this->outputJson($output, [ + 'success' => false, + 'error' => 'IONOS integration is not enabled or not properly configured', + 'details' => [ + 'ionos-mailconfig-enabled' => 'must be set to "yes"', + 'ionos_mailconfig_api_base_url' => 'must be configured', + 'ionos_mailconfig_api_auth_user' => 'must be configured', + 'ionos_mailconfig_api_auth_pass' => 'must be configured', + 'ncw.ext_ref' => 'must be configured in system config', + ] + ], $outputFormat); + } else { + $output->writeln('IONOS integration is not enabled or not properly configured'); + $output->writeln('Please verify the following configuration:'); + $output->writeln(' - ionos-mailconfig-enabled is set to "yes"'); + $output->writeln(' - ionos_mailconfig_api_base_url is configured'); + $output->writeln(' - ionos_mailconfig_api_auth_user is configured'); + $output->writeln(' - ionos_mailconfig_api_auth_pass is configured'); + $output->writeln(' - ncw.ext_ref is configured in system config'); + } + return 1; + } + + // Get and display the mail domain + $mailDomain = $this->configService->getMailDomain(); + if (empty($mailDomain)) { + if ($isJsonOutput) { + $this->outputJson($output, [ + 'success' => false, + 'error' => 'Mail domain could not be determined', + 'details' => 'Please verify ncw.customerDomain is configured in system config' + ], $outputFormat); + } else { + $output->writeln('Mail domain could not be determined'); + $output->writeln('Please verify ncw.customerDomain is configured in system config'); + } + return 1; + } + + if (!$isJsonOutput) { + $output->writeln('✓ IONOS API is properly configured'); + $output->writeln('✓ Mail domain: ' . $mailDomain . ''); + $output->writeln(''); + } + + if (!$this->userManager->userExists($userId)) { + if ($isJsonOutput) { + $this->outputJson($output, [ + 'success' => false, + 'error' => 'User does not exist', + 'userId' => $userId + ], $outputFormat); + } else { + $output->writeln("User $userId does not exist"); + } + return 1; + } + + if (!$isJsonOutput) { + $output->writeln('Creating IONOS mail account...'); + $output->writeln(' user-id: ' . $userId); + $output->writeln(' name: ' . $name); + $output->writeln(' email-user: ' . $emailUser); + $output->writeln(' full-email: ' . $emailUser . '@' . $mailDomain); + } + + try { + $this->logger->info('Starting IONOS email account creation from CLI', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'accountName' => $name + ]); + + // Use the shared account creation service + $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $name); + + if ($isJsonOutput) { + $this->outputJson($output, [ + 'success' => true, + 'account' => [ + 'id' => $account->getId(), + 'userId' => $account->getUserId(), + 'name' => $account->getName(), + 'email' => $account->getEmail(), + 'inbound' => [ + 'host' => $account->getInboundHost(), + 'port' => $account->getInboundPort(), + 'sslMode' => $account->getInboundSslMode(), + 'user' => $account->getInboundUser(), + ], + 'outbound' => [ + 'host' => $account->getOutboundHost(), + 'port' => $account->getOutboundPort(), + 'sslMode' => $account->getOutboundSslMode(), + 'user' => $account->getOutboundUser(), + ] + ] + ], $outputFormat); + } else { + $output->writeln('Account created successfully!'); + $output->writeln(' Account ID: ' . $account->getId()); + $output->writeln(' Email: ' . $account->getEmail()); + } + + return 0; + } catch (ServiceException $e) { + $this->logger->error('IONOS service error: ' . $e->getMessage(), [ + 'statusCode' => $e->getCode() + ]); + + if ($isJsonOutput) { + $this->outputJson($output, [ + 'success' => false, + 'error' => 'IONOS service error', + 'message' => $e->getMessage(), + 'statusCode' => $e->getCode() + ], $outputFormat); + } else { + $output->writeln('Failed to create IONOS account: ' . $e->getMessage() . ''); + } + return 1; + } catch (\Exception $e) { + $this->logger->error('Unexpected error creating account: ' . $e->getMessage()); + + if ($isJsonOutput) { + $this->outputJson($output, [ + 'success' => false, + 'error' => 'Unexpected error', + 'message' => $e->getMessage() + ], $outputFormat); + } else { + $output->writeln('Could not create account: ' . $e->getMessage() . ''); + } + return 1; + } + } + + /** + * Output data as JSON based on the specified format + */ + private function outputJson(OutputInterface $output, array $data, ?string $format): void { + if ($format === 'json_pretty') { + $output->writeln(json_encode($data, JSON_PRETTY_PRINT)); + } else { + $output->writeln(json_encode($data)); + } + } +} 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..06706462a0 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -11,12 +11,12 @@ 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\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; use Psr\Log\LoggerInterface; #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] @@ -29,8 +29,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); @@ -53,42 +53,72 @@ public function create(string $accountName, string $emailUser): JSONResponse { return $error; } + $userId = $this->getUserIdOrFail(); + if ($userId instanceof JSONResponse) { + return $userId; + } + try { - $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailUser, 'accountName' => $accountName ]); - $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); + $this->logger->info('Starting IONOS email account creation from web', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'accountName' => $accountName + ]); + + // Use the shared account creation service + $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $accountName); - $this->logger->info('IONOS email account created successfully', [ 'emailAddress' => $ionosResponse->getEmail() ]); - return $this->createNextcloudMailAccount($accountName, $ionosResponse); + $this->logger->info('Account creation completed successfully', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $userId, + ]); + + return new JSONResponse([ + 'id' => $account->getId(), + 'accountName' => $account->getName(), + 'emailAddress' => $account->getEmail(), + ]); } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, 'account creation'); + } catch (\Exception $e) { + $this->logger->error('Unexpected error during account creation: ' . $e->getMessage(), [ + 'exception' => $e, + 'userId' => $userId, + ]); + return MailJsonResponse::error('Could not create account'); + } + } + + /** + * Get the current user ID or return error response + * + * @return string|JSONResponse User ID string or error response + */ + private function getUserIdOrFail(): string|JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { $data = [ 'error' => self::ERR_IONOS_API_ERROR, - 'statusCode' => $e->getCode(), + 'statusCode' => 401, + 'message' => 'No user session found', ]; - $this->logger->error('IONOS service error: ' . $e->getMessage(), $data); - + $this->logger->error('No user session found during account creation', $data); return MailJsonResponse::fail($data); - } catch (\Exception $e) { - return MailJsonResponse::error('Could not create account'); } + return $user->getUID(); } - 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(), - ); + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $context): JSONResponse { + $data = [ + 'error' => self::ERR_IONOS_API_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); + return MailJsonResponse::fail($data); } } diff --git a/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..ffbd354b95 --- /dev/null +++ b/lib/Service/IONOS/IonosAccountCreationService.php @@ -0,0 +1,205 @@ +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): MailAccount { + $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 ServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + 409 + ); + } + throw new ServiceException('Nextcloud account exists but no IONOS account found', 500); + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->updateAccount($existingAccount->getMailAccount(), $accountName, $mailConfig); + } 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): MailAccount { + 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 ServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + 409, + $e + ); + } + // 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): MailAccount { + $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 $account; + } + + /** + * Update an existing Nextcloud mail account + */ + private function updateAccount(MailAccount $account, string $accountName, MailAccountConfig $mailConfig): MailAccount { + $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 $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..6de51f7ccb 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; @@ -119,21 +122,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 +185,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 +220,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 +315,71 @@ private function normalizeSslMode(string $apiSslMode): string { } /** - * Build success response with mail configuration + * Build mail account configuration from server details * - * @param MailAccountResponse $response - * @return MailAccountConfig + * @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 buildSuccessResponse(MailAccountResponse $response): MailAccountConfig { - $smtpServer = $response->getServer()->getSmtp(); - $imapServer = $response->getServer()->getImap(); - + 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(), + '' + ); + } + + /** + * Build configuration from MailAccountCreatedResponse (newly created account) + * + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password + */ + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + $response->getPassword() + ); + } + /** * Delete an IONOS email account via API * @@ -338,6 +431,66 @@ public function deleteEmailAccount(string $userId): bool { } } + /** + * 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 email address of the IONOS account for a specific user * @@ -390,4 +543,13 @@ public function tryDeleteEmailAccount(string $userId): void { // Don't throw - this is a fire and forget operation } } + + /** + * 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/tests/Unit/Command/IonosCreateAccountTest.php b/tests/Unit/Command/IonosCreateAccountTest.php new file mode 100644 index 0000000000..6cc7786f9b --- /dev/null +++ b/tests/Unit/Command/IonosCreateAccountTest.php @@ -0,0 +1,195 @@ +accountCreationService = $this->createMock(IonosAccountCreationService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->configService = $this->createMock(IonosConfigService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->command = new IonosCreateAccount( + $this->accountCreationService, + $this->userManager, + $this->configService, + $this->logger + ); + } + + private function createInputMock(string $userId, string $name, string $emailUser): InputInterface&MockObject { + $input = $this->createMock(InputInterface::class); + $arguments = [ + 'user-id' => $userId, + 'email-user' => $emailUser, + ]; + $options = [ + 'name' => $name, + 'output' => null, + ]; + $input->method('getArgument') + ->willReturnCallback(fn ($arg) => $arguments[$arg] ?? null); + $input->method('getOption') + ->willReturnCallback(fn ($opt) => $options[$opt] ?? null); + return $input; + } + + private function createOutputMock(): OutputInterface&MockObject { + return $this->createMock(OutputInterface::class); + } + + + public function testName(): void { + $this->assertSame('mail:ionos:create', $this->command->getName()); + } + + public function testDescription(): void { + $this->assertSame('Creates IONOS mail account and configure it in Nextcloud', $this->command->getDescription()); + } + + public function testArguments(): void { + $definition = $this->command->getDefinition(); + + // Check arguments + $arguments = $definition->getArguments(); + $argumentNames = array_map(fn ($arg) => $arg->getName(), $arguments); + + $this->assertCount(2, $arguments); + $this->assertContains('user-id', $argumentNames); + $this->assertContains('email-user', $argumentNames); + + foreach ($arguments as $arg) { + $this->assertTrue($arg->isRequired()); + } + + // Check options + $options = $definition->getOptions(); + $optionNames = array_map(fn ($opt) => $opt->getName(), $options); + + $this->assertContains('name', $optionNames); + $this->assertContains('output', $optionNames); + + // Verify 'name' option is required + $nameOption = $definition->getOption('name'); + $this->assertTrue($nameOption->isValueRequired()); + + // Verify 'output' option is optional + $outputOption = $definition->getOption('output'); + $this->assertTrue($outputOption->isValueOptional()); + } + + public function testInvalidUserId(): void { + $userId = 'invalidUser'; + $input = $this->createInputMock($userId, self::TEST_ACCOUNT_NAME, 'testuser'); + $output = $this->createOutputMock(); + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn(self::TEST_MAIL_DOMAIN); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with($userId) + ->willReturn(false); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testSuccessfulAccountCreation(): void { + $accountId = 42; + + $input = $this->createInputMock(self::TEST_USER_ID, self::TEST_ACCOUNT_NAME, self::TEST_EMAIL_USER); + $output = $this->createOutputMock(); + + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn(self::TEST_MAIL_DOMAIN); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with(self::TEST_USER_ID) + ->willReturn(true); + + $savedAccount = new MailAccount(); + $savedAccount->setId($accountId); + $savedAccount->setEmail(self::TEST_EMAIL_USER); + + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with(self::TEST_USER_ID, self::TEST_EMAIL_USER, self::TEST_ACCOUNT_NAME) + ->willReturn($savedAccount); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } + + public function testServiceException(): void { + $input = $this->createInputMock(self::TEST_USER_ID, self::TEST_ACCOUNT_NAME, self::TEST_EMAIL_USER); + $output = $this->createOutputMock(); + + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn(self::TEST_MAIL_DOMAIN); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with(self::TEST_USER_ID) + ->willReturn(true); + + $exception = new ServiceException('IONOS API error', 500); + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with(self::TEST_USER_ID, self::TEST_EMAIL_USER, self::TEST_ACCOUNT_NAME) + ->willThrowException($exception); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } +} 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..06b8b4bb50 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\Controller\IonosAccountsController; +use OCA\Mail\Db\MailAccount; 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 OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -use ReflectionClass; 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,63 @@ public function testCreateSuccess(): void { $accountName = 'Test Account'; $emailUser = 'test'; $emailAddress = 'test@example.com'; + $userId = 'testuser'; - // Create MailAccountConfig DTO - $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', - ); - - $mailAccountConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); + // Setup user session + $this->setupUserSession($userId); - // Mock successful IONOS mail service response - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willReturn($mailAccountConfig); + // Create a real MailAccount instance + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId($userId); + $mailAccount->setName($accountName); + $mailAccount->setEmail($emailAddress); - // Mock account creation response - $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; - $accountResponse = $this->createMock(JSONResponse::class); - $accountResponse->method('getData')->willReturn($accountData); - - $this->accountsController - ->method('create') - ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($accountResponse); + // Mock account creation service to return a successful account + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($mailAccount); $response = $this->controller->create($accountName, $emailUser); - // The controller now directly returns the AccountsController response - $this->assertSame($accountResponse, $response); + $this->assertInstanceOf(JSONResponse::class, $response); + $data = $response->getData(); + $this->assertEquals(1, $data['id']); + $this->assertEquals($accountName, $data['accountName']); + $this->assertEquals($emailAddress, $data['emailAddress']); } public function testCreateWithServiceException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'testuser'; + + // Setup user session + $this->setupUserSession($userId); - // Mock IONOS mail service to throw ServiceException - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw ServiceException + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Failed to create email account')); $this->logger ->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 +150,34 @@ public function testCreateWithServiceException(): void { public function testCreateWithServiceExceptionWithStatusCode(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'testuser'; - // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Setup user session + $this->setupUserSession($userId); + + // 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); @@ -194,10 +187,15 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { public function testCreateWithGenericException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'testuser'; + + // Setup user session + $this->setupUserSession($userId); - // Mock IONOS mail service to throw a generic exception - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw a generic exception + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new \Exception('Generic error')); $expectedResponse = \OCA\Mail\Http\JsonResponse::error('Could not create account', @@ -209,61 +207,4 @@ public function testCreateWithGenericException(): void { self::assertEquals($expectedResponse, $response); } - - - public function testCreateNextcloudMailAccount(): void { - $accountName = 'Test Account'; - $emailAddress = 'test@example.com'; - - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); - - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); - - $mailConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); - - $expectedResponse = $this->createMock(JSONResponse::class); - - $this->accountsController - ->expects($this->once()) - ->method('create') - ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($expectedResponse); - - $reflection = new ReflectionClass($this->controller); - $method = $reflection->getMethod('createNextcloudMailAccount'); - $method->setAccessible(true); - - $result = $method->invoke($this->controller, $accountName, $mailConfig); - - $this->assertSame($expectedResponse, $result); - } } 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..9e37888dda --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php @@ -0,0 +1,475 @@ +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(MailAccount::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(MailAccount::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()); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(409); + $this->expectExceptionMessage('IONOS account exists but email mismatch'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + 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->logger->expects($this->once()) + ->method('info') + ->with('Nextcloud mail account already exists, resetting credentials', $this->anything()); + + $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 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(MailAccount::class, $result); + $this->assertEquals(2, $result->getId()); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionFails(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Creation failed', 500); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $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->expectExceptionMessage('Creation failed'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $expectedEmail = 'test@example.com'; + $existingEmail = 'other@example.com'; + + $resolutionResult = ConflictResolutionResult::emailMismatch($expectedEmail, $existingEmail); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $expectedEmail) + ->willReturn([]); + + $originalException = new ServiceException('Account already exists', 409); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $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(409); + $this->expectExceptionMessage('IONOS account exists but email mismatch'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + 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; + }); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(MailAccount::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 + */ + 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..4d2db79546 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); + /** + * 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); + } - // Mock user session + /** + * 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); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API 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'); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with 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'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception (mailbox doesn't exist) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -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'); - - // Mock API client - verify should be false - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => false, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); + $this->setupConfigMocks(allowInsecure: true); + $apiInstance = $this->setupApiClient(verifySSL: false); - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - $this->logger->expects($this->exactly(2)) - ->method('info'); + $this->logger->expects($this->exactly(2))->method('info'); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testTryDeleteEmailAccountWhenIntegrationDisabled(): void { - $userId = 'testuser123'; - - // Mock integration as disabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(false); - // Should log that integration is not enabled $this->logger->expects($this->once()) ->method('debug') ->with( 'IONOS integration is not enabled, skipping email account deletion', - ['userId' => $userId] + ['userId' => self::TEST_USER_ID] ); - // Should not attempt to create API client - $this->apiClientService->expects($this->never()) - ->method('newClient'); + $this->apiClientService->expects($this->never())->method('newClient'); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledSuccess(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Should log success at info level (from deleteEmailAccount only) $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'Attempting to delete IONOS email account') { - $this->assertSame($userId, $context['userId']); - $this->assertSame('test-ext-ref', $context['extRef']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); + $this->assertSame(self::TEST_EXT_REF, $context['extRef']); } elseif ($message === 'Successfully deleted IONOS email account') { - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); } }); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledButDeletionFails(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('API Error', 500); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log the error from deleteEmailAccount and then from tryDeleteEmailAccount $this->logger->expects($this->exactly(2)) ->method('error') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'API Exception when calling MailConfigurationAPIApi->deleteMailbox') { - // This is from deleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertSame(500, $context['statusCode']); } elseif ($message === 'Failed to delete IONOS mailbox for user') { - // This is from tryDeleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertInstanceOf(ServiceException::class, $context['exception']); } }); - // Call tryDeleteEmailAccount - should NOT throw exception (fire and forget) - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock 404 API exception (mailbox already deleted or never existed) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('Not Found', 404); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log at info level (from deleteEmailAccount) and debug (404 is treated as success) $this->logger->expects($this->once()) ->method('info') ->with( 'Attempting to delete IONOS email account', [ - 'userId' => $userId, - 'extRef' => 'test-ext-ref', + 'userId' => self::TEST_USER_ID, + 'extRef' => self::TEST_EXT_REF, ] ); @@ -1027,20 +837,131 @@ 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 { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEmail']) + ->getMock(); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willReturn($mailAccountResponse); + + $this->logger->expects($this->exactly(2))->method('debug'); + + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); + + $this->assertEquals(self::TEST_EMAIL, $result); + } + + public function testGetIonosEmailForUserReturnsNullWhen404(): 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); + + $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); + + $this->logger->expects($this->exactly(2))->method('debug'); + + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); + + $this->assertNull($result); + } + + public function testGetIonosEmailForUserReturnsNullOnApiError(): 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); + + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Internal Server Error', + 500, + [], + '{"error": "Server error"}' + ); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willThrowException($apiException); + + $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'; + })); + + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); + + $this->assertNull($result); + } + + public function testGetIonosEmailForUserReturnsNullOnGeneralException(): 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); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willThrowException(new \Exception('Unexpected error')); + + $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'] === self::TEST_USER_ID; + })); + + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); + + $this->assertNull($result); + } + + public function testGetAccountConfigForUserReturnsConfigWhenAccountExists(): void { $userId = 'testuser123'; - $expectedEmail = 'testuser@example.com'; + $emailAddress = 'testuser@example.com'; // Mock config $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); @@ -1057,26 +978,72 @@ public function testGetIonosEmailForUserReturnsEmailWhenAccountExists(): void { $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API response with existing account + $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'); + + $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'); + + $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']) + ->onlyMethods(['getEmail', 'getServer']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($expectedEmail); + $mailAccountResponse->method('getEmail')->willReturn($emailAddress); + $mailAccountResponse->method('getServer')->willReturn($mailServer); $apiInstance->method('getFunctionalAccount') ->with('IONOS', 'test-ext-ref', $userId) ->willReturn($mailAccountResponse); // Expect logging calls - $this->logger->expects($this->exactly(2)) + // 1 debug from getMailAccountResponse (getting account) + // 2 debug from buildConfigFromAccountResponse (normalizeSslMode for IMAP and SMTP) + // 1 info from getAccountConfigForUser + $this->logger->expects($this->exactly(3)) ->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $this->logger->expects($this->once()) + ->method('info') + ->with('Retrieved existing IONOS account configuration', $this->callback(function ($context) use ($emailAddress, $userId) { + return $context['email'] === $emailAddress + && $context['userId'] === $userId; + })); - $this->assertEquals($expectedEmail, $result); + $result = $this->service->getAccountConfigForUser($userId); + + $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('ssl', $result->getImap()->getSecurity()); + $this->assertEquals($emailAddress, $result->getImap()->getUsername()); + // Password is not available when retrieving existing accounts (security) + $this->assertEquals('', $result->getImap()->getPassword()); + $this->assertEquals('smtp.example.com', $result->getSmtp()->getHost()); + $this->assertEquals(587, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals($emailAddress, $result->getSmtp()->getUsername()); + $this->assertEquals('', $result->getSmtp()->getPassword()); } - public function testGetIonosEmailForUserReturnsNullWhen404(): void { + public function testGetAccountConfigForUserReturnsNullWhenAccountDoesNotExist(): void { $userId = 'testuser123'; // Mock config @@ -1093,7 +1060,7 @@ public function testGetIonosEmailForUserReturnsNullWhen404(): void { $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception + // Mock API to throw 404 exception (account doesn't exist) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -1106,15 +1073,29 @@ public function testGetIonosEmailForUserReturnsNullWhen404(): void { ->willThrowException($apiException); // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + // 1 debug from getMailAccountResponse (getting account) + // 1 debug from getMailAccountResponse catch block (404 not found) + // 1 debug from getAccountConfigForUser (no existing account) + $this->logger->expects($this->exactly(3)) + ->method('debug') + ->willReturnCallback(function ($message, $context) use ($userId) { + if ($message === 'Getting IONOS mail account for user') { + $this->assertEquals($userId, $context['userId']); + $this->assertEquals('test-ext-ref', $context['extRef']); + } elseif ($message === 'User does not have IONOS mail account') { + $this->assertEquals($userId, $context['userId']); + $this->assertEquals(404, $context['statusCode']); + } elseif ($message === 'No existing IONOS account found for user') { + $this->assertEquals($userId, $context['userId']); + } + }); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getAccountConfigForUser($userId); $this->assertNull($result); } - public function testGetIonosEmailForUserReturnsNullOnApiError(): void { + public function testGetAccountConfigForUserReturnsNullOnApiError(): void { $userId = 'testuser123'; // Mock config @@ -1144,56 +1125,135 @@ public function testGetIonosEmailForUserReturnsNullOnApiError(): void { ->willThrowException($apiException); // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); + // 1 debug from getMailAccountResponse (getting account) + // 1 debug from getAccountConfigForUser (no existing account) + $this->logger->expects($this->exactly(2)) + ->method('debug') + ->willReturnCallback(function ($message, $context) use ($userId) { + if ($message === 'Getting IONOS mail account for user') { + $this->assertEquals($userId, $context['userId']); + $this->assertEquals('test-ext-ref', $context['extRef']); + } elseif ($message === 'No existing IONOS account found for user') { + $this->assertEquals($userId, $context['userId']); + } + }); $this->logger->expects($this->once()) ->method('error') - ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { + ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) use ($userId) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error' + && $context['userId'] === $userId; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getAccountConfigForUser($userId); $this->assertNull($result); } - public function testGetIonosEmailForUserReturnsNullOnGeneralException(): void { + public function testResetAppPasswordSuccess(): void { $userId = 'testuser123'; + $appName = 'NEXTCLOUD_WORKSPACE'; + $expectedPassword = 'new-app-password-123'; - // 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(); + + $apiInstance->expects($this->once()) + ->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, $userId, $appName) + ->willReturn($expectedPassword); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Resetting IONOS app password', $this->callback(function ($context) use ($userId, $appName) { + return $context['userId'] === $userId + && $context['appName'] === $appName + && $context['extRef'] === self::TEST_EXT_REF; + })); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Successfully reset IONOS app password', $this->callback(function ($context) use ($userId, $appName) { + return $context['userId'] === $userId + && $context['appName'] === $appName; + })); + + $result = $this->service->resetAppPassword($userId, $appName); + + $this->assertEquals($expectedPassword, $result); + } + + public function testResetAppPasswordWithApiException(): void { + $userId = 'testuser123'; + $appName = 'NEXTCLOUD_WORKSPACE'; + + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception - $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) - ->willThrowException(new \Exception('Unexpected error')); + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Mailbox not found"}' + ); + + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, $userId, $appName) + ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) - ->method('debug'); + ->method('debug') + ->with('Resetting IONOS app password', $this->anything()); $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'; + ->with('API Exception when calling MailConfigurationAPIApi->setAppPassword', $this->callback(function ($context) use ($userId, $appName) { + return $context['statusCode'] === 404 + && $context['message'] === 'Not Found' + && $context['userId'] === $userId + && $context['appName'] === $appName; })); - $result = $this->service->getIonosEmailForUser($userId); + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password: Not Found'); + $this->expectExceptionCode(404); - $this->assertNull($result); + $this->service->resetAppPassword($userId, $appName); + } + + public function testResetAppPasswordWithUnexpectedResponse(): void { + $userId = 'testuser123'; + $appName = 'NEXTCLOUD_WORKSPACE'; + + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + + // API returns unexpected response type (not a string) + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, $userId, $appName) + ->willReturn(['unexpected' => 'response']); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Resetting IONOS app password', $this->anything()); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Failed to reset IONOS app password: Unexpected response type', $this->callback(function ($context) use ($userId, $appName) { + return $context['userId'] === $userId + && $context['appName'] === $appName; + })); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password'); + $this->expectExceptionCode(500); + + $this->service->resetAppPassword($userId, $appName); } } 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 + ); + } +}