feat: implement initial read only access

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-07 21:58:35 -04:00
parent 661e0e1213
commit 92e85aed47
17 changed files with 863 additions and 4 deletions
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use KTXM\ServiceWopi\Console\ConfigureCommand;
use KTXM\ServiceWopi\Discovery\DiscoveryClient;
use KTXM\ServiceWopi\Discovery\DiscoveryDocument;
use KTXM\ServiceWopi\Discovery\ServerAddress;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class ConfigureCommandTest extends TestCase
{
public function testSavesOnlyValidatedWopiSettingsAndPreservesLimits(): void
{
$tenants = $this->createMock(TenantService::class);
$tenant = (new TenantObject())->setIdentifier('tenant-id')->setEnabled(true);
$tenants->method('fetchById')->willReturn($tenant);
$tenants->method('fetchServiceConfiguration')->willReturn(['max_bytes' => 1048576]);
$tenants->expects(self::once())->method('storeServiceConfiguration')->with('tenant-id', 'wopi', ['max_bytes' => 1048576, 'enabled' => true, 'office_server' => 'https://office.test', 'origin_server' => 'https://app.test',
])->willReturn(true);
$discovery = $this->createStub(DiscoveryClient::class);
$discovery->method('fetch')->willReturn(DiscoveryDocument::parse(
'<wopi-discovery><net-zone name="external-https"><app name="Word"><action name="view" ext="docx" urlsrc="https://office.test/view?"/></app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>',
new ServerAddress('https://office.test'),
));
$command = new CommandTester(new ConfigureCommand($tenants, $discovery));
self::assertSame(0, $command->execute(['tenant' => 'tenant-id', 'office-server' => 'https://office.test/', '--origin-server' => 'https://app.test/']));
self::assertStringNotContainsString('https://office.test', $command->getDisplay());
}
public function testDoesNotWriteIfDiscoveryFails(): void
{
$tenants = $this->createMock(TenantService::class);
$tenants->method('fetchById')->willReturn((new TenantObject())->setIdentifier('tenant-id')->setEnabled(true));
$tenants->method('fetchServiceConfiguration')->willReturn([]);
$tenants->expects(self::never())->method('storeServiceConfiguration');
$discovery = $this->createStub(DiscoveryClient::class);
$discovery->method('fetch')->willThrowException(new RuntimeException('private upstream details'));
$command = new CommandTester(new ConfigureCommand($tenants, $discovery));
self::assertSame(1, $command->execute(['tenant' => 'tenant-id', 'office-server' => 'https://office.test', '--origin-server' => 'https://app.test']));
self::assertStringNotContainsString('private upstream details', $command->getDisplay());
}
#[\PHPUnit\Framework\Attributes\DataProvider('originCases')]
public function testOriginResolution(array $domains, string $selector, ?string $override, ?string $expected): void
{
$tenant = (new TenantObject())->setIdentifier('tenant-id')->setEnabled(true)
->setDomains(new \KTXC\Models\Tenant\DomainCollection($domains));
$tenants = $this->createMock(TenantService::class);
$tenants->method('fetchById')->willReturn($selector === 'tenant-id' ? $tenant : null);
$tenants->method('fetchByDomain')->willReturn($tenant);
$tenants->method('fetchServiceConfiguration')->willReturn([]);
$discovery = $this->createMock(DiscoveryClient::class);
if ($expected === null) {
$tenants->expects(self::never())->method('storeServiceConfiguration');
$discovery->expects(self::never())->method('fetch');
} else {
$tenants->expects(self::once())->method('storeServiceConfiguration')->with('tenant-id', 'wopi', array_merge(
['enabled' => true, 'office_server' => 'https://office.test'],
$override !== null || count($domains) !== 1 ? ['origin_server' => $expected] : [],
))->willReturn(true);
$discovery->expects(self::once())->method('fetch')->willReturn(DiscoveryDocument::parse(
'<wopi-discovery><net-zone name="external-https"><app name="Word"><action name="view" ext="docx" urlsrc="https://office.test/view?"/></app></net-zone><proof-key modulus="abc" exponent="AQAB"/></wopi-discovery>',
new ServerAddress('https://office.test'),
));
}
$arguments = ['tenant' => $selector, 'office-server' => 'https://office.test'];
if ($override !== null) {
$arguments['--origin-server'] = $override;
}
$command = new CommandTester(new ConfigureCommand($tenants, $discovery));
self::assertSame($expected === null ? 1 : 0, $command->execute($arguments));
}
public static function originCases(): array
{
return [
'single domain by ID' => [['app.test'], 'tenant-id', null, 'https://app.test'],
'selected domain among aliases' => [['app.test', 'alias.test'], 'alias.test', null, 'https://alias.test'],
'ambiguous ID' => [['app.test', 'alias.test'], 'tenant-id', null, null],
'no domains' => [[], 'tenant-id', null, null],
'explicit override' => [['app.test', 'alias.test'], 'tenant-id', 'https://origin.test:8443', 'https://origin.test:8443'],
'HTTP rejected' => [['app.test'], 'tenant-id', 'http://origin.test', null],
'path rejected' => [['app.test'], 'tenant-id', 'https://origin.test/path', null],
];
}
}
+159
View File
@@ -0,0 +1,159 @@
<?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([]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use KTXM\ServiceWopi\Host\ProofValidator;
use PHPUnit\Framework\TestCase;
final class ProofTest extends TestCase
{
public function testProofBytesAndRotationWithIndependentOpenSslSignatures(): void
{
$randomFile = tempnam(sys_get_temp_dir(), 'wopi-test-random-');
$previousRandomFile = getenv('RANDFILE');
putenv('RANDFILE=' . $randomFile);
try {
$private = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
} finally {
putenv($previousRandomFile === false ? 'RANDFILE' : 'RANDFILE=' . $previousRandomFile);
unlink($randomFile);
}
$rsa = openssl_pkey_get_details($private)['rsa'];
$keys = ['modulus' => base64_encode($rsa['n']), 'exponent' => base64_encode($rsa['e'])];
$token = 'secret-token';
$url = 'https://app.test/wopi/files/test?access_token=secret-token';
$now = time();
$timestamp = ($now + 62135596800) * 10000000;
$upper = strtoupper($url);
$data = pack('N', strlen($token)) . $token . pack('N', strlen($upper)) . $upper
. pack('N', 8) . pack('N2', intdiv($timestamp, 4294967296), $timestamp % 4294967296);
openssl_sign($data, $signed, $private, OPENSSL_ALGO_SHA256);
$signature = base64_encode($signed);
$validator = new ProofValidator();
self::assertTrue($validator->valid($keys, $token, $url, (string) $timestamp, $signature, '', $now));
self::assertTrue($validator->valid($keys, $token, $url, (string) $timestamp, '', $signature, $now));
self::assertTrue($validator->valid(['oldmodulus' => $keys['modulus'], 'oldexponent' => $keys['exponent']], $token, $url, (string) $timestamp, $signature, '', $now));
self::assertFalse($validator->valid($keys, 'wrong-token', $url, (string) $timestamp, $signature, '', $now));
self::assertFalse($validator->valid($keys, $token, $url . '&extra=1', (string) $timestamp, $signature, '', $now));
self::assertFalse($validator->valid($keys, $token, $url, (string) $timestamp, $signature, '', $now + 1201));
self::assertFalse($validator->valid($keys, $token, $url, (string) $timestamp, $signature, '', $now - 1201));
self::assertFalse($validator->valid([], $token, $url, (string) $timestamp, $signature, '', $now));
self::assertFalse($validator->valid($keys, $token, $url, 'not-a-timestamp', $signature, '', $now));
}
}
+2
View File
@@ -2,3 +2,5 @@
$loader = require dirname(__DIR__, 4) . '/vendor/autoload.php';
$loader->addPsr4('KTXM\\ServiceWopi\\', dirname(__DIR__, 2) . '/lib/');
$modules = new KTXC\Module\ModuleAutoloader(dirname(__DIR__, 3));
$modules->register();