Add WOPI discovery service and connection diagnostics

This commit is contained in:
Sebastian Krupinski
2026-09-06 21:05:47 -04:00
committed by Sebastian Krupinski
parent 069f7068c8
commit 661e0e1213
14 changed files with 1238 additions and 2 deletions
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
use KTXM\ServiceWopi\Console\CheckCommand;
use KTXM\ServiceWopi\Discovery\Action;
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
use KTXM\ServiceWopi\Discovery\DiscoveryDocument;
use KTXM\ServiceWopi\Discovery\ServerAddress;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class DiscoveryTest extends TestCase
{
private function xml(string $action = 'view', string $url = 'https://office.test/editor?', string $requires = ''): string
{
return '<wopi-discovery><net-zone name="external-https"><app name="Word">'
. '<action ext="docx" name="' . $action . '" requires="' . $requires
. '" urlsrc="' . htmlspecialchars($url, ENT_XML1 | ENT_QUOTES) . '"/>'
. '</app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>';
}
public function testParsesActionsAndDoesNotPermitEditingWithoutHostCapabilities(): void
{
$server = new ServerAddress('https://OFFICE.test:443/');
self::assertSame('https://office.test/hosting/discovery', $server->discoveryUrl());
$document = DiscoveryDocument::parse($this->xml(), $server);
self::assertTrue($document->hasProofKeys);
self::assertTrue($document->actions[0]->supportedBy([]));
$edit = DiscoveryDocument::parse($this->xml('edit'), $server)->actions[0];
self::assertFalse($edit->supportedBy([]));
self::assertFalse($edit->supportedBy(['locks']));
self::assertTrue($edit->supportedBy(['locks', 'update']));
$extra = DiscoveryDocument::parse($this->xml('view', requires: ' containers, future '), $server)->actions[0];
self::assertFalse($extra->supportedBy([]));
}
public function testAcceptsEuroOfficeExternalHttpLabelOnlyWithTrustedHttpsUrls(): void
{
$xml = str_replace('external-https', 'external-http', $this->xml(
url: 'https://office.test/editor?<ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&',
));
$document = DiscoveryDocument::parse($xml, new ServerAddress('https://office.test'));
$source = 'https://app.test/wopi/files/123';
parse_str(parse_url($document->actions[0]->launchUrl($source), PHP_URL_QUERY), $parameters);
self::assertSame(['wopisrc' => $source], $parameters);
$this->expectException(InvalidArgumentException::class);
DiscoveryDocument::parse(str_replace('https://office.test', 'http://office.test', $xml), new ServerAddress('https://office.test'));
}
#[DataProvider('invalidServers')]
public function testRejectsInvalidServerAddresses(string $url): void
{
$this->expectException(InvalidArgumentException::class);
new ServerAddress($url);
}
public static function invalidServers(): array
{
return array_map(static fn ($url) => [$url], [
'http://office.test', 'file:///etc/passwd', 'https://user:pass@office.test',
'https://office.test?secret=x', 'https://office.test/#fragment',
"https://office.test/\r\n", 'https://office.test\\@other.test',
]);
}
#[DataProvider('invalidDocuments')]
public function testRejectsInvalidDiscovery(string $xml): void
{
$this->expectException(RuntimeException::class);
DiscoveryDocument::parse($xml, new ServerAddress('https://office.test'));
}
public static function invalidDocuments(): array
{
return [
[''], ['<html><body>Welcome</body></html>'], ['<wopi-discovery>'],
['<!DOCTYPE wopi-discovery [<!ENTITY x SYSTEM "file:///etc/passwd">]><wopi-discovery>&x;</wopi-discovery>'],
['<wopi-discovery><net-zone name="external-http"><app/></net-zone></wopi-discovery>'],
[str_repeat('x', DiscoveryDocument::MAX_BYTES + 1)],
];
}
#[DataProvider('untrustedActions')]
public function testRejectsUntrustedActions(string $url): void
{
$this->expectException(InvalidArgumentException::class);
DiscoveryDocument::parse($this->xml(url: $url), new ServerAddress('https://office.test'));
}
public static function untrustedActions(): array
{
return array_map(static fn ($url) => [$url], [
'http://office.test/editor', 'https://evil.test/editor',
'https://office.test.evil.test/editor', 'https://office.test:8443/editor',
'https://user@office.test/editor', '//office.test/editor',
'javascript:alert(1)', 'https://office.test/editor#fragment',
]);
}
#[DataProvider('launchTemplates')]
public function testBuildsLaunchUrlWithoutTokenAndRemovesUnknownPlaceholders(string $template): void
{
$action = DiscoveryDocument::parse($this->xml(url: $template), new ServerAddress('https://office.test'))->actions[0];
$source = 'https://app.test/m/service_wopi/files/opaque-id';
$url = $action->launchUrl($source);
self::assertStringNotContainsString('<', $url);
self::assertStringNotContainsString('access_token', $url);
parse_str(parse_url($url, PHP_URL_QUERY), $parameters);
self::assertSame($source, $parameters['WOPISrc']);
self::assertCount(1, $parameters);
}
public static function launchTemplates(): array
{
return [
['https://office.test/editor'],
['https://office.test/editor?'],
['https://office.test/editor?<ui=UI_LLCC&><future=UNKNOWN&>'],
['https://office.test/editor?<WOPISrc=WOPI_SOURCE&><ui=UI_LLCC&>'],
];
}
public function testCommandReports404WithoutClaimingConnectionSuccess(): void
{
$client = $this->createStub(DiscoveryClient::class);
$client->method('fetch')->willThrowException(new RuntimeException('Discovery returned HTTP 404.'));
$command = new CommandTester(new CheckCommand($client));
self::assertSame(1, $command->execute(['server' => 'https://office.test']));
self::assertStringContainsString('HTTP 404', $command->getDisplay());
}
public function testCommandQualifiesViewingOnly(): void
{
$client = $this->createStub(DiscoveryClient::class);
$client->method('fetch')->willReturn(DiscoveryDocument::parse($this->xml(), new ServerAddress('https://office.test')));
$command = new CommandTester(new CheckCommand($client));
self::assertSame(0, $command->execute(['server' => 'https://office.test']));
self::assertStringContainsString('does not verify', $command->getDisplay());
}
public function testEditOnlyDiscoveryDoesNotPassReadOnlyQualification(): void
{
$client = $this->createStub(DiscoveryClient::class);
$client->method('fetch')->willReturn(DiscoveryDocument::parse($this->xml('edit'), new ServerAddress('https://office.test')));
$command = new CommandTester(new CheckCommand($client));
self::assertSame(1, $command->execute(['server' => 'https://office.test']));
}
}
+4
View File
@@ -0,0 +1,4 @@
<?php
$loader = require dirname(__DIR__, 4) . '/vendor/autoload.php';
$loader->addPsr4('KTXM\\ServiceWopi\\', dirname(__DIR__, 2) . '/lib/');
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="bootstrap.php" failOnWarning="true" failOnNotice="true" failOnDeprecation="true" cacheDirectory="/tmp/ktrix-wopi-phpunit">
<testsuites><testsuite name="WOPI"><directory>Unit</directory></testsuite></testsuites>
</phpunit>