diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml index f2a27710f1..2d4c5d1a57 100644 --- a/.github/workflows/cypress-e2e.yml +++ b/.github/workflows/cypress-e2e.yml @@ -46,7 +46,7 @@ jobs: image: ${{ matrix.code-image == 'release' && 'collabora/code:latest' || 'juliushaertl/nc-code-nightly:latest' }} # zizmor: ignore[unpinned-images] env: extra_params: '--o:ssl.enable=false --o:home_mode.enable=true' - aliasgroup1: 'http://172.17.0.1' + aliasgroup1: 'http://172.17.0.1:8081' ports: - "9980:9980" options: --cap-add SYS_ADMIN --cap-add SYS_CHROOT @@ -153,11 +153,10 @@ jobs: php occ app:enable --force spreed php occ app:list php occ config:system:set trusted_domains 1 --value="172.17.0.1" + php occ config:system:set overwrite.cli.url --value="http://172.17.0.1:8081" - php occ config:app:set richdocuments wopi_url --value="http://localhost:9980" - php occ config:app:set richdocuments public_wopi_url --value="http://localhost:9980" php occ config:system:set allow_local_remote_servers --value true --type bool - php occ richdocuments:activate-config + php occ richdocuments:activate-config --wopi-url="http://localhost:9980" --callback-url="http://172.17.0.1:8081" curl http://admin:admin@localhost:8081/ocs/v1.php/cloud/capabilities\?format\=json -H 'OCS-APIRequest: true' @@ -228,6 +227,10 @@ jobs: path: data/nextcloud.log retention-days: 5 + - name: Dump docker logs on failure + if: failure() + uses: jwalton/gh-docker-logs@2741064ab9d7af54b0b1ffb6076cf64c16f0220e # v2.2.2 + summary: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b07f5d5495..581be16b5a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -69,7 +69,7 @@ jobs: image: ${{ matrix.code-image == 'release' && 'collabora/code:latest' || 'juliushaertl/nc-code-nightly:latest' }} # zizmor: ignore[unpinned-images] env: extra_params: '--o:ssl.enable=false' - aliasgroup1: 'http://nextcloud' + aliasgroup1: 'http://172.17.0.1:8081' ports: - "9980:9980" @@ -109,6 +109,7 @@ jobs: mkdir data echo '"\OC\Memcache\APCu","hashing_default_password"=>true];' > config/config.php ./occ maintenance:install --verbose --database=${{ matrix.databases }} --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password + ./occ config:system:set overwrite.cli.url --value="http://172.17.0.1:8081" ./occ app:enable --force ${{ env.APP_NAME }} - name: Run ${{ matrix.scenarios }} integration tests @@ -144,7 +145,7 @@ jobs: image: ghcr.io/juliusknorr/nextcloud-dev-code:latest # zizmor: ignore[unpinned-images] env: extra_params: '--o:ssl.enable=false' - aliasgroup1: 'http://nextcloud' + aliasgroup1: 'http://172.17.0.1:8081' ports: - "9980:9980" @@ -184,6 +185,7 @@ jobs: mkdir data echo '"\OC\Memcache\APCu","hashing_default_password"=>true];' > config/config.php ./occ maintenance:install --verbose --database=${{ matrix.databases }} --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password + ./occ config:system:set overwrite.cli.url --value="http://172.17.0.1:8081" ./occ app:enable --force ${{ env.APP_NAME }} - name: Run ${{ matrix.scenarios }} integration tests @@ -221,7 +223,7 @@ jobs: image: ghcr.io/juliusknorr/nextcloud-dev-code:latest # zizmor: ignore[unpinned-images] env: extra_params: '--o:ssl.enable=false' - aliasgroup1: 'http://nextcloud' + aliasgroup1: 'http://172.17.0.1:8081' ports: - "9980:9980" @@ -261,6 +263,7 @@ jobs: mkdir data echo '"\OC\Memcache\APCu","hashing_default_password"=>true];' > config/config.php ./occ maintenance:install --verbose --database=${{ matrix.databases }} --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass password + ./occ config:system:set overwrite.cli.url --value="http://172.17.0.1:8081" ./occ app:enable --force ${{ env.APP_NAME }} - name: Run ${{ matrix.scenarios }} integration tests @@ -306,7 +309,7 @@ jobs: image: ghcr.io/juliusknorr/nextcloud-dev-code:latest # zizmor: ignore[unpinned-images] env: extra_params: '--o:ssl.enable=false' - aliasgroup1: 'http://nextcloud' + aliasgroup1: 'http://172.17.0.1:8081' ports: - "9980:9980" @@ -345,6 +348,7 @@ jobs: echo '"\OC\Memcache\APCu","hashing_default_password"=>true];' > config/config.php ./occ maintenance:install --verbose --database=oci --database-name=XE --database-host=127.0.0.1 --database-port=1521 --database-user=system --database-pass=rootpassword --admin-user admin --admin-pass password php -f index.php + ./occ config:system:set overwrite.cli.url --value="http://172.17.0.1:8081" ./occ app:enable --force ${{ env.APP_NAME }} - name: Run ${{ matrix.scenarios }} integration tests diff --git a/appinfo/routes.php b/appinfo/routes.php index e17267f6ce..2b6ca6e163 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -30,6 +30,7 @@ ['name' => 'settings#getSettings', 'url' => 'ajax/settings.php', 'verb' => 'GET'], ['name' => 'settings#updateWatermarkSettings', 'url' => 'settings/watermark', 'verb' => 'POST'], ['name' => 'settings#checkSettings', 'url' => 'settings/check', 'verb' => 'GET'], + ['name' => 'settings#accessCheck', 'url' => 'settings/access-check', 'verb' => 'GET'], ['name' => 'settings#demoServers', 'url' => 'settings/demo', 'verb' => 'GET'], ['name' => 'settings#getFontNames', 'url' => 'settings/fonts', 'verb' => 'GET'], ['name' => 'settings#getJsonFontList', 'url' => 'settings/fonts.json', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 6ab1102f5f..e2bf29ea2b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -40,6 +40,9 @@ use OCA\Richdocuments\Preview\Pdf; use OCA\Richdocuments\Preview\Rtf; use OCA\Richdocuments\Reference\OfficeTargetReferenceProvider; +use OCA\Richdocuments\SetupCheck\CollaboraUpdate; +use OCA\Richdocuments\SetupCheck\ConnectivityCheck; +use OCA\Richdocuments\SetupCheck\ProofKeyCheck; use OCA\Richdocuments\Storage\SecureViewWrapper; use OCA\Richdocuments\TaskProcessing\SlideDeckGenerationProvider; use OCA\Richdocuments\TaskProcessing\SlideDeckGenerationTaskType; @@ -122,6 +125,10 @@ public function register(IRegistrationContext $context): void { $context->registerTaskProcessingProvider(TextToSpreadsheetProvider::class); $context->registerTaskProcessingProvider(SlideDeckGenerationProvider::class); $context->registerTaskProcessingTaskType(SlideDeckGenerationTaskType::class); + + $context->registerSetupCheck(CollaboraUpdate::class); + $context->registerSetupCheck(ConnectivityCheck::class); + $context->registerSetupCheck(ProofKeyCheck::class); } #[\Override] diff --git a/lib/Command/ActivateConfig.php b/lib/Command/ActivateConfig.php index 804b491225..558fbfc3f3 100644 --- a/lib/Command/ActivateConfig.php +++ b/lib/Command/ActivateConfig.php @@ -73,6 +73,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + try { + $this->connectivityService->testWopiAccess($output); + } catch (\Throwable $e) { + $output->writeln('Failed to verify WOPI connectivity'); + $output->writeln($e->getMessage()); + return 1; + } + try { $this->connectivityService->autoConfigurePublicUrl(); } catch (\Throwable $e) { diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2c522fb633..051d3dd582 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -25,6 +25,8 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\JSONResponse; @@ -38,7 +40,6 @@ use OCP\PreConditionNotMetException; use OCP\Util; use Psr\Log\LoggerInterface; -use Symfony\Component\Console\Output\NullOutput; class SettingsController extends Controller { /** Settings file URLs are generated with a settings token and carry no document context. */ @@ -76,11 +77,20 @@ public function __construct( parent::__construct($appName, $request); } + /** + * Callback for the Collabora WOPI access check. Collabora requires a 200 response and cannot + * handle a body without Content-Length, which the PHP built-in webserver does not add. + */ + #[PublicPage] + #[NoCSRFRequired] + public function accessCheck(): DataDisplayResponse { + $body = 'OK'; + return new DataDisplayResponse($body, Http::STATUS_OK, ['Content-Type' => 'text/plain', 'Content-Length' => (string)strlen($body)]); + } + public function checkSettings(): DataResponse { try { - $output = new NullOutput(); - $this->connectivityService->testDiscovery($output); - $this->connectivityService->testCapabilities($output); + $this->connectivityService->test(); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new DataResponse([ @@ -192,9 +202,11 @@ public function setSettings( } try { - $output = new NullOutput(); - $this->connectivityService->testDiscovery($output); - $this->connectivityService->testCapabilities($output); + $this->connectivityService->testDiscovery(); + $this->connectivityService->testCapabilities(); + if ($wopi_url !== null) { + $this->connectivityService->testWopiAccess(); + } $this->connectivityService->autoConfigurePublicUrl(); } catch (\Throwable $e) { return new JSONResponse([ diff --git a/lib/Service/CachedRequestService.php b/lib/Service/CachedRequestService.php index 319578e120..8437310d31 100644 --- a/lib/Service/CachedRequestService.php +++ b/lib/Service/CachedRequestService.php @@ -66,7 +66,7 @@ public function getLastUpdate(): ?int { * @return string * @throws \Exception */ - final public function fetch(): string { + public function fetch(): string { $cache = $this->cacheFactory->createDistributed('richdocuments'); $client = $this->clientService->newClient(); diff --git a/lib/Service/CapabilitiesService.php b/lib/Service/CapabilitiesService.php index cf338b81cf..8fd30d84df 100644 --- a/lib/Service/CapabilitiesService.php +++ b/lib/Service/CapabilitiesService.php @@ -110,6 +110,18 @@ public function getProductName(): string { return $this->l10n->t('Nextcloud Office (Collabora)'); } + public function isEnterprise(): bool { + return ($this->getCapabilities()['productName'] ?? false) === 'Collabora Online'; + } + + public function isCode(): bool { + return ($this->getCapabilities()['productName'] ?? false) === 'Collabora Online Development Edition'; + } + + public function hasWopiAccessCheck(): bool { + return $this->getCapabilities()['hasWopiAccessCheck'] ?? false; + } + public function hasOtherOOXMLApps(): bool { if ($this->appManager->isEnabledForUser('officeonline')) { return true; @@ -130,6 +142,12 @@ public function getCapabilitiesEndpoint(): ?string { return rtrim($remoteHost, '/') . '/hosting/capabilities'; } + #[\Override] + public function fetch(): string { + $this->capabilities = null; + return parent::fetch(); + } + #[\Override] protected function sendRequest(IClient $client): string { $response = $client->get($this->getCapabilitiesEndpoint(), $this->getDefaultRequestOptions()); diff --git a/lib/Service/ConnectivityService.php b/lib/Service/ConnectivityService.php index cbe6b9c93e..23c3f632f0 100644 --- a/lib/Service/ConnectivityService.php +++ b/lib/Service/ConnectivityService.php @@ -8,8 +8,12 @@ namespace OCA\Richdocuments\Service; use Exception; +use GuzzleHttp\Exception\BadResponseException; use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\WOPI\Parser; +use OCP\Http\Client\IClientService; +use OCP\IL10N; +use OCP\IURLGenerator; use Symfony\Component\Console\Output\OutputInterface; class ConnectivityService { @@ -17,35 +21,82 @@ public function __construct( private AppConfig $appConfig, private DiscoveryService $discoveryService, private CapabilitiesService $capabilitiesService, + private IClientService $clientService, + private IURLGenerator $urlGenerator, private Parser $parser, + private IL10N $l10n, ) { } /** * @throws Exception */ - public function testDiscovery(OutputInterface $output): void { - $this->discoveryService->resetCache(); + public function testDiscovery(?OutputInterface $output = null): void { $this->discoveryService->fetch(); - $output->writeln('✓ Fetched /hosting/discovery endpoint'); + $output?->writeln('✓ Fetched /hosting/discovery endpoint'); $this->parser->getUrlSrcValue('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); - $output->writeln('✓ Valid mimetype response'); + $output?->writeln('✓ Valid mimetype response'); // FIXME: Optional when allowing generic WOPI servers $this->parser->getUrlSrcValue('Capabilities'); - $output->writeln('✓ Valid capabilities entry'); + $output?->writeln('✓ Valid capabilities entry'); } - public function testCapabilities(OutputInterface $output): void { - $this->capabilitiesService->resetCache(); + public function testCapabilities(?OutputInterface $output = null): void { $this->capabilitiesService->fetch(); - $output->writeln('✓ Fetched /hosting/capabilities endpoint'); + $output?->writeln('✓ Fetched /hosting/capabilities endpoint'); if ($this->capabilitiesService->getCapabilities() === []) { throw new \Exception('Empty capabilities, unexpected result from ' . $this->capabilitiesService->getCapabilitiesEndpoint()); } - $output->writeln('✓ Detected WOPI server: ' . $this->capabilitiesService->getServerProductName() . ' ' . $this->capabilitiesService->getProductVersion() . ''); + $output?->writeln('✓ Detected WOPI server: ' . $this->capabilitiesService->getServerProductName() . ' ' . $this->capabilitiesService->getProductVersion() . ''); + } + + public function testWopiAccess(?OutputInterface $output = null): void { + if (!$this->capabilitiesService->hasWopiAccessCheck()) { + return; + } + + $client = $this->clientService->newClient(); + + $url = str_replace('/hosting/capabilities', '/hosting/wopiAccessCheck', $this->capabilitiesService->getCapabilitiesEndpoint()); + $callbackUrl = $this->appConfig->getNextcloudUrl() ?: trim($this->urlGenerator->getAbsoluteURL(''), '/'); + + try { + $result = $client->post($url, array_merge(RemoteOptionsService::getDefaultOptions(), [ + 'body' => json_encode(['callbackUrl' => $callbackUrl . '/index.php/apps/richdocuments/settings/access-check']), 'headers' => ['Content-Type' => 'application/json'] + ])); + } catch (BadResponseException $e) { + $result = $e->getResponse(); + } + $status = json_decode((string)$result->getBody(), true)['status'] ?? throw new \Exception('Unexpected response from ' . $url); + + $errorMessage = match ($status) { + 'Ok' => null, + 'NotHttpSuccess' => $this->l10n->t('The connection was successful but the response to the request was not 200'), + 'HostNotFound' => $this->l10n->t('DNS error, the host is not known by the Collabora Online server'), + 'WopiHostNotAllowed' => $this->l10n->t('The host for this request is not allowed to be used as a WOPI Host, this is likely a configuration issue in coolwsd.xml'), + 'ConnectionAborted' => $this->l10n->t('The connection was aborted by the destination server'), + 'CertificateValidation' => $this->l10n->t('The certificate of the response is invalid or otherwise not accepted'), + 'SelfSignedCertificate' => $this->l10n->t('The certificate of the response is self-signed and not trusted by the system'), + 'ExpiredCertificate' => $this->l10n->t('The certificate of the response is expired'), + 'SslHandshakeFail' => $this->l10n->t('Couldn’t establish an SSL/TLS connection'), + 'MissingSsl' => $this->l10n->t('The response wasn’t using SSL/TLS contrary to expected'), + 'NotHttps' => $this->l10n->t('HTTPS is expected to connect to Collabora Online as the WOPI host uses it. This is necessary to prevent mixed content errors.'), + 'NoScheme' => $this->l10n->t('A scheme (http:// or https://) for the WOPI host URL must be specified'), + 'Timeout' => $this->l10n->t('The request didn’t get a response within the time frame allowed'), + 'UnspecifiedError' => $this->l10n->t('The response could not be read, for example because the connection was closed before the full response was received. Check the server logs of Collabora for more details.'), + default => $this->l10n->t('Unknown error "%s". Check the server logs of Collabora for more details.', [$status]), + }; + + if ($errorMessage) { + throw new \Exception( + $this->l10n->t('The Collabora server could not properly reach the Nextcloud server at %s', [$callbackUrl]) . ' ' . $errorMessage + ); + } + + $output?->writeln('✓ Verified WOPI access from Collabora to ' . $callbackUrl . ''); } /** @@ -60,4 +111,13 @@ public function autoConfigurePublicUrl(): void { $detectedUrl = $this->appConfig->domainOnly($determinedUrl); $this->appConfig->setAppValue('public_wopi_url', $detectedUrl); } + + /** + * @throws Exception + */ + public function test(?OutputInterface $output = null): void { + $this->testDiscovery($output); + $this->testCapabilities($output); + $this->testWopiAccess($output); + } } diff --git a/lib/SetupCheck/CollaboraUpdate.php b/lib/SetupCheck/CollaboraUpdate.php new file mode 100644 index 0000000000..667f0bf99a --- /dev/null +++ b/lib/SetupCheck/CollaboraUpdate.php @@ -0,0 +1,80 @@ +l10n->t('Collabora server version check'); + } + + #[\Override] + public function run(): SetupResult { + $installedVersion = $this->capabilitiesService->getProductVersion(); + if ($installedVersion === null || !$this->config->getSystemValueBool('has_internet_connection', true)) { + return SetupResult::success(); + } + + $url = match (true) { + $this->capabilitiesService->isCode() => 'https://rating.collaboraonline.com/UpdateCheck', + $this->capabilitiesService->isEnterprise() => 'https://rating.collaboraonline.com/UpdateCheck?product=cool', + default => null, + }; + if ($url === null) { + return SetupResult::success(); + } + + $cache = $this->cacheFactory->createDistributed('richdocuments'); + $cacheKey = 'latest_version_' . md5($url); + $latestVersion = $cache->get($cacheKey); + if ($latestVersion === null) { + try { + $response = $this->clientService->newClient()->get($url, ['timeout' => 5]); + $latestVersion = (string)(json_decode((string)$response->getBody(), true)['coolwsd_version'] ?? ''); + } catch (\Exception $e) { + $this->logger->info('Failed to fetch latest Collabora version', ['exception' => $e]); + return SetupResult::info($this->l10n->t('Could not check for Collabora server updates')); + } + $cache->set($cacheKey, $latestVersion, self::CACHE_TTL); + } + + if ($latestVersion !== '' && version_compare($latestVersion, $installedVersion, '>')) { + return SetupResult::warning($this->l10n->t('Collabora server version is out of date. Currently using %s, new version is available: %s', [$installedVersion, $latestVersion])); + } + + return SetupResult::success(); + } +} diff --git a/lib/SetupCheck/ConnectivityCheck.php b/lib/SetupCheck/ConnectivityCheck.php new file mode 100644 index 0000000000..cb2f34ab39 --- /dev/null +++ b/lib/SetupCheck/ConnectivityCheck.php @@ -0,0 +1,51 @@ +l10n->t('Collabora server connectivity check'); + } + + #[\Override] + public function run(): SetupResult { + if ($this->appConfig->getCollaboraUrlInternal() === '') { + return SetupResult::success(); + } + + try { + $this->connectivityService->test(); + } catch (\Throwable $e) { + return SetupResult::error($this->l10n->t('Collabora is not configured properly:') . ' ' . $e->getMessage()); + } + + return SetupResult::success(); + } +} diff --git a/lib/SetupCheck/ProofKeyCheck.php b/lib/SetupCheck/ProofKeyCheck.php new file mode 100644 index 0000000000..7d713ac800 --- /dev/null +++ b/lib/SetupCheck/ProofKeyCheck.php @@ -0,0 +1,49 @@ +l10n->t('Collabora WOPI proof keys'); + } + + #[\Override] + public function run(): SetupResult { + if ($this->appConfig->getCollaboraUrlInternal() === '' || $this->discoveryService->hasProofKey()) { + return SetupResult::success(); + } + + return SetupResult::warning( + $this->l10n->t('The Collabora server does not provide WOPI proof keys, so requests from it cannot be verified. Generate proof keys on the Collabora server.'), + self::DOCS_URL, + ); + } +} diff --git a/tests/lib/Controller/SettingsControllerTest.php b/tests/lib/Controller/SettingsControllerTest.php index da2980c4fd..10e8f1b31f 100644 --- a/tests/lib/Controller/SettingsControllerTest.php +++ b/tests/lib/Controller/SettingsControllerTest.php @@ -39,6 +39,7 @@ class SettingsControllerTest extends TestCase { private IUserManager $userManager; private PermissionManager $permissionManager; private SettingsService $settingsService; + private ConnectivityService $connectivityService; protected function setUp(): void { parent::setUp(); @@ -54,6 +55,7 @@ protected function setUp(): void { $this->createMock(ISystemTagObjectMapper::class), ); $this->settingsService = $this->createMock(SettingsService::class); + $this->connectivityService = $this->createMock(ConnectivityService::class); } private function makeController(): SettingsController { @@ -63,7 +65,7 @@ private function makeController(): SettingsController { $this->createMock(IL10N::class), $this->createMock(AppConfig::class), $this->createMock(IConfig::class), - $this->createMock(ConnectivityService::class), + $this->connectivityService, $this->createMock(DiscoveryService::class), $this->createMock(CapabilitiesService::class), $this->createMock(DemoService::class), @@ -212,4 +214,32 @@ public function testGetSettingsFileScopesToTheEditorNotTheOwner(): void { $this->assertSame(Http::STATUS_OK, $response->getStatus()); } + + public function testAccessCheckSendsContentLength(): void { + $response = $this->makeController()->accessCheck(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('OK', $response->render()); + $this->assertSame('2', $response->getHeaders()['Content-Length']); + } + + /** @dataProvider dataSetSettingsWopiAccessCheck */ + public function testSetSettingsOnlyChecksWopiAccessWhenWopiUrlChanges(?string $wopiUrl, int $expectedChecks): void { + $this->connectivityService->expects($this->once())->method('testDiscovery'); + $this->connectivityService->expects($this->once())->method('testCapabilities'); + $this->connectivityService->expects($this->exactly($expectedChecks))->method('testWopiAccess'); + + $response = $this->makeController()->setSettings( + $wopiUrl, null, null, null, null, null, null, null, null, 'client-id', 'secret', + ); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + + public static function dataSetSettingsWopiAccessCheck(): array { + return [ + 'wopi_url unchanged' => [null, 0], + 'wopi_url changed' => ['https://collabora.example', 1], + ]; + } } diff --git a/tests/lib/Service/CapabilitiesServiceTest.php b/tests/lib/Service/CapabilitiesServiceTest.php new file mode 100644 index 0000000000..1b9b2cb1a3 --- /dev/null +++ b/tests/lib/Service/CapabilitiesServiceTest.php @@ -0,0 +1,65 @@ +createStub(ICache::class); + $cache->method('get')->willReturnOnConsecutiveCalls('{"productVersion":"1.0"}', '{"productVersion":"2.0"}'); + $cacheFactory = $this->createStub(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + + $appData = $this->createStub(IAppData::class); + $appData->method('getFolder')->willReturn($this->createStub(ISimpleFolder::class)); + $appDataFactory = $this->createStub(IAppDataFactory::class); + $appDataFactory->method('get')->willReturn($appData); + + $response = $this->createStub(IResponse::class); + $response->method('getBody')->willReturn('{"productVersion":"2.0"}'); + $client = $this->createStub(IClient::class); + $client->method('get')->willReturn($response); + $clientService = $this->createStub(IClientService::class); + $clientService->method('newClient')->willReturn($client); + + $config = $this->createStub(IConfig::class); + $config->method('getAppValue')->willReturn('https://collabora.example'); + + $service = new CapabilitiesService( + $clientService, + $cacheFactory, + $appDataFactory, + $this->createStub(IAppConfig::class), + $this->createStub(LoggerInterface::class), + $config, + $this->createStub(IAppManager::class), + $this->createStub(IL10N::class), + ); + + $this->assertSame('1.0', $service->getProductVersion()); + $service->fetch(); + $this->assertSame('2.0', $service->getProductVersion()); + } +} diff --git a/tests/lib/Service/ConnectivityServiceTest.php b/tests/lib/Service/ConnectivityServiceTest.php new file mode 100644 index 0000000000..c1b4b256a1 --- /dev/null +++ b/tests/lib/Service/ConnectivityServiceTest.php @@ -0,0 +1,82 @@ +createStub(AppConfig::class); + $appConfig->method('getNextcloudUrl')->willReturn('https://nextcloud.example'); + $capabilitiesService = $this->createStub(CapabilitiesService::class); + $capabilitiesService->method('hasWopiAccessCheck')->willReturn($hasWopiAccessCheck); + $capabilitiesService->method('getCapabilitiesEndpoint')->willReturn('https://collabora.example/hosting/capabilities'); + $response = $this->createStub(IResponse::class); + $response->method('getBody')->willReturn($body); + $client->method('post')->willReturn($response); + $clientService = $this->createStub(IClientService::class); + $clientService->method('newClient')->willReturn($client); + $l10n = $this->createStub(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + return new ConnectivityService( + $appConfig, + $this->createStub(DiscoveryService::class), + $capabilitiesService, + $clientService, + $this->createStub(IURLGenerator::class), + $this->createStub(Parser::class), + $l10n, + ); + } + + public function testWopiAccessSkippedWithoutCapability(): void { + $client = $this->createMock(IClient::class); + $client->expects($this->never())->method('post'); + + $this->makeService(false, null, $client)->testWopiAccess(); + } + + public function testWopiAccessOk(): void { + $client = $this->createMock(IClient::class); + $service = $this->makeService(true, '{"status":"Ok"}', $client); + $client->expects($this->once())->method('post')->with( + 'https://collabora.example/hosting/wopiAccessCheck', + $this->callback(fn (array $options) => json_decode($options['body'], true)['callbackUrl'] === 'https://nextcloud.example/index.php/apps/richdocuments/settings/access-check'), + ); + + $service->testWopiAccess(); + } + + public static function dataWopiAccessFailure(): array { + return [ + 'known error' => ['{"status":"WopiHostNotAllowed"}'], + 'unknown error' => ['{"status":"SomethingNew"}'], + 'invalid response' => [''], + ]; + } + + /** @dataProvider dataWopiAccessFailure */ + public function testWopiAccessFailure(string $body): void { + $this->expectException(\Exception::class); + + $this->makeService(true, $body, $this->createStub(IClient::class))->testWopiAccess(); + } +} diff --git a/tests/lib/SetupCheck/CollaboraUpdateTest.php b/tests/lib/SetupCheck/CollaboraUpdateTest.php new file mode 100644 index 0000000000..8c3abcabbb --- /dev/null +++ b/tests/lib/SetupCheck/CollaboraUpdateTest.php @@ -0,0 +1,73 @@ + [null, true, '{"coolwsd_version":"25.04.2"}', SetupResult::SUCCESS], + 'cached outdated' => ['25.04.1', true, null, SetupResult::WARNING, '25.04.2'], + 'cached unknown' => ['25.04.1', true, null, SetupResult::SUCCESS, ''], + 'no internet connection' => ['25.04.1', false, '{"coolwsd_version":"25.04.2"}', SetupResult::SUCCESS], + 'up to date' => ['25.04.2', true, '{"coolwsd_version":"25.04.2"}', SetupResult::SUCCESS], + 'outdated' => ['25.04.1', true, '{"coolwsd_version":"25.04.2"}', SetupResult::WARNING], + 'unexpected response' => ['25.04.1', true, 'error', SetupResult::SUCCESS], + 'request failed' => ['25.04.1', true, null, SetupResult::INFO], + ]; + } + + /** @dataProvider dataRun */ + public function testRun(?string $installedVersion, bool $hasInternet, ?string $body, string $expected, ?string $cachedVersion = null): void { + $capabilitiesService = $this->createStub(CapabilitiesService::class); + $capabilitiesService->method('getProductVersion')->willReturn($installedVersion); + $capabilitiesService->method('isCode')->willReturn(true); + $config = $this->createStub(IConfig::class); + $config->method('getSystemValueBool')->willReturn($hasInternet); + + $client = $this->createStub(IClient::class); + if ($body === null) { + $client->method('get')->willThrowException(new \Exception('timeout')); + } else { + $response = $this->createStub(IResponse::class); + $response->method('getBody')->willReturn($body); + $client->method('get')->willReturn($response); + } + $clientService = $this->createStub(IClientService::class); + $clientService->method('newClient')->willReturn($client); + $cache = $this->createStub(ICache::class); + $cache->method('get')->willReturn($cachedVersion); + $cacheFactory = $this->createStub(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($cache); + + $check = new CollaboraUpdate( + $this->createStub(IL10N::class), + $capabilitiesService, + $config, + $clientService, + $cacheFactory, + $this->createStub(LoggerInterface::class), + ); + + $this->assertSame($expected, $check->run()->getSeverity()); + } +} diff --git a/tests/lib/SetupCheck/ConnectivityCheckTest.php b/tests/lib/SetupCheck/ConnectivityCheckTest.php new file mode 100644 index 0000000000..9d67ee43fa --- /dev/null +++ b/tests/lib/SetupCheck/ConnectivityCheckTest.php @@ -0,0 +1,42 @@ + ['', false, 0, SetupResult::SUCCESS], + 'reachable' => ['https://collabora.example', false, 1, SetupResult::SUCCESS], + 'unreachable' => ['https://collabora.example', true, 1, SetupResult::ERROR], + ]; + } + + /** @dataProvider dataRun */ + public function testRun(string $wopiUrl, bool $fails, int $expectedTests, string $expected): void { + $appConfig = $this->createStub(AppConfig::class); + $appConfig->method('getCollaboraUrlInternal')->willReturn($wopiUrl); + $connectivityService = $this->createMock(ConnectivityService::class); + $test = $connectivityService->expects($this->exactly($expectedTests))->method('test'); + if ($fails) { + $test->willThrowException(new \Exception('unreachable')); + } + + $check = new ConnectivityCheck($this->createStub(IL10N::class), $appConfig, $connectivityService); + + $this->assertSame($expected, $check->run()->getSeverity()); + } +} diff --git a/tests/lib/SetupCheck/ProofKeyCheckTest.php b/tests/lib/SetupCheck/ProofKeyCheckTest.php new file mode 100644 index 0000000000..786c321b63 --- /dev/null +++ b/tests/lib/SetupCheck/ProofKeyCheckTest.php @@ -0,0 +1,39 @@ + ['', false, SetupResult::SUCCESS], + 'proof key present' => ['https://collabora.example', true, SetupResult::SUCCESS], + 'proof key missing' => ['https://collabora.example', false, SetupResult::WARNING], + ]; + } + + /** @dataProvider dataRun */ + public function testRun(string $wopiUrl, bool $hasProofKey, string $expected): void { + $appConfig = $this->createStub(AppConfig::class); + $appConfig->method('getCollaboraUrlInternal')->willReturn($wopiUrl); + $discoveryService = $this->createStub(DiscoveryService::class); + $discoveryService->method('hasProofKey')->willReturn($hasProofKey); + + $check = new ProofKeyCheck($this->createStub(IL10N::class), $appConfig, $discoveryService); + + $this->assertSame($expected, $check->run()->getSeverity()); + } +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9e0fe758a0..76c513953b 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -47,12 +47,6 @@ - - - - - - fetchPreview($template, $x, $y, $a, $forceIcon, $mode)]]> @@ -95,12 +89,6 @@ - - - - - - diff --git a/tests/run-integration.sh b/tests/run-integration.sh index d04e46bc75..bb16449a32 100755 --- a/tests/run-integration.sh +++ b/tests/run-integration.sh @@ -44,14 +44,15 @@ curl --fail http://localhost:$PORT_COOL/hosting/capabilities PHP_CLI_SERVER_WORKERS=10 php -S localhost:$PORT_SERVERA -t $OC_PATH & PHPPIDA=$! -PHP_CLI_SERVER_WORKERS=10 php -S localhost:$PORT_SERVERB -t $OC_PATH & +PHP_CLI_SERVER_WORKERS=10 php -S 0.0.0.0:$PORT_SERVERB -t $OC_PATH & PHPPIDB=$! +$OCC config:system:set allow_local_remote_servers --value true --type bool +$OCC config:system:set trusted_domains 1 --value="172.17.0.1" $OCC config:app:set richdocuments wopi_url --value="http://localhost:9980" $OCC config:app:set richdocuments public_wopi_url --value="http://localhost:9980" $OCC richdocuments:activate-config -$OCC config:system:set allow_local_remote_servers --value true --type bool $OCC config:system:set gs.trustedHosts 0 --value="localhost:$PORT_SERVERA" $OCC config:system:set gs.trustedHosts 1 --value="localhost:$PORT_SERVERB" diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 8c88d79ceb..ee014f61b2 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -203,6 +203,18 @@ namespace Symfony\Component\HttpFoundation { } } +namespace Symfony\Component\Console\Output { + interface OutputInterface { + public function writeln(string $text): void; + } +} + +namespace GuzzleHttp\Exception { + class BadResponseException extends \Exception { + public function getResponse(); + } +} + #[\Attribute(Attribute::TARGET_PARAMETER)] class SensitiveParameter { }