diff --git a/appinfo/routes.php b/appinfo/routes.php index c62dc954c8..6b10476d73 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -120,9 +120,20 @@ 'url' => '/api/tags', 'verb' => 'POST' ], + // External provider routes (generic) [ - 'name' => 'ionosAccounts#create', - 'url' => '/api/ionos/accounts', + 'name' => 'externalAccounts#getProviders', + 'url' => '/api/providers', + 'verb' => 'GET' + ], + [ + 'name' => 'externalAccounts#create', + 'url' => '/api/providers/{providerId}/accounts', + 'verb' => 'POST' + ], + [ + 'name' => 'externalAccounts#generatePassword', + 'url' => '/api/providers/{providerId}/password', 'verb' => 'POST' ], [ 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/AppInfo/Application.php b/lib/AppInfo/Application.php index 3004b98093..325c5d5b37 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -54,6 +54,8 @@ use OCA\Mail\Listener\TaskProcessingListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; +use OCA\Mail\Provider\MailAccountProvider\Implementations\IonosProvider; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Provider\MailProvider; use OCA\Mail\Search\FilteringProvider; use OCA\Mail\Service\Attachment\AttachmentService; @@ -172,5 +174,19 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { + $container = $context->getServerContainer(); + + // Register mail account providers + try { + $providerRegistry = $container->get(ProviderRegistryService::class); + $ionosProvider = $container->get(IonosProvider::class); + $providerRegistry->registerProvider($ionosProvider); + } catch (\Exception $e) { + // Log but don't fail - provider registration is optional + $logger = $container->get(\Psr\Log\LoggerInterface::class); + $logger->error('Failed to register mail account providers', [ + 'exception' => $e, + ]); + } } } 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/ExternalAccountsController.php b/lib/Controller/ExternalAccountsController.php new file mode 100644 index 0000000000..a293031132 --- /dev/null +++ b/lib/Controller/ExternalAccountsController.php @@ -0,0 +1,271 @@ +getUserIdOrFail(); + + // Get parameters from request body + $parameters = $this->request->getParams(); + + // Remove Nextcloud-specific parameters + unset($parameters['providerId']); + unset($parameters['_route']); + + $this->logger->info('Starting external mail account creation', [ + 'userId' => $userId, + 'providerId' => $providerId, + 'parameters' => array_keys($parameters), + ]); + + // Get the provider + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found: ' . $providerId, + ], Http::STATUS_NOT_FOUND); + } + + // Check if provider is enabled and available for this user + if (!$provider->isEnabled()) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_AVAILABLE, + 'message' => 'Provider is not enabled: ' . $providerId, + ], Http::STATUS_BAD_REQUEST); + } + + if (!$provider->isAvailableForUser($userId)) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_AVAILABLE, + 'message' => 'Provider is not available for this user', + ], Http::STATUS_BAD_REQUEST); + } + + // Create the account + $account = $provider->createAccount($userId, $parameters); + + $this->logger->info('External account creation completed successfully', [ + 'emailAddress' => $account->getEmail(), + 'accountId' => $account->getId(), + 'userId' => $userId, + 'providerId' => $providerId, + ]); + + return MailJsonResponse::success($account, Http::STATUS_CREATED); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\InvalidArgumentException $e) { + $this->logger->error('Invalid parameters for account creation', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $this->logger->error('Unexpected error during external account creation', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not create account'); + } + } + + /** + * Get information about available providers + * + * @NoAdminRequired + * + * @return JSONResponse + */ + #[TrapError] + public function getProviders(): JSONResponse { + try { + $userId = $this->getUserIdOrFail(); + $availableProviders = $this->providerRegistry->getAvailableProvidersForUser($userId); + + $providersInfo = []; + foreach ($availableProviders as $provider) { + $capabilities = $provider->getCapabilities(); + $providersInfo[] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'parameterSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + + return MailJsonResponse::success([ + 'providers' => $providersInfo, + ]); + } catch (\Exception $e) { + $this->logger->error('Error getting available providers', [ + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not get providers'); + } + } + + /** + * Generate an app password for a provider-managed account + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $providerId The provider ID + * @return JSONResponse + */ + #[TrapError] + public function generatePassword(string $providerId): JSONResponse { + // Get accountId from request body + $accountId = $this->request->getParam('accountId'); + + if ($accountId === null) { + return MailJsonResponse::fail(['error' => 'Account ID is required']); + } + + try { + $userId = $this->getUserIdOrFail(); + + $this->logger->info('Generating app password', [ + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found', + ], Http::STATUS_NOT_FOUND); + } + + // Check if provider supports app passwords + if (!$provider->getCapabilities()->supportsAppPasswords()) { + return MailJsonResponse::fail([ + 'error' => 'NOT_SUPPORTED', + 'message' => 'Provider does not support app passwords', + ], Http::STATUS_BAD_REQUEST); + } + + // For now, delegate to IONOS-specific implementation + // In the future, this should be a method on the provider interface + if ($providerId === 'ionos') { + $ionosMailService = \OC::$server->get(\OCA\Mail\Service\IONOS\IonosMailService::class); + $password = $ionosMailService->generateUserAppPassword(); + + $this->logger->info('App password generated successfully', [ + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + + return MailJsonResponse::success(['password' => $password]); + } + + return MailJsonResponse::fail([ + 'error' => 'NOT_IMPLEMENTED', + 'message' => 'App password generation not implemented for this provider', + ], Http::STATUS_NOT_IMPLEMENTED); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\Exception $e) { + $this->logger->error('Unexpected error generating app password', [ + 'exception' => $e, + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + return MailJsonResponse::error('Could not generate app password'); + } + } + + /** + * Get the current user ID + * + * @return string User ID string + * @throws ServiceException + */ + private function getUserIdOrFail(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found', 401); + } + return $user->getUID(); + } + + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $providerId): JSONResponse { + $data = [ + 'error' => self::ERR_SERVICE_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + + // If it's an IonosServiceException, merge in the additional data + if ($e instanceof IonosServiceException) { + $data = array_merge($data, $e->getData()); + } + + $this->logger->error('Service error during provider operation', array_merge($data, [ + 'providerId' => $providerId, + ])); + + return MailJsonResponse::fail($data); + } +} diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 60b647296e..761d6fd5c0 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -8,15 +8,17 @@ */ namespace OCA\Mail\Controller; +use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\JsonResponse as MailJsonResponse; use OCA\Mail\Http\TrapError; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\IonosMailService; +use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; use Psr\Log\LoggerInterface; #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] @@ -29,8 +31,8 @@ class IonosAccountsController extends Controller { public function __construct( string $appName, IRequest $request, - private IonosMailService $ionosMailService, - private AccountsController $accountsController, + private IonosAccountCreationService $accountCreationService, + private IUserSession $userSession, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -54,41 +56,64 @@ public function create(string $accountName, string $emailUser): JSONResponse { } try { - $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailUser, 'accountName' => $accountName ]); - $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); + $userId = $this->getUserIdOrFail(); - $this->logger->info('IONOS email account created successfully', [ 'emailAddress' => $ionosResponse->getEmail() ]); - return $this->createNextcloudMailAccount($accountName, $ionosResponse); - } catch (ServiceException $e) { - $data = [ - 'error' => self::ERR_IONOS_API_ERROR, - 'statusCode' => $e->getCode(), - ]; - $this->logger->error('IONOS service error: ' . $e->getMessage(), $data); + $this->logger->info('Starting IONOS email account creation from web', [ + 'userId' => $userId, + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ]); + + $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $accountName); - return MailJsonResponse::fail($data); + $this->logger->info('Account creation completed successfully', [ + 'emailAddress' => $account->getEmail(), + 'accountName' => $accountName, + 'accountId' => $account->getId(), + 'userId' => $userId, + ]); + + return MailJsonResponse::success($account, Http::STATUS_CREATED); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, 'account creation'); } catch (\Exception $e) { + $this->logger->error('Unexpected error during account creation: ' . $e->getMessage(), [ + 'exception' => $e, + ]); return MailJsonResponse::error('Could not create account'); } } - private function createNextcloudMailAccount(string $accountName, MailAccountConfig $mailConfig): JSONResponse { - $imap = $mailConfig->getImap(); - $smtp = $mailConfig->getSmtp(); - - return $this->accountsController->create( - $accountName, - $mailConfig->getEmail(), - $imap->getHost(), - $imap->getPort(), - $imap->getSecurity(), - $imap->getUsername(), - $imap->getPassword(), - $smtp->getHost(), - $smtp->getPort(), - $smtp->getSecurity(), - $smtp->getUsername(), - $smtp->getPassword(), - ); + /** + * Get the current user ID + * + * @return string User ID string + * @throws ServiceException + */ + private function getUserIdOrFail(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found during account creation', 401); + } + return $user->getUID(); + } + + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $context): JSONResponse { + $data = [ + 'error' => self::ERR_IONOS_API_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + + // If it's an IonosServiceException, merge in the additional data + if ($e instanceof IonosServiceException) { + $data = array_merge($data, $e->getData()); + } + + $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); + return MailJsonResponse::fail($data); } } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 6166e15a02..ad6f0d817e 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -16,13 +16,12 @@ use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Service\AccountProviderService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\InternalAddressService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; use OCA\Mail\Service\SmimeService; @@ -100,8 +99,7 @@ public function __construct( InternalAddressService $internalAddressService, IAvailabilityCoordinator $availabilityCoordinator, QuickActionsService $quickActionsService, - private IonosConfigService $ionosConfigService, - private IonosMailConfigService $ionosMailConfigService, + private AccountProviderService $accountProviderService, ) { parent::__construct($appName, $request); @@ -216,8 +214,7 @@ public function index(): TemplateResponse { $this->initialStateService->provideInitialState('preferences', [ 'attachment-size-limit' => $this->config->getSystemValue('app.mail.attachment-size-limit', 0), - 'ionos-mailconfig-enabled' => $this->ionosMailConfigService->isMailConfigAvailable(), - 'ionos-mailconfig-domain' => $this->ionosConfigService->getMailDomain(), + 'mail-providers-available' => !empty($this->accountProviderService->getAvailableProvidersForUser($this->currentUserId)), 'app-version' => $this->config->getAppValue('mail', 'installed_version'), 'external-avatars' => $this->preferences->getPreference($this->currentUserId, 'external-avatars', 'true'), 'layout-mode' => $this->preferences->getPreference($this->currentUserId, 'layout-mode', 'vertical-split'), diff --git a/lib/Exception/IonosServiceException.php b/lib/Exception/IonosServiceException.php new file mode 100644 index 0000000000..c3dfbdffb3 --- /dev/null +++ b/lib/Exception/IonosServiceException.php @@ -0,0 +1,38 @@ + $data [optional] Additional data to pass with the exception. + */ + public function __construct( + $message = '', + $code = 0, + ?Throwable $previous = null, + private readonly array $data = [], + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Get additional data associated with the exception + * + * @return array + */ + public function getData(): array { + return $this->data; + } +} diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 30e06ee6a8..326f7192c2 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -10,8 +10,8 @@ namespace OCA\Mail\Listener; use OCA\Mail\Exception\ClientException; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\UserDeletedEvent; @@ -30,7 +30,7 @@ class UserDeletedListener implements IEventListener { public function __construct( AccountService $accountService, LoggerInterface $logger, - private readonly IonosMailService $ionosMailService, + private readonly ProviderRegistryService $providerRegistry, ) { $this->accountService = $accountService; $this->logger = $logger; @@ -46,11 +46,14 @@ public function handle(Event $event): void { $user = $event->getUser(); $userId = $user->getUID(); - // Delete IONOS mailbox if IONOS integration is enabled - $this->ionosMailService->tryDeleteEmailAccount($userId); + $accounts = $this->accountService->findByUserId($userId); + + // Delete provider-managed accounts (generic system) + // This works with any registered provider (IONOS, Office365, etc.) + $this->providerRegistry->deleteProviderManagedAccounts($userId, $accounts); // Delete all mail accounts in Nextcloud - foreach ($this->accountService->findByUserId($userId) as $account) { + foreach ($accounts as $account) { try { $this->accountService->delete( $userId, diff --git a/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php b/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php new file mode 100644 index 0000000000..4ff748814a --- /dev/null +++ b/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php @@ -0,0 +1,63 @@ +email; + } + + public function getImap(): MailServerConfig { + return $this->imap; + } + + 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 + * + * @return array{email: string, imap: array, smtp: array} + */ + public function toArray(): array { + return [ + 'email' => $this->email, + 'imap' => $this->imap->toArray(), + 'smtp' => $this->smtp->toArray(), + ]; + } +} diff --git a/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php b/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php new file mode 100644 index 0000000000..5092ef12c7 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php @@ -0,0 +1,77 @@ +host; + } + + public function getPort(): int { + return $this->port; + } + + public function getSecurity(): string { + return $this->security; + } + + public function getUsername(): string { + return $this->username; + } + + 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 + * + * @return array{host: string, port: int, security: string, username: string, password: string} + */ + public function toArray(): array { + return [ + 'host' => $this->host, + 'port' => $this->port, + 'security' => $this->security, + 'username' => $this->username, + 'password' => $this->password, + ]; + } +} diff --git a/lib/Provider/MailAccountProvider/IMailAccountProvider.php b/lib/Provider/MailAccountProvider/IMailAccountProvider.php new file mode 100644 index 0000000000..c61a875d0d --- /dev/null +++ b/lib/Provider/MailAccountProvider/IMailAccountProvider.php @@ -0,0 +1,106 @@ + $parameters Provider-specific parameters (e.g., email username, domain) + * @return Account The created Nextcloud mail account + * @throws \OCA\Mail\Exception\ServiceException If account creation fails + */ + public function createAccount(string $userId, array $parameters): Account; + + /** + * Update an existing mail account (e.g., reset password) + * + * @param string $userId The Nextcloud user ID + * @param int $accountId The Nextcloud mail account ID + * @param array $parameters Provider-specific parameters + * @return Account The updated account + * @throws \OCA\Mail\Exception\ServiceException If update fails + */ + public function updateAccount(string $userId, int $accountId, array $parameters): Account; + + /** + * Delete a mail account from the external provider + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to delete + * @return bool True if deletion was successful + */ + public function deleteAccount(string $userId, string $email): bool; + + /** + * Check if the given email address is managed by this provider + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to check + * @return bool True if this provider manages this email + */ + public function managesEmail(string $userId, string $email): bool; + + /** + * Get the email address managed by this provider for the given user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getProvisionedEmail(string $userId): ?string; +} diff --git a/lib/Provider/MailAccountProvider/IProviderCapabilities.php b/lib/Provider/MailAccountProvider/IProviderCapabilities.php new file mode 100644 index 0000000000..c36247aa37 --- /dev/null +++ b/lib/Provider/MailAccountProvider/IProviderCapabilities.php @@ -0,0 +1,68 @@ + Config schema + */ + public function getConfigSchema(): array; + + /** + * Get the parameter schema for account creation + * + * Returns an array describing what parameters are needed + * when creating an account (e.g., username, domain). + * + * @return array Parameter schema + */ + public function getCreationParameterSchema(): array; + + /** + * Get the email domain for this provider (if applicable) + * + * Returns the domain suffix used for email addresses created by this provider. + * For example, "example.com" for accounts like "user@example.com" + * + * @return string|null The email domain or null if not applicable + */ + public function getEmailDomain(): ?string; +} diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php new file mode 100644 index 0000000000..c4144bb239 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php @@ -0,0 +1,185 @@ +configService->isIonosIntegrationEnabled(); + } catch (\Exception $e) { + $this->logger->debug('IONOS provider is not enabled', [ + 'exception' => $e, + ]); + return false; + } + } + + /** + * Check if IONOS account provisioning is available for a user + * + * For IONOS, account is available only if user doesn't already have one + * (since multipleAccounts = false) + * + * @param string $userId The Nextcloud user ID + * @return bool True if provisioning should be shown + */ + public function isAvailableForUser(string $userId): bool { + try { + $hasAccount = $this->queryService->mailAccountExistsForUserId($userId); + return !$hasAccount; + } catch (\Exception $e) { + $this->logger->error('Error checking IONOS availability for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Create or update an IONOS mail account + * + * @param string $userId The Nextcloud user ID + * @param string $emailUser The email username (local part before @) + * @param string $accountName The display name for the account + * @return Account The created or updated mail account + * @throws ServiceException If account creation fails + */ + public function createAccount(string $userId, string $emailUser, string $accountName): Account { + $this->logger->info('Creating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + /** + * Update an existing IONOS mail account + * + * Currently uses the same logic as creation (which handles updates) + * + * @param string $userId The Nextcloud user ID + * @param string $emailUser The email username (local part before @) + * @param string $accountName The display name for the account + * @return Account The updated account + * @throws ServiceException If update fails + */ + public function updateAccount(string $userId, string $emailUser, string $accountName): Account { + $this->logger->info('Updating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + // Currently, creation service handles both create and update + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + /** + * Delete an IONOS mail account + * + * @param string $userId The Nextcloud user ID + * @return bool True if deletion was successful + */ + public function deleteAccount(string $userId): bool { + $this->logger->info('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + try { + $this->mutationService->tryDeleteEmailAccount($userId); + return true; + } catch (\Exception $e) { + $this->logger->error('Error deleting IONOS account via facade', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Get the provisioned email address for a user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getProvisionedEmail(string $userId): ?string { + try { + return $this->queryService->getIonosEmailForUser($userId); + } catch (\Exception $e) { + $this->logger->debug('Error getting IONOS provisioned email', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return null; + } + } + + /** + * Check if a specific email address is managed by IONOS for a user + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to check + * @return bool True if this email is managed by IONOS + */ + public function managesEmail(string $userId, string $email): bool { + $ionosEmail = $this->getProvisionedEmail($userId); + if ($ionosEmail === null) { + return false; + } + return strcasecmp($email, $ionosEmail) === 0; + } + + /** + * Get the email domain used by IONOS + * + * @return string|null The email domain or null if not configured + */ + public function getEmailDomain(): ?string { + try { + return $this->configService->getMailDomain(); + } catch (\Exception $e) { + $this->logger->debug('Could not get IONOS email domain', [ + 'exception' => $e, + ]); + return null; + } + } +} diff --git a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php new file mode 100644 index 0000000000..dcaea65949 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php @@ -0,0 +1,137 @@ +capabilities === null) { + // Get email domain via facade + $emailDomain = $this->facade->getEmailDomain(); + + $this->capabilities = new ProviderCapabilities( + multipleAccounts: false, // IONOS allows only one account per user + appPasswords: true, // IONOS supports app password generation + passwordReset: true, // IONOS supports password reset + configSchema: [ + 'ionos_mailconfig_api_base_url' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Base URL for the IONOS Mail Configuration API', + ], + 'ionos_mailconfig_api_auth_user' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Basic auth username for IONOS API', + ], + 'ionos_mailconfig_api_auth_pass' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Basic auth password for IONOS API', + ], + 'ionos_mailconfig_api_allow_insecure' => [ + 'type' => 'boolean', + 'required' => false, + 'description' => 'Allow insecure connections (for development)', + ], + 'ncw.ext_ref' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'External reference ID (system config)', + ], + 'ncw.customerDomain' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Customer domain for email addresses (system config)', + ], + ], + creationParameterSchema: [ + 'accountName' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Name', + ], + 'emailUser' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'User', + ], + ], + emailDomain: $emailDomain, + ); + } + return $this->capabilities; + } + + public function isEnabled(): bool { + return $this->facade->isEnabled(); + } + + public function isAvailableForUser(string $userId): bool { + return $this->facade->isAvailableForUser($userId); + } + + public function createAccount(string $userId, array $parameters): Account { + $emailUser = $parameters['emailUser'] ?? ''; + $accountName = $parameters['accountName'] ?? ''; + + if (empty($emailUser) || empty($accountName)) { + throw new \InvalidArgumentException('emailUser and accountName are required'); + } + + return $this->facade->createAccount($userId, $emailUser, $accountName); + } + + public function updateAccount(string $userId, int $accountId, array $parameters): Account { + // For now, use same creation logic which handles updates + return $this->createAccount($userId, $parameters); + } + + public function deleteAccount(string $userId, string $email): bool { + return $this->facade->deleteAccount($userId); + } + + public function managesEmail(string $userId, string $email): bool { + return $this->facade->managesEmail($userId, $email); + } + + public function getProvisionedEmail(string $userId): ?string { + return $this->facade->getProvisionedEmail($userId); + } +} diff --git a/lib/Provider/MailAccountProvider/ProviderCapabilities.php b/lib/Provider/MailAccountProvider/ProviderCapabilities.php new file mode 100644 index 0000000000..4668079bfb --- /dev/null +++ b/lib/Provider/MailAccountProvider/ProviderCapabilities.php @@ -0,0 +1,51 @@ +multipleAccounts; + } + + public function supportsAppPasswords(): bool { + return $this->appPasswords; + } + + public function supportsPasswordReset(): bool { + return $this->passwordReset; + } + + public function getConfigSchema(): array { + return $this->configSchema; + } + + public function getCreationParameterSchema(): array { + return $this->creationParameterSchema; + } + + public function getEmailDomain(): ?string { + return $this->emailDomain; + } +} diff --git a/lib/Provider/MailAccountProvider/ProviderRegistryService.php b/lib/Provider/MailAccountProvider/ProviderRegistryService.php new file mode 100644 index 0000000000..d74814fa0e --- /dev/null +++ b/lib/Provider/MailAccountProvider/ProviderRegistryService.php @@ -0,0 +1,171 @@ + */ + private array $providers = []; + + public function __construct( + private LoggerInterface $logger, + ) { + } + + /** + * Register a provider + * + * @param IMailAccountProvider $provider The provider to register + */ + public function registerProvider(IMailAccountProvider $provider): void { + $id = $provider->getId(); + + if (isset($this->providers[$id])) { + $this->logger->warning('Provider already registered, overwriting', [ + 'providerId' => $id, + ]); + } + + $this->providers[$id] = $provider; + $this->logger->debug('Registered mail account provider', [ + 'providerId' => $id, + 'providerName' => $provider->getName(), + ]); + } + + /** + * Get a provider by ID + * + * @param string $providerId The provider ID + * @return IMailAccountProvider|null The provider or null if not found + */ + public function getProvider(string $providerId): ?IMailAccountProvider { + return $this->providers[$providerId] ?? null; + } + + /** + * Get all registered providers + * + * @return array Array of providers indexed by ID + */ + public function getAllProviders(): array { + return $this->providers; + } + + /** + * Get all enabled providers + * + * @return array Array of enabled providers indexed by ID + */ + public function getEnabledProviders(): array { + return array_filter($this->providers, fn (IMailAccountProvider $provider) => $provider->isEnabled()); + } + + /** + * Get all providers available for a specific user + * + * @param string $userId The Nextcloud user ID + * @return array Array of available providers indexed by ID + */ + public function getAvailableProvidersForUser(string $userId): array { + return array_filter( + $this->getEnabledProviders(), + fn (IMailAccountProvider $provider) => $provider->isAvailableForUser($userId) + ); + } + + /** + * Find which provider manages a specific email address + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address + * @return IMailAccountProvider|null The managing provider or null + */ + public function findProviderForEmail(string $userId, string $email): ?IMailAccountProvider { + foreach ($this->getEnabledProviders() as $provider) { + if ($provider->managesEmail($userId, $email)) { + return $provider; + } + } + return null; + } + + /** + * Get provider information for API responses + * + * @return array + */ + public function getProviderInfo(): array { + $info = []; + foreach ($this->providers as $id => $provider) { + $capabilities = $provider->getCapabilities(); + $info[$id] = [ + 'id' => $id, + 'name' => $provider->getName(), + 'enabled' => $provider->isEnabled(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + ], + ]; + } + return $info; + } + + /** + * Delete all provider-managed accounts for a specific user + * + * This method iterates through the user's accounts and deletes those + * that are managed by registered providers. + * + * @param string $userId The Nextcloud user ID + * @param array $accounts List of user's mail accounts + */ + public function deleteProviderManagedAccounts(string $userId, array $accounts): void { + foreach ($accounts as $account) { + $email = $account->getEmail(); + + // Check if this account is managed by a provider + $provider = $this->findProviderForEmail($userId, $email); + if ($provider !== null) { + try { + $this->logger->info('Deleting provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + ]); + + $provider->deleteAccount($userId, $email); + + $this->logger->info('Successfully deleted provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to delete provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + // Continue with other accounts even if one fails + } + } + } + } +} diff --git a/lib/Provider/MailAccountProvider/README.md b/lib/Provider/MailAccountProvider/README.md new file mode 100644 index 0000000000..537e90ec12 --- /dev/null +++ b/lib/Provider/MailAccountProvider/README.md @@ -0,0 +1,254 @@ +# Mail Account Provider System + +This directory contains the pluggable mail account provider system for Nextcloud Mail. + +## Overview + +The provider system allows external mail services (like IONOS, Office365, Google Workspace, etc.) to provision mail accounts through their APIs and integrate seamlessly with Nextcloud Mail. + +## Architecture + +``` +lib/Provider/MailAccountProvider/ +├── IMailAccountProvider.php # Main provider interface +├── IProviderCapabilities.php # Capabilities interface +├── ProviderCapabilities.php # Base capabilities implementation +├── ProviderRegistryService.php # Central provider registry +└── Implementations/ + ├── IonosProvider.php # IONOS implementation + └── [Other providers...] +``` + +## Key Interfaces + +### IMailAccountProvider + +Main interface that all providers must implement: + +- `getId()`: Unique provider identifier (e.g., 'ionos', 'office365') +- `getName()`: Human-readable name +- `getCapabilities()`: What features the provider supports +- `isEnabled()`: Is the provider configured and ready to use? +- `isAvailableForUser()`: Can this user create accounts with this provider? +- `createAccount()`: Provision a new mail account +- `updateAccount()`: Update existing account (e.g., reset password) +- `deleteAccount()`: Delete account from provider +- `managesEmail()`: Does this provider manage a specific email address? +- `getProvisionedEmail()`: What email did this provider provision for a user? + +### IProviderCapabilities + +Declares what features a provider supports: + +- `allowsMultipleAccounts()`: Can a user have multiple accounts? +- `supportsAppPasswords()`: Can generate app-specific passwords? +- `supportsPasswordReset()`: Can reset account passwords? +- `getConfigSchema()`: What configuration fields are needed? +- `getCreationParameterSchema()`: What parameters are needed to create an account? + +## Creating a New Provider + +### 1. Create Provider Class + +```php + [ + 'type' => 'string', + 'required' => true, + 'description' => 'API endpoint URL', + ], + 'myprovider_api_key' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'API authentication key', + ], + ], + creationParameterSchema: [ + 'username' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Email username', + ], + 'displayName' => [ + 'type' => 'string', + 'required' => false, + 'description' => 'User display name', + 'default' => '', + ], + ], + ); + } + + public function isEnabled(): bool { + // Check if configuration is valid + try { + $apiUrl = $this->config->getAppValue('mail', 'myprovider_api_url'); + $apiKey = $this->config->getAppValue('mail', 'myprovider_api_key'); + return !empty($apiUrl) && !empty($apiKey); + } catch (\Exception $e) { + return false; + } + } + + public function isAvailableForUser(string $userId): bool { + // Determine if user can create accounts + // E.g., check if they already have one (if multipleAccounts=false) + return true; + } + + public function createAccount(string $userId, array $parameters): Account { + // 1. Validate parameters + $username = $parameters['username'] ?? ''; + if (empty($username)) { + throw new \InvalidArgumentException('username is required'); + } + + // 2. Call external API to provision mailbox + $mailConfig = $this->callProviderAPI($userId, $username); + + // 3. Create Nextcloud Mail account + $account = new MailAccount(); + $account->setUserId($userId); + $account->setEmail($mailConfig['email']); + $account->setInboundHost($mailConfig['imap_host']); + // ... set other properties ... + + return new Account($this->accountService->save($account)); + } + + // Implement other methods... +} +``` + +### 2. Register Provider + +In `lib/AppInfo/Application.php`: + +```php +public function boot(IBootContext $context): void { + $container = $context->getServerContainer(); + $providerRegistry = $container->get(ProviderRegistryService::class); + + // Register your provider + $myProvider = $container->get(MyProvider::class); + $providerRegistry->registerProvider($myProvider); +} +``` + +### 3. Configure Provider + +```bash +# Via occ command +occ config:app:set mail myprovider_api_url --value="https://api.example.com" +occ config:app:set mail myprovider_api_key --value="secret-key" + +# Or via Admin UI (future enhancement) +``` + +### 4. Use Provider + +Your provider will automatically: +- Appear in `GET /api/providers` if enabled +- Be usable via `POST /api/providers/myprovider/accounts` +- Work with generic CLI commands (when implemented) +- Show in UI provider selection (when implemented) + +## API Endpoints + +### Get Available Providers +```http +GET /apps/mail/api/providers +``` + +Returns list of providers available to current user with capabilities and parameter schemas. + +### Create Account +```http +POST /apps/mail/api/providers/{providerId}/accounts +Content-Type: application/json + +{ + "param1": "value1", + "param2": "value2" +} +``` + +Creates a mail account using the specified provider with given parameters. + +### Generate App Password +```http +POST /apps/mail/api/providers/{providerId}/password +Content-Type: application/json + +{ + "accountId": 123 +} +``` + +Generates an app-specific password (if provider supports it). + +## Configuration Storage + +Providers store configuration using standard Nextcloud mechanisms: + +**App Config** (per-app settings): +```php +$this->appConfig->getValueString('mail', 'myprovider_setting'); +$this->appConfig->setValueString('mail', 'myprovider_setting', $value); +``` + +**System Config** (global settings): +```php +$this->config->getSystemValue('myprovider.global_setting'); +$this->config->setSystemValue('myprovider.global_setting', $value); +``` + +## Design Principles + +1. **No Database Changes**: Account metadata derived at runtime +2. **Plug-and-Play**: Providers are self-contained +3. **Declarative**: Capabilities and schemas describe behavior +4. **Safe Defaults**: Errors don't break the app +5. **Backward Compatible**: Existing accounts unaffected + +## Testing + +When creating a provider, test: + +1. **Configuration validation**: isEnabled() works correctly +2. **User availability**: isAvailableForUser() logic +3. **Account creation**: Full flow including API calls +4. **Account deletion**: Cleanup on provider side +5. **Error handling**: API failures, invalid parameters +6. **Email management**: managesEmail() correctly identifies accounts + +## Examples + +See `Implementations/IonosProvider.php` for a complete, production-ready example. + +## Further Reading + +- `PROVIDER_REFACTORING_GUIDE.md`: Architecture and implementation details +- `IMPLEMENTATION_SUMMARY.md`: Current status and next steps +- Core interfaces in this directory for full API documentation diff --git a/lib/Service/AccountProviderService.php b/lib/Service/AccountProviderService.php new file mode 100644 index 0000000000..240edf733d --- /dev/null +++ b/lib/Service/AccountProviderService.php @@ -0,0 +1,100 @@ + $accountJson The account JSON to enhance + * @param string $userId The user ID + * @param string $email The account email address + * @return array The enhanced account JSON + */ + public function addProviderMetadata(array $accountJson, string $userId, string $email): array { + try { + $provider = $this->providerRegistry->findProviderForEmail($userId, $email); + + if ($provider !== null) { + $capabilities = $provider->getCapabilities(); + + $accountJson['managedByProvider'] = $provider->getId(); + $accountJson['providerCapabilities'] = [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ]; + } else { + $accountJson['managedByProvider'] = null; + $accountJson['providerCapabilities'] = null; + } + } catch (\Exception $e) { + $this->logger->debug('Error determining account provider', [ + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + + // Safe defaults on error + $accountJson['managedByProvider'] = null; + $accountJson['providerCapabilities'] = null; + } + + return $accountJson; + } + + /** + * Get all providers available for a user + * + * @param string $userId The user ID + * @return array + */ + public function getAvailableProvidersForUser(string $userId): array { + $providers = $this->providerRegistry->getAvailableProvidersForUser($userId); + $result = []; + + foreach ($providers as $provider) { + $capabilities = $provider->getCapabilities(); + $result[$provider->getId()] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'parameterSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + + return $result; + } +} diff --git a/lib/Service/IONOS/ConflictResolutionResult.php b/lib/Service/IONOS/ConflictResolutionResult.php new file mode 100644 index 0000000000..35db775627 --- /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/Core/IonosAccountMutationService.php b/lib/Service/IONOS/Core/IonosAccountMutationService.php new file mode 100644 index 0000000000..45217bbe00 --- /dev/null +++ b/lib/Service/IONOS/Core/IonosAccountMutationService.php @@ -0,0 +1,385 @@ +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 + */ + 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(), + 'userId' => $userId + ]); + + $apiInstance = $this->createApiInstance(); + + $mailCreateData = new MailCreateData(); + $mailCreateData->setNextcloudUserId($userId); + $mailCreateData->setLocalPart($userName); + + if (!$mailCreateData->valid()) { + $this->logger->error('Validate message to mailconfig service', [ + 'data' => $mailCreateData->listInvalidProperties(), + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Invalid mail configuration', self::HTTP_INTERNAL_SERVER_ERROR); + } + + try { + $this->logger->debug('Send message to mailconfig service', ['data' => $mailCreateData]); + $result = $apiInstance->createMailbox(self::BRAND, $this->configService->getExternalReference(), $mailCreateData); + + if ($result instanceof MailAddonErrorMessage) { + $this->logger->error('Failed to create ionos mail', [ + 'status code' => $result->getStatus(), + 'message' => $result->getMessage(), + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', $result->getStatus()); + } + if ($result instanceof MailAccountCreatedResponse) { + $this->logger->info('Successfully created IONOS mail account', [ + 'email' => $result->getEmail(), + 'userId' => $userId, + 'userName' => $userName + ]); + return $this->buildSuccessResponse($result); + } + + $this->logger->error('Failed to create ionos mail: Unknown response type', [ + 'data' => $result, + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', 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->createMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody() + ]); + throw new ServiceException('Failed to create ionos mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->createMailbox', [ + 'exception' => $e, + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account via API + * + * @param string $userId The Nextcloud user ID + * @return bool true if deletion was successful + * @throws ServiceException + */ + public function deleteEmailAccount(string $userId): bool { + $this->logger->info('Attempting to delete IONOS email account', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + + $apiInstance->deleteMailbox(self::BRAND, $this->configService->getExternalReference(), $userId); + + $this->logger->info('Successfully deleted IONOS email account', [ + 'userId' => $userId + ]); + + return true; + } catch (ApiException $e) { + // 404 means the mailbox doesn't exist - treat as success + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('IONOS mailbox does not exist (already deleted or never created)', [ + 'userId' => $userId, + 'statusCode' => $e->getCode() + ]); + return true; + } + + $this->logger->error('API Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'exception' => $e, + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account without throwing exceptions (fire and forget) + * + * This method checks if IONOS integration is enabled and attempts to delete + * the email account. All errors are logged but not thrown, making it safe + * to call in event listeners or other contexts where exceptions should not + * interrupt the flow. + * + * @param string $userId The Nextcloud user ID + * @return void + */ + public function tryDeleteEmailAccount(string $userId): void { + // Check if IONOS integration is enabled + if (!$this->configService->isIonosIntegrationEnabled()) { + $this->logger->debug('IONOS integration is not enabled, skipping email account deletion', [ + 'userId' => $userId + ]); + return; + } + + try { + $this->deleteEmailAccount($userId); + // Success is already logged by deleteEmailAccount + } catch (ServiceException $e) { + $this->logger->error('Failed to delete IONOS mailbox for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + // Don't throw - this is a fire and forget operation + } + } + + /** + * Reset app password for the IONOS mail account (generates a new password) + * + * @param string $userId The Nextcloud user ID + * @param string $appName The application name for the password + * @return string The new password + * @throws ServiceException + */ + public function resetAppPassword(string $userId, string $appName): string { + $this->logger->debug('Resetting IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->setAppPassword( + self::BRAND, + $this->configService->getExternalReference(), + $userId, + $appName + ); + + if (is_string($result)) { + $this->logger->info('Successfully reset IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName + ]); + return $result; + } + + $this->logger->error('Failed to reset IONOS app password: Unexpected response type', [ + 'userId' => $userId, + 'appName' => $appName, + 'result' => $result + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'exception' => $e, + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Get the current user ID from the session + * + * @return string The user ID + * @throws ServiceException If no user is logged in + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->error('No user session found when attempting to create IONOS mail account'); + throw new ServiceException('No user session found'); + } + return $user->getUID(); + } + + /** + * Create and configure API instance with authentication + * + * @return \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi + */ + private function createApiInstance(): \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + + /** + * Normalize SSL mode from API response to expected format + * + * Maps API SSL mode values (e.g., "TLS", "SSL") to standard values ("tls", "ssl", "none") + * + * @param string $apiSslMode SSL mode from API response + * @return string Normalized SSL mode: "tls", "ssl", or "none" + */ + private function normalizeSslMode(string $apiSslMode): string { + $normalized = strtolower($apiSslMode); + + if (str_contains($normalized, 'tls') || str_contains($normalized, 'starttls')) { + $result = 'tls'; + } elseif (str_contains($normalized, 'ssl')) { + $result = 'ssl'; + } else { + $result = 'none'; + } + + $this->logger->debug('Normalized SSL mode', [ + 'input' => $apiSslMode, + 'output' => $result + ]); + + return $result; + } + + /** + * Build success response with mail 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() + ); + } + + /** + * Build mail account configuration from server details + * + * @param Imap $imapServer IMAP server configuration object + * @param Smtp $smtpServer SMTP server configuration object + * @param string $email Email address + * @param string $password Account password + * @return MailAccountConfig Complete mail account configuration + */ + private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { + $imapConfig = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: $this->normalizeSslMode($imapServer->getSslMode()), + username: $email, + password: $password, + ); + + $smtpConfig = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: $this->normalizeSslMode($smtpServer->getSslMode()), + username: $email, + password: $password, + ); + + return new MailAccountConfig( + email: $email, + imap: $imapConfig, + smtp: $smtpConfig, + ); + } +} diff --git a/lib/Service/IONOS/Core/IonosAccountQueryService.php b/lib/Service/IONOS/Core/IonosAccountQueryService.php new file mode 100644 index 0000000000..9c7487a40d --- /dev/null +++ b/lib/Service/IONOS/Core/IonosAccountQueryService.php @@ -0,0 +1,245 @@ +getCurrentUserId(); + return $this->mailAccountExistsForUserId($userId); + } + + /** + * Check if a specific user has an IONOS email account + * + * @param string $userId The user ID to check + * @return bool true if account exists, false otherwise + */ + public function mailAccountExistsForUserId(string $userId): bool { + $response = $this->getMailAccountResponse($userId); + + if ($response !== null) { + $this->logger->debug('User has existing IONOS mail account', [ + 'email' => $response->getEmail(), + 'userId' => $userId + ]); + return true; + } + + return false; + } + + /** + * Get the IONOS mail account response for a specific user + * + * @param string $userId The Nextcloud user ID + * @return MailAccountResponse|null The account response if it exists, null otherwise + */ + public function getMailAccountResponse(string $userId): ?MailAccountResponse { + try { + $this->logger->debug('Getting IONOS mail account for user', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->getFunctionalAccount( + self::BRAND, + $this->configService->getExternalReference(), + $userId + ); + + if ($result instanceof MailAccountResponse) { + return $result; + } + + return null; + } catch (ApiException $e) { + // 404 - no account exists + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('No IONOS mail account found for user', [ + 'userId' => $userId, + 'statusCode' => $e->getCode(), + ]); + return null; + } + + // Other errors + $this->logger->error('Error checking IONOS mail account', [ + 'userId' => $userId, + 'statusCode' => $e->getCode(), + 'error' => $e->getMessage(), + ]); + return null; + } catch (\Exception $e) { + $this->logger->error('Unexpected error checking IONOS mail account', [ + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get account configuration for a specific user + * + * @param string $userId The Nextcloud user ID + * @return MailAccountConfig|null Account configuration or null if not found + */ + public function getAccountConfigForUser(string $userId): ?MailAccountConfig { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + return null; + } + + return $this->mapResponseToAccountConfig($response); + } + + /** + * Get account configuration for the current logged-in user + * + * @return MailAccountConfig|null Account configuration or null if not found + */ + public function getAccountConfigForCurrentUser(): ?MailAccountConfig { + $userId = $this->getCurrentUserId(); + return $this->getAccountConfigForUser($userId); + } + + /** + * Get the IONOS email address for a specific user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getIonosEmailForUser(string $userId): ?string { + try { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + $this->logger->debug('No IONOS email found for user', [ + 'userId' => $userId, + ]); + return null; + } + + $email = $response->getEmail(); + $this->logger->debug('Retrieved IONOS email for user', [ + 'userId' => $userId, + 'email' => $email, + ]); + + return $email; + } catch (\Exception $e) { + $this->logger->error('Error getting IONOS email for user', [ + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get the configured mail domain + * + * @return string The mail domain + */ + public function getMailDomain(): string { + return $this->configService->getMailDomain(); + } + + /** + * Get the current user ID from the session + * + * @return string The user ID + * @throws \RuntimeException If no user is logged in + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new \RuntimeException('No user logged in'); + } + return $user->getUID(); + } + + /** + * Create and configure API instance with authentication + * + * @return \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi + */ + private function createApiInstance(): \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + + /** + * Map API response to MailAccountConfig + * + * @param MailAccountResponse $response The API response + * @return MailAccountConfig The mapped configuration + */ + private function mapResponseToAccountConfig(MailAccountResponse $response): MailAccountConfig { + $imapServer = $response->getImap(); + $smtpServer = $response->getSmtp(); + + $imap = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: 'tls', // Default, should be normalized from API response + username: $response->getEmail(), + password: $imapServer->getPassword() + ); + + $smtp = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: 'tls', // Default, should be normalized from API response + username: $response->getEmail(), + password: $smtpServer->getPassword() + ); + + return new MailAccountConfig( + email: $response->getEmail(), + imap: $imap, + smtp: $smtp + ); + } +} 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..0620845a8a --- /dev/null +++ b/lib/Service/IONOS/IonosAccountCreationService.php @@ -0,0 +1,220 @@ +buildEmailAddress($emailUser); + + // Check if Nextcloud account already exists + $existingAccounts = $this->accountService->findByUserIdAndAddress($userId, $expectedEmail); + + if (!empty($existingAccounts)) { + return $this->handleExistingAccount($userId, $emailUser, $accountName, $existingAccounts[0]); + } + + // No existing account - create new one + return $this->handleNewAccount($userId, $emailUser, $accountName); + } + + /** + * Handle the case where a Nextcloud mail account already exists + */ + private function handleExistingAccount(string $userId, string $emailUser, string $accountName, $existingAccount): Account { + $this->logger->info('Nextcloud mail account already exists, resetting credentials', [ + 'accountId' => $existingAccount->getId(), + 'emailAddress' => $existingAccount->getEmail(), + 'userId' => $userId, + ]); + + try { + $resolutionResult = $this->conflictResolver->resolveConflict($userId, $emailUser); + + if (!$resolutionResult->canRetry()) { + if ($resolutionResult->hasEmailMismatch()) { + throw new IonosServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + IonosMailService::STATUS__409_CONFLICT, + null, + [ + 'expectedEmail' => $resolutionResult->getExpectedEmail(), + 'existingEmail' => $resolutionResult->getExistingEmail(), + ] + ); + } + throw new ServiceException('Nextcloud account exists but no IONOS account found', 500); + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->updateAccount($existingAccount->getMailAccount(), $accountName, $mailConfig); + } catch (IonosServiceException $e) { + // Re-throw IonosServiceException as-is + throw $e; + } catch (ServiceException $e) { + throw new ServiceException('Failed to reset IONOS account credentials: ' . $e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Handle the case where no Nextcloud account exists yet + */ + private function handleNewAccount(string $userId, string $emailUser, string $accountName): Account { + try { + $this->logger->info('Creating new IONOS email account', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'accountName' => $accountName + ]); + + $mailConfig = $this->ionosMailService->createEmailAccountForUser($userId, $emailUser); + + $this->logger->info('IONOS email account created successfully', [ + 'emailAddress' => $mailConfig->getEmail() + ]); + + return $this->createAccount($userId, $accountName, $mailConfig); + } catch (ServiceException $e) { + // Try to resolve conflict - IONOS account might already exist + $this->logger->info('IONOS account creation failed, attempting conflict resolution', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'error' => $e->getMessage() + ]); + + $resolutionResult = $this->conflictResolver->resolveConflict($userId, $emailUser); + + if (!$resolutionResult->canRetry()) { + if ($resolutionResult->hasEmailMismatch()) { + throw new IonosServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + IonosMailService::STATUS__409_CONFLICT, + $e, + [ + 'expectedEmail' => $resolutionResult->getExpectedEmail(), + 'existingEmail' => $resolutionResult->getExistingEmail(), + ] + ); + } + // No existing IONOS account found - re-throw original error + throw $e; + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->createAccount($userId, $accountName, $mailConfig); + } + } + + /** + * Create a new Nextcloud mail account + */ + private function createAccount(string $userId, string $accountName, MailAccountConfig $mailConfig): Account { + $account = new MailAccount(); + $account->setUserId($userId); + $account->setName($accountName); + $account->setEmail($mailConfig->getEmail()); + $account->setAuthMethod('password'); + + $this->setAccountCredentials($account, $mailConfig); + + $account = $this->accountService->save($account); + + $this->logger->info('Created new Nextcloud mail account', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $userId, + ]); + + return new Account($account); + } + + /** + * Update an existing Nextcloud mail account + */ + private function updateAccount(MailAccount $account, string $accountName, MailAccountConfig $mailConfig): Account { + $account->setName($accountName); + $this->setAccountCredentials($account, $mailConfig); + + $account = $this->accountService->update($account); + + $this->logger->info('Updated existing Nextcloud mail account with new credentials', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $account->getUserId(), + ]); + + return new Account($account); + } + + /** + * Set IMAP and SMTP credentials on a mail account + */ + private function setAccountCredentials(MailAccount $account, MailAccountConfig $mailConfig): void { + $imap = $mailConfig->getImap(); + $account->setInboundHost($imap->getHost()); + $account->setInboundPort($imap->getPort()); + $account->setInboundSslMode($imap->getSecurity()); + $account->setInboundUser($imap->getUsername()); + $account->setInboundPassword($this->crypto->encrypt($imap->getPassword())); + + $smtp = $mailConfig->getSmtp(); + $account->setOutboundHost($smtp->getHost()); + $account->setOutboundPort($smtp->getPort()); + $account->setOutboundSslMode($smtp->getSecurity()); + $account->setOutboundUser($smtp->getUsername()); + $account->setOutboundPassword($this->crypto->encrypt($smtp->getPassword())); + } + + /** + * Build full email address from username + */ + private function buildEmailAddress(string $emailUser): string { + $domain = $this->ionosMailService->getMailDomain(); + return $emailUser . '@' . $domain; + } +} diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php index 3b5ff579ee..d72f57f99d 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Service/IONOS/IonosConfigService.php @@ -22,6 +22,11 @@ * Service for managing IONOS API configuration */ class IonosConfigService { + /** + * Application name used for IONOS app password management + */ + public const APP_NAME = 'NEXTCLOUD_WORKSPACE'; + public function __construct( private readonly IConfig $config, private readonly IAppConfig $appConfig, diff --git a/lib/Service/IONOS/IonosMailConfigService.php b/lib/Service/IONOS/IonosMailConfigService.php index 77e9b8126e..9e4b7a6706 100644 --- a/lib/Service/IONOS/IonosMailConfigService.php +++ b/lib/Service/IONOS/IonosMailConfigService.php @@ -9,6 +9,8 @@ namespace OCA\Mail\Service\IONOS; +use OCA\Mail\Service\AccountService; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -18,6 +20,8 @@ class IonosMailConfigService { public function __construct( private IonosConfigService $ionosConfigService, private IonosMailService $ionosMailService, + private AccountService $accountService, + private IUserSession $userSession, private LoggerInterface $logger, ) { } @@ -27,7 +31,8 @@ public function __construct( * * The configuration is available only if: * 1. The IONOS integration is enabled and properly configured - * 2. The user does NOT already have an IONOS mail account + * 2. The user does NOT already have an IONOS mail account configured remotely + * 3. OR the user has a remote IONOS account but it's NOT configured locally in the mail app * * @return bool True if mail configuration should be shown, false otherwise */ @@ -38,14 +43,45 @@ public function isMailConfigAvailable(): bool { return false; } - // Check if user already has an account - $userHasAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); + // Get current user + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->debug('IONOS mail config not available - no user session'); + return false; + } + $userId = $user->getUID(); + + // Check if user already has a remote IONOS account + $userHasRemoteAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); + + if (!$userHasRemoteAccount) { + // No remote account exists, configuration should be available + return true; + } - if ($userHasAccount) { - $this->logger->debug('IONOS mail config not available - user already has an account'); + // User has a remote account, check if it's configured locally + $ionosEmail = $this->ionosMailService->getIonosEmailForUser($userId); + if ($ionosEmail === null) { + // This shouldn't happen if userHasRemoteAccount is true, but handle it gracefully + $this->logger->warning('IONOS remote account exists but email could not be retrieved'); return false; } + // Check if the IONOS email is configured in the local mail app + $localAccounts = $this->accountService->findByUserIdAndAddress($userId, $ionosEmail); + $hasLocalAccount = count($localAccounts) > 0; + + if ($hasLocalAccount) { + $this->logger->debug('IONOS mail config not available - user already has account configured locally', [ + 'email' => $ionosEmail, + ]); + return false; + } + + // Remote account exists but not configured locally - show configuration + $this->logger->debug('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => $ionosEmail, + ]); return true; } catch (\Exception $e) { $this->logger->error('Error checking IONOS mail config availability', [ diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 59279b6ee9..0b5ee943d1 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -11,12 +11,15 @@ 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; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCP\Exceptions\AppConfigException; use OCP\IUserSession; use Psr\Log\LoggerInterface; @@ -27,6 +30,7 @@ class IonosMailService { private const BRAND = 'IONOS'; private const HTTP_NOT_FOUND = 404; + public const STATUS__409_CONFLICT = 409; private const HTTP_INTERNAL_SERVER_ERROR = 500; public function __construct( @@ -119,21 +123,39 @@ private function getMailAccountResponse(string $userId): ?MailAccountResponse { } /** - * Create an IONOS email account via API + * Create an IONOS email account via API for the current logged-in user * + * @param string $userName The local part of the email address (before @domain) * @return MailAccountConfig Mail account configuration * @throws ServiceException * @throws AppConfigException */ public function createEmailAccount(string $userName): MailAccountConfig { $userId = $this->getCurrentUserId(); + return $this->createEmailAccountForUser($userId, $userName); + } + + /** + * Create an IONOS email account via API for a specific user + * + * This method allows creating email accounts without relying on the user session, + * making it suitable for use in OCC commands or admin operations. + * + * @param string $userId The Nextcloud user ID + * @param string $userName The local part of the email address (before @domain) + * @return MailAccountConfig Mail account configuration + * @throws ServiceException + * @throws AppConfigException + */ + public function createEmailAccountForUser(string $userId, string $userName): MailAccountConfig { $domain = $this->configService->getMailDomain(); $this->logger->debug('Sending request to mailconfig service', [ 'extRef' => $this->configService->getExternalReference(), 'userName' => $userName, 'domain' => $domain, - 'apiBaseUrl' => $this->configService->getApiBaseUrl() + 'apiBaseUrl' => $this->configService->getApiBaseUrl(), + 'userId' => $userId ]); $apiInstance = $this->createApiInstance(); @@ -164,7 +186,7 @@ public function createEmailAccount(string $userName): MailAccountConfig { ]); throw new ServiceException('Failed to create ionos mail', $result->getStatus()); } - if ($result instanceof MailAccountResponse) { + if ($result instanceof MailAccountCreatedResponse) { $this->logger->info('Successfully created IONOS mail account', [ 'email' => $result->getEmail(), 'userId' => $userId, @@ -199,6 +221,45 @@ public function createEmailAccount(string $userName): MailAccountConfig { } } + /** + * Get IONOS account configuration for a specific user + * + * This method retrieves the configuration of an existing IONOS mail account. + * Useful when an account was previously created but Nextcloud account creation failed. + * + * @param string $userId The Nextcloud user ID + * @return MailAccountConfig|null Mail account configuration if exists, null otherwise + * @throws ServiceException + */ + public function getAccountConfigForUser(string $userId): ?MailAccountConfig { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + $this->logger->debug('No existing IONOS account found for user', [ + 'userId' => $userId + ]); + return null; + } + + $this->logger->info('Retrieved existing IONOS account configuration', [ + 'email' => $response->getEmail(), + 'userId' => $userId + ]); + + return $this->buildConfigFromAccountResponse($response); + } + + /** + * Get IONOS account configuration for the current logged-in user + * + * @return MailAccountConfig|null Mail account configuration if exists, null otherwise + * @throws ServiceException + */ + public function getAccountConfigForCurrentUser(): ?MailAccountConfig { + $userId = $this->getCurrentUserId(); + return $this->getAccountConfigForUser($userId); + } + /** * Get the current user ID * @@ -255,38 +316,71 @@ private function normalizeSslMode(string $apiSslMode): string { } /** - * Build success response with mail configuration + * Build success response with mail configuration from MailAccountCreatedResponse (newly created account) * - * @param MailAccountResponse $response - * @return MailAccountConfig + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password */ - private function buildSuccessResponse(MailAccountResponse $response): MailAccountConfig { - $smtpServer = $response->getServer()->getSmtp(); - $imapServer = $response->getServer()->getImap(); + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + $response->getPassword() + ); + } + /** + * Build mail account configuration from server details + * + * @param Imap $imapServer IMAP server configuration object + * @param Smtp $smtpServer SMTP server configuration object + * @param string $email Email address + * @param string $password Account password + * @return MailAccountConfig Complete mail account configuration + */ + private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { $imapConfig = new MailServerConfig( host: $imapServer->getHost(), port: $imapServer->getPort(), security: $this->normalizeSslMode($imapServer->getSslMode()), - username: $response->getEmail(), - password: $response->getPassword(), + username: $email, + password: $password, ); $smtpConfig = new MailServerConfig( host: $smtpServer->getHost(), port: $smtpServer->getPort(), security: $this->normalizeSslMode($smtpServer->getSslMode()), - username: $response->getEmail(), - password: $response->getPassword(), + username: $email, + password: $password, ); return new MailAccountConfig( - email: $response->getEmail(), + email: $email, imap: $imapConfig, smtp: $smtpConfig, ); } + /** + * Build configuration from MailAccountResponse (existing account) + * Note: MailAccountResponse does not include password for security reasons + * + * @param MailAccountResponse $response The account response from getFunctionalAccount + * @return MailAccountConfig The mail account configuration with empty password + */ + private function buildConfigFromAccountResponse(MailAccountResponse $response): MailAccountConfig { + // Password is not available when retrieving existing accounts + // It should be retrieved from Nextcloud's credential store separately + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + '' + ); + } + /** * Delete an IONOS email account via API * @@ -390,4 +484,73 @@ public function tryDeleteEmailAccount(string $userId): void { // Don't throw - this is a fire and forget operation } } + + /** + * Reset app password for the IONOS mail account (generates a new password) + * + * @param string $userId The Nextcloud user ID + * @param string $appName The application name for the password + * @return string The new password + * @throws ServiceException + */ + public function resetAppPassword(string $userId, string $appName): string { + $this->logger->debug('Resetting IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->setAppPassword( + self::BRAND, + $this->configService->getExternalReference(), + $userId, + $appName + ); + + if (is_string($result)) { + $this->logger->info('Successfully reset IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName + ]); + return $result; + } + + $this->logger->error('Failed to reset IONOS app password: Unexpected response type', [ + 'userId' => $userId, + 'appName' => $appName, + 'result' => $result + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'exception' => $e, + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Get the configured mail domain for IONOS accounts + * + * @return string The mail domain (e.g., "example.com") + */ + public function getMailDomain(): string { + return $this->configService->getMailDomain(); + } } diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index 40df87afd9..cc07821f7b 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -77,7 +77,8 @@ public function createNewAccount(string $accountName, ?string $smtpPassword, string $uid, string $authMethod, - ?int $accountId = null): Account { + ?int $accountId = null, + bool $skipConnectivityTest = false): Account { $this->logger->info('Setting up manually configured account'); $newAccount = new MailAccount([ 'accountId' => $accountId, @@ -105,7 +106,7 @@ public function createNewAccount(string $accountName, $newAccount->setAuthMethod($authMethod); $account = new Account($newAccount); - if ($authMethod === 'password' && $imapPassword !== null) { + if (!$skipConnectivityTest && $authMethod === 'password' && $imapPassword !== null) { $this->logger->debug('Connecting to account {account}', ['account' => $newAccount->getEmail()]); $this->testConnectivity($account); } diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue index 72ec99fa53..a5852cd802 100644 --- a/src/components/AccountForm.vue +++ b/src/components/AccountForm.vue @@ -199,11 +199,11 @@ required @change="clearFeedback" /> - - @@ -261,7 +261,7 @@ import { import { CONSENT_ABORTED, getUserConsent } from '../integration/oauth.js' import useMainStore from '../store/mainStore.js' import { mapStores, mapState } from 'pinia' -import NewEmailAddressTab from './ionos/NewEmailAddressTab.vue' +import ExternalProviderTab from './ExternalProviderTab.vue' export default { name: 'AccountForm', @@ -274,7 +274,7 @@ export default { ButtonVue, IconLoading, IconCheck, - NewEmailAddressTab, + ExternalProviderTab, }, props: { displayName: { @@ -332,8 +332,8 @@ export default { 'microsoftOauthUrl', ]), - useIonosMailconfig() { - return this.mainStore.getPreference('ionos-mailconfig-enabled', null) + useProviderMailconfig() { + return this.mainStore.getPreference('mail-providers-available', false) }, settingsPage() { diff --git a/src/components/ExternalProviderTab.vue b/src/components/ExternalProviderTab.vue new file mode 100644 index 0000000000..4c8f3d4025 --- /dev/null +++ b/src/components/ExternalProviderTab.vue @@ -0,0 +1,418 @@ + + + + + + diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue deleted file mode 100644 index cb4c290f49..0000000000 --- a/src/components/ionos/NewEmailAddressTab.vue +++ /dev/null @@ -1,220 +0,0 @@ - - - - - - - - diff --git a/src/init.js b/src/init.js index bc9529ad6a..a9a851251e 100644 --- a/src/init.js +++ b/src/init.js @@ -38,12 +38,8 @@ export default function initAfterAppCreation() { value: preferences['config-installed-version'], }) mainStore.savePreferenceMutation({ - key: 'ionos-mailconfig-enabled', - value: preferences['ionos-mailconfig-enabled'], - }) - mainStore.savePreferenceMutation({ - key: 'ionos-mailconfig-domain', - value: preferences['ionos-mailconfig-domain'], + key: 'mail-providers-available', + value: preferences['mail-providers-available'], }) mainStore.savePreferenceMutation({ key: 'external-avatars', 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/ExternalAccountsControllerTest.php b/tests/Unit/Controller/ExternalAccountsControllerTest.php new file mode 100644 index 0000000000..7268433b25 --- /dev/null +++ b/tests/Unit/Controller/ExternalAccountsControllerTest.php @@ -0,0 +1,388 @@ +request = $this->createMock(IRequest::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->controller = new ExternalAccountsController( + $this->appName, + $this->request, + $this->providerRegistry, + $this->userSession, + $this->logger, + ); + } + + public function testCreateWithNoUserSession(): void { + $this->userSession->method('getUser') + ->willReturn(null); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + public function testCreateWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->create('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testCreateWithDisabledProvider(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('disabled-provider') + ->willReturn($provider); + + $response = $this->controller->create('disabled-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + } + + public function testCreateWithProviderNotAvailableForUser(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->with('testuser') + ->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + $this->assertStringContainsString('not available for this user', $data['data']['message']); + } + + public function testCreateSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + '_route' => 'some-route', + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]); + + $mailAccount = new MailAccount(); + $mailAccount->setId(123); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->with('testuser') + ->willReturn(true); + $provider->method('createAccount') + ->with('testuser', [ + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]) + ->willReturn($account); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + } + + public function testCreateWithInvalidArgumentException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new \InvalidArgumentException('Missing required parameter')); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('INVALID_PARAMETERS', $data['data']['error']); + } + + public function testCreateWithServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new ServiceException('Service error', 500)); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + } + + public function testCreateWithIonosServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new IonosServiceException('IONOS error', 503, null, ['detail' => 'API unavailable'])); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + $this->assertEquals('API unavailable', $data['data']['detail']); + } + + public function testGetProviders(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + creationParameterSchema: [ + 'param1' => ['type' => 'string', 'required' => true], + ], + emailDomain: 'example.com', + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('test-provider'); + $provider->method('getName')->willReturn('Test Provider'); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn(['test-provider' => $provider]); + + $response = $this->controller->getProviders(); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('providers', $data['data']); + $this->assertCount(1, $data['data']['providers']); + + $providerInfo = $data['data']['providers'][0]; + $this->assertEquals('test-provider', $providerInfo['id']); + $this->assertEquals('Test Provider', $providerInfo['name']); + $this->assertTrue($providerInfo['capabilities']['multipleAccounts']); + $this->assertTrue($providerInfo['capabilities']['appPasswords']); + $this->assertFalse($providerInfo['capabilities']['passwordReset']); + $this->assertEquals('example.com', $providerInfo['capabilities']['emailDomain']); + } + + public function testGeneratePasswordWithNoAccountId(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(null); + + $response = $this->controller->generatePassword('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + public function testGeneratePasswordWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->generatePassword('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testGeneratePasswordWithProviderNotSupporting(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $capabilities = new ProviderCapabilities( + appPasswords: false, + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getCapabilities') + ->willReturn($capabilities); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->generatePassword('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('NOT_SUPPORTED', $data['data']['error']); + } +} diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index ad43b0085d..3a0186c66b 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -10,26 +10,26 @@ namespace OCA\Mail\Tests\Unit\Controller; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Controller\AccountsController; +use OCA\Mail\Account; use OCA\Mail\Controller\IonosAccountsController; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; -use OCA\Mail\Service\IONOS\IonosMailService; -use OCP\AppFramework\Http\JSONResponse; +use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -use ReflectionClass; class IonosAccountsControllerTest extends TestCase { private string $appName; private IRequest&MockObject $request; - private IonosMailService&MockObject $ionosMailService; + private IonosAccountCreationService&MockObject $accountCreationService; - private AccountsController&MockObject $accountsController; + private IUserSession&MockObject $userSession; private LoggerInterface|MockObject $logger; @@ -40,19 +40,28 @@ protected function setUp(): void { $this->appName = 'mail'; $this->request = $this->createMock(IRequest::class); - $this->ionosMailService = $this->createMock(IonosMailService::class); - $this->accountsController = $this->createMock(AccountsController::class); + $this->accountCreationService = $this->createMock(IonosAccountCreationService::class); + $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new IonosAccountsController( $this->appName, $this->request, - $this->ionosMailService, - $this->accountsController, + $this->accountCreationService, + $this->userSession, $this->logger, ); } + /** + * Helper method to setup user session mock + */ + private function setupUserSession(string $userId): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($userId); + $this->userSession->method('getUser')->willReturn($user); + } + public function testCreateWithMissingFields(): void { // Test with empty account name $response = $this->controller->create('', 'testuser'); @@ -75,87 +84,90 @@ public function testCreateSuccess(): void { $accountName = 'Test Account'; $emailUser = 'test'; $emailAddress = 'test@example.com'; + $userId = 'test-user-123'; - // Create MailAccountConfig DTO - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + // Setup user session + $this->setupUserSession($userId); - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + // Create a real MailAccount instance and wrap it in Account + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId($userId); + $mailAccount->setName($accountName); + $mailAccount->setEmail($emailAddress); - $mailAccountConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); + $account = new Account($mailAccount); - // Mock successful IONOS mail service response - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willReturn($mailAccountConfig); + // Verify response matches the expected MailJsonResponse::success() format + $accountResponse = \OCA\Mail\Http\JsonResponse::success($account, 201); - // Mock account creation response - $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; - $accountResponse = $this->createMock(JSONResponse::class); - $accountResponse->method('getData')->willReturn($accountData); + // Mock account creation service to return a successful account + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); - $this->accountsController - ->method('create') - ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($accountResponse); + // Verify logging calls + $this->logger + ->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + $this->assertEquals('Starting IONOS email account creation from web', $message); + $this->assertEquals([ + 'userId' => $userId, + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ], $context); + } elseif ($callCount === 2) { + $this->assertEquals('Account creation completed successfully', $message); + $this->assertEquals([ + 'emailAddress' => $emailAddress, + 'accountName' => $accountName, + 'accountId' => 1, + 'userId' => $userId, + ], $context); + } + }); $response = $this->controller->create($accountName, $emailUser); - // The controller now directly returns the AccountsController response - $this->assertSame($accountResponse, $response); + $this->assertEquals($accountResponse, $response); } public function testCreateWithServiceException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; - // Mock IONOS mail service to throw ServiceException - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Setup user session + $this->setupUserSession($userId); + + // Mock account creation service to throw ServiceException + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Failed to create email account')); $this->logger ->expects($this->once()) ->method('error') ->with( - 'IONOS service error: Failed to create email account', + 'IONOS service error during account creation: Failed to create email account', [ 'error' => 'IONOS_API_ERROR', 'statusCode' => 0, + 'message' => 'Failed to create email account', ] ); $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ 'error' => 'IONOS_API_ERROR', 'statusCode' => 0, + 'message' => 'Failed to create email account', ]); $response = $this->controller->create($accountName, $emailUser); @@ -165,26 +177,82 @@ public function testCreateWithServiceException(): void { public function testCreateWithServiceExceptionWithStatusCode(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); - // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw ServiceException with status code + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Duplicate email account', 409)); $this->logger ->expects($this->once()) ->method('error') ->with( - 'IONOS service error: Duplicate email account', + 'IONOS service error during account creation: Duplicate email account', [ 'error' => 'IONOS_API_ERROR', 'statusCode' => 409, + 'message' => 'Duplicate email account', ] ); $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ 'error' => 'IONOS_API_ERROR', 'statusCode' => 409, + 'message' => 'Duplicate email account', + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + + public function testCreateWithIonosServiceExceptionWithAdditionalData(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); + + // Create IonosServiceException with additional data + $additionalData = [ + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ]; + + // Mock account creation service to throw IonosServiceException with additional data + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willThrowException(new IonosServiceException('Email already exists', 409, null, $additionalData)); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error during account creation: Email already exists', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + 'message' => 'Email already exists', + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + 'message' => 'Email already exists', + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', ]); $response = $this->controller->create($accountName, $emailUser); @@ -194,11 +262,28 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { public function testCreateWithGenericException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); - // Mock IONOS mail service to throw a generic exception - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willThrowException(new \Exception('Generic error')); + // Mock account creation service to throw a generic exception + $exception = new \Exception('Generic error'); + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willThrowException($exception); + + // Verify error logging for unexpected exceptions + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Unexpected error during account creation: Generic error', + [ + 'exception' => $exception, + ] + ); $expectedResponse = \OCA\Mail\Http\JsonResponse::error('Could not create account', 500, @@ -210,60 +295,33 @@ public function testCreateWithGenericException(): void { self::assertEquals($expectedResponse, $response); } - - public function testCreateNextcloudMailAccount(): void { + public function testCreateWithNoUserSession(): void { $accountName = 'Test Account'; - $emailAddress = 'test@example.com'; - - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); - - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); - - $mailConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); + $emailUser = 'test'; - $expectedResponse = $this->createMock(JSONResponse::class); + // Mock user session to return null (no user logged in) + $this->userSession->method('getUser')->willReturn(null); - $this->accountsController + // Should catch the ServiceException thrown by getUserIdOrFail + $this->logger ->expects($this->once()) - ->method('create') + ->method('error') ->with( - $accountName, - $emailAddress, - 'mail.localhost', - 1143, - 'none', - $emailAddress, - 'tmp', - 'mail.localhost', - 1587, - 'none', - $emailAddress, - 'tmp', - ) - ->willReturn($expectedResponse); - - $reflection = new ReflectionClass($this->controller); - $method = $reflection->getMethod('createNextcloudMailAccount'); - $method->setAccessible(true); - - $result = $method->invoke($this->controller, $accountName, $mailConfig); - - $this->assertSame($expectedResponse, $result); + 'IONOS service error during account creation: No user session found during account creation', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 401, + 'message' => 'No user session found during account creation', + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 401, + 'message' => 'No user session found during account creation', + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); } } diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 234db9b33c..73aaa3035c 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -16,13 +16,12 @@ use OCA\Mail\Controller\PageController; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Service\AccountProviderService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\InternalAddressService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; @@ -115,9 +114,7 @@ class PageControllerTest extends TestCase { private IAvailabilityCoordinator&MockObject $availabilityCoordinator; - private IonosConfigService&MockObject $ionosConfigService; - - private IonosMailConfigService&MockObject $ionosMailConfigService; + private AccountProviderService&MockObject $accountProviderService; protected function setUp(): void { parent::setUp(); @@ -145,8 +142,7 @@ protected function setUp(): void { $this->internalAddressService = $this->createMock(InternalAddressService::class); $this->availabilityCoordinator = $this->createMock(IAvailabilityCoordinator::class); $this->quickActionsService = $this->createMock(QuickActionsService::class); - $this->ionosConfigService = $this->createMock(IonosConfigService::class); - $this->ionosMailConfigService = $this->createMock(IonosMailConfigService::class); + $this->accountProviderService = $this->createMock(AccountProviderService::class); $this->controller = new PageController( $this->appName, @@ -172,8 +168,7 @@ protected function setUp(): void { $this->internalAddressService, $this->availabilityCoordinator, $this->quickActionsService, - $this->ionosConfigService, - $this->ionosMailConfigService, + $this->accountProviderService, ); } @@ -291,12 +286,10 @@ public function testIndex(): void { $this->returnValue('cron'), $this->returnValue('yes'), ); - $this->ionosMailConfigService->expects($this->once()) - ->method('isMailConfigAvailable') - ->willReturn(false); - $this->ionosConfigService->expects($this->once()) - ->method('getMailDomain') - ->willReturn('example.tld'); + $this->accountProviderService->expects($this->once()) + ->method('getAvailableProvidersForUser') + ->with($this->userId) + ->willReturn([]); $this->aiIntegrationsService->expects(self::exactly(4)) ->method('isLlmProcessingEnabled') ->willReturn(false); @@ -347,8 +340,6 @@ public function testIndex(): void { 'external-avatars' => 'true', 'reply-mode' => 'bottom', 'app-version' => '1.2.3', - 'ionos-mailconfig-enabled' => false, - 'ionos-mailconfig-domain' => 'example.tld', 'collect-data' => 'true', 'start-mailbox-id' => '123', 'tag-classified-messages' => 'false', @@ -356,6 +347,7 @@ public function testIndex(): void { 'layout-mode' => 'vertical-split', 'layout-message-view' => 'threaded', 'follow-up-reminders' => 'true', + 'mail-providers-available' => false, ]], ['prefill_displayName', 'Jane Doe'], ['prefill_email', 'jane@doe.cz'], diff --git a/tests/Unit/Exception/IonosServiceExceptionTest.php b/tests/Unit/Exception/IonosServiceExceptionTest.php new file mode 100644 index 0000000000..390209e9df --- /dev/null +++ b/tests/Unit/Exception/IonosServiceExceptionTest.php @@ -0,0 +1,72 @@ +assertEquals('Test message', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + $this->assertEquals([], $exception->getData()); + } + + public function testConstructorWithData(): void { + $data = [ + 'errorCode' => 'DUPLICATE_EMAIL', + 'email' => 'test@example.com', + 'userId' => 'user123', + ]; + + $exception = new IonosServiceException('Duplicate email', 409, null, $data); + + $this->assertEquals('Duplicate email', $exception->getMessage()); + $this->assertEquals(409, $exception->getCode()); + $this->assertEquals($data, $exception->getData()); + } + + public function testConstructorWithPreviousException(): void { + $previous = new \Exception('Original error'); + $data = ['context' => 'test']; + + $exception = new IonosServiceException('Wrapped error', 500, $previous, $data); + + $this->assertEquals('Wrapped error', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + $this->assertEquals($previous, $exception->getPrevious()); + $this->assertEquals($data, $exception->getData()); + } + + public function testGetDataReturnsEmptyArrayByDefault(): void { + $exception = new IonosServiceException(); + + $this->assertEquals([], $exception->getData()); + } + + public function testGetDataPreservesComplexData(): void { + $data = [ + 'errorCode' => 'VALIDATION_ERROR', + 'fields' => ['email', 'password'], + 'metadata' => [ + 'timestamp' => 1234567890, + 'requestId' => 'req-123', + ], + ]; + + $exception = new IonosServiceException('Validation failed', 400, null, $data); + + $this->assertEquals($data, $exception->getData()); + $this->assertIsArray($exception->getData()['fields']); + $this->assertIsArray($exception->getData()['metadata']); + } +} diff --git a/tests/Unit/Listener/UserDeletedListenerTest.php b/tests/Unit/Listener/UserDeletedListenerTest.php index b027f03f6e..83db0e8a13 100644 --- a/tests/Unit/Listener/UserDeletedListenerTest.php +++ b/tests/Unit/Listener/UserDeletedListenerTest.php @@ -14,8 +14,8 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\ClientException; use OCA\Mail\Listener\UserDeletedListener; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\IUser; use OCP\User\Events\UserDeletedEvent; @@ -25,7 +25,7 @@ class UserDeletedListenerTest extends TestCase { private AccountService&MockObject $accountService; private LoggerInterface&MockObject $logger; - private IonosMailService&MockObject $ionosMailService; + private ProviderRegistryService&MockObject $providerRegistry; private UserDeletedListener $listener; protected function setUp(): void { @@ -33,12 +33,12 @@ protected function setUp(): void { $this->accountService = $this->createMock(AccountService::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); $this->listener = new UserDeletedListener( $this->accountService, $this->logger, - $this->ionosMailService + $this->providerRegistry ); } @@ -61,8 +61,8 @@ public function testImplementsIEventListener(): void { public function testHandleUnrelated(): void { $event = new Event(); - $this->ionosMailService->expects($this->never()) - ->method('tryDeleteEmailAccount'); + $this->providerRegistry->expects($this->never()) + ->method('deleteProviderManagedAccounts'); $this->accountService->expects($this->never()) ->method('findByUserId'); @@ -76,15 +76,15 @@ public function testHandleUserDeletedWithNoAccounts(): void { $user = $this->createUserMock('test-user'); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', []); + $this->accountService->expects($this->never()) ->method('delete'); @@ -99,15 +99,15 @@ public function testHandleUserDeletedWithSingleAccount(): void { $account = $this->createAccountMock(42); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account]); + $this->accountService->expects($this->once()) ->method('delete') ->with('test-user', 42); @@ -125,15 +125,15 @@ public function testHandleUserDeletedWithMultipleAccounts(): void { $account3 = $this->createAccountMock(3); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account1, $account2, $account3]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account1, $account2, $account3]); + $this->accountService->expects($this->exactly(3)) ->method('delete') ->willReturnCallback(function ($userId, $accountId) { @@ -154,15 +154,15 @@ public function testHandleUserDeletedWithClientException(): void { $exception = new ClientException('Test exception'); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account]); + $this->accountService->expects($this->once()) ->method('delete') ->with('test-user', 42) @@ -187,15 +187,15 @@ public function testHandleUserDeletedWithPartialFailure(): void { $exception = new ClientException('Failed to delete account 2'); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account1, $account2, $account3]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account1, $account2, $account3]); + $this->accountService->expects($this->exactly(3)) ->method('delete') ->willReturnCallback(function ($userId, $accountId) use ($exception) { diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php new file mode 100644 index 0000000000..f58c67fa6b --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php @@ -0,0 +1,344 @@ +configService = $this->createMock(IonosConfigService::class); + $this->queryService = $this->createMock(IonosAccountQueryService::class); + $this->mutationService = $this->createMock(IonosAccountMutationService::class); + $this->creationService = $this->createMock(IonosAccountCreationService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->facade = new IonosProviderFacade( + $this->configService, + $this->queryService, + $this->mutationService, + $this->creationService, + $this->logger, + ); + } + + public function testIsEnabledReturnsTrue(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $result = $this->facade->isEnabled(); + + $this->assertTrue($result); + } + + public function testIsEnabledReturnsFalse(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(false); + + $result = $this->facade->isEnabled(); + + $this->assertFalse($result); + } + + public function testIsEnabledHandlesException(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS provider is not enabled', $this->anything()); + + $result = $this->facade->isEnabled(); + + $this->assertFalse($result); + } + + public function testIsAvailableForUserReturnsTrueWhenNoAccount(): void { + $userId = 'user123'; + + $this->queryService->expects($this->once()) + ->method('mailAccountExistsForUserId') + ->with($userId) + ->willReturn(false); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertTrue($result); + } + + public function testIsAvailableForUserReturnsFalseWhenAccountExists(): void { + $userId = 'user123'; + + $this->queryService->expects($this->once()) + ->method('mailAccountExistsForUserId') + ->with($userId) + ->willReturn(true); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertFalse($result); + } + + public function testIsAvailableForUserHandlesException(): void { + $userId = 'user123'; + + $this->queryService->expects($this->once()) + ->method('mailAccountExistsForUserId') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error checking IONOS availability for user', $this->anything()); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertFalse($result); + } + + public function testCreateAccountSuccess(): void { + $userId = 'user123'; + $emailUser = 'john.doe'; + $accountName = 'John Doe'; + $account = $this->createMock(Account::class); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Creating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); + + $result = $this->facade->createAccount($userId, $emailUser, $accountName); + + $this->assertSame($account, $result); + } + + public function testUpdateAccountSuccess(): void { + $userId = 'user123'; + $emailUser = 'john.doe'; + $accountName = 'John Doe'; + $account = $this->createMock(Account::class); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Updating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); + + $result = $this->facade->updateAccount($userId, $emailUser, $accountName); + + $this->assertSame($account, $result); + } + + public function testDeleteAccountSuccess(): void { + $userId = 'user123'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + $this->mutationService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with($userId); + + $result = $this->facade->deleteAccount($userId); + + $this->assertTrue($result); + } + + public function testDeleteAccountHandlesException(): void { + $userId = 'user123'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + $this->mutationService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with($userId) + ->willThrowException(new \Exception('Deletion failed')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error deleting IONOS account via facade', $this->anything()); + + $result = $this->facade->deleteAccount($userId); + + $this->assertFalse($result); + } + + public function testGetProvisionedEmailSuccess(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->getProvisionedEmail($userId); + + $this->assertSame($email, $result); + } + + public function testGetProvisionedEmailHandlesException(): void { + $userId = 'user123'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->facade->getProvisionedEmail($userId); + + $this->assertNull($result); + } + + public function testManagesEmailReturnsTrue(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertTrue($result); + } + + public function testManagesEmailReturnsTrueCaseInsensitive(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + $checkEmail = 'USER@IONOS.COM'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->managesEmail($userId, $checkEmail); + + $this->assertTrue($result); + } + + public function testManagesEmailReturnsFalseWhenNoIonosAccount(): void { + $userId = 'user123'; + $email = 'user@other.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn(null); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertFalse($result); + } + + public function testManagesEmailReturnsFalseWhenDifferentEmail(): void { + $userId = 'user123'; + $ionosEmail = 'user@ionos.com'; + $checkEmail = 'other@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($ionosEmail); + + $result = $this->facade->managesEmail($userId, $checkEmail); + + $this->assertFalse($result); + } + + public function testManagesEmailHandlesException(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertFalse($result); + } + + public function testGetEmailDomainSuccess(): void { + $domain = 'ionos.com'; + + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn($domain); + + $result = $this->facade->getEmailDomain(); + + $this->assertSame($domain, $result); + } + + public function testGetEmailDomainHandlesException(): void { + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Could not get IONOS email domain', $this->anything()); + + $result = $this->facade->getEmailDomain(); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php new file mode 100644 index 0000000000..bbf04c406e --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php @@ -0,0 +1,260 @@ +facade = $this->createMock(IonosProviderFacade::class); + + $this->provider = new IonosProvider( + $this->facade, + ); + } + + public function testGetId(): void { + $this->assertEquals('ionos', $this->provider->getId()); + } + + public function testGetName(): void { + $this->assertEquals('IONOS Mail', $this->provider->getName()); + } + + public function testGetCapabilities(): void { + $this->facade->method('getEmailDomain') + ->willReturn('example.com'); + + $capabilities = $this->provider->getCapabilities(); + + $this->assertFalse($capabilities->allowsMultipleAccounts()); + $this->assertTrue($capabilities->supportsAppPasswords()); + $this->assertTrue($capabilities->supportsPasswordReset()); + $this->assertEquals('example.com', $capabilities->getEmailDomain()); + + $configSchema = $capabilities->getConfigSchema(); + $this->assertArrayHasKey('ionos_mailconfig_api_base_url', $configSchema); + $this->assertArrayHasKey('ionos_mailconfig_api_auth_user', $configSchema); + $this->assertArrayHasKey('ionos_mailconfig_api_auth_pass', $configSchema); + + $creationSchema = $capabilities->getCreationParameterSchema(); + $this->assertArrayHasKey('accountName', $creationSchema); + $this->assertArrayHasKey('emailUser', $creationSchema); + } + + public function testGetCapabilitiesWithExceptionOnDomain(): void { + $this->facade->method('getEmailDomain') + ->willReturn(null); + + $capabilities = $this->provider->getCapabilities(); + + $this->assertNull($capabilities->getEmailDomain()); + } + + public function testGetCapabilitiesCached(): void { + $this->facade->expects($this->once()) + ->method('getEmailDomain') + ->willReturn('example.com'); + + // Call twice to test caching + $capabilities1 = $this->provider->getCapabilities(); + $capabilities2 = $this->provider->getCapabilities(); + + $this->assertSame($capabilities1, $capabilities2); + } + + public function testIsEnabledWhenEnabled(): void { + $this->facade->method('isEnabled') + ->willReturn(true); + + $this->assertTrue($this->provider->isEnabled()); + } + + public function testIsEnabledWhenDisabled(): void { + $this->facade->method('isEnabled') + ->willReturn(false); + + $this->assertFalse($this->provider->isEnabled()); + } + + public function testIsAvailableForUserWhenNoAccount(): void { + $this->facade->method('isAvailableForUser') + ->with('testuser') + ->willReturn(true); + + $this->assertTrue($this->provider->isAvailableForUser('testuser')); + } + + public function testIsAvailableForUserWhenHasAccount(): void { + $this->facade->method('isAvailableForUser') + ->with('testuser') + ->willReturn(false); + + $this->assertFalse($this->provider->isAvailableForUser('testuser')); + } + + public function testCreateAccountSuccess(): void { + $userId = 'testuser'; + $parameters = [ + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]; + + $mailAccount = new MailAccount(); + $mailAccount->setId(123); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $this->facade->expects($this->once()) + ->method('createAccount') + ->with($userId, 'user', 'Test Account') + ->willReturn($account); + + $result = $this->provider->createAccount($userId, $parameters); + + $this->assertSame($account, $result); + $this->assertEquals('user@example.com', $result->getEmail()); + } + + public function testCreateAccountWithMissingEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'accountName' => 'Test Account', + ]); + } + + public function testCreateAccountWithMissingAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => 'user', + ]); + } + + public function testCreateAccountWithEmptyEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => '', + 'accountName' => 'Test Account', + ]); + } + + public function testCreateAccountWithEmptyAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => 'user', + 'accountName' => '', + ]); + } + + public function testUpdateAccount(): void { + $userId = 'testuser'; + $accountId = 123; + $parameters = [ + 'emailUser' => 'user', + 'accountName' => 'Updated Account', + ]; + + $mailAccount = new MailAccount(); + $mailAccount->setId($accountId); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $this->facade->expects($this->once()) + ->method('createAccount') + ->with($userId, 'user', 'Updated Account') + ->willReturn($account); + + $result = $this->provider->updateAccount($userId, $accountId, $parameters); + + $this->assertSame($account, $result); + } + + public function testDeleteAccount(): void { + $this->facade->expects($this->once()) + ->method('deleteAccount') + ->with('testuser') + ->willReturn(true); + + $result = $this->provider->deleteAccount('testuser', 'user@example.com'); + + $this->assertTrue($result); + } + + public function testManagesEmailWhenMatches(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'user@example.com') + ->willReturn(true); + + $this->assertTrue($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testManagesEmailCaseInsensitive(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'USER@EXAMPLE.COM') + ->willReturn(true); + + $this->assertTrue($this->provider->managesEmail('testuser', 'USER@EXAMPLE.COM')); + } + + public function testManagesEmailWhenDoesNotMatch(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'other@example.com') + ->willReturn(false); + + $this->assertFalse($this->provider->managesEmail('testuser', 'other@example.com')); + } + + public function testManagesEmailWhenNoIonosEmail(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'user@example.com') + ->willReturn(false); + + $this->assertFalse($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testGetProvisionedEmail(): void { + $this->facade->method('getProvisionedEmail') + ->with('testuser') + ->willReturn('user@example.com'); + + $result = $this->provider->getProvisionedEmail('testuser'); + + $this->assertEquals('user@example.com', $result); + } + + public function testGetProvisionedEmailWithNoEmail(): void { + $this->facade->method('getProvisionedEmail') + ->with('testuser') + ->willReturn(null); + + + $result = $this->provider->getProvisionedEmail('testuser'); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php b/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php new file mode 100644 index 0000000000..0ed0176ed3 --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php @@ -0,0 +1,265 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new ProviderRegistryService($this->logger); + } + + public function testRegisterProvider(): void { + $provider = $this->createMockProvider('test', 'Test Provider'); + + $this->registry->registerProvider($provider); + + $this->assertEquals($provider, $this->registry->getProvider('test')); + } + + public function testGetProviderReturnsNullForUnknownId(): void { + $result = $this->registry->getProvider('unknown'); + + $this->assertNull($result); + } + + public function testGetAllProviders(): void { + $provider1 = $this->createMockProvider('test1', 'Test Provider 1'); + $provider2 = $this->createMockProvider('test2', 'Test Provider 2'); + + $this->registry->registerProvider($provider1); + $this->registry->registerProvider($provider2); + + $providers = $this->registry->getAllProviders(); + + $this->assertCount(2, $providers); + $this->assertArrayHasKey('test1', $providers); + $this->assertArrayHasKey('test2', $providers); + } + + public function testGetEnabledProviders(): void { + $enabledProvider = $this->createMockProvider('enabled', 'Enabled', true); + $disabledProvider = $this->createMockProvider('disabled', 'Disabled', false); + + $this->registry->registerProvider($enabledProvider); + $this->registry->registerProvider($disabledProvider); + + $enabled = $this->registry->getEnabledProviders(); + + $this->assertCount(1, $enabled); + $this->assertArrayHasKey('enabled', $enabled); + $this->assertArrayNotHasKey('disabled', $enabled); + } + + public function testGetAvailableProvidersForUser(): void { + $availableProvider = $this->createMockProvider('available', 'Available', true, true); + $unavailableProvider = $this->createMockProvider('unavailable', 'Unavailable', true, false); + + $this->registry->registerProvider($availableProvider); + $this->registry->registerProvider($unavailableProvider); + + $available = $this->registry->getAvailableProvidersForUser('testuser'); + + $this->assertCount(1, $available); + $this->assertArrayHasKey('available', $available); + } + + public function testFindProviderForEmail(): void { + $matchingProvider = $this->createMockProvider('matching', 'Matching', true); + $matchingProvider->method('managesEmail') + ->willReturn(true); + + $nonMatchingProvider = $this->createMockProvider('nonmatching', 'Non-Matching', true); + $nonMatchingProvider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($matchingProvider); + $this->registry->registerProvider($nonMatchingProvider); + + $result = $this->registry->findProviderForEmail('user', 'test@example.com'); + + $this->assertEquals($matchingProvider, $result); + } + + public function testFindProviderForEmailReturnsNullIfNoneMatch(): void { + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($provider); + + $result = $this->registry->findProviderForEmail('user', 'test@example.com'); + + $this->assertNull($result); + } + + public function testGetProviderInfo(): void { + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('test'); + $provider->method('getName')->willReturn('Test Provider'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->registry->registerProvider($provider); + + $info = $this->registry->getProviderInfo(); + + $this->assertArrayHasKey('test', $info); + $this->assertEquals('test', $info['test']['id']); + $this->assertEquals('Test Provider', $info['test']['name']); + $this->assertTrue($info['test']['enabled']); + $this->assertTrue($info['test']['capabilities']['multipleAccounts']); + $this->assertTrue($info['test']['capabilities']['appPasswords']); + $this->assertFalse($info['test']['capabilities']['passwordReset']); + } + + public function testDeleteProviderManagedAccountsWithNoProviderManaged(): void { + $userId = 'testuser'; + $account = $this->createMockAccount('user@example.com'); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($provider); + + $provider->expects($this->never()) + ->method('deleteAccount'); + + $this->registry->deleteProviderManagedAccounts($userId, [$account]); + } + + public function testDeleteProviderManagedAccountsWithProviderManaged(): void { + $userId = 'testuser'; + $email = 'user@example.com'; + $account = $this->createMockAccount($email); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->with($userId, $email) + ->willReturn(true); + $provider->expects($this->once()) + ->method('deleteAccount') + ->with($userId, $email); + + $this->registry->registerProvider($provider); + + $this->registry->deleteProviderManagedAccounts($userId, [$account]); + } + + public function testDeleteProviderManagedAccountsWithMultipleAccounts(): void { + $userId = 'testuser'; + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + $email3 = 'user3@example.com'; + + $account1 = $this->createMockAccount($email1); + $account2 = $this->createMockAccount($email2); + $account3 = $this->createMockAccount($email3); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturnMap([ + [$userId, $email1, true], + [$userId, $email2, false], + [$userId, $email3, true], + ]); + $provider->expects($this->exactly(2)) + ->method('deleteAccount') + ->willReturnCallback(function ($uid, $email) use ($userId, $email1, $email3) { + $this->assertSame($userId, $uid); + $this->assertContains($email, [$email1, $email3]); + return true; + }); + + $this->registry->registerProvider($provider); + + $this->registry->deleteProviderManagedAccounts($userId, [$account1, $account2, $account3]); + } + + public function testDeleteProviderManagedAccountsContinuesOnException(): void { + $userId = 'testuser'; + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + + $account1 = $this->createMockAccount($email1); + $account2 = $this->createMockAccount($email2); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(true); + $provider->expects($this->exactly(2)) + ->method('deleteAccount') + ->willReturnCallback(function ($uid, $email) use ($email1) { + if ($email === $email1) { + throw new \Exception('Deletion failed'); + } + return true; + }); + + $this->registry->registerProvider($provider); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Failed to delete provider-managed account', $this->anything()); + + // Should not throw exception, continues with second account + $this->registry->deleteProviderManagedAccounts($userId, [$account1, $account2]); + } + + private function createMockAccount(string $email): object { + return new class($email) { + private string $email; + + public function __construct(string $email) { + $this->email = $email; + } + + public function getEmail(): string { + return $this->email; + } + }; + } + + private function createMockProvider( + string $id, + string $name, + bool $enabled = true, + bool $availableForUser = true, + ): IMailAccountProvider&MockObject { + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn($id); + $provider->method('getName')->willReturn($name); + $provider->method('isEnabled')->willReturn($enabled); + $provider->method('isAvailableForUser')->willReturn($availableForUser); + + $capabilities = new ProviderCapabilities(); + $provider->method('getCapabilities')->willReturn($capabilities); + + return $provider; + } +} diff --git a/tests/Unit/Service/AccountProviderServiceTest.php b/tests/Unit/Service/AccountProviderServiceTest.php new file mode 100644 index 0000000000..f66d1f7d25 --- /dev/null +++ b/tests/Unit/Service/AccountProviderServiceTest.php @@ -0,0 +1,182 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new AccountProviderService( + $this->providerRegistry, + $this->logger, + ); + } + + public function testAddProviderMetadataWithNoProvider(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willReturn(null); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + $this->assertArrayHasKey('managedByProvider', $result); + $this->assertNull($result['managedByProvider']); + $this->assertArrayHasKey('providerCapabilities', $result); + $this->assertNull($result['providerCapabilities']); + } + + public function testAddProviderMetadataWithProvider(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + emailDomain: 'example.com', + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId') + ->willReturn('test-provider'); + $provider->method('getCapabilities') + ->willReturn($capabilities); + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willReturn($provider); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + $this->assertEquals('test-provider', $result['managedByProvider']); + $this->assertIsArray($result['providerCapabilities']); + $this->assertTrue($result['providerCapabilities']['multipleAccounts']); + $this->assertTrue($result['providerCapabilities']['appPasswords']); + $this->assertFalse($result['providerCapabilities']['passwordReset']); + $this->assertEquals('example.com', $result['providerCapabilities']['emailDomain']); + } + + public function testAddProviderMetadataWithException(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willThrowException(new \Exception('Test exception')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error determining account provider', $this->anything()); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + // Should return safe defaults + $this->assertNull($result['managedByProvider']); + $this->assertNull($result['providerCapabilities']); + } + + public function testGetAvailableProvidersForUser(): void { + $capabilities1 = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + creationParameterSchema: [ + 'param1' => ['type' => 'string', 'required' => true], + ], + emailDomain: 'example.com', + ); + + $capabilities2 = new ProviderCapabilities( + multipleAccounts: false, + appPasswords: false, + passwordReset: true, + creationParameterSchema: [ + 'param2' => ['type' => 'string', 'required' => false], + ], + emailDomain: 'test.com', + ); + + $provider1 = $this->createMock(IMailAccountProvider::class); + $provider1->method('getId')->willReturn('provider1'); + $provider1->method('getName')->willReturn('Provider 1'); + $provider1->method('getCapabilities')->willReturn($capabilities1); + + $provider2 = $this->createMock(IMailAccountProvider::class); + $provider2->method('getId')->willReturn('provider2'); + $provider2->method('getName')->willReturn('Provider 2'); + $provider2->method('getCapabilities')->willReturn($capabilities2); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn([ + 'provider1' => $provider1, + 'provider2' => $provider2, + ]); + + $result = $this->service->getAvailableProvidersForUser('testuser'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('provider1', $result); + $this->assertArrayHasKey('provider2', $result); + + // Check provider1 + $this->assertEquals('provider1', $result['provider1']['id']); + $this->assertEquals('Provider 1', $result['provider1']['name']); + $this->assertTrue($result['provider1']['capabilities']['multipleAccounts']); + $this->assertTrue($result['provider1']['capabilities']['appPasswords']); + $this->assertFalse($result['provider1']['capabilities']['passwordReset']); + $this->assertEquals('example.com', $result['provider1']['capabilities']['emailDomain']); + $this->assertArrayHasKey('param1', $result['provider1']['parameterSchema']); + + // Check provider2 + $this->assertEquals('provider2', $result['provider2']['id']); + $this->assertEquals('Provider 2', $result['provider2']['name']); + $this->assertFalse($result['provider2']['capabilities']['multipleAccounts']); + $this->assertFalse($result['provider2']['capabilities']['appPasswords']); + $this->assertTrue($result['provider2']['capabilities']['passwordReset']); + $this->assertEquals('test.com', $result['provider2']['capabilities']['emailDomain']); + $this->assertArrayHasKey('param2', $result['provider2']['parameterSchema']); + } + + public function testGetAvailableProvidersForUserWithNoProviders(): void { + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn([]); + + $result = $this->service->getAvailableProvidersForUser('testuser'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php new file mode 100644 index 0000000000..5b38eae62a --- /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..ccc6541304 --- /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..6814772616 --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php @@ -0,0 +1,591 @@ +ionosMailService = $this->createMock(IonosMailService::class); + $this->conflictResolver = $this->createMock(IonosAccountConflictResolver::class); + $this->accountService = $this->createMock(AccountService::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new IonosAccountCreationService( + $this->ionosMailService, + $this->conflictResolver, + $this->accountService, + $this->crypto, + $this->logger, + ); + } + + public function testCreateOrUpdateAccountNewAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'test-password-123'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willReturn($mailConfig); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(1); + $savedAccount->setUserId($userId); + $savedAccount->setEmail($emailAddress); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account) use ($savedAccount) { + $this->assertEquals('testuser', $account->getUserId()); + $this->assertEquals('Test Account', $account->getName()); + $this->assertEquals('test@example.com', $account->getEmail()); + $this->assertEquals('password', $account->getAuthMethod()); + return $savedAccount; + }); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(1, $result->getId()); + } + + public function testCreateOrUpdateAccountExistingNextcloudAccountSuccess(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-password-456'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $mailConfig = $this->createMailAccountConfig($emailAddress, $newPassword); + + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($newPassword) + ->willReturn('encrypted-' . $newPassword); + + $this->accountService->expects($this->once()) + ->method('update') + ->with($existingAccount) + ->willReturn($existingAccount); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(5, $result->getId()); + } + + public function testCreateOrUpdateAccountExistingAccountEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $existingEmail = 'different@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $resolutionResult = ConflictResolutionResult::emailMismatch($emailAddress, $existingEmail); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Nextcloud mail account already exists, resetting credentials', $this->anything()); + + try { + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + $this->fail('Expected IonosServiceException to be thrown'); + } catch (IonosServiceException $e) { + $this->assertEquals(409, $e->getCode()); + $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); + + $data = $e->getData(); + $this->assertArrayHasKey('expectedEmail', $data); + $this->assertArrayHasKey('existingEmail', $data); + $this->assertEquals($emailAddress, $data['expectedEmail']); + $this->assertEquals($existingEmail, $data['existingEmail']); + } + } + + public function testCreateOrUpdateAccountNewAccountWithConflictResolution(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'reset-password-789'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + // First attempt to create fails + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException(new ServiceException('Account already exists', 409)); + + // Conflict resolution succeeds + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(2); + $savedAccount->setUserId($userId); + $savedAccount->setEmail($emailAddress); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturn($savedAccount); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(2, $result->getId()); + } + + public function testCreateOrUpdateAccountSetsCorrectCredentials(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'secret-password'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willReturn($mailConfig); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(10); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account) use ($savedAccount, $emailAddress) { + // Verify IMAP settings + $this->assertEquals('imap.example.com', $account->getInboundHost()); + $this->assertEquals(993, $account->getInboundPort()); + $this->assertEquals('ssl', $account->getInboundSslMode()); + $this->assertEquals($emailAddress, $account->getInboundUser()); + $this->assertEquals('encrypted-secret-password', $account->getInboundPassword()); + + // Verify SMTP settings + $this->assertEquals('smtp.example.com', $account->getOutboundHost()); + $this->assertEquals(465, $account->getOutboundPort()); + $this->assertEquals('ssl', $account->getOutboundSslMode()); + $this->assertEquals($emailAddress, $account->getOutboundUser()); + $this->assertEquals('encrypted-secret-password', $account->getOutboundPassword()); + + return $savedAccount; + }); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + } + + private function createMailAccountConfig(string $emailAddress, string $password): MailAccountConfig { + $imapConfig = new MailServerConfig( + host: 'imap.example.com', + port: 993, + security: 'ssl', + username: $emailAddress, + password: $password, + ); + + $smtpConfig = new MailServerConfig( + host: 'smtp.example.com', + port: 465, + security: 'ssl', + username: $emailAddress, + password: $password, + ); + + return new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + } + + /** + * Helper to create an account object with a MailAccount + * This simulates the structure returned by AccountService::findByUserIdAndAddress + */ + public function testCreateOrUpdateAccountExistingAccountNoIonosAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Nextcloud account exists but no IONOS account found'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountExistingAccountConflictResolverThrows(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $originalException = new ServiceException('IONOS API error', 503); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(503); + $this->expectExceptionMessage('Failed to reset IONOS account credentials: IONOS API error'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionFails(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Account creation failed', 500); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Account creation failed'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $existingEmail = 'other@example.com'; + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Account already exists', 409); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $resolutionResult = ConflictResolutionResult::emailMismatch($emailAddress, $existingEmail); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + try { + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + $this->fail('Expected IonosServiceException to be thrown'); + } catch (IonosServiceException $e) { + $this->assertEquals(409, $e->getCode()); + $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); + $this->assertStringContainsString($emailAddress, $e->getMessage()); + $this->assertStringContainsString($existingEmail, $e->getMessage()); + + $data = $e->getData(); + $this->assertArrayHasKey('expectedEmail', $data); + $this->assertArrayHasKey('existingEmail', $data); + $this->assertEquals($emailAddress, $data['expectedEmail']); + $this->assertEquals($existingEmail, $data['existingEmail']); + + // Verify the previous exception is set + $this->assertSame($originalException, $e->getPrevious()); + } + } + + public function testUpdateAccountSetsCorrectCredentials(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Updated Account Name'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-password-xyz'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(7); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + $existingAccount->setName('Old Account Name'); + + $mailConfig = $this->createMailAccountConfig($emailAddress, $newPassword); + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($newPassword) + ->willReturn('encrypted-' . $newPassword); + + $this->accountService->expects($this->once()) + ->method('update') + ->willReturnCallback(function (MailAccount $account) use ($existingAccount, $emailAddress, $accountName) { + // Verify account name is updated + $this->assertEquals($accountName, $account->getName()); + + // Verify IMAP settings + $this->assertEquals('imap.example.com', $account->getInboundHost()); + $this->assertEquals(993, $account->getInboundPort()); + $this->assertEquals('ssl', $account->getInboundSslMode()); + $this->assertEquals($emailAddress, $account->getInboundUser()); + $this->assertEquals('encrypted-new-password-xyz', $account->getInboundPassword()); + + // Verify SMTP settings + $this->assertEquals('smtp.example.com', $account->getOutboundHost()); + $this->assertEquals(465, $account->getOutboundPort()); + $this->assertEquals('ssl', $account->getOutboundSslMode()); + $this->assertEquals($emailAddress, $account->getOutboundUser()); + $this->assertEquals('encrypted-new-password-xyz', $account->getOutboundPassword()); + + return $existingAccount; + }); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(7, $result->getId()); + } + + private function createAccountWithMailAccount(MailAccount $mailAccount): object { + return new class($mailAccount) { + public function __construct( + private MailAccount $mailAccount, + ) { + } + + public function getId(): int { + return $this->mailAccount->getId(); + } + + public function getEmail(): string { + return $this->mailAccount->getEmail(); + } + + public function getMailAccount(): MailAccount { + return $this->mailAccount; + } + }; + } +} diff --git a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php index 9a3f6ea83e..8951981ecf 100644 --- a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php @@ -38,6 +38,10 @@ protected function setUp(): void { ); } + public function testAppNameConstantExists(): void { + $this->assertSame('NEXTCLOUD_WORKSPACE', IonosConfigService::APP_NAME); + } + public function testGetExternalReferenceSuccess(): void { $this->config->method('getSystemValue') ->with('ncw.ext_ref') diff --git a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php index a05bdd9e82..e1fdcf980b 100644 --- a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php @@ -10,15 +10,20 @@ namespace OCA\Mail\Tests\Unit\Service\IONOS; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Service\AccountService; use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\IONOS\IonosMailService; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; class IonosMailConfigServiceTest extends TestCase { private IonosConfigService&MockObject $ionosConfigService; private IonosMailService&MockObject $ionosMailService; + private AccountService&MockObject $accountService; + private IUserSession&MockObject $userSession; private LoggerInterface&MockObject $logger; private IonosMailConfigService $service; @@ -27,11 +32,15 @@ protected function setUp(): void { $this->ionosConfigService = $this->createMock(IonosConfigService::class); $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->accountService = $this->createMock(AccountService::class); + $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->service = new IonosMailConfigService( $this->ionosConfigService, $this->ionosMailService, + $this->accountService, + $this->userSession, $this->logger, ); } @@ -41,58 +50,174 @@ public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void ->method('isIonosIntegrationEnabled') ->willReturn(false); - $this->ionosMailService->expects($this->never()) - ->method('mailAccountExistsForCurrentUser'); + $this->userSession->expects($this->never()) + ->method('getUser'); $result = $this->service->isMailConfigAvailable(); $this->assertFalse($result); } - public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void { + public function testIsMailConfigAvailableReturnsFalseWhenNoUserSession(): void { $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config not available - no user session'); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertFalse($result); + } + + public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoRemoteAccount(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') ->willReturn(false); - $this->logger->expects($this->never()) - ->method('debug'); + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); $result = $this->service->isMailConfigAvailable(); $this->assertTrue($result); } - public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void { + public function testIsMailConfigAvailableReturnsFalseWhenUserHasRemoteAndLocalAccount(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') ->willReturn(true); + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('testuser@ionos.com'); + + // Return a non-empty array to simulate that a local account exists + $mockAccount = $this->createMock(\OCA\Mail\Account::class); + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@ionos.com') + ->willReturn([$mockAccount]); + $this->logger->expects($this->once()) ->method('debug') - ->with('IONOS mail config not available - user already has an account'); + ->with('IONOS mail config not available - user already has account configured locally', [ + 'email' => 'testuser@ionos.com', + ]); $result = $this->service->isMailConfigAvailable(); $this->assertFalse($result); } - public function testIsMailConfigAvailableReturnsFalseOnException(): void { + public function testIsMailConfigAvailableReturnsTrueWhenUserHasRemoteAccountButNotLocal(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - $exception = new \Exception('Test exception'); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('testuser@ionos.com'); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@ionos.com') + ->willReturn([]); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => 'testuser@ionos.com', + ]); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertTrue($result); + } + + public function testIsMailConfigAvailableReturnsFalseWhenEmailCannotBeRetrieved(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->ionosMailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('warning') + ->with('IONOS remote account exists but email could not be retrieved'); + + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertFalse($result); + } + + public function testIsMailConfigAvailableReturnsFalseOnException(): void { + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $exception = new \Exception('Test exception'); + + $this->userSession->expects($this->once()) + ->method('getUser') ->willThrowException($exception); $this->logger->expects($this->once()) diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 75ba54a88d..64a2e24e65 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -13,13 +13,14 @@ 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; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Service\IONOS\ApiMailConfigClientService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailService; use OCP\IUser; @@ -28,6 +29,20 @@ use Psr\Log\LoggerInterface; class IonosMailServiceTest extends TestCase { + private const TEST_USER_ID = 'testuser123'; + private const TEST_USER_NAME = 'test'; + private const TEST_DOMAIN = 'example.com'; + private const TEST_EMAIL = self::TEST_USER_NAME . '@' . self::TEST_DOMAIN; + private const TEST_PASSWORD = 'test-password'; + private const TEST_EXT_REF = 'test-ext-ref'; + private const TEST_API_BASE_URL = 'https://api.example.com'; + private const TEST_BASIC_AUTH_USER = 'testuser'; + private const TEST_BASIC_AUTH_PASSWORD = 'testpass'; + private const IMAP_HOST = 'imap.example.com'; + private const IMAP_PORT = 993; + private const SMTP_HOST = 'smtp.example.com'; + private const SMTP_PORT = 587; + private ApiMailConfigClientService&MockObject $apiClientService; private IonosConfigService&MockObject $configService; private IUserSession&MockObject $userSession; @@ -50,54 +65,102 @@ protected function setUp(): void { ); } - public function testCreateEmailAccountSuccess(): void { - $userName = 'test'; - $domain = 'example.com'; - $emailAddress = $userName . '@' . $domain; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session + /** + * Setup standard config mocks with default values + */ + private function setupConfigMocks( + string $externalReference = self::TEST_EXT_REF, + string $apiBaseUrl = self::TEST_API_BASE_URL, + bool $allowInsecure = false, + string $basicAuthUser = self::TEST_BASIC_AUTH_USER, + string $basicAuthPassword = self::TEST_BASIC_AUTH_PASSWORD, + string $mailDomain = self::TEST_DOMAIN, + ): void { + $this->configService->method('getExternalReference')->willReturn($externalReference); + $this->configService->method('getApiBaseUrl')->willReturn($apiBaseUrl); + $this->configService->method('getAllowInsecure')->willReturn($allowInsecure); + $this->configService->method('getBasicAuthUser')->willReturn($basicAuthUser); + $this->configService->method('getBasicAuthPassword')->willReturn($basicAuthPassword); + $this->configService->method('getMailDomain')->willReturn($mailDomain); + } + + /** + * Setup user session with mock user + */ + private function setupUserSession(string $userId): IUser&MockObject { $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); + $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); + return $user; + } - // Mock API client + /** + * Setup API client mocks and return API instance + */ + private function setupApiClient(bool $verifySSL = true): MailConfigurationAPIApi&MockObject { $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], + 'verify' => $verifySSL, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API response - use getMockBuilder with onlyMethods for existing methods + return $apiInstance; + } + + /** + * Create a mock IMAP server + */ + private function createMockImapServer( + string $host = self::IMAP_HOST, + int $port = self::IMAP_PORT, + string $sslMode = 'ssl', + ): Imap&MockObject { $imapServer = $this->getMockBuilder(Imap::class) ->disableOriginalConstructor() ->onlyMethods(['getHost', 'getPort', 'getSslMode']) ->getMock(); - $imapServer->method('getHost')->willReturn('imap.example.com'); - $imapServer->method('getPort')->willReturn(993); - $imapServer->method('getSslMode')->willReturn('ssl'); + $imapServer->method('getHost')->willReturn($host); + $imapServer->method('getPort')->willReturn($port); + $imapServer->method('getSslMode')->willReturn($sslMode); + return $imapServer; + } + /** + * Create a mock SMTP server + */ + private function createMockSmtpServer( + string $host = self::SMTP_HOST, + int $port = self::SMTP_PORT, + string $sslMode = 'tls', + ): Smtp&MockObject { $smtpServer = $this->getMockBuilder(Smtp::class) ->disableOriginalConstructor() ->onlyMethods(['getHost', 'getPort', 'getSslMode']) ->getMock(); - $smtpServer->method('getHost')->willReturn('smtp.example.com'); - $smtpServer->method('getPort')->willReturn(587); - $smtpServer->method('getSslMode')->willReturn('tls'); + $smtpServer->method('getHost')->willReturn($host); + $smtpServer->method('getPort')->willReturn($port); + $smtpServer->method('getSslMode')->willReturn($sslMode); + return $smtpServer; + } + + /** + * Create a mock MailAccountResponse + */ + private function createMockMailAccountResponse( + string $email = self::TEST_EMAIL, + string $password = self::TEST_PASSWORD, + ?string $imapSslMode = 'ssl', + ?string $smtpSslMode = 'tls', + ): MailAccountCreatedResponse&MockObject { + $imapServer = $this->createMockImapServer(self::IMAP_HOST, self::IMAP_PORT, $imapSslMode); + $smtpServer = $this->createMockSmtpServer(self::SMTP_HOST, self::SMTP_PORT, $smtpSslMode); $mailServer = $this->getMockBuilder(MailServer::class) ->disableOriginalConstructor() @@ -106,116 +169,89 @@ public function testCreateEmailAccountSuccess(): void { $mailServer->method('getImap')->willReturn($imapServer); $mailServer->method('getSmtp')->willReturn($smtpServer); - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + $mailAccountResponse = $this->getMockBuilder(MailAccountCreatedResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); + $mailAccountResponse->method('getEmail')->willReturn($email); + $mailAccountResponse->method('getPassword')->willReturn($password); $mailAccountResponse->method('getServer')->willReturn($mailServer); - $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + return $mailAccountResponse; + } - // Expect logging calls - $this->logger->expects($this->exactly(4)) - ->method('debug'); + public function testCreateEmailAccountSuccess(): void { + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); + $apiInstance = $this->setupApiClient(); + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $this->logger->expects($this->exactly(4))->method('debug'); $this->logger->expects($this->once()) ->method('info') - ->with('Successfully created IONOS mail account', $this->callback(function ($context) use ($emailAddress) { - return $context['email'] === $emailAddress - && $context['userId'] === 'testuser123' - && $context['userName'] === 'test'; + ->with('Successfully created IONOS mail account', $this->callback(function ($context) { + return $context['email'] === self::TEST_EMAIL + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); - $result = $this->service->createEmailAccount($userName); + $result = $this->service->createEmailAccount(self::TEST_USER_NAME); $this->assertInstanceOf(MailAccountConfig::class, $result); - $this->assertEquals($emailAddress, $result->getEmail()); - $this->assertEquals('imap.example.com', $result->getImap()->getHost()); - $this->assertEquals(993, $result->getImap()->getPort()); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); $this->assertEquals('ssl', $result->getImap()->getSecurity()); - $this->assertEquals($emailAddress, $result->getImap()->getUsername()); - $this->assertEquals('test-password', $result->getImap()->getPassword()); - $this->assertEquals('smtp.example.com', $result->getSmtp()->getHost()); - $this->assertEquals(587, $result->getSmtp()->getPort()); + $this->assertEquals(self::TEST_EMAIL, $result->getImap()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); $this->assertEquals('tls', $result->getSmtp()->getSecurity()); - $this->assertEquals($emailAddress, $result->getSmtp()->getUsername()); - $this->assertEquals('test-password', $result->getSmtp()->getPassword()); + $this->assertEquals(self::TEST_EMAIL, $result->getSmtp()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getSmtp()->getPassword()); } public function testCreateEmailAccountWithApiException(): void { - $userName = 'test'; - $domain = 'example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw exception $apiInstance->method('createMailbox') ->willThrowException(new \Exception('API call failed')); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->callback(function ($context) use ($userName) { + ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(500); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): void { - $userName = 'test'; - $domain = 'example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock MailAddonErrorMessage response $errorMessage = $this->getMockBuilder(MailAddonErrorMessage::class) ->disableOriginalConstructor() ->onlyMethods(['getStatus', 'getMessage']) @@ -225,86 +261,55 @@ public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): void $apiInstance->method('createMailbox')->willReturn($errorMessage); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Failed to create ionos mail', $this->callback(function ($context) use ($userName) { + ->with('Failed to create ionos mail', $this->callback(function ($context) { return $context['status code'] === MailAddonErrorMessage::STATUS__400_BAD_REQUEST && $context['message'] === 'Bad Request' - && $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(400); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithUnknownResponseType(): void { - $userName = 'test'; - $domain = 'example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock unknown response type (return a stdClass instead of expected types) $unknownResponse = new \stdClass(); $apiInstance->method('createMailbox')->willReturn($unknownResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Failed to create ionos mail: Unknown response type', $this->callback(function ($context) use ($userName) { - return $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + ->with('Failed to create ionos mail: Unknown response type', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(500); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithNoUserSession(): void { - $userName = 'test'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock no user session + $this->setupConfigMocks(); $this->userSession->method('getUser')->willReturn(null); - // Expect logging call $this->logger->expects($this->once()) ->method('error') ->with('No user session found when attempting to create IONOS mail account'); @@ -312,74 +317,64 @@ public function testCreateEmailAccountWithNoUserSession(): void { $this->expectException(ServiceException::class); $this->expectExceptionMessage('No user session found'); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } - /** - * Test SSL mode normalization with various API response values - * - * @dataProvider sslModeNormalizationProvider - */ - public function testSslModeNormalization(string $apiSslMode, string $expectedSecurity): void { - $userName = 'test'; - $domain = 'example.com'; - $emailAddress = $userName . '@' . $domain; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - $this->configService->method('getMailDomain')->willReturn($domain); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + public function testCreateEmailAccountForUserSuccess(): void { + $userId = 'admin123'; + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient')->willReturn($client); + // No user session needed for this method - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); - // Mock API response with specific SSL mode - $imapServer = $this->getMockBuilder(Imap::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHost', 'getPort', 'getSslMode']) - ->getMock(); - $imapServer->method('getHost')->willReturn('imap.example.com'); - $imapServer->method('getPort')->willReturn(993); - $imapServer->method('getSslMode')->willReturn($apiSslMode); + $this->logger->expects($this->exactly(4))->method('debug'); + $this->logger->expects($this->once()) + ->method('info') + ->with('Successfully created IONOS mail account', $this->callback(function ($context) use ($userId) { + return $context['email'] === self::TEST_EMAIL + && $context['userId'] === $userId + && $context['userName'] === self::TEST_USER_NAME; + })); - $smtpServer = $this->getMockBuilder(Smtp::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHost', 'getPort', 'getSslMode']) - ->getMock(); - $smtpServer->method('getHost')->willReturn('smtp.example.com'); - $smtpServer->method('getPort')->willReturn(587); - $smtpServer->method('getSslMode')->willReturn($apiSslMode); + $result = $this->service->createEmailAccountForUser($userId, self::TEST_USER_NAME); - $mailServer = $this->getMockBuilder(MailServer::class) - ->disableOriginalConstructor() - ->onlyMethods(['getImap', 'getSmtp']) - ->getMock(); - $mailServer->method('getImap')->willReturn($imapServer); - $mailServer->method('getSmtp')->willReturn($smtpServer); + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + $this->assertEquals(self::TEST_EMAIL, $result->getImap()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals(self::TEST_EMAIL, $result->getSmtp()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getSmtp()->getPassword()); + } - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) - ->disableOriginalConstructor() - ->onlyMethods(['getEmail', 'getPassword', 'getServer']) - ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); - $mailAccountResponse->method('getServer')->willReturn($mailServer); + /** + * Test SSL mode normalization with various API response values + * + * @dataProvider sslModeNormalizationProvider + */ + public function testSslModeNormalization(string $apiSslMode, string $expectedSecurity): void { + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); + $apiInstance = $this->setupApiClient(); + + $mailAccountResponse = $this->createMockMailAccountResponse( + self::TEST_EMAIL, + self::TEST_PASSWORD, + $apiSslMode, + $apiSslMode + ); $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); - $result = $this->service->createEmailAccount($userName); + $result = $this->service->createEmailAccount(self::TEST_USER_NAME); $this->assertEquals($expectedSecurity, $result->getImap()->getSecurity()); $this->assertEquals($expectedSecurity, $result->getSmtp()->getSecurity()); @@ -432,39 +427,26 @@ public static function sslModeNormalizationProvider(): array { } public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn('testuser@example.com'); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willReturn($mailAccountResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -472,26 +454,15 @@ public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists( } public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -500,12 +471,10 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -513,26 +482,15 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { } public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -541,18 +499,15 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error'; })); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -561,39 +516,25 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi } public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralException(): void { - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException(new \Exception('Unexpected error')); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('Exception when getting IONOS mail account', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123'; + && $context['userId'] === self::TEST_USER_ID; })); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -603,73 +544,42 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralExceptio public function testDeleteEmailAccountSuccess(): void { - $userId = 'testuser123'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock successful deletion (returns void) $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Expect logging calls $callCount = 0; $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId, &$callCount) { + ->willReturnCallback(function ($message, $context) use (&$callCount) { $callCount++; if ($callCount === 1) { $this->assertEquals('Attempting to delete IONOS email account', $message); - $this->assertEquals($userId, $context['userId']); - $this->assertEquals('test-ext-ref', $context['extRef']); + $this->assertEquals(self::TEST_USER_ID, $context['userId']); + $this->assertEquals(self::TEST_EXT_REF, $context['extRef']); } elseif ($callCount === 2) { $this->assertEquals('Successfully deleted IONOS email account', $message); - $this->assertEquals($userId, $context['userId']); + $this->assertEquals(self::TEST_USER_ID, $context['userId']); } }); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception (mailbox doesn't exist) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -679,48 +589,37 @@ public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('debug') - ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId + ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID && $context['statusCode'] === 404; })); - // Should return true for 404 (treat as success) - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { - $userId = 'testuser123'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -730,295 +629,206 @@ public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('error') - ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) { return $context['statusCode'] === 500 && $context['message'] === 'Internal Server Error' - && $context['userId'] === $userId; + && $context['userId'] === self::TEST_USER_ID; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to delete IONOS mail: Internal Server Error'); $this->expectExceptionCode(500); - $this->service->deleteEmailAccount($userId); + $this->service->deleteEmailAccount(self::TEST_USER_ID); } public function testDeleteEmailAccountThrowsExceptionOnGeneralError(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $generalException = new \Exception('Unexpected error'); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($generalException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('error') - ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === $userId; + && $context['userId'] === self::TEST_USER_ID; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to delete IONOS mail'); $this->expectExceptionCode(500); - $this->service->deleteEmailAccount($userId); + $this->service->deleteEmailAccount(self::TEST_USER_ID); } public function testDeleteEmailAccountWithInsecureConnection(): void { - $userId = 'testuser123'; - - // Mock config with insecure connection allowed - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(true); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(allowInsecure: true); + $apiInstance = $this->setupApiClient(verifySSL: false); - // Mock API client - verify should be false - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => false, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - $this->logger->expects($this->exactly(2)) - ->method('info'); + $this->logger->expects($this->exactly(2))->method('info'); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testTryDeleteEmailAccountWhenIntegrationDisabled(): void { - $userId = 'testuser123'; - - // Mock integration as disabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(false); - // Should log that integration is not enabled $this->logger->expects($this->once()) ->method('debug') ->with( 'IONOS integration is not enabled, skipping email account deletion', - ['userId' => $userId] + ['userId' => self::TEST_USER_ID] ); - // Should not attempt to create API client - $this->apiClientService->expects($this->never()) - ->method('newClient'); + $this->apiClientService->expects($this->never())->method('newClient'); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledSuccess(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // Mock API client - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Should log success at info level (from deleteEmailAccount only) $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'Attempting to delete IONOS email account') { - $this->assertSame($userId, $context['userId']); - $this->assertSame('test-ext-ref', $context['extRef']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); + $this->assertSame(self::TEST_EXT_REF, $context['extRef']); } elseif ($message === 'Successfully deleted IONOS email account') { - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); } }); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledButDeletionFails(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('API Error', 500); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log the error from deleteEmailAccount and then from tryDeleteEmailAccount $this->logger->expects($this->exactly(2)) ->method('error') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'API Exception when calling MailConfigurationAPIApi->deleteMailbox') { - // This is from deleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertSame(500, $context['statusCode']); } elseif ($message === 'Failed to delete IONOS mailbox for user') { - // This is from tryDeleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertInstanceOf(ServiceException::class, $context['exception']); } }); - // Call tryDeleteEmailAccount - should NOT throw exception (fire and forget) - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock 404 API exception (mailbox already deleted or never existed) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('Not Found', 404); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log at info level (from deleteEmailAccount) and debug (404 is treated as success) $this->logger->expects($this->once()) ->method('info') ->with( 'Attempting to delete IONOS email account', [ - 'userId' => $userId, - 'extRef' => 'test-ext-ref', + 'userId' => self::TEST_USER_ID, + 'extRef' => self::TEST_EXT_REF, ] ); @@ -1027,73 +837,51 @@ public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { ->with( 'IONOS mailbox does not exist (already deleted or never created)', [ - 'userId' => $userId, + 'userId' => self::TEST_USER_ID, 'statusCode' => 404 ] ); - // Call tryDeleteEmailAccount - should NOT throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testGetIonosEmailForUserReturnsEmailWhenAccountExists(): void { - $userId = 'testuser123'; - $expectedEmail = 'testuser@example.com'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($expectedEmail); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willReturn($mailAccountResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); - $this->assertEquals($expectedEmail, $result); + $this->assertEquals(self::TEST_EMAIL, $result); } public function testGetIonosEmailForUserReturnsNullWhen404(): void { - $userId = 'testuser123'; - - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -1102,36 +890,25 @@ public function testGetIonosEmailForUserReturnsNullWhen404(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } public function testGetIonosEmailForUserReturnsNullOnApiError(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -1140,60 +917,187 @@ public function testGetIonosEmailForUserReturnsNullOnApiError(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error'; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } public function testGetIonosEmailForUserReturnsNullOnGeneralException(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // Mock config - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(false); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException(new \Exception('Unexpected error')); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('Exception when getting IONOS mail account', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123'; + && $context['userId'] === self::TEST_USER_ID; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); + + $this->assertNull($result); + } + + public function testGetAccountConfigForUserReturnsConfigWhenAccountExists(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Create mock response for existing account + $imapServer = $this->createMockImapServer(); + $smtpServer = $this->createMockSmtpServer(); + + $mailServer = $this->getMockBuilder(MailServer::class) + ->disableOriginalConstructor() + ->onlyMethods(['getImap', 'getSmtp']) + ->getMock(); + $mailServer->method('getImap')->willReturn($imapServer); + $mailServer->method('getSmtp')->willReturn($smtpServer); + + $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEmail', 'getServer']) + ->getMock(); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); + $mailAccountResponse->method('getServer')->willReturn($mailServer); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willReturn($mailAccountResponse); + + $result = $this->service->getAccountConfigForUser(self::TEST_USER_ID); + + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + // Password is empty when retrieving existing accounts + $this->assertEquals('', $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals('', $result->getSmtp()->getPassword()); + } + + public function testGetAccountConfigForUserReturnsNullWhenAccountDoesNotExist(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Mock API to throw 404 exception + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Not Found"}' + ); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willThrowException($apiException); + + $result = $this->service->getAccountConfigForUser(self::TEST_USER_ID); $this->assertNull($result); } + + public function testResetAppPasswordSuccess(): void { + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + $appName = 'NEXTCLOUD_WORKSPACE'; + $expectedPassword = 'new-app-password-123'; + + $apiInstance->expects($this->once()) + ->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willReturn($expectedPassword); + + $result = $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + + $this->assertEquals($expectedPassword, $result); + } + + public function testResetAppPasswordWithApiException(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + $appName = 'NEXTCLOUD_WORKSPACE'; + + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Mailbox not found"}' + ); + + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willThrowException($apiException); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password: Not Found'); + $this->expectExceptionCode(404); + + $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + } + + public function testResetAppPasswordWithUnexpectedResponse(): void { + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + $appName = 'NEXTCLOUD_WORKSPACE'; + + // API returns unexpected response type (not a string) + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willReturn(['unexpected' => 'response']); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password'); + $this->expectExceptionCode(500); + + $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + } + + public function testGetMailDomain(): void { + $this->configService->method('getMailDomain')->willReturn(self::TEST_DOMAIN); + + $result = $this->service->getMailDomain(); + + $this->assertEquals(self::TEST_DOMAIN, $result); + } } diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php new file mode 100644 index 0000000000..8637b2c1ff --- /dev/null +++ b/tests/Unit/Service/SetupServiceTest.php @@ -0,0 +1,386 @@ +accountService = $this->createMock(AccountService::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); + $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->tagMapper = $this->createMock(TagMapper::class); + + $this->setupService = new SetupService( + $this->accountService, + $this->crypto, + $this->smtpClientFactory, + $this->imapClientFactory, + $this->logger, + $this->tagMapper + ); + } + + private function mockSuccessfulImapConnection(): Horde_Imap_Client_Socket&MockObject { + $imapClient = $this->createMock(Horde_Imap_Client_Socket::class); + $imapClient->expects(self::once())->method('login'); + $imapClient->expects(self::once())->method('logout'); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->willReturn($imapClient); + + return $imapClient; + } + + private function mockSuccessfulSmtpConnection(): Horde_Mail_Transport_Smtphorde&MockObject { + $smtpTransport = $this->createMock(Horde_Mail_Transport_Smtphorde::class); + $smtpTransport->expects(self::once())->method('getSMTPObject'); + + $this->smtpClientFactory->expects(self::once()) + ->method('create') + ->willReturn($smtpTransport); + + return $smtpTransport; + } + + private function mockPasswordEncryption(): void { + $this->crypto->expects(self::exactly(2)) + ->method('encrypt') + ->willReturnOnConsecutiveCalls('encrypted-imap-password', 'encrypted-smtp-password'); + } + + private function assertAccountPropertiesMatch( + MailAccount $account, + string $accountName, + string $emailAddress, + string $imapHost, + int $imapPort, + string $imapSslMode, + string $imapUser, + string $smtpHost, + int $smtpPort, + string $smtpSslMode, + string $smtpUser, + string $uid, + string $authMethod, + ): void { + self::assertSame($accountName, $account->getName(), 'Account name does not match'); + self::assertSame($emailAddress, $account->getEmail(), 'Email address does not match'); + self::assertSame($imapHost, $account->getInboundHost(), 'IMAP host does not match'); + self::assertSame($imapPort, $account->getInboundPort(), 'IMAP port does not match'); + self::assertSame($imapSslMode, $account->getInboundSslMode(), 'IMAP SSL mode does not match'); + self::assertSame($imapUser, $account->getInboundUser(), 'IMAP user does not match'); + self::assertSame($smtpHost, $account->getOutboundHost(), 'SMTP host does not match'); + self::assertSame($smtpPort, $account->getOutboundPort(), 'SMTP port does not match'); + self::assertSame($smtpSslMode, $account->getOutboundSslMode(), 'SMTP SSL mode does not match'); + self::assertSame($smtpUser, $account->getOutboundUser(), 'SMTP user does not match'); + self::assertSame($uid, $account->getUserId(), 'User ID does not match'); + self::assertSame($authMethod, $account->getAuthMethod(), 'Auth method does not match'); + } + + public function testCreateNewAccountWithPasswordAuth(): void { + $this->mockPasswordEncryption(); + + $this->logger->expects(self::once()) + ->method('info') + ->with('Setting up manually configured account'); + + $debugCalls = []; + $this->logger->expects(self::exactly(2)) + ->method('debug') + ->willReturnCallback(function (string $message, array $context = []) use (&$debugCalls): void { + $debugCalls[] = ['message' => $message, 'context' => $context]; + }); + + $this->mockSuccessfulImapConnection(); + $this->mockSuccessfulSmtpConnection(); + + $this->accountService->expects(self::once()) + ->method('save') + ->with(self::callback(function (MailAccount $account): bool { + $this->assertAccountPropertiesMatch( + $account, + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + return true; + })); + + $this->tagMapper->expects(self::once()) + ->method('createDefaultTags') + ->with(self::isInstanceOf(MailAccount::class)); + + $result = $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + + self::assertInstanceOf(Account::class, $result); + + // Verify debug log calls + self::assertCount(2, $debugCalls); + self::assertSame('Connecting to account {account}', $debugCalls[0]['message']); + self::assertSame(['account' => self::EMAIL_ADDRESS], $debugCalls[0]['context']); + self::assertStringContainsString('account created ', $debugCalls[1]['message']); + self::assertSame([], $debugCalls[1]['context']); + } + + public function testCreateNewAccountWithOAuth2(): void { + $this->crypto->expects(self::never())->method('encrypt'); + + $this->logger->expects(self::once()) + ->method('info') + ->with('Setting up manually configured account'); + $this->logger->expects(self::once()) + ->method('debug') + ->with(self::stringContains('account created ')); + + $this->imapClientFactory->expects(self::never())->method('getClient'); + $this->smtpClientFactory->expects(self::never())->method('create'); + + $this->accountService->expects(self::once()) + ->method('save') + ->with(self::callback(function (MailAccount $account): bool { + return $account->getAuthMethod() === self::AUTH_METHOD_OAUTH2; + })); + + $this->tagMapper->expects(self::once())->method('createDefaultTags'); + + $result = $this->setupService->createNewAccount( + 'OAuth2 Account', + 'oauth@example.com', + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + 'oauth@example.com', + null, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + 'oauth@example.com', + null, + 'user456', + self::AUTH_METHOD_OAUTH2 + ); + + self::assertInstanceOf(Account::class, $result); + } + + public function testCreateNewAccountWithSkipConnectivityTest(): void { + $accountName = 'Skip Test Account'; + $emailAddress = 'skip@example.com'; + $imapHost = 'imap.example.com'; + $imapPort = 993; + $imapSslMode = 'ssl'; + $imapUser = 'skip@example.com'; + $imapPassword = 'password'; + $smtpHost = 'smtp.example.com'; + $smtpPort = 465; + $smtpSslMode = 'ssl'; + $smtpUser = 'skip@example.com'; + $smtpPassword = 'password'; + $uid = 'user789'; + $authMethod = 'password'; + $skipConnectivityTest = true; + + $this->crypto->expects(self::exactly(2)) + ->method('encrypt') + ->willReturnOnConsecutiveCalls('encrypted1', 'encrypted2'); + + $this->imapClientFactory->expects(self::never()) + ->method('getClient'); + + $this->smtpClientFactory->expects(self::never()) + ->method('create'); + + $this->accountService->expects(self::once()) + ->method('save'); + + $this->tagMapper->expects(self::once()) + ->method('createDefaultTags'); + + $result = $this->setupService->createNewAccount( + $accountName, + $emailAddress, + $imapHost, + $imapPort, + $imapSslMode, + $imapUser, + $imapPassword, + $smtpHost, + $smtpPort, + $smtpSslMode, + $smtpUser, + $smtpPassword, + $uid, + $authMethod, + null, + $skipConnectivityTest + ); + + self::assertInstanceOf(Account::class, $result); + } + + public function testCreateNewAccountWithInvalidAuthMethod(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid auth method invalid'); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + 'invalid' + ); + } + + public function testCreateNewAccountImapConnectionFailure(): void { + $this->expectException(CouldNotConnectException::class); + + $this->mockPasswordEncryption(); + + $imapClient = $this->createMock(Horde_Imap_Client_Socket::class); + $imapClient->expects(self::once()) + ->method('login') + ->willThrowException(new Horde_Imap_Client_Exception('Connection failed')); + $imapClient->expects(self::once()) + ->method('logout'); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->willReturn($imapClient); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + } + + public function testCreateNewAccountSmtpConnectionFailure(): void { + $this->expectException(CouldNotConnectException::class); + + $this->mockPasswordEncryption(); + $this->mockSuccessfulImapConnection(); + + $smtpTransport = $this->createMock(Horde_Mail_Transport_Smtphorde::class); + $smtpTransport->expects(self::once()) + ->method('getSMTPObject') + ->willThrowException(new Horde_Mail_Exception('SMTP connection failed')); + + $this->smtpClientFactory->expects(self::once()) + ->method('create') + ->willReturn($smtpTransport); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + } +}