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(''); $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([]); } }