feat(core): implement tenant-scoped system store manager
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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';
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user