diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index feb957787b..3158598a25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -400,6 +400,80 @@ jobs: run_command: empty-upload force: true + user-migration-smoke-test: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.5' ] + nextcloud-versions: [ 'stable35' ] + db: [ 'mysql' ] + env: + TEST_USER: user1 + TEST_EMAIL: user1@localhost + EXPORT_DIRECTORY: /tmp + name: Nextcloud ${{ matrix.nextcloud-versions }} php${{ matrix.php-versions }} user migration test + steps: + - name: Set up Nextcloud env + uses: nextcloud/setup-server-action@34b73d5b0e3633f83a52227d00cc2a6c41d01d9a # v1.0.0 + with: + nextcloud-version: ${{ matrix.nextcloud-versions }} + php-version: ${{ matrix.php-versions }} + patch-php-version-check: ${{ matrix.php-versions == '8.6' }} + node-version: 'false' + install: true + - name: Checkout mail + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: nextcloud/apps/mail + fetch-depth: 2 + - name: Install dependencies + working-directory: nextcloud/apps/mail + run: composer install --no-dev + - name: Install mail + run: php -f nextcloud/occ app:enable mail + - name: Checkout user_migration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: nextcloud/user_migration + ref: 3b4007d9ed2c80c53ca8bce6692e14aab5e482b0 # v10.5.0 + path: nextcloud/apps/user_migration + - name: Install dependencies + working-directory: nextcloud/apps/user_migration + run: composer install --no-dev + - name: Patch version check for nightly Nextcloud + if: ${{ matrix.nextcloud-versions == 'master' }} + run: sed -i -E 's/max-version="([0-9]{2})"/max-version="99"/' nextcloud/apps/user_migration/appinfo/info.xml + - name: Install user_migration + run: php -f nextcloud/occ app:enable user_migration + - name: Create test user + run: php -f nextcloud/occ user:add --generate-password $TEST_USER + - name: Create mail account to migrate + run: | + php -f nextcloud/occ mail:account:create-imap \ + $TEST_USER 'Test Account' $TEST_EMAIL \ + imap.localhost 993 ssl $TEST_EMAIL imap-password \ + smtp.localhost 465 ssl $TEST_EMAIL smtp-password + - name: Test basic mail export + run: php -f nextcloud/occ user:export -t mail_account $TEST_USER $EXPORT_DIRECTORY + - name: Assert the account was written to the export + run: | + EXPORTED_ZIP=$(find $EXPORT_DIRECTORY -name "$TEST_USER*.zip" | tail -1) + unzip -l "$EXPORTED_ZIP" | grep -q 'mail/accounts/.*\.json' \ + || { echo "::error::no account file in the export"; unzip -l "$EXPORTED_ZIP"; exit 1; } + - name: Delete exported user to avoid overwrite confirmation during import + run: php -f nextcloud/occ user:delete $TEST_USER + - name: Test basic mail import + run: | + EXPORTED_ZIP=$(find $EXPORT_DIRECTORY -name "$TEST_USER*.zip" | tail -1) + php -f nextcloud/occ user:import $EXPORTED_ZIP + - name: Assert the account was restored + run: | + php -f nextcloud/occ mail:account:export $TEST_USER | tee /tmp/imported-accounts.txt + grep -q "E-Mail: $TEST_EMAIL" /tmp/imported-accounts.txt \ + || { echo "::error::imported user has no mail account"; exit 1; } + grep -q "IMAP user: $TEST_EMAIL" /tmp/imported-accounts.txt \ + || { echo "::error::imported account lost its IMAP username"; exit 1; } + summary: runs-on: ubuntu-latest-low needs: @@ -407,6 +481,7 @@ jobs: - integration-tests - frontend-unit-test - frontend-e2e-tests + - user-migration-smoke-test if: always() @@ -421,3 +496,5 @@ jobs: run: if ${{ needs.frontend-unit-test.result != 'success' && needs.frontend-unit-test.result != 'skipped' }}; then exit 1; fi - name: Frontend E2E test status run: if ${{ needs.frontend-e2e-tests.result != 'success' && needs.frontend-e2e-tests.result != 'skipped' }}; then exit 1; fi + - name: User migration smoke test status + run: if ${{ needs.user-migration-smoke-test.result != 'success' && needs.user-migration-smoke-test.result != 'skipped' }}; then exit 1; fi diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 94a7f01770..98bc081e9b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,6 +17,7 @@ use OCA\Mail\Contracts\IAvatarService; use OCA\Mail\Contracts\IDkimService; use OCA\Mail\Contracts\IDkimValidator; +use OCA\Mail\Contracts\IInternalAddressService; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Contracts\IMailTransmission; @@ -63,6 +64,7 @@ use OCA\Mail\Service\AvatarService; use OCA\Mail\Service\DkimService; use OCA\Mail\Service\DkimValidator; +use OCA\Mail\Service\InternalAddressService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\Search\MailSearch; @@ -127,6 +129,7 @@ public function register(IRegistrationContext $context): void { $context->registerServiceAlias(IMailManager::class, MailManager::class); $context->registerServiceAlias(IMailSearch::class, MailSearch::class); $context->registerServiceAlias(IMailTransmission::class, MailTransmission::class); + $context->registerServiceAlias(IInternalAddressService::class, InternalAddressService::class); $context->registerServiceAlias(ITrustedSenderService::class, TrustedSenderService::class); $context->registerServiceAlias(IUserPreferences::class, UserPreferenceService::class); $context->registerServiceAlias(IDkimService::class, DkimService::class); diff --git a/lib/Db/Actions.php b/lib/Db/Actions.php index da4afc7ee5..087ea9116e 100644 --- a/lib/Db/Actions.php +++ b/lib/Db/Actions.php @@ -30,6 +30,13 @@ public function __construct() { $this->addType('accountId', 'integer'); } + /** + * @return list + */ + public function getActionSteps(): array { + return $this->actionSteps; + } + public function setActionSteps(array $actionSteps): void { $this->actionSteps = $actionSteps; } diff --git a/lib/Db/AliasMapper.php b/lib/Db/AliasMapper.php index 2a3f2791b3..510445c0a3 100644 --- a/lib/Db/AliasMapper.php +++ b/lib/Db/AliasMapper.php @@ -12,6 +12,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use function array_map; @@ -88,6 +89,28 @@ public function findAll(int $accountId, string $currentUserId): array { return $this->findEntities($qb); } + /** + * @throws Exception + */ + public function countByAccountId(int $accountId, string $currentUserId): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'count')) + ->from($this->getTableName(), 'aliases') + ->join('aliases', 'mail_accounts', 'accounts', $qb->expr()->eq('aliases.account_id', 'accounts.id')) + ->where( + $qb->expr()->andX( + $qb->expr()->eq('accounts.user_id', $qb->createNamedParameter($currentUserId)), + $qb->expr()->eq('aliases.account_id', $qb->createNamedParameter($accountId)) + ) + ); + + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + } + /** * @param int $accountId the account whose aliases will be deleted * diff --git a/lib/Db/MailboxMapper.php b/lib/Db/MailboxMapper.php index 7ece9a8a1f..c920750993 100644 --- a/lib/Db/MailboxMapper.php +++ b/lib/Db/MailboxMapper.php @@ -50,6 +50,23 @@ public function findAll(Account $account): array { return $this->findEntities($select); } + /** + * @throws Exception + */ + public function countByAccount(Account $account): int { + $qb = $this->db->getQueryBuilder(); + + $select = $qb->select($qb->func()->count('*', 'count')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('account_id', $qb->createNamedParameter($account->getId()))); + + $result = $select->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + } + /** * @return \Generator */ diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 256b546e98..7c27a8ceca 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -174,13 +174,19 @@ public function deleteByAccountId(int $accountId): void { /** * @param MailAccount $newAccount + * @param bool $scheduleBackgroundJobs Optional parameter to save the mail account + * without scheduling the corresponding background jobs. This can be useful if + * further database modifications must be done before running any background + * jobs. Defaults to `true`. * @return MailAccount */ - public function save(MailAccount $newAccount): MailAccount { + public function save(MailAccount $newAccount, bool $scheduleBackgroundJobs = true): MailAccount { $newAccount = $this->mapper->save($newAccount); // Insert background jobs for this account - $this->scheduleBackgroundJobs($newAccount->getId()); + if ($scheduleBackgroundJobs) { + $this->scheduleBackgroundJobs($newAccount->getId()); + } // Invalidate cache to ensure created account is being included // in subsequent `findByUserId` and `findByUserIdAndAddress` calls diff --git a/lib/Service/AliasesService.php b/lib/Service/AliasesService.php index 57392e8c64..d76a21d5e9 100644 --- a/lib/Service/AliasesService.php +++ b/lib/Service/AliasesService.php @@ -15,6 +15,7 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ClientException; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\Exception; class AliasesService { public function __construct( @@ -32,6 +33,13 @@ public function findAll(int $accountId, string $currentUserId): array { return $this->aliasMapper->findAll($accountId, $currentUserId); } + /** + * @throws Exception + */ + public function countByAccountId(int $accountId, string $currentUserId): int { + return $this->aliasMapper->countByAccountId($accountId, $currentUserId); + } + /** * @param int $aliasId * @param string $currentUserId @@ -53,21 +61,22 @@ public function findByAliasAndUserId(string $aliasEmail, string $userId): Alias } /** - * @param string $userId - * @param int $accountId - * @param string $alias - * @param string $aliasName - * - * @return Alias * @throws DoesNotExistException */ - public function create(string $userId, int $accountId, string $alias, string $aliasName): Alias { + public function create(string $userId, + int $accountId, + string $alias, + ?string $aliasName, + ?string $signature = null, + ?int $smimeCertificateId = null): Alias { $this->mailAccountMapper->find($userId, $accountId); $aliasEntity = new Alias(); $aliasEntity->setAccountId($accountId); $aliasEntity->setAlias($alias); $aliasEntity->setName($aliasName); + $aliasEntity->setSignature($signature); + $aliasEntity->setSmimeCertificateId($smimeCertificateId); return $this->aliasMapper->insert($aliasEntity); } diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index 424028f67e..5f96c753a0 100644 --- a/lib/UserMigration/MailAccountMigrator.php +++ b/lib/UserMigration/MailAccountMigrator.php @@ -4,177 +4,147 @@ /** * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-only + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Mail\UserMigration; -use Exception; -use JsonException; -use OCA\Mail\Db\Alias; -use OCA\Mail\Db\MailAccount; -use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\AliasesService; +use OCA\Mail\AppInfo\Application; +use OCA\Mail\UserMigration\Service\AccountMigrationService; +use OCA\Mail\UserMigration\Service\AppConfigMigrationService; +use OCA\Mail\UserMigration\Service\InternalAddressesMigrationService; +use OCA\Mail\UserMigration\Service\LegacyAccountMigrationService; +use OCA\Mail\UserMigration\Service\QuickActionsMigrationService; +use OCA\Mail\UserMigration\Service\SmimeMigrationService; +use OCA\Mail\UserMigration\Service\TagsMigrationService; +use OCA\Mail\UserMigration\Service\TextBlocksMigrationService; +use OCA\Mail\UserMigration\Service\TrustedSendersMigrationService; use OCP\IL10N; use OCP\IUser; -use OCP\Security\ICrypto; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; use OCP\UserMigration\UserMigrationException; use Symfony\Component\Console\Output\OutputInterface; -use function array_map; -use function json_decode; -use function json_encode; -class MailAccountMigrator implements IMigrator { +class MailAccountMigrator implements IMigrator, ISizeEstimationMigrator { + public const EXPORT_ROOT = Application::APP_ID; + public const FILENAME_PLACEHOLDER = '{filename}'; + + /** The current archive format. */ + public const VERSION = 2_00_00; + + /** + * The only released predecessor. It reported the literal `01_00_00`, which + * PHP reads as octal, hence 4096 rather than 10000. + */ + public const LEGACY_VERSION = 4096; public function __construct( - private AccountService $accountService, - private AliasesService $aliasesService, - private IL10N $l10n, - private ICrypto $crypto, + private readonly IL10N $l10n, + private readonly AccountMigrationService $accountMigrationService, + private readonly AppConfigMigrationService $appConfigMigrationService, + private readonly InternalAddressesMigrationService $internalAddressesMigrationService, + private readonly TrustedSendersMigrationService $trustedSendersMigrationService, + private readonly TextBlocksMigrationService $textBlocksMigrationService, + private readonly TagsMigrationService $tagsMigrationService, + private readonly SmimeMigrationService $smimeMigrationService, + private readonly QuickActionsMigrationService $quickActionsMigrationService, + private readonly LegacyAccountMigrationService $legacyAccountMigrationService, ) { } + /** + * The interface reports in KiB, the services in bytes. + */ + #[\Override] + public function getEstimatedExportSize(IUser $user): int|float { + $bytes = $this->appConfigMigrationService->getEstimatedExportSize($user) + + $this->internalAddressesMigrationService->getEstimatedExportSize($user) + + $this->trustedSendersMigrationService->getEstimatedExportSize($user) + + $this->textBlocksMigrationService->getEstimatedExportSize($user) + + $this->tagsMigrationService->getEstimatedExportSize($user) + + $this->smimeMigrationService->getEstimatedExportSize($user) + + $this->accountMigrationService->getEstimatedExportSize($user) + + $this->quickActionsMigrationService->getEstimatedExportSize($user); + + return ceil($bytes / 1024); + } + #[\Override] public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output, ): void { - $accounts = $this->accountService->findByUserId($user->getUID()); - $index = []; - foreach ($accounts as $account) { - if ($account->getMailAccount()->getProvisioningId() !== null) { - // These configuration of these accounts is owned by the admins - $output->writeln("Skipping provisioned account {$account->getId()}"); - continue; - } - - $accountFilePath = "mail/accounts/{$account->getId()}.json"; - $accountData = $account->jsonSerialize(); - - if ($account->getMailAccount()->getAuthMethod() === 'password') { - $encryptedInboundPassword = $account->getMailAccount()->getInboundPassword(); - $encryptedOutboundPassword = $account->getMailAccount()->getOutboundPassword(); - if ($encryptedInboundPassword !== null) { - try { - $accountData['inboundPassword'] = $this->crypto->decrypt($encryptedInboundPassword); - } catch (Exception $e) { - $output->writeln("Can not decrypt inbound password of account {$account->getId()}: " . $e->getMessage()); - } - } - if ($encryptedOutboundPassword !== null) { - try { - $accountData['outboundPassword'] = $this->crypto->decrypt($encryptedOutboundPassword); - } catch (Exception $e) { - $output->writeln("Can not decrypt outbound password of account {$account->getId()}: " . $e->getMessage()); - } - } - } elseif ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { - $encryptedRefreshToken = $account->getMailAccount()->getOauthRefreshToken(); - $encryptedAccessToken = $account->getMailAccount()->getOauthAccessToken(); - if ($encryptedRefreshToken !== null) { - try { - $accountData['oauthRefreshToken'] = $this->crypto->decrypt($encryptedRefreshToken); - } catch (Exception $e) { - $output->writeln("Can not decrypt oauth refresh token of account {$account->getId()}: " . $e->getMessage()); - } - } - if ($encryptedAccessToken !== null) { - try { - $accountData['oauthAccessToken'] = $this->crypto->decrypt($encryptedAccessToken); - } catch (Exception $e) { - $output->writeln("Can not decrypt oauth access token of account {$account->getId()}: " . $e->getMessage()); - } - } - $accountData['oauthTokenTtl'] = $account->getMailAccount()->getOauthTokenTtl(); - } - - unset( - $accountData['draftsMailboxId'], - $accountData['sentMailboxId'], - $accountData['trashMailboxId'], - $accountData['archiveMailboxId'], - $accountData['snoozeMailboxId'], - $accountData['junkMailboxId'], - ); + $output->writeln( + $this->l10n->t( + 'Exporting mail accounts for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output); + $this->internalAddressesMigrationService->exportInternalAddresses($user, $exportDestination, $output); + $this->trustedSendersMigrationService->exportTrustedSenders($user, $exportDestination, $output); + $this->textBlocksMigrationService->exportTextBlocks($user, $exportDestination, $output); + $this->tagsMigrationService->exportTags($user, $exportDestination, $output); + $this->smimeMigrationService->exportCertificates($user, $exportDestination, $output); + $this->accountMigrationService->exportAccounts($user, $exportDestination, $output); + $this->quickActionsMigrationService->exportQuickActions($user, $exportDestination, $output); + } - $aliases = $this->aliasesService->findAll( - $account->getId(), - $account->getUserId(), // perf: this adds overhead - add dedicated method to fetch by account id only + /** + * App configuration and quick actions run last because they reference + * account, mailbox and tag ids that only exist after those are imported. + * + * @throws \JsonException + * @throws \OCA\Mail\Exception\ServiceException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\DB\Exception + * @throws \OCP\UserMigration\UserMigrationException + */ + #[\Override] + public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Importing mail accounts for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + if ($this->getArchiveVersion($importSource) === self::LEGACY_VERSION) { + $this->accountMigrationService->scheduleBackgroundJobs( + $this->legacyAccountMigrationService->importAccounts($user, $importSource, $output), + $output, ); - $accountData['aliases'] = array_map(function (Alias $alias) { - $data = $alias->jsonSerialize(); - return $data; - }, $aliases); - $exportDestination->addFileContents($accountFilePath, json_encode($accountData)); - $index[$account->getId()] = $accountFilePath; + return; } - $exportDestination->addFileContents('mail/accounts/index.json', json_encode($index)); - } + $this->internalAddressesMigrationService->importInternalAddresses($user, $importSource, $output); + $this->trustedSendersMigrationService->importTrustedSenders($user, $importSource, $output); + $this->textBlocksMigrationService->importTextBlocks($user, $importSource, $output); + $migratedTags = $this->tagsMigrationService->importTags($user, $importSource, $output); + $migratedCertificates = $this->smimeMigrationService->importCertificates($user, $importSource, $output); + $migratedAccountsAndMailboxes = $this->accountMigrationService->importAccounts($user, $importSource, $output, + $migratedCertificates); - #[\Override] - public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { try { - $index = json_decode($importSource->getFileContents('mail/accounts/index.json'), true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new UserMigrationException("Invalid index content: {$e->getMessage()}", $e->getCode(), $e); + $this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output, + $migratedAccountsAndMailboxes['accounts'], $migratedAccountsAndMailboxes['mailboxes']); + $this->quickActionsMigrationService->importQuickActions($user, $importSource, $output, + $migratedAccountsAndMailboxes['accounts'], $migratedAccountsAndMailboxes['mailboxes'], $migratedTags); + } finally { + $this->accountMigrationService->scheduleBackgroundJobs($migratedAccountsAndMailboxes['accounts'], $output); } - foreach ($index as $accountFilePath) { - try { - $accountData = json_decode($importSource->getFileContents($accountFilePath), true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new UserMigrationException("Invalid account content: {$e->getMessage()}", $e->getCode(), $e); - } - - // Wipe the old ID(s) to prevent overwrites - unset( - $accountData['id'], - $accountData['accountId'], - ); - - $newAccount = new MailAccount($accountData); - - // Change UID to new owner - $newAccount->setUserId($user->getUID()); - // Map the rest of the properties that are not mapped via the constructor - $newAccount->setName($accountData['name']); - $newAccount->setAuthMethod($accountData['authMethod']); - $newAccount->setEditorMode($accountData['editorMode'] ?? 'plain'); - $newAccount->setSearchBody($accountData['searchBody'] ?? false); - $newAccount->setClassificationEnabled($accountData['classificationEnabled'] ?? false); - $newAccount->setSignatureAboveQuote($accountData['signatureAboveQuote'] ?? false); - $newAccount->setPersonalNamespace($accountData['personalNamespace'] ?? null); - if (isset($accountData['inboundPassword'])) { - $newAccount->setInboundPassword($this->crypto->encrypt($accountData['inboundPassword'])); - } - if (isset($accountData['outboundPassword'])) { - $newAccount->setOutboundPassword($this->crypto->encrypt($accountData['outboundPassword'])); - } - if (isset($accountData['oauthRefreshToken'])) { - $newAccount->setOauthRefreshToken($this->crypto->encrypt($accountData['oauthRefreshToken'])); - } - if (isset($accountData['oauthAccessToken'])) { - $newAccount->setOauthAccessToken($this->crypto->encrypt($accountData['oauthAccessToken'])); - } - $newAccount->setOauthTokenTtl($accountData['oauthTokenTtl'] ?? null); - - $mailAccount = $this->accountService->save( - $newAccount - ); + } - // Import aliases - foreach ($accountData['aliases'] as $alias) { - $this->aliasesService->create( - $user->getUID(), - $mailAccount->getId(), - $alias['alias'], - $alias['name'], - ); - } + private function getArchiveVersion(IImportSource $importSource): ?int { + try { + return $importSource->getMigratorVersion($this->getId()); + } catch (UserMigrationException) { + return null; } } @@ -195,16 +165,23 @@ public function getDescription(): string { #[\Override] public function getVersion(): int { - return 01_00_00; + return self::VERSION; } + /** + * Only a newer archive is refused here: returning false aborts the import of + * every other migrator too, so an archive this migrator merely cannot read + * is accepted and skipped in import(). + */ #[\Override] public function canImport(IImportSource $importSource): bool { try { - return $importSource->getMigratorVersion($this->getId()) <= $this->getVersion(); + $version = $importSource->getMigratorVersion($this->getId()); } catch (UserMigrationException) { return false; } + + return $version === null || $version <= self::VERSION; } } diff --git a/lib/UserMigration/Service/AccountMigrationService.php b/lib/UserMigration/Service/AccountMigrationService.php new file mode 100644 index 0000000000..df72bbf4cd --- /dev/null +++ b/lib/UserMigration/Service/AccountMigrationService.php @@ -0,0 +1,708 @@ +, aliases: list + * } + */ +class AccountMigrationService { + use TTransactional; + + public const ACCOUNT_FOLDER = MailAccountMigrator::EXPORT_ROOT . '/accounts/'; + public const ACCOUNT_FILES = self::ACCOUNT_FOLDER . MailAccountMigrator::FILENAME_PLACEHOLDER . '.json'; + + /** + * Measured on real export payloads and rounded up: an underestimate makes + * the caller reserve too little space, which is worse than overshooting. + */ + public const ESTIMATED_ACCOUNT_SIZE = 1500; + public const ESTIMATED_MAILBOX_SIZE = 300; + public const ESTIMATED_ALIAS_SIZE = 250; + + /** Every key the export writes; an archive missing one is not ours. */ + private const REQUIRED_ACCOUNT_KEYS = [ + 'accountId', 'name', 'emailAddress', 'order', + 'showSubscribedOnly', 'smimeCertificateId', 'editorMode', 'trashRetentionDays', + 'outOfOfficeFollowsSystem', 'imipCreate', 'classificationEnabled', 'searchBody', + 'personalNamespace', 'signature', 'signatureAboveQuote', 'signatureMode', + 'protocol', 'authMethod', + 'imapHost', 'imapPort', 'imapSslMode', 'imapUser', 'inboundPassword', + 'smtpHost', 'smtpPort', 'smtpSslMode', 'smtpUser', 'outboundPassword', + 'oauthRefreshToken', 'oauthAccessToken', 'oauthTokenTtl', + 'sieveEnabled', 'sieveHost', 'sievePort', 'sieveSslMode', 'sieveUser', 'sievePassword', + 'draftsMailboxId', 'sentMailboxId', 'trashMailboxId', + 'archiveMailboxId', 'junkMailboxId', 'snoozeMailboxId', + 'mailboxes', 'aliases', + ]; + + private const REQUIRED_MAILBOX_KEYS = [ + 'databaseId', 'name', 'attributes', 'delimiter', 'specialUse', + 'selectable', 'syncInBackground', 'myAcls', 'shared', + ]; + + private const REQUIRED_ALIAS_KEYS = ['alias', 'name', 'signature', 'smimeCertificateId']; + + public function __construct( + private readonly AccountService $accountService, + private readonly AliasesService $aliasesService, + private readonly MailboxMapper $mailboxMapper, + private readonly ICrypto $crypto, + private readonly IL10N $l10n, + private readonly IDBConnection $connection, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Mailboxes dominate the size of an account file, so they are counted in the + * database instead of being estimated per account. + * + * @throws \OCP\DB\Exception + */ + public function getEstimatedExportSize(IUser $user): int { + $bytes = 0; + + foreach ($this->accountService->findByUserId($user->getUID()) as $account) { + if ($this->getExportSkipReason($account->getMailAccount()) !== null) { + continue; + } + + $mailboxCount = $this->mailboxMapper->countByAccount($account); + $aliasCount = $this->aliasesService->countByAccountId($account->getId(), $account->getUserId()); + + $bytes += self::ESTIMATED_ACCOUNT_SIZE + + $mailboxCount * self::ESTIMATED_MAILBOX_SIZE + + $aliasCount * self::ESTIMATED_ALIAS_SIZE; + } + + return $bytes; + } + + /** + * Exports all mail accounts for the given user. + * This includes the mailboxes (without messages), + * aliases and Sieve settings. + * + * @throws UserMigrationException + */ + public function exportAccounts(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting mail accounts for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $accounts = $this->accountService->findByUserId($user->getUID()); + + foreach ($accounts as $account) { + $mailAccount = $account->getMailAccount(); + + $skipReason = $this->getExportSkipReason($mailAccount); + if ($skipReason !== null) { + $output->writeln($skipReason, OutputInterface::VERBOSITY_VERBOSE); + + continue; + } + + try { + $accountData = $this->serialiseAccount($account); + } catch (\Exception $exception) { + throw new UserMigrationException( + "Failed to decrypt passwords for user {$user->getUID()}", + previous: $exception + ); + } + + $this->getMailboxes($account, $accountData); + $this->getAliases($account, $accountData); + + $accountFilePath = str_replace(MailAccountMigrator::FILENAME_PLACEHOLDER, (string)$account->getId(), + self::ACCOUNT_FILES); + + try { + $exportDestination->addFileContents($accountFilePath, json_encode($accountData, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export mail accounts for user {$user->getUID()}", + previous: $exception + ); + } + } + } + + /** + * Import all mail accounts for the given user existing + * on export. This includes the mailboxes (without messages), + * aliases and Sieve settings. + * + * @param array $certificatesMapping + * @return array{accounts: array, mailboxes: array} Ids of the exported accounts and + * mailboxes mapped to the imported ones + * @throws \JsonException + * @throws \OCP\UserMigration\UserMigrationException + */ + public function importAccounts(IUser $user, + IImportSource $importSource, + OutputInterface $output, + array $certificatesMapping): array { + $output->writeln( + $this->l10n->t( + 'Importing mail accounts for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $accounts = $this->getAccounts($importSource, $output); + $accountAndMailboxMappings = ['accounts' => [], 'mailboxes' => []]; + $orderOffset = $this->getNextAccountOrder($user); + + foreach ($accounts as $accountData) { + try { + $this->validateAccount($accountData); + } catch (UserMigrationException $exception) { + $this->logger->warning('Skipping an invalid mail account while importing user ' . $user->getUID(), [ + 'exception' => $exception, + ]); + $output->writeln( + $this->l10n->t( + 'A mail account of user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + $protocol = $accountData['protocol']; + if ($protocol !== MailAccount::PROTOCOL_IMAP) { + $output->writeln( + $this->l10n->t( + 'Skipping account with ID %s because migrating %s accounts is not supported yet', + [$accountData['accountId'], $protocol] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + try { + $newAccount = $this->buildAccount($user, $accountData, $certificatesMapping, $orderOffset); + $imported = $this->atomic(function () use ($newAccount, $accountData, $certificatesMapping): array { + $mailAccount = $this->accountService->save($newAccount, false); + $this->setAliases($mailAccount, $accountData, $certificatesMapping); + + return [ + 'accountId' => $mailAccount->getId(), + 'mailboxes' => $this->setMailboxes($mailAccount, $accountData), + ]; + }, $this->connection); + } catch (Throwable $exception) { + // Each account commits on its own, so a failure leaves nothing + // behind and the remaining accounts can still be imported. + $this->logger->error('Failed to import a mail account of user ' . $user->getUID(), [ + 'exception' => $exception, + ]); + $output->writeln( + $this->l10n->t( + 'Failed to import a mail account of user %s, it will be skipped. See the log for details.', + [$user->getUID()] + ) + ); + + continue; + } + + $accountAndMailboxMappings['accounts'][$accountData['accountId']] = $imported['accountId']; + $accountAndMailboxMappings['mailboxes'] += $imported['mailboxes']; + } + + return $accountAndMailboxMappings; + } + + /** + * Imported accounts are appended after the user's existing ones: restoring + * the exported order verbatim would interleave them into an arrangement the + * user already has. Zero when there is nothing to append to, so a migration + * into a fresh user keeps the exported order exactly. + */ + private function getNextAccountOrder(IUser $user): int { + $orders = array_map( + static fn (Account $account): int => (int)$account->getMailAccount()->getOrder(), + $this->accountService->findByUserId($user->getUID()), + ); + + return $orders === [] ? 0 : max($orders) + 1; + } + + /** + * Necessary to do after all data is being imported as we + * could run into race conditions when doing directly after + * saving each mail account into database. + * + * @param array $accountsMapping Ids of the exported accounts mapped to the imported ones + */ + public function scheduleBackgroundJobs(array $accountsMapping, OutputInterface $output): void { + foreach ($accountsMapping as $accountId) { + $output->writeln( + $this->l10n->t( + 'Scheduling background jobs for mail account %s', + [$accountId] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $this->accountService->scheduleBackgroundJobs($accountId); + } + } + + /** + * @param array $certificatesMapping + * @param int $orderOffset Added to the exported order so imported accounts + * sort after the ones the user already has + */ + private function buildAccount(IUser $user, array $accountData, array $certificatesMapping, int $orderOffset): MailAccount { + $newAccount = new MailAccount(); + + $newAccount->setUserId($user->getUID()); + $newAccount->setName($accountData['name']); + $newAccount->setEmail($accountData['emailAddress']); + + $newAccount->setShowSubscribedOnly($accountData['showSubscribedOnly']); + + $oldCertificateId = $accountData['smimeCertificateId']; + $newAccount->setSmimeCertificateId($certificatesMapping[$oldCertificateId] ?? null); + $newAccount->setEditorMode($accountData['editorMode'] ?? 'plaintext'); + $newAccount->setTrashRetentionDays($accountData['trashRetentionDays']); + $newAccount->setOooFollowsSystem($accountData['outOfOfficeFollowsSystem']); + $newAccount->setImipCreate($accountData['imipCreate']); + $newAccount->setClassificationEnabled($accountData['classificationEnabled']); + $newAccount->setSearchBody($accountData['searchBody']); + + $newAccount->setOrder($orderOffset + max(0, $accountData['order'])); + $newAccount->setSignature($accountData['signature']); + $newAccount->setSignatureAboveQuote($accountData['signatureAboveQuote']); + $newAccount->setSignatureMode($accountData['signatureMode']); + + $newAccount->setPersonalNamespace($accountData['personalNamespace']); + + $newAccount->setInboundHost($accountData['imapHost']); + $newAccount->setInboundPort($accountData['imapPort']); + $newAccount->setInboundSslMode($accountData['imapSslMode']); + if (isset($accountData['imapUser'])) { + $newAccount->setInboundUser($accountData['imapUser']); + } + + if (isset($accountData['smtpHost'], $accountData['smtpPort'], $accountData['smtpSslMode'])) { + $newAccount->setOutboundHost($accountData['smtpHost']); + $newAccount->setOutboundPort($accountData['smtpPort']); + $newAccount->setOutboundSslMode($accountData['smtpSslMode']); + } + if (isset($accountData['smtpUser'])) { + $newAccount->setOutboundUser($accountData['smtpUser']); + } + + $newAccount->setAuthMethod($accountData['authMethod']); + $this->setPasswords($newAccount, $accountData); + $this->setOauthToken($newAccount, $accountData); + + $this->setSieveSettings($newAccount, $accountData); + + return $newAccount; + } + + /** + * @throws UserMigrationException + */ + private function validateAccount(mixed $accountData): void { + if (!is_array($accountData)) { + throw new UserMigrationException('Invalid account export structure'); + } + + $this->assertRequiredKeys($accountData, self::REQUIRED_ACCOUNT_KEYS, 'account'); + + if (!is_int($accountData['accountId'])) { + throw new UserMigrationException('Invalid account id in account export'); + } + + $this->assertListOfRequiredKeys($accountData['mailboxes'], self::REQUIRED_MAILBOX_KEYS, 'mailbox'); + $this->assertListOfRequiredKeys($accountData['aliases'], self::REQUIRED_ALIAS_KEYS, 'alias'); + } + + /** + * @param list $requiredKeys + * @throws UserMigrationException + */ + private function assertRequiredKeys(array $entry, array $requiredKeys, string $label): void { + foreach ($requiredKeys as $requiredKey) { + if (!array_key_exists($requiredKey, $entry)) { + throw new UserMigrationException("Exported $label is missing $requiredKey"); + } + } + } + + /** + * @param list $requiredKeys + * @throws UserMigrationException + */ + private function assertListOfRequiredKeys(mixed $entries, array $requiredKeys, string $label): void { + if (!is_array($entries) || !array_is_list($entries)) { + throw new UserMigrationException("Invalid {$label}es in account export"); + } + + foreach ($entries as $entry) { + if (!is_array($entry)) { + throw new UserMigrationException("Invalid $label in account export"); + } + + $this->assertRequiredKeys($entry, $requiredKeys, $label); + } + } + + /** + * Provisioned accounts are managed by the target instance and non-IMAP + * accounts are not migratable yet, so neither is exported. + * + * @return string|null The reason to skip the account, or null to export it + */ + private function getExportSkipReason(MailAccount $mailAccount): ?string { + if ($mailAccount->getProvisioningId() !== null) { + return $this->l10n->t( + 'Skipping provisioned account with ID %s', + [$mailAccount->getId()] + ); + } + + if ($mailAccount->getProtocol() !== MailAccount::PROTOCOL_IMAP) { + return $this->l10n->t( + 'Skipping account with ID %s because migrating %s accounts is not supported yet', + [$mailAccount->getId(), $mailAccount->getProtocol()] + ); + } + + return null; + } + + /** + * The archive format. Deliberately not `MailAccount::toJson()`: that shape + * serves the API, so a field added for the UI would silently change what + * every export contains and what the import has to accept. + * + * @return MailAccountExport + * @throws \Exception when a stored secret cannot be decrypted + */ + private function serialiseAccount(Account $account): array { + $mailAccount = $account->getMailAccount(); + $authMethod = $mailAccount->getAuthMethod() ?? 'password'; + $usesPassword = $authMethod === 'password'; + $usesOauth = $authMethod === 'xoauth2'; + + return [ + 'accountId' => $account->getId(), + 'name' => $mailAccount->getName(), + 'emailAddress' => $mailAccount->getEmail(), + 'order' => $mailAccount->getOrder(), + 'showSubscribedOnly' => $mailAccount->getShowSubscribedOnly() === true, + 'smimeCertificateId' => $mailAccount->getSmimeCertificateId(), + 'editorMode' => $mailAccount->getEditorMode(), + 'trashRetentionDays' => $mailAccount->getTrashRetentionDays(), + 'outOfOfficeFollowsSystem' => $mailAccount->getOutOfOfficeFollowsSystem(), + 'imipCreate' => $mailAccount->getImipCreate(), + 'classificationEnabled' => $mailAccount->getClassificationEnabled(), + 'searchBody' => $mailAccount->getSearchBody(), + 'personalNamespace' => $mailAccount->getPersonalNamespace(), + 'signature' => $mailAccount->getSignature(), + 'signatureAboveQuote' => $mailAccount->isSignatureAboveQuote() === true, + 'signatureMode' => $mailAccount->getSignatureMode(), + 'protocol' => $mailAccount->getProtocol(), + + 'authMethod' => $authMethod, + 'imapHost' => $mailAccount->getInboundHost(), + 'imapPort' => $mailAccount->getInboundPort(), + 'imapSslMode' => $mailAccount->getInboundSslMode(), + 'imapUser' => $mailAccount->getInboundUser(), + 'inboundPassword' => $usesPassword ? $this->decrypt($mailAccount->getInboundPassword()) : null, + + 'smtpHost' => $mailAccount->getOutboundHost(), + 'smtpPort' => $mailAccount->getOutboundPort(), + 'smtpSslMode' => $mailAccount->getOutboundSslMode(), + 'smtpUser' => $mailAccount->getOutboundUser(), + 'outboundPassword' => $usesPassword ? $this->decrypt($mailAccount->getOutboundPassword()) : null, + + 'oauthRefreshToken' => $usesOauth ? $this->decrypt($mailAccount->getOauthRefreshToken()) : null, + 'oauthAccessToken' => $usesOauth ? $this->decrypt($mailAccount->getOauthAccessToken()) : null, + 'oauthTokenTtl' => $usesOauth ? $mailAccount->getOauthTokenTtl() : null, + + 'sieveEnabled' => $mailAccount->isSieveEnabled() === true, + 'sieveHost' => $mailAccount->getSieveHost(), + 'sievePort' => $mailAccount->getSievePort(), + 'sieveSslMode' => $mailAccount->getSieveSslMode(), + 'sieveUser' => $mailAccount->getSieveUser(), + 'sievePassword' => $mailAccount->isSieveEnabled() ? $this->decrypt($mailAccount->getSievePassword()) : null, + + 'draftsMailboxId' => $mailAccount->getDraftsMailboxId(), + 'sentMailboxId' => $mailAccount->getSentMailboxId(), + 'trashMailboxId' => $mailAccount->getTrashMailboxId(), + 'archiveMailboxId' => $mailAccount->getArchiveMailboxId(), + 'junkMailboxId' => $mailAccount->getJunkMailboxId(), + 'snoozeMailboxId' => $mailAccount->getSnoozeMailboxId(), + + 'mailboxes' => [], + 'aliases' => [], + ]; + } + + /** + * @throws \Exception + */ + private function decrypt(?string $encrypted): ?string { + return $encrypted === null ? null : $this->crypto->decrypt($encrypted); + } + + /** + * Message counts and sync state are deliberately not exported. + */ + private function getMailboxes(Account $account, array &$accountData): void { + $mailboxes = $this->mailboxMapper->findAll($account); + $accountData['mailboxes'] = array_map(static function (Mailbox $mailbox) { + return [ + 'databaseId' => $mailbox->getId(), + 'name' => $mailbox->getName(), + 'attributes' => $mailbox->getAttributes(), + 'delimiter' => $mailbox->getDelimiter(), + 'specialUse' => $mailbox->getSpecialUse(), + 'selectable' => $mailbox->getSelectable(), + 'syncInBackground' => $mailbox->getSyncInBackground(), + 'myAcls' => $mailbox->getMyAcls(), + 'shared' => $mailbox->isShared(), + ]; + }, $mailboxes); + } + + /** + * Gets all aliases for the given account and + * saves it to `$accountData`. + * + * @param Account $account + * @param array $accountData + * @return void + */ + private function getAliases(Account $account, array &$accountData): void { + $aliases = $this->aliasesService->findAll( + $account->getId(), + $account->getUserId(), // perf: this adds overhead - add dedicated method to fetch by account id only + ); + $accountData['aliases'] = array_map(static function (Alias $alias) { + return [ + 'alias' => $alias->getAlias(), + 'name' => $alias->getName(), + 'signature' => $alias->getSignature(), + 'smimeCertificateId' => $alias->getSmimeCertificateId(), + ]; + }, $aliases); + } + + /** + * `getFolderListing()` returns names relative to the folder, so they have to + * be prefixed again. It is also the only way to test for a folder: the export + * writes no directory entries, so `pathExists()` on a folder is always false. + * + * @return Generator + * @throws UserMigrationException + */ + private function getAccounts(IImportSource $importSource, OutputInterface $output): Generator { + foreach ($importSource->getFolderListing(self::ACCOUNT_FOLDER) as $accountFileName) { + if (!str_ends_with($accountFileName, '.json')) { + continue; + } + + try { + yield json_decode( + $importSource->getFileContents(self::ACCOUNT_FOLDER . $accountFileName), + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (JsonException|UserMigrationException $exception) { + $this->logger->warning('Skipping an unreadable mail account file: ' . $accountFileName, [ + 'exception' => $exception, + ]); + $output->writeln( + $this->l10n->t( + 'Mail account %s could not be read and will be skipped. Continue …', + [$accountFileName] + ), OutputInterface::VERBOSITY_VERBOSE + ); + } + } + } + + private function setPasswords(MailAccount $mailAccount, array $accountData): void { + if ($mailAccount->getAuthMethod() !== 'password') { + return; + } + + if (isset($accountData['inboundPassword'])) { + $mailAccount->setInboundPassword($this->crypto->encrypt($accountData['inboundPassword'])); + } + if (isset($accountData['outboundPassword'])) { + $mailAccount->setOutboundPassword($this->crypto->encrypt($accountData['outboundPassword'])); + } + } + + private function setOauthToken(MailAccount $mailAccount, array $accountData): void { + if ($mailAccount->getAuthMethod() !== 'xoauth2') { + return; + } + + if (isset($accountData['oauthRefreshToken'])) { + $mailAccount->setOauthRefreshToken($this->crypto->encrypt($accountData['oauthRefreshToken'])); + } + if (isset($accountData['oauthAccessToken'])) { + $mailAccount->setOauthAccessToken($this->crypto->encrypt($accountData['oauthAccessToken'])); + } + $mailAccount->setOauthTokenTtl($accountData['oauthTokenTtl']); + } + + private function setSieveSettings(MailAccount $mailAccount, array $accountData): void { + $sieveEnabled = (bool)$accountData['sieveEnabled']; + $mailAccount->setSieveEnabled($sieveEnabled); + + if ($sieveEnabled) { + $mailAccount->setSieveHost($accountData['sieveHost']); + $mailAccount->setSievePort($accountData['sievePort']); + $mailAccount->setSieveSslMode($accountData['sieveSslMode']); + + // Sieve can use the IMAP credentials, which + // is indicated by empty username and password. + $useCustomCredentials = isset($accountData['sieveUser']) && isset($accountData['sievePassword']); + if ($useCustomCredentials) { + $mailAccount->setSieveUser($accountData['sieveUser']); + $mailAccount->setSievePassword($this->crypto->encrypt($accountData['sievePassword'])); + } + } + } + + /** + * @param array $certificatesMapping + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\DB\Exception + */ + private function setAliases(MailAccount $mailAccount, array $accountData, array $certificatesMapping): void { + $userId = $mailAccount->getUserId(); + + foreach ($accountData['aliases'] as $alias) { + $oldCertificateId = $alias['smimeCertificateId']; + + $this->aliasesService->create( + $userId, + $mailAccount->getId(), + $alias['alias'], + $alias['name'], + $alias['signature'], + $certificatesMapping[$oldCertificateId] ?? null, + ); + } + } + + /** + * Imports all mailboxes for the given mail account. + * + * @return array Contains the old mailbox id as key and the + * new mailbox id as value. Example: `'2' => '4'` + * @throws \OCP\DB\Exception + */ + private function setMailboxes(MailAccount $mailAccount, array $accountData): array { + $mailboxMapping = []; + + foreach ($accountData['mailboxes'] as $oldMailbox) { + $newMailbox = new Mailbox(); + + $newMailbox->setName($oldMailbox['name']); + $newMailbox->setNameHash(md5($oldMailbox['name'])); + $newMailbox->setAccountId($mailAccount->getId()); + $newMailbox->setAttributes($oldMailbox['attributes']); + $newMailbox->setDelimiter($oldMailbox['delimiter']); + $newMailbox->setSpecialUse($oldMailbox['specialUse']); + $newMailbox->setMessages(0); + $newMailbox->setUnseen(0); + $newMailbox->setSelectable($oldMailbox['selectable']); + $newMailbox->setSyncInBackground($oldMailbox['syncInBackground']); + $newMailbox->setMyAcls($oldMailbox['myAcls']); + $newMailbox->setShared($oldMailbox['shared']); + + /** @var Mailbox $mailbox */ + $mailbox = $this->mailboxMapper->insert($newMailbox); + + $oldMailboxId = $oldMailbox['databaseId']; + $mailboxMapping[$oldMailboxId] = $mailbox->getId(); + + if ($oldMailboxId === $accountData['draftsMailboxId']) { + $mailAccount->setDraftsMailboxId($mailbox->getId()); + } + if ($oldMailboxId === $accountData['sentMailboxId']) { + $mailAccount->setSentMailboxId($mailbox->getId()); + } + if ($oldMailboxId === $accountData['trashMailboxId']) { + $mailAccount->setTrashMailboxId($mailbox->getId()); + } + if ($oldMailboxId === $accountData['archiveMailboxId']) { + $mailAccount->setArchiveMailboxId($mailbox->getId()); + } + if ($oldMailboxId === $accountData['junkMailboxId']) { + $mailAccount->setJunkMailboxId($mailbox->getId()); + } + if ($oldMailboxId === $accountData['snoozeMailboxId']) { + $mailAccount->setSnoozeMailboxId($mailbox->getId()); + } + } + + $this->accountService->update($mailAccount); + + return $mailboxMapping; + } +} diff --git a/lib/UserMigration/Service/AppConfigMigrationService.php b/lib/UserMigration/Service/AppConfigMigrationService.php new file mode 100644 index 0000000000..123004b669 --- /dev/null +++ b/lib/UserMigration/Service/AppConfigMigrationService.php @@ -0,0 +1,246 @@ +getAppConfigSettings($user), JSON_THROW_ON_ERROR)); + } + + /** + * @return list + */ + private function getAppConfigSettings(IUser $user): array { + $appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID); + + return array_values(array_map(function (string $appConfigKey) use ($user) { + return [ + 'key' => $appConfigKey, + 'value' => $this->config->getUserValue($user->getUID(), Application::APP_ID, $appConfigKey) + ]; + }, $appConfigKeys)); + } + + /** + * Export the user configuration stored via IConfig. + * + * @throws UserMigrationException + */ + public function exportAppConfiguration(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting mail app configuration for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $appConfigSettings = $this->getAppConfigSettings($user); + + try { + $exportDestination->addFileContents(self::APP_CONFIGURATION_FILE, json_encode($appConfigSettings, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export mail app configuration for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import the user configuration stored via IConfig + * on export. + * + * @param array $accountsMapping Ids of the exported accounts mapped to the imported ones + * @param array $mailboxesMapping Ids of the exported mailboxes mapped to the imported ones + * @throws JsonException + */ + public function importAppConfiguration(IUser $user, + IImportSource $importSource, + OutputInterface $output, + array $accountsMapping, + array $mailboxesMapping): void { + $output->writeln( + $this->l10n->t( + 'Importing mail app configuration for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $appConfigFileContent = $importSource->getFileContents(self::APP_CONFIGURATION_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Mail app configuration for user %s not found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $appConfig = json_decode($appConfigFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateAppConfig($appConfig); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Mail app configuration for user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + foreach ($appConfig as $appSetting) { + $key = $appSetting['key']; + $value = match ($key) { + self::ACCOUNT_SETTINGS_KEY => $this->remapAccountSettings($appSetting['value'], $accountsMapping), + self::START_MAILBOX_ID_KEY => $this->remapStartMailboxId($appSetting['value'], $mailboxesMapping), + default => $appSetting['value'], + }; + + if ($value === null) { + $output->writeln( + $this->l10n->t( + 'Mail app configuration key %s for user %s could not be migrated and will be removed. Continue …', + [$key, $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + // user_migration restores every preference before the migrators + // run, so leaving the key would keep the exporting instance's ids. + $this->config->deleteUserValue($user->getUID(), Application::APP_ID, $key); + + continue; + } + + $output->writeln( + $this->l10n->t( + 'Importing mail app configuration key %s for user %s', + [$key, $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + /** @noinspection PhpUnhandledExceptionInspection */ + $this->config->setUserValue($user->getUID(), Application::APP_ID, $key, $value); + } + } + + /** + * Settings of accounts that were not imported are dropped. + * + * @param array $accountsMapping + * @return string|null The remapped value, or null if nothing is left to import + * @throws JsonException + */ + private function remapAccountSettings(string $value, array $accountsMapping): ?string { + try { + $accountSettings = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (!is_array($accountSettings) || !array_is_list($accountSettings)) { + return null; + } + + $remappedAccountSettings = []; + + foreach ($accountSettings as $accountSetting) { + if (!is_array($accountSetting) || !isset($accountSetting['accountId']) + || !is_int($accountSetting['accountId']) + || !isset($accountsMapping[$accountSetting['accountId']])) { + continue; + } + + $accountSetting['accountId'] = $accountsMapping[$accountSetting['accountId']]; + $remappedAccountSettings[] = $accountSetting; + } + + if ($remappedAccountSettings === []) { + return null; + } + + return json_encode($remappedAccountSettings, JSON_THROW_ON_ERROR); + } + + /** + * @param array $mailboxesMapping + * @return string|null The remapped mailbox id, or null if the mailbox was not imported + */ + private function remapStartMailboxId(string $value, array $mailboxesMapping): ?string { + if (!ctype_digit($value)) { + return null; + } + + return isset($mailboxesMapping[(int)$value]) ? (string)$mailboxesMapping[(int)$value] : null; + } + + /** + * Validate the parsed app configuration and their containing + * settings to ensure they have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateAppConfig(mixed $appConfig): void { + $appConfigArrayIsValid = is_array($appConfig) && array_is_list($appConfig); + if (!$appConfigArrayIsValid) { + throw new UserMigrationException('Invalid mail app configuration export structure'); + } + + foreach ($appConfig as $appSetting) { + $appSettingArrayIsValid = is_array($appSetting); + + $keyIsValid = $appSettingArrayIsValid + && array_key_exists('key', $appSetting) + && is_string($appSetting['key']); + + $valueIsValid = $appSettingArrayIsValid + && array_key_exists('value', $appSetting) + && is_string($appSetting['value']); + + if (!$keyIsValid || !$valueIsValid) { + throw new UserMigrationException('Invalid mail app configuration entry'); + } + } + } +} diff --git a/lib/UserMigration/Service/InternalAddressesMigrationService.php b/lib/UserMigration/Service/InternalAddressesMigrationService.php new file mode 100644 index 0000000000..e7e52ed970 --- /dev/null +++ b/lib/UserMigration/Service/InternalAddressesMigrationService.php @@ -0,0 +1,153 @@ +internalAddressService->getInternalAddresses($user->getUID())) * self::ESTIMATED_INTERNAL_ADDRESS_SIZE; + } + + /** + * Export all addresses the user defined as internal ones + * on export. + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @throws UserMigrationException + */ + public function exportInternalAddresses(IUser $user, + IExportDestination $exportDestination, + OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting internal addresses for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $internalAddresses = $this->internalAddressService->getInternalAddresses($user->getUID()); + + try { + $exportDestination->addFileContents(self::INTERNAL_ADDRESSES_FILE, + json_encode($internalAddresses, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export internal addresses for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all addresses the user defined as internal ones. + */ + public function importInternalAddresses(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Importing internal addresses for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $internalAddressesFileContent = $importSource->getFileContents(self::INTERNAL_ADDRESSES_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Internal addresses for user %s not found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $internalAddresses = json_decode($internalAddressesFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateInternalAddresses($internalAddresses); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Internal addresses configuration for user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + foreach ($internalAddresses as $internalAddress) { + $this->internalAddressService->add($user->getUID(), $internalAddress['address'], $internalAddress['type']); + } + } + + /** + * Validate the parsed internal addresses to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateInternalAddresses(mixed $internalAddresses): void { + $internalAddressesArrayIsValid = is_array($internalAddresses) && array_is_list($internalAddresses); + if (!$internalAddressesArrayIsValid) { + throw new UserMigrationException('Invalid internal addresses export structure'); + } + + foreach ($internalAddresses as $internalAddress) { + $internalAddressArrayIsValid = is_array($internalAddress); + + $idIsValid = $internalAddressArrayIsValid + && array_key_exists('id', $internalAddress) + && is_int($internalAddress['id']); + + $addressIsValid = $internalAddressArrayIsValid + && array_key_exists('address', $internalAddress) + && is_string($internalAddress['address']); + + $uidIsValid = $internalAddressArrayIsValid + && array_key_exists('uid', $internalAddress) + && is_string($internalAddress['uid']); + + $typeIsValid = $internalAddressArrayIsValid + && array_key_exists('type', $internalAddress) + && is_string($internalAddress['type']); + + if ( + !$idIsValid + || !$addressIsValid + || !$uidIsValid + || !$typeIsValid + ) { + throw new UserMigrationException('Invalid internal address entry'); + } + } + } +} diff --git a/lib/UserMigration/Service/LegacyAccountMigrationService.php b/lib/UserMigration/Service/LegacyAccountMigrationService.php new file mode 100644 index 0000000000..856cd2d8c3 --- /dev/null +++ b/lib/UserMigration/Service/LegacyAccountMigrationService.php @@ -0,0 +1,269 @@ + Ids of the exported accounts mapped to the imported ones + */ + public function importAccounts(IUser $user, IImportSource $importSource, OutputInterface $output): array { + $output->writeln( + $this->l10n->t( + 'Importing mail accounts of user %s from a version 1 export', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $accountsMapping = []; + $orderOffset = $this->getNextAccountOrder($user); + + foreach ($this->getAccountFilePaths($importSource, $output) as $accountFilePath) { + $accountData = $this->getAccount($importSource, $accountFilePath, $output); + if ($accountData === null) { + continue; + } + + $protocol = $accountData['protocol'] ?? MailAccount::PROTOCOL_IMAP; + if ($protocol !== MailAccount::PROTOCOL_IMAP) { + $output->writeln( + $this->l10n->t( + 'Skipping account %s because migrating %s accounts is not supported yet', + [$accountFilePath, $protocol] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + $newAccount = $this->buildAccount($user, $accountData, $orderOffset); + + try { + $mailAccount = $this->atomic(function () use ($newAccount, $accountData): MailAccount { + $saved = $this->accountService->save($newAccount, false); + $this->setAliases($saved, $accountData); + + return $saved; + }, $this->connection); + } catch (Throwable $exception) { + $this->logger->error('Failed to import a version 1 mail account of user ' . $user->getUID(), [ + 'exception' => $exception, + ]); + $output->writeln( + $this->l10n->t( + 'Failed to import a mail account of user %s, it will be skipped. See the log for details.', + [$user->getUID()] + ) + ); + + continue; + } + + $accountsMapping[(int)($accountData['accountId'] ?? $mailAccount->getId())] = $mailAccount->getId(); + } + + return $accountsMapping; + } + + /** + * Version 1 wrote a map of account id to archive path instead of relying on + * the folder listing, and the paths it stored are already absolute. + * + * @return list + */ + private function getAccountFilePaths(IImportSource $importSource, OutputInterface $output): array { + try { + $index = json_decode( + $importSource->getFileContents(self::ACCOUNT_INDEX_FILE), + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t('No readable account index in the version 1 export. Continue …'), + OutputInterface::VERBOSITY_VERBOSE + ); + + return []; + } + + if (!is_array($index)) { + return []; + } + + return array_values(array_filter($index, static fn (mixed $path): bool => is_string($path))); + } + + private function getAccount(IImportSource $importSource, string $path, OutputInterface $output): ?array { + try { + $accountData = json_decode($importSource->getFileContents($path), true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t('Mail account %s could not be read and will be skipped. Continue …', [$path]), + OutputInterface::VERBOSITY_VERBOSE + ); + + return null; + } + + $hasInbound = isset( + $accountData['emailAddress'], + $accountData['imapHost'], + $accountData['imapPort'], + $accountData['imapSslMode'], + ); + if (!is_array($accountData) || !$hasInbound) { + $output->writeln( + $this->l10n->t('Mail account %s is invalid and will be skipped. Continue …', [$path]), + OutputInterface::VERBOSITY_VERBOSE + ); + + return null; + } + + return $accountData; + } + + private function buildAccount(IUser $user, array $accountData, int $orderOffset): MailAccount { + $newAccount = new MailAccount(); + + $newAccount->setUserId($user->getUID()); + $newAccount->setName($accountData['name'] ?? $accountData['emailAddress']); + $newAccount->setEmail($accountData['emailAddress']); + $newAccount->setOrder($orderOffset + max(0, (int)($accountData['order'] ?? 0))); + $newAccount->setShowSubscribedOnly((bool)($accountData['showSubscribedOnly'] ?? false)); + $newAccount->setEditorMode($accountData['editorMode'] ?? 'plaintext'); + $newAccount->setTrashRetentionDays($accountData['trashRetentionDays'] ?? null); + $newAccount->setOooFollowsSystem((bool)($accountData['outOfOfficeFollowsSystem'] ?? false)); + $newAccount->setImipCreate((bool)($accountData['imipCreate'] ?? false)); + $newAccount->setClassificationEnabled((bool)($accountData['classificationEnabled'] ?? false)); + $newAccount->setSearchBody((bool)($accountData['searchBody'] ?? false)); + $newAccount->setPersonalNamespace($accountData['personalNamespace'] ?? null); + $newAccount->setSignature($accountData['signature'] ?? null); + $newAccount->setSignatureAboveQuote((bool)($accountData['signatureAboveQuote'] ?? false)); + $newAccount->setSignatureMode((int)($accountData['signatureMode'] ?? MailAccount::SIGNATURE_MODE_PLAIN)); + + // Version 1 never exported certificates, so any id it carries points at + // a certificate that does not exist on the importing instance. + $newAccount->setSmimeCertificateId(null); + + $newAccount->setInboundHost($accountData['imapHost']); + $newAccount->setInboundPort($accountData['imapPort']); + $newAccount->setInboundSslMode($accountData['imapSslMode']); + if (isset($accountData['imapUser'])) { + $newAccount->setInboundUser($accountData['imapUser']); + } + + if (isset($accountData['smtpHost'], $accountData['smtpPort'], $accountData['smtpSslMode'])) { + $newAccount->setOutboundHost($accountData['smtpHost']); + $newAccount->setOutboundPort($accountData['smtpPort']); + $newAccount->setOutboundSslMode($accountData['smtpSslMode']); + } + if (isset($accountData['smtpUser'])) { + $newAccount->setOutboundUser($accountData['smtpUser']); + } + + $newAccount->setAuthMethod($accountData['authMethod'] ?? 'password'); + if (isset($accountData['inboundPassword'])) { + $newAccount->setInboundPassword($this->crypto->encrypt($accountData['inboundPassword'])); + } + if (isset($accountData['outboundPassword'])) { + $newAccount->setOutboundPassword($this->crypto->encrypt($accountData['outboundPassword'])); + } + if (isset($accountData['oauthRefreshToken'])) { + $newAccount->setOauthRefreshToken($this->crypto->encrypt($accountData['oauthRefreshToken'])); + } + if (isset($accountData['oauthAccessToken'])) { + $newAccount->setOauthAccessToken($this->crypto->encrypt($accountData['oauthAccessToken'])); + } + $newAccount->setOauthTokenTtl($accountData['oauthTokenTtl'] ?? null); + + $sieveEnabled = (bool)($accountData['sieveEnabled'] ?? false); + $newAccount->setSieveEnabled($sieveEnabled); + if ($sieveEnabled) { + $newAccount->setSieveHost($accountData['sieveHost'] ?? null); + $newAccount->setSievePort($accountData['sievePort'] ?? null); + $newAccount->setSieveSslMode($accountData['sieveSslMode'] ?? null); + $newAccount->setSieveUser($accountData['sieveUser'] ?? null); + } + + return $newAccount; + } + + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\DB\Exception + */ + private function setAliases(MailAccount $mailAccount, array $accountData): void { + $userId = $mailAccount->getUserId(); + + foreach ($accountData['aliases'] ?? [] as $alias) { + if (!is_array($alias) || !isset($alias['alias'])) { + continue; + } + + $this->aliasesService->create( + $userId, + $mailAccount->getId(), + $alias['alias'], + $alias['name'] ?? null, + $alias['signature'] ?? null, + ); + } + } + + private function getNextAccountOrder(IUser $user): int { + $orders = array_map( + static fn (Account $account): int => (int)$account->getMailAccount()->getOrder(), + $this->accountService->findByUserId($user->getUID()), + ); + + return $orders === [] ? 0 : max($orders) + 1; + } +} diff --git a/lib/UserMigration/Service/QuickActionsMigrationService.php b/lib/UserMigration/Service/QuickActionsMigrationService.php new file mode 100644 index 0000000000..fff271554b --- /dev/null +++ b/lib/UserMigration/Service/QuickActionsMigrationService.php @@ -0,0 +1,316 @@ +quickActionsService->findAll($user->getUID()) as $quickAction) { + $bytes += self::ESTIMATED_QUICK_ACTION_SIZE + + count($quickAction->getActionSteps()) * self::ESTIMATED_ACTION_STEP_SIZE; + } + + return $bytes; + } + + /** + * Export all quick actions the user defined across + * their accounts. + * + * @param IUser $user + * @param IExportDestination $exportDestination + * @param OutputInterface $output + * @throws UserMigrationException + */ + public function exportQuickActions(IUser $user, + IExportDestination $exportDestination, + OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting quick actions for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $quickActions = $this->quickActionsService->findAll($user->getUID()); + + try { + $exportDestination->addFileContents(self::QUICK_ACTIONS_FILE, + json_encode($quickActions, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export quick actions for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all quick actions the user defined across + * their accounts. + * + * @throws \OCA\Mail\Exception\ServiceException + */ + public function importQuickActions(IUser $user, + IImportSource $importSource, + OutputInterface $output, + array $accountMapping, + array $mailboxMapping, + array $tagMapping): void { + $output->writeln( + $this->l10n->t( + 'Importing quick actions for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + if (count($accountMapping) === 0) { + $output->writeln( + $this->l10n->t( + 'Missing account mapping when importing quick actions for user %s. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $quickActionsFileContent = $importSource->getFileContents(self::QUICK_ACTIONS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Quick actions for user %s not found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $quickActions = json_decode($quickActionsFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateQuickActions($quickActions); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Quick actions configuration for user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + foreach ($quickActions as $quickAction) { + $oldAccountId = $quickAction['accountId']; + + if (!array_key_exists($oldAccountId, $accountMapping)) { + $output->writeln( + $this->l10n->t( + 'Skipping quick action %s because account %s was not imported. Continue …', + [$quickAction['name'], (string)$oldAccountId] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + $actionSteps = $this->mapActionSteps($quickAction['actionSteps'], $mailboxMapping, $tagMapping); + if ($actionSteps === null) { + $output->writeln( + $this->l10n->t( + 'Skipping quick action %s because it references a tag or mailbox that was not imported. Continue …', + [$quickAction['name']] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + try { + $this->atomic(function () use ($quickAction, $accountMapping, $oldAccountId, $actionSteps): void { + $createdQuickAction = $this->quickActionsService->create($quickAction['name'], + $accountMapping[$oldAccountId]); + + foreach ($actionSteps as $actionStep) { + $this->quickActionsService->createActionStep($actionStep['name'], $actionStep['order'], + $createdQuickAction->getId(), $actionStep['tagId'], $actionStep['mailboxId']); + } + }, $this->connection); + } catch (ServiceException $exception) { + $this->logger->warning('Skipping a quick action that could not be recreated', [ + 'exception' => $exception, + ]); + $output->writeln( + $this->l10n->t( + 'Skipping quick action %s because it could not be recreated. Continue …', + [$quickAction['name']] + ), OutputInterface::VERBOSITY_VERBOSE + ); + } + } + } + + /** + * Maps the exported tag and mailbox ids of every action step to the ids they + * got on import. Yields null as soon as one step cannot be recreated: action + * step orders have to be gapless, so a quick action missing a step in the + * middle is rejected by the service anyway. + * + * @param array $mailboxMapping + * @param array $tagMapping + * @return list|null + */ + private function mapActionSteps(array $actionSteps, array $mailboxMapping, array $tagMapping): ?array { + $mappedActionSteps = []; + + foreach ($actionSteps as $actionStep) { + // A newer instance may export step types this one cannot create. + if (!in_array($actionStep['name'], QuickActionsService::AVAILABLE_ACTION_STEPS, true)) { + return null; + } + if ($actionStep['tagId'] !== null && !isset($tagMapping[$actionStep['tagId']])) { + return null; + } + if ($actionStep['mailboxId'] !== null && !isset($mailboxMapping[$actionStep['mailboxId']])) { + return null; + } + + $mappedActionSteps[] = [ + 'name' => $actionStep['name'], + 'order' => $actionStep['order'], + 'tagId' => $tagMapping[$actionStep['tagId']] ?? null, + 'mailboxId' => $mailboxMapping[$actionStep['mailboxId']] ?? null, + ]; + } + + usort($mappedActionSteps, static fn (array $a, array $b): int => $a['order'] <=> $b['order']); + + return $mappedActionSteps; + } + + /** + * Validate the parsed quick actions to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateQuickActions(mixed $quickActions): void { + $quickActionsArrayIsValid = is_array($quickActions) && array_is_list($quickActions); + if (!$quickActionsArrayIsValid) { + throw new UserMigrationException('Invalid quick actions export structure'); + } + + foreach ($quickActions as $quickAction) { + $quickActionArrayIsValid = is_array($quickAction); + + $idIsValid = $quickActionArrayIsValid + && array_key_exists('id', $quickAction) + && is_int($quickAction['id']); + + $nameIsValid = $quickActionArrayIsValid + && array_key_exists('name', $quickAction) + && is_string($quickAction['name']); + + $accountIdIsValid = $quickActionArrayIsValid + && array_key_exists('accountId', $quickAction) + && is_int($quickAction['accountId']); + + $actionStepsArrayIsValid = $quickActionArrayIsValid + && array_key_exists('actionSteps', $quickAction) + && is_array($quickAction['actionSteps']) + && array_is_list($quickAction['actionSteps']) + && $this->validateQuickSteps($quickAction['actionSteps']); + + if ( + !$idIsValid + || !$nameIsValid + || !$accountIdIsValid + || !$actionStepsArrayIsValid + ) { + throw new UserMigrationException('Invalid quick action entry'); + } + } + } + + private function validateQuickSteps(mixed $quickSteps): bool { + $quickStepsArrayIsValid = true; + + foreach ($quickSteps as $actionStep) { + $actionStepArrayIsValid = is_array($actionStep); + + $idIsValid = $actionStepArrayIsValid + && array_key_exists('id', $actionStep) + && is_int($actionStep['id']); + + $nameIsValid = $actionStepArrayIsValid + && array_key_exists('name', $actionStep) + && is_string($actionStep['name']); + + $orderIsValid = $actionStepArrayIsValid + && array_key_exists('order', $actionStep) + && is_int($actionStep['order']); + + $actionIdIsValid = $actionStepArrayIsValid + && array_key_exists('actionId', $actionStep) + && is_int($actionStep['actionId']); + + $tagIdIsValid = $actionStepArrayIsValid + && array_key_exists('tagId', $actionStep) + && (is_int($actionStep['tagId']) || is_null($actionStep['tagId'])); + + $mailboxIdIsValid = $actionStepArrayIsValid + && array_key_exists('mailboxId', $actionStep) + && (is_int($actionStep['mailboxId']) || is_null($actionStep['mailboxId'])); + + $actionStepIsValid = $idIsValid + && $nameIsValid + && $orderIsValid + && $actionIdIsValid + && $tagIdIsValid + && $mailboxIdIsValid; + + $quickStepsArrayIsValid = $quickStepsArrayIsValid && $actionStepIsValid; + } + + return $quickStepsArrayIsValid; + } +} diff --git a/lib/UserMigration/Service/SmimeMigrationService.php b/lib/UserMigration/Service/SmimeMigrationService.php new file mode 100644 index 0000000000..7603d70f9c --- /dev/null +++ b/lib/UserMigration/Service/SmimeMigrationService.php @@ -0,0 +1,209 @@ +smimeService->findAllCertificates($user->getUID()) as $certificate) { + $bytes += self::ESTIMATED_CERTIFICATE_SIZE + + strlen($certificate->getCertificate()) + + strlen((string)$certificate->getPrivateKey()); + } + + return $bytes; + } + + /** + * Export all S/MIME certificates added by the user + * on export. + * + * @throws UserMigrationException + * @throws ServiceException + * @throws Exception + */ + public function exportCertificates(IUser $user, + IExportDestination $exportDestination, + OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting S/MIME certificates for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $certificates = $this->smimeService->findAllCertificates($user->getUID()); + + foreach ($certificates as $certificate) { + $exportContent = [ + 'id' => $certificate->getId(), + 'certificate' => $this->crypto->decrypt($certificate->getCertificate()), + 'privateKey' => $certificate->getPrivateKey() !== null + ? $this->crypto->decrypt((string)$certificate->getPrivateKey()) + : null, + ]; + + try { + $exportDestination->addFileContents( + str_replace( + MailAccountMigrator::FILENAME_PLACEHOLDER, + (string)$certificate->getId(), + self::SMIME_CERTIFICATE_FILES + ), json_encode($exportContent, JSON_THROW_ON_ERROR) + ); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export S/MIME certificates for user {$user->getUID()}", + previous: $exception + ); + } + + } + } + + /** + * Import all S/MIME certificates added by the user. + * + * @return array Contains the old certificate ID as array key and the new + * certificate ID as value. + * + * @throws UserMigrationException + * @throws ServiceException + */ + public function importCertificates(IUser $user, + IImportSource $importSource, + OutputInterface $output): array { + $output->writeln( + $this->l10n->t( + 'Importing S/MIME certificates for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $certificatesMapping = []; + + // The export writes no directory entries, so the folder listing is the only + // way to find out whether anything was exported. + $certificateFileNames = $importSource->getFolderListing(self::SMIME_CERTIFICATE_FOLDER); + + foreach ($certificateFileNames as $certificateFileName) { + if (!str_ends_with($certificateFileName, '.json')) { + continue; + } + + // getFolderListing() returns names relative to the folder + $certificateFilePath = self::SMIME_CERTIFICATE_FOLDER . $certificateFileName; + + try { + $certificate = json_decode($importSource->getFileContents($certificateFilePath), + true, flags: JSON_THROW_ON_ERROR); + $this->validateCertificate($certificate); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'S/MIME configuration %s for user %s is invalid and will be skipped. Continue …', + [$certificateFilePath, $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + try { + $newCertificate = $this->smimeService->createCertificate($user->getUID(), + $certificate['certificate'], $certificate['privateKey']); + } catch (ServiceException|SmimeCertificateParserException) { + $output->writeln( + $this->l10n->t( + 'S/MIME certificate %s for user %s could not be imported and will be skipped. Continue …', + [$certificateFilePath, $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + continue; + } + + $oldCertificateId = $certificate['id']; + $certificatesMapping[$oldCertificateId] = $newCertificate->getId(); + } + + if (count($certificatesMapping) === 0) { + $output->writeln( + $this->l10n->t( + 'No S/MIME certificates for user %s found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + } + + return $certificatesMapping; + } + + /** + * Validate the parsed certificates to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateCertificate(mixed $certificate): void { + $certificateArrayIsValid = is_array($certificate); + + $idIsValid = $certificateArrayIsValid + && array_key_exists('id', $certificate) + && is_int($certificate['id']); + + $certificateNameIsValid = $certificateArrayIsValid + && array_key_exists('certificate', $certificate) + && is_string($certificate['certificate']); + + $privateKeyIsValid = $certificateArrayIsValid + && array_key_exists('privateKey', $certificate) + && (is_string($certificate['privateKey']) || is_null($certificate['privateKey'])); + + if ( + !$idIsValid + || !$certificateNameIsValid + || !$privateKeyIsValid + ) { + throw new UserMigrationException('Invalid certificate entry'); + } + } +} diff --git a/lib/UserMigration/Service/TagsMigrationService.php b/lib/UserMigration/Service/TagsMigrationService.php new file mode 100644 index 0000000000..2832eb94b3 --- /dev/null +++ b/lib/UserMigration/Service/TagsMigrationService.php @@ -0,0 +1,197 @@ +tagMapper->getAllTagsForUser($user->getUID())) * self::ESTIMATED_TAG_SIZE; + } + + /** + * Export all tags the user currently uses. + * + * @throws UserMigrationException + */ + public function exportTags(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting tags for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $tags = $this->tagMapper->getAllTagsForUser($user->getUID()); + + try { + $exportDestination->addFileContents(self::TAGS_FILE, json_encode($tags, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export tags for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all tags the user used on export. + * + * @throws \OCP\DB\Exception + */ + public function importTags(IUser $user, IImportSource $importSource, OutputInterface $output): array { + $output->writeln( + $this->l10n->t( + 'Importing tags for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $tagsFileContent = $importSource->getFileContents(self::TAGS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Tags for user %s not found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return []; + } + + try { + $tags = json_decode($tagsFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateTags($tags); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Tag configuration for user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return []; + } + + $newTags = []; + $existingTagIds = $this->getTagIdsByImapLabel($user->getUID()); + + foreach ($tags as $tag) { + $existingTagId = $existingTagIds[$tag['imapLabel']] ?? null; + if ($existingTagId !== null) { + $output->writeln( + $this->l10n->t( + 'Tag %s already exists for user %s and will be reused. Continue …', + [$tag['imapLabel'], $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $newTags[$tag['id']] = $existingTagId; + + continue; + } + + $newTag = new Tag(); + + $newTag->setUserId($user->getUID()); + $newTag->setDisplayName($tag['displayName']); + $newTag->setImapLabel($tag['imapLabel']); + $newTag->setColor($tag['color']); + $newTag->setIsDefaultTag($tag['isDefaultTag']); + + $newTags[$tag['id']] = $this->tagMapper->insert($newTag)->getId(); + } + + return $newTags; + } + + /** + * A user can only have one tag per IMAP label, so an already existing tag + * has to be reused instead of inserted again. + * + * @return array + */ + private function getTagIdsByImapLabel(string $userId): array { + $tagIds = []; + + foreach ($this->tagMapper->getAllTagsForUser($userId) as $tag) { + $tagIds[$tag->getImapLabel()] = $tag->getId(); + } + + return $tagIds; + } + + /** + * Validate the parsed tags to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateTags(mixed $tags): void { + $tagsArrayIsValid = is_array($tags) && array_is_list($tags); + if (!$tagsArrayIsValid) { + throw new UserMigrationException('Invalid tags export structure'); + } + + foreach ($tags as $tag) { + $tagArrayIsValid = is_array($tag); + + $idIsValid = $tagArrayIsValid + && array_key_exists('id', $tag) + && is_int($tag['id']); + + $displayNameIsValid = $tagArrayIsValid + && array_key_exists('displayName', $tag) + && is_string($tag['displayName']); + + $imapLabelIsValid = $tagArrayIsValid + && array_key_exists('imapLabel', $tag) + && is_string($tag['imapLabel']); + + $colorIsValid = $tagArrayIsValid + && array_key_exists('color', $tag) + && is_string($tag['color']); + + $isDefaultTagIsValid = $tagArrayIsValid + && array_key_exists('isDefaultTag', $tag) + && is_bool($tag['isDefaultTag']); + + if ( + !$idIsValid + || !$displayNameIsValid + || !$imapLabelIsValid + || !$colorIsValid + || !$isDefaultTagIsValid + ) { + throw new UserMigrationException('Invalid tag entry'); + } + } + } +} diff --git a/lib/UserMigration/Service/TextBlocksMigrationService.php b/lib/UserMigration/Service/TextBlocksMigrationService.php new file mode 100644 index 0000000000..a2db85859c --- /dev/null +++ b/lib/UserMigration/Service/TextBlocksMigrationService.php @@ -0,0 +1,155 @@ +textBlockService->findAll($user->getUID()) as $textBlock) { + $bytes += self::ESTIMATED_TEXT_BLOCK_SIZE + + strlen($textBlock->getTitle()) + + strlen($textBlock->getContent()) + + strlen((string)$textBlock->getPreview()); + } + + return $bytes; + } + + /** + * Export all text blocks the user created itself. + * This does not include those shared with others. + * + * @throws UserMigrationException + */ + public function exportTextBlocks(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting text blocks for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $textBlocks = $this->textBlockService->findAll($user->getUID()); + + try { + $exportDestination->addFileContents(self::TEXT_BLOCKS_FILE, json_encode($textBlocks, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export text blocks for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all text blocks the user created itself on export. + * This does not include those shared with others. + */ + public function importTextBlocks(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Importing text blocks for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $textBlocksFileContent = $importSource->getFileContents(self::TEXT_BLOCKS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Text blocks for user %s not found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $textBlocks = json_decode($textBlocksFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateTextBlocks($textBlocks); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Text blocks configuration for user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + foreach ($textBlocks as $textBlock) { + $output->writeln( + $this->l10n->t( + 'Importing text block %s for user %s', + [$textBlock['title'], $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $this->textBlockService->create($user->getUID(), $textBlock['title'], $textBlock['content']); + } + } + + /** + * Validate the parsed text blocks to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateTextBlocks(mixed $textBlocks): void { + $textBlocksArrayIsValid = is_array($textBlocks) && array_is_list($textBlocks); + if (!$textBlocksArrayIsValid) { + throw new UserMigrationException('Invalid text blocks export structure'); + } + + foreach ($textBlocks as $textBlock) { + $textBlockArrayIsValid = is_array($textBlock); + + $titleIsValid = $textBlockArrayIsValid + && array_key_exists('title', $textBlock) + && is_string($textBlock['title']); + + $contentIsValid = $textBlockArrayIsValid + && array_key_exists('content', $textBlock) + && is_string($textBlock['content']); + + if (!$titleIsValid || !$contentIsValid) { + throw new UserMigrationException('Invalid text block entry'); + } + } + } +} diff --git a/lib/UserMigration/Service/TrustedSendersMigrationService.php b/lib/UserMigration/Service/TrustedSendersMigrationService.php new file mode 100644 index 0000000000..1afcedafd4 --- /dev/null +++ b/lib/UserMigration/Service/TrustedSendersMigrationService.php @@ -0,0 +1,141 @@ +trustedSenderService->getTrusted($user->getUID())) * self::ESTIMATED_TRUSTED_SENDER_SIZE; + } + + /** + * Export all addresses the user defined as trustworthy. + * + * @throws UserMigrationException + */ + public function exportTrustedSenders(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Exporting trusted senders for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $trustedSenders = $this->trustedSenderService->getTrusted($user->getUID()); + + try { + $exportDestination->addFileContents(self::TRUSTED_SENDERS_FILE, json_encode($trustedSenders, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException $exception) { + throw new UserMigrationException( + "Failed to export trusted senders configuration for user {$user->getUID()}", + previous: $exception + ); + } + } + + /** + * Import all addresses the user defined as trustworthy + * on export. + */ + public function importTrustedSenders(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln( + $this->l10n->t( + 'Importing trusted senders for user %s', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + try { + $trustedSendersFileContent = $importSource->getFileContents(self::TRUSTED_SENDERS_FILE); + } catch (UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Trusted senders configuration for user %s not found. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + try { + $trustedSenders = json_decode($trustedSendersFileContent, true, flags: JSON_THROW_ON_ERROR); + $this->validateTrustedSenders($trustedSenders); + } catch (JsonException|UserMigrationException) { + $output->writeln( + $this->l10n->t( + 'Trusted senders configuration for user %s is invalid and will be skipped. Continue …', + [$user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + foreach ($trustedSenders as $trustedSender) { + $output->writeln( + $this->l10n->t( + 'Importing trusted sender %s for user %s', + [$trustedSender['email'], $user->getUID()] + ), OutputInterface::VERBOSITY_VERBOSE + ); + + $this->trustedSenderService->trust($user->getUID(), $trustedSender['email'], $trustedSender['type']); + } + } + + /** + * Validate the parsed trusted senders to ensure they + * have the expected structure and types. + * + * @throws UserMigrationException + */ + private function validateTrustedSenders(mixed $trustedSenders): void { + $trustedSendersArrayIsValid = is_array($trustedSenders) && array_is_list($trustedSenders); + if (!$trustedSendersArrayIsValid) { + throw new UserMigrationException('Invalid trusted senders export structure'); + } + + foreach ($trustedSenders as $trustedSender) { + $trustedSenderArrayIsValid = is_array($trustedSender); + + $emailIsValid = $trustedSenderArrayIsValid + && array_key_exists('email', $trustedSender) + && is_string($trustedSender['email']); + + $typeIsValid = $trustedSenderArrayIsValid + && array_key_exists('type', $trustedSender) + && is_string($trustedSender['type']); + + if (!$emailIsValid || !$typeIsValid) { + throw new UserMigrationException('Invalid trusted sender entry'); + } + } + } +} diff --git a/tests/Integration/Db/AliasMapperTest.php b/tests/Integration/Db/AliasMapperTest.php index 96124ac031..e9fe6ad054 100644 --- a/tests/Integration/Db/AliasMapperTest.php +++ b/tests/Integration/Db/AliasMapperTest.php @@ -85,6 +85,48 @@ public function testFind() { ); } + public function testCountByAccountId() { + $account = $this->insertAccount(); + + self::assertSame(0, $this->mapper->countByAccountId($account->getId(), $account->getUserId())); + + foreach (['one', 'two'] as $name) { + $alias = new Alias(); + $alias->setAccountId($account->getId()); + $alias->setAlias("$name@marvel.com"); + $alias->setName($name); + $this->mapper->insert($alias); + } + + self::assertSame(2, $this->mapper->countByAccountId($account->getId(), $account->getUserId())); + } + + public function testCountByAccountIdIgnoresOtherUsers() { + $account = $this->insertAccount(); + + $alias = new Alias(); + $alias->setAccountId($account->getId()); + $alias->setAlias('alias@marvel.com'); + $alias->setName('alias'); + $this->mapper->insert($alias); + + self::assertSame(0, $this->mapper->countByAccountId($account->getId(), 'someone-else')); + } + + private function insertAccount(string $userId = 'user12345'): MailAccount { + $account = new MailAccount(); + $account->setName('Peter Parker'); + $account->setInboundHost('mail.marvel.com'); + $account->setInboundPort(159); + $account->setInboundUser('spiderman'); + $account->setInboundPassword('xxxxxxxx'); + $account->setInboundSslMode('tls'); + $account->setEmail('peter.parker@marvel.com'); + $account->setUserId($userId); + + return (new MailAccountMapper($this->db))->insert($account); + } + public function testDeleteProvisionedAliasesByUid() { $accountMapper = new MailAccountMapper($this->db); diff --git a/tests/Integration/Db/MailboxMapperTest.php b/tests/Integration/Db/MailboxMapperTest.php index 09cabd12ec..0c438b0159 100644 --- a/tests/Integration/Db/MailboxMapperTest.php +++ b/tests/Integration/Db/MailboxMapperTest.php @@ -82,6 +82,38 @@ public function testFindAll() { $this->assertCount(5, $result); } + public function testCountByAccountNoData() { + $account = $this->createMock(Account::class); + $account->method('getId')->willReturn(13); + + $count = $this->mapper->countByAccount($account); + + $this->assertSame(0, $count); + } + + public function testCountByAccountOnlyCountsItsOwnAccount() { + $account = $this->createMock(Account::class); + $account->method('getId')->willReturn(13); + foreach (range(1, 10) as $i) { + $qb = $this->db->getQueryBuilder(); + $insert = $qb->insert($this->mapper->getTableName()) + ->values([ + 'name' => $qb->createNamedParameter("folder$i"), + 'account_id' => $qb->createNamedParameter($i <= 5 ? 13 : 14, IQueryBuilder::PARAM_INT), + 'delimiter' => $qb->createNamedParameter('.'), + 'messages' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'unseen' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'selectable' => $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL), + 'name_hash' => $qb->createNamedParameter(md5("folder$i")), + ]); + $insert->executeStatement(); + } + + $count = $this->mapper->countByAccount($account); + + $this->assertSame(5, $count); + } + public function testNoInboxFound() { /** @var Account|MockObject $account */ $account = $this->createMock(Account::class); diff --git a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php index a785966ec2..ed0bfd0470 100644 --- a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php +++ b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php @@ -13,8 +13,14 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use ChristophWurst\Nextcloud\Testing\TestUser; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MailboxMapper; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\AliasesService; use OCA\Mail\UserMigration\MailAccountMigrator; +use OCA\Mail\UserMigration\Service\AccountMigrationService; +use OCP\IUser; +use OCP\Security\ICrypto; use OCP\Server; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; @@ -28,54 +34,234 @@ class MailAccountMigratorIntegrationTest extends TestCase { use TestUser; private AccountService $accountService; - + private AliasesService $aliasesService; + private MailboxMapper $mailboxMapper; private MailAccountMigrator $migrator; + private OutputInterface $output; + + /** @var array */ + private array $archive = []; protected function setUp(): void { parent::setUp(); $this->accountService = Server::get(AccountService::class); + $this->aliasesService = Server::get(AliasesService::class); + $this->mailboxMapper = Server::get(MailboxMapper::class); $this->migrator = Server::get(MailAccountMigrator::class); + $this->output = $this->createStub(OutputInterface::class); + } + + public function testMigrateAccountWithMailboxesAndAliases(): void { + $sourceUser = $this->createTestUser(); + $destinationUser = $this->createTestUser(); + + $sourceAccount = $this->createAccount($sourceUser); + $this->createMailbox($sourceAccount, 'INBOX'); + $this->createMailbox($sourceAccount, 'Sent'); + $this->aliasesService->create( + $sourceUser->getUID(), + $sourceAccount->getId(), + 'alias@domain.tld', + 'Alias', + 'Kind regards', + ); + + $this->migrator->export($sourceUser, $this->exportDestination(), $this->output); + $this->migrator->import($destinationUser, $this->importSource(), $this->output); + + $importedAccounts = $this->accountService->findByUserId($destinationUser->getUID()); + self::assertCount(1, $importedAccounts); + + $importedAccount = $importedAccounts[0]; + self::assertSame($sourceAccount->getEmail(), $importedAccount->getEmail()); + self::assertSame($destinationUser->getUID(), $importedAccount->getUserId()); + self::assertNotSame($sourceAccount->getId(), $importedAccount->getId()); + + $importedMailboxes = $this->mailboxMapper->findAll($importedAccount); + self::assertCount(2, $importedMailboxes); + self::assertEqualsCanonicalizing( + ['INBOX', 'Sent'], + array_map(static fn (Mailbox $mailbox): string => $mailbox->getName(), $importedMailboxes), + ); + + $importedAliases = $this->aliasesService->findAll($importedAccount->getId(), $destinationUser->getUID()); + self::assertCount(1, $importedAliases); + self::assertSame('alias@domain.tld', $importedAliases[0]->getAlias()); + self::assertSame('Kind regards', $importedAliases[0]->getSignature()); } - public function testMigrate(): void { + public function testMigrateUserWithoutAccounts(): void { $sourceUser = $this->createTestUser(); $destinationUser = $this->createTestUser(); - $mailAccount = new MailAccount([]); - $mailAccount->setUserId($sourceUser->getUID()); - $this->accountService->save($mailAccount); - $exportContents = []; + $this->migrator->export($sourceUser, $this->exportDestination(), $this->output); + + // The archive has to be written even with nothing to migrate, otherwise + // the assertion below would hold before import() has done anything. + self::assertNotEmpty($this->archive); + self::assertSame( + [], + array_filter(array_keys($this->archive), static fn (string $path): bool + => str_starts_with($path, AccountMigrationService::ACCOUNT_FOLDER)), + ); + + $this->migrator->import($destinationUser, $this->importSource(), $this->output); + + self::assertCount(0, $this->accountService->findByUserId($destinationUser->getUID())); + } + + public function testImportsAVersionOneArchive(): void { + $destinationUser = $this->createTestUser(); + + // exactly what migrator version 1 wrote: an index of full paths, and per + // account `MailAccount::toJson()` without any mailbox ids + $this->archive = [ + 'mail/accounts/index.json' => json_encode([19 => 'mail/accounts/19.json']), + 'mail/accounts/19.json' => json_encode([ + 'id' => 19, + 'accountId' => 19, + 'name' => 'Legacy Account', + 'emailAddress' => 'legacy@domain.tld', + 'authMethod' => 'password', + 'imapHost' => 'imap.domain.tld', + 'imapPort' => 993, + 'imapUser' => 'legacy@domain.tld', + 'imapSslMode' => 'ssl', + 'inboundPassword' => 'legacy-imap-secret', + 'smtpHost' => 'smtp.domain.tld', + 'smtpPort' => 465, + 'smtpUser' => 'legacy@domain.tld', + 'smtpSslMode' => 'ssl', + 'outboundPassword' => 'legacy-smtp-secret', + 'signature' => 'Sent from a v1 export', + 'sieveEnabled' => false, + 'smimeCertificateId' => 77, + 'aliases' => [ + ['id' => 5, 'name' => 'Legacy Alias', 'alias' => 'legacy-alias@domain.tld', + 'signature' => 'Alias signature', 'provisioned' => false, + 'signatureMode' => 0, 'smimeCertificateId' => null], + ], + ]), + ]; + + $this->migrator->import($destinationUser, $this->legacyImportSource(), $this->output); + + $accounts = $this->accountService->findByUserId($destinationUser->getUID()); + self::assertCount(1, $accounts); + + $imported = $accounts[0]->getMailAccount(); + self::assertSame('legacy@domain.tld', $imported->getEmail()); + self::assertSame('imap.domain.tld', $imported->getInboundHost()); + self::assertSame('Sent from a v1 export', $imported->getSignature()); + self::assertSame( + 'legacy-imap-secret', + Server::get(ICrypto::class)->decrypt($imported->getInboundPassword()), + ); + + // version 1 never exported certificates, so the id it carries is dangling + self::assertNull($imported->getSmimeCertificateId()); + self::assertNull($imported->getSentMailboxId()); + self::assertCount(0, $this->mailboxMapper->findAll($accounts[0])); + + $aliases = $this->aliasesService->findAll($imported->getId(), $destinationUser->getUID()); + self::assertCount(1, $aliases); + self::assertSame('legacy-alias@domain.tld', $aliases[0]->getAlias()); + self::assertSame('Alias signature', $aliases[0]->getSignature()); + } + + private function legacyImportSource(): IImportSource { + $importSource = $this->importSource(); + $importSource->method('getMigratorVersion')->willReturn(MailAccountMigrator::LEGACY_VERSION); + + return $importSource; + } + + public function testEstimatedExportSizeGrowsWithTheAccount(): void { + $user = $this->createTestUser(); + $emptySize = $this->migrator->getEstimatedExportSize($user); + + $account = $this->createAccount($user); + $this->createMailbox($account, 'INBOX'); + + self::assertGreaterThan($emptySize, $this->migrator->getEstimatedExportSize($user)); + } + + private function createAccount(IUser $user): MailAccount { + $account = new MailAccount(); + $account->setUserId($user->getUID()); + $account->setName('Test Account'); + $account->setEmail('test@domain.tld'); + $account->setAuthMethod('password'); + $account->setInboundHost('imap.domain.tld'); + $account->setInboundPort(993); + $account->setInboundSslMode('ssl'); + $account->setInboundUser('test@domain.tld'); + $account->setOutboundHost('smtp.domain.tld'); + $account->setOutboundPort(465); + $account->setOutboundSslMode('ssl'); + $account->setOutboundUser('test@domain.tld'); + + return $this->accountService->save($account, false); + } + + private function createMailbox(MailAccount $account, string $name): void { + $mailbox = new Mailbox(); + $mailbox->setAccountId($account->getId()); + $mailbox->setName($name); + $mailbox->setNameHash(md5($name)); + $mailbox->setAttributes('[]'); + $mailbox->setSpecialUse('[]'); + $mailbox->setDelimiter('.'); + $mailbox->setMessages(0); + $mailbox->setUnseen(0); + $mailbox->setSelectable(true); + $mailbox->setSyncInBackground(true); + $mailbox->setShared(false); + + $this->mailboxMapper->insert($mailbox); + } + + private function exportDestination(): IExportDestination { $exportDestination = $this->createMock(IExportDestination::class); $exportDestination->method('addFileContents') - ->willReturnCallback(function (string $path, string $contents) use (&$exportContents) { - $exportContents[$path] = $contents; + ->willReturnCallback(function (string $path, string $contents): void { + $this->archive[$path] = $contents; }); + + return $exportDestination; + } + + /** + * Mirrors the archive semantics the real import source has: folder listings + * yield names relative to the folder, and only files exist as entries. + */ + private function importSource(): IImportSource { $importSource = $this->createMock(IImportSource::class); $importSource->method('getFileContents') - ->willReturnCallback(function (string $path) use (&$exportContents) { - if (!array_key_exists($path, $exportContents)) { - $availableFiles = join(', ', array_keys($exportContents)); - throw new UserMigrationException("File contents for {$path} not found. Available: {$availableFiles}"); + ->willReturnCallback(function (string $path): string { + if (!array_key_exists($path, $this->archive)) { + $available = implode(', ', array_keys($this->archive)); + throw new UserMigrationException("File contents for $path not found. Available: $available"); } - return $exportContents[$path]; + return $this->archive[$path]; + }); + // ZIP::fileExists() locates an exact entry, and the export writes no + // directory entries, so a folder path never exists in a real archive. + $importSource->method('pathExists') + ->willReturnCallback(fn (string $path): bool => array_key_exists($path, $this->archive)); + $importSource->method('getFolderListing') + ->willReturnCallback(function (string $path): array { + $names = []; + foreach (array_keys($this->archive) as $archivedPath) { + if (str_starts_with($archivedPath, $path)) { + $names[] = substr($archivedPath, strlen($path)); + } + } + return $names; }); - $output = $this->createStub(OutputInterface::class); - - $this->migrator->export( - $sourceUser, - $exportDestination, - $output, - ); - $this->migrator->import( - $destinationUser, - $importSource, - $output, - ); - - $destinationAccoutns = $this->accountService->findByUserId($destinationUser->getUID()); - self::assertCount(1, $destinationAccoutns); + return $importSource; } } diff --git a/tests/Unit/Service/AliasesServiceTest.php b/tests/Unit/Service/AliasesServiceTest.php index fa550a3e05..c6a1d4bbf4 100644 --- a/tests/Unit/Service/AliasesServiceTest.php +++ b/tests/Unit/Service/AliasesServiceTest.php @@ -103,6 +103,52 @@ public function testCreate(): void { $this->assertEquals($entity->getName(), $result->getName()); } + public function testCreateWithSignatureAndCertificate(): void { + $this->mailAccountMapper->expects(self::once()) + ->method('find'); + + $this->aliasMapper->expects(self::once()) + ->method('insert') + ->willReturnCallback(static function (Alias $alias) { + $alias->setId(100); + return $alias; + }); + + $result = $this->service->create( + $this->user, + 200, + 'jane@doe.com', + 'Jane Doe', + 'Kind regards', + 42 + ); + + $this->assertEquals('Kind regards', $result->getSignature()); + $this->assertEquals(42, $result->getSmimeCertificateId()); + } + + public function testCreateWithoutSignatureAndCertificate(): void { + $this->mailAccountMapper->expects(self::once()) + ->method('find'); + + $this->aliasMapper->expects(self::once()) + ->method('insert') + ->willReturnCallback(static function (Alias $alias) { + $alias->setId(100); + return $alias; + }); + + $result = $this->service->create( + $this->user, + 200, + 'jane@doe.com', + 'Jane Doe' + ); + + $this->assertNull($result->getSignature()); + $this->assertNull($result->getSmimeCertificateId()); + } + public function testCreateForbiddenAccountId(): void { $this->expectException(DoesNotExistException::class); diff --git a/tests/Unit/UserMigration/MailAccountMigratorTest.php b/tests/Unit/UserMigration/MailAccountMigratorTest.php index ac7d9c2807..8ca3e70f66 100644 --- a/tests/Unit/UserMigration/MailAccountMigratorTest.php +++ b/tests/Unit/UserMigration/MailAccountMigratorTest.php @@ -4,29 +4,36 @@ /** * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-only + * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit; +namespace Unit\UserMigration; use ChristophWurst\Nextcloud\Testing\ServiceMockObject; use ChristophWurst\Nextcloud\Testing\TestCase; -use Exception; -use OCA\Mail\Account; -use OCA\Mail\Db\MailAccount; -use OCA\Mail\Service\AccountService; +use OCA\Mail\Exception\ServiceException; use OCA\Mail\UserMigration\MailAccountMigrator; use OCP\IUser; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; -use OCP\UserMigration\UserMigrationException; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Output\OutputInterface; -use function json_decode; -use function json_encode; -use function substr; class MailAccountMigratorTest extends TestCase { + private const EXPORT_DELEGATIONS = [ + 'appConfigMigrationService' => 'exportAppConfiguration', + 'internalAddressesMigrationService' => 'exportInternalAddresses', + 'trustedSendersMigrationService' => 'exportTrustedSenders', + 'textBlocksMigrationService' => 'exportTextBlocks', + 'tagsMigrationService' => 'exportTags', + 'smimeMigrationService' => 'exportCertificates', + 'accountMigrationService' => 'exportAccounts', + 'quickActionsMigrationService' => 'exportQuickActions', + ]; + private const TAGS_MAPPING = [1 => 11]; + private const CERTIFICATES_MAPPING = [2 => 22]; + private const ACCOUNTS_MAPPING = [3 => 33]; + private const MAILBOXES_MAPPING = [4 => 44]; private MailAccountMigrator $migrator; @@ -41,17 +48,6 @@ protected function setUp(): void { $this->serviceMock->getParameter('l10n') ->method('t') ->willReturnArgument(0); - $this->serviceMock->getParameter('crypto') - ->method('encrypt') - ->willReturnCallback(fn (string $value) => $value . '_encrypted'); - $this->serviceMock->getParameter('crypto') - ->method('decrypt') - ->willReturnCallback(function (string $encryptedValue) { - if (!str_ends_with($encryptedValue, '_encrypted')) { - throw new Exception('Invalid encrypted value'); - } - return substr($encryptedValue, 0, strlen($encryptedValue) - strlen('_encrypted')); - }); $this->migrator = $this->serviceMock->getService(); $this->output = $this->createMock(OutputInterface::class); @@ -78,7 +74,7 @@ public function testGetDescription(): void { public function testGetVersion(): void { $version = $this->migrator->getVersion(); - self::assertGreaterThanOrEqual(01_00_00, $version); + self::assertSame(MailAccountMigrator::VERSION, $version); } public function testCantImportNewer(): void { @@ -103,137 +99,229 @@ public function testCanImportSame(): void { self::assertTrue($canImport); } - public function testCanImportOlder(): void { - $importSource = $this->createMock(IImportSource::class); - $importSource->method('getMigratorVersion') - ->with('mail_account') - ->willReturn($this->migrator->getVersion() - 00_00_01); + public function testExportDelegatesToEveryMigrationService(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('jane'); + $exportDestination = $this->createMock(IExportDestination::class); - $canImport = $this->migrator->canImport($importSource); + $calls = []; + foreach (self::EXPORT_DELEGATIONS as $parameter => $method) { + $this->serviceMock->getParameter($parameter) + ->expects(self::once()) + ->method($method) + ->willReturnCallback(function () use (&$calls, $method) { + $calls[] = $method; + return []; + }); + } - self::assertTrue($canImport); + $this->migrator->export($user, $exportDestination, $this->output); + + self::assertSame(array_values(self::EXPORT_DELEGATIONS), $calls); } - public function testExportBasicAccountInfo(): void { + public function testImportDelegatesInDependencyOrderAndThreadsMappings(): void { $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('user_export'); - $mailAccount1 = new MailAccount([]); - $account1 = $this->createMock(Account::class); - $account1->method('getId')->willReturn(101); - $account1->method('getUserId')->willReturn('user_export'); - $account1->method('getMailAccount')->willReturn($mailAccount1); - $mailAccount1->setAuthMethod('password'); - $mailAccount1->setInboundPassword('imap_pass_encrypted'); - $account1->method('jsonSerialize')->willReturn([ - 'id' => 101, - 'email' => 'jane@doe.org', - ]); - $mailAccount2 = new MailAccount([]); - $account2 = $this->createMock(Account::class); - $account2->method('getId')->willReturn(102); - $account2->method('getUserId')->willReturn('user_export'); - $account2->method('getMailAccount')->willReturn($mailAccount2); - $mailAccount2->setAuthMethod('password'); - $mailAccount2->setInboundPassword('imap_pass_encrypted'); - $account2->method('jsonSerialize')->willReturn([ - 'id' => 102, - 'email' => 'jane@doe.com', - ]); - /** @var AccountService|MockObject $accountService */ - $accountService = $this->serviceMock->getParameter('accountService'); - $accountService->expects(self::once()) - ->method('findByUserId') - ->with('user_export') - ->willReturn([ - $account1, - $account2, - ]); - $exportDestination = $this->createMock(IExportDestination::class); - $exportDestination->method('addFileContents') - ->willReturnCallback(function (string $path, string $content) { - if ($path === 'mail/accounts/index.json') { - self::assertSame( - [ - 101 => 'mail/accounts/101.json', - 102 => 'mail/accounts/102.json', - ], - json_decode($content, true) - ); - } elseif ($path === 'mail/accounts/101.json') { - $accountData = json_decode($content, true); - self::assertArrayHasKey('id', $accountData); - self::assertSame(101, $accountData['id']); - self::assertArrayHasKey('inboundPassword', $accountData); - self::assertSame('imap_pass', $accountData['inboundPassword']); - } elseif ($path === 'mail/accounts/102.json') { - $accountData = json_decode($content, true); - self::assertArrayHasKey('id', $accountData); - self::assertSame(102, $accountData['id']); - self::assertArrayHasKey('inboundPassword', $accountData); - self::assertSame('imap_pass', $accountData['inboundPassword']); - } else { - $this->fail('Invalid file content path ' . $path); - } + $user->method('getUID')->willReturn('jane'); + $importSource = $this->createMock(IImportSource::class); + + $calls = []; + $this->serviceMock->getParameter('internalAddressesMigrationService') + ->method('importInternalAddresses') + ->willReturnCallback(function () use (&$calls): void { + $calls[] = 'importInternalAddresses'; + }); + $this->serviceMock->getParameter('trustedSendersMigrationService') + ->method('importTrustedSenders') + ->willReturnCallback(function () use (&$calls): void { + $calls[] = 'importTrustedSenders'; + }); + $this->serviceMock->getParameter('textBlocksMigrationService') + ->method('importTextBlocks') + ->willReturnCallback(function () use (&$calls): void { + $calls[] = 'importTextBlocks'; + }); + $this->serviceMock->getParameter('tagsMigrationService') + ->method('importTags') + ->willReturnCallback(function () use (&$calls): array { + $calls[] = 'importTags'; + return self::TAGS_MAPPING; + }); + $this->serviceMock->getParameter('smimeMigrationService') + ->method('importCertificates') + ->willReturnCallback(function () use (&$calls): array { + $calls[] = 'importCertificates'; + return self::CERTIFICATES_MAPPING; }); - $this->migrator->export( - $user, - $exportDestination, - $this->output, - ); - } + $this->serviceMock->getParameter('accountMigrationService') + ->expects(self::once()) + ->method('importAccounts') + ->willReturnCallback(function ($actualUser, $actualSource, $actualOutput, array $certificatesMapping) use (&$calls): array { + $calls[] = 'importAccounts'; + self::assertSame(self::CERTIFICATES_MAPPING, $certificatesMapping); + return ['accounts' => self::ACCOUNTS_MAPPING, 'mailboxes' => self::MAILBOXES_MAPPING]; + }); - public function testImportInvalidIndex(): void { - $this->expectException(UserMigrationException::class); - $user = $this->createStub(IUser::class); + $this->serviceMock->getParameter('appConfigMigrationService') + ->expects(self::once()) + ->method('importAppConfiguration') + ->willReturnCallback(function ($actualUser, $actualSource, $actualOutput, array $accountsMapping, array $mailboxesMapping) use (&$calls): void { + $calls[] = 'importAppConfiguration'; + self::assertSame(self::ACCOUNTS_MAPPING, $accountsMapping); + self::assertSame(self::MAILBOXES_MAPPING, $mailboxesMapping); + }); - $importSource = $this->createMock(IImportSource::class); - $importSource->method('getFileContents') - ->with('mail/accounts/index.json') - ->willReturn('fail'); - - $this->migrator->import( - $user, - $importSource, - $this->output, - ); + $this->serviceMock->getParameter('quickActionsMigrationService') + ->expects(self::once()) + ->method('importQuickActions') + ->willReturnCallback(function ($actualUser, + $actualSource, + $actualOutput, + array $accountsMapping, + array $mailboxesMapping, + array $tagsMapping) use (&$calls): void { + $calls[] = 'importQuickActions'; + self::assertSame(self::ACCOUNTS_MAPPING, $accountsMapping); + self::assertSame(self::MAILBOXES_MAPPING, $mailboxesMapping); + self::assertSame(self::TAGS_MAPPING, $tagsMapping); + }); + + $this->serviceMock->getParameter('accountMigrationService') + ->expects(self::once()) + ->method('scheduleBackgroundJobs') + ->willReturnCallback(function (array $accountsMapping) use (&$calls): void { + $calls[] = 'scheduleBackgroundJobs'; + self::assertSame(self::ACCOUNTS_MAPPING, $accountsMapping); + }); + + $this->migrator->import($user, $importSource, $this->output); + + self::assertSame([ + 'importInternalAddresses', + 'importTrustedSenders', + 'importTextBlocks', + 'importTags', + 'importCertificates', + 'importAccounts', + 'importAppConfiguration', + 'importQuickActions', + 'scheduleBackgroundJobs', + ], $calls); } - public function testImportBasicAccountInfo(): void { + public function testEstimatedExportSizeSumsEveryServiceAndReturnsKib(): void { $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('user_import'); - $accountData = [ - 'id' => 101, - 'userId' => 'user_export', - 'name' => 'Jane Doe', - 'email' => 'jane@doe.org', - 'authMethod' => 'password', - 'aliases' => [], + $user->method('getUID')->willReturn('jane'); + + $services = [ + 'appConfigMigrationService' => 100, + 'internalAddressesMigrationService' => 200, + 'trustedSendersMigrationService' => 300, + 'textBlocksMigrationService' => 400, + 'tagsMigrationService' => 500, + 'smimeMigrationService' => 600, + 'accountMigrationService' => 700, + 'quickActionsMigrationService' => 800, ]; + foreach ($services as $parameter => $bytes) { + $this->serviceMock->getParameter($parameter) + ->expects(self::once()) + ->method('getEstimatedExportSize') + ->with($user) + ->willReturn($bytes); + } + + $size = $this->migrator->getEstimatedExportSize($user); + + self::assertSame(ceil(array_sum($services) / 1024), $size); + } + + public function testEstimatedExportSizeForUserWithoutData(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('jane'); + + foreach (self::EXPORT_DELEGATIONS as $parameter => $method) { + $this->serviceMock->getParameter($parameter) + ->method('getEstimatedExportSize') + ->willReturn(0); + } + + $size = $this->migrator->getEstimatedExportSize($user); + + self::assertSame(0.0, $size); + } + + public function testImportRoutesVersionOneArchivesToTheLegacyImporter(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('jane'); $importSource = $this->createMock(IImportSource::class); - $importSource->method('getFileContents') - ->willReturnMap([ - ['mail/accounts/index.json', json_encode([101 => 'mail/accounts/101.json'])], - ['mail/accounts/101.json', json_encode($accountData)], - ]); - $newAccount = new MailAccount([]); - $newAccount->setUserId('user_import'); - $newAccount->setName('Jane Doe'); - $newAccount->setAuthMethod('password'); - $newAccount->setEditorMode('plain'); - $newAccount->setClassificationEnabled(false); - /** @var AccountService|MockObject $accountService */ - $accountService = $this->serviceMock->getParameter('accountService'); - $accountService->expects(self::once()) - ->method('save') - ->with(self::equalTo($newAccount)) - ->willReturnArgument(0); + $importSource->method('getMigratorVersion') + ->with('mail_account') + ->willReturn(MailAccountMigrator::LEGACY_VERSION); + + $this->serviceMock->getParameter('legacyAccountMigrationService') + ->expects(self::once()) + ->method('importAccounts') + ->with($user, $importSource, $this->output) + ->willReturn(self::ACCOUNTS_MAPPING); + $this->serviceMock->getParameter('accountMigrationService') + ->expects(self::once()) + ->method('scheduleBackgroundJobs') + ->with(self::ACCOUNTS_MAPPING); + + // the current-format services must stay out of it + $this->serviceMock->getParameter('accountMigrationService') + ->expects(self::never()) + ->method('importAccounts'); + foreach (['appConfigMigrationService', 'tagsMigrationService', 'smimeMigrationService', + 'quickActionsMigrationService'] as $parameter) { + $this->serviceMock->getParameter($parameter) + ->expects(self::never()) + ->method(self::anything()); + } + + $this->migrator->import($user, $importSource, $this->output); + } - $this->migrator->import( - $user, - $importSource, - $this->output, - ); + public function testCanImportVersionOne(): void { + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('mail_account') + ->willReturn(MailAccountMigrator::LEGACY_VERSION); + + self::assertTrue($this->migrator->canImport($importSource)); } + public function testCanImportArchiveWithoutMailData(): void { + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('mail_account') + ->willReturn(null); + + $canImport = $this->migrator->canImport($importSource); + + self::assertTrue($canImport); + } + + public function testImportSchedulesBackgroundJobsWhenALaterStepFails(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('jane'); + + $this->serviceMock->getParameter('accountMigrationService') + ->method('importAccounts') + ->willReturn(['accounts' => self::ACCOUNTS_MAPPING, 'mailboxes' => self::MAILBOXES_MAPPING]); + $this->serviceMock->getParameter('quickActionsMigrationService') + ->method('importQuickActions') + ->willThrowException(new ServiceException('quick action import failed')); + + $this->serviceMock->getParameter('accountMigrationService') + ->expects(self::once()) + ->method('scheduleBackgroundJobs') + ->with(self::ACCOUNTS_MAPPING); + + $this->expectException(ServiceException::class); + + $this->migrator->import($user, $this->createMock(IImportSource::class), $this->output); + } } diff --git a/tests/Unit/UserMigration/Service/AccountMigrationServiceTest.php b/tests/Unit/UserMigration/Service/AccountMigrationServiceTest.php new file mode 100644 index 0000000000..6061b02b06 --- /dev/null +++ b/tests/Unit/UserMigration/Service/AccountMigrationServiceTest.php @@ -0,0 +1,1029 @@ +output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(AccountMigrationService::class); + + $this->serviceMock->getParameter('crypto') + ->method('encrypt') + ->willReturnCallback(function (string $value) { + return $value . '_encrypted'; + }); + + $this->serviceMock->getParameter('crypto') + ->method('decrypt') + ->willReturnCallback(function (string $encryptedValue) { + if (!str_ends_with($encryptedValue, '_encrypted')) { + throw new Exception('Invalid encrypted value'); + } + return substr($encryptedValue, 0, strlen($encryptedValue) - strlen('_encrypted')); + }); + + $this->migrationService = $this->serviceMock->getService(); + } + + public function testExportsAccountWithDecryptedCredentials(): void { + $account = new Account($this->getExportableMailAccount()); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([$account]); + $this->serviceMock->getParameter('mailboxMapper') + ->method('findAll') + ->with($account) + ->willReturn([$this->getInboxMailbox()]); + $this->serviceMock->getParameter('aliasesService') + ->method('findAll') + ->with(self::EXPORTED_ACCOUNT_ID, self::USER_ID) + ->willReturn([$this->getAlias()]); + + $exportedFiles = []; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->willReturnCallback(function (string $path, string $contents) use (&$exportedFiles): void { + $exportedFiles[$path] = $contents; + }); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + + $expectedPath = AccountMigrationService::ACCOUNT_FOLDER . self::EXPORTED_ACCOUNT_ID . '.json'; + self::assertArrayHasKey($expectedPath, $exportedFiles); + $exported = json_decode($exportedFiles[$expectedPath], true, flags: JSON_THROW_ON_ERROR); + self::assertSame('imap_secret', $exported['inboundPassword']); + self::assertSame('smtp_secret', $exported['outboundPassword']); + self::assertSame('sieve_secret', $exported['sievePassword']); + self::assertSame(self::EXPORTED_ACCOUNT_ID, $exported['accountId']); + } + + public function testExportSkipsProvisionedAccounts(): void { + $mailAccount = $this->getExportableMailAccount(); + $mailAccount->setProvisioningId(7); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([new Account($mailAccount)]); + + $this->exportDestination->expects(self::never()) + ->method('addFileContents'); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + } + + public function testEstimatedExportSizeGrowsWithMailboxesAndAliases(): void { + $account = new Account($this->getExportableMailAccount()); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([$account]); + $this->serviceMock->getParameter('mailboxMapper') + ->expects(self::once()) + ->method('countByAccount') + ->with($account) + ->willReturn(2); + $this->serviceMock->getParameter('aliasesService') + ->expects(self::once()) + ->method('countByAccountId') + ->with(self::EXPORTED_ACCOUNT_ID, self::USER_ID) + ->willReturn(1); + $this->serviceMock->getParameter('mailboxMapper') + ->expects(self::never()) + ->method('findAll'); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame( + AccountMigrationService::ESTIMATED_ACCOUNT_SIZE + + 2 * AccountMigrationService::ESTIMATED_MAILBOX_SIZE + + AccountMigrationService::ESTIMATED_ALIAS_SIZE, + $size, + ); + } + + public function testEstimatedExportSizeIgnoresAccountsThatAreNotExported(): void { + $provisioned = $this->getExportableMailAccount(); + $provisioned->setProvisioningId(7); + $jmap = $this->getExportableMailAccount(); + $jmap->setProtocol(MailAccount::PROTOCOL_JMAP); + + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([new Account($provisioned), new Account($jmap)]); + $this->serviceMock->getParameter('mailboxMapper') + ->expects(self::never()) + ->method('countByAccount'); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(0, $size); + } + + public function testExportSkipsAccountsWithUnsupportedProtocol(): void { + $mailAccount = $this->getExportableMailAccount(); + $mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([new Account($mailAccount)]); + + $this->exportDestination->expects(self::never()) + ->method('addFileContents'); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + } + + public function testImportSkipsAccountsWithUnsupportedProtocol(): void { + $accountData = $this->getExportedAccountData(); + $accountData['protocol'] = MailAccount::PROTOCOL_JMAP; + $this->expectAccountFileToBeImported($accountData); + + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('save'); + $this->serviceMock->getParameter('connection') + ->expects(self::never()) + ->method('beginTransaction'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public function testExportRetainsAllMailboxColumnsRestoredOnImport(): void { + $account = new Account($this->getExportableMailAccount()); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->willReturn([$account]); + $this->serviceMock->getParameter('mailboxMapper') + ->method('findAll') + ->willReturn([$this->getInboxMailbox()]); + $this->serviceMock->getParameter('aliasesService') + ->method('findAll') + ->willReturn([]); + + $exportedContents = null; + $this->exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $contents) use (&$exportedContents): void { + $exportedContents = $contents; + }); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + + $exported = json_decode($exportedContents, true, flags: JSON_THROW_ON_ERROR); + self::assertEqualsCanonicalizing( + ['databaseId', 'name', 'attributes', 'delimiter', 'specialUse', 'selectable', 'syncInBackground', 'myAcls', 'shared'], + array_keys($exported['mailboxes'][0]), + ); + } + + /** + * `serialiseAccount()` is a hand-written list, so a column added to + * `MailAccount` would silently stop being migrated. Every persisted property + * has to be either exported or listed below with a reason. + */ + private const NOT_EXPORTED = [ + // belongs to the importing user, never to the archive + 'userId', + // owned by the admins of the target instance + 'provisioningId', + // sync and runtime state, recomputed after the first sync + 'lastMailboxSync', + 'quotaPercentage', + // a per-account diagnostic toggle, not user data + 'debug', + // only ever set on JMAP accounts, which export skips + 'path', + ]; + + /** Archive keys that do not match their property name. */ + private const PROPERTY_TO_ARCHIVE_KEY = [ + 'email' => 'emailAddress', + 'oooFollowsSystem' => 'outOfOfficeFollowsSystem', + 'inboundHost' => 'imapHost', + 'inboundPort' => 'imapPort', + 'inboundSslMode' => 'imapSslMode', + 'inboundUser' => 'imapUser', + 'outboundHost' => 'smtpHost', + 'outboundPort' => 'smtpPort', + 'outboundSslMode' => 'smtpSslMode', + 'outboundUser' => 'smtpUser', + ]; + + public function testSerialiserCoversEveryPersistedAccountField(): void { + $account = new Account($this->getExportableMailAccount()); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->willReturn([$account]); + $this->serviceMock->getParameter('mailboxMapper') + ->method('findAll') + ->willReturn([]); + $this->serviceMock->getParameter('aliasesService') + ->method('findAll') + ->willReturn([]); + + $exported = null; + $this->exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $contents) use (&$exported): void { + $exported = $contents; + }); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + + $archive = json_decode($exported, true, flags: JSON_THROW_ON_ERROR); + $reflection = new \ReflectionClass(MailAccount::class); + + foreach ($reflection->getProperties(\ReflectionProperty::IS_PROTECTED) as $property) { + if ($property->getDeclaringClass()->getName() !== MailAccount::class) { + continue; + } + + $name = $property->getName(); + if (in_array($name, self::NOT_EXPORTED, true)) { + continue; + } + + self::assertArrayHasKey( + self::PROPERTY_TO_ARCHIVE_KEY[$name] ?? $name, + $archive, + "MailAccount::\$$name is not in the archive. Add it to serialiseAccount(), " + . 'or to NOT_EXPORTED with the reason it must not be migrated.', + ); + } + } + + public function testImportsAccountWithMailboxesAndAliases(): void { + $this->expectAccountFileToBeImported($this->getExportedAccountData()); + $this->expectAccountToBeSaved(); + + $insertedMailboxes = []; + $this->serviceMock->getParameter('mailboxMapper') + ->expects(self::exactly(2)) + ->method('insert') + ->willReturnCallback(function (Mailbox $mailbox) use (&$insertedMailboxes): Mailbox { + $mailbox->setId($mailbox->getName() === 'INBOX' ? self::IMPORTED_INBOX_ID : self::IMPORTED_SENT_ID); + $insertedMailboxes[$mailbox->getName()] = $mailbox; + return $mailbox; + }); + + $createdAliases = []; + $this->serviceMock->getParameter('aliasesService') + ->expects(self::once()) + ->method('create') + ->willReturnCallback(function (string $userId, + int $accountId, + string $alias, + ?string $name, + ?string $signature = null, + ?int $smimeCertificateId = null) use (&$createdAliases): Alias { + $createdAliases[] = [ + 'userId' => $userId, + 'accountId' => $accountId, + 'alias' => $alias, + 'name' => $name, + 'signature' => $signature, + 'smimeCertificateId' => $smimeCertificateId, + ]; + return new Alias(); + }); + + $mappings = $this->migrationService->importAccounts( + $this->user, + $this->importSource, + $this->output, + [11 => 22], + ); + + self::assertSame( + ['accounts' => [self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID], + 'mailboxes' => [self::EXPORTED_INBOX_ID => self::IMPORTED_INBOX_ID, self::EXPORTED_SENT_ID => self::IMPORTED_SENT_ID]], + $mappings, + ); + self::assertSame(self::USER_ID, $this->savedAccount->getUserId()); + self::assertSame('jane@doe.com', $this->savedAccount->getEmail()); + self::assertSame(22, $this->savedAccount->getSmimeCertificateId()); + self::assertSame('imap_secret_encrypted', $this->savedAccount->getInboundPassword()); + self::assertSame('smtp_secret_encrypted', $this->savedAccount->getOutboundPassword()); + self::assertSame('sieve_secret_encrypted', $this->savedAccount->getSievePassword()); + self::assertSame([ + ['userId' => self::USER_ID, + 'accountId' => self::IMPORTED_ACCOUNT_ID, + 'alias' => 'alias@doe.com', + 'name' => 'Jane Alias', + 'signature' => 'Kind regards', + 'smimeCertificateId' => 22], + ], $createdAliases); + } + + public function testImportRestoresMailboxColumnsAndSpecialMailboxes(): void { + $this->expectAccountFileToBeImported($this->getExportedAccountData()); + $this->expectAccountToBeSaved(); + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willReturn(new Alias()); + + $insertedMailboxes = []; + $this->serviceMock->getParameter('mailboxMapper') + ->method('insert') + ->willReturnCallback(function (Mailbox $mailbox) use (&$insertedMailboxes): Mailbox { + $mailbox->setId($mailbox->getName() === 'INBOX' ? self::IMPORTED_INBOX_ID : self::IMPORTED_SENT_ID); + $insertedMailboxes[$mailbox->getName()] = $mailbox; + return $mailbox; + }); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + $inbox = $insertedMailboxes['INBOX']; + self::assertSame(self::IMPORTED_ACCOUNT_ID, $inbox->getAccountId()); + self::assertSame('["\\\\subscribed"]', $inbox->getAttributes()); + self::assertSame('["\\\\Inbox"]', $inbox->getSpecialUse()); + self::assertSame('.', $inbox->getDelimiter()); + self::assertTrue($inbox->getSelectable()); + self::assertTrue($inbox->getSyncInBackground()); + self::assertSame('lrswipkxte', $inbox->getMyAcls()); + self::assertFalse($inbox->isShared()); + self::assertSame(0, $inbox->getMessages()); + self::assertSame(0, $inbox->getUnseen()); + self::assertSame(md5('INBOX'), $inbox->getNameHash()); + self::assertSame(self::IMPORTED_SENT_ID, $this->savedAccount->getSentMailboxId()); + } + + public function testImportReadsAccountFilesFromInsideTheAccountFolder(): void { + $this->importSource->method('getFolderListing') + ->with(AccountMigrationService::ACCOUNT_FOLDER) + ->willReturn(['19.json', 'not-an-account.txt']); + + $requestedPaths = []; + $this->importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use (&$requestedPaths): string { + $requestedPaths[] = $path; + return json_encode($this->getExportedAccountData(), JSON_THROW_ON_ERROR); + }); + + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame([AccountMigrationService::ACCOUNT_FOLDER . '19.json'], $requestedPaths); + } + + public function testImportSkipsAnUnreadableAccountFileAndKeepsGoing(): void { + $this->importSource->method('getFolderListing') + ->with(AccountMigrationService::ACCOUNT_FOLDER) + ->willReturn(['broken.json', '19.json']); + $this->importSource->method('getFileContents') + ->willReturnCallback(function (string $path): string { + if (str_ends_with($path, 'broken.json')) { + throw new UserMigrationException('not readable'); + } + return json_encode($this->getExportedAccountData(), JSON_THROW_ON_ERROR); + }); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame([self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID], $mappings['accounts']); + } + + public function testImportWithoutStoredPasswordsDoesNotEncryptNull(): void { + $accountData = $this->getExportedAccountData(); + $accountData['inboundPassword'] = null; + $accountData['outboundPassword'] = null; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertNull($this->savedAccount->getInboundPassword()); + self::assertNull($this->savedAccount->getOutboundPassword()); + } + + public function testImportOauthAccountWithoutStoredTokens(): void { + $accountData = $this->getExportedAccountData(); + $accountData['authMethod'] = 'xoauth2'; + $accountData['inboundPassword'] = null; + $accountData['outboundPassword'] = null; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertNull($this->savedAccount->getOauthRefreshToken()); + self::assertNull($this->savedAccount->getOauthAccessToken()); + self::assertNull($this->savedAccount->getOauthTokenTtl()); + } + + public function testImportKeepsUserNamesForOauthAccounts(): void { + $accountData = $this->getExportedAccountData(); + $accountData['authMethod'] = 'xoauth2'; + $accountData['oauthRefreshToken'] = 'refresh'; + $accountData['oauthAccessToken'] = 'access'; + $accountData['oauthTokenTtl'] = 3600; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame('jane', $this->savedAccount->getInboundUser()); + self::assertSame('jane', $this->savedAccount->getOutboundUser()); + self::assertSame('refresh_encrypted', $this->savedAccount->getOauthRefreshToken()); + self::assertSame('access_encrypted', $this->savedAccount->getOauthAccessToken()); + self::assertSame(3600, $this->savedAccount->getOauthTokenTtl()); + } + + public function testImportKeepsTheExportedOrderForAUserWithoutAccounts(): void { + $accountData = $this->getExportedAccountData(); + $this->expectAccountFileToBeImported($accountData); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([]); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame($accountData['order'], $this->savedAccount->getOrder()); + self::assertSame($accountData['signatureMode'], $this->savedAccount->getSignatureMode()); + } + + public function testImportAppendsAccountsAfterTheOnesTheUserAlreadyHas(): void { + $existing = $this->getExportableMailAccount(); + $existing->setOrder(4); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([new Account($existing)]); + + $accountData = $this->getExportedAccountData(); + $accountData['order'] = 2; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(7, $this->savedAccount->getOrder()); + } + + public function testExportWritesEveryKeyTheImportRequires(): void { + $account = new Account($this->getExportableMailAccount()); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->willReturn([$account]); + $this->serviceMock->getParameter('mailboxMapper') + ->method('findAll') + ->willReturn([]); + $this->serviceMock->getParameter('aliasesService') + ->method('findAll') + ->willReturn([]); + + $exported = null; + $this->exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $contents) use (&$exported): void { + $exported = $contents; + }); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + + self::assertEqualsCanonicalizing( + array_keys($this->getExportedAccountData()), + array_keys(json_decode($exported, true, flags: JSON_THROW_ON_ERROR)), + ); + } + + public function testImportRestoresPersonalNamespace(): void { + $accountData = $this->getExportedAccountData(); + $accountData['personalNamespace'] = 'INBOX.'; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame('INBOX.', $this->savedAccount->getPersonalNamespace()); + } + + public function testImportAliasWithoutName(): void { + $accountData = $this->getExportedAccountData(); + $accountData['aliases'][0]['name'] = null; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + + $names = []; + $this->serviceMock->getParameter('mailboxMapper') + ->method('insert') + ->willReturnCallback(static function (Mailbox $mailbox): Mailbox { + $mailbox->setId(self::IMPORTED_INBOX_ID); + return $mailbox; + }); + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willReturnCallback(function (string $userId, + int $accountId, + string $alias, + ?string $name = null) use (&$names): Alias { + $names[] = $name; + return new Alias(); + }); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame([null], $names); + } + + public static function provideInvalidAccountData(): array { + return [ + 'missing account id' => ['accountId'], + 'missing email address' => ['emailAddress'], + 'missing sieve flag' => ['sieveEnabled'], + 'missing special mailbox id' => ['sentMailboxId'], + 'missing mailboxes' => ['mailboxes'], + 'missing aliases' => ['aliases'], + ]; + } + + /** + * @dataProvider provideInvalidAccountData + */ + public function testImportSkipsAccountsMissingARequiredKey(string $key): void { + $accountData = $this->getExportedAccountData(); + unset($accountData[$key]); + + $this->expectAccountFileToBeImported($accountData); + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('save'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public static function provideMalformedAccountData(): array { + return [ + 'not an object' => ['not-an-account'], + 'account id is not an int' => [['accountId' => 'nineteen']], + 'mailboxes are not a list' => [['mailboxes' => ['unexpected' => 'object']]], + 'aliases are not a list' => [['aliases' => ['unexpected' => 'object']]], + 'mailbox is not an object' => [['mailboxes' => ['not-a-mailbox']]], + 'alias is not an object' => [['aliases' => ['not-an-alias']]], + ]; + } + + /** + * @dataProvider provideMalformedAccountData + */ + public function testImportSkipsMalformedAccounts(mixed $payload): void { + $accountData = is_array($payload) ? array_merge($this->getExportedAccountData(), $payload) : $payload; + + $this->expectAccountFileToBeImported($accountData); + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('save'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public function testImportSkipsMailboxesWithMissingColumns(): void { + $accountData = $this->getExportedAccountData(); + unset($accountData['mailboxes'][0]['selectable']); + $this->expectAccountFileToBeImported($accountData); + + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('save'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public function testExportWithoutAccounts(): void { + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([]); + + $this->exportDestination->expects(self::never()) + ->method('addFileContents'); + + $this->migrationService->exportAccounts($this->user, $this->exportDestination, $this->output); + } + + public function testImportAssignsEveryRoleWhenTwoShareAMailbox(): void { + $accountData = $this->getExportedAccountData(); + // a setup where the same folder serves as both archive and snooze + $accountData['archiveMailboxId'] = self::EXPORTED_SENT_ID; + $accountData['snoozeMailboxId'] = self::EXPORTED_SENT_ID; + $this->expectAccountFileToBeImported($accountData); + $this->expectAccountToBeSaved(); + $this->expectMailboxesAndAliasesToBeImported(); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(self::IMPORTED_SENT_ID, $this->savedAccount->getArchiveMailboxId()); + self::assertSame(self::IMPORTED_SENT_ID, $this->savedAccount->getSnoozeMailboxId()); + self::assertSame(self::IMPORTED_SENT_ID, $this->savedAccount->getSentMailboxId()); + } + + public static function provideWronglyTypedAccountValues(): array { + return [ + 'order is a string' => ['order', 'abc'], + 'password is an int' => ['inboundPassword', 12345], + 'certificate id is an array' => ['smimeCertificateId', ['nope']], + ]; + } + + /** + * `validateAccount()` only checks that keys exist, so a wrongly typed value + * first shows up as a TypeError while the account is being built. + * + * @dataProvider provideWronglyTypedAccountValues + */ + public function testImportSkipsAccountsWithWronglyTypedValues(string $key, mixed $value): void { + $accountData = $this->getExportedAccountData(); + $accountData[$key] = $value; + $this->expectAccountFileToBeImported($accountData); + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('save'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public function testImportWithoutAccountFolderImportsNothing(): void { + $this->importSource->method('getFolderListing') + ->with(AccountMigrationService::ACCOUNT_FOLDER) + ->willReturn([]); + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('save'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public function testImportKeepsAliasCertificateEmptyWhenCertificateWasNotImported(): void { + $this->expectAccountFileToBeImported($this->getExportedAccountData()); + $this->expectAccountToBeSaved(); + $this->serviceMock->getParameter('mailboxMapper') + ->method('insert') + ->willReturnCallback(static function (Mailbox $mailbox): Mailbox { + $mailbox->setId(self::IMPORTED_INBOX_ID); + return $mailbox; + }); + + $certificateIds = []; + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willReturnCallback(function (string $userId, + int $accountId, + string $alias, + ?string $name, + ?string $signature = null, + ?int $smimeCertificateId = null) use (&$certificateIds): Alias { + $certificateIds[] = $smimeCertificateId; + return new Alias(); + }); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame([null], $certificateIds); + } + + public function testImportSkipsTheFailingAccountAndKeepsGoing(): void { + $this->expectAccountFileToBeImported($this->getExportedAccountData()); + $this->expectAccountToBeSaved(); + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willThrowException(new Exception('alias insert failed')); + + $this->serviceMock->getParameter('connection') + ->expects(self::once()) + ->method('beginTransaction'); + $this->serviceMock->getParameter('connection') + ->expects(self::once()) + ->method('rollBack'); + $this->serviceMock->getParameter('connection') + ->expects(self::never()) + ->method('commit'); + + $this->serviceMock->getParameter('logger') + ->expects(self::once()) + ->method('error') + ->with(self::stringContains(self::USER_ID), self::arrayHasKey('exception')); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame(['accounts' => [], 'mailboxes' => []], $mappings); + } + + public function testImportCommitsOneTransactionPerAccount(): void { + $secondAccountData = $this->getExportedAccountData(); + $secondAccountData['accountId'] = 20; + $secondAccountData['mailboxes'] = []; + $secondAccountData['aliases'] = []; + $this->expectAccountFileToBeImported($this->getExportedAccountData(), $secondAccountData); + + $savedIds = [self::IMPORTED_ACCOUNT_ID, 43]; + $saveCount = 0; + $this->serviceMock->getParameter('accountService') + ->method('save') + ->willReturnCallback(function (MailAccount $account, bool $scheduleBackgroundJobs = true) use ($savedIds, &$saveCount): MailAccount { + $account->setId($savedIds[$saveCount++]); + return $account; + }); + $this->serviceMock->getParameter('mailboxMapper') + ->method('insert') + ->willReturnCallback(function (Mailbox $mailbox): Mailbox { + $mailbox->setId($mailbox->getName() === 'INBOX' ? self::IMPORTED_INBOX_ID : self::IMPORTED_SENT_ID); + return $mailbox; + }); + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willReturn(new Alias()); + + $this->serviceMock->getParameter('connection') + ->expects(self::exactly(2)) + ->method('beginTransaction'); + $this->serviceMock->getParameter('connection') + ->expects(self::exactly(2)) + ->method('commit'); + + $mappings = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output, []); + + self::assertSame( + [self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID, 20 => 43], + $mappings['accounts'], + ); + self::assertSame( + [self::EXPORTED_INBOX_ID => self::IMPORTED_INBOX_ID, self::EXPORTED_SENT_ID => self::IMPORTED_SENT_ID], + $mappings['mailboxes'], + ); + } + + public function testScheduleBackgroundJobs(): void { + $scheduledIds = []; + $this->serviceMock->getParameter('accountService') + ->expects(self::exactly(2)) + ->method('scheduleBackgroundJobs') + ->willReturnCallback(function (int $accountId) use (&$scheduledIds) { + $scheduledIds[] = $accountId; + }); + + $this->migrationService->scheduleBackgroundJobs( + [19 => 101, 20 => 102], + $this->output, + ); + + self::assertSame([101, 102], $scheduledIds); + } + + public function testScheduleBackgroundJobsWithoutImportedAccounts(): void { + $this->serviceMock->getParameter('accountService') + ->expects(self::never()) + ->method('scheduleBackgroundJobs'); + + $this->migrationService->scheduleBackgroundJobs([], $this->output); + } + + /** + * `IImportSource::getFolderListing()` returns names relative to the folder, + * so the stub must not hand out full paths. + */ + private function expectAccountFileToBeImported(mixed ...$accountData): void { + $fileNames = []; + $contentsByPath = []; + foreach ($accountData as $index => $data) { + $fileName = (is_array($data) ? $data['accountId'] ?? $index : $index) . '.json'; + $fileNames[] = $fileName; + $contentsByPath[AccountMigrationService::ACCOUNT_FOLDER . $fileName] = json_encode($data, JSON_THROW_ON_ERROR); + } + + $this->importSource->method('getFolderListing') + ->with(AccountMigrationService::ACCOUNT_FOLDER) + ->willReturn($fileNames); + $this->importSource->method('getFileContents') + ->willReturnCallback(static function (string $path) use ($contentsByPath): string { + self::assertArrayHasKey($path, $contentsByPath); + return $contentsByPath[$path]; + }); + } + + private function expectMailboxesAndAliasesToBeImported(): void { + $this->serviceMock->getParameter('mailboxMapper') + ->method('insert') + ->willReturnCallback(static function (Mailbox $mailbox): Mailbox { + $mailbox->setId($mailbox->getName() === 'INBOX' ? self::IMPORTED_INBOX_ID : self::IMPORTED_SENT_ID); + return $mailbox; + }); + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willReturn(new Alias()); + } + + private function expectAccountToBeSaved(): void { + $this->serviceMock->getParameter('accountService') + ->method('save') + ->willReturnCallback(function (MailAccount $account, bool $scheduleBackgroundJobs = true): MailAccount { + self::assertFalse($scheduleBackgroundJobs); + $account->setId(self::IMPORTED_ACCOUNT_ID); + $this->savedAccount = $account; + return $account; + }); + } + + private function getExportableMailAccount(): MailAccount { + $mailAccount = new MailAccount(); + + $mailAccount->setId(self::EXPORTED_ACCOUNT_ID); + $mailAccount->setUserId(self::USER_ID); + $mailAccount->setName('Jane Doe'); + $mailAccount->setEmail('jane@doe.com'); + $mailAccount->setProtocol(MailAccount::PROTOCOL_IMAP); + $mailAccount->setAuthMethod('password'); + $mailAccount->setInboundHost('imap.doe.com'); + $mailAccount->setInboundPort(993); + $mailAccount->setInboundUser('jane'); + $mailAccount->setInboundSslMode('ssl'); + $mailAccount->setInboundPassword('imap_secret_encrypted'); + $mailAccount->setOutboundHost('smtp.doe.com'); + $mailAccount->setOutboundPort(465); + $mailAccount->setOutboundUser('jane'); + $mailAccount->setOutboundSslMode('ssl'); + $mailAccount->setOutboundPassword('smtp_secret_encrypted'); + $mailAccount->setSieveEnabled(true); + $mailAccount->setSieveHost('sieve.doe.com'); + $mailAccount->setSievePort(4190); + $mailAccount->setSieveUser('jane'); + $mailAccount->setSieveSslMode('tls'); + $mailAccount->setSievePassword('sieve_secret_encrypted'); + + return $mailAccount; + } + + private function getInboxMailbox(): Mailbox { + $mailbox = new Mailbox(); + + $mailbox->setId(self::EXPORTED_INBOX_ID); + $mailbox->setAccountId(self::EXPORTED_ACCOUNT_ID); + $mailbox->setName('INBOX'); + $mailbox->setNameHash(md5('INBOX')); + $mailbox->setAttributes('["\\\\subscribed"]'); + $mailbox->setSpecialUse('["\\\\Inbox"]'); + $mailbox->setDelimiter('.'); + $mailbox->setSelectable(true); + $mailbox->setSyncInBackground(true); + $mailbox->setMyAcls('lrswipkxte'); + $mailbox->setShared(false); + $mailbox->setMessages(12); + $mailbox->setUnseen(3); + + return $mailbox; + } + + private function getAlias(): Alias { + $alias = new Alias(); + + $alias->setId(5); + $alias->setAccountId(self::EXPORTED_ACCOUNT_ID); + $alias->setAlias('alias@doe.com'); + $alias->setName('Jane Alias'); + $alias->setSignature('Kind regards'); + $alias->setSmimeCertificateId(11); + + return $alias; + } + + private function getExportedAccountData(): array { + return [ + 'accountId' => self::EXPORTED_ACCOUNT_ID, + 'name' => 'Jane Doe', + 'emailAddress' => 'jane@doe.com', + 'order' => 3, + 'showSubscribedOnly' => true, + 'smimeCertificateId' => 11, + 'editorMode' => 'richtext', + 'trashRetentionDays' => 60, + 'outOfOfficeFollowsSystem' => true, + 'imipCreate' => false, + 'classificationEnabled' => true, + 'searchBody' => false, + 'personalNamespace' => 'INBOX.', + 'signature' => 'Jane', + 'signatureAboveQuote' => true, + 'signatureMode' => 1, + 'protocol' => MailAccount::PROTOCOL_IMAP, + 'authMethod' => 'password', + 'imapHost' => 'imap.doe.com', + 'imapPort' => 993, + 'imapSslMode' => 'ssl', + 'imapUser' => 'jane', + 'inboundPassword' => 'imap_secret', + 'smtpHost' => 'smtp.doe.com', + 'smtpPort' => 465, + 'smtpSslMode' => 'ssl', + 'smtpUser' => 'jane', + 'outboundPassword' => 'smtp_secret', + 'oauthRefreshToken' => null, + 'oauthAccessToken' => null, + 'oauthTokenTtl' => null, + 'sieveEnabled' => true, + 'sieveHost' => 'sieve.doe.com', + 'sievePort' => 4190, + 'sieveSslMode' => 'tls', + 'sieveUser' => 'jane', + 'sievePassword' => 'sieve_secret', + 'draftsMailboxId' => null, + 'sentMailboxId' => self::EXPORTED_SENT_ID, + 'trashMailboxId' => null, + 'archiveMailboxId' => null, + 'junkMailboxId' => null, + 'snoozeMailboxId' => null, + 'mailboxes' => [ + ['databaseId' => self::EXPORTED_INBOX_ID, + 'name' => 'INBOX', + 'attributes' => '["\\\\subscribed"]', + 'delimiter' => '.', + 'specialUse' => '["\\\\Inbox"]', + 'selectable' => true, + 'syncInBackground' => true, + 'myAcls' => 'lrswipkxte', + 'shared' => false], + ['databaseId' => self::EXPORTED_SENT_ID, + 'name' => 'Sent', + 'attributes' => '[]', + 'delimiter' => '.', + 'specialUse' => '["\\\\Sent"]', + 'selectable' => true, + 'syncInBackground' => false, + 'myAcls' => null, + 'shared' => false], + ], + 'aliases' => [ + ['alias' => 'alias@doe.com', + 'name' => 'Jane Alias', + 'signature' => 'Kind regards', + 'smimeCertificateId' => 11], + ], + ]; + } +} diff --git a/tests/Unit/UserMigration/Service/AppConfigMigrationServiceTest.php b/tests/Unit/UserMigration/Service/AppConfigMigrationServiceTest.php new file mode 100644 index 0000000000..a086bb46de --- /dev/null +++ b/tests/Unit/UserMigration/Service/AppConfigMigrationServiceTest.php @@ -0,0 +1,300 @@ +serviceMock = $this->createServiceMock(AppConfigMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + + $this->output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + } + + public function testEstimatedExportSizeMatchesTheExportedPayload(): void { + $this->serviceMock->getParameter('config') + ->method('getUserKeys') + ->with(self::USER_ID, Application::APP_ID) + ->willReturn($this->getAppKeys()); + $this->serviceMock->getParameter('config') + ->method('getUserValue') + ->willReturnCallback(fn (string $userId, string $appId, string $key): ?string => $this->getAppValue($key)); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(self::EXPECTED_APP_CONFIG_BYTES, $size); + self::assertSame(strlen(json_encode($this->getAppConfig())), $size); + } + + public function testExportsMultipleAppConfigurations(): void { + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(AppConfigMigrationService::APP_CONFIGURATION_FILE, json_encode($this->getAppConfig())); + + $this->serviceMock->getParameter('config') + ->expects(self::once()) + ->method('getUserKeys') + ->with(self::USER_ID, Application::APP_ID) + ->willReturn($this->getAppKeys()); + + $calls = []; + $this->serviceMock->getParameter('config') + ->expects(self::exactly(3)) + ->method('getUserValue') + ->willReturnCallback(function (string $userId, + string $appId, + string $key) use (&$calls): ?string { + $calls[] = [$userId, $appId, $key]; + return $this->getAppValue($key); + }); + + $this->migrationService->exportAppConfiguration($this->user, $this->exportDestination, $this->output); + + $expected = array_map(fn (string $key) => [self::USER_ID, Application::APP_ID, $key], $this->getAppKeys()); + self::assertEqualsCanonicalizing($expected, $calls); + } + + public function testExportsNoAppConfiguration(): void { + $emptyAppConfig = []; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(AppConfigMigrationService::APP_CONFIGURATION_FILE, json_encode($emptyAppConfig)); + + $this->serviceMock->getParameter('config') + ->expects(self::once()) + ->method('getUserKeys') + ->with(self::USER_ID, Application::APP_ID) + ->willReturn($emptyAppConfig); + $this->serviceMock->getParameter('config') + ->expects(self::never()) + ->method('getUserValue'); + + $this->migrationService->exportAppConfiguration($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleAppConfigurations(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(AppConfigMigrationService::APP_CONFIGURATION_FILE) + ->willReturn(json_encode($this->getAppConfig())); + + $this->captureImportedValues(3); + + $this->migrationService->importAppConfiguration( + $this->user, + $this->importSource, + $this->output, + [self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID], + [], + ); + + self::assertEqualsCanonicalizing( + [ + AppConfigMigrationService::ACCOUNT_SETTINGS_KEY => json_encode([['accountId' => self::IMPORTED_ACCOUNT_ID, 'collapsed' => false]]), + 'collect-data' => 'true', + 'ui-heartbeat' => '1770367800', + ], + $this->importedValues, + ); + } + + public function testImportRemapsAccountSettingsAndStartMailboxId(): void { + $appConfig = [ + ['key' => AppConfigMigrationService::ACCOUNT_SETTINGS_KEY, + 'value' => json_encode([ + ['accountId' => self::EXPORTED_ACCOUNT_ID, 'collapsed' => false], + ['accountId' => 999, 'collapsed' => true], + ])], + ['key' => AppConfigMigrationService::START_MAILBOX_ID_KEY, + 'value' => (string)self::EXPORTED_MAILBOX_ID], + ]; + $this->importSource->method('getFileContents') + ->willReturn(json_encode($appConfig)); + + $this->captureImportedValues(2); + + $this->migrationService->importAppConfiguration( + $this->user, + $this->importSource, + $this->output, + [self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID], + [self::EXPORTED_MAILBOX_ID => self::IMPORTED_MAILBOX_ID], + ); + + self::assertSame( + [AppConfigMigrationService::ACCOUNT_SETTINGS_KEY => json_encode([['accountId' => self::IMPORTED_ACCOUNT_ID, 'collapsed' => false]]), + AppConfigMigrationService::START_MAILBOX_ID_KEY => (string)self::IMPORTED_MAILBOX_ID], + $this->importedValues, + ); + } + + public static function provideUnmappableReferences(): array { + return [ + 'account was not imported' => [ + AppConfigMigrationService::ACCOUNT_SETTINGS_KEY, + '[{"accountId":999,"collapsed":false}]', + ], + 'account settings are not a list' => [ + AppConfigMigrationService::ACCOUNT_SETTINGS_KEY, + '{"accountId":19}', + ], + 'account settings are invalid JSON' => [ + AppConfigMigrationService::ACCOUNT_SETTINGS_KEY, + 'not json at all', + ], + 'account settings entry without account id' => [ + AppConfigMigrationService::ACCOUNT_SETTINGS_KEY, + '[{"collapsed":false}]', + ], + 'start mailbox was not imported' => [ + AppConfigMigrationService::START_MAILBOX_ID_KEY, + '999', + ], + 'start mailbox is not numeric' => [ + AppConfigMigrationService::START_MAILBOX_ID_KEY, + '', + ], + ]; + } + + /** + * @dataProvider provideUnmappableReferences + */ + public function testImportSkipsUnmappableReferences(string $key, string $value): void { + $this->importSource->method('getFileContents') + ->willReturn(json_encode([['key' => $key, 'value' => $value]])); + + $this->serviceMock->getParameter('config') + ->expects(self::never()) + ->method('setUserValue'); + $this->serviceMock->getParameter('config') + ->expects(self::once()) + ->method('deleteUserValue') + ->with(self::USER_ID, Application::APP_ID, $key); + + $this->migrationService->importAppConfiguration( + $this->user, + $this->importSource, + $this->output, + [self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID], + [self::EXPORTED_MAILBOX_ID => self::IMPORTED_MAILBOX_ID], + ); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(AppConfigMigrationService::APP_CONFIGURATION_FILE) + ->willThrowException(new UserMigrationException()); + + $this->serviceMock->getParameter('config') + ->expects(self::never()) + ->method('setUserValue'); + + $this->migrationService->importAppConfiguration($this->user, $this->importSource, $this->output, [], []); + } + + public static function provideFileContentsWithNoSettingsImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoSettingsImported + */ + public function testImportEmptyOrInvalidAppConfigurations(string $fileContents): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(AppConfigMigrationService::APP_CONFIGURATION_FILE) + ->willReturn($fileContents); + + $this->serviceMock->getParameter('config') + ->expects(self::never()) + ->method('setUserValue'); + + $this->migrationService->importAppConfiguration($this->user, $this->importSource, $this->output, [], []); + } + + private function captureImportedValues(int $expectedCalls): void { + $this->serviceMock->getParameter('config') + ->expects(self::exactly($expectedCalls)) + ->method('setUserValue') + ->willReturnCallback(function (string $userId, + string $appId, + string $key, + string $value): void { + self::assertSame(self::USER_ID, $userId); + self::assertSame(Application::APP_ID, $appId); + $this->importedValues[$key] = $value; + }); + } + + private function getAppConfig(): array { + return [ + ['key' => AppConfigMigrationService::ACCOUNT_SETTINGS_KEY, + 'value' => json_encode([['accountId' => self::EXPORTED_ACCOUNT_ID, 'collapsed' => false]])], + ['key' => 'collect-data', + 'value' => 'true' + ], + ['key' => 'ui-heartbeat', + 'value' => '1770367800'] + ]; + } + + private function getAppKeys(): array { + return array_map(function (array $appConfig) { + return $appConfig['key']; + }, $this->getAppConfig()); + } + + private function getAppValue(string $key): ?string { + foreach ($this->getAppConfig() as $appConfig) { + if ($appConfig['key'] === $key) { + return $appConfig['value']; + } + } + return null; + } +} diff --git a/tests/Unit/UserMigration/Service/InternalAddressesMigrationServiceTest.php b/tests/Unit/UserMigration/Service/InternalAddressesMigrationServiceTest.php new file mode 100644 index 0000000000..2ebae97cec --- /dev/null +++ b/tests/Unit/UserMigration/Service/InternalAddressesMigrationServiceTest.php @@ -0,0 +1,164 @@ +serviceMock = $this->createServiceMock(InternalAddressesMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + } + + public function testEstimatedExportSizeScalesWithAddressCount(): void { + $this->serviceMock->getParameter('internalAddressService') + ->method('getInternalAddresses') + ->with(self::USER_ID) + ->willReturn([$this->getTrustedIndividual(), $this->getTrustedDomain()]); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(2 * InternalAddressesMigrationService::ESTIMATED_INTERNAL_ADDRESS_SIZE, $size); + } + + public function testExportsMultipleInternalAddresses(): void { + $trustedSendersList = [$this->getTrustedIndividual(), $this->getTrustedDomain()]; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(InternalAddressesMigrationService::INTERNAL_ADDRESSES_FILE, json_encode($trustedSendersList)); + + $this->serviceMock->getParameter('internalAddressService') + ->method('getInternalAddresses') + ->with(self::USER_ID) + ->willReturn($trustedSendersList); + + $this->migrationService->exportInternalAddresses($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoneInternalAddress(): void { + $trustedSendersList = []; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(InternalAddressesMigrationService::INTERNAL_ADDRESSES_FILE, json_encode($trustedSendersList)); + + $this->serviceMock->getParameter('internalAddressService') + ->method('getInternalAddresses') + ->with(self::USER_ID) + ->willReturn($trustedSendersList); + + $this->migrationService->exportInternalAddresses($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleInternalAddresses(): void { + $trustedIndividual = $this->getTrustedIndividual(); + $trustedDomain = $this->getTrustedDomain(); + $trustedSendersList = [$trustedIndividual, $trustedDomain]; + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(InternalAddressesMigrationService::INTERNAL_ADDRESSES_FILE) + ->willReturn(json_encode($trustedSendersList)); + + $callCount = 0; + $expectedAddresses = [$trustedIndividual, $trustedDomain]; + $this->serviceMock + ->getParameter('internalAddressService')->expects(self::exactly(2))->method('add') + ->willReturnCallback(function (string $uid, string $address, string $type) use ( + &$callCount, + $expectedAddresses + ): void { + $expected = $expectedAddresses[$callCount]; + self::assertSame(self::USER_ID, $uid); + self::assertSame($expected->getAddress(), $address); + self::assertSame($expected->getType(), $type); + $callCount++; + }); + + $this->migrationService->importInternalAddresses($this->user, $this->importSource, $this->output); + } + + public static function provideFileContentsWithNoInternalAddressesImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoInternalAddressesImported + */ + public function testImportEmptyOrInvalidInternalAddresses(string $fileContents): void { + $this->importSource + ->expects(self::once())->method('getFileContents') + ->with(InternalAddressesMigrationService::INTERNAL_ADDRESSES_FILE) + ->willReturn($fileContents); + $this->serviceMock->getParameter('internalAddressService')->expects(self::never())->method('add'); + + $this->migrationService->importInternalAddresses($this->user, $this->importSource, $this->output); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource + ->expects(self::once()) + ->method('getFileContents') + ->with(InternalAddressesMigrationService::INTERNAL_ADDRESSES_FILE) + ->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('internalAddressService')->expects(self::never())->method('add'); + + $this->migrationService->importInternalAddresses($this->user, $this->importSource, $this->output); + } + + private function getTrustedIndividual(): InternalAddress { + $individualSender = new InternalAddress; + + $individualSender->setId(1); + $individualSender->setUserId(self::USER_ID); + $individualSender->setAddress('max@mustermann.com'); + $individualSender->setType('individual'); + + return $individualSender; + } + + private function getTrustedDomain(): InternalAddress { + $domainSender = new InternalAddress(); + + $domainSender->setId(2); + $domainSender->setUserId(self::USER_ID); + $domainSender->setAddress('nextcloud.com'); + $domainSender->setType('domain'); + + return $domainSender; + } +} diff --git a/tests/Unit/UserMigration/Service/LegacyAccountMigrationServiceTest.php b/tests/Unit/UserMigration/Service/LegacyAccountMigrationServiceTest.php new file mode 100644 index 0000000000..3188d00648 --- /dev/null +++ b/tests/Unit/UserMigration/Service/LegacyAccountMigrationServiceTest.php @@ -0,0 +1,231 @@ +output = $this->createMock(OutputInterface::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(LegacyAccountMigrationService::class); + $this->serviceMock->getParameter('crypto') + ->method('encrypt') + ->willReturnCallback(static fn (string $value): string => $value . '_encrypted'); + + $this->migrationService = $this->serviceMock->getService(); + } + + public function testImportsAccountsAndAliases(): void { + $this->expectArchive([self::EXPORTED_ACCOUNT_ID => self::ACCOUNT_PATH], $this->getV1AccountData()); + $this->expectAccountToBeSaved(); + + $createdAliases = []; + $this->serviceMock->getParameter('aliasesService') + ->method('create') + ->willReturnCallback(function (string $userId, + int $accountId, + string $alias, + ?string $name = null, + ?string $signature = null) use (&$createdAliases): Alias { + $createdAliases[] = ['alias' => $alias, 'name' => $name, 'signature' => $signature]; + return new Alias(); + }); + + $mapping = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertSame([self::EXPORTED_ACCOUNT_ID => self::IMPORTED_ACCOUNT_ID], $mapping); + self::assertSame('jane@doe.com', $this->savedAccount->getEmail()); + self::assertSame('imap.doe.com', $this->savedAccount->getInboundHost()); + self::assertSame('imap_secret_encrypted', $this->savedAccount->getInboundPassword()); + self::assertSame('smtp_secret_encrypted', $this->savedAccount->getOutboundPassword()); + self::assertSame([ + ['alias' => 'alias@doe.com', 'name' => 'Jane Alias', 'signature' => 'Kind regards'], + ], $createdAliases); + } + + public function testImportDropsCertificateIdsThatVersionOneNeverExported(): void { + $accountData = $this->getV1AccountData(); + $accountData['smimeCertificateId'] = 11; + $this->expectArchive([self::EXPORTED_ACCOUNT_ID => self::ACCOUNT_PATH], $accountData); + $this->expectAccountToBeSaved(); + $this->serviceMock->getParameter('aliasesService')->method('create')->willReturn(new Alias()); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertNull($this->savedAccount->getSmimeCertificateId()); + } + + public function testImportLeavesSpecialMailboxesUnsetBecauseVersionOneHadNone(): void { + $this->expectArchive([self::EXPORTED_ACCOUNT_ID => self::ACCOUNT_PATH], $this->getV1AccountData()); + $this->expectAccountToBeSaved(); + $this->serviceMock->getParameter('aliasesService')->method('create')->willReturn(new Alias()); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertNull($this->savedAccount->getSentMailboxId()); + self::assertNull($this->savedAccount->getDraftsMailboxId()); + } + + public function testImportAppendsAfterExistingAccounts(): void { + $existing = new MailAccount(); + $existing->setOrder(4); + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->willReturn([new Account($existing)]); + + $accountData = $this->getV1AccountData(); + $accountData['order'] = 2; + $this->expectArchive([self::EXPORTED_ACCOUNT_ID => self::ACCOUNT_PATH], $accountData); + $this->expectAccountToBeSaved(); + $this->serviceMock->getParameter('aliasesService')->method('create')->willReturn(new Alias()); + + $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertSame(7, $this->savedAccount->getOrder()); + } + + public function testImportWithoutIndexImportsNothing(): void { + $this->importSource->method('getFileContents') + ->with(LegacyAccountMigrationService::ACCOUNT_INDEX_FILE) + ->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('accountService')->expects(self::never())->method('save'); + + $mapping = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertSame([], $mapping); + } + + public static function provideUnusableAccountFiles(): array { + return [ + 'not valid JSON' => ['this is not json {{{'], + 'not an object' => ['"just a string"'], + 'missing email address' => ['{"imapHost":"imap.doe.com"}'], + 'missing imap host' => ['{"emailAddress":"jane@doe.com"}'], + 'incomplete imap settings' => ['{"emailAddress":"jane@doe.com","imapHost":"imap.doe.com"}'], + ]; + } + + /** + * @dataProvider provideUnusableAccountFiles + */ + public function testImportSkipsUnusableAccountFiles(string $contents): void { + $this->importSource->method('getFileContents') + ->willReturnCallback(static function (string $path) use ($contents): string { + return $path === LegacyAccountMigrationService::ACCOUNT_INDEX_FILE + ? json_encode([self::EXPORTED_ACCOUNT_ID => self::ACCOUNT_PATH]) + : $contents; + }); + $this->serviceMock->getParameter('accountService')->expects(self::never())->method('save'); + + $mapping = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertSame([], $mapping); + } + + public function testImportSkipsNonImapAccounts(): void { + $accountData = $this->getV1AccountData(); + $accountData['protocol'] = MailAccount::PROTOCOL_JMAP; + $this->expectArchive([self::EXPORTED_ACCOUNT_ID => self::ACCOUNT_PATH], $accountData); + $this->serviceMock->getParameter('accountService')->expects(self::never())->method('save'); + + $mapping = $this->migrationService->importAccounts($this->user, $this->importSource, $this->output); + + self::assertSame([], $mapping); + } + + private function expectArchive(array $index, array $accountData): void { + $this->importSource->method('getFileContents') + ->willReturnCallback(static function (string $path) use ($index, $accountData): string { + return $path === LegacyAccountMigrationService::ACCOUNT_INDEX_FILE + ? json_encode($index, JSON_THROW_ON_ERROR) + : json_encode($accountData, JSON_THROW_ON_ERROR); + }); + } + + private function expectAccountToBeSaved(): void { + $this->serviceMock->getParameter('accountService') + ->method('save') + ->willReturnCallback(function (MailAccount $account, bool $scheduleBackgroundJobs = true): MailAccount { + self::assertFalse($scheduleBackgroundJobs); + $account->setId(self::IMPORTED_ACCOUNT_ID); + $this->savedAccount = $account; + return $account; + }); + } + + /** + * The shape version 1 wrote: `MailAccount::toJson()` of the time, with the + * special mailbox ids removed and decrypted secrets added. + */ + private function getV1AccountData(): array { + return [ + 'id' => self::EXPORTED_ACCOUNT_ID, + 'accountId' => self::EXPORTED_ACCOUNT_ID, + 'name' => 'Jane Doe', + 'emailAddress' => 'jane@doe.com', + 'order' => 0, + 'authMethod' => 'password', + 'imapHost' => 'imap.doe.com', + 'imapPort' => 993, + 'imapUser' => 'jane', + 'imapSslMode' => 'ssl', + 'inboundPassword' => 'imap_secret', + 'smtpHost' => 'smtp.doe.com', + 'smtpPort' => 465, + 'smtpUser' => 'jane', + 'smtpSslMode' => 'ssl', + 'outboundPassword' => 'smtp_secret', + 'signature' => 'Jane', + 'signatureAboveQuote' => true, + 'editorMode' => 'richtext', + 'showSubscribedOnly' => true, + 'personalNamespace' => 'INBOX.', + 'provisioningId' => null, + 'sieveEnabled' => false, + 'aliases' => [ + ['id' => 5, + 'name' => 'Jane Alias', + 'alias' => 'alias@doe.com', + 'signature' => 'Kind regards', + 'provisioned' => false, + 'signatureMode' => 0, + 'smimeCertificateId' => null], + ], + ]; + } +} diff --git a/tests/Unit/UserMigration/Service/QuickActionsMigrationServiceTest.php b/tests/Unit/UserMigration/Service/QuickActionsMigrationServiceTest.php new file mode 100644 index 0000000000..35600c9be3 --- /dev/null +++ b/tests/Unit/UserMigration/Service/QuickActionsMigrationServiceTest.php @@ -0,0 +1,462 @@ + 100]; + private const MAILBOX_MAPPING = [self::QUICK_STEP_MAILBOX_ID => 200]; + private const TAG_MAPPING = [self::QUICK_STEP_TAG_ID => 22]; + + private OutputInterface $output; + private IUser $user; + private IExportDestination $exportDestination; + private IImportSource $importSource; + private ServiceMockObject $serviceMock; + private QuickActionsMigrationService $migrationService; + + protected function setUp(): void { + parent::setUp(); + + $this->output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(QuickActionsMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testEstimatedExportSizeCountsActionsAndSteps(): void { + $quickActions = $this->getQuickActions(); + $this->serviceMock->getParameter('quickActionsService') + ->method('findAll') + ->with(self::USER_ID) + ->willReturn($quickActions); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + $expectedSteps = array_sum(array_map( + static fn (Actions $quickAction): int => count($quickAction->jsonSerialize()['actionSteps']), + $quickActions, + )); + self::assertSame( + count($quickActions) * QuickActionsMigrationService::ESTIMATED_QUICK_ACTION_SIZE + + $expectedSteps * QuickActionsMigrationService::ESTIMATED_ACTION_STEP_SIZE, + $size, + ); + } + + public function testExportsMultipleQuickActions(): void { + $quickActions = $this->getQuickActions(); + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE, json_encode($quickActions)); + + $this->serviceMock->getParameter('quickActionsService') + ->method('findAll') + ->with(self::USER_ID) + ->willReturn($quickActions); + $this->migrationService->exportQuickActions($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoQuickActions(): void { + $this->serviceMock->getParameter('quickActionsService')->method('findAll')->with(self::USER_ID)->willReturn([]); + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE, json_encode([])); + + $this->migrationService->exportQuickActions($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleQuickActions(): void { + $quickActions = $this->getQuickActions(); + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode($quickActions)); + + $accountMapping = [self::QUICK_STEP_ACCOUNT_ID => rand(100, 199)]; + $mailboxMapping = [self::QUICK_STEP_MAILBOX_ID => rand(200, 299)]; + $tagMapping = [2 => rand(300, 399)]; + + $createdIds = []; + $createCallCount = 0; + $expectedActions = $this->getQuickActions(); + $this->serviceMock + ->getParameter('quickActionsService')->expects(self::exactly(2))->method('create') + ->willReturnCallback(function (string $name, int $accountId) use ( + &$createCallCount, + &$createdIds, + $expectedActions, + $accountMapping + ): Actions { + $expected = $expectedActions[$createCallCount]; + self::assertSame($expected->getName(), $name); + self::assertSame($accountMapping[$expected->getAccountId()], $accountId); + $newActions = new Actions(); + $newActions->setId(rand(400, 499)); + $newActions->setName($name); + $newActions->setAccountId($accountId); + $createdIds[] = $newActions->getId(); + $createCallCount++; + return $newActions; + }); + + $expectedSteps = [ + ['name' => 'markAsRead', 'order' => 1, 'actionIndex' => 0, 'tagId' => null, 'mailboxId' => null], + ['name' => 'moveThread', 'order' => 2, 'actionIndex' => 0, 'tagId' => null, 'mailboxId' => $mailboxMapping[self::QUICK_STEP_MAILBOX_ID]], + ['name' => 'markAsImportant', 'order' => 1, 'actionIndex' => 1, 'tagId' => null, 'mailboxId' => null], + ['name' => 'applyTag', 'order' => 2, 'actionIndex' => 1, 'tagId' => $tagMapping[2], 'mailboxId' => null], + ]; + $createStepCallCount = 0; + $this->serviceMock + ->getParameter('quickActionsService')->expects(self::exactly(4))->method('createActionStep') + ->willReturnCallback(function (string $name, int $order, int $actionId, ?int $tagId, ?int $mailboxId) use ( + & + $createStepCallCount, + &$createdIds, + $expectedSteps + ): ActionStep { + $expected = $expectedSteps[$createStepCallCount]; + self::assertSame($expected['name'], $name); + self::assertSame($expected['order'], $order); + self::assertSame($createdIds[$expected['actionIndex']], $actionId); + self::assertSame($expected['tagId'], $tagId); + self::assertSame($expected['mailboxId'], $mailboxId); + $newStep = new ActionStep(); + $newStep->setId(rand(500, 599)); + $createStepCallCount++; + return $newStep; + }); + + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, $accountMapping, + $mailboxMapping, $tagMapping); + } + + public function testImportNoQuickActions(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode([])); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, self::MAILBOX_MAPPING, []); + } + + public static function provideFileContentsWithNoQuickActionsImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoQuickActionsImported + */ + public function testImportEmptyOrInvalidQuickActions(string $fileContents): void { + $this->importSource + ->expects(self::once())->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn($fileContents); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, self::MAILBOX_MAPPING, []); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource + ->expects(self::once()) + ->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, self::MAILBOX_MAPPING, []); + } + + public static function provideIncompleteMappings(): array { + return [ + 'no accounts imported' => ['accountMapping' => [], 'mailboxMapping' => self::MAILBOX_MAPPING], + 'nothing imported' => ['accountMapping' => [], 'mailboxMapping' => []], + ]; + } + + /** + * @dataProvider provideIncompleteMappings + */ + public function testImportWithoutAccountMappingIsSkipped(array $accountMapping, + array $mailboxMapping): void { + $this->importSource->expects(self::never())->method('getFileContents'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('create'); + $this->serviceMock->getParameter('quickActionsService')->expects(self::never())->method('createActionStep'); + + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, $accountMapping, + $mailboxMapping, []); + } + + public function testImportSkipsOnlyTheQuickActionWhoseMailboxIsMissing(): void { + $this->importSource->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode($this->getQuickActions())); + + $createdNames = []; + $this->serviceMock->getParameter('quickActionsService') + ->method('create') + ->willReturnCallback(function (string $name, int $accountId) use (&$createdNames): Actions { + $createdNames[] = $name; + $created = new Actions(); + $created->setId(count($createdNames)); + return $created; + }); + $this->serviceMock->getParameter('quickActionsService') + ->expects(self::exactly(2)) + ->method('createActionStep') + ->willReturn(new ActionStep()); + + // The first action moves a thread into an unmapped mailbox; the second + // only needs the tag, so it has to survive on its own. + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, [], self::TAG_MAPPING); + + self::assertSame(['Second quick action'], $createdNames); + } + + public function testImportSortsActionStepsTheArchiveListedOutOfOrder(): void { + $quickAction = new Actions(); + $quickAction->setId(self::FIRST_QUICK_STEP_ID); + $quickAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $quickAction->setName('Out of order'); + + $second = new ActionStep(); + $second->setId(2); + $second->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $second->setName('markAsImportant'); + $second->setOrder(2); + + $first = new ActionStep(); + $first->setId(1); + $first->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $first->setName('markAsRead'); + $first->setOrder(1); + + $quickAction->setActionSteps([$second, $first]); + + $this->importSource->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode([$quickAction])); + + $created = new Actions(); + $created->setId(42); + $this->serviceMock->getParameter('quickActionsService')->method('create')->willReturn($created); + + $orders = []; + $this->serviceMock->getParameter('quickActionsService') + ->method('createActionStep') + ->willReturnCallback(function (string $name, int $order) use (&$orders): ActionStep { + $orders[] = $order; + return new ActionStep(); + }); + + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, self::MAILBOX_MAPPING, self::TAG_MAPPING); + + self::assertSame([1, 2], $orders); + } + + public function testImportSkipsAQuickActionTheServiceRejects(): void { + $this->importSource->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode([$this->getFirstQuickAction()])); + + $created = new Actions(); + $created->setId(42); + $this->serviceMock->getParameter('quickActionsService')->method('create')->willReturn($created); + $this->serviceMock->getParameter('quickActionsService') + ->method('createActionStep') + ->willThrowException(new ServiceException('Invalid action step order')); + + $this->serviceMock->getParameter('connection') + ->expects(self::once()) + ->method('rollBack'); + $this->serviceMock->getParameter('logger') + ->expects(self::once()) + ->method('warning'); + + // must not escape and abort the rest of the import + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, self::MAILBOX_MAPPING, self::TAG_MAPPING); + } + + public function testImportSkipsQuickActionsWithAnUnknownStepType(): void { + $quickAction = new Actions(); + $quickAction->setId(self::FIRST_QUICK_STEP_ID); + $quickAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $quickAction->setName('From a newer instance'); + + $step = new ActionStep(); + $step->setId(1); + $step->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $step->setName('teleportToMars'); + $step->setOrder(1); + $quickAction->setActionSteps([$step]); + + $this->importSource->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode([$quickAction])); + + $this->serviceMock->getParameter('quickActionsService') + ->expects(self::never()) + ->method('create'); + $this->serviceMock->getParameter('quickActionsService') + ->expects(self::never()) + ->method('createActionStep'); + + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, self::MAILBOX_MAPPING, self::TAG_MAPPING); + } + + public function testImportWithoutMailboxesStillImportsMailboxlessQuickActions(): void { + $quickAction = new Actions(); + $quickAction->setId(self::FIRST_QUICK_STEP_ID); + $quickAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $quickAction->setName('Mark as read'); + + $step = new ActionStep(); + $step->setId(1); + $step->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $step->setName('markAsRead'); + $step->setOrder(1); + $quickAction->setActionSteps([$step]); + + $this->importSource->method('getFileContents') + ->with(QuickActionsMigrationService::QUICK_ACTIONS_FILE) + ->willReturn(json_encode([$quickAction])); + + $created = new Actions(); + $created->setId(42); + $this->serviceMock->getParameter('quickActionsService') + ->expects(self::once()) + ->method('create') + ->with('Mark as read', self::ACCOUNT_MAPPING[self::QUICK_STEP_ACCOUNT_ID]) + ->willReturn($created); + + $createdSteps = []; + $this->serviceMock->getParameter('quickActionsService') + ->expects(self::once()) + ->method('createActionStep') + ->willReturnCallback(function (string $name, + int $order, + int $actionId, + ?int $tagId = null, + ?int $mailboxId = null) use (&$createdSteps): ActionStep { + $createdSteps[] = ['name' => $name, 'order' => $order, 'actionId' => $actionId, + 'tagId' => $tagId, 'mailboxId' => $mailboxId]; + return new ActionStep(); + }); + + $this->migrationService->importQuickActions($this->user, $this->importSource, $this->output, + self::ACCOUNT_MAPPING, [], []); + + self::assertSame( + [['name' => 'markAsRead', 'order' => 1, 'actionId' => 42, 'tagId' => null, 'mailboxId' => null]], + $createdSteps, + ); + } + + private function getFirstQuickAction(): Actions { + $firstAction = new Actions(); + + $firstAction->setId(self::FIRST_QUICK_STEP_ID); + $firstAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $firstAction->setName('First quick action'); + + $actionSteps = $this->getActionStepsForFirstQuickAction(); + $firstAction->setActionSteps($actionSteps); + + return $firstAction; + } + + private function getActionStepsForFirstQuickAction(): array { + $firstStep = new ActionStep(); + $firstStep->setId(1); + $firstStep->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $firstStep->setName('markAsRead'); + $firstStep->setOrder(1); + + $secondStep = new ActionStep(); + $secondStep->setId(2); + $secondStep->setActionId(self::QUICK_STEP_ACCOUNT_ID); + $secondStep->setMailboxId(self::QUICK_STEP_MAILBOX_ID); + $secondStep->setName('moveThread'); + $secondStep->setOrder(2); + + return [$firstStep, $secondStep]; + } + + private function getSecondQuickAction(): Actions { + $secondAction = new Actions(); + + $secondAction->setId(self::SECOND_QUICK_STEP_ID); + $secondAction->setAccountId(self::QUICK_STEP_ACCOUNT_ID); + $secondAction->setName('Second quick action'); + + $actionSteps = $this->getActionStepsForSecondQuickAction(); + $secondAction->setActionSteps($actionSteps); + + return $secondAction; + } + + private function getActionStepsForSecondQuickAction(): array { + $firstStep = new ActionStep(); + $firstStep->setId(3); + $firstStep->setActionId(self::SECOND_QUICK_STEP_ID); + $firstStep->setName('markAsImportant'); + $firstStep->setOrder(1); + + $secondStep = new ActionStep(); + $secondStep->setId(4); + $secondStep->setActionId(self::SECOND_QUICK_STEP_ID); + $secondStep->setTagId(self::QUICK_STEP_TAG_ID); + $secondStep->setName('applyTag'); + $secondStep->setOrder(2); + + return [$firstStep, $secondStep]; + } + + private function getQuickActions(): array { + return [$this->getFirstQuickAction(), $this->getSecondQuickAction()]; + } +} diff --git a/tests/Unit/UserMigration/Service/SmimeMigrationServiceTest.php b/tests/Unit/UserMigration/Service/SmimeMigrationServiceTest.php new file mode 100644 index 0000000000..942766fba0 --- /dev/null +++ b/tests/Unit/UserMigration/Service/SmimeMigrationServiceTest.php @@ -0,0 +1,304 @@ +output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(SmimeMigrationService::class); + + $this->serviceMock->getParameter('crypto') + ->method('encrypt') + ->willReturnCallback(function (string $value) { + return $value . '_encrypted'; + }); + + $this->serviceMock->getParameter('crypto') + ->method('decrypt') + ->willReturnCallback(function (string $encryptedValue) { + if (!str_ends_with($encryptedValue, '_encrypted')) { + throw new Exception('Invalid encrypted value'); + } + return substr($encryptedValue, 0, strlen($encryptedValue) - strlen('_encrypted')); + }); + + $this->migrationService = $this->serviceMock->getService(); + } + + public function testImportWithoutCertificateFolderImportsNothing(): void { + $this->importSource->expects(self::once()) + ->method('getFolderListing') + ->with(SmimeMigrationService::SMIME_CERTIFICATE_FOLDER) + ->willReturn([]); + $this->serviceMock->getParameter('smimeService')->expects(self::never())->method('createCertificate'); + + $mappedCertificates = $this->migrationService->importCertificates($this->user, $this->importSource, $this->output); + + self::assertSame([], $mappedCertificates); + } + + public function testExportWithoutCertificates(): void { + $this->serviceMock->getParameter('smimeService') + ->method('findAllCertificates') + ->with(self::USER_ID) + ->willReturn([]); + + $this->exportDestination->expects(self::never())->method('addFileContents'); + + $this->migrationService->exportCertificates($this->user, $this->exportDestination, $this->output); + } + + public function testEstimatedExportSizeMeasuresKeyMaterial(): void { + $certificate = new SmimeCertificate(); + $certificate->setCertificate(str_repeat('c', 3000)); + $certificate->setPrivateKey(str_repeat('k', 2000)); + + $this->serviceMock->getParameter('smimeService') + ->method('findAllCertificates') + ->with(self::USER_ID) + ->willReturn([$certificate]); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(SmimeMigrationService::ESTIMATED_CERTIFICATE_SIZE + 3000 + 2000, $size); + } + + public function testEstimatedExportSizeWithoutPrivateKey(): void { + $certificate = new SmimeCertificate(); + $certificate->setCertificate(str_repeat('c', 3000)); + + $this->serviceMock->getParameter('smimeService') + ->method('findAllCertificates') + ->willReturn([$certificate]); + + self::assertSame(SmimeMigrationService::ESTIMATED_CERTIFICATE_SIZE + 3000, $this->migrationService->getEstimatedExportSize($this->user)); + } + + public function testExportsMultipleCertificates(): void { + $userCertificate = $this->getUserCertificate(); + $chainCertificate = $this->getChainCertificate(); + $callCount = 0; + $expectedCertificates = [$userCertificate, $chainCertificate]; + $this->exportDestination->expects(self::exactly(2))->method('addFileContents') + ->willReturnCallback(function (string $filename, string $fileContent) use (&$callCount, $expectedCertificates): void { + $expected = $expectedCertificates[$callCount]; + self::assertSame( + str_replace(MailAccountMigrator::FILENAME_PLACEHOLDER, (string)$expected->getId(), SmimeMigrationService::SMIME_CERTIFICATE_FILES), + $filename + ); + self::assertSame( + json_encode(['id' => $expected->getId(), 'certificate' => $expected->getCertificate(), 'privateKey' => $expected->getPrivateKey()]), + $fileContent + ); + $callCount++; + }); + $this->serviceMock->getParameter('crypto')->expects(self::exactly(3))->method('decrypt'); + + $allEncryptedCertificates = [$this->getEncryptedSmimeCertificate($userCertificate), $this->getEncryptedSmimeCertificate($chainCertificate)]; + $this->serviceMock->getParameter('smimeService')->method('findAllCertificates')->with(self::USER_ID)->willReturn($allEncryptedCertificates); + $this->migrationService->exportCertificates($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoCertificates(): void { + $certificateFiles = []; + + $this->serviceMock->getParameter('smimeService')->method('findAllCertificates')->with(self::USER_ID)->willReturn($certificateFiles); + $this->exportDestination->expects(self::never())->method('addFileContents'); + + $this->migrationService->exportCertificates($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleCertificates(): void { + $userCertificate = $this->getUserCertificate(); + $chainCertificate = $this->getChainCertificate(); + + // getFolderListing() returns names relative to the folder + $certificateFileNames = [$userCertificate->getId() . '.json', $chainCertificate->getId() . '.json']; + $certificateFiles = array_map( + static fn (string $name) => SmimeMigrationService::SMIME_CERTIFICATE_FOLDER . $name, + $certificateFileNames, + ); + + $this->importSource->expects(self::once())->method('getFolderListing')->willReturn($certificateFileNames); + + $expectedCertificates = [$userCertificate, $chainCertificate]; + + $getFileContentsCallCount = 0; + $this->importSource->expects(self::exactly(2))->method('getFileContents') + ->willReturnCallback(function (string $filename) use (&$getFileContentsCallCount, $certificateFiles, $expectedCertificates): string { + $expected = $expectedCertificates[$getFileContentsCallCount]; + self::assertSame($certificateFiles[$getFileContentsCallCount], $filename); + $result = json_encode(['id' => $expected->getId(), 'certificate' => $expected->getCertificate(), 'privateKey' => $expected->getPrivateKey()]); + $getFileContentsCallCount++; + return $result; + }); + + $createCertificateCallCount = 0; + $this->serviceMock->getParameter('smimeService')->expects(self::exactly(2))->method('createCertificate') + ->willReturnCallback(function (string $userId, string $certificate, ?string $privateKey) use (&$createCertificateCallCount, $expectedCertificates): SmimeCertificate { + $expected = $expectedCertificates[$createCertificateCallCount]; + self::assertSame(self::USER_ID, $userId); + self::assertSame($expected->getCertificate(), $certificate); + self::assertSame($expected->getPrivateKey(), $privateKey); + $newCertificate = new SmimeCertificate(); + $newCertificate->setId(random_int(10, 999)); + $createCertificateCallCount++; + return $newCertificate; + }); + + $mappedCertificates = $this->migrationService->importCertificates($this->user, $this->importSource, $this->output); + + $this->assertCount(2, $mappedCertificates); + $this->assertArrayHasKey($userCertificate->getId(), $mappedCertificates); + $this->assertIsInt($mappedCertificates[$userCertificate->getId()]); + $this->assertArrayHasKey($chainCertificate->getId(), $mappedCertificates); + $this->assertIsInt($mappedCertificates[$chainCertificate->getId()]); + } + + public function testImportNoCertificatesFolder(): void { + $this->importSource->expects(self::once()) + ->method('getFolderListing') + ->with(SmimeMigrationService::SMIME_CERTIFICATE_FOLDER) + ->willReturn([]); + $this->importSource->expects(self::never())->method('getFileContents'); + $this->serviceMock->getParameter('smimeService')->expects(self::never())->method('createCertificate'); + $mappedTags = $this->migrationService->importCertificates($this->user, $this->importSource, $this->output); + $this->assertCount(0, $mappedTags); + } + + public static function provideCreateCertificateFailures(): array { + return [ + 'private key mismatch' => [new ServiceException('Private key does not match certificate')], + 'unparsable certificate' => [new SmimeCertificateParserException('Could not parse certificate')], + ]; + } + + /** + * @dataProvider provideCreateCertificateFailures + */ + public function testImportSkipsCertificatesThatCannotBeCreated(Exception $failure): void { + $userCertificate = $this->getUserCertificate(); + $chainCertificate = $this->getChainCertificate(); + + $this->importSource->expects(self::once())->method('getFolderListing') + ->willReturn(['1.json', '2.json']); + $this->importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($userCertificate, $chainCertificate): string { + $certificate = str_ends_with($path, '1.json') ? $userCertificate : $chainCertificate; + return json_encode(['id' => $certificate->getId(), + 'certificate' => $certificate->getCertificate(), + 'privateKey' => $certificate->getPrivateKey()]); + }); + + $this->serviceMock->getParameter('smimeService') + ->expects(self::exactly(2)) + ->method('createCertificate') + ->willReturnCallback(function (string $userId, string $certificate) use ($userCertificate, $failure): SmimeCertificate { + if ($certificate === $userCertificate->getCertificate()) { + throw $failure; + } + $created = new SmimeCertificate(); + $created->setId(500); + return $created; + }); + + $mappedCertificates = $this->migrationService->importCertificates($this->user, $this->importSource, $this->output); + + self::assertSame([$chainCertificate->getId() => 500], $mappedCertificates); + } + + public static function provideFileContentsWithNoCertificatesImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'missing required fields' => [json_encode(['unexpected' => 'field'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoCertificatesImported + */ + public function testImportEmptyOrInvalidCertificates(string $fileContents): void { + $certificateFile = SmimeMigrationService::SMIME_CERTIFICATE_FOLDER . '1.json'; + $this->importSource->expects(self::once())->method('getFolderListing')->willReturn(['1.json']); + $this->importSource->expects(self::once())->method('getFileContents')->with($certificateFile)->willReturn($fileContents); + $this->serviceMock->getParameter('smimeService')->expects(self::never())->method('createCertificate'); + $mappedCertificates = $this->migrationService->importCertificates($this->user, $this->importSource, $this->output); + $this->assertCount(0, $mappedCertificates); + } + + private function getUserCertificate(): SmimeCertificate { + $certificate = new SmimeCertificate(); + + $certificate->setId(1); + $certificate->setUserId(self::USER_ID); + $certificate->setEmailAddress('user@domain.tld'); + $publicKey = file_get_contents(__DIR__ . '/../../../data/smime-certs/user@domain.tld.crt'); + $privateKey = file_get_contents(__DIR__ . '/../../../data/smime-certs/user@domain.tld.key'); + $certificate->setCertificate($publicKey); + $certificate->setPrivateKey($privateKey); + + return $certificate; + } + + private function getChainCertificate(): SmimeCertificate { + $certificate = new SmimeCertificate(); + + $certificate->setId(2); + $certificate->setUserId(self::USER_ID); + $certificate->setEmailAddress('chain@imap.localhost'); + $publicKey = file_get_contents(__DIR__ . '/../../../data/smime-certs/chain@imap.localhost.crt'); + $certificate->setCertificate($publicKey); + $certificate->setPrivateKey(null); + + return $certificate; + } + + private function getEncryptedSmimeCertificate(SmimeCertificate $certificateToEncrypt): SmimeCertificate { + $encryptedCertificate = new SmimeCertificate(); + + $encryptedCertificate->setId($certificateToEncrypt->getId()); + $encryptedCertificate->setUserId($certificateToEncrypt->getUserId()); + $encryptedCertificate->setEmailAddress($certificateToEncrypt->getEmailAddress()); + $encryptedCertificate->setCertificate("{$certificateToEncrypt->getCertificate()}_encrypted"); + $encryptedCertificate->setPrivateKey($certificateToEncrypt->getPrivateKey() !== null ? "{$certificateToEncrypt->getPrivateKey()}_encrypted" : null); + + return $encryptedCertificate; + } +} diff --git a/tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php b/tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php new file mode 100644 index 0000000000..762095f867 --- /dev/null +++ b/tests/Unit/UserMigration/Service/TagsMigrationServiceTest.php @@ -0,0 +1,241 @@ +output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(TagsMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testEstimatedExportSizeScalesWithTagCount(): void { + $this->serviceMock->getParameter('tagMapper') + ->method('getAllTagsForUser') + ->with(self::USER_ID) + ->willReturn([$this->getTestingTag(), $this->getSuccessfulTag()]); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(2 * TagsMigrationService::ESTIMATED_TAG_SIZE, $size); + } + + public function testEstimatedExportSizeWithoutTags(): void { + $this->serviceMock->getParameter('tagMapper') + ->method('getAllTagsForUser') + ->willReturn([]); + + self::assertSame(0, $this->migrationService->getEstimatedExportSize($this->user)); + } + + public function testExportsMultipleTags(): void { + $tagsList = [$this->getTestingTag(), $this->getSuccessfulTag()]; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(TagsMigrationService::TAGS_FILE, json_encode($tagsList)); + + $this->serviceMock->getParameter('tagMapper') + ->method('getAllTagsForUser') + ->with(self::USER_ID) + ->willReturn($tagsList); + $this->migrationService->exportTags($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoTags(): void { + $tagsList = []; + + $this->serviceMock->getParameter('tagMapper') + ->method('getAllTagsForUser') + ->with(self::USER_ID) + ->willReturn($tagsList); + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(TagsMigrationService::TAGS_FILE, json_encode($tagsList)); + + $this->migrationService->exportTags($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleTags(): void { + $testingTag = $this->getTestingTag(); + $successfulTag = $this->getSuccessfulTag(); + $tagsList = [$testingTag, $successfulTag]; + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TagsMigrationService::TAGS_FILE) + ->willReturn(json_encode($tagsList)); + + $callCount = 0; + $expectedTags = [$testingTag, $successfulTag]; + $this->serviceMock->getParameter('tagMapper') + ->expects(self::exactly(2)) + ->method('insert') + ->willReturnCallback(function (Tag $tag) use ( + &$callCount, + $expectedTags + ): Tag { + $expectedTag = $expectedTags[$callCount]; + self::assertSame(self::USER_ID, $tag->getUserId()); + self::assertSame($expectedTag->getDisplayName(), $tag->getDisplayName()); + self::assertSame($expectedTag->getImapLabel(), $tag->getImapLabel()); + self::assertSame($expectedTag->getColor(), $tag->getColor()); + self::assertSame($expectedTag->getIsDefaultTag(), $tag->getIsDefaultTag()); + $tag->setId(random_int(10, 999)); + $callCount++; + return $tag; + }); + + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + + $this->assertCount(2, $mappedTags); + $this->assertArrayHasKey($testingTag->getId(), $mappedTags); + $this->assertIsInt($mappedTags[$testingTag->getId()]); + $this->assertArrayHasKey($successfulTag->getId(), $mappedTags); + $this->assertIsInt($mappedTags[$successfulTag->getId()]); + } + + public function testImportNoTags(): void { + $tagsList = []; + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TagsMigrationService::TAGS_FILE) + ->willReturn(json_encode($tagsList)); + $this->serviceMock->getParameter('tagMapper') + ->expects(self::never()) + ->method('insert'); + + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + + $this->assertCount(0, $mappedTags); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TagsMigrationService::TAGS_FILE) + ->willThrowException(new UserMigrationException()); + $this->serviceMock->getParameter('tagMapper') + ->expects(self::never()) + ->method('insert'); + + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + + $this->assertCount(0, $mappedTags); + } + + public static function provideFileContentsWithNoTagsImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoTagsImported + */ + public function testImportEmptyOrInvalidTags(string $fileContents): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TagsMigrationService::TAGS_FILE) + ->willReturn($fileContents); + $this->serviceMock->getParameter('tagMapper') + ->expects(self::never()) + ->method('insert'); + + $mappedTags = $this->migrationService->importTags($this->user, $this->importSource, $this->output); + + $this->assertCount(0, $mappedTags); + } + + public function testImportReusesTagsTheUserAlreadyHas(): void { + $testingTag = $this->getTestingTag(); + $successfulTag = $this->getSuccessfulTag(); + $existingTag = $this->getSuccessfulTag(); + $existingTag->setId(77); + + $serviceMock = $this->createServiceMock(TagsMigrationService::class); + $serviceMock->getParameter('tagMapper') + ->method('getAllTagsForUser') + ->with(self::USER_ID) + ->willReturn([$existingTag]); + $serviceMock->getParameter('tagMapper') + ->expects(self::once()) + ->method('insert') + ->willReturnCallback(static function (Tag $tag): Tag { + $tag->setId(88); + return $tag; + }); + + $this->importSource->method('getFileContents') + ->with(TagsMigrationService::TAGS_FILE) + ->willReturn(json_encode([$testingTag, $successfulTag])); + + $mappedTags = $serviceMock->getService() + ->importTags($this->user, $this->importSource, $this->output); + + self::assertSame( + [$testingTag->getId() => 88, $successfulTag->getId() => 77], + $mappedTags, + ); + } + + private function getTestingTag(): Tag { + $testingTag = new Tag(); + + $testingTag->setId(1); + $testingTag->setUserId(self::USER_ID); + $testingTag->setImapLabel('testing'); + $testingTag->setDisplayName('Testing'); + $testingTag->setColor('#fff'); + $testingTag->setIsDefaultTag(false); + + return $testingTag; + } + + private function getSuccessfulTag(): Tag { + $successfulTag = new Tag(); + + $successfulTag->setId(2); + $successfulTag->setUserId(self::USER_ID); + $successfulTag->setImapLabel('successful'); + $successfulTag->setDisplayName('Successful'); + $successfulTag->setColor('#fff'); + $successfulTag->setIsDefaultTag(false); + + return $successfulTag; + } + +} diff --git a/tests/Unit/UserMigration/Service/TextBlocksMigrationServiceTest.php b/tests/Unit/UserMigration/Service/TextBlocksMigrationServiceTest.php new file mode 100644 index 0000000000..9af60d97f8 --- /dev/null +++ b/tests/Unit/UserMigration/Service/TextBlocksMigrationServiceTest.php @@ -0,0 +1,188 @@ +output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(TextBlocksMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testEstimatedExportSizeMeasuresTheContent(): void { + $textBlock = new TextBlock(); + $textBlock->setTitle('Greeting'); + $textBlock->setContent(str_repeat('a', 5000)); + $textBlock->setPreview('Hi'); + + $this->serviceMock->getParameter('textBlockService') + ->method('findAll') + ->with(self::USER_ID) + ->willReturn([$textBlock]); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(TextBlocksMigrationService::ESTIMATED_TEXT_BLOCK_SIZE + 8 + 5000 + 2, $size); + } + + public function testExportsMultipleTextBlocks(): void { + $textBlocksList = [$this->getLoremIpsum1(), $this->getIpsumLorem2()]; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE, json_encode($textBlocksList)); + + $this->serviceMock->getParameter('textBlockService') + ->method('findAll') + ->with(self::USER_ID) + ->willReturn($textBlocksList); + + $this->migrationService->exportTextBlocks($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoneTextBlocks(): void { + $textBlocksList = []; + + $this->serviceMock->getParameter('textBlockService') + ->method('findAll') + ->with(self::USER_ID) + ->willReturn($textBlocksList); + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE, json_encode($textBlocksList)); + + $this->migrationService->exportTextBlocks($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleTextBlocks(): void { + $textBlock1 = $this->getLoremIpsum1(); + $textBlock2 = $this->getIpsumLorem2(); + $textBlocksList = [$textBlock1, $textBlock2]; + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE) + ->willReturn(json_encode($textBlocksList)); + + $callCount = 0; + $expectedBlocks = [$textBlock1, $textBlock2]; + $this->serviceMock->getParameter('textBlockService') + ->expects(self::exactly(2)) + ->method('create') + ->willReturnCallback(function (string $uid, string $title, string $content) use (&$callCount, $expectedBlocks): TextBlock { + $expectedBlock = $expectedBlocks[$callCount]; + self::assertSame(self::USER_ID, $uid); + self::assertEquals([$expectedBlock->getTitle(), $expectedBlock->getContent()], [$title, $content]); + $callCount++; + return $expectedBlock; + }); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportNoneTextBlocks(): void { + $textBlocksList = []; + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE) + ->willReturn(json_encode($textBlocksList)); + + $this->serviceMock->getParameter('textBlockService') + ->expects(self::never()) + ->method('create'); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE) + ->willThrowException(new UserMigrationException()); + + $this->serviceMock->getParameter('textBlockService') + ->expects(self::never()) + ->method('create'); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + public static function provideFileContentsWithNoTextBlocksImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoTextBlocksImported + */ + public function testImportEmptyOrInvalidTextBlocks(string $fileContents): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TextBlocksMigrationService::TEXT_BLOCKS_FILE) + ->willReturn($fileContents); + + $this->serviceMock->getParameter('textBlockService') + ->expects(self::never()) + ->method('create'); + + $this->migrationService->importTextBlocks($this->user, $this->importSource, $this->output); + } + + private function getLoremIpsum1(): TextBlock { + $textBlock = new TextBlock(); + + $textBlock->setId(1); + $textBlock->setOwner(self::USER_ID); + $textBlock->setTitle('Lorem ipsum 1'); + $textBlock->setPreview('Lorem ipsum dolor sit amet'); + $textBlock->setContent('

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

'); + + return $textBlock; + } + + private function getIpsumLorem2(): TextBlock { + $textBlock = new TextBlock(); + + $textBlock->setId(2); + $textBlock->setOwner(self::USER_ID); + $textBlock->setTitle('Ipsum lorem 2'); + $textBlock->setPreview('Ipsum lorem amet sit dolor'); + $textBlock->setContent('

At vero eos et accusam et justo duo dolores et ea rebum.

'); + + return $textBlock; + } +} diff --git a/tests/Unit/UserMigration/Service/TrustedSendersMigrationServiceTest.php b/tests/Unit/UserMigration/Service/TrustedSendersMigrationServiceTest.php new file mode 100644 index 0000000000..15401cdef8 --- /dev/null +++ b/tests/Unit/UserMigration/Service/TrustedSendersMigrationServiceTest.php @@ -0,0 +1,164 @@ +output = $this->createMock(OutputInterface::class); + $this->exportDestination = $this->createMock(IExportDestination::class); + $this->importSource = $this->createMock(IImportSource::class); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn(self::USER_ID); + + $this->serviceMock = $this->createServiceMock(TrustedSendersMigrationService::class); + $this->migrationService = $this->serviceMock->getService(); + } + + public function testEstimatedExportSizeScalesWithSenderCount(): void { + $this->serviceMock->getParameter('trustedSenderService') + ->method('getTrusted') + ->with(self::USER_ID) + ->willReturn([$this->getTrustedIndividual(), $this->getTrustedDomain()]); + + $size = $this->migrationService->getEstimatedExportSize($this->user); + + self::assertSame(2 * TrustedSendersMigrationService::ESTIMATED_TRUSTED_SENDER_SIZE, $size); + } + + public function testExportsMultipleTrustedSenders(): void { + $trustedSendersList = [$this->getTrustedIndividual(), $this->getTrustedDomain()]; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(TrustedSendersMigrationService::TRUSTED_SENDERS_FILE, json_encode($trustedSendersList)); + + $this->serviceMock->getParameter('trustedSenderService') + ->method('getTrusted') + ->with(self::USER_ID) + ->willReturn($trustedSendersList); + $this->migrationService->exportTrustedSenders($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoneTrustedSenders(): void { + $trustedSendersList = []; + $this->exportDestination->expects(self::once()) + ->method('addFileContents') + ->with(TrustedSendersMigrationService::TRUSTED_SENDERS_FILE, json_encode($trustedSendersList)); + + $this->serviceMock->getParameter('trustedSenderService') + ->method('getTrusted') + ->with(self::USER_ID) + ->willReturn($trustedSendersList); + $this->migrationService->exportTrustedSenders($this->user, $this->exportDestination, $this->output); + } + + public function testImportMultipleTrustedSenders(): void { + $trustedIndividual = $this->getTrustedIndividual(); + $trustedDomain = $this->getTrustedDomain(); + $trustedSendersList = [$trustedIndividual, $trustedDomain]; + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TrustedSendersMigrationService::TRUSTED_SENDERS_FILE) + ->willReturn(json_encode($trustedSendersList)); + + $callCount = 0; + $expectedSenders = [$trustedIndividual, $trustedDomain]; + $this->serviceMock->getParameter('trustedSenderService') + ->expects(self::exactly(2)) + ->method('trust') + ->willReturnCallback(function (string $uid, string $email, string $type) use (&$callCount, $expectedSenders): void { + $expectedSender = $expectedSenders[$callCount]; + self::assertSame(self::USER_ID, $uid); + self::assertEquals([$expectedSender->getEmail(), $expectedSender->getType()], [$email, $type]); + $callCount++; + }); + + $this->migrationService->importTrustedSenders($this->user, $this->importSource, $this->output); + } + + public function testImportNoFileIsBeingIgnored(): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TrustedSendersMigrationService::TRUSTED_SENDERS_FILE) + ->willThrowException(new UserMigrationException()); + + $this->serviceMock->getParameter('trustedSenderService') + ->expects(self::never()) + ->method('trust'); + + $this->migrationService->importTrustedSenders($this->user, $this->importSource, $this->output); + } + + public static function provideFileContentsWithNoTrustedSendersImported(): array { + return [ + 'empty list' => [json_encode([])], + 'invalid JSON' => ['this is not valid json {{{'], + 'JSON object instead of list' => [json_encode(['unexpected' => 'object'])], + ]; + } + + /** + * @dataProvider provideFileContentsWithNoTrustedSendersImported + */ + public function testImportEmptyOrInvalidTrustedSenders(string $fileContents): void { + $this->importSource->expects(self::once()) + ->method('getFileContents') + ->with(TrustedSendersMigrationService::TRUSTED_SENDERS_FILE) + ->willReturn($fileContents); + + $this->serviceMock->getParameter('trustedSenderService') + ->expects(self::never()) + ->method('trust'); + + $this->migrationService->importTrustedSenders($this->user, $this->importSource, $this->output); + } + + private function getTrustedIndividual(): TrustedSender { + $individualSender = new TrustedSender; + + $individualSender->setId(1); + $individualSender->setUserId(self::USER_ID); + $individualSender->setEmail('max@mustermann.com'); + $individualSender->setType('individual'); + + return $individualSender; + } + + private function getTrustedDomain(): TrustedSender { + $domainSender = new TrustedSender(); + + $domainSender->setId(2); + $domainSender->setUserId(self::USER_ID); + $domainSender->setEmail('nextcloud.com'); + $domainSender->setType('domain'); + + return $domainSender; + } +}