diff --git a/core/lib/Console/Tenant/TenantStoreListCommand.php b/core/lib/Console/Tenant/TenantStoreListCommand.php new file mode 100644 index 0000000..cb4ca2f --- /dev/null +++ b/core/lib/Console/Tenant/TenantStoreListCommand.php @@ -0,0 +1,51 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = (string) $input->getArgument('tenant'); + + try { + $stores = $this->stores->list($tenant); + if ($stores === []) { + $io->text("No logical stores configured for tenant '{$tenant}'."); + return Command::SUCCESS; + } + + $rows = []; + foreach ($stores as $name => $store) { + $rows[] = [$name, $store->provider, (string) $store->service, $store->namespace]; + } + $io->table(['Name', 'Provider', 'Service', 'Namespace'], $rows); + return Command::SUCCESS; + } catch (\Throwable $error) { + $io->error('Failed to list tenant stores: ' . $error->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Tenant/TenantStoreRemoveCommand.php b/core/lib/Console/Tenant/TenantStoreRemoveCommand.php new file mode 100644 index 0000000..51309d9 --- /dev/null +++ b/core/lib/Console/Tenant/TenantStoreRemoveCommand.php @@ -0,0 +1,58 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->addArgument('name', InputArgument::REQUIRED, 'Logical store name'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = (string) $input->getArgument('tenant'); + $name = (string) $input->getArgument('name'); + + try { + if (!$this->stores->remove($tenant, $name)) { + $io->warning("Logical store '{$name}' was not configured for tenant '{$tenant}'."); + return Command::SUCCESS; + } + + $this->logger->info('Tenant logical store removed via console', compact('tenant', 'name')); + $io->success("Logical store '{$name}' removed from tenant '{$tenant}'."); + return Command::SUCCESS; + } catch (\Throwable $error) { + $this->logger->error('Tenant logical store removal failed', [ + 'tenant' => $tenant, + 'name' => $name, + 'error' => $error->getMessage(), + ]); + $io->error('Failed to remove tenant store: ' . $error->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Tenant/TenantStoreSetCommand.php b/core/lib/Console/Tenant/TenantStoreSetCommand.php new file mode 100644 index 0000000..e525176 --- /dev/null +++ b/core/lib/Console/Tenant/TenantStoreSetCommand.php @@ -0,0 +1,62 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->addArgument('name', InputArgument::REQUIRED, 'Logical store name, such as previews') + ->addArgument('provider', InputArgument::REQUIRED, 'System-store provider identifier') + ->addArgument('service', InputArgument::REQUIRED, 'System-store service identifier') + ->addArgument('namespace', InputArgument::REQUIRED, 'Provider key namespace'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = (string) $input->getArgument('tenant'); + $name = (string) $input->getArgument('name'); + $provider = (string) $input->getArgument('provider'); + $service = (string) $input->getArgument('service'); + $namespace = (string) $input->getArgument('namespace'); + + try { + $this->stores->set($tenant, $name, $provider, $service, $namespace); + $this->logger->info('Tenant logical store configured via console', compact( + 'tenant', 'name', 'provider', 'service', 'namespace', + )); + $io->success("Logical store '{$name}' configured for tenant '{$tenant}'."); + return Command::SUCCESS; + } catch (\Throwable $error) { + $this->logger->error('Tenant logical store configuration failed', [ + 'tenant' => $tenant, + 'name' => $name, + 'error' => $error->getMessage(), + ]); + $io->error('Failed to configure tenant store: ' . $error->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 90e78d2..6dd41fe 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -229,6 +229,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M \KTXC\Console\Tenant\TenantListCommand::class, \KTXC\Console\Tenant\TenantDeleteCommand::class, \KTXC\Console\Tenant\TenantAuthEnableCommand::class, + \KTXC\Console\Tenant\TenantStoreListCommand::class, + \KTXC\Console\Tenant\TenantStoreSetCommand::class, + \KTXC\Console\Tenant\TenantStoreRemoveCommand::class, \KTXC\Console\User\UserCreateCommand::class, \KTXC\Console\User\UserListCommand::class, \KTXC\Console\User\UserDeleteCommand::class, diff --git a/core/lib/Stores/TenantStore.php b/core/lib/Stores/TenantStore.php index 8d3d1fe..bc4a446 100644 --- a/core/lib/Stores/TenantStore.php +++ b/core/lib/Stores/TenantStore.php @@ -77,6 +77,38 @@ class TenantStore $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]); } + /** + * Atomically creates or replaces one logical store reference. + * + * @param array{provider: string, service: string|int, namespace: string} $reference + */ + public function storeConfigurationStore(string $identifier, string $name, array $reference): bool + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( + ['identifier' => $identifier], + ['$set' => ["configuration.stores.{$name}" => $reference]], + ); + + return $result->getMatchedCount() > 0; + } + + /** + * Atomically removes one logical store reference. + */ + public function removeConfigurationStore(string $identifier, string $name): ?bool + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( + ['identifier' => $identifier], + ['$unset' => ["configuration.stores.{$name}" => '']], + ); + + if ($result->getMatchedCount() === 0) { + return null; + } + + return $result->getModifiedCount() > 0; + } + // ========================================================================= // Settings Operations // ========================================================================= diff --git a/core/lib/SystemStore/SystemStoreConfigurationService.php b/core/lib/SystemStore/SystemStoreConfigurationService.php new file mode 100644 index 0000000..9ab33b3 --- /dev/null +++ b/core/lib/SystemStore/SystemStoreConfigurationService.php @@ -0,0 +1,65 @@ + */ + public function list(string $tenantId): array + { + $tenant = $this->tenants->fetch($tenantId); + if ($tenant === null) { + throw new SystemStoreException("Tenant '{$tenantId}' was not found"); + } + + return $tenant->getConfiguration()->stores()->all(); + } + + public function set( + string $tenantId, + string $name, + string $provider, + string|int $service, + string $namespace, + ): StoreReference { + self::validateName($name); + $reference = new StoreReference($provider, $service, $namespace); + + if (!$this->tenants->storeConfigurationStore($tenantId, $name, $reference->toArray())) { + throw new SystemStoreException("Tenant '{$tenantId}' was not found"); + } + + return $reference; + } + + public function remove(string $tenantId, string $name): bool + { + self::validateName($name); + $removed = $this->tenants->removeConfigurationStore($tenantId, $name); + if ($removed === null) { + throw new SystemStoreException("Tenant '{$tenantId}' was not found"); + } + + return $removed; + } + + private static function validateName(string $name): void + { + if (preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) { + throw new \InvalidArgumentException('Logical store names must use lowercase letters, numbers, and hyphens'); + } + } +} diff --git a/tests/php/Unit/Console/Tenant/TenantStoreCommandsTest.php b/tests/php/Unit/Console/Tenant/TenantStoreCommandsTest.php new file mode 100644 index 0000000..6ed7a42 --- /dev/null +++ b/tests/php/Unit/Console/Tenant/TenantStoreCommandsTest.php @@ -0,0 +1,83 @@ +createStub(SystemStoreConfigurationService::class); + $stores->method('list')->willReturn([ + 'previews' => new StoreReference('local', 'default', 'previews'), + ]); + $tester = new CommandTester(new TenantStoreListCommand($stores)); + + $status = $tester->execute(['tenant' => 'tenant-a']); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('previews', $tester->getDisplay()); + self::assertStringContainsString('local', $tester->getDisplay()); + } + + #[Test] + public function configuresATenantStore(): void + { + $stores = $this->createMock(SystemStoreConfigurationService::class); + $stores->expects(self::once()) + ->method('set') + ->with('tenant-a', 'previews', 'local', 'default', 'previews') + ->willReturn(new StoreReference('local', 'default', 'previews')); + $tester = new CommandTester(new TenantStoreSetCommand($stores, new NullLogger())); + + $status = $tester->execute([ + 'tenant' => 'tenant-a', + 'name' => 'previews', + 'provider' => 'local', + 'service' => 'default', + 'namespace' => 'previews', + ]); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('configured', $tester->getDisplay()); + } + + #[Test] + public function removesATenantStore(): void + { + $stores = $this->createMock(SystemStoreConfigurationService::class); + $stores->expects(self::once())->method('remove')->with('tenant-a', 'previews')->willReturn(true); + $tester = new CommandTester(new TenantStoreRemoveCommand($stores, new NullLogger())); + + $status = $tester->execute(['tenant' => 'tenant-a', 'name' => 'previews']); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('removed', $tester->getDisplay()); + } + + #[Test] + public function removingAnAbsentStoreIsIdempotent(): void + { + $stores = $this->createStub(SystemStoreConfigurationService::class); + $stores->method('remove')->willReturn(false); + $tester = new CommandTester(new TenantStoreRemoveCommand($stores, new NullLogger())); + + $status = $tester->execute(['tenant' => 'tenant-a', 'name' => 'previews']); + + self::assertSame(Command::SUCCESS, $status); + self::assertStringContainsString('not configured', $tester->getDisplay()); + } +} diff --git a/tests/php/Unit/SystemStore/SystemStoreConfigurationServiceTest.php b/tests/php/Unit/SystemStore/SystemStoreConfigurationServiceTest.php new file mode 100644 index 0000000..e0391a2 --- /dev/null +++ b/tests/php/Unit/SystemStore/SystemStoreConfigurationServiceTest.php @@ -0,0 +1,104 @@ +tenants = $this->createMock(TenantStore::class); + $this->configuration = new SystemStoreConfigurationService($this->tenants); + } + + #[Test] + public function listsConfiguredLogicalStores(): void + { + $tenant = (new TenantObject())->setConfiguration( + (new TenantConfiguration())->jsonDeserialize([ + 'stores' => [ + 'previews' => [ + 'provider' => 'local', + 'service' => 'default', + 'namespace' => 'previews', + ], + ], + ]), + ); + $this->tenants->expects(self::once())->method('fetch')->with('tenant-a')->willReturn($tenant); + + $stores = $this->configuration->list('tenant-a'); + + self::assertSame('local', $stores['previews']->provider); + } + + #[Test] + public function atomicallyStoresOneLogicalStoreReference(): void + { + $this->tenants->expects(self::once()) + ->method('storeConfigurationStore') + ->with('tenant-a', 'previews', [ + 'provider' => 'local', + 'service' => 'default', + 'namespace' => 'previews', + ]) + ->willReturn(true); + + $reference = $this->configuration->set( + 'tenant-a', 'previews', 'local', 'default', 'previews', + ); + + self::assertSame('local', $reference->provider); + } + + #[Test] + public function reportsAMissingTenantDuringConfiguration(): void + { + $this->tenants->expects(self::once())->method('storeConfigurationStore')->willReturn(false); + $this->expectException(SystemStoreException::class); + + $this->configuration->set('missing', 'previews', 'local', 'default', 'previews'); + } + + #[Test] + public function atomicallyRemovesOneLogicalStoreReference(): void + { + $this->tenants->expects(self::once()) + ->method('removeConfigurationStore') + ->with('tenant-a', 'previews') + ->willReturn(true); + + self::assertTrue($this->configuration->remove('tenant-a', 'previews')); + } + + #[Test] + public function reportsAMissingTenantDuringRemoval(): void + { + $this->tenants->expects(self::once())->method('removeConfigurationStore')->willReturn(null); + $this->expectException(SystemStoreException::class); + + $this->configuration->remove('missing', 'previews'); + } + + #[Test] + public function rejectsUnsafeStoreNamesBeforeBuildingMongoPaths(): void + { + $this->tenants->expects(self::never())->method('storeConfigurationStore'); + $this->expectException(\InvalidArgumentException::class); + + $this->configuration->set('tenant-a', 'previews.other', 'local', 'default', 'previews'); + } +}