From d918302edf79daa94d67d2d35d739d58a117d500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 28 Sep 2023 09:29:30 +0200 Subject: [PATCH 1/4] New sync mechanism --- core/Command/Sync/Sync.php | 209 ++++++ core/register_command.php | 2 + lib/private/Server.php | 20 + lib/private/Sync/SyncManager.php | 72 +++ lib/private/Sync/User/UserSyncDBBackend.php | 118 ++++ lib/private/Sync/User/UserSyncer.php | 605 ++++++++++++++++++ lib/private/User/Database.php | 52 ++ lib/public/IServerContainer.php | 6 + lib/public/Sync/ISyncManager.php | 86 +++ lib/public/Sync/ISyncer.php | 190 ++++++ lib/public/Sync/SyncException.php | 27 + lib/public/Sync/User/IUserSyncBackend.php | 112 ++++ lib/public/Sync/User/IUserSyncer.php | 46 ++ .../Sync/User/SyncBackendBrokenException.php | 30 + .../User/SyncBackendUserFailedException.php | 31 + lib/public/Sync/User/SyncingUser.php | 183 ++++++ 16 files changed, 1789 insertions(+) create mode 100644 core/Command/Sync/Sync.php create mode 100644 lib/private/Sync/SyncManager.php create mode 100644 lib/private/Sync/User/UserSyncDBBackend.php create mode 100644 lib/private/Sync/User/UserSyncer.php create mode 100644 lib/public/Sync/ISyncManager.php create mode 100644 lib/public/Sync/ISyncer.php create mode 100644 lib/public/Sync/SyncException.php create mode 100644 lib/public/Sync/User/IUserSyncBackend.php create mode 100644 lib/public/Sync/User/IUserSyncer.php create mode 100644 lib/public/Sync/User/SyncBackendBrokenException.php create mode 100644 lib/public/Sync/User/SyncBackendUserFailedException.php create mode 100644 lib/public/Sync/User/SyncingUser.php diff --git a/core/Command/Sync/Sync.php b/core/Command/Sync/Sync.php new file mode 100644 index 000000000000..36efecde5438 --- /dev/null +++ b/core/Command/Sync/Sync.php @@ -0,0 +1,209 @@ + + * + */ + +namespace OC\Core\Command\Sync; + +use OCP\Sync\ISyncManager; +use OCP\Sync\ISyncer; +use OCP\Sync\SyncException; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Helper\ProgressBar; + +class Sync extends Command { + /** @var ISyncManager */ + private $syncManager; + + public function __construct(ISyncManager $syncManager) { + parent::__construct(); + $this->syncManager = $syncManager; + } + + protected function configure() { + $this->setName('sync:sync') + ->setDescription('sync any of the registered sync services') + ->addArgument( + 'service', + InputArgument::REQUIRED, + 'The service that will sync' + )->addOption( + 'only-one', + null, + InputOption::VALUE_REQUIRED, + 'check and sync only the item with the id provided', + )->addOption( + 'option', + 'o', + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'options to be used with the service, in "=" form' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $service = $input->getArgument('service'); + + $syncer = $this->syncManager->getSyncer($service); + if ($syncer === null) { + $output->writeln("{$service} not found"); + return 1; + } + + $opts = []; + if ($input->getOption('option') !== null) { + foreach ($input->getOption('option') as $option) { + $keyValue = \explode('=', $option, 2); + $opts[$keyValue[0]] = $keyValue[1]; + } + } + + $target = $input->getOption('only-one'); + if ($target !== null) { + $checkOk = $this->checkOnlyOne($syncer, $target, $opts, $output); + $syncOk = $this->syncOnlyOne($syncer, $target, $opts, $output); + } else { + $checkOk = $this->checkLocalData($syncer, $opts, $output); + $syncOk = $this->syncRemoteData($syncer, $opts, $output); + } + + if (!$checkOk || !$syncOk) { + return 2; + } + + return 0; + } + + private function checkOnlyOne(ISyncer $syncer, $target, $opts, OutputInterface $output) { + try { + $state = $syncer->checkOne($target, $opts); + if ($state !== ISyncer::CHECK_STATE_NO_CHANGE) { + $output->writeln("{$target} state changed to {$state}"); + } + return $state === ISyncer::CHECK_STATE_NO_CHANGE; + } catch (SyncException $ex) { + $this->outputExceptions($output, $ex); + } + return false; + } + + private function syncOnlyOne(ISyncer $syncer, $target, $opts, OutputInterface $output) { + try { + $syncOk = $syncer->syncOne($target, $opts); + if (!$syncOk) { + $output->writeln("{$target} cannot be synced because it isn't found remotely"); + } + return $syncOk; + } catch (SyncException $ex) { + $this->outputExceptions($output, $ex); + } + return false; + } + + private function checkLocalData(ISyncer $syncer, $opts, OutputInterface $output) { + $itemStateList = []; + $errorList = []; + + // Check local data + $output->writeln('Checking local data'); + $progress = new ProgressBar($output); + $progress->start($syncer->localItemCount($opts)); + $syncer->check(function ($item, $state) use ($output, $progress, &$itemStateList, &$errorList) { + if (\is_array($item) && $state !== ISyncer::CHECK_STATE_NO_CHANGE) { + $key = \array_key_first($item); + $itemStateList[] = [ + 'key' => $key, + 'value' => $item[$key], + 'state' => $state, + ]; + } + if ($item instanceof \Exception) { + $errorList[] = $item; + } else { + $progress->advance(); + } + }, $opts); + $progress->finish(); + $output->writeln(''); + + if (!empty($itemStateList)) { + $output->writeln(''); + foreach ($itemStateList as $itemState) { + $output->writeln("- State for item with {$itemState['key']} = {$itemState['value']} has changed to {$itemState['state']}"); + } + } + + if (!empty($errorList)) { + $output->writeln(''); + $output->writeln('Following errors happened:'); + $this->outputExceptions($output, $errorList); + + return false; + } + + return true; + } + + private function syncRemoteData(ISyncer $syncer, $opts, OutputInterface $output) { + $output->writeln(''); + $errorList = []; + // Sync remote data + $output->writeln('Syncing remote data'); + $progress = new ProgressBar($output); + $progress->start($syncer->remoteItemCount($opts)); + $syncer->sync(function ($item) use ($output, $progress, &$errorList) { + if ($item instanceof \Exception) { + $errorList[] = $item; + } else { + $progress->advance(); + } + }, $opts); + $progress->finish(); + $output->writeln(''); + + if (!empty($errorList)) { + $output->writeln(''); + $output->writeln('Following errors happened:'); + $this->outputExceptions($output, $errorList); + + return false; + } + + return true; + } + + private function outputExceptions(OutputInterface $output, $exList) { + $prefix = '- '; + if (!\is_array($exList)) { + $prefix = "Error: "; + $exList = [$exList]; + } + + foreach ($exList as $errorItem) { + $output->writeln("{$prefix}{$errorItem->getMessage()}"); + $previous = $errorItem->getPrevious(); + while ($previous !== null) { + $previousClass = \get_class($previous); + $output->writeln(" Caused by: {$previousClass}: {$previous->getMessage()}"); + $previous = $previous->getPrevious(); + } + } + } +} diff --git a/core/register_command.php b/core/register_command.php index cc84923d3451..98520eb057e3 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -204,6 +204,8 @@ $application->add(new OC\Core\Command\Group\ListGroups(\OC::$server->getGroupManager())); $application->add(new OC\Core\Command\Group\ListGroupMembers(\OC::$server->getGroupManager())); + $application->add(new OC\Core\Command\Sync\Sync(\OC::$server->getSyncManager())); + $application->add(new OC\Core\Command\Security\ListCertificates(\OC::$server->getCertificateManager(null), \OC::$server->getL10N('core'))); $application->add(new OC\Core\Command\Security\ImportCertificate(\OC::$server->getCertificateManager(null))); $application->add(new OC\Core\Command\Security\RemoveCertificate(\OC::$server->getCertificateManager(null))); diff --git a/lib/private/Server.php b/lib/private/Server.php index 46172e9f0d97..22d559be6ce3 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -95,6 +95,9 @@ use OC\User\AccountTermMapper; use OC\User\Session; use OC\User\SyncService; +use OC\Sync\User\UserSyncDBBackend; +use OC\Sync\User\UserSyncer; +use OC\Sync\SyncManager; use OCP\App\IServiceLoader; use OCP\AppFramework\QueryException; use OCP\AppFramework\Utility\ITimeFactory; @@ -111,6 +114,7 @@ use OCP\Shutdown\IShutdownManager; use OCP\Theme\IThemeService; use OCP\Util\UserSearch; +use OCP\Sync\ISyncManager; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use OC\Files\External\StoragesBackendService; @@ -976,6 +980,18 @@ public function __construct($webRoot, \OC\Config $config) { ); return $policyManager; }); + + $this->registerService(SyncManager::class, function ($c) { + $userSyncDbBackend = new UserSyncDBBackend(new \OC\User\Database()); // anything better? + + $userSyncer = new UserSyncer($c->getUserManager(), $c->getAccountMapper(), $c->getConfig(), $c->getLogger()); + $userSyncer->registerBackend($userSyncDbBackend); + + $syncManager = new SyncManager(); + $syncManager->registerSyncer('user', $userSyncer); + return $syncManager; + }); + $this->registerAlias(ISyncManager::class, SyncManager::class); } /** @@ -1759,4 +1775,8 @@ public function getLicenseManager() { public function getLoginPolicyManager() { return $this->query(LoginPolicyManager::class); } + + public function getSyncManager() { + return $this->query(ISyncManager::class); + } } diff --git a/lib/private/Sync/SyncManager.php b/lib/private/Sync/SyncManager.php new file mode 100644 index 000000000000..945b24bc7dba --- /dev/null +++ b/lib/private/Sync/SyncManager.php @@ -0,0 +1,72 @@ + + * + */ + +namespace OC\Sync; + +use OCP\Sync\ISyncManager; +use OCP\Sync\ISyncer; +use OCP\Sync\User\IUserSyncer; + +class SyncManager implements ISyncManager { + /** @var array */ + private $register = []; + + /** + * @inheritDoc + */ + public function registerSyncer(string $name, ISyncer $syncer): bool { + if (isset($this->register[$name])) { + return false; + } + $this->register[$name] = $syncer; + return true; + } + + /** + * {@inheritDoc} + * + * This method will return false if the name is NOT taken (and we can't + * overwrite it) + */ + public function overwriteSyncer(string $name, ISyncer $syncer): bool { + if (isset($this->register[$name])) { + $this->register[$name] = $syncer; + return true; + } + return false; + } + + /** + * @inheritDoc + */ + public function getSyncer(string $name): ?ISyncer { + return $this->register[$name] ?? null; + } + + /** + * @inheritDoc + */ + public function getUserSyncer(): ?IUserSyncer { + $syncer = $this->register['user'] ?? null; + if ($syncer !== null && $syncer instanceof IUserSyncer) { + return $syncer; + } + return null; + } +} diff --git a/lib/private/Sync/User/UserSyncDBBackend.php b/lib/private/Sync/User/UserSyncDBBackend.php new file mode 100644 index 000000000000..5f168f567a5f --- /dev/null +++ b/lib/private/Sync/User/UserSyncDBBackend.php @@ -0,0 +1,118 @@ + + * + */ + +namespace OC\Sync\User; + +use OC\User\Database; +use OCP\UserInterface; +use OCP\Sync\User\IUserSyncBackend; +use OCP\Sync\User\SyncingUser; + +class UserSyncDBBackend implements IUserSyncBackend { + /** @var Database */ + private $dbUserBackend; + + private $pointer = 0; + private $cachedUserData = ['min' => 0, 'max' => 0, 'last' => false]; + + public function __construct(Database $dbUserBackend) { + $this->dbUserBackend = $dbUserBackend; + } + + /** + * @inheritDoc + */ + public function resetPointer() { + $this->pointer = 0; + $this->cachedUserData = ['min' => 0, 'max' => 0, 'last' => false]; + } + + /** + * @inheritDoc + */ + public function getNextUser(): ?SyncingUser { + $chunk = 500; + $minPointer = $this->cachedUserData['min']; + if (!isset($this->cachedUserData['users'][$this->pointer - $minPointer])) { + if ($this->cachedUserData['last']) { + // we've reached the end + return null; + } + + $users = $this->dbUserBackend->getUsersData('', $chunk, $this->pointer); + + $minPointer = $this->pointer; + $this->cachedUserData = [ + 'min' => $this->pointer, + 'max' => $this->pointer + $chunk, + 'last' => empty($users), + 'users' => $users, + ]; + } + + $syncingUser = null; + if (isset($this->cachedUserData['users'][$this->pointer - $minPointer])) { + $data = $this->cachedUserData['users'][$this->pointer - $minPointer]; + $uid = $data['uid']; + $displayname = $data['displayname']; + if ($displayname === null || $displayname === '') { + $displayname = $uid; + } + $syncingUser = new SyncingUser($uid); + $syncingUser->setDisplayName($displayname); + $syncingUser->setHome($this->dbUserBackend->getHome($uid)); + } + $this->pointer++; + return $syncingUser; + } + + /** + * @inheritDoc + */ + public function getSyncingUser(string $id): ?SyncingUser { + $syncingUser = null; + + $data = $this->dbUserBackend->getUserData($id); + if ($data !== false) { + $uid = $data['uid']; + $displayname = $data['displayname']; + if ($displayname === null || $displayname === '') { + $displayname = $uid; + } + $syncingUser = new SyncingUser($uid); + $syncingUser->setDisplayName($displayname); + $syncingUser->setHome($this->dbUserBackend->getHome($uid)); + } + return $syncingUser; + } + + /** + * @inheritDoc + */ + public function userCount(): ?int { + return $this->dbUserBackend->countUsers(); + } + + /** + * @inheritDoc + */ + public function getUserInterface(): UserInterface { + return $this->dbUserBackend; + } +} diff --git a/lib/private/Sync/User/UserSyncer.php b/lib/private/Sync/User/UserSyncer.php new file mode 100644 index 000000000000..ebe98442f371 --- /dev/null +++ b/lib/private/Sync/User/UserSyncer.php @@ -0,0 +1,605 @@ + + * + */ + +namespace OC\Sync\User; + +use OC\User\Account; +use OC\User\AccountMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\Sync\ISyncer; +use OCP\Sync\SyncException; +use OCP\Sync\User\IUserSyncer; +use OCP\Sync\User\IUserSyncBackend; +use OCP\Sync\User\SyncBackendBrokenException; +use OCP\Sync\User\SyncBackendUserFailedException; +use OCP\Sync\User\SyncingUser; +use OCP\IConfig; +use OCP\ILogger; +use OCP\IUserManager; +use OCP\PreConditionNotMetException; + +class UserSyncer implements IUserSyncer { + /** @var IUserManager */ + private $userManager; + /** @var AccountMapper */ + private $mapper; + /** @var IConfig */ + private $config; + /** @var ILogger */ + private $logger; + /** @var array */ + private $userSyncBackends = []; + + /** + * @param IUserManager $userManager + * @param AccountMapper $mapper + * @param IConfig $config + * @param ILogger $logger + */ + public function __construct(IUserManager $userManager, AccountMapper $mapper, IConfig $config, ILogger $logger) { + $this->userManager = $userManager; + $this->mapper = $mapper; + $this->config = $config; + $this->logger = $logger; + } + + /** + * @inheritDoc + */ + public function registerBackend(IUserSyncBackend $userSyncBackend) { + $this->userSyncBackends[\get_class($userSyncBackend)] = $userSyncBackend; + } + + /** + * {@inheritDoc} + * + * Using "missingAction" as option won't do anything here. It will be ignored. + * + * Custom options: + * - "backends" => "back1,back2,back3" + * Only those backends will be counted, assuming they're registered. The + * rest of the backends will be ignored. + */ + public function localItemCount($opts = []): ?int { + $backends = $this->extractBackendsFromOpts($opts); + + if (empty($backends)) { + return $this->mapper->getUserCount(false); + } else { + $countPerBackend = $this->mapper->getUserCountPerBackend(false); + $totalCount = 0; + foreach ($countPerBackend as $backend => $nUsers) { + if (\in_array($backend, $backends)) { + $totalCount += $nUsers; + } + } + return $totalCount; + } + } + + /** + * {@inheritDoc} + * + * This method will disable missing users by default, and will check + * all the backends if the "backends" option isn't provided. + * + * Custom options: + * - "missingAction" => "remove" or "disable". + * The action to do if the account is missing in the backend + * - "backends" => "back1,back2,back3" + * Only those backends will be synced, assuming they're registered. The + * rest of the backends will be ignored. + */ + public function check($callback, $opts = []) { + $backends = $this->extractBackendsFromOpts($opts); + + $missingAction = 'disable'; + if (isset($opts['missingAction']) && $opts['missingAction'] === 'remove') { + $missingAction = 'remove'; + } + + $backendToUserSync = []; + $brokenBackends = []; + foreach ($this->userSyncBackends as $userSyncBackend) { + $userInterface = $userSyncBackend->getUserInterface(); + $userInterfaceClass = \get_class($userInterface); + $backendToUserSync[$userInterfaceClass] = $userSyncBackend; + + if (!empty($backends) && !\in_array($userInterfaceClass, $backends)) { + // mark the backend as broken in order to skip checking it + $brokenBackends[$userInterfaceClass] = true; + } + } + + $this->mapper->callForUsers(function (Account $a) use ($callback, $backendToUserSync, &$brokenBackends, $missingAction) { + // Note that we can't return false because we want to check all accounts + // regardless of any error + $targetBackend = $a->getBackend(); + + if (isset($brokenBackends[$targetBackend])) { + // skip without the callback + return; + } + + $targetUserSyncBackend = $backendToUserSync[$targetBackend] ?? null; + if ($targetUserSyncBackend === null) { + // send and exception to the callback + $callback(new SyncException("{$a->getUserId()}, backend {$targetBackend} is not found"), ISyncer::CHECK_STATE_ERROR); + return; + } + + $syncingUser = null; + try { + $syncingUser = $targetUserSyncBackend->getSyncingUser($a->getUserId()); + } catch (SyncBackendUserFailedException $ex) { + $callback($ex, ISyncer::CHECK_STATE_ERROR); + return; + } catch (SyncBackendBrokenException $ex) { + $callback($ex, ISyncer::CHECK_STATE_ERROR); + $brokenBackends[$targetBackend] = true; + return; + } + + if ($syncingUser === null) { + // user not found + // generate key-value from the account + $kv = [ + 'uid' => $a->getUserId(), + 'displayname' => $a->getDisplayName(), + 'email' => $a->getEmail(), + ]; + + $userObj = $this->userManager->get($a->getUserId()); // check for null? the user should always be found + if ($missingAction === 'remove') { + $userObj->delete(); + $callback($kv, ISyncer::CHECK_STATE_REMOVED); + } else { + if ($userObj->isEnabled()) { + $userObj->setEnabled(false); + $callback($kv, ISyncer::CHECK_STATE_DISABLED); + } else { + $callback($kv, ISyncer::CHECK_STATE_NO_CHANGE); + } + } + } else { + $callback($syncingUser->getAllData(), ISyncer::CHECK_STATE_NO_CHANGE); + } + }, '', false, null, null); + } + + /** + * {@inheritDoc} + * + * Custom options: + * - "missingAction" => "remove" or "disable". + * The action to do if the account is missing in the backend + * - "backends" => "back1,back2,back3" + * Only those backends will be synced, assuming they're registered. The + * rest of the backends will be ignored. + * + * @param string $id the id of the item we want to sync + * @param array $opts options to customize the behavior + * @throws SyncException if the sync fails + * @return string any of the CHECK_STATE_* constants + */ + public function checkOne(string $id, $opts = []): string { + $backends = $this->extractBackendsFromOpts($opts); + $missingAction = 'disable'; + if (isset($opts['missingAction']) && $opts['missingAction'] === 'remove') { + $missingAction = 'remove'; + } + + try { + $account = $this->mapper->getByUid($id); + $targetBackend = $account->getBackend(); + } catch (DoesNotExistException $e) { + throw new SyncBackendUserFailedException("The database returned no accounts for this uid: $id"); + } catch (MultipleObjectsReturnedException $e) { + throw new SyncBackendUserFailedException("The database returned multiple accounts for this uid: $id"); + } + + foreach ($this->userSyncBackends as $userSyncBackend) { + if (!isset($targetBackend) || \get_class($userSyncBackend->getUserInterface()) === $targetBackend) { + $syncingUser = $userSyncBackend->getSyncingUser($id); + if ($syncingUser === null) { + // user not found + $userObj = $this->userManager->get($account->getUserId()); + if ($missingAction === 'remove') { + $userObj->delete(); + return ISyncer::CHECK_STATE_REMOVED; + } else { + if ($userObj->isEnabled()) { + $userObj->setEnabled(false); + return ISyncer::CHECK_STATE_DISABLED; + } + } + } + } + } + return ISyncer::CHECK_STATE_NO_CHANGE; + } + + /** + * {@inheritDoc} + * + * Custom options: + * - "backends" => "back1,back2,back3" + * Only those backends will be synced, assuming they're registered. The + * rest of the backends will be ignored. + * + * @param array $opts options to customize the behavior + * @throws SyncException if the sync fails + * @return int|null the number of users or null if we can't get the info + */ + public function remoteItemCount($opts = []): ?int { + $backends = $this->extractBackendsFromOpts($opts); + + $items = 0; + foreach ($this->userSyncBackends as $userSyncBackend) { + if (!empty($backends) && !\in_array(\get_class($userSyncBackend->getUserInterface()), $backends)) { + // skip the backend + continue; + } + + $nUsers = $userSyncBackend->userCount(); + if ($nUsers === null) { + return null; + } + $items += $nUsers; + } + return $items; + } + + /** + * {@inheritDoc} + * + * Custom options: + * - "backends" => "back1,back2,back3" + * Only those backends will be synced, assuming they're registered. The + * rest of the backends will be ignored. + */ + public function sync($callback, $opts = []) { + $backends = $this->extractBackendsFromOpts($opts); + + foreach ($this->userSyncBackends as $userSyncBackend) { + if (!empty($backends) && !\in_array(\get_class($userSyncBackend->getUserInterface()), $backends)) { + // skip the backend + continue; + } + + $userSyncBackend->resetPointer(); + $backendSyncing = true; + do { + try { + $syncingUser = $userSyncBackend->getNextUser(); + if ($syncingUser === null) { + // no more users in the backend + $backendSyncing = false; + } else { + $this->syncSyncingUser($syncingUser, $userSyncBackend); + $callback($syncingUser->getAllData()); + } + } catch (SyncBackendUserFailedException $ex) { + // jump to the next user + $callback($ex); + continue; + } catch (SyncBackendBrokenException $ex) { + // skip the current backend + $callback($ex); + break; + } + } while ($backendSyncing); + } + } + + /** + * {@inheritDoc} + * + * Custom options: + * - "backends" => "back1,back2,back3" + * Only those backends will be synced, assuming they're registered. The + * rest of the backends will be ignored. + * + * @param string $id the id of the item we want to sync + * @param array $opts options to customize the behavior + * @throws SyncException if the sync fails + * @return bool true if synced without issues, false if the user isn't found + * remotely + */ + public function syncOne(string $id, $opts = []): bool { + $backends = $this->extractBackendsFromOpts($opts); + + foreach ($this->userSyncBackends as $userSyncBackend) { + if (!empty($backends) && !\in_array(\get_class($userSyncBackend->getUserInterface()), $backends)) { + // skip the backend + continue; + } + + $syncingUser = $userSyncBackend->getSyncingUser($id); + if ($syncingUser !== null) { + $this->syncSyncingUser($syncingUser, $userSyncBackend); + return true; + } + } + return false; + } + + /** + * Get a list of the requested backends from the opts. + */ + private function extractBackendsFromOpts($opts) { + $backends = []; + if (isset($opts['backends'])) { + $backends = \explode(',', $opts['backends']); + } + return $backends; + } + + /** + * Copied and adapted from the SyncService::createOrSyncAccount method + */ + private function syncSyncingUser(SyncingUser $syncingUser, IUserSyncBackend $userSyncBackend) { + $backend = $userSyncBackend->getUserInterface(); + $uid = $syncingUser->getUid(); + // Try to find the account based on the uid + try { + $account = $this->mapper->getByUid($uid); + // Check the backend matches + $existingAccountBackend = \get_class($backend); + if ($account->getBackend() !== $existingAccountBackend) { + $this->logger->warning( + "User <$uid> already provided by another backend({$account->getBackend()} !== $existingAccountBackend), skipping.", + ['app' => self::class] + ); + throw new SyncBackendUserFailedException('Returned account has different backend to the requested backend for sync'); + } + } catch (DoesNotExistException $e) { + // Create a new account for this uid and backend pairing and sync + $account = $this->createNewAccount(\get_class($backend), $uid); + } catch (MultipleObjectsReturnedException $e) { + throw new SyncBackendUserFailedException("The database returned multiple accounts for this uid: $uid"); + } + + // The account exists, sync + $account = $this->syncAccount($account, $syncingUser); + if ($account->getId() === null) { + // New account, insert + $this->mapper->insert($account); + } else { + $this->mapper->update($account); + } + return $account; + } + + private function createNewAccount($backend, $uid) { + $this->logger->info("Creating new account with UID $uid and backend $backend"); + $a = new Account(); + $a->setUserId($uid); + $a->setState(Account::STATE_ENABLED); + $a->setBackend($backend); + return $a; + } + + private function syncState(Account $a) { + $uid = $a->getUserId(); + $value = $this->config->getUserValue($uid, 'core', 'enabled', null); + if ($value !== null) { + if ($value === 'true') { + $a->setState(Account::STATE_ENABLED); + } else { + $a->setState(Account::STATE_DISABLED); + } + if (\array_key_exists('state', $a->getUpdatedFields())) { + if ($value === 'true') { + $this->logger->debug( + "Enabling <$uid>", + ['app' => self::class] + ); + } else { + $this->logger->debug( + "Disabling <$uid>", + ['app' => self::class] + ); + } + } + } + } + + /** + * @param Account $a + */ + private function syncLastLogin(Account $a) { + $uid = $a->getUserId(); + $value = $this->config->getUserValue($uid, 'login', 'lastLogin', null); + if ($value !== null) { + $a->setLastLogin($value); + if (\array_key_exists('lastLogin', $a->getUpdatedFields())) { + $this->logger->debug( + "Setting lastLogin for <$uid> to <$value>", + ['app' => self::class] + ); + } + } + } + + /** + * @param Account $a + * @param UserInterface $backend + */ + private function syncEmail(Account $a, SyncingUser $syncingUser) { + $uid = $a->getUserId(); + $email = $syncingUser->getEmail(); + if ($email !== null) { + $a->setEmail($email); + } else { + $email = $this->config->getUserValue($uid, 'settings', 'email', null); + if ($email !== null) { + $a->setEmail($email); + } + } + + if (\array_key_exists('email', $a->getUpdatedFields())) { + $this->logger->debug( + "Setting email for <$uid> to <$email>", + ['app' => self::class] + ); + } + } + + /** + * @param Account $a + * @param UserInterface $backend + */ + private function syncQuota(Account $a, SyncingUser $syncingUser) { + $uid = $a->getUserId(); + $quota = $syncingUser->getQuota(); + if ($quota !== null) { + $a->setQuota($quota); + } else { + $quota = $this->config->getUserValue($uid, 'files', 'quota', null); + if ($quota !== null) { + $a->setQuota($quota); + } + } + + if (\array_key_exists('quota', $a->getUpdatedFields())) { + $this->logger->debug( + "Setting quota for <$uid> to <$quota>", + ['app' => self::class] + ); + } + } + + /** + * @param Account $a + * @param UserInterface $backend + */ + private function syncHome(Account $a, SyncingUser $syncingUser) { + // Fallback for backends that dont yet use the new interfaces + $uid = $a->getUserId(); + $homeSyncing = $syncingUser->getHome(); + // Log when the backend returns a string that is a different home to the current value + if (\is_string($homeSyncing) && $a->getHome() !== $homeSyncing) { + $existing = $a->getHome(); + if ($existing !== '') { + $this->logger->error("Returned home: $homeSyncing for user: $uid which differs from existing value: $existing"); + } + } + // Home is handled differently, it should only be set on account creation, when there is no home already set + // Otherwise it could change on a sync and result in a new user folder being created + if ($a->getHome() === '') { + if (!\is_string($homeSyncing) || $homeSyncing[0] !== '/') { + $homeSyncing = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . "/$uid"; + $this->logger->debug( + "No home provided for <$uid>", + ['app' => self::class] + ); + } + // This will set the home if not provided by the backend + $a->setHome($homeSyncing); + if (\array_key_exists('home', $a->getUpdatedFields())) { + $this->logger->debug( + "Setting home for <$uid> to <$homeSyncing>", + ['app' => self::class] + ); + } + } + } + + /** + * @param Account $a + * @param UserInterface $backend + */ + private function syncDisplayName(Account $a, SyncingUser $syncingUser) { + $uid = $a->getUserId(); + $displayName = $syncingUser->getDisplayName(); + if ($displayName !== null) { + // TODO: What if displayName === null ; or the backend doesn't provide a displayName? + $a->setDisplayName($displayName); + if (\array_key_exists('displayName', $a->getUpdatedFields())) { + $this->logger->debug( + "Setting displayName for <$uid> to <$displayName>", + ['app' => self::class] + ); + } + } + } + + /** + * TODO store username in account table instead of user preferences + * + * @param Account $a + * @param UserInterface $backend + */ + private function syncUserName(Account $a, SyncingUser $syncingUser) { + $uid = $a->getUserId(); + $userName = $syncingUser->getUserName(); + if ($userName !== null) { + $currentUserName = $this->config->getUserValue($uid, 'core', 'username', null); + if ($userName !== $currentUserName) { + try { + $this->config->setUserValue($uid, 'core', 'username', $userName); + } catch (PreConditionNotMetException $e) { + // ignore, because precondition is empty + } + $this->logger->debug( + "Setting userName for <$uid> from <$currentUserName> to <$userName>", + ['app' => self::class] + ); + } + } + } + + /** + * @param Account $a + * @param UserInterface $backend + */ + private function syncSearchTerms(Account $a, SyncingUser $syncingUser) { + $uid = $a->getUserId(); + $searchTerms = $syncingUser->getSearchTerms(); + if ($searchTerms !== null) { + $a->setSearchTerms($searchTerms); + if ($a->haveTermsChanged()) { + $logTerms = \implode('|', $searchTerms); + $this->logger->debug( + "Setting searchTerms for <$uid> to <$logTerms>", + ['app' => self::class] + ); + } + } + } + + /** + * @param Account $a + * @param UserInterface $backend of the user + * @return Account + */ + private function syncAccount(Account $a, SyncingUser $syncingUser) { + $this->syncState($a); + $this->syncLastLogin($a); + $this->syncEmail($a, $syncingUser); + $this->syncQuota($a, $syncingUser); + $this->syncHome($a, $syncingUser); + $this->syncDisplayName($a, $syncingUser); + $this->syncUserName($a, $syncingUser); + $this->syncSearchTerms($a, $syncingUser); + return $a; + } +} diff --git a/lib/private/User/Database.php b/lib/private/User/Database.php index c522f50f95b9..65f853dbab55 100644 --- a/lib/private/User/Database.php +++ b/lib/private/User/Database.php @@ -289,6 +289,58 @@ public function getUsers($search = '', $limit = null, $offset = null) { return $users; } + /** + * Get a list of all users, each user having "uid" and "displayname". + * The data will be cached and available for other methods. + * For example: + * [ + * ['uid' => 'user1', 'displayname' => 'display1'], + * ['uid' => 'user2', 'displayname' => 'awesome'], + * ] + * + * Note: This method isn't part of the `UserInterface`. Additional data + * could be included if needed + * + * @return array + */ + public function getUsersData($search = '', $limit = null, $offset = null) { + $parameters = []; + $searchLike = ''; + if ($search !== '') { + $search = \OC::$server->getDatabaseConnection()->escapeLikeParameter($search); + $parameters[] = '%' . $search . '%'; + $searchLike = ' WHERE LOWER(`uid`) LIKE LOWER(?)'; + } + + $query = \OC_DB::prepare('SELECT `uid`, `displayname` FROM `*PREFIX*users`' . $searchLike . ' ORDER BY `uid` ASC', $limit, $offset); + $result = $query->execute($parameters); + $users = []; + while ($row = $result->fetchRow()) { + $uid = $row['uid']; + $this->cache[$uid]['uid'] = $uid; + $this->cache[$uid]['displayname'] = $row['displayname']; + $users[] = [ + 'uid' => $uid, + 'displayname' => $row['displayname'], + ]; + } + return $users; + } + + /** + * Get a map with user data, containing "uid" and "displayname" + * The data will be cached and available for other methods. + * For example: + * ['uid' => 'user1', 'displayname' => 'display1'] + * + * Note: This method isn't part of the `UserInterface`. Additional data + * could be included if needed + */ + public function getUserData($uid) { + $this->loadUser($uid); + return $this->cache[$uid]; + } + /** * check if a user exists * @param string $uid the username diff --git a/lib/public/IServerContainer.php b/lib/public/IServerContainer.php index 61f72a70940c..29eaa0c00133 100644 --- a/lib/public/IServerContainer.php +++ b/lib/public/IServerContainer.php @@ -554,4 +554,10 @@ public function getLicenseManager(); * @since 10.12.0 */ public function getLoginPolicyManager(); + + /** + * @return \OCP\Sync\ISyncManager + * @since 10.14.0 + */ + public function getSyncManager(); } diff --git a/lib/public/Sync/ISyncManager.php b/lib/public/Sync/ISyncManager.php new file mode 100644 index 000000000000..a6eb740b4407 --- /dev/null +++ b/lib/public/Sync/ISyncManager.php @@ -0,0 +1,86 @@ + + * + */ + +namespace OCP\Sync; + +use OCP\Sync\User\IUserSyncer; + +/** + * Interface ISyncManager + * + * @package OCP\Sync + * @since 10.14.0 + */ +interface ISyncManager { + /** + * Register the syncer with the provided name. + * This method WON'T overwrite a syncer that was already registered with + * the same name. Use `overwriteSyncer` if you want to overwrite. + * + * @param string $name the name which will be used to register the syncer + * @param ISyncer $syncer the syncer to be registered + * @return bool true if it's registered, false otherwise. In particular, + * registering a syncer with a name already used will return false and the + * old syncer will be kept. + * @since 10.14.0 + */ + public function registerSyncer(string $name, ISyncer $syncer): bool; + + /** + * Register the syncer with the provided name. If a syncer is already + * registered with the same name, it will be overwritten and the provided + * one will be used instead. + * + * Note that using this method with a non-used name (so the method will + * register the syncer instead of overwrite another one) has an undefined + * behavior and could change in the future. + * This method is intended to be used to purposely overwrite an already + * existing syncer; using it as a registration method to overcome the + * limitations with the `registerSyncer` is not intended. + * + * @param string $name the name which will be used to register the syncer + * @param ISyncer $syncer the syncer to be registered + * @return bool true if it's registered, false otherwise. + * @since 10.14.0 + */ + public function overwriteSyncer(string $name, ISyncer $syncer): bool; + + /** + * Get the syncer registered with the provided name. If no syncer is + * registered with that name, it will return null + * + * @param string $name the name that was used to register the syncer + * @return ISyncer|null the registered syncer or null if there is no + * syncer with that name. + * @since 10.14.0 + */ + public function getSyncer(string $name): ?ISyncer; + + /** + * Get the syncer to sync users. This is intended to be a shortcut for + * `(IUserSyncer) $syncManager->getSyncer('user')` + * If the syncer with the "user" name isn't a `IUserSyncer`, this method + * will return null + * + * @return IUserSyncer|null the syncer to sync users or null if none + * is registered + * @since 10.14.0 + */ + public function getUserSyncer(): ?IUserSyncer; +} diff --git a/lib/public/Sync/ISyncer.php b/lib/public/Sync/ISyncer.php new file mode 100644 index 000000000000..82bec44891db --- /dev/null +++ b/lib/public/Sync/ISyncer.php @@ -0,0 +1,190 @@ + + * + */ + +namespace OCP\Sync; + +/** + * Interface ISyncer. + * Represent a sync service that can be registered in the ISyncManager + * A sync service will provide 2 main functionalities: + * - Check whether a local item is still present in the remote source + * - Sync remote items from the remote source and store them locally + * + * Each implementation is expected to sync a different item. For example, + * one implementation can sync users, another file metadata, and another + * calendar data. + * + * @package OCP\Sync + * @since 10.14.0 + */ +interface ISyncer { + /** No change in the state of the item */ + public const CHECK_STATE_NO_CHANGE = 'no change'; + /** Item removed locally */ + public const CHECK_STATE_REMOVED = 'removed'; + /** Item disabled locally */ + public const CHECK_STATE_DISABLED = 'disabled'; + /** Item errored */ + public const CHECK_STATE_ERROR = 'error'; + + /** + * The number of items that have been synced and stored locally. + * + * This number might be different than the `remoteItemCount` if items + * are added or removed. + * Note that this number might include items that might be disabled or + * might be missing from the remote system. + * + * This method is intended to give a limit to a progress bar that could + * be used while checking (for the `check` method). + * + * Custom options can be used to change the behavior of the method, + * for example, to count only items with a specific state. The same + * options should be used (if possible) with the `check` and `checkOne` + * methods. + * The options are specific to each syncer. + * + * @param array $opts options to customize the behavior + * of this method + * @return int|null the number of items synced and stored locally + * @since 10.14.0 + */ + public function localItemCount($opts = []): ?int; + + /** + * Check the currently synced items against the remote system. If the state + * of the item is different (the item is missing from the remote system, or + * it has a different state), the syncer could perform some actions (depending + * on the implementation). + * + * The callback must be something like `callback($item, $state)` where the item + * could be an array with key-value data or an exception. The callback will be + * called on each item, and the same callback will also be called if an exception + * happens. Multiple exceptions might happen, and the callback will be called + * on each of them. + * This means that this method can send exceptions to the callback in order to + * report errors for individual items without interrupting the method. + * Exceptions sent through the callback MUST be `SyncException` or a subclass. + * + * Custom options can be passed as array to customize the + * behavior of the syncer. These options are specific to each syncer. The same + * options should be used (if possible) with the `localItemCount` and `checkOne` + * methods. + * + * @param callable $callback + * @param array $opts options to customize the sync behavior + * @throws SyncException if the method fails + * @since 10.14.0 + */ + public function check($callback, $opts = []); + + /** + * Check only the specified item associated with the target id. + * The id must be known and depends on the syncer. For example, + * if the syncer is syncing users, the id could be the userId, + * or it could be the user's email; if the syncer is syncing groups, + * it could be the groupId. + * Specific syncers should document the target id that they're expecting. + * + * Custom options can be passed as array to customize the + * behavior of the syncer. These options are specific to each syncer. The same + * options should be used (if possible) with the `localItemCount` and `check` + * methods. + * + * @param string $id the id of the item we want to sync + * @param array $opts options to customize the behavior + * @throws SyncException if the fails fails + * @return string one of the CHECK_STATE_* constants + * @since 10.14.0 + */ + public function checkOne(string $id, $opts = []): string; + + /** + * The maximum number of items that are expected to be synced at a given + * time. This can vary when items are added or removed from the backend. + * These items will be brought to the local system via the `sync` method. + * + * This method is intended to give a limit to a progress bar that could + * be used while syncing (for the `sync` method). + * + * If the number of items is unknown or can't be retrieved, null must be + * returned. + * + * Custom options can be used to change the behavior of the method, + * for example, to count only items from a specific backend if multiple + * backends are being used. The same options should be used (if possible) + * with the `sync` and `syncOne` methods. + * The options are specific to each syncer. + * + * @param array $opts options to customize the behavior + * of this method + * @return int|null the number of items to be synced, or null if such + * number is unknown + * @since 10.14.0 + */ + public function remoteItemCount($opts = []): ?int; + + /** + * Run the syncer to sync every item that can be synced with this service. + * This will bring items from an external source to the local system. + * + * The callback must be something like `callback($item)` where the item could + * be an array with key-value data or an exception. The callback will be + * call on each item, and the same callback will also be call if an exception + * happens. Multiple exceptions might happen, and the callback will be called + * on each of them. + * This means that this method can send exceptions to the callback in order to + * report errors for individual items without interrupting the method. + * Exceptions sent through the callback MUST be `SyncException` or a subclass. + * + * Custom options can be passed as array to customize the + * behavior of the syncer. These options are specific to each syncer. The same + * options should be used (if possible) with the `remoteItemCount` and + * `syncOne` methods. + * + * @param callable $callback + * @param array $opts options to customize the sync behavior + * @throws SyncException if the sync fails + * @since 10.14.0 + */ + public function sync($callback, $opts = []); + + /** + * Sync only the specified item associated with the target id. + * The id must be known and depends on the syncer. For example, + * if the syncer is syncing users, the id could be the userId, + * or it could be the user's email; if the syncer is syncing groups, + * it could be the groupId. + * Specific syncers should document the target id that they're expecting. + * + * Custom options can be passed as array to customize the + * behavior of the syncer. These options are specific to each syncer. The + * same options should be used (if possible) with the `remoteItemCount` + * and `sync` methods. + + * + * @param string $id the id of the item we want to sync + * @param array $opts options to customize the sync behavior + * @throws SyncException if the sync fails + * @return bool true if synced without issues, false if the item isn't + * found remotely. + * @since 10.14.0 + */ + public function syncOne(string $id, $opts = []): bool; +} diff --git a/lib/public/Sync/SyncException.php b/lib/public/Sync/SyncException.php new file mode 100644 index 000000000000..169bffca5465 --- /dev/null +++ b/lib/public/Sync/SyncException.php @@ -0,0 +1,27 @@ + + * + */ + +namespace OCP\Sync; + +/** + * An exception that could happen while syncing with the SyncManager + * @since 10.14.0 + */ +class SyncException extends \Exception { +} diff --git a/lib/public/Sync/User/IUserSyncBackend.php b/lib/public/Sync/User/IUserSyncBackend.php new file mode 100644 index 000000000000..d0718b44eafd --- /dev/null +++ b/lib/public/Sync/User/IUserSyncBackend.php @@ -0,0 +1,112 @@ + + * + */ + +namespace OCP\Sync\User; + +use OCP\UserInterface; + +/** + * Interface IUserSyncBackend. + * The backend that will be used to sync users with the IUserSyncer. + * The backend will connect to a data source to extract information about the + * users. + * + * Implementations are expected to use an internal pointer in order to get + * the users one by one. It's also advised to use some kind of caching to + * limit the interactions with the external data source while we're retrieving + * the users. + * + * @package OCP\Sync\User + * @since 10.14.0 + */ +interface IUserSyncBackend { + /** + * Reset the internal pointer. + * Once we've traversed all the users, the `getNextUser` will start to + * return null. The `resetPointer` method can be used to reset the internal + * pointer so the users can be returned again from the beginning. + * This method can be used anytime to reset the pointer and start from the + * beginning. + * + * @since 10.14.0 + */ + public function resetPointer(); + + /** + * Get the next user to be synced. + * The expected behavior is the following: + * - A `SyncingUser` will be returned if everything is ok. + * - `null` will be returned if there are no more users to be synced. + * - A `SyncBackendBroken` exception will be thrown if we can't connect to + * the backend. The `UserSyncer` is expected to abort syncing this backend and + * jump to the next one available. + * - A `SyncBackendUserFailed` exception will be thrown if there is something + * wrong with the current user and can't be synced. The `UserSyncer` will handle + * that exception and call again this method to get the next user. + * + * A lot of calls to this method are expected. The implementation is expected + * to cache a bunch of users to reduce the number of calls to the backend + * + * @return SyncingUser|null the user that needs to be synced or null if there are + * no more users to be synced. + * @throws SyncBackendBrokenException if we can't connect to the backend or + * we can't get any data from it. This should cause no additional calls + * to this method + * @throws SyncBackendUserFailedException if there is something wrong with + * the current user and the user can't or shouldn't be synced. Further calls + * to this method are expected in order to sync the rest of the users. + * @since 10.14.0 + */ + public function getNextUser(): ?SyncingUser; + + /** + * Get the specified user in order to sync it. + * The behavior will be the same as the `getNextUser`, but this method will + * just always return the specified user + * + * @param string $uid the uid of the user we want to sync. + * @return SyncingUser|null the specified user that needs to be synced or null + * if the user isn't found + * @throws SyncBackendBrokenException if we can't connect to the backend or + * we can't get any data from it. This should cause no additional calls + * to this method + * @throws SyncBackendUserFailedException if there is something wrong with + * the current user and the user can't or shouldn't be synced. + * @since 10.14.0 + */ + public function getSyncingUser(string $uid): ?SyncingUser; + + /** + * Get the number of users in the backend, or null if such information is + * unknown + * + * @return int|null the number of users, or null if it's unknown + * @since 10.14.0 + */ + public function userCount(): ?int; + + /** + * Get the UserInterface / backend associated with this syncer. All users + * must come from the same backend and must have the same UserInterface + * + * @return UserInterface the backend where all the user being synced come from. + * @since 10.14.0 + */ + public function getUserInterface(): UserInterface; +} diff --git a/lib/public/Sync/User/IUserSyncer.php b/lib/public/Sync/User/IUserSyncer.php new file mode 100644 index 000000000000..e98c7fd0abd3 --- /dev/null +++ b/lib/public/Sync/User/IUserSyncer.php @@ -0,0 +1,46 @@ + + * + */ + +namespace OCP\Sync\User; + +use OCP\Sync\ISyncer; + +/** + * Interface IUserSyncer. + * Represent a sync service that can be registered in the ISyncManager. + * This particular sync service syncs users from multiple backends such as + * LDAP, Database and others. + * Note that just the registered backends will be used during the syncing. + * + * This interface extends the `ISyncer` one in order to allow registration + * of multiple user backends that will synced. + * + * @package OCP\Sync\User + * @since 10.14.0 + */ +interface IUserSyncer extends ISyncer { + /** + * Register the provided backend so that the service can get the users + * from that backend and sync them. + * + * @param IUserSyncBackend $userSyncBackend the backend + * @since 10.14.0 + */ + public function registerBackend(IUserSyncBackend $userSyncBackend); +} diff --git a/lib/public/Sync/User/SyncBackendBrokenException.php b/lib/public/Sync/User/SyncBackendBrokenException.php new file mode 100644 index 000000000000..2bd602ed7b1a --- /dev/null +++ b/lib/public/Sync/User/SyncBackendBrokenException.php @@ -0,0 +1,30 @@ + + * + */ + +namespace OCP\Sync\User; + +use OCP\Sync\SyncException; + +/** + * Exception thrown while syncing, when the user backend is broken / inaccessible + * and we don't want more calls to the backend. + * @since 10.14.0 + */ +class SyncBackendBrokenException extends SyncException { +} diff --git a/lib/public/Sync/User/SyncBackendUserFailedException.php b/lib/public/Sync/User/SyncBackendUserFailedException.php new file mode 100644 index 000000000000..af66cbceac34 --- /dev/null +++ b/lib/public/Sync/User/SyncBackendUserFailedException.php @@ -0,0 +1,31 @@ + + * + */ + +namespace OCP\Sync\User; + +use OCP\Sync\SyncException; + +/** + * Exception thrown while syncing, when there is a problem with the user we + * want to sync. This means that such user should be skipped, but it's possible + * to get more users from the backend. + * @since 10.14.0 + */ +class SyncBackendUserFailedException extends SyncException { +} diff --git a/lib/public/Sync/User/SyncingUser.php b/lib/public/Sync/User/SyncingUser.php new file mode 100644 index 000000000000..14d8c7281ca7 --- /dev/null +++ b/lib/public/Sync/User/SyncingUser.php @@ -0,0 +1,183 @@ + + * + */ + +namespace OCP\Sync\User; + +/** + * Class representing a user that is being synced. + * This is just a data holder to transfer information between the + * `IUserSyncBackend` and the `IUserSyncer` because the backend will likely + * be in a different app. + * @since 10.14.0 + */ +class SyncingUser { + /** @var array */ + private $userData; + + /** + * @param string $uid + * @since 10.14.0 + */ + public function __construct(string $uid) { + $this->userData['uid'] = $uid; + } + + /** + * Get the uid of the user + * @since 10.14.0 + */ + public function getUid() { + return $this->userData['uid']; + } + + /** + * Set the displayName that should be used. + * This method will return this same instance + * @since 10.14.0 + */ + public function setDisplayName(string $displayName) { + $this->userData['displayName'] = $displayName; + return $this; + } + + /** + * Get the provided displayName or null if it wasn't provided + * @since 10.14.0 + */ + public function getDisplayName() { + return $this->userData['displayName'] ?? null; + } + + /** + * Set the email + * @since 10.14.0 + */ + public function setEmail(string $email) { + $this->userData['email'] = $email; + return $this; + } + + /** + * Get the email or null if it wasn't provided + * @since 10.14.0 + */ + public function getEmail() { + return $this->userData['email'] ?? null; + } + + /** + * Set the quota + * @since 10.14.0 + */ + public function setQuota(string $quota) { + $this->userData['quota'] = $quota; + return $this; + } + + /** + * Get the quota or null if it wasn't provided + * @since 10.14.0 + */ + public function getQuota() { + return $this->userData['quota'] ?? null; + } + + /** + * Set the home + * @since 10.14.0 + */ + public function setHome(string $home) { + $this->userData['home'] = $home; + return $this; + } + + /** + * Get the home or null if it wasn't provided + * @since 10.14.0 + */ + public function getHome() { + return $this->userData['home'] ?? null; + } + + /** + * Set the username + * @since 10.14.0 + */ + public function setUsername(string $username) { + $this->userData['username'] = $username; + return $this; + } + + /** + * Get the username or null if it wasn't provided + * @since 10.14.0 + */ + public function getUsername() { + return $this->userData['username'] ?? null; + } + + /** + * Set a list of search terms + * @since 10.14.0 + */ + public function setSearchTerms(array $searchTerms) { + $this->userData['searchTerms'] = $searchTerms; + return $this; + } + + /** + * Get the list of search terms or null if it wasn't provided + * @since 10.14.0 + */ + public function getSearchTerms() { + return $this->userData['searchTerms'] ?? null; + } + + /** + * Set additional information for the user + * Fields that can be set via the `set*` methods won't be set and this + * method will return false. + * @since 10.14.0 + */ + public function setExtra(string $key, string $value) { + $props = ['uid', 'displayName', 'email', 'quota', 'home', 'username', 'searchTerms']; + if (\in_array($key, $props, true)) { + // reserved name + return false; + } + $this->userData[$key] = $value; + return $this; + } + + /** + * Get the additional information or null if the requested info isn't set + * @since 10.14.0 + */ + public function getExtra(string $key) { + return $this->userData[$key] ?? null; + } + + /** + * Get all data already set, as a map (key => value) + * @since 10.14.0 + */ + public function getAllData() { + return $this->userData; + } +} From 50aa77f2867d6c479f1e264b4c7268376757842b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Mon, 9 Oct 2023 14:27:20 +0200 Subject: [PATCH 2/4] Fix code problems --- core/Command/Sync/Sync.php | 4 ++-- lib/private/Sync/User/UserSyncer.php | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/Command/Sync/Sync.php b/core/Command/Sync/Sync.php index 36efecde5438..77fdcffa86f5 100644 --- a/core/Command/Sync/Sync.php +++ b/core/Command/Sync/Sync.php @@ -125,7 +125,7 @@ private function checkLocalData(ISyncer $syncer, $opts, OutputInterface $output) $output->writeln('Checking local data'); $progress = new ProgressBar($output); $progress->start($syncer->localItemCount($opts)); - $syncer->check(function ($item, $state) use ($output, $progress, &$itemStateList, &$errorList) { + $syncer->check(function ($item, $state) use ($progress, &$itemStateList, &$errorList) { if (\is_array($item) && $state !== ISyncer::CHECK_STATE_NO_CHANGE) { $key = \array_key_first($item); $itemStateList[] = [ @@ -168,7 +168,7 @@ private function syncRemoteData(ISyncer $syncer, $opts, OutputInterface $output) $output->writeln('Syncing remote data'); $progress = new ProgressBar($output); $progress->start($syncer->remoteItemCount($opts)); - $syncer->sync(function ($item) use ($output, $progress, &$errorList) { + $syncer->sync(function ($item) use ($progress, &$errorList) { if ($item instanceof \Exception) { $errorList[] = $item; } else { diff --git a/lib/private/Sync/User/UserSyncer.php b/lib/private/Sync/User/UserSyncer.php index ebe98442f371..4608a4450477 100644 --- a/lib/private/Sync/User/UserSyncer.php +++ b/lib/private/Sync/User/UserSyncer.php @@ -215,6 +215,10 @@ public function checkOne(string $id, $opts = []): string { throw new SyncBackendUserFailedException("The database returned multiple accounts for this uid: $id"); } + if (!empty($backends) && !\in_array($targetBackend, $backends)) { + throw new SyncBackendUserFailedException("User found not belonging to any of the requested backends"); + } + foreach ($this->userSyncBackends as $userSyncBackend) { if (!isset($targetBackend) || \get_class($userSyncBackend->getUserInterface()) === $targetBackend) { $syncingUser = $userSyncBackend->getSyncingUser($id); From 663e311366ac46c8883571d2712abfd6b89d5d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 10 Oct 2023 18:25:07 +0200 Subject: [PATCH 3/4] Adjust code and add unit tests --- lib/private/Sync/User/UserSyncDBBackend.php | 20 +- lib/private/Sync/User/UserSyncer.php | 14 +- tests/lib/Sync/SyncManagerTest.php | 92 + tests/lib/Sync/User/UserSyncDBBackendTest.php | 253 +++ tests/lib/Sync/User/UserSyncerTest.php | 1920 +++++++++++++++++ 5 files changed, 2293 insertions(+), 6 deletions(-) create mode 100644 tests/lib/Sync/SyncManagerTest.php create mode 100644 tests/lib/Sync/User/UserSyncDBBackendTest.php create mode 100644 tests/lib/Sync/User/UserSyncerTest.php diff --git a/lib/private/Sync/User/UserSyncDBBackend.php b/lib/private/Sync/User/UserSyncDBBackend.php index 5f168f567a5f..9b4c7e1a22bb 100644 --- a/lib/private/Sync/User/UserSyncDBBackend.php +++ b/lib/private/Sync/User/UserSyncDBBackend.php @@ -35,6 +35,20 @@ public function __construct(Database $dbUserBackend) { $this->dbUserBackend = $dbUserBackend; } + /** + * This is intended to be used just for unit tests + */ + public function getPointer(): int { + return $this->pointer; + } + + /** + * This is intended to be used just for unit tests + */ + public function getCachedUserData(): array { + return $this->cachedUserData; + } + /** * @inheritDoc */ @@ -106,7 +120,11 @@ public function getSyncingUser(string $id): ?SyncingUser { * @inheritDoc */ public function userCount(): ?int { - return $this->dbUserBackend->countUsers(); + $nUsers = $this->dbUserBackend->countUsers(); + if ($nUsers === false) { + return null; + } + return $nUsers; } /** diff --git a/lib/private/Sync/User/UserSyncer.php b/lib/private/Sync/User/UserSyncer.php index 4608a4450477..0965fb241a9b 100644 --- a/lib/private/Sync/User/UserSyncer.php +++ b/lib/private/Sync/User/UserSyncer.php @@ -72,10 +72,13 @@ public function registerBackend(IUserSyncBackend $userSyncBackend) { * * Using "missingAction" as option won't do anything here. It will be ignored. * + * This method won't take into account whether the backends (including the + * requested ones) are registered or not. + * * Custom options: * - "backends" => "back1,back2,back3" - * Only those backends will be counted, assuming they're registered. The - * rest of the backends will be ignored. + * Only those backends will be counted. The rest of the backends will + * be ignored. */ public function localItemCount($opts = []): ?int { $backends = $this->extractBackendsFromOpts($opts); @@ -104,8 +107,9 @@ public function localItemCount($opts = []): ?int { * - "missingAction" => "remove" or "disable". * The action to do if the account is missing in the backend * - "backends" => "back1,back2,back3" - * Only those backends will be synced, assuming they're registered. The - * rest of the backends will be ignored. + * Only those backends will be checked. The rest of the backends will + * be ignored. If the backends aren't registered, an error will be + * send through the callback. */ public function check($callback, $opts = []) { $backends = $this->extractBackendsFromOpts($opts); @@ -140,7 +144,7 @@ public function check($callback, $opts = []) { $targetUserSyncBackend = $backendToUserSync[$targetBackend] ?? null; if ($targetUserSyncBackend === null) { - // send and exception to the callback + // backend not registered -> send and exception to the callback $callback(new SyncException("{$a->getUserId()}, backend {$targetBackend} is not found"), ISyncer::CHECK_STATE_ERROR); return; } diff --git a/tests/lib/Sync/SyncManagerTest.php b/tests/lib/Sync/SyncManagerTest.php new file mode 100644 index 000000000000..d4c2687c8a85 --- /dev/null +++ b/tests/lib/Sync/SyncManagerTest.php @@ -0,0 +1,92 @@ + + * + */ + +namespace Tests\Sync; + +use OC\Sync\SyncManager; +use OCP\Sync\ISyncer; +use OCP\Sync\User\IUserSyncer; +use Test\TestCase; + +class SyncManagerTest extends TestCase { + /** @var SyncManager */ + private $syncManager; + + protected function setUp(): void { + $this->syncManager = new SyncManager(); + } + + public function testRegisterSyncer() { + $syncer = $this->createMock(ISyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('mySyncer', $syncer)); + } + + public function testRegisterSyncerTwice() { + $syncer = $this->createMock(ISyncer::class); + $syncer2 = $this->createMock(ISyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('mySyncer', $syncer)); + $this->assertFalse($this->syncManager->registerSyncer('mySyncer', $syncer2)); + } + + public function testOverwriteSyncer() { + $syncer = $this->createMock(ISyncer::class); + $syncer2 = $this->createMock(ISyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('mySyncer', $syncer)); + $this->assertTrue($this->syncManager->overwriteSyncer('mySyncer', $syncer2)); + } + + public function testOverwriteSyncerNotOverwrite() { + $syncer = $this->createMock(ISyncer::class); + $this->assertFalse($this->syncManager->overwriteSyncer('mySyncer', $syncer)); + } + + public function testGetSyncerMissing() { + $this->assertNull($this->syncManager->getSyncer('mySyncer')); + } + + public function testGetSyncerSetAndGet() { + $syncer = $this->createMock(ISyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('mySyncer', $syncer)); + $this->assertSame($syncer, $this->syncManager->getSyncer('mySyncer')); + } + + public function testGetUserSyncerMissing() { + $this->assertNull($this->syncManager->getUserSyncer()); + } + + public function testGetUserSyncerSetAndGet() { + $userSyncer = $this->createMock(IUserSyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('user', $userSyncer)); + $this->assertSame($userSyncer, $this->syncManager->getUserSyncer()); + } + + public function testGetUserSyncerSetAndGetOverwrite() { + $userSyncer = $this->createMock(IUserSyncer::class); + $userSyncer2 = $this->createMock(IUserSyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('user', $userSyncer)); + $this->assertTrue($this->syncManager->overwriteSyncer('user', $userSyncer2)); + $this->assertSame($userSyncer2, $this->syncManager->getUserSyncer()); + } + + public function testGetUserSyncerNotUserSyncer() { + $syncer = $this->createMock(ISyncer::class); + $this->assertTrue($this->syncManager->registerSyncer('user', $syncer)); + $this->assertNull($this->syncManager->getUserSyncer()); + } +} diff --git a/tests/lib/Sync/User/UserSyncDBBackendTest.php b/tests/lib/Sync/User/UserSyncDBBackendTest.php new file mode 100644 index 000000000000..5c9b9fd3c797 --- /dev/null +++ b/tests/lib/Sync/User/UserSyncDBBackendTest.php @@ -0,0 +1,253 @@ + + * + */ + +namespace Tests\Sync\User; + +use OC\Sync\User\UserSyncDBBackend; +use OC\User\Database; +use OCP\UserInterface; +use OCP\Sync\User\IUserSyncBackend; +use OCP\Sync\User\SyncingUser; +use Test\TestCase; + +class UserSyncDBBackendTest extends TestCase { + /** @var UserSyncDBBackend */ + private $userSyncDBBackend; + /** @var Database */ + private $database; + + protected function setUp(): void { + $this->database = $this->createMock(Database::class); + $this->userSyncDBBackend = new UserSyncDBBackend($this->database); + } + + public function testResetPointer() { + $this->userSyncDBBackend->resetPointer(); + $this->assertSame(0, $this->userSyncDBBackend->getPointer()); + $this->assertEquals(['min' => 0, 'max' => 0, 'last' => false], $this->userSyncDBBackend->getCachedUserData()); + } + + public function testGetNextUser() { + $userData = [ + ['uid' => 'user1', 'displayname' => ''], + ['uid' => 'user2', 'displayname' => 'awesome'], + ]; + + $this->database->expects($this->once()) + ->method('getUsersData') + ->willReturn($userData); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('user1'); + $expectedUser->setHome('/home/user1'); + + $nextUser = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser->getAllData(), $nextUser->getAllData()); + } + + public function testGetNextUser2x() { + $userData = [ + ['uid' => 'user1', 'displayname' => 'display1'], + ['uid' => 'user2', 'displayname' => 'awesome'], + ]; + + $this->database->expects($this->once()) + ->method('getUsersData') + ->willReturn($userData); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('display1'); + $expectedUser->setHome('/home/user1'); + $expectedUser2 = new SyncingUser('user2'); + $expectedUser2->setDisplayName('awesome'); + $expectedUser2->setHome('/home/user2'); + + $nextUser = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser->getAllData(), $nextUser->getAllData()); + $nextUser2 = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser2->getAllData(), $nextUser2->getAllData()); + } + + public function testGetNextUser3x() { + $userData = [ + ['uid' => 'user1', 'displayname' => 'display1'], + ['uid' => 'user2', 'displayname' => 'awesome'], + ]; + + $this->database->expects($this->exactly(2)) + ->method('getUsersData') + ->will($this->onConsecutiveCalls($userData, [])); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('display1'); + $expectedUser->setHome('/home/user1'); + $expectedUser2 = new SyncingUser('user2'); + $expectedUser2->setDisplayName('awesome'); + $expectedUser2->setHome('/home/user2'); + + $nextUser = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser->getAllData(), $nextUser->getAllData()); + $nextUser2 = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser2->getAllData(), $nextUser2->getAllData()); + + $this->assertNull($this->userSyncDBBackend->getNextUser()); + } + + public function testGetNextUser3xMoreData() { + $userData = [ + ['uid' => 'user1', 'displayname' => 'display1'], + ['uid' => 'user2', 'displayname' => 'awesome'], + ]; + $userData2 = [ + ['uid' => 'user3', 'displayname' => 'blob'], + ['uid' => 'user4', 'displayname' => 'limeJuice'], + ]; + + $this->database->expects($this->exactly(2)) + ->method('getUsersData') + ->will($this->onConsecutiveCalls($userData, $userData2)); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('display1'); + $expectedUser->setHome('/home/user1'); + $expectedUser2 = new SyncingUser('user2'); + $expectedUser2->setDisplayName('awesome'); + $expectedUser2->setHome('/home/user2'); + $expectedUser3 = new SyncingUser('user3'); + $expectedUser3->setDisplayName('blob'); + $expectedUser3->setHome('/home/user3'); + + $nextUser = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser->getAllData(), $nextUser->getAllData()); + $nextUser2 = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser2->getAllData(), $nextUser2->getAllData()); + $nextUser3 = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser3->getAllData(), $nextUser3->getAllData()); + } + + public function testGetNextUser4x() { + $userData = [ + ['uid' => 'user1', 'displayname' => 'display1'], + ['uid' => 'user2', 'displayname' => 'awesome'], + ]; + + $this->database->expects($this->exactly(2)) + ->method('getUsersData') + ->will($this->onConsecutiveCalls($userData, [])); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('display1'); + $expectedUser->setHome('/home/user1'); + $expectedUser2 = new SyncingUser('user2'); + $expectedUser2->setDisplayName('awesome'); + $expectedUser2->setHome('/home/user2'); + + $nextUser = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser->getAllData(), $nextUser->getAllData()); + $nextUser2 = $this->userSyncDBBackend->getNextUser(); + $this->assertEquals($expectedUser2->getAllData(), $nextUser2->getAllData()); + + $this->assertNull($this->userSyncDBBackend->getNextUser()); // would need to fetch data + $this->assertNull($this->userSyncDBBackend->getNextUser()); // no need to fetch data + } + + public function testGetSyncingUser() { + $userData = ['uid' => 'user1', 'displayname' => 'display1']; + + $this->database->method('getUserData') + ->with('user1') + ->willReturn($userData); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('display1'); + $expectedUser->setHome('/home/user1'); + + $this->assertEquals($expectedUser->getAllData(), $this->userSyncDBBackend->getSyncingUser('user1')->getAllData()); + } + + public function testGetSyncingUserNoDisplayname() { + $userData = ['uid' => 'user1', 'displayname' => '']; + + $this->database->method('getUserData') + ->with('user1') + ->willReturn($userData); + $this->database->method('getHome') + ->will($this->returnCallback(function ($uid) { + return "/home/{$uid}"; + })); + + $expectedUser = new SyncingUser('user1'); + $expectedUser->setDisplayName('user1'); + $expectedUser->setHome('/home/user1'); + + $this->assertEquals($expectedUser->getAllData(), $this->userSyncDBBackend->getSyncingUser('user1')->getAllData()); + } + + public function testGetSyncingUserMissing() { + $this->database->method('getUserData') + ->with('user1') + ->willReturn(false); + $this->database->expects($this->never()) + ->method('getHome'); + + $this->assertNull($this->userSyncDBBackend->getSyncingUser('user1')); + } + + public function testUserCount() { + $this->database->expects($this->once()) + ->method('countUsers') + ->willReturn(77); + $this->assertSame(77, $this->userSyncDBBackend->userCount()); + } + + public function testUserCountFailure() { + $this->database->expects($this->once()) + ->method('countUsers') + ->willReturn(false); + $this->assertNull($this->userSyncDBBackend->userCount()); + } + + public function testGetUserInterface() { + $this->assertSame($this->database, $this->userSyncDBBackend->getUserInterface()); + } +} diff --git a/tests/lib/Sync/User/UserSyncerTest.php b/tests/lib/Sync/User/UserSyncerTest.php new file mode 100644 index 000000000000..61e6fe517855 --- /dev/null +++ b/tests/lib/Sync/User/UserSyncerTest.php @@ -0,0 +1,1920 @@ + + * + */ + +namespace Tests\Sync; + +use OC\Sync\User\UserSyncer; +use OC\User\Account; +use OC\User\AccountMapper; +use OCP\IConfig; +use OCP\ILogger; +use OCP\IUserManager; +use OCP\IUser; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\Sync\SyncException; +use OCP\Sync\User\IUserSyncBackend; +use OCP\Sync\User\SyncBackendUserFailedException; +use OCP\Sync\User\SyncBackendBrokenException; +use OCP\Sync\User\SyncingUser; +use OCP\Sync\ISyncer; +use OCP\UserInterface; +use Test\TestCase; + +class UserSyncerTest extends TestCase { + /** @var IUserManager */ + private $userManager; + /** @var AccountMapper */ + private $mapper; + /** @var IConfig */ + private $config; + /** @var ILogger */ + private $logger; + /** @var UserSyncer */ + private $userSyncer; + + protected function setUp(): void { + $this->mapper = $this->createMock(AccountMapper::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->config = $this->createMock(IConfig::class); + $this->logger = $this->createMock(ILogger::class); + + $this->userSyncer = new UserSyncer($this->userManager, $this->mapper, $this->config, $this->logger); + } + + public function testLocalItemCount() { + $this->mapper->method('getUserCount')->willReturn(97); + $this->assertSame(97, $this->userSyncer->localItemCount()); + } + + public function testLocalItemCountWithBackends() { + $userInterface = $this->createMock(UserInterface::class); + $userSyncBackend = $this->createMock(IUserSyncBackend::class); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $perBackendCount = [ + 'back1' => 77, + \get_class($userInterface) => 123, + ]; + + $this->mapper->method('getUserCountPerBackend')->willReturn($perBackendCount); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->assertSame(123, $this->userSyncer->localItemCount(['backends' => \get_class($userInterface)])); + } + + public function testLocalItemCountWithBackendsNotRegistered() { + $userInterface = $this->createMock(UserInterface::class); + $userSyncBackend = $this->createMock(IUserSyncBackend::class); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $perBackendCount = [ + 'back1' => 77, + \get_class($userInterface) => 123, + ]; + + $this->mapper->method('getUserCountPerBackend')->willReturn($perBackendCount); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->assertSame(77, $this->userSyncer->localItemCount(['backends' => 'back1'])); + } + + public function testCheck() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', $syncUser1] + ])); + $userSyncBackend2->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user2', $syncUser2] + ])); + + $accountList = [$account1, $account2]; + $this->mapper->method('callForUsers') + ->will($this->returnCallback(function ($callback) use ($accountList) { + foreach ($accountList as $acc) { + $callback($acc); + } + })); + + $collectedData = []; + $collectingCallback = function ($kvData, $state) use (&$collectedData) { + $collectedData[] = ['data' => $kvData, 'state' => $state]; + }; + + $this->userSyncer->check($collectingCallback); + + $this->assertSame(2, \count($collectedData)); // 2 results + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[0]['state']); + + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[1]['state']); + } + + public function testCheckFailedUser() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->method('getSyncingUser') + ->will($this->throwException(new SyncBackendUserFailedException('Failed to fetch'))); + $userSyncBackend2->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user2', $syncUser2] + ])); + + $accountList = [$account1, $account2]; + $this->mapper->method('callForUsers') + ->will($this->returnCallback(function ($callback) use ($accountList) { + foreach ($accountList as $acc) { + $callback($acc); + } + })); + + $collectedData = []; + $collectingCallback = function ($kvData, $state) use (&$collectedData) { + $collectedData[] = ['data' => $kvData, 'state' => $state]; + }; + + $this->userSyncer->check($collectingCallback); + + $this->assertSame(2, \count($collectedData)); // 2 results + + $this->assertEquals(new SyncBackendUserFailedException('Failed to fetch'), $collectedData[0]['data']); + $this->assertSame(ISyncer::CHECK_STATE_ERROR, $collectedData[0]['state']); + + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[1]['state']); + } + + public function testCheckBrokenBackend() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account1_2 = Account::fromRow([ + 'id' => 123456, + 'email' => 'disp02@example.io', + 'user_id' => 'user1_2', + 'lower_user_id' => 'user1_2', + 'display_name' => 'display1or2', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1_2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1_2 = new SyncingUser('user1_2'); + $syncUser1_2->setDisplayName('display1or2'); + $syncUser1_2->setEmail('disp02@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->expects($this->once()) + ->method('getSyncingUser') + ->will($this->throwException(new SyncBackendBrokenException('Backend disconnected'))); + $userSyncBackend2->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user2', $syncUser2] + ])); + + $accountList = [$account1, $account1_2, $account2]; + $this->mapper->method('callForUsers') + ->will($this->returnCallback(function ($callback) use ($accountList) { + foreach ($accountList as $acc) { + $callback($acc); + } + })); + + $collectedData = []; + $collectingCallback = function ($kvData, $state) use (&$collectedData) { + $collectedData[] = ['data' => $kvData, 'state' => $state]; + }; + + $this->userSyncer->check($collectingCallback); + + $this->assertSame(2, \count($collectedData)); // 2 results + + $this->assertEquals(new SyncBackendBrokenException('Backend disconnected'), $collectedData[0]['data']); + $this->assertSame(ISyncer::CHECK_STATE_ERROR, $collectedData[0]['state']); + // only one "backend broken" exception is expected for the syncBackend1 + + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[1]['state']); + } + + public function testCheckOnlyOneBackend() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', $syncUser1] + ])); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user2', $syncUser2] + ])); + + $accountList = [$account1, $account2]; + $this->mapper->method('callForUsers') + ->will($this->returnCallback(function ($callback) use ($accountList) { + foreach ($accountList as $acc) { + $callback($acc); + } + })); + + $collectedData = []; + $collectingCallback = function ($kvData, $state) use (&$collectedData) { + $collectedData[] = ['data' => $kvData, 'state' => $state]; + }; + + $this->userSyncer->check($collectingCallback, ['backends' => 'Mock_UserInterface_001']); + + $this->assertSame(1, \count($collectedData)); // 1 results + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[0]['state']); + } + + public function testCheckMissingUserDisable() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', null] + ])); + $userSyncBackend2->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user2', $syncUser2] + ])); + + $userObj = $this->createMock(IUser::class); + $userObj->method('isEnabled')->willReturn(true); + $userObj->expects($this->once()) + ->method('setEnabled') + ->with(false); + $userObj->expects($this->never()) + ->method('delete'); + $this->userManager->method('get') + ->will($this->returnValueMap([ + ['user1', false, $userObj] + ])); + + $accountList = [$account1, $account2]; + $this->mapper->method('callForUsers') + ->will($this->returnCallback(function ($callback) use ($accountList) { + foreach ($accountList as $acc) { + $callback($acc); + } + })); + + $collectedData = []; + $collectingCallback = function ($kvData, $state) use (&$collectedData) { + $collectedData[] = ['data' => $kvData, 'state' => $state]; + }; + + $this->userSyncer->check($collectingCallback); + + $this->assertSame(2, \count($collectedData)); // 2 results + + $this->assertEquals(['uid' => 'user1', 'displayname' => 'display1', 'email' => 'disp@example.io'], $collectedData[0]['data']); + $this->assertSame(ISyncer::CHECK_STATE_DISABLED, $collectedData[0]['state']); + + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[1]['state']); + } + + public function testCheckMissingUserRemoved() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', null] + ])); + $userSyncBackend2->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user2', $syncUser2] + ])); + + $userObj = $this->createMock(IUser::class); + $userObj->method('isEnabled')->willReturn(true); + $userObj->expects($this->never()) + ->method('setEnabled'); + $userObj->expects($this->once()) + ->method('delete'); + $this->userManager->method('get') + ->will($this->returnValueMap([ + ['user1', false, $userObj] + ])); + + $accountList = [$account1, $account2]; + $this->mapper->method('callForUsers') + ->will($this->returnCallback(function ($callback) use ($accountList) { + foreach ($accountList as $acc) { + $callback($acc); + } + })); + + $collectedData = []; + $collectingCallback = function ($kvData, $state) use (&$collectedData) { + $collectedData[] = ['data' => $kvData, 'state' => $state]; + }; + + $this->userSyncer->check($collectingCallback, ['missingAction' => 'remove']); + + $this->assertSame(2, \count($collectedData)); // 2 results + + $this->assertEquals(['uid' => 'user1', 'displayname' => 'display1', 'email' => 'disp@example.io'], $collectedData[0]['data']); + $this->assertSame(ISyncer::CHECK_STATE_REMOVED, $collectedData[0]['state']); + + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]['data']); + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $collectedData[1]['state']); + } + + public function testCheckOne() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->willReturn($account1); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', $syncUser1] + ])); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->assertSame(ISyncer::CHECK_STATE_NO_CHANGE, $this->userSyncer->checkOne('user1')); + } + + public function testCheckOneDisabled() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->willReturn($account1); + + $userObj = $this->createMock(IUser::class); + $userObj->method('isEnabled')->willReturn(true); + $userObj->expects($this->once()) + ->method('setEnabled'); + $userObj->expects($this->never()) + ->method('delete'); + $this->userManager->method('get') + ->will($this->returnValueMap([ + ['user1', false, $userObj] + ])); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', null] + ])); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->assertSame(ISyncer::CHECK_STATE_DISABLED, $this->userSyncer->checkOne('user1')); + } + + public function testCheckOneRemoved() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->willReturn($account1); + + $userObj = $this->createMock(IUser::class); + $userObj->method('isEnabled')->willReturn(true); + $userObj->expects($this->never()) + ->method('setEnabled'); + $userObj->expects($this->once()) + ->method('delete'); + $this->userManager->method('get') + ->will($this->returnValueMap([ + ['user1', false, $userObj] + ])); + + $userSyncBackend->method('getSyncingUser') + ->will($this->returnValueMap([ + ['user1', null] + ])); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->assertSame(ISyncer::CHECK_STATE_REMOVED, $this->userSyncer->checkOne('user1', ['missingAction' => 'remove'])); + } + + public function testCheckOneSyncFetchException() { + $this->expectException(SyncException::class); + + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->willReturn($account1); + + $userSyncBackend->method('getSyncingUser') + ->will($this->throwException(new SyncBackendBrokenException('disconnecter from external'))); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->userSyncer->checkOne('user1'); + } + + public function testCheckOneAccountNotExists() { + $this->expectException(SyncBackendUserFailedException::class); + $this->expectExceptionMessage('The database returned no accounts for this uid: user1'); + + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->will($this->throwException(new DoesNotExistException('account does not exists'))); + + $this->userSyncer->checkOne('user1'); + } + + public function testCheckOneMultipleAccountExist() { + $this->expectException(SyncBackendUserFailedException::class); + $this->expectExceptionMessage('The database returned multiple accounts for this uid: user1'); + + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->will($this->throwException(new MultipleObjectsReturnedException('account does not exists'))); + + $this->userSyncer->checkOne('user1'); + } + + public function testCheckOneNotInBackend() { + $this->expectException(SyncBackendUserFailedException::class); + $this->expectExceptionMessage('User found not belonging to any of the requested backends'); + + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $this->mapper->expects($this->once()) + ->method('getByUid') + ->willReturn($account1); + + $this->userSyncer->checkOne('user1', ['backends' => 'anotherOne']); + } + + public function testRemoteItemCount() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + $userSyncBackend->method('userCount')->willReturn(290); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + $userSyncBackend2->method('userCount')->willReturn(150); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $this->assertSame(440, $this->userSyncer->remoteItemCount()); + } + + public function testRemoteItemCountNull() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + $userSyncBackend->method('userCount')->willReturn(290); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + $userSyncBackend2->method('userCount')->willReturn(null); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $this->assertNull($this->userSyncer->remoteItemCount()); + } + + public function testRemoteItemCountWithBackends() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + $userSyncBackend->method('userCount')->willReturn(290); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + $userSyncBackend2->method('userCount')->willReturn(150); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $this->assertSame(290, $this->userSyncer->remoteItemCount(['backends' => 'Mock_UserInterface_001'])); + } + + public function testSync() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser1, null)); + $userSyncBackend2->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser2, null)); + + $this->mapper->method('getByUid') + ->will($this->returnValueMap([ + ['user1', $account1], + ['user2', $account2], + ])); + $this->mapper->expects($this->exactly(2)) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $collectedData = []; + $collectingCallback = function ($kvData) use (&$collectedData) { + $collectedData[] = $kvData; + }; + + $this->userSyncer->sync($collectingCallback); + + $this->assertSame(2, \count($collectedData)); + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]); + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]); + } + + public function testSyncNew() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser1, null)); + $userSyncBackend2->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser2, null)); + + $this->mapper->method('getByUid') + ->will($this->throwException(new DoesNotExistException('account does not exists'))); + $this->mapper->expects($this->never()) + ->method('update'); + $this->mapper->expects($this->exactly(2)) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $collectedData = []; + $collectingCallback = function ($kvData) use (&$collectedData) { + $collectedData[] = $kvData; + }; + + $this->userSyncer->sync($collectingCallback); + + $this->assertSame(2, \count($collectedData)); + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]); + $this->assertEquals($syncUser2->getAllData(), $collectedData[1]); + } + + public function testSyncUserFailed() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $account3 = Account::fromRow([ + 'id' => 2346, + 'email' => 'awe3@example.io', + 'user_id' => 'user3', + 'lower_user_id' => 'user3', + 'display_name' => 'awesome3', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user3', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser3 = new SyncingUser('user3'); + $syncUser3->setDisplayName('awesome3'); + $syncUser3->setEmail('awe3@example.io'); + + $userSyncBackend->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser1, null)); + $userSyncBackend2->expects($this->exactly(3)) + ->method('getNextUser') + ->will($this->returnCallback(function () use ($syncUser3) { + static $i = 0; + $i++; + if ($i === 1) { + throw new SyncBackendUserFailedException('user collision'); + } elseif ($i === 2) { + return $syncUser3; + } else { + return null; + } + })); + + $this->mapper->method('getByUid') + ->will($this->returnValueMap([ + ['user1', $account1], + ['user2', $account2], + ['user3', $account3], + ])); + $this->mapper->expects($this->exactly(2)) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $collectedData = []; + $collectingCallback = function ($kvData) use (&$collectedData) { + $collectedData[] = $kvData; + }; + + $this->userSyncer->sync($collectingCallback); + + $this->assertSame(3, \count($collectedData)); + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]); + $this->assertEquals(new SyncBackendUserFailedException('user collision'), $collectedData[1]); + $this->assertEquals($syncUser3->getAllData(), $collectedData[2]); + } + + public function testSyncBackendBroken() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $account3 = Account::fromRow([ + 'id' => 2346, + 'email' => 'awe3@example.io', + 'user_id' => 'user3', + 'lower_user_id' => 'user3', + 'display_name' => 'awesome3', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user3', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser3 = new SyncingUser('user3'); + $syncUser3->setDisplayName('awesome3'); + $syncUser3->setEmail('awe3@example.io'); + + $userSyncBackend->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser1, null)); + $userSyncBackend2->expects($this->once()) + ->method('getNextUser') + ->will($this->returnCallback(function () use ($syncUser3) { + static $i = 0; + $i++; + if ($i === 1) { + throw new SyncBackendBrokenException('disconnected from external'); + } else { + return null; + } + })); + + $this->mapper->method('getByUid') + ->will($this->returnValueMap([ + ['user1', $account1], + ['user2', $account2], + ['user3', $account3], + ])); + $this->mapper->expects($this->once()) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $collectedData = []; + $collectingCallback = function ($kvData) use (&$collectedData) { + $collectedData[] = $kvData; + }; + + $this->userSyncer->sync($collectingCallback); + + $this->assertSame(2, \count($collectedData)); + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]); + $this->assertEquals(new SyncBackendBrokenException('disconnected from external'), $collectedData[1]); + } + + public function testSyncSpecificBackends() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $account2 = Account::fromRow([ + 'id' => 234, + 'email' => 'awe@example.io', + 'user_id' => 'user2', + 'lower_user_id' => 'user2', + 'display_name' => 'awesome', + 'quota' => null, + 'last_login' => 998866, + 'backend' => \get_class($userInterface2), + 'home' => '/home/user2', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '888', + ]); + $syncUser2 = new SyncingUser('user2'); + $syncUser2->setDisplayName('awesome'); + $syncUser2->setEmail('awe@example.io'); + + $userSyncBackend->expects($this->exactly(2)) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser1, null)); + $userSyncBackend2->expects($this->never()) + ->method('getNextUser') + ->will($this->onConsecutiveCalls($syncUser2, null)); + + $this->mapper->method('getByUid') + ->will($this->returnValueMap([ + ['user1', $account1], + ['user2', $account2], + ])); + $this->mapper->expects($this->once()) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $collectedData = []; + $collectingCallback = function ($kvData) use (&$collectedData) { + $collectedData[] = $kvData; + }; + + $this->userSyncer->sync($collectingCallback, ['backends' => 'Mock_UserInterface_001']); + + $this->assertSame(1, \count($collectedData)); + + $this->assertEquals($syncUser1->getAllData(), $collectedData[0]); + } + + public function testSyncOne() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $userSyncBackend->expects($this->once()) + ->method('getSyncingUser') + ->willReturn($syncUser1); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->mapper->method('getByUid') + ->will($this->returnValueMap([ + ['user1', $account1], + ])); + $this->mapper->expects($this->once()) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $this->assertTrue($this->userSyncer->syncOne('user1')); + } + + public function testSyncOneNew() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $userSyncBackend->expects($this->once()) + ->method('getSyncingUser') + ->willReturn($syncUser1); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->mapper->method('getByUid') + ->will($this->throwException(new DoesNotExistException('account missing'))); + $this->mapper->expects($this->never()) + ->method('update'); + $this->mapper->expects($this->once()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $this->assertTrue($this->userSyncer->syncOne('user1')); + } + + public function testSyncOneExternalNotFound() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $userSyncBackend->expects($this->once()) + ->method('getSyncingUser') + ->willReturn(null); + $userSyncBackend2->expects($this->once()) + ->method('getSyncingUser') + ->willReturn(null); + + $this->mapper->expects($this->never()) + ->method('getByUid'); + $this->mapper->expects($this->never()) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $this->assertFalse($this->userSyncer->syncOne('user1')); + } + + public function testSyncOneExternalException() { + $this->expectException(SyncException::class); + + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $userSyncBackend->expects($this->once()) + ->method('getSyncingUser') + ->will($this->throwException(new SyncBackendBrokenException('disconnected from external'))); + $userSyncBackend2->expects($this->never()) + ->method('getSyncingUser'); + + $this->mapper->expects($this->never()) + ->method('getByUid'); + $this->mapper->expects($this->never()) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $this->userSyncer->syncOne('user1'); + } + + public function testSyncOneWithBackends() { + $userInterface = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_001') + ->getMock(); + $userSyncBackend = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_001') + ->getMock(); + $userSyncBackend->method('getUserInterface')->willReturn($userInterface); + + $userInterface2 = $this->getMockBuilder(UserInterface::class) + ->setMockClassName('Mock_UserInterface_002') + ->getMock(); + $userSyncBackend2 = $this->getMockBuilder(IUserSyncBackend::class) + ->setMockClassName('Mock_IUserSyncBackend_002') + ->getMock(); + $userSyncBackend2->method('getUserInterface')->willReturn($userInterface2); + + $this->userSyncer->registerBackend($userSyncBackend); + $this->userSyncer->registerBackend($userSyncBackend2); + + $account1 = Account::fromRow([ + 'id' => 123, + 'email' => 'disp@example.io', + 'user_id' => 'user1', + 'lower_user_id' => 'user1', + 'display_name' => 'display1', + 'quota' => null, + 'last_login' => 998877, + 'backend' => \get_class($userInterface), + 'home' => '/home/user1', + 'state' => Account::STATE_ENABLED, + 'creation_time' => '777', + ]); + $syncUser1 = new SyncingUser('user1'); + $syncUser1->setDisplayName('display1'); + $syncUser1->setEmail('disp@example.io'); + + $userSyncBackend->expects($this->never()) + ->method('getSyncingUser'); + $userSyncBackend2->expects($this->once()) + ->method('getSyncingUser') + ->willReturn(null); + + $this->mapper->method('getByUid') + ->will($this->returnValueMap([ + ['user1', $account1], + ])); + $this->mapper->expects($this->never()) + ->method('update'); + $this->mapper->expects($this->never()) + ->method('insert'); + + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['user1', 'core', 'enabled', 'true'], + ['user2', 'core', 'enabled', 'true'], + ['user1', 'login', 'lastLogin', 'true'], + ['user2', 'login', 'lastLogin', 'true'], + ['user1', 'core', 'username', 'user1_re'], + ['user2', 'core', 'username', 'user2_re'], + ])); + $this->config->method('getSystemValue') + ->will($this->returnValueMap([ + ['datadirectory', \OC::$SERVERROOT . '/data', '/foo/bar'] + ])); + + $this->assertFalse($this->userSyncer->syncOne('user1', ['backends' => 'Mock_UserInterface_002'])); + } +} From 33981656192753dfee8ede2f12c7185eb14f3cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Mon, 23 Oct 2023 12:21:54 +0200 Subject: [PATCH 4/4] Add more unit tests --- tests/Core/Command/Sync/SyncTest.php | 304 +++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 tests/Core/Command/Sync/SyncTest.php diff --git a/tests/Core/Command/Sync/SyncTest.php b/tests/Core/Command/Sync/SyncTest.php new file mode 100644 index 000000000000..0a0f2e637033 --- /dev/null +++ b/tests/Core/Command/Sync/SyncTest.php @@ -0,0 +1,304 @@ + + * + */ + +namespace Tests\Core\Command\Sync; + +use OC\Core\Command\Sync\Sync; +use OCP\Sync\ISyncManager; +use OCP\Sync\ISyncer; +use OCP\Sync\SyncException; +use Symfony\Component\Console\Tester\CommandTester; + +class SyncTest extends \Test\TestCase { + /** @var CommandTester */ + private $commandTester; + /** @var ISyncManager */ + private $syncManager; + + protected function setUp(): void { + parent::setUp(); + + $this->syncManager = $this->createMock(ISyncManager::class); + + $this->commandTester = new CommandTester(new Sync($this->syncManager)); + } + + public function testSync() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('check') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['item' => ['id' => 'abcdef', 'date' => '23th Jan', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'zzzzzz', 'date' => '12th Jun', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'fgfgfg', 'date' => '8th Jul', 'author' => 'Bob'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ]; + foreach ($items as $item) { + $callback($item['item'], $item['state']); + } + })); + $syncer->method('sync') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['id' => 'abcdef', 'date' => '24th Jan', 'author' => 'Alice'], + ['id' => 'zzzzzz', 'date' => '13th Jun', 'author' => 'Alice'], + ['id' => 'fgfgfg', 'date' => '9th Jul', 'author' => 'Bob'], + ]; + foreach ($items as $item) { + $callback($item); + } + })); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(0, $this->commandTester->execute(['service' => 'calendar'])); + } + + public function testSync2() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('check') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['item' => ['id' => 'abcdef', 'date' => '23th Jan', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'zzzzzz', 'date' => '12th Jun', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_DISABLED], + ['item' => ['id' => 'fgfgfg', 'date' => '8th Jul', 'author' => 'Bob'], 'state' => ISyncer::CHECK_STATE_REMOVED], + ]; + foreach ($items as $item) { + $callback($item['item'], $item['state']); + } + })); + $syncer->method('sync') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['id' => 'abcdef', 'date' => '24th Jan', 'author' => 'Alice'], + ['id' => 'zzzzzz', 'date' => '13th Jun', 'author' => 'Alice'], + ['id' => 'fgfgfg', 'date' => '9th Jul', 'author' => 'Bob'], + ]; + foreach ($items as $item) { + $callback($item); + } + })); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(0, $this->commandTester->execute(['service' => 'calendar'])); + $this->assertStringContainsString('- State for item with id = zzzzzz has changed to disabled', $this->commandTester->getDisplay()); + $this->assertStringContainsString('- State for item with id = fgfgfg has changed to removed', $this->commandTester->getDisplay()); + } + + public function testSyncSyncException() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('check') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['item' => ['id' => 'abcdef', 'date' => '23th Jan', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'zzzzzz', 'date' => '12th Jun', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'fgfgfg', 'date' => '8th Jul', 'author' => 'Bob'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ]; + foreach ($items as $item) { + $callback($item['item'], $item['state']); + } + })); + $syncer->method('sync') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['id' => 'abcdef', 'date' => '24th Jan', 'author' => 'Alice'], + new SyncException('No connection available'), + ['id' => 'fgfgfg', 'date' => '9th Jul', 'author' => 'Bob'], + ]; + foreach ($items as $item) { + $callback($item); + } + })); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar'])); + $this->assertStringContainsString('- No connection available', $this->commandTester->getDisplay()); + } + + public function testSyncCheckException() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('check') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['item' => ['id' => 'abcdef', 'date' => '23th Jan', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => new SyncException('Checking failed'), 'state' => ISyncer::CHECK_STATE_ERROR], + ['item' => ['id' => 'fgfgfg', 'date' => '8th Jul', 'author' => 'Bob'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ]; + foreach ($items as $item) { + $callback($item['item'], $item['state']); + } + })); + $syncer->method('sync') + ->will($this->returnCallback(function ($callback) { + $items = [ + ['id' => 'abcdef', 'date' => '24th Jan', 'author' => 'Alice'], + ['id' => 'zzzzzz', 'date' => '13th Jun', 'author' => 'Alice'], + ['id' => 'fgfgfg', 'date' => '9th Jul', 'author' => 'Bob'], + ]; + foreach ($items as $item) { + $callback($item); + } + })); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar'])); + $this->assertStringContainsString('- Checking failed', $this->commandTester->getDisplay()); + } + + public function testSyncOpts() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('check') + ->with($this->anything(), $this->equalTo(['opt1' => 'abcdef', 'isImp' => 'yes', 'opt2' => '9999'])) + ->will($this->returnCallback(function ($callback) { + $items = [ + ['item' => ['id' => 'abcdef', 'date' => '23th Jan', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'zzzzzz', 'date' => '12th Jun', 'author' => 'Alice'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ['item' => ['id' => 'fgfgfg', 'date' => '8th Jul', 'author' => 'Bob'], 'state' => ISyncer::CHECK_STATE_NO_CHANGE], + ]; + foreach ($items as $item) { + $callback($item['item'], $item['state']); + } + })); + $syncer->method('sync') + ->with($this->anything(), $this->equalTo(['opt1' => 'abcdef', 'isImp' => 'yes', 'opt2' => '9999'])) + ->will($this->returnCallback(function ($callback) { + $items = [ + ['id' => 'abcdef', 'date' => '24th Jan', 'author' => 'Alice'], + ['id' => 'zzzzzz', 'date' => '13th Jun', 'author' => 'Alice'], + ['id' => 'fgfgfg', 'date' => '9th Jul', 'author' => 'Bob'], + ]; + foreach ($items as $item) { + $callback($item); + } + })); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(0, $this->commandTester->execute(['service' => 'calendar', '-o' => ['opt1=abcdef', 'isImp=yes', 'opt2=9999']])); + } + + public function testSyncOne() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('checkOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(ISyncer::CHECK_STATE_NO_CHANGE); + $syncer->method('syncOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(true); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(0, $this->commandTester->execute(['service' => 'calendar', '--only-one' => 'zzzzzz'])); + } + + public function testSyncOneCheckDisabled() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('checkOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(ISyncer::CHECK_STATE_DISABLED); + $syncer->method('syncOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(true); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar', '--only-one' => 'zzzzzz'])); + $this->assertStringContainsString('zzzzzz state changed to disabled', $this->commandTester->getDisplay()); + } + + public function testSyncOneCheckRemoved() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('checkOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(ISyncer::CHECK_STATE_REMOVED); + $syncer->method('syncOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(true); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar', '--only-one' => 'zzzzzz'])); + $this->assertStringContainsString('zzzzzz state changed to removed', $this->commandTester->getDisplay()); + } + + public function testSyncOneCheckException() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('checkOne') + ->with('zzzzzz', $this->anything()) + ->will($this->throwException(new SyncException('Cannot check the user'))); + $syncer->method('syncOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(true); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar', '--only-one' => 'zzzzzz'])); + $this->assertStringContainsString('Error: Cannot check the user', $this->commandTester->getDisplay()); + } + + public function testSyncOneSyncNotFound() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('checkOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(ISyncer::CHECK_STATE_NO_CHANGE); + $syncer->method('syncOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(false); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar', '--only-one' => 'zzzzzz'])); + $this->assertStringContainsString('zzzzzz cannot be synced because it isn\'t found remotely', $this->commandTester->getDisplay()); + } + + public function testSyncOneSyncException() { + $syncer = $this->createMock(ISyncer::class); + $syncer->method('checkOne') + ->with('zzzzzz', $this->anything()) + ->willReturn(ISyncer::CHECK_STATE_NO_CHANGE); + $syncer->method('syncOne') + ->with('zzzzzz', $this->anything()) + ->will($this->throwException(new SyncException('Cannot access external source'))); + + $this->syncManager->method('getSyncer') + ->willReturn($syncer); + + $this->assertSame(2, $this->commandTester->execute(['service' => 'calendar', '--only-one' => 'zzzzzz'])); + $this->assertStringContainsString('Error: Cannot access external source', $this->commandTester->getDisplay()); + } + + public function testSyncServiceNotFound() { + $this->syncManager->method('getSyncer') + ->willReturn(null); + + $this->assertSame(1, $this->commandTester->execute(['service' => 'calendar'])); + } +}