diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3004b98093..6eba519334 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -68,6 +68,7 @@ use OCA\Mail\Service\UserPreferenceService; use OCA\Mail\SetupChecks\MailConnectionPerformance; use OCA\Mail\SetupChecks\MailTransport; +use OCA\Mail\UserMigration\MailAccountMigrator; use OCA\Mail\Vendor\Favicon\Favicon; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -87,6 +88,9 @@ include_once __DIR__ . '/../../vendor/autoload.php'; +/** + * @codeCoverageIgnore + */ final class Application extends App implements IBootstrap { public const APP_ID = 'mail'; @@ -164,6 +168,8 @@ public function register(IRegistrationContext $context): void { $context->registerSetupCheck(MailTransport::class); $context->registerSetupCheck(MailConnectionPerformance::class); + $context->registerUserMigrator(MailAccountMigrator::class); + // bypass Horde Translation system Horde_Translation::setHandler('Horde_Imap_Client', new HordeTranslationHandler()); Horde_Translation::setHandler('Horde_Mime', new HordeTranslationHandler()); diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index f91c8f1705..f64f1ac6be 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -31,6 +31,7 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -52,7 +53,8 @@ class AccountsController extends Controller { private IRemoteHostValidator $hostValidator; private MailboxSync $mailboxSync; - public function __construct(string $appName, + public function __construct( + string $appName, IRequest $request, AccountService $accountService, $UserId, @@ -66,6 +68,7 @@ public function __construct(string $appName, IConfig $config, IRemoteHostValidator $hostValidator, MailboxSync $mailboxSync, + private ITimeFactory $timeFactory, ) { parent::__construct($appName, $request); $this->accountService = $accountService; @@ -386,6 +389,13 @@ public function create(string $accountName, } try { $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId, $authMethod, null, $classificationEnabled); + // Set initial heartbeat + $this->config->setUserValue( + $account->getUserId(), + Application::APP_ID, + 'ui-heartbeat', + (string)$this->timeFactory->getTime(), + ); } catch (CouldNotConnectException $e) { $data = [ 'error' => $e->getReason(), diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index 7a6853d0c5..8c26ef1137 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -83,12 +83,12 @@ * @method void setAuthMethod(string $method) * @method int getSignatureMode() * @method void setSignatureMode(int $signatureMode) - * @method string getOauthAccessToken() + * @method string|null getOauthAccessToken() * @method void setOauthAccessToken(string $token) - * @method string getOauthRefreshToken() + * @method string|null getOauthRefreshToken() * @method void setOauthRefreshToken(string $token) * @method int|null getOauthTokenTtl() - * @method void setOauthTokenTtl(int $ttl) + * @method void setOauthTokenTtl(int|null $ttl) * @method int|null getSmimeCertificateId() * @method void setSmimeCertificateId(int|null $smimeCertificateId) * @method int|null getQuotaPercentage() @@ -302,6 +302,7 @@ public function toJson() { 'name' => $this->getName(), 'order' => $this->getOrder(), 'emailAddress' => $this->getEmail(), + 'authMethod' => $this->getAuthMethod() ?? 'password', 'imapHost' => $this->getInboundHost(), 'imapPort' => $this->getInboundPort(), 'imapUser' => $this->getInboundUser(), diff --git a/lib/IMAP/IMAPClientFactory.php b/lib/IMAP/IMAPClientFactory.php index 8a88fc46a6..93f88d6040 100644 --- a/lib/IMAP/IMAPClientFactory.php +++ b/lib/IMAP/IMAPClientFactory.php @@ -101,9 +101,13 @@ public function getClient(Account $account, bool $useCache = true): Horde_Imap_C ]; if ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { try { - $decryptedAccessToken = $this->crypto->decrypt($account->getMailAccount()->getOauthAccessToken()); + $oauthAccessToken = $account->getMailAccount()->getOauthAccessToken(); + if ($oauthAccessToken === null) { + throw new ServiceException('Missing access token for xoauth2 account'); + } + $decryptedAccessToken = $this->crypto->decrypt($oauthAccessToken); } catch (Exception $e) { - throw new ServiceException('Could not decrypt account password: ' . $e->getMessage(), 0, $e); + throw new ServiceException('Could not decrypt account access token: ' . $e->getMessage(), 0, $e); } $params['password'] = $decryptedAccessToken; // Not used, but Horde wants this diff --git a/lib/Integration/GoogleIntegration.php b/lib/Integration/GoogleIntegration.php index 5dcf779d39..918aaf08cb 100644 --- a/lib/Integration/GoogleIntegration.php +++ b/lib/Integration/GoogleIntegration.php @@ -119,7 +119,8 @@ public function finishConnect(Account $account, } public function refresh(Account $account): Account { - if ($account->getMailAccount()->getOauthTokenTtl() === null || $account->getMailAccount()->getOauthRefreshToken() === null) { + $oauthRefreshToken = $account->getMailAccount()->getOauthRefreshToken(); + if ($account->getMailAccount()->getOauthTokenTtl() === null || $oauthRefreshToken === null) { // Account is not authorized yet return $account; } @@ -137,7 +138,7 @@ public function refresh(Account $account): Account { return $account; } - $refreshToken = $this->crypto->decrypt($account->getMailAccount()->getOauthRefreshToken()); + $refreshToken = $this->crypto->decrypt($oauthRefreshToken); $clientSecret = $this->crypto->decrypt($encryptedClientSecret); $httpClient = $this->clientService->newClient(); try { diff --git a/lib/Integration/MicrosoftIntegration.php b/lib/Integration/MicrosoftIntegration.php index ccdceaf39d..b80e060ac0 100644 --- a/lib/Integration/MicrosoftIntegration.php +++ b/lib/Integration/MicrosoftIntegration.php @@ -133,7 +133,8 @@ public function finishConnect(Account $account, } public function refresh(Account $account): Account { - if ($account->getMailAccount()->getOauthTokenTtl() === null || $account->getMailAccount()->getOauthRefreshToken() === null) { + $oauthRefreshToken = $account->getMailAccount()->getOauthRefreshToken(); + if ($account->getMailAccount()->getOauthTokenTtl() === null || $oauthRefreshToken === null) { // Account is not authorized yet return $account; } @@ -152,7 +153,7 @@ public function refresh(Account $account): Account { return $account; } - $refreshToken = $this->crypto->decrypt($account->getMailAccount()->getOauthRefreshToken()); + $refreshToken = $this->crypto->decrypt($oauthRefreshToken); $clientSecret = $this->crypto->decrypt($encryptedClientSecret); $httpClient = $this->clientService->newClient(); try { diff --git a/lib/SMTP/SmtpClientFactory.php b/lib/SMTP/SmtpClientFactory.php index 10083a8e48..00eed8cf3d 100644 --- a/lib/SMTP/SmtpClientFactory.php +++ b/lib/SMTP/SmtpClientFactory.php @@ -9,10 +9,12 @@ namespace OCA\Mail\SMTP; +use Exception; use Horde_Mail_Transport; use Horde_Mail_Transport_Smtphorde; use Horde_Smtp_Password_Xoauth2; use OCA\Mail\Account; +use OCA\Mail\Exception\ServiceException; use OCA\Mail\Support\HostNameFactory; use OCP\IConfig; use OCP\Security\ICrypto; @@ -64,7 +66,15 @@ public function create(Account $account): Horde_Mail_Transport { ], ]; if ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { - $decryptedAccessToken = $this->crypto->decrypt($account->getMailAccount()->getOauthAccessToken()); + try { + $oauthAccessToken = $account->getMailAccount()->getOauthAccessToken(); + if ($oauthAccessToken === null) { + throw new ServiceException('Missing access token for xoauth2 account'); + } + $decryptedAccessToken = $this->crypto->decrypt($oauthAccessToken); + } catch (Exception $e) { + throw new ServiceException('Could not decrypt account access token: ' . $e->getMessage(), 0, $e); + } $params['password'] = $decryptedAccessToken; // Not used, but Horde wants this $params['xoauth2_token'] = new Horde_Smtp_Password_Xoauth2( diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index e4db6afd20..77a03da560 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -11,7 +11,6 @@ namespace OCA\Mail\Service; use OCA\Mail\Account; -use OCA\Mail\AppInfo\Application; use OCA\Mail\BackgroundJob\PreviewEnhancementProcessingJob; use OCA\Mail\BackgroundJob\QuotaJob; use OCA\Mail\BackgroundJob\RepairSyncJob; @@ -176,14 +175,6 @@ public function save(MailAccount $newAccount): MailAccount { // Insert background jobs for this account $this->scheduleBackgroundJobs($newAccount->getId()); - // Set initial heartbeat - $this->config->setUserValue( - $newAccount->getUserId(), - Application::APP_ID, - 'ui-heartbeat', - (string)$this->timeFactory->getTime(), - ); - return $newAccount; } diff --git a/lib/UserMigration/MailAccountMigrator.php b/lib/UserMigration/MailAccountMigrator.php new file mode 100644 index 0000000000..27d81644d9 --- /dev/null +++ b/lib/UserMigration/MailAccountMigrator.php @@ -0,0 +1,203 @@ +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; + } + + $exportDestination->addFileContents('mail/accounts/index.json', json_encode($index)); + } + + 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'], + ); + } + } + } + + public function getId(): string { + return 'mail_account'; + } + + public function getDisplayName(): string { + return $this->l10n->t('Mail'); + } + + public function getDescription(): string { + return $this->l10n->t('Mail account parameters, aliases and preferences'); + } + + public function getVersion(): int { + return 01_00_00; + } + + public function canImport(IImportSource $importSource): bool { + try { + return $importSource->getMigratorVersion($this->getId()) <= $this->getVersion(); + } catch (UserMigrationException) { + return false; + } + } + +} diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php index afe009f69c..22fc14bde0 100644 --- a/tests/Integration/Db/MailAccountTest.php +++ b/tests/Integration/Db/MailAccountTest.php @@ -72,6 +72,7 @@ public function testToAPI() { 'outOfOfficeFollowsSystem' => true, 'debug' => false, 'classificationEnabled' => true, + 'authMethod' => 'password', ], $a->toJson()); } @@ -111,6 +112,7 @@ public function testMailAccountConstruct() { 'outOfOfficeFollowsSystem' => false, 'debug' => false, 'classificationEnabled' => true, + 'authMethod' => 'password', ]; $a = new MailAccount($expected); // TODO: fix inconsistency diff --git a/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php new file mode 100644 index 0000000000..66a35d937b --- /dev/null +++ b/tests/Integration/UserMigration/MailAccountMigratorIntegrationTest.php @@ -0,0 +1,81 @@ +accountService = Server::get(AccountService::class); + $this->migrator = Server::get(MailAccountMigrator::class); + } + + 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->createMock(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/Controller/AccountsControllerTest.php b/tests/Unit/Controller/AccountsControllerTest.php index c37af7da55..b3dce3b014 100644 --- a/tests/Unit/Controller/AccountsControllerTest.php +++ b/tests/Unit/Controller/AccountsControllerTest.php @@ -26,6 +26,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; @@ -79,6 +80,9 @@ class AccountsControllerTest extends TestCase { /** @var MailboxSync|MockObject */ private $mailboxSync; + /** @var ITimeFactory|MockObject */ + private $timeFactory; + /** @var IConfig|(IConfig&MockObject)|MockObject */ private IConfig|MockObject $config; /** @var IRemoteHostValidator|MockObject */ @@ -102,6 +106,7 @@ protected function setUp(): void { $this->config = $this->createMock(IConfig::class); $this->hostValidator = $this->createMock(IRemoteHostValidator::class); $this->hostValidator->method('isValid')->willReturn(true); + $this->timeFactory = $this->createMock(ITimeFactory::class); $this->controller = new AccountsController( $this->appName, @@ -118,6 +123,7 @@ protected function setUp(): void { $this->config, $this->hostValidator, $this->mailboxSync, + $this->timeFactory, ); $this->account = $this->createMock(Account::class); $this->accountId = 123; diff --git a/tests/Unit/Service/AccountServiceTest.php b/tests/Unit/Service/AccountServiceTest.php index 89ebe555c0..d0dc48c978 100644 --- a/tests/Unit/Service/AccountServiceTest.php +++ b/tests/Unit/Service/AccountServiceTest.php @@ -174,7 +174,7 @@ public function testSave() { ->with($mailAccount) ->will($this->returnArgument(0)); - $this->time->expects(self::exactly(2)) + $this->time->expects(self::once()) ->method('getTime') ->willReturn(1755850409); @@ -183,10 +183,6 @@ public function testSave() { $this->jobList->expects($this->exactly(5)) ->method('scheduleAfter'); - $this->config->expects(self::once()) - ->method('setUserValue') - ->with('user1', 'mail', 'ui-heartbeat', 1755850409); - $actual = $this->accountService->save($mailAccount); $this->assertEquals($mailAccount, $actual); diff --git a/tests/Unit/UserMigration/MailAccountMigratorTest.php b/tests/Unit/UserMigration/MailAccountMigratorTest.php new file mode 100644 index 0000000000..623e84c779 --- /dev/null +++ b/tests/Unit/UserMigration/MailAccountMigratorTest.php @@ -0,0 +1,241 @@ + */ + private ServiceMockObject $serviceMock; + private OutputInterface|MockObject $output; + + protected function setUp(): void { + parent::setUp(); + + $this->serviceMock = $this->createServiceMock(MailAccountMigrator::class); + $this->serviceMock->getParameter('l10n') + ->method('t') + ->willReturnArgument(0); + $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->migrator = $this->serviceMock->getService(); + + $this->output = $this->createMock(OutputInterface::class); + } + + public function testGetId(): void { + $id = $this->migrator->getId(); + + self::assertEquals('mail_account', $id); + } + + public function testGetDisplayName(): void { + $displayName = $this->migrator->getDisplayName(); + + self::assertEquals('Mail', $displayName); + } + + public function testGetDescription(): void { + $description = $this->migrator->getDisplayName(); + + self::assertNotEmpty($description); + } + + public function testGetVersion(): void { + $version = $this->migrator->getVersion(); + + self::assertGreaterThanOrEqual(01_00_00, $version); + } + + public function testCantImportNewer(): void { + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('mail_account') + ->willReturn(99_00_00); + + $canImport = $this->migrator->canImport($importSource); + + self::assertFalse($canImport); + } + + public function testCanImportSame(): void { + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('mail_account') + ->willReturn($this->migrator->getVersion()); + + $canImport = $this->migrator->canImport($importSource); + + 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); + + $canImport = $this->migrator->canImport($importSource); + + 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->createMock(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, + ); + } + +}