feat(core): implement tenant-scoped system store manager

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-28 20:47:27 -04:00
parent a1413db12f
commit e137de1183
5 changed files with 383 additions and 2 deletions
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\SystemStore;
use DateTimeImmutable;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use KTXC\SystemStore\SystemStoreManager;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\SystemIdentity;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\InvalidKeyException;
use KTXF\SystemStore\Provider\ProviderBaseInterface;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\SystemStoreException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class SystemStoreManagerTest extends TestCase
{
private TenantService&MockObject $tenants;
private ProviderManager&MockObject $providers;
private ProviderBaseInterface&MockObject $provider;
private SystemStoreServiceInterface&MockObject $service;
private SystemStoreManager $manager;
protected function setUp(): void
{
$configuration = (new TenantConfiguration())->jsonDeserialize([
'stores' => [
'previews' => [
'provider' => 'storage-provider',
'service' => 'preview-storage',
'namespace' => 'tenant-previews',
],
],
]);
$tenant = (new TenantObject())
->setIdentifier('tenant-a')
->setConfiguration($configuration);
$this->tenants = $this->createMock(TenantService::class);
$this->tenants->method('fetchById')->with('tenant-a')->willReturn($tenant);
$this->service = $this->createMock(SystemStoreServiceInterface::class);
$this->provider = $this->createMock(ProviderBaseInterface::class);
$this->provider->method('serviceFetch')
->with('tenant-a', SystemIdentity::USER, 'preview-storage')
->willReturn($this->service);
$this->providers = $this->createMock(ProviderManager::class);
$this->providers->method('resolve')
->with(ProviderInterface::TYPE_SYSTEM_STORE, 'storage-provider')
->willReturn($this->provider);
$this->manager = new SystemStoreManager($this->tenants, $this->providers);
}
#[Test]
public function delegatesStatWithPhysicalNamespaceAndReturnsLogicalKey(): void
{
$this->service->expects(self::once())
->method('stat')
->with('tenant-previews/documents/report.pdf')
->willReturn($this->info('tenant-previews/documents/report.pdf'));
$info = $this->manager->stat('tenant-a', 'previews', 'documents/report.pdf');
self::assertSame('documents/report.pdf', $info?->key);
}
#[Test]
public function delegatesReadDeleteAndWriteWithPhysicalNamespace(): void
{
$resource = $this->resource();
$this->service->expects(self::once())
->method('read')
->with('tenant-previews/image.webp')
->willReturn($resource);
$this->service->expects(self::once())
->method('delete')
->with('tenant-previews/image.webp', null)
->willReturn(true);
$this->service->expects(self::once())
->method('write')
->with('tenant-previews/image.webp', $resource, ['variant' => 'popover'], null)
->willReturn($this->info('tenant-previews/image.webp'));
self::assertSame($resource, $this->manager->read('tenant-a', 'previews', 'image.webp'));
self::assertTrue($this->manager->delete('tenant-a', 'previews', 'image.webp'));
self::assertSame(
'image.webp',
$this->manager->write(
'tenant-a',
'previews',
'image.webp',
$resource,
['variant' => 'popover'],
)->key,
);
}
#[Test]
public function lazilyListsLogicalKeysWithinTheNamespace(): void
{
$this->service->expects(self::once())
->method('list')
->with('tenant-previews/documents/')
->willReturn((function (): iterable {
yield $this->info('tenant-previews/documents/one.webp');
yield $this->info('tenant-previews/documents/two.webp');
})());
$items = iterator_to_array($this->manager->list('tenant-a', 'previews', 'documents/'));
self::assertSame(['documents/one.webp', 'documents/two.webp'], array_column($items, 'key'));
}
#[Test]
public function rejectsTraversalKeys(): void
{
$this->service->expects(self::never())->method('read');
$this->expectException(InvalidKeyException::class);
$this->manager->read('tenant-a', 'previews', '../secret');
}
#[Test]
public function rejectsProviderResultsOutsideTheConfiguredNamespace(): void
{
$this->service->expects(self::once())
->method('stat')
->willReturn($this->info('another-tenant/secret'));
$this->expectException(SystemStoreException::class);
$this->manager->stat('tenant-a', 'previews', 'secret');
}
#[Test]
public function rejectsAnUnconfiguredLogicalStore(): void
{
$this->service->expects(self::never())->method('stat');
$this->expectException(SystemStoreException::class);
$this->expectExceptionMessage("System store 'icons' is not configured");
$this->manager->stat('tenant-a', 'icons', 'logo.svg');
}
private function info(string $key): BlobInfo
{
return new BlobInfo(
key: $key,
mimeType: 'image/webp',
size: 123,
etag: 'revision-1',
modifiedAt: new DateTimeImmutable('2026-08-28T12:00:00+00:00'),
);
}
private function resource(): BinaryResource
{
return new BinaryResource(
'image.webp',
'image/webp',
(function (): \Generator {
yield 'content';
})(),
);
}
}