diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe85857457..e7ae504335 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -304,6 +304,57 @@ jobs: env: CI: true + user-migration-smoke-test: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.4' ] + nextcloud-versions: [ 'stable33' ] + db: [ 'mysql' ] + env: + TEST_USER: user1 + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: nextcloud/user_migration + path: nextcloud/apps/user_migration + - name: Install dependencies + working-directory: nextcloud/apps/user_migration + run: composer install --no-dev + - 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: Test basic mail export + run: php -f nextcloud/occ user:export -t mail_account $TEST_USER $EXPORT_DIRECTORY + - 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 + summary: runs-on: ubuntu-latest-low needs: @@ -311,6 +362,7 @@ jobs: - integration-tests - frontend-unit-test - frontend-e2e-tests + - user-migration-smoke-test if: always() @@ -325,3 +377,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/Service/AccountService.php b/lib/Service/AccountService.php index dd06812ada..1cdbfa5879 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -149,6 +149,10 @@ public function delete(string $currentUserId, int $accountId): void { } $this->aliasesService->deleteAll($accountId); $this->mapper->delete($mailAccount); + + // Invalidate cache to ensure deleted account is not included + // in subsequent `findByUserId` and `findByUserIdAndAddress` calls + unset($this->accounts[$currentUserId]); } /** @@ -164,17 +168,31 @@ public function deleteByAccountId(int $accountId): void { } $this->aliasesService->deleteAll($accountId); $this->mapper->delete($mailAccount); + + // Invalidate cache to ensure deleted account is not included + // in subsequent `findByUserId` and `findByUserIdAndAddress` calls + unset($this->accounts[$mailAccount->getUserId()]); } /** * @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 + unset($this->accounts[$newAccount->getUserId()]); return $newAccount; } @@ -196,6 +214,10 @@ public function updateSignature(int $id, string $uid, ?string $signature = null) $mailAccount = $account->getMailAccount(); $mailAccount->setSignature($signature); $this->mapper->save($mailAccount); + + // Invalidate cache to ensure changed signature is being included + // in subsequent `findByUserId` and `findByUserIdAndAddress` calls + unset($this->accounts[$uid]); } /** diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php index 424028f67e..40ecb5ea42 100644 --- a/lib/UserMigration/MailAccountMigrator.php +++ b/lib/UserMigration/MailAccountMigrator.php @@ -4,17 +4,17 @@ /** * 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\Exception\ClientException; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\UserMigration\Service\AccountMigrationService; +use OCA\Mail\UserMigration\Service\AppConfigMigrationService; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\IL10N; use OCP\IUser; use OCP\Security\ICrypto; @@ -23,17 +23,16 @@ use OCP\UserMigration\IMigrator; 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 { + public const EXPORT_ROOT = Application::APP_ID; + public const FILENAME_PLACEHOLDER = '{filename}'; public function __construct( - private AccountService $accountService, - private AliasesService $aliasesService, - private IL10N $l10n, - private ICrypto $crypto, + private readonly IL10N $l10n, + private readonly ICrypto $crypto, + private readonly AccountMigrationService $accountMigrationService, + private readonly AppConfigMigrationService $appConfigMigrationService, ) { } @@ -42,140 +41,36 @@ 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'], - ); - - $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(function (Alias $alias) { - $data = $alias->jsonSerialize(); - return $data; - }, $aliases); - - $exportDestination->addFileContents($accountFilePath, json_encode($accountData)); - $index[$account->getId()] = $accountFilePath; - } + $output->writeln($this->l10n->t("Exporting mail accounts for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE); - $exportDestination->addFileContents('mail/accounts/index.json', json_encode($index)); + $this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output); } #[\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); - } - 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'], - ); - } - } + $output->writeln($this->l10n->t("Importing mail accounts for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE); + $this->deleteExistingData($user, $output); + + $this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output); + + $this->accountMigrationService->scheduleBackgroundJobs($user, $output); + } + + /** + * Delete all existing user data of our app to ensure + * the result of the import is always the same. + * + * @param IUser $user + * @param OutputInterface $output + * @throws ClientException + * @throws DoesNotExistException + * @throws ServiceException + */ + private function deleteExistingData(IUser $user, OutputInterface $output): void { + $output->writeln($this->l10n->t("Deleting existing mail data for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE); + + $this->appConfigMigrationService->deleteAppConfiguration($user, $output); + $this->accountMigrationService->deleteAllAccounts($user, $output); } #[\Override] @@ -195,7 +90,7 @@ public function getDescription(): string { #[\Override] public function getVersion(): int { - return 01_00_00; + return 02_00_00; } #[\Override] diff --git a/lib/UserMigration/Service/AccountMigrationService.php b/lib/UserMigration/Service/AccountMigrationService.php new file mode 100644 index 0000000000..5445bfd582 --- /dev/null +++ b/lib/UserMigration/Service/AccountMigrationService.php @@ -0,0 +1,78 @@ +accountService->findByUserId($user->getUID()); + $accountCount = count($allAccounts); + $uid = $user->getUID(); + + $output->writeln($this->l10n->t("Deleting {$accountCount} mail account(s) for user {$uid}"), OutputInterface::VERBOSITY_VERBOSE); + + foreach ($allAccounts as $account) { + $accountId = $account->getId(); + + if ($account->getMailAccount()->getProvisioningId() !== null) { + $output->writeln($this->l10n->t("Skipping deletion of provisioned account {$account->getId()}"), OutputInterface::VERBOSITY_VERBOSE); + continue; + } + + $this->accountService->deleteByAccountId($accountId); + $output->writeln($this->l10n->t("Deleted mail account {$accountId}"), OutputInterface::VERBOSITY_VERBOSE); + } + } + + /** + * Schedule background jobs for the added accounts. + * 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 IUser $user + * @param OutputInterface $output + * @return void + */ + public function scheduleBackgroundJobs(IUser $user, OutputInterface $output): void { + $accounts = $this->accountService->findByUserId($user->getUID()); + $accountCount = count($accounts); + + $output->writeln($this->l10n->t("Scheduling background jobs for {$accountCount} mail account(s)"), OutputInterface::VERBOSITY_VERBOSE); + + foreach ($accounts as $account) { + $mailAccount = $account->getMailAccount(); + $mailAccountId = $mailAccount->getId(); + $this->accountService->scheduleBackgroundJobs($mailAccountId); + $output->writeln($this->l10n->t("Scheduled background jobs for mail account {$mailAccountId}"), OutputInterface::VERBOSITY_VERY_VERBOSE); + } + } +} diff --git a/lib/UserMigration/Service/AppConfigMigrationService.php b/lib/UserMigration/Service/AppConfigMigrationService.php new file mode 100644 index 0000000000..4d5b02aaff --- /dev/null +++ b/lib/UserMigration/Service/AppConfigMigrationService.php @@ -0,0 +1,139 @@ +writeln( + $this->l10n->t("Exporting mail app configuration for user {$user->getUID()}"), + OutputInterface::VERBOSITY_VERBOSE + ); + + $appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID); + $appConfigSettings = array_map(function (string $appConfigKey) use ($user) { + return [ + 'key' => $appConfigKey, + 'value' => $this->config->getUserValue($user->getUID(), Application::APP_ID, $appConfigKey) + ]; + }, $appConfigKeys); + + try { + $exportDestination->addFileContents(self::APP_CONFIGURATION_FILE, json_encode($appConfigSettings, JSON_THROW_ON_ERROR)); + } catch (JsonException|UserMigrationException) { + throw new UserMigrationException("Failed to export mail app configuration for user {$user->getUID()}"); + } + } + + /** + * Import the user configuration stored via IConfig + * on export + * + * @throws \OCP\PreConditionNotMetException + * @throws \OCP\UserMigration\UserMigrationException + */ + public function importAppConfiguration(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln( + $this->l10n->t("Importing mail app configuration for user {$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 {$user->getUID()} not found. Continue..."), + OutputInterface::VERBOSITY_VERBOSE + ); + + return; + } + + $appConfig = json_decode($appConfigFileContent, true); + $this->validateAppConfig($appConfig); + + foreach ($appConfig as $appSetting) { + $output->writeln( + $this->l10n->t("Importing mail app configuration key {$appSetting['key']} for user {$user->getUID()}"), + OutputInterface::VERBOSITY_VERBOSE + ); + + $this->config->setUserValue($user->getUID(), Application::APP_ID, $appSetting['key'], $appSetting['value']); + } + + } + + /** + * Delete the user configuration stored via IConfig. + */ + public function deleteAppConfiguration(IUser $user, OutputInterface $output): void { + $output->writeln( + $this->l10n->t("Delete existing mail app configuration for user {$user->getUID()}"), + OutputInterface::VERBOSITY_VERBOSE + ); + + $appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID); + + foreach ($appConfigKeys as $appConfigKey) { + $output->writeln( + $this->l10n->t("Deleting mail app configuration key {$appConfigKey} for user {$user->getUID()}"), + OutputInterface::VERBOSITY_VERBOSE + ); + + $this->config->deleteUserValue($user->getUID(), Application::APP_ID, $appConfigKey); + } + } + + /** + * 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 = array_key_exists('key', $appSetting) && is_string($appSetting['key']); + $valueIsValid = array_key_exists('value', $appSetting) && is_string($appSetting['value']); + + if (!$appSettingArrayIsValid || !$keyIsValid || !$valueIsValid) { + throw new UserMigrationException('Invalid mail app configuration entry'); + } + } + } +} diff --git a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php index a785966ec2..a695bcff46 100644 --- a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php +++ b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php @@ -12,15 +12,9 @@ use ChristophWurst\Nextcloud\Testing\DatabaseTransaction; use ChristophWurst\Nextcloud\Testing\TestCase; use ChristophWurst\Nextcloud\Testing\TestUser; -use OCA\Mail\Db\MailAccount; use OCA\Mail\Service\AccountService; use OCA\Mail\UserMigration\MailAccountMigrator; use OCP\Server; -use OCP\UserMigration\IExportDestination; -use OCP\UserMigration\IImportSource; -use OCP\UserMigration\UserMigrationException; -use Symfony\Component\Console\Output\OutputInterface; -use function array_key_exists; class MailAccountMigratorIntegrationTest extends TestCase { @@ -39,43 +33,6 @@ protected function setUp(): void { } public function testMigrate(): void { - $sourceUser = $this->createTestUser(); - $destinationUser = $this->createTestUser(); - $mailAccount = new MailAccount([]); - $mailAccount->setUserId($sourceUser->getUID()); - $this->accountService->save($mailAccount); - - $exportContents = []; - $exportDestination = $this->createMock(IExportDestination::class); - $exportDestination->method('addFileContents') - ->willReturnCallback(function (string $path, string $contents) use (&$exportContents) { - $exportContents[$path] = $contents; - }); - $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}"); - } - return $exportContents[$path]; - }); - - $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); } } diff --git a/tests/Unit/UserMigration/MailAccountMigratorTest.php b/tests/Unit/UserMigration/MailAccountMigratorTest.php index ac7d9c2807..a8da78b6e2 100644 --- a/tests/Unit/UserMigration/MailAccountMigratorTest.php +++ b/tests/Unit/UserMigration/MailAccountMigratorTest.php @@ -4,7 +4,7 @@ /** * 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; @@ -12,18 +12,10 @@ 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\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 { @@ -113,127 +105,4 @@ public function testCanImportOlder(): void { self::assertTrue($canImport); } - - public function testExportBasicAccountInfo(): 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); - } - }); - - $this->migrator->export( - $user, - $exportDestination, - $this->output, - ); - } - - public function testImportInvalidIndex(): void { - $this->expectException(UserMigrationException::class); - $user = $this->createStub(IUser::class); - - $importSource = $this->createMock(IImportSource::class); - $importSource->method('getFileContents') - ->with('mail/accounts/index.json') - ->willReturn('fail'); - - $this->migrator->import( - $user, - $importSource, - $this->output, - ); - } - - public function testImportBasicAccountInfo(): 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' => [], - ]; - $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); - - $this->migrator->import( - $user, - $importSource, - $this->output, - ); - } - } diff --git a/tests/Unit/UserMigration/Service/AccountMigrationServiceTest.php b/tests/Unit/UserMigration/Service/AccountMigrationServiceTest.php new file mode 100644 index 0000000000..a82c11e1a8 --- /dev/null +++ b/tests/Unit/UserMigration/Service/AccountMigrationServiceTest.php @@ -0,0 +1,93 @@ +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 testScheduleBackgroundJobs(): void { + $mailAccount1 = new MailAccount(); + $mailAccount1->setId(101); + $mailAccount1->setUserId(self::USER_ID); + $account1 = new Account($mailAccount1); + + $mailAccount2 = new MailAccount(); + $mailAccount2->setId(102); + $mailAccount2->setUserId(self::USER_ID); + $account2 = new Account($mailAccount2); + + $this->serviceMock->getParameter('accountService') + ->method('findByUserId') + ->with(self::USER_ID) + ->willReturn([$account1, $account2]); + + $scheduledIds = []; + $this->serviceMock->getParameter('accountService') + ->expects(self::exactly(2)) + ->method('scheduleBackgroundJobs') + ->willReturnCallback(function (int $accountId) use (&$scheduledIds) { + $scheduledIds[] = $accountId; + }); + + $this->migrationService->scheduleBackgroundJobs( + $this->user, + $this->output, + ); + + self::assertSame([101, 102], $scheduledIds); + } +} diff --git a/tests/Unit/UserMigration/Service/AppConfigMigrationServiceTest.php b/tests/Unit/UserMigration/Service/AppConfigMigrationServiceTest.php new file mode 100644 index 0000000000..7dc42c9c69 --- /dev/null +++ b/tests/Unit/UserMigration/Service/AppConfigMigrationServiceTest.php @@ -0,0 +1,120 @@ +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 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()); + $this->serviceMock->getParameter('config')->method('getUserValue')->with(self::USER_ID, Application::APP_ID, self::callback(function ($appConfigKey): bool { + return in_array($appConfigKey, $this->getAppKeys(), true); + }))->willReturnCallback(function ($userId, $appId, $appConfigKey): string { + return $this->getAppValue($appConfigKey); + }); + + $this->migrationService->exportAppConfiguration($this->user, $this->exportDestination, $this->output); + } + + public function testExportsNoAppConfiguration(): void { + $trustedSendersList = []; + $this->exportDestination->expects(self::once())->method('addFileContents')->with(AppConfigMigrationService::APP_CONFIGURATION_FILE, json_encode($trustedSendersList)); + + $this->serviceMock->getParameter('config')->expects(self::once())->method('getUserKeys')->with(self::USER_ID, Application::APP_ID)->willReturn($trustedSendersList); + $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->serviceMock->getParameter('config')->expects(self::exactly(3))->method('setUserValue')->with(self::USER_ID, Application::APP_ID, self::callback(function ($key) { + return in_array($key, $this->getAppKeys()); + }), self::callback(function ($searchedValue): bool { + return in_array($searchedValue, $this->getAppValues(), true); + })); + + $this->migrationService->importAppConfiguration($this->user, $this->importSource, $this->output); + } + + public function testImportNoAppConfiguration(): void { + $trustedSendersList = []; + $this->importSource->expects(self::once())->method('getFileContents')->with(AppConfigMigrationService::APP_CONFIGURATION_FILE)->willReturn(json_encode($trustedSendersList)); + + $this->serviceMock->getParameter('config')->expects(self::never())->method('setUserValue'); + + $this->migrationService->importAppConfiguration($this->user, $this->importSource, $this->output); + } + + private function getAppConfig(): array { + return [ + ['key' => 'account-settings', + 'value' => '[{\"accountId\":19,\"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; + } + + private function getAppValues(): array { + return array_map(function (array $appConfig) { + return $appConfig['value']; + }, $this->getAppConfig()); + } +}