Files
service_wopi/tests/php/Unit/HostTest.php
T
2026-09-07 21:58:35 -04:00

160 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
use KTXC\Context\TenantContextInterface;
use KTXC\Http\Request\Request;
use KTXC\Service\TenantService;
use KTXM\ServiceWopi\Controllers\WopiController;
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
use KTXM\ServiceWopi\Host\Configuration;
use KTXM\ServiceWopi\Host\Documents;
use KTXM\ServiceWopi\Host\HostException;
use KTXM\ServiceWopi\Host\WopiService;
use KTXM\ServiceWopi\Host\ProofValidator;
use KTXM\ServiceWopi\Host\SessionStore;
use PHPUnit\Framework\TestCase;
class MemorySessions extends SessionStore
{
public array $records = [];
public array $cacheRecords = [];
public function __construct() {}
public function fileId(string $tenant, string $user, string $resource): string { return str_repeat('a', 32); }
public function save(string $token, array $session): void { $this->records[hash('sha256', $token)] = $session; }
public function find(string $tenant, string $file, string $token, int $now): ?array
{
$s = $this->records[hash('sha256', $token)] ?? null;
return $s && $s['tenant'] === $tenant && $s['file'] === $file && $s['expires'] > $now && $s['mode'] === 'view' ? $s : null;
}
public function cached(string $tenant, string $server, int $now): ?string { return $this->cacheRecords[$tenant . $server] ?? null; }
public function cache(string $tenant, string $server, string $xml, int $expires): void { $this->cacheRecords[$tenant . $server] = $xml; }
}
final class HostTest extends TestCase
{
public function testServiceConfigurationTakesPrecedenceAndDerivesDomain(): void
{
$tenants = $this->createMock(TenantService::class);
$tenants->method('fetchServiceConfiguration')->willReturn(['enabled' => true, 'office_server' => 'https://office.test']);
$tenants->expects(self::never())->method('fetchSettings');
$tenants->method('fetchById')->willReturn((new \KTXC\Models\Tenant\TenantObject())->setDomains(new \KTXC\Models\Tenant\DomainCollection(['app.test'])));
$wopi = new WopiService($tenants, new MemorySessions(), $this->createStub(Documents::class), $this->createStub(DiscoveryClient::class), $this->createStub(ProofValidator::class));
self::assertSame('https://app.test', $wopi->configuration('tenant')->origin->origin);
}
public function testServicesSurviveTenantConfigurationRoundTrip(): void
{
$services = ['wopi' => ['enabled' => true, 'office_server' => 'https://office.test'], 'other' => ['enabled' => false]];
$config = (new \KTXC\Models\Tenant\TenantConfiguration())->jsonDeserialize(['services' => $services]);
self::assertSame($services, $config->jsonSerialize()['services']);
}
public function testAmbiguousRuntimeOriginFailsClosed(): void
{
$this->expectException(HostException::class);
new Configuration(['enabled' => true, 'office_server' => 'https://office.test'], ['app.test', 'alias.test']);
}
public function testRouterSelectsSeparateAbsoluteHandlers(): void
{
$reflection = new \ReflectionClass(\KTXC\Routing\Router::class);
$router = $reflection->newInstanceWithoutConstructor();
$reflection->getProperty('logger')->setValue($router, new \Psr\Log\NullLogger());
$reflection->getMethod('extract')->invoke($router, dirname(__DIR__, 3) . '/lib/Controllers/WopiController.php', '/m/service_wopi');
$reflection->getProperty('initialized')->setValue($router, true);
$id = str_repeat('a', 32);
foreach (['' => 'retrieveMeta', '/contents' => 'retrieveContents', '/unknown' => 'unsupported'] as $suffix => $handler) {
$route = $router->match(Request::create('https://app.test/m/service_wopi/wopi/files/' . $id . $suffix));
self::assertSame($handler, $route?->classMethodName);
self::assertFalse($route->authenticated);
if ($suffix !== '/unknown') { self::assertSame($id, $route->params['id']); }
}
self::assertSame('unsupported', $router->match(Request::create('https://app.test/m/service_wopi/wopi/files/' . $id . '/contents', 'POST'))?->classMethodName);
}
private function setupWopi(bool $proofValid = true): array
{
$tenants = $this->createStub(TenantService::class);
$tenants->method('fetchServiceConfiguration')->willReturn(['enabled' => true, 'office_server' => 'https://office.test', 'origin_server' => 'https://app.test']);
$store = new MemorySessions();
$documents = $this->createStub(Documents::class);
$documents->method('read')->willReturn(['label' => 'file.docx', 'size' => 4, 'content' => "a\0bc", 'version' => 'version-1']);
$client = $this->createStub(DiscoveryClient::class);
$client->method('fetchXml')->willReturn('<wopi-discovery><net-zone name="external-https"><app name="Word"><action name="view" ext="docx" urlsrc="https://office.test/view?"/><action name="edit" ext="xlsx" urlsrc="https://office.test/edit?"/></app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>');
$proof = $this->createStub(ProofValidator::class);
$proof->method('valid')->willReturn($proofValid);
return [new WopiService($tenants, $store, $documents, $client, $proof), $store];
}
private function request(string $token, string $suffix = ''): Request
{
return Request::create('https://app.test/m/service_wopi/wopi/files/' . str_repeat('a', 32) . $suffix . '?access_token=' . $token);
}
public function testLaunchAndReadOnlyEndpoints(): void
{
[$wopi, $store] = $this->setupWopi();
self::assertSame([WopiService::FORMATS['docx']], $wopi->capabilities('tenant', 'user')['mimeTypes']);
$launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file');
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $launch['accessToken']);
self::assertStringNotContainsString($launch['accessToken'], json_encode($store->records));
self::assertStringNotContainsString($launch['accessToken'], $launch['actionUrl']);
self::assertSame('view', $launch['mode']);
self::assertGreaterThan(time() * 1000, $launch['accessTokenTtl']);
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('enabled')->willReturn(true);
$tenant->method('requireIdentifier')->willReturn('tenant');
$controller = new WopiController($tenant, $wopi);
$metadata = $controller->retrieveMeta($this->request($launch['accessToken']), str_repeat('a', 32));
self::assertSame(200, $metadata->getStatusCode());
$data = json_decode($metadata->getContent(), true);
self::assertTrue($data['ReadOnly']);
self::assertFalse($data['UserCanWrite']);
self::assertFalse($data['SupportsLocks']);
$content = $controller->retrieveContents($this->request($launch['accessToken'], '/contents'), str_repeat('a', 32));
self::assertSame("a\0bc", $content->getContent());
self::assertStringContainsString('no-store', $content->headers->get('Cache-Control'));
self::assertSame('4', $content->headers->get('Content-Length'));
$post = Request::create('https://app.test/m/service_wopi/wopi/files/' . str_repeat('a', 32) . '/contents', 'POST');
self::assertSame(405, $controller->unsupported($post)->getStatusCode());
}
public function testRejectsExpiredTokensAndCrossTenantAccess(): void
{
[$wopi, $store] = $this->setupWopi();
$launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file');
foreach (['other-tenant', 'tenant'] as $tenant) {
if ($tenant === 'tenant') $store->records[hash('sha256', $launch['accessToken'])]['expires'] = time();
try {
$wopi->access($tenant, str_repeat('a', 32), $this->request($launch['accessToken']));
self::fail('Expected token rejection');
} catch (HostException $error) { self::assertSame(401, $error->status); }
}
}
public function testRejectsProofFailure(): void
{
[$wopi] = $this->setupWopi(false);
$launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file');
$this->expectException(HostException::class);
$this->expectExceptionMessage('Invalid WOPI proof');
$wopi->access('tenant', str_repeat('a', 32), $this->request($launch['accessToken']));
}
public function testRejectsChangedFile(): void
{
[$wopi, $store] = $this->setupWopi();
$launch = $wopi->launch('tenant', 'user', 'default:personal:folder:file');
$store->records[hash('sha256', $launch['accessToken'])]['version'] = 'old-version';
$this->expectException(HostException::class);
$this->expectExceptionMessage('document changed');
$wopi->access('tenant', str_repeat('a', 32), $this->request($launch['accessToken']));
}
public function testConfigurationRequiresExplicitEnablementAndHttps(): void
{
$this->expectException(HostException::class);
new Configuration([]);
}
}