diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-01-29 19:50:13 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-02-06 11:58:24 +0100 |
commit | fbef47a5d74c62927a84bc77bf3182f38bc712ce (patch) | |
tree | ec582a2410c48bc23e5e7bb97ed31023132b3bdd | |
parent | 9fffdf2851dd0256206ceabf872f7d29063dac69 (diff) | |
download | nextcloud-server-fbef47a5d74c62927a84bc77bf3182f38bc712ce.tar.gz nextcloud-server-fbef47a5d74c62927a84bc77bf3182f38bc712ce.zip |
fix(AccountManager): Sanitize social media handles
Ensure to only accept valid X and fediverse handles.
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r-- | apps/settings/tests/UserMigration/AccountMigratorTest.php | 9 | ||||
-rw-r--r-- | apps/settings/tests/UserMigration/assets/account-complex.json | 2 | ||||
-rw-r--r-- | lib/private/Accounts/AccountManager.php | 203 | ||||
-rw-r--r-- | tests/lib/Accounts/AccountManagerTest.php | 250 |
4 files changed, 335 insertions, 129 deletions
diff --git a/apps/settings/tests/UserMigration/AccountMigratorTest.php b/apps/settings/tests/UserMigration/AccountMigratorTest.php index ab5ffc6b314..f3f3e2bf90f 100644 --- a/apps/settings/tests/UserMigration/AccountMigratorTest.php +++ b/apps/settings/tests/UserMigration/AccountMigratorTest.php @@ -12,6 +12,7 @@ use OCA\Settings\UserMigration\AccountMigrator; use OCP\Accounts\IAccountManager; use OCP\AppFramework\App; use OCP\IAvatarManager; +use OCP\IConfig; use OCP\IUserManager; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; @@ -50,8 +51,11 @@ class AccountMigratorTest extends TestCase { private const REGEX_CONFIG_FILE = '/^' . Application::APP_ID . '\/' . '[a-z]+\.json' . '$/'; protected function setUp(): void { + parent::setUp(); + $app = new App(Application::APP_ID); $container = $app->getContainer(); + $container->get(IConfig::class)->setSystemValue('has_internet_connection', false); $this->userManager = $container->get(IUserManager::class); $this->avatarManager = $container->get(IAvatarManager::class); @@ -62,6 +66,11 @@ class AccountMigratorTest extends TestCase { $this->output = $this->createMock(OutputInterface::class); } + protected function tearDown(): void { + \OCP\Server::get(IConfig::class)->setSystemValue('has_internet_connection', true); + parent::tearDown(); + } + public function dataImportExportAccount(): array { return array_map( function (string $filename) { diff --git a/apps/settings/tests/UserMigration/assets/account-complex.json b/apps/settings/tests/UserMigration/assets/account-complex.json index 819ce0e7da4..cb4668cf18c 100644 --- a/apps/settings/tests/UserMigration/assets/account-complex.json +++ b/apps/settings/tests/UserMigration/assets/account-complex.json @@ -1 +1 @@ -{"displayname":{"name":"displayname","value":"Steve Smith","scope":"v2-local","verified":"0","verificationData":""},"address":{"name":"address","value":"123 Water St","scope":"v2-local","verified":"0","verificationData":""},"website":{"name":"website","value":"https://example.org","scope":"v2-local","verified":"0","verificationData":""},"email":{"name":"email","value":"steve@example.org","scope":"v2-federated","verified":"1","verificationData":""},"avatar":{"name":"avatar","value":"","scope":"v2-local","verified":"0","verificationData":""},"phone":{"name":"phone","value":"+12178515387","scope":"v2-private","verified":"0","verificationData":""},"twitter":{"name":"twitter","value":"steve","scope":"v2-federated","verified":"0","verificationData":""},"fediverse":{"name":"fediverse","value":"@steve@floss.social","scope":"v2-federated","verified":"0","verificationData":""},"organisation":{"name":"organisation","value":"Mytery Machine","scope":"v2-private","verified":"0","verificationData":""},"role":{"name":"role","value":"Manager","scope":"v2-private","verified":"0","verificationData":""},"headline":{"name":"headline","value":"I am Steve","scope":"v2-local","verified":"0","verificationData":""},"biography":{"name":"biography","value":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris porttitor ullamcorper dictum. Sed fermentum ut ligula scelerisque semper. Aliquam interdum convallis tellus eu dapibus. Integer in justo sollicitudin, hendrerit ligula sit amet, blandit sem.\n\nSuspendisse consectetur ultrices accumsan. Quisque sagittis bibendum lectus ut placerat. Mauris tincidunt ornare neque, et pulvinar tortor porttitor eu.","scope":"v2-local","verified":"0","verificationData":""},"birthdate":{"name":"birthdate","value":"","scope":"v2-local","verified":"0","verificationData":""},"profile_enabled":{"name":"profile_enabled","value":"1","scope":"v2-local","verified":"0","verificationData":""},"pronouns":{"name":"pronouns","value":"they/them","scope":"v2-local","verified":"0","verificationData":""},"additional_mail":[{"name":"additional_mail","value":"steve@example.com","scope":"v2-published","verified":"0","verificationData":""},{"name":"additional_mail","value":"steve@earth.world","scope":"v2-local","verified":"0","verificationData":""}]}
\ No newline at end of file +{"displayname":{"name":"displayname","value":"Steve Smith","scope":"v2-local","verified":"0","verificationData":""},"address":{"name":"address","value":"123 Water St","scope":"v2-local","verified":"0","verificationData":""},"website":{"name":"website","value":"https://example.org","scope":"v2-local","verified":"0","verificationData":""},"email":{"name":"email","value":"steve@example.org","scope":"v2-federated","verified":"1","verificationData":""},"avatar":{"name":"avatar","value":"","scope":"v2-local","verified":"0","verificationData":""},"phone":{"name":"phone","value":"+12178515387","scope":"v2-private","verified":"0","verificationData":""},"twitter":{"name":"twitter","value":"steve","scope":"v2-federated","verified":"0","verificationData":""},"fediverse":{"name":"fediverse","value":"steve@floss.social","scope":"v2-federated","verified":"0","verificationData":""},"organisation":{"name":"organisation","value":"Mytery Machine","scope":"v2-private","verified":"0","verificationData":""},"role":{"name":"role","value":"Manager","scope":"v2-private","verified":"0","verificationData":""},"headline":{"name":"headline","value":"I am Steve","scope":"v2-local","verified":"0","verificationData":""},"biography":{"name":"biography","value":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris porttitor ullamcorper dictum. Sed fermentum ut ligula scelerisque semper. Aliquam interdum convallis tellus eu dapibus. Integer in justo sollicitudin, hendrerit ligula sit amet, blandit sem.\n\nSuspendisse consectetur ultrices accumsan. Quisque sagittis bibendum lectus ut placerat. Mauris tincidunt ornare neque, et pulvinar tortor porttitor eu.","scope":"v2-local","verified":"0","verificationData":""},"birthdate":{"name":"birthdate","value":"","scope":"v2-local","verified":"0","verificationData":""},"profile_enabled":{"name":"profile_enabled","value":"1","scope":"v2-local","verified":"0","verificationData":""},"pronouns":{"name":"pronouns","value":"they/them","scope":"v2-local","verified":"0","verificationData":""},"additional_mail":[{"name":"additional_mail","value":"steve@example.com","scope":"v2-published","verified":"0","verificationData":""},{"name":"additional_mail","value":"steve@earth.world","scope":"v2-local","verified":"0","verificationData":""}]}
\ No newline at end of file diff --git a/lib/private/Accounts/AccountManager.php b/lib/private/Accounts/AccountManager.php index 0d091786cd6..d69e72a29de 100644 --- a/lib/private/Accounts/AccountManager.php +++ b/lib/private/Accounts/AccountManager.php @@ -23,6 +23,7 @@ use OCP\Cache\CappedMemoryCache; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Defaults; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClientService; use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; @@ -93,51 +94,12 @@ class AccountManager implements IAccountManager { private IURLGenerator $urlGenerator, private ICrypto $crypto, private IPhoneNumberUtil $phoneNumberUtil, + private IClientService $clientService, ) { $this->internalCache = new CappedMemoryCache(); } /** - * @return string Provided phone number in E.164 format when it was a valid number - * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code - */ - protected function parsePhoneNumber(string $input): string { - $defaultRegion = $this->config->getSystemValueString('default_phone_region', ''); - - if ($defaultRegion === '') { - // When no default region is set, only +49… numbers are valid - if (!str_starts_with($input, '+')) { - throw new InvalidArgumentException(self::PROPERTY_PHONE); - } - - $defaultRegion = 'EN'; - } - - $phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($input, $defaultRegion); - if ($phoneNumber !== null) { - return $phoneNumber; - } - - throw new InvalidArgumentException(self::PROPERTY_PHONE); - } - - /** - * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty - */ - protected function parseWebsite(string $input): string { - $parts = parse_url($input); - if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) { - throw new InvalidArgumentException(self::PROPERTY_WEBSITE); - } - - if (!isset($parts['host']) || $parts['host'] === '') { - throw new InvalidArgumentException(self::PROPERTY_WEBSITE); - } - - return $input; - } - - /** * @param IAccountProperty[] $properties */ protected function testValueLengths(array $properties, bool $throwOnData = false): void { @@ -175,42 +137,6 @@ class AccountManager implements IAccountManager { } } - protected function sanitizePhoneNumberValue(IAccountProperty $property, bool $throwOnData = false): void { - if ($property->getName() !== self::PROPERTY_PHONE) { - if ($throwOnData) { - throw new InvalidArgumentException(sprintf('sanitizePhoneNumberValue can only sanitize phone numbers, %s given', $property->getName())); - } - return; - } - if ($property->getValue() === '') { - return; - } - try { - $property->setValue($this->parsePhoneNumber($property->getValue())); - } catch (InvalidArgumentException $e) { - if ($throwOnData) { - throw $e; - } - $property->setValue(''); - } - } - - protected function sanitizeWebsite(IAccountProperty $property, bool $throwOnData = false): void { - if ($property->getName() !== self::PROPERTY_WEBSITE) { - if ($throwOnData) { - throw new InvalidArgumentException(sprintf('sanitizeWebsite can only sanitize web domains, %s given', $property->getName())); - } - } - try { - $property->setValue($this->parseWebsite($property->getValue())); - } catch (InvalidArgumentException $e) { - if ($throwOnData) { - throw $e; - } - $property->setValue(''); - } - } - protected function updateUser(IUser $user, array $data, ?array $oldUserData, bool $throwOnData = false): array { if ($oldUserData === null) { $oldUserData = $this->getUser($user, false); @@ -735,18 +661,139 @@ class AccountManager implements IAccountManager { return $account; } + /** + * Converts value (phone number) in E.164 format when it was a valid number + * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code + */ + protected function sanitizePropertyPhoneNumber(IAccountProperty $property): void { + $defaultRegion = $this->config->getSystemValueString('default_phone_region', ''); + + if ($defaultRegion === '') { + // When no default region is set, only +49… numbers are valid + if (!str_starts_with($property->getValue(), '+')) { + throw new InvalidArgumentException(self::PROPERTY_PHONE); + } + + $defaultRegion = 'EN'; + } + + $phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($property->getValue(), $defaultRegion); + if ($phoneNumber === null) { + throw new InvalidArgumentException(self::PROPERTY_PHONE); + } + $property->setValue($phoneNumber); + } + + /** + * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty + */ + private function sanitizePropertyWebsite(IAccountProperty $property): void { + $parts = parse_url($property->getValue()); + if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) { + throw new InvalidArgumentException(self::PROPERTY_WEBSITE); + } + + if (!isset($parts['host']) || $parts['host'] === '') { + throw new InvalidArgumentException(self::PROPERTY_WEBSITE); + } + } + + /** + * @throws InvalidArgumentException If the property value is not a valid user handle according to X's rules + */ + private function sanitizePropertyTwitter(IAccountProperty $property): void { + if ($property->getName() === self::PROPERTY_TWITTER) { + $matches = []; + // twitter handles only contain alpha numeric characters and the underscore and must not be longer than 15 characters + if (preg_match('/^@?([a-zA-Z0-9_]{2,15})$/', $property->getValue(), $matches) !== 1) { + throw new InvalidArgumentException(self::PROPERTY_TWITTER); + } + + // drop the leading @ if any to make it the valid handle + $property->setValue($matches[1]); + + } + } + + /** + * @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain) + */ + private function sanitizePropertyFediverse(IAccountProperty $property): void { + if ($property->getName() === self::PROPERTY_FEDIVERSE) { + $matches = []; + if (preg_match('/^@?([^@\s\/\\\]+)@([^\s\/\\\]+)$/', trim($property->getValue()), $matches) !== 1) { + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } + + [, $username, $instance] = $matches; + $validated = filter_var($instance, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME); + if ($validated !== $instance) { + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } + + if ($this->config->getSystemValueBool('has_internet_connection', true)) { + $client = $this->clientService->newClient(); + + try { + // try the public account lookup API of mastodon + $response = $client->get("https://{$instance}/api/v1/accounts/lookup?acct={$username}@{$instance}"); + // should be a json response with account information + $data = $response->getBody(); + if (is_resource($data)) { + $data = stream_get_contents($data); + } + $decoded = json_decode($data, true); + // ensure the username is the same the user passed + // in this case we can assume this is a valid fediverse server and account + if (!is_array($decoded) || ($decoded['username'] ?? '') !== $username) { + throw new InvalidArgumentException(); + } + } catch (InvalidArgumentException) { + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } catch (\Exception $error) { + $this->logger->error('Could not verify fediverse account', ['exception' => $error, 'instance' => $instance]); + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } + } + + $property->setValue("$username@$instance"); + } + } + public function updateAccount(IAccount $account): void { $this->testValueLengths(iterator_to_array($account->getAllProperties()), true); try { $property = $account->getProperty(self::PROPERTY_PHONE); - $this->sanitizePhoneNumberValue($property); + if ($property->getValue() !== '') { + $this->sanitizePropertyPhoneNumber($property); + } } catch (PropertyDoesNotExistException $e) { // valid case, nothing to do } try { $property = $account->getProperty(self::PROPERTY_WEBSITE); - $this->sanitizeWebsite($property); + if ($property->getValue() !== '') { + $this->sanitizePropertyWebsite($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + try { + $property = $account->getProperty(self::PROPERTY_TWITTER); + if ($property->getValue() !== '') { + $this->sanitizePropertyTwitter($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + try { + $property = $account->getProperty(self::PROPERTY_FEDIVERSE); + if ($property->getValue() !== '') { + $this->sanitizePropertyFediverse($property); + } } catch (PropertyDoesNotExistException $e) { // valid case, nothing to do } diff --git a/tests/lib/Accounts/AccountManagerTest.php b/tests/lib/Accounts/AccountManagerTest.php index 4462c6c0722..fab3aaf5fdd 100644 --- a/tests/lib/Accounts/AccountManagerTest.php +++ b/tests/lib/Accounts/AccountManagerTest.php @@ -17,6 +17,9 @@ use OCP\Accounts\UserUpdatedEvent; use OCP\BackgroundJob\IJobList; use OCP\Defaults; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; use OCP\IConfig; use OCP\IDBConnection; use OCP\IPhoneNumberUtil; @@ -37,45 +40,31 @@ use Test\TestCase; * @package Test\Accounts */ class AccountManagerTest extends TestCase { - /** @var IVerificationToken|MockObject */ - protected $verificationToken; - /** @var IMailer|MockObject */ - protected $mailer; - /** @var ICrypto|MockObject */ - protected $crypto; - /** @var IURLGenerator|MockObject */ - protected $urlGenerator; - /** @var Defaults|MockObject */ - protected $defaults; - /** @var IFactory|MockObject */ - protected $l10nFactory; - - /** @var IDBConnection */ - private $connection; - - /** @var IConfig|MockObject */ - private $config; - - /** @var IEventDispatcher|MockObject */ - private $eventDispatcher; - - /** @var IJobList|MockObject */ - private $jobList; - /** @var IPhoneNumberUtil */ - private $phoneNumberUtil; /** accounts table name */ private string $table = 'accounts'; - - /** @var LoggerInterface|MockObject */ - private $logger; - private AccountManager $accountManager; + private IDBConnection $connection; + private IPhoneNumberUtil $phoneNumberUtil; + + protected IVerificationToken&MockObject $verificationToken; + protected IMailer&MockObject $mailer; + protected ICrypto&MockObject $crypto; + protected IURLGenerator&MockObject $urlGenerator; + protected Defaults&MockObject $defaults; + protected IFactory&MockObject $l10nFactory; + protected IConfig&MockObject $config; + protected IEventDispatcher&MockObject $eventDispatcher; + protected IJobList&MockObject $jobList; + private LoggerInterface&MockObject $logger; + private IClientService&MockObject $clientService; protected function setUp(): void { parent::setUp(); + $this->connection = \OCP\Server::get(IDBConnection::class); + $this->phoneNumberUtil = new PhoneNumberUtil(); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); - $this->connection = \OC::$server->get(IDBConnection::class); $this->config = $this->createMock(IConfig::class); $this->jobList = $this->createMock(IJobList::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -85,7 +74,7 @@ class AccountManagerTest extends TestCase { $this->l10nFactory = $this->createMock(IFactory::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->crypto = $this->createMock(ICrypto::class); - $this->phoneNumberUtil = new PhoneNumberUtil(); + $this->clientService = $this->createMock(IClientService::class); $this->accountManager = new AccountManager( $this->connection, @@ -100,6 +89,7 @@ class AccountManagerTest extends TestCase { $this->urlGenerator, $this->crypto, $this->phoneNumberUtil, + $this->clientService, ); } @@ -465,6 +455,7 @@ class AccountManagerTest extends TestCase { $this->urlGenerator, $this->crypto, $this->phoneNumberUtil, + $this->clientService, ]) ->onlyMethods($mockedMethods) ->getMock(); @@ -684,7 +675,7 @@ class AccountManagerTest extends TestCase { $this->assertEquals($expected, $accountManager->getAccount($user)); } - public function dataParsePhoneNumber(): array { + public static function dataParsePhoneNumber(): array { return [ ['0711 / 25 24 28-90', 'DE', '+4971125242890'], ['0711 / 25 24 28-90', '', null], @@ -695,39 +686,198 @@ class AccountManagerTest extends TestCase { /** * @dataProvider dataParsePhoneNumber */ - public function testParsePhoneNumber(string $phoneInput, string $defaultRegion, ?string $phoneNumber): void { + public function testSanitizePhoneNumberOnUpdateAccount(string $phoneInput, string $defaultRegion, ?string $phoneNumber): void { $this->config->method('getSystemValueString') ->willReturn($defaultRegion); + $user = $this->createMock(IUser::class); + $account = new Account($user); + $account->setProperty(IAccountManager::PROPERTY_PHONE, $phoneInput, IAccountManager::SCOPE_LOCAL, IAccountManager::NOT_VERIFIED); + $manager = $this->getInstance(['getUser', 'updateUser']); + $manager->method('getUser') + ->with($user, false) + ->willReturn([]); + $manager->expects($phoneNumber === null ? self::never() : self::once()) + ->method('updateUser'); + if ($phoneNumber === null) { $this->expectException(\InvalidArgumentException::class); - self::invokePrivate($this->accountManager, 'parsePhoneNumber', [$phoneInput]); - } else { - self::assertEquals($phoneNumber, self::invokePrivate($this->accountManager, 'parsePhoneNumber', [$phoneInput])); + } + + $manager->updateAccount($account); + + if ($phoneNumber !== null) { + self::assertEquals($phoneNumber, $account->getProperty(IAccountManager::PROPERTY_PHONE)->getValue()); } } - public function dataParseWebsite(): array { + public static function dataSanitizeOnUpdate(): array { return [ - ['https://nextcloud.com', 'https://nextcloud.com'], - ['http://nextcloud.com', 'http://nextcloud.com'], - ['ftp://nextcloud.com', null], - ['//nextcloud.com/', null], - ['https:///?query', null], + [IAccountManager::PROPERTY_WEBSITE, 'https://nextcloud.com', 'https://nextcloud.com'], + [IAccountManager::PROPERTY_WEBSITE, 'http://nextcloud.com', 'http://nextcloud.com'], + [IAccountManager::PROPERTY_WEBSITE, 'ftp://nextcloud.com', null], + [IAccountManager::PROPERTY_WEBSITE, '//nextcloud.com/', null], + [IAccountManager::PROPERTY_WEBSITE, 'https:///?query', null], + + [IAccountManager::PROPERTY_TWITTER, '@nextcloud', 'nextcloud'], + [IAccountManager::PROPERTY_TWITTER, '_nextcloud', '_nextcloud'], + [IAccountManager::PROPERTY_TWITTER, 'FooB4r', 'FooB4r'], + [IAccountManager::PROPERTY_TWITTER, 'X', null], + [IAccountManager::PROPERTY_TWITTER, 'next.cloud', null], + [IAccountManager::PROPERTY_TWITTER, 'ab/cd.zip', null], + [IAccountManager::PROPERTY_TWITTER, 'tooLongForTwitterAndX', null], + + [IAccountManager::PROPERTY_FEDIVERSE, 'nextcloud@mastodon.social', 'nextcloud@mastodon.social'], + [IAccountManager::PROPERTY_FEDIVERSE, '@nextcloud@mastodon.xyz', 'nextcloud@mastodon.xyz'], + [IAccountManager::PROPERTY_FEDIVERSE, 'l33t.h4x0r@sub.localhost.local', 'l33t.h4x0r@sub.localhost.local'], + [IAccountManager::PROPERTY_FEDIVERSE, 'invalid/name@mastodon.social', null], + [IAccountManager::PROPERTY_FEDIVERSE, 'name@evil.host/malware.exe', null], + [IAccountManager::PROPERTY_FEDIVERSE, '@is-it-a-host-or-name', null], + [IAccountManager::PROPERTY_FEDIVERSE, 'only-a-name', null], ]; } /** - * @dataProvider dataParseWebsite - * @param string $websiteInput - * @param string|null $websiteOutput + * @dataProvider dataSanitizeOnUpdate */ - public function testParseWebsite(string $websiteInput, ?string $websiteOutput): void { - if ($websiteOutput === null) { + public function testSanitizingOnUpdateAccount(string $property, string $input, ?string $output): void { + + if ($property === IAccountManager::PROPERTY_FEDIVERSE) { + // We do not test the server response here we do this in the `testSanitizingFediverseServer` + $this->config + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(false); + } + + $user = $this->createMock(IUser::class); + + $account = new Account($user); + $account->setProperty($property, $input, IAccountManager::SCOPE_LOCAL, IAccountManager::NOT_VERIFIED); + + $manager = $this->getInstance(['getUser', 'updateUser']); + $manager->method('getUser') + ->with($user, false) + ->willReturn([]); + $manager->expects($output === null ? self::never() : self::once()) + ->method('updateUser'); + + if ($output === null) { $this->expectException(\InvalidArgumentException::class); - self::invokePrivate($this->accountManager, 'parseWebsite', [$websiteInput]); + $this->expectExceptionMessage($property); + } + + $manager->updateAccount($account); + + if ($output !== null) { + self::assertEquals($output, $account->getProperty($property)->getValue()); + } + } + + public static function dataSanitizeFediverseServer(): array { + return [ + 'no internet' => [ + '@foo@example.com', + 'foo@example.com', + false, + null, + ], + 'no internet - no at' => [ + 'foo@example.com', + 'foo@example.com', + false, + null, + ], + 'valid response' => [ + '@foo@example.com', + 'foo@example.com', + true, + json_encode(['username' => 'foo']), + ], + 'valid response - no at' => [ + 'foo@example.com', + 'foo@example.com', + true, + json_encode(['username' => 'foo']), + ], + // failures + 'invalid response' => [ + '@foo@example.com', + null, + true, + json_encode(['not found']), + ], + 'no response' => [ + '@foo@example.com', + null, + true, + null, + ], + 'wrong user' => [ + '@foo@example.com', + null, + true, + json_encode(['username' => 'foo@other.example.com']), + ], + ]; + } + + /** + * @dataProvider dataSanitizeFediverseServer + */ + public function testSanitizingFediverseServer(string $input, ?string $output, bool $hasInternet, ?string $serverResponse): void { + $this->config->expects(self::once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn($hasInternet); + + if ($hasInternet) { + $client = $this->createMock(IClient::class); + if ($serverResponse !== null) { + $response = $this->createMock(IResponse::class); + $response->method('getBody') + ->willReturn($serverResponse); + $client->expects(self::once()) + ->method('get') + ->with('https://example.com/api/v1/accounts/lookup?acct=foo@example.com') + ->willReturn($response); + } else { + $client->expects(self::once()) + ->method('get') + ->with('https://example.com/api/v1/accounts/lookup?acct=foo@example.com') + ->willThrowException(new \Exception('404')); + } + + $this->clientService + ->expects(self::once()) + ->method('newClient') + ->willReturn($client); } else { - self::assertEquals($websiteOutput, self::invokePrivate($this->accountManager, 'parseWebsite', [$websiteInput])); + $this->clientService + ->expects(self::never()) + ->method('newClient'); + } + + $user = $this->createMock(IUser::class); + $account = new Account($user); + $account->setProperty(IAccountManager::PROPERTY_FEDIVERSE, $input, IAccountManager::SCOPE_LOCAL, IAccountManager::NOT_VERIFIED); + + $manager = $this->getInstance(['getUser', 'updateUser']); + $manager->method('getUser') + ->with($user, false) + ->willReturn([]); + $manager->expects($output === null ? self::never() : self::once()) + ->method('updateUser'); + + if ($output === null) { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(IAccountManager::PROPERTY_FEDIVERSE); + } + + $manager->updateAccount($account); + + if ($output !== null) { + self::assertEquals($output, $account->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getValue()); } } |