root = sys_get_temp_dir() . '/ktrix-local-system-store-' . bin2hex(random_bytes(8)); $this->service = new Service($this->root . '/service'); } protected function tearDown(): void { if (!is_dir($this->root)) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($items as $item) { $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); } rmdir($this->root); } #[Test] public function writesStatsAndStreamsABlob(): void { $written = $this->service->write( 'previews/report.webp', $this->resource('preview-data'), ['variant' => 'popover'], ); self::assertSame('previews/report.webp', $written->key); self::assertSame(strlen('preview-data'), $written->size); self::assertStringStartsWith('sha256:', $written->etag); self::assertSame($written->etag, $this->service->stat('previews/report.webp')?->etag); self::assertSame(['variant' => 'popover'], $this->service->stat('previews/report.webp')?->attributes); $read = $this->service->read('previews/report.webp'); self::assertNotNull($read); self::assertSame('image/webp', $read->mimeType()); self::assertSame('preview-data', implode('', iterator_to_array($read->stream()))); } #[Test] public function enforcesAtomicWriteConditions(): void { $first = $this->service->write('preview.webp', $this->resource('first')); $second = $this->service->write( 'preview.webp', $this->resource('second'), condition: WriteCondition::ifMatch($first->etag), ); self::assertNotSame($first->etag, $second->etag); try { $this->service->write( 'preview.webp', $this->resource('stale'), condition: WriteCondition::ifMatch($first->etag), ); self::fail('A stale ETag should fail'); } catch (WriteConflictException) { self::assertSame($second->etag, $this->service->stat('preview.webp')?->etag); } $this->expectException(WriteConflictException::class); $this->service->write( 'preview.webp', $this->resource('duplicate'), condition: WriteCondition::ifAbsent(), ); } #[Test] public function deletesConditionally(): void { $info = $this->service->write('preview.webp', $this->resource('content')); self::assertTrue($this->service->delete('preview.webp', WriteCondition::ifMatch($info->etag))); self::assertNull($this->service->stat('preview.webp')); self::assertFalse($this->service->delete('preview.webp')); } #[Test] public function lazilyListsOnlyTheRequestedPrefix(): void { $this->service->write('previews/a.webp', $this->resource('a')); $this->service->write('previews/nested/b.webp', $this->resource('b')); $this->service->write('icons/c.webp', $this->resource('c')); $items = iterator_to_array($this->service->list('previews/')); $keys = array_map(static fn($item): string => $item->key, $items); sort($keys); self::assertSame(['previews/a.webp', 'previews/nested/b.webp'], $keys); } #[Test] public function rejectsTraversalKeys(): void { $this->expectException(InvalidKeyException::class); $this->service->read('../secret'); } #[Test] public function providerExposesTenantIsolatedServicesOnlyToSystem(): void { $provider = new Provider($this->root); self::assertNull($provider->serviceFetch('tenant-a', 'user-a', 'default')); self::assertSame([], $provider->serviceList('tenant-a', 'user-a', [])); $tenantA = $provider->serviceFetch('tenant-a', SystemIdentity::USER, 'default'); $tenantB = $provider->serviceFetch('tenant-b', SystemIdentity::USER, 'default'); self::assertInstanceOf(Service::class, $tenantA); self::assertInstanceOf(Service::class, $tenantB); $tenantA->write('same-key', $this->resource('tenant-a')); $tenantB->write('same-key', $this->resource('tenant-b')); self::assertSame('tenant-a', implode('', iterator_to_array($tenantA->read('same-key')->stream()))); self::assertSame('tenant-b', implode('', iterator_to_array($tenantB->read('same-key')->stream()))); } #[Test] public function moduleRegistersTheLocalSystemStoreProvider(): void { $registrations = []; $providers = $this->createMock(ProviderManager::class); $providers->expects(self::exactly(2)) ->method('register') ->willReturnCallback(function (string $type, string $identifier, string $class) use (&$registrations): void { $registrations[] = [$type, $identifier, $class]; }); $events = $this->createStub(EventListenerRegistrarInterface::class); (new Module($providers, $events))->boot(); self::assertContains( [ProviderInterface::TYPE_DOCUMENT, 'default', DocumentProvider::class], $registrations, ); self::assertContains( [ProviderInterface::TYPE_SYSTEM_STORE, 'local', Provider::class], $registrations, ); } private function resource(string $content): BinaryResource { return new BinaryResource( 'preview.webp', 'image/webp', (function () use ($content): \Generator { yield substr($content, 0, 3); yield substr($content, 3); })(), ); } }