Skip to content

Commit 8eb6d2c

Browse files
committed
feat(UserMigration): Overwork migration to include all settings (user-specific app configuration)
Signed-off-by: David Dreschner <david.dreschner@nextcloud.com>
1 parent b51a7e6 commit 8eb6d2c

3 files changed

Lines changed: 267 additions & 1 deletion

File tree

lib/UserMigration/MailAccountMigrator.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OCA\Mail\Exception\ClientException;
1414
use OCA\Mail\Exception\ServiceException;
1515
use OCA\Mail\UserMigration\Service\AccountMigrationService;
16+
use OCA\Mail\UserMigration\Service\AppConfigMigrationService;
1617
use OCP\AppFramework\Db\DoesNotExistException;
1718
use OCP\IL10N;
1819
use OCP\IUser;
@@ -31,6 +32,7 @@ public function __construct(
3132
private readonly IL10N $l10n,
3233
private readonly ICrypto $crypto,
3334
private readonly AccountMigrationService $accountMigrationService,
35+
private readonly AppConfigMigrationService $appConfigMigrationService,
3436
) {
3537
}
3638

@@ -40,14 +42,17 @@ public function export(IUser $user,
4042
OutputInterface $output,
4143
): void {
4244
$output->writeln($this->l10n->t("Exporting mail accounts for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE);
45+
46+
$this->appConfigMigrationService->exportAppConfiguration($user, $exportDestination, $output);
4347
}
4448

4549
#[\Override]
4650
public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
4751
$output->writeln($this->l10n->t("Importing mail accounts for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE);
48-
4952
$this->deleteExistingData($user, $output);
5053

54+
$this->appConfigMigrationService->importAppConfiguration($user, $importSource, $output);
55+
5156
$this->accountMigrationService->scheduleBackgroundJobs($user, $output);
5257
}
5358

@@ -63,6 +68,8 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
6368
*/
6469
private function deleteExistingData(IUser $user, OutputInterface $output): void {
6570
$output->writeln($this->l10n->t("Deleting existing mail data for user {$user->getUID()}"), OutputInterface::VERBOSITY_VERBOSE);
71+
72+
$this->appConfigMigrationService->deleteAppConfiguration($user, $output);
6673
$this->accountMigrationService->deleteAllAccounts($user, $output);
6774
}
6875

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Mail\UserMigration\Service;
11+
12+
use JsonException;
13+
use OCA\Mail\AppInfo\Application;
14+
use OCA\Mail\UserMigration\MailAccountMigrator;
15+
use OCP\IConfig;
16+
use OCP\IL10N;
17+
use OCP\IUser;
18+
use OCP\UserMigration\IExportDestination;
19+
use OCP\UserMigration\IImportSource;
20+
use OCP\UserMigration\UserMigrationException;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
23+
class AppConfigMigrationService {
24+
public const APP_CONFIGURATION_FILE = MailAccountMigrator::EXPORT_ROOT . '/app_configuration.json';
25+
26+
public function __construct(
27+
private readonly IConfig $config,
28+
private readonly IL10N $l10n,
29+
) {
30+
}
31+
32+
/**
33+
* Export the user configuration stored via IConfig.
34+
*
35+
* @throws UserMigrationException
36+
*/
37+
public function exportAppConfiguration(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
38+
$output->writeln(
39+
$this->l10n->t("Exporting mail app configuration for user {$user->getUID()}"),
40+
OutputInterface::VERBOSITY_VERBOSE
41+
);
42+
43+
$appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID);
44+
$appConfigSettings = array_map(function (string $appConfigKey) use ($user) {
45+
return [
46+
'key' => $appConfigKey,
47+
'value' => $this->config->getUserValue($user->getUID(), Application::APP_ID, $appConfigKey)
48+
];
49+
}, $appConfigKeys);
50+
51+
try {
52+
$exportDestination->addFileContents(self::APP_CONFIGURATION_FILE, json_encode($appConfigSettings, JSON_THROW_ON_ERROR));
53+
} catch (JsonException|UserMigrationException) {
54+
throw new UserMigrationException("Failed to export mail app configuration for user {$user->getUID()}");
55+
}
56+
}
57+
58+
/**
59+
* Import the user configuration stored via IConfig
60+
* on export
61+
*
62+
* @throws \OCP\PreConditionNotMetException
63+
* @throws \OCP\UserMigration\UserMigrationException
64+
*/
65+
public function importAppConfiguration(IUser $user, IImportSource $importSource, OutputInterface $output): void {
66+
$output->writeln(
67+
$this->l10n->t("Importing mail app configuration for user {$user->getUID()}"),
68+
OutputInterface::VERBOSITY_VERBOSE
69+
);
70+
71+
try {
72+
$appConfigFileContent = $importSource->getFileContents(self::APP_CONFIGURATION_FILE);
73+
} catch (UserMigrationException) {
74+
$output->writeln(
75+
$this->l10n->t("Mail app configuration for user {$user->getUID()} not found. Continue..."),
76+
OutputInterface::VERBOSITY_VERBOSE
77+
);
78+
79+
return;
80+
}
81+
82+
$appConfig = json_decode($appConfigFileContent, true);
83+
$this->validateAppConfig($appConfig);
84+
85+
foreach ($appConfig as $appSetting) {
86+
$output->writeln(
87+
$this->l10n->t("Importing mail app configuration key {$appSetting['key']} for user {$user->getUID()}"),
88+
OutputInterface::VERBOSITY_VERBOSE
89+
);
90+
91+
$this->config->setUserValue($user->getUID(), Application::APP_ID, $appSetting['key'], $appSetting['value']);
92+
}
93+
94+
}
95+
96+
/**
97+
* Delete the user configuration stored via IConfig.
98+
*/
99+
public function deleteAppConfiguration(IUser $user, OutputInterface $output): void {
100+
$output->writeln(
101+
$this->l10n->t("Delete existing mail app configuration for user {$user->getUID()}"),
102+
OutputInterface::VERBOSITY_VERBOSE
103+
);
104+
105+
$appConfigKeys = $this->config->getUserKeys($user->getUID(), Application::APP_ID);
106+
107+
foreach ($appConfigKeys as $appConfigKey) {
108+
$output->writeln(
109+
$this->l10n->t("Deleting mail app configuration key {$appConfigKey} for user {$user->getUID()}"),
110+
OutputInterface::VERBOSITY_VERBOSE
111+
);
112+
113+
$this->config->deleteUserValue($user->getUID(), Application::APP_ID, $appConfigKey);
114+
}
115+
}
116+
117+
/**
118+
* Validate the parsed app configuration and their containing
119+
* settings to ensure they have the expected structure and types.
120+
*
121+
* @throws UserMigrationException
122+
*/
123+
private function validateAppConfig(mixed $appConfig): void {
124+
$appConfigArrayIsValid = is_array($appConfig) && array_is_list($appConfig);
125+
if (!$appConfigArrayIsValid) {
126+
throw new UserMigrationException('Invalid mail app configuration export structure');
127+
}
128+
129+
foreach ($appConfig as $appSetting) {
130+
$appSettingArrayIsValid = is_array($appSetting);
131+
$keyIsValid = array_key_exists('key', $appSetting) && is_string($appSetting['key']);
132+
$valueIsValid = array_key_exists('value', $appSetting) && is_string($appSetting['value']);
133+
134+
if (!$appSettingArrayIsValid || !$keyIsValid || !$valueIsValid) {
135+
throw new UserMigrationException('Invalid mail app configuration entry');
136+
}
137+
}
138+
}
139+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace Unit\UserMigration\Service;
11+
12+
use ChristophWurst\Nextcloud\Testing\ServiceMockObject;
13+
use ChristophWurst\Nextcloud\Testing\TestCase;
14+
use OCA\Mail\AppInfo\Application;
15+
use OCA\Mail\UserMigration\Service\AppConfigMigrationService;
16+
use OCP\IUser;
17+
use OCP\UserMigration\IExportDestination;
18+
use OCP\UserMigration\IImportSource;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
21+
class AppConfigMigrationServiceTest extends TestCase {
22+
private const USER_ID = '123';
23+
private OutputInterface $output;
24+
private IUser $user;
25+
private IExportDestination $exportDestination;
26+
private IImportSource $importSource;
27+
private ServiceMockObject $serviceMock;
28+
private AppConfigMigrationService $migrationService;
29+
30+
protected function setUp(): void {
31+
parent::setUp();
32+
33+
$this->serviceMock = $this->createServiceMock(AppConfigMigrationService::class);
34+
$this->migrationService = $this->serviceMock->getService();
35+
36+
$this->output = $this->createMock(OutputInterface::class);
37+
$this->exportDestination = $this->createMock(IExportDestination::class);
38+
$this->importSource = $this->createMock(IImportSource::class);
39+
40+
$this->user = $this->createMock(IUser::class);
41+
$this->user->method('getUID')->willReturn(self::USER_ID);
42+
}
43+
44+
public function testExportsMultipleAppConfigurations(): void {
45+
$this->exportDestination->expects(self::once())->method('addFileContents')->with(AppConfigMigrationService::APP_CONFIGURATION_FILE, json_encode($this->getAppConfig()));
46+
47+
$this->serviceMock->getParameter('config')->expects(self::once())->method('getUserKeys')->with(self::USER_ID, Application::APP_ID)->willReturn($this->getAppKeys());
48+
$this->serviceMock->getParameter('config')->method('getUserValue')->with(self::USER_ID, Application::APP_ID, self::callback(function ($appConfigKey): bool {
49+
return in_array($appConfigKey, $this->getAppKeys(), true);
50+
}))->willReturnCallback(function ($userId, $appId, $appConfigKey): string {
51+
return $this->getAppValue($appConfigKey);
52+
});
53+
54+
$this->migrationService->exportAppConfiguration($this->user, $this->exportDestination, $this->output);
55+
}
56+
57+
public function testExportsNoAppConfiguration(): void {
58+
$trustedSendersList = [];
59+
$this->exportDestination->expects(self::once())->method('addFileContents')->with(AppConfigMigrationService::APP_CONFIGURATION_FILE, json_encode($trustedSendersList));
60+
61+
$this->serviceMock->getParameter('config')->expects(self::once())->method('getUserKeys')->with(self::USER_ID, Application::APP_ID)->willReturn($trustedSendersList);
62+
$this->serviceMock->getParameter('config')->expects(self::never())->method('getUserValue');
63+
64+
$this->migrationService->exportAppConfiguration($this->user, $this->exportDestination, $this->output);
65+
}
66+
67+
public function testImportMultipleAppConfigurations(): void {
68+
$this->importSource->expects(self::once())->method('getFileContents')->with(AppConfigMigrationService::APP_CONFIGURATION_FILE)->willReturn(json_encode($this->getAppConfig()));
69+
70+
$this->serviceMock->getParameter('config')->expects(self::exactly(3))->method('setUserValue')->with(self::USER_ID, Application::APP_ID, self::callback(function ($key) {
71+
return in_array($key, $this->getAppKeys());
72+
}), self::callback(function ($searchedValue): bool {
73+
return in_array($searchedValue, $this->getAppValues(), true);
74+
}));
75+
76+
$this->migrationService->importAppConfiguration($this->user, $this->importSource, $this->output);
77+
}
78+
79+
public function testImportNoAppConfiguration(): void {
80+
$trustedSendersList = [];
81+
$this->importSource->expects(self::once())->method('getFileContents')->with(AppConfigMigrationService::APP_CONFIGURATION_FILE)->willReturn(json_encode($trustedSendersList));
82+
83+
$this->serviceMock->getParameter('config')->expects(self::never())->method('setUserValue');
84+
85+
$this->migrationService->importAppConfiguration($this->user, $this->importSource, $this->output);
86+
}
87+
88+
private function getAppConfig(): array {
89+
return [
90+
['key' => 'account-settings',
91+
'value' => '[{\"accountId\":19,\"collapsed\":false}]'],
92+
['key' => 'collect-data',
93+
'value' => 'true'
94+
],
95+
['key' => 'ui-heartbeat',
96+
'value' => '1770367800']
97+
];
98+
}
99+
100+
private function getAppKeys(): array {
101+
return array_map(function (array $appConfig) {
102+
return $appConfig['key'];
103+
}, $this->getAppConfig());
104+
}
105+
106+
private function getAppValue(string $key): ?string {
107+
foreach ($this->getAppConfig() as $appConfig) {
108+
if ($appConfig['key'] === $key) {
109+
return $appConfig['value'];
110+
}
111+
}
112+
return null;
113+
}
114+
115+
private function getAppValues(): array {
116+
return array_map(function (array $appConfig) {
117+
return $appConfig['value'];
118+
}, $this->getAppConfig());
119+
}
120+
}

0 commit comments

Comments
 (0)