diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index b85de9fd..998896fe 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -38,8 +38,8 @@ jobs: uses: shivammathur/cache-extensions@v1 with: php-version: '8.5' - extensions: swoole, pdo_sqlite, pcntl - key: load-test-ext-v1 + extensions: swoole, pdo_sqlite, pcntl, opcache + key: load-test-ext-v2 - name: Cache extensions uses: actions/cache@v3 @@ -52,9 +52,10 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' - extensions: swoole, pdo_sqlite, pcntl + extensions: swoole, pdo_sqlite, pcntl, opcache coverage: none tools: composer:2.9 + ini-values: opcache.enable_cli=1, opcache.jit_buffer_size=128M, opcache.jit=tracing - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader @@ -112,8 +113,8 @@ jobs: uses: shivammathur/cache-extensions@v1 with: php-version: '8.5' - extensions: swoole, pdo_mysql, pcntl - key: load-test-mysql-ext-v1 + extensions: swoole, pdo_mysql, pcntl, opcache + key: load-test-mysql-ext-v2 - name: Cache extensions uses: actions/cache@v3 @@ -126,9 +127,10 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' - extensions: swoole, pdo_mysql, pcntl + extensions: swoole, pdo_mysql, pcntl, opcache coverage: none tools: composer:2.9 + ini-values: opcache.enable_cli=1, opcache.jit_buffer_size=128M, opcache.jit=tracing - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d421646..9363037e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ CHANGELOG 1.0.0 (202x-xx-xx) ------------------ +* Removed `ServerOptions::setDseNamingContexts()`. RootDSE `namingContexts` is now derived from the backend. * Updated the minimum version of PHP to version 8.1. * All classes now use "strict_types=1". This internal change should not impact external usage. * Client and server options arrays have been replaced by ClientOptions and ServerOptions classes. diff --git a/composer.json b/composer.json index 7534bd04..611be584 100644 --- a/composer.json +++ b/composer.json @@ -79,8 +79,8 @@ "docker compose -f tests/resources/openldap/docker-compose.yml up -d --build --wait", "LDAP_TESTS_ENABLED=1 php -d xdebug.mode=off vendor/bin/phpunit --testsuite integration" ], - "test-load": "@php -d xdebug.mode=off tests/bin/ldap-load-test.php --backend=sqlite --runner=swoole --duration=10 --warmup=2 --clients=8 --output=text", - "test-load-compare": "@php -d xdebug.mode=off tests/bin/ldap-bench-compare.php", + "test-load": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-load-test.php --backend=sqlite --runner=swoole --duration=10 --warmup=2 --clients=8 --output=text", + "test-load-compare": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-bench-compare.php", "profile": "tests/profile/profile.sh", "profile-up": "docker compose -f tests/profile/docker-compose.yml up -d --build --wait", "profile-down": "docker compose -f tests/profile/docker-compose.yml down", diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index bb412c48..725eeb05 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -25,7 +25,6 @@ LDAP Server Configuration * [ServerOptions:setSchemaValidationMode](#setschemavalidationmode) * [ServerOptions:setSchema](#setschema) * [RootDSE Options](#rootdse-options) - * [ServerOptions:setDseNamingContexts](#setdsenamingcontexts) * [ServerOptions:setDseAltServer](#setdsealtserver) * [ServerOptions:setDseVendorName](#setdsevendorname) * [ServerOptions:setDseVendorVersion](#setdsevendorversion) @@ -423,12 +422,7 @@ Replaces the active schema used for validation and operational attributes. See ## RootDSE Options ------------------- -#### setDseNamingContexts - -The namingContexts attribute for the RootDSE as an array of strings. - -**Default**: `['dc=FreeDSx,dc=local']` +The `namingContexts` attribute is derived from the backend. No configuration is needed. ------------------ #### setDseAltServer diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 9ccd4f09..ccc61fc8 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -62,9 +62,7 @@ use FreeDSx\Ldap\ServerOptions; $passwordHash = '{SHA}' . base64_encode(sha1('secret', true)); -$server = new LdapServer( - (new ServerOptions())->setDseNamingContexts('dc=example,dc=com') -); +$server = new LdapServer(); $server->useStorage(new InMemoryStorage([ new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), @@ -94,9 +92,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\ServerOptions; $server = new LdapServer( - (new ServerOptions()) - ->setDseNamingContexts('dc=example,dc=com') - ->setSaslMechanisms(ServerOptions::SASL_PLAIN) + (new ServerOptions())->setSaslMechanisms(ServerOptions::SASL_PLAIN), ); $server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); @@ -152,12 +148,10 @@ class MyAuthenticator implements PasswordAuthenticatableInterface } $server = new LdapServer( - (new ServerOptions()) - ->setDseNamingContexts('dc=example,dc=com') - ->setSaslMechanisms( - ServerOptions::SASL_PLAIN, - ServerOptions::SASL_SCRAM_SHA_256, - ) + (new ServerOptions())->setSaslMechanisms( + ServerOptions::SASL_PLAIN, + ServerOptions::SASL_SCRAM_SHA_256, + ), ); $server->useStorage(JsonFileStorage::forPcntl('/var/lib/myapp/ldap.json')); @@ -919,8 +913,9 @@ $server = (new LdapServer())->usePasswordAuthenticator(new MyAuthenticator()); ## Handling the RootDSE -The server generates a default RootDSE from `ServerOptions` values (`setDseNamingContexts()`, `setDseVendorName()`, -etc.). For most deployments this is sufficient. The default entry always advertises: +The server generates a default RootDSE. `namingContexts` is derived from the backend (storage contents, or whatever a +custom backend declares); other attributes such as `vendorName` come from `ServerOptions`. For most deployments this is +sufficient. The default entry always advertises: - `supportedControl`: paging (RFC 2696) - `supportedExtension`: WhoAmI (RFC 4532), Password Modify (RFC 3062), and StartTLS (RFC 4511) if an SSL certificate is configured diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 122cfa94..e96b29e7 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -149,7 +149,6 @@ public function useStorage(EntryStorageInterface $storage): self storage: $storage, limits: $this->options->makeSearchLimits(), validator: $this->buildSchemaValidator(), - namingContexts: $this->options->getDseNamingContexts(), operationalAttrs: new OperationalAttributeGenerator($schema), )); } @@ -248,7 +247,7 @@ public function dump( $output->write((new DirectoryDumper( $backend, - $this->options->getDseNamingContexts(), + $backend->namingContexts(), $this->options->getFilterEvaluator(), ))->dump($options)); diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php index ac92171d..ab2ca8fd 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php @@ -142,6 +142,7 @@ private function getRootDseHandler(): ServerProtocolHandler\ServerRootDseHandler return new ServerProtocolHandler\ServerRootDseHandler( options: $this->options, queue: $this->queue, + backend: $this->handlerFactory->makeBackend(), rootDseHandler: $this->handlerFactory->makeRootDseHandler(), ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php index 444cca25..fa3f5e39 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php @@ -26,6 +26,8 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestContext; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -53,6 +55,7 @@ class ServerRootDseHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerOptions $options, private readonly ServerQueue $queue, + private readonly LdapBackendInterface $backend, private readonly ?RootDseHandlerInterface $rootDseHandler = null, ) {} @@ -65,7 +68,10 @@ public function handleRequest( TokenInterface $token, ): OperationResult { $entry = Entry::fromArray('', [ - 'namingContexts' => $this->options->getDseNamingContexts(), + 'namingContexts' => array_map( + fn(Dn $dn): string => $dn->toString(), + $this->backend->namingContexts(), + ), 'subschemaSubentry' => [$this->options->getSubschemaEntry()->toString()], 'supportedControl' => [ Control::OID_PAGING, diff --git a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php index ae6b82b6..537eda02 100644 --- a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -47,4 +47,11 @@ public function compare( Dn $dn, EqualityFilter $filter, ): bool; + + /** + * Normalised DNs the backend hosts. Advertised by the server as RootDSE namingContexts. + * + * @return list + */ + public function namingContexts(): array; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php index 3900b5be..868d9628 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php @@ -98,6 +98,11 @@ public function querySubtree(): string; */ public function queryHasChildren(): string; + /** + * SELECT dn for entries whose parent is not in `entries` (i.e. naming-context roots). No parameters. + */ + public function queryNamingContexts(): string; + /** * Upsert a single entry. Parameters: [lc_dn, dn, lc_parent_dn, attributes] */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php index 68ff06ed..dbe08c45 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php @@ -99,6 +99,16 @@ public function queryHasChildren(): string SQL; } + public function queryNamingContexts(): string + { + return <<namingContextsFromArray($this->entries); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php index c7cc2abf..ec8be603 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/JsonFileStorage.php @@ -121,6 +121,11 @@ public function atomic(callable $operation): void }); } + public function namingContexts(): array + { + return $this->namingContextsFromArray($this->read()); + } + /** * @param callable(string): string $mutation */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php index c7953e91..2ab48dad 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -311,6 +311,21 @@ public function hasChildren(Dn $dn): bool return $stmt->fetch() !== false; } + public function namingContexts(): array + { + $stmt = $this->prepareAndExecute($this->dialect->queryNamingContexts()); + + $contexts = []; + while (($row = $stmt->fetch()) !== false) { + if (!is_array($row) || !isset($row['dn']) || !is_string($row['dn'])) { + continue; + } + $contexts[] = (new Dn($row['dn']))->normalize(); + } + + return $contexts; + } + public function atomic(callable $operation): void { $pdo = $this->provider->get(); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php index 980b5671..ea2d746c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php @@ -78,6 +78,23 @@ private function sortedStreamFromArray( ); } + /** + * @param array $entries Entries keyed by normalised DN string + * @return list + */ + private function namingContextsFromArray(array $entries): array + { + $roots = []; + foreach (array_keys($entries) as $normDn) { + $parent = (new Dn($normDn))->getParent()?->normalize()->toString() ?? ''; + if ($parent === '' || !isset($entries[$parent])) { + $roots[] = new Dn($normDn); + } + } + + return $roots; + } + /** * @param array $entries Entries keyed by normalised DN string * @return Generator diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php index 166f5850..6bfa6c43 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/JsonEntryBuffer.php @@ -109,6 +109,20 @@ public function atomic(callable $operation): void $operation($this); } + public function namingContexts(): array + { + $roots = []; + + foreach (array_keys($this->data) as $normDn) { + $parent = (new Dn($normDn))->getParent()?->normalize()->toString() ?? ''; + if ($parent === '' || !isset($this->data[$parent])) { + $roots[] = new Dn($normDn); + } + } + + return $roots; + } + /** * @return array */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php index 2a3ace3d..c521fd44 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Writer/WriteSerializingStorage.php @@ -77,6 +77,11 @@ public function atomic(callable $operation): void }); } + public function namingContexts(): array + { + return $this->reads->namingContexts(); + } + public function reset(): void { if ($this->reads instanceof ResettableInterface) { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php index 2c5a3589..da23f7b1 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/EntryStorageInterface.php @@ -60,4 +60,11 @@ public function remove(Dn $dn): void; * @param callable(EntryStorageInterface): void $operation */ public function atomic(callable $operation): void; + + /** + * Normalised DNs of entries whose parent is not in storage. Advertised by the server as RootDSE namingContexts. + * + * @return list + */ + public function namingContexts(): array; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php index 1281c50a..146e7f72 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php @@ -32,7 +32,7 @@ final readonly class DirectoryDumper { /** - * @param string[] $namingContexts dump roots when DumpOptions::baseDn is not set + * @param list $namingContexts dump roots when DumpOptions::baseDn is not set */ public function __construct( private WritableStorageBackend $backend, @@ -71,10 +71,7 @@ private function resolveBases(DumpOptions $options): array return [$options->getBaseDn()]; } - return array_values(array_map( - fn(string $namingContext): Dn => new Dn($namingContext), - $this->namingContexts, - )); + return $this->namingContexts; } /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 2e845a98..42bb3394 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -50,35 +50,26 @@ final class WritableStorageBackend implements WritableLdapBackendInterface, Rese { use WritableBackendTrait; - /** - * @var array normalised DN strings of protected naming contexts. - */ - private readonly array $namingContexts; - private readonly SearchStreamBuilder $searchStream; - /** - * @param string[] $namingContexts DN strings that may not be deleted. - */ public function __construct( private readonly EntryStorageInterface $storage, private readonly SearchLimits $limits = new SearchLimits(), private readonly ?SchemaValidator $validator = null, - array $namingContexts = [], private readonly OperationalAttributeGenerator $operationalAttrs = new OperationalAttributeGenerator(), private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), ) { - $normalised = []; - foreach ($namingContexts as $namingContext) { - $normalised[(new Dn($namingContext))->normalize()->toString()] = true; - } - $this->namingContexts = $normalised; $this->searchStream = new SearchStreamBuilder( $this->storage, $this->limits, ); } + public function namingContexts(): array + { + return $this->storage->namingContexts(); + } + public function reset(): void { if ($this->storage instanceof ResettableInterface) { @@ -217,7 +208,11 @@ public function add( ): void { $this->writeAtomic(function (EntryStorageInterface $storage) use ($command, $context): void { $dn = $command->entry->getDn()->normalize(); - $this->assertParentExists($storage, $dn); + $this->assertParentExists( + $storage, + $dn, + $context, + ); if ($storage->exists($dn)) { $this->throwEntryAlreadyExists($command->entry->getDn()); @@ -242,8 +237,6 @@ public function delete( DeleteCommand $command, WriteContext $context, ): void { - $this->assertNotNamingContext($command->dn); - $this->writeAtomic(function (EntryStorageInterface $storage) use ($command): void { $dn = $command->dn->normalize(); $this->findOrFail($storage, $dn); @@ -258,6 +251,11 @@ public function delete( ); } + $this->assertNotNamingContext( + $storage, + $command->dn, + ); + $storage->remove($dn); }); } @@ -383,8 +381,6 @@ public function move( MoveCommand $command, WriteContext $context, ): void { - $this->assertNotNamingContext($command->dn); - $this->writeAtomic(function (EntryStorageInterface $storage) use ($command, $context): void { $normOld = $command->dn->normalize(); $entry = $this->findOrFail($storage, $normOld); @@ -396,6 +392,11 @@ public function move( ); } + $this->assertNotNamingContext( + $storage, + $command->dn, + ); + $this->assertNewSuperiorExists($storage, $command); $newEntry = $this->entryHandler->apply($entry, $command); @@ -459,22 +460,26 @@ private function findOrFail(EntryStorageInterface $storage, Dn $dn): Entry /** * @throws OperationException */ - private function assertParentExists(EntryStorageInterface $storage, Dn $dn): void - { + private function assertParentExists( + EntryStorageInterface $storage, + Dn $dn, + WriteContext $context, + ): void { $parent = $dn->getParent(); - // Skip check when the immediate parent is a root-level entry (single-component - // DN), which is a valid naming context that may not be stored on this server. - if ($parent === null || $parent->getParent() === null) { + if ($parent !== null && $storage->exists($parent)) { return; } - if (!$storage->exists($parent)) { - $this->throwNoSuchObject( - $storage, - $parent, - ); + // New naming-context roots may only be created by system writes. + if ($context->isSystem()) { + return; } + + $this->throwNoSuchObject( + $storage, + $parent ?? $dn, + ); } /** @@ -499,9 +504,13 @@ private function assertNewSuperiorExists(EntryStorageInterface $storage, MoveCom /** * @throws OperationException */ - private function assertNotNamingContext(Dn $dn): void - { - if (!isset($this->namingContexts[$dn->normalize()->toString()])) { + private function assertNotNamingContext( + EntryStorageInterface $storage, + Dn $dn, + ): void { + $parent = $dn->normalize()->getParent(); + + if ($parent !== null && $parent->toString() !== '' && $storage->exists($parent)) { return; } diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php index 4658a2e5..19433fec 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/ProxyBackend.php @@ -57,8 +57,17 @@ class ProxyBackend implements WritableLdapBackendInterface, PasswordAuthenticata protected ClientOptions $options; + /** + * @var list Normalised DNs the proxy advertises as namingContexts. + */ + private readonly array $namingContexts; + + /** + * @param string[] $namingContexts DN strings the proxy exposes; advertised by the server as RootDSE namingContexts. + */ public function __construct( LdapClient|ClientOptions $clientOrOptions = new ClientOptions(), + array $namingContexts = [], ) { if ($clientOrOptions instanceof LdapClient) { $this->ldap = $clientOrOptions; @@ -66,6 +75,16 @@ public function __construct( } else { $this->options = $clientOrOptions; } + + $this->namingContexts = array_values(array_map( + fn(string $dn): Dn => (new Dn($dn))->normalize(), + $namingContexts, + )); + } + + public function namingContexts(): array + { + return $this->namingContexts; } public function search( diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php index 5e05f053..e75009c8 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/PcntlServerRunner.php @@ -82,7 +82,6 @@ public function __construct( pcntl_async_signals(true); $this->handledSignals = [ - SIGHUP, SIGINT, SIGTERM, SIGQUIT, @@ -239,6 +238,11 @@ function () use ($protocolHandler, $context) { }, ); } + // Children don't reload config; ignore SIGHUP so terminal hangups don't kill them. + pcntl_signal( + SIGHUP, + SIG_IGN, + ); } /** @@ -254,6 +258,15 @@ function () { }, ); } + pcntl_signal( + SIGHUP, + function () { + $this->logInfo( + 'Received SIGHUP. Configuration reload is not yet implemented.', + $this->defaultContext, + ); + }, + ); } /** diff --git a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php index 28b73eb6..4dc6d3d6 100644 --- a/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php +++ b/src/FreeDSx/Ldap/Server/ServerRunner/SwooleServerRunner.php @@ -110,6 +110,19 @@ private function registerShutdownSignals(): void Process::signal(SIGTERM, $this->handleShutdownSignal(...)); Process::signal(SIGINT, $this->handleShutdownSignal(...)); Process::signal(SIGQUIT, $this->handleShutdownSignal(...)); + // SIGHUP is reserved for configuration reload, not shutdown. Stub it. + Process::signal( + SIGHUP, + $this->handleReloadSignal(...), + ); + } + + private function handleReloadSignal(int $signal): void + { + $this->getRunnerLogger()?->info( + 'Received SIGHUP. Configuration reload is not yet implemented.', + ['signal' => $signal], + ); } private function handleShutdownSignal(int $signal): void diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 699d04b9..24e8ab20 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -120,11 +120,6 @@ final class ServerOptions private ?Dn $subschemaEntry = null; - /** - * @var string[] - */ - private array $dseNamingContexts = ['dc=FreeDSx,dc=local']; - private string $dseVendorName = 'FreeDSx'; private ?string $dseVendorVersion = null; @@ -355,21 +350,6 @@ public function setSubschemaEntry(Dn $subschemaEntry): self return $this; } - /** - * @return string[] - */ - public function getDseNamingContexts(): array - { - return $this->dseNamingContexts; - } - - public function setDseNamingContexts(string ...$dseNamingContexts): self - { - $this->dseNamingContexts = $dseNamingContexts; - - return $this; - } - public function getDseVendorName(): string { return $this->dseVendorName; @@ -782,7 +762,7 @@ public function setOnServerReady(?Closure $onServerReady): self } /** - * @return array{ip: string, port: int, unix_socket: string, transport: string, idle_timeout: int, require_authentication: bool, allow_anonymous: bool, backend: ?LdapBackendInterface, rootdse_handler: ?RootDseHandlerInterface, logger: ?LoggerInterface, use_ssl: bool, ssl_cert: ?string, ssl_cert_key: ?string, ssl_cert_passphrase: ?string, dse_alt_server: ?string, dse_naming_contexts: string[], dse_vendor_name: string, dse_vendor_version: ?string, sasl_mechanisms: string[]} + * @return array{ip: string, port: int, unix_socket: string, transport: string, idle_timeout: int, require_authentication: bool, allow_anonymous: bool, backend: ?LdapBackendInterface, rootdse_handler: ?RootDseHandlerInterface, logger: ?LoggerInterface, use_ssl: bool, ssl_cert: ?string, ssl_cert_key: ?string, ssl_cert_passphrase: ?string, dse_alt_server: ?string, dse_vendor_name: string, dse_vendor_version: ?string, sasl_mechanisms: string[]} */ public function toArray(): array { @@ -802,7 +782,6 @@ public function toArray(): array 'ssl_cert_key' => $this->getSslCertKey(), 'ssl_cert_passphrase' => $this->getSslCertPassphrase(), 'dse_alt_server' => $this->getDseAltServer(), - 'dse_naming_contexts' => $this->getDseNamingContexts(), 'dse_vendor_name' => $this->getDseVendorName(), 'dse_vendor_version' => $this->getDseVendorVersion(), 'sasl_mechanisms' => $this->getSaslMechanisms(), diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index 3acbc968..33c7c756 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -21,6 +21,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\Search\Filters; +use Throwable; final class LdapServerTest extends ServerTestCase { @@ -436,4 +437,46 @@ public function testItCanEndPagingEarly(): void $paging->end(); $this->assertFalse($paging->hasEntries()); } + + public function testSighupDoesNotShutdownTheServer(): void + { + if (!extension_loaded('posix')) { + $this->markTestSkipped('The posix extension is required to send signals.'); + } + + $this->authenticate(); + $this->assertSame( + 'dn:cn=user,dc=foo,dc=bar', + $this->ldapClient()->whoami(), + ); + + $this->sendServerSignal(SIGHUP); + + // Give the async signal handler a moment to run before checking state. + usleep(250_000); + + $this->assertTrue( + $this->isServerRunning(), + 'The server must remain running after SIGHUP.', + ); + + $newClient = $this->buildClient('tcp'); + + try { + $newClient->bind( + 'cn=user,dc=foo,dc=bar', + '12345', + ); + $this->assertSame( + 'dn:cn=user,dc=foo,dc=bar', + $newClient->whoami(), + ); + } finally { + try { + $newClient->unbind(); + } catch (Throwable) { + // Connection may already be closed; ignore unbind failures. + } + } + } } diff --git a/tests/integration/Ldif/LdapDumpServerTest.php b/tests/integration/Ldif/LdapDumpServerTest.php index b3096a03..7afa7142 100644 --- a/tests/integration/Ldif/LdapDumpServerTest.php +++ b/tests/integration/Ldif/LdapDumpServerTest.php @@ -19,7 +19,6 @@ use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; -use FreeDSx\Ldap\ServerOptions; use Tests\Integration\FreeDSx\Ldap\ServerTestCase; final class LdapDumpServerTest extends ServerTestCase @@ -110,7 +109,7 @@ public function test_the_dump_file_parses_into_the_seeded_entries(): void public function test_a_fresh_server_seeded_from_the_dump_reconstructs_the_directory(): void { $storage = new InMemoryStorage(); - (new LdapServer((new ServerOptions())->setDseNamingContexts('dc=foo,dc=bar'))) + (new LdapServer()) ->useStorage($storage) ->seed(new StringLdifLoader((string) file_get_contents(self::$dumpPath))); diff --git a/tests/integration/ServerTestCase.php b/tests/integration/ServerTestCase.php index 56af1d2c..e2cce394 100644 --- a/tests/integration/ServerTestCase.php +++ b/tests/integration/ServerTestCase.php @@ -179,6 +179,30 @@ protected function waitForServerOutput(string $marker): string ); } + protected function sendServerSignal(int $signal): void + { + $process = $this->overrideProcess ?? self::$sharedProcess + ?? throw new RuntimeException('No server process is running.'); + + $pid = $process->getPid(); + + if ($pid === null) { + throw new RuntimeException('The server process has no PID.'); + } + + posix_kill( + $pid, + $signal, + ); + } + + protected function isServerRunning(): bool + { + $process = $this->overrideProcess ?? self::$sharedProcess; + + return $process?->isRunning() ?? false; + } + protected function authenticate(): void { $this->ldapClient()->bind( diff --git a/tests/performance/Threshold/CiThresholds.php b/tests/performance/Threshold/CiThresholds.php index 40090139..398acb38 100644 --- a/tests/performance/Threshold/CiThresholds.php +++ b/tests/performance/Threshold/CiThresholds.php @@ -53,22 +53,22 @@ public static function forProfile(string $key): ThresholdSet ), 'sqlite:pcntl' => new ThresholdSet( maxErrors: 0, - minThroughput: 1100.0, + minThroughput: 935.0, maxP99Ms: 150.0, ), 'sqlite:swoole' => new ThresholdSet( maxErrors: 0, - minThroughput: 900.0, + minThroughput: 700.0, maxP99Ms: 500.0, ), 'mysql:pcntl' => new ThresholdSet( maxErrors: 0, - minThroughput: 850.0, + minThroughput: 720.0, maxP99Ms: 200.0, ), 'mysql:swoole' => new ThresholdSet( maxErrors: 0, - minThroughput: 850.0, + minThroughput: 720.0, maxP99Ms: 200.0, ), default => throw new InvalidArgumentException(sprintf( diff --git a/tests/support/Backend/RecordingLdapBackend.php b/tests/support/Backend/RecordingLdapBackend.php index ebf3221a..e4013f8b 100644 --- a/tests/support/Backend/RecordingLdapBackend.php +++ b/tests/support/Backend/RecordingLdapBackend.php @@ -65,4 +65,9 @@ public function compare( ): bool { return false; } + + public function namingContexts(): array + { + return []; + } } diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index dd01d243..0ad57c75 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -143,7 +143,6 @@ protected function execute( ->setSslCertKey(self::SSL_KEY) ->setUseSsl($useSsl) ->setAllowAnonymous($allowAnonymous) - ->setDseNamingContexts('dc=foo,dc=bar') ->setSocketAcceptTimeout(0.1) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index f4c1d236..2b71d3fe 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -129,9 +129,6 @@ public function test_it_should_get_the_default_options(): void 'ssl_cert_key' => null, 'ssl_cert_passphrase' => null, 'dse_alt_server' => null, - 'dse_naming_contexts' => [ - 'dc=FreeDSx,dc=local', - ], 'dse_vendor_name' => 'FreeDSx', 'dse_vendor_version' => null, 'sasl_mechanisms' => [], diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php index 1ad774c5..82c6a9c7 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php @@ -26,6 +26,7 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerRootDseHandler; use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestContext; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -45,25 +46,42 @@ final class ServerRootDseHandlerTest extends TestCase private RootDseHandlerInterface&MockObject $mockDseHandler; + private LdapBackendInterface&MockObject $mockBackend; + protected function setUp(): void { $this->options = new ServerOptions(); $this->mockToken = $this->createMock(TokenInterface::class); $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockDseHandler = $this->createMock(RootDseHandlerInterface::class); + $this->withBackendNamingContexts([]); + } + + /** + * @param list $dns + */ + private function withBackendNamingContexts(array $dns): void + { + $this->mockBackend = $this->createMock(LdapBackendInterface::class); + $this->mockBackend + ->method('namingContexts') + ->willReturn(array_map( + fn(string $dn): Dn => new Dn($dn), + $dns, + )); $this->subject = new ServerRootDseHandler( $this->options, $this->mockQueue, + $this->mockBackend, null, ); } public function test_it_should_send_back_a_RootDSE(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $search = new LdapMessageRequest( 1, @@ -134,13 +152,13 @@ public function test_it_always_advertises_paging_and_password_modify(): void public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements_a_rootdse_aware_interface(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $this->subject = new ServerRootDseHandler( $this->options, $this->mockQueue, + $this->mockBackend, $this->mockDseHandler, ); @@ -234,9 +252,8 @@ public function test_it_should_include_supported_sasl_mechanisms_when_configured public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_requested(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $search = new LdapMessageRequest( 1, @@ -320,9 +337,8 @@ public function test_it_uses_configured_subschema_entry_dn(): void public function test_it_should_only_return_specific_attributes_from_the_RootDSE_if_requested(): void { - $this->options - ->setDseVendorName('Foo') - ->setDseNamingContexts('dc=Foo,dc=Bar'); + $this->options->setDseVendorName('Foo'); + $this->withBackendNamingContexts(['dc=Foo,dc=Bar']); $search = new LdapMessageRequest( 1, diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php index 65c9544f..223f4352 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php @@ -253,4 +253,49 @@ public function test_list_with_positive_time_limit_returns_entries_when_within_d self::assertCount(2, $entries); } + + public function test_naming_contexts_is_empty_when_storage_is_empty(): void + { + self::assertSame( + [], + (new InMemoryStorage())->namingContexts(), + ); + } + + public function test_naming_contexts_returns_entries_whose_parent_is_missing(): void + { + $storage = new InMemoryStorage([ + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')), + new Entry(new Dn('dc=other,dc=org'), new Attribute('dc', 'other')), + ]); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $storage->namingContexts(), + ); + + sort($contexts); + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + + public function test_naming_contexts_returns_orphans_whose_parent_is_not_in_storage(): void + { + $storage = new InMemoryStorage([ + new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')), + ]); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $storage->namingContexts(), + ); + + self::assertSame( + ['cn=alice,dc=example,dc=com'], + $contexts, + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php index d4bb80e0..35dab2d7 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/JsonFileStorageTest.php @@ -57,7 +57,7 @@ protected function setUp(): void new Attribute('dc', 'example'), ), ), - $this->context(), + $this->systemContext(), ); $this->subject->add( new AddCommand($this->alice), @@ -73,6 +73,14 @@ private function context(): WriteContext ); } + private function systemContext(): WriteContext + { + return WriteContext::system( + new AnonToken(), + new ControlBag(), + ); + } + protected function tearDown(): void { if (file_exists($this->tempFile)) { @@ -272,4 +280,28 @@ public function test_has_children_returns_false_for_leaf_entry(): void { self::assertFalse($this->storage->hasChildren(new Dn('cn=alice,dc=example,dc=com'))); } + + public function test_naming_contexts_returns_top_most_entries_from_storage(): void + { + $this->subject->add( + new AddCommand( + new Entry( + new Dn('dc=other,dc=org'), + new Attribute('dc', 'other'), + ), + ), + $this->systemContext(), + ); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $this->storage->namingContexts(), + ); + + sort($contexts); + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php index b9a9e4cd..bc9d77a8 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php @@ -63,7 +63,7 @@ protected function setUp(): void new AddCommand( new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), ), - $this->context(), + $this->systemContext(), ); $this->subject->add( new AddCommand($this->alice), @@ -79,6 +79,14 @@ private function context(): WriteContext ); } + private function systemContext(): WriteContext + { + return WriteContext::system( + new AnonToken(), + new ControlBag(), + ); + } + public function test_get_returns_entry_by_dn(): void { $entry = $this->subject->get(new Dn('cn=Alice,dc=example,dc=com')); @@ -575,8 +583,6 @@ public function test_add_translates_dn_too_long_to_admin_limit_exceeded(): void $storage = $this->createPdoStorageWithMaxDnLength(5); $backend = new WritableStorageBackend($storage); - // Use a root-level parent (dc=example) so assertParentExists skips the lookup - // and the path reaches PdoStorage::store() where the DnTooLongException fires. $entry = new Entry( new Dn('cn=TooLong,dc=example'), new Attribute('cn', 'TooLong'), @@ -585,7 +591,7 @@ public function test_add_translates_dn_too_long_to_admin_limit_exceeded(): void try { $backend->add( new AddCommand($entry), - $this->context(), + $this->systemContext(), ); self::fail('Expected OperationException was not thrown.'); } catch (OperationException $e) { @@ -615,7 +621,7 @@ public function test_subtree_does_not_match_escaped_comma_suffix_collision(): vo ); $backend->add( new AddCommand($base), - $this->context(), + $this->systemContext(), ); $backend->add( new AddCommand($escaped), @@ -647,7 +653,7 @@ public function test_subtree_includes_entries_with_escaped_comma_under_correct_p ); $backend->add( new AddCommand($base), - $this->context(), + $this->systemContext(), ); $backend->add( new AddCommand($escaped), @@ -735,4 +741,33 @@ public function test_nested_atomic_rolls_back_inner_on_exception(): void self::assertNotNull($this->storage->find(new Dn('cn=outer,dc=example,dc=com'))); self::assertNull($this->storage->find(new Dn('cn=inner,dc=example,dc=com'))); } + + public function test_naming_contexts_returns_entries_whose_parent_is_missing_in_storage(): void + { + $this->storage->store(new Entry( + new Dn('dc=other,dc=org'), + new Attribute('dc', 'other'), + )); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $this->storage->namingContexts(), + ); + + sort($contexts); + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + + public function test_naming_contexts_is_empty_when_storage_is_empty(): void + { + $emptyStorage = SqliteStorage::forPcntl(':memory:'); + + self::assertSame( + [], + $emptyStorage->namingContexts(), + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 9827e99f..011beb32 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -242,18 +242,46 @@ public function test_add_throws_no_such_object_when_parent_does_not_exist(): voi ); } - public function test_add_allows_root_naming_context_entry(): void + public function test_add_allows_root_naming_context_entry_for_system_context(): void { $backend = new WritableStorageBackend(new InMemoryStorage()); $root = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); $backend->add( new AddCommand($root), - $this->context(), + $this->systemContext(), ); self::assertNotNull($backend->get(new Dn('dc=example,dc=com'))); } + public function test_add_refuses_root_naming_context_entry_for_non_system_context(): void + { + $backend = new WritableStorageBackend(new InMemoryStorage()); + $root = new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $backend->add( + new AddCommand($root), + $this->context(), + ); + } + + public function test_add_refuses_single_rdn_root_entry_for_non_system_context(): void + { + $backend = new WritableStorageBackend(new InMemoryStorage()); + $root = new Entry(new Dn('dc=com'), new Attribute('dc', 'com')); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::NO_SUCH_OBJECT); + + $backend->add( + new AddCommand($root), + $this->context(), + ); + } + public function test_delete_removes_entry(): void { $this->subject->delete( @@ -287,16 +315,13 @@ public function test_delete_throws_not_allowed_on_non_leaf_when_entry_has_subord ); } - public function test_delete_throws_unwilling_to_perform_for_configured_naming_context(): void + public function test_delete_throws_unwilling_to_perform_when_parent_is_not_in_storage(): void { $leaf = new Entry( new Dn('dc=example,dc=com'), new Attribute('dc', 'example'), ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$leaf]), - namingContexts: ['dc=example,dc=com'], - ); + $backend = new WritableStorageBackend(new InMemoryStorage([$leaf])); self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); @@ -307,36 +332,13 @@ public function test_delete_throws_unwilling_to_perform_for_configured_naming_co ); } - public function test_delete_naming_context_check_is_case_insensitive(): void + public function test_move_throws_unwilling_to_perform_when_parent_is_not_in_storage(): void { $leaf = new Entry( new Dn('dc=example,dc=com'), new Attribute('dc', 'example'), ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$leaf]), - namingContexts: ['DC=Example,DC=Com'], - ); - - self::expectException(OperationException::class); - self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); - - $backend->delete( - new DeleteCommand(new Dn('dc=example,dc=com')), - $this->context(), - ); - } - - public function test_move_throws_unwilling_to_perform_when_renaming_naming_context(): void - { - $leaf = new Entry( - new Dn('dc=example,dc=com'), - new Attribute('dc', 'example'), - ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$leaf]), - namingContexts: ['dc=example,dc=com'], - ); + $backend = new WritableStorageBackend(new InMemoryStorage([$leaf])); self::expectException(OperationException::class); self::expectExceptionCode(ResultCode::UNWILLING_TO_PERFORM); @@ -352,7 +354,7 @@ public function test_move_throws_unwilling_to_perform_when_renaming_naming_conte ); } - public function test_delete_allows_non_naming_context_entries_when_naming_context_configured(): void + public function test_delete_allows_entries_whose_parent_is_in_storage(): void { $base = new Entry( new Dn('dc=example,dc=com'), @@ -362,10 +364,7 @@ public function test_delete_allows_non_naming_context_entries_when_naming_contex new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice'), ); - $backend = new WritableStorageBackend( - storage: new InMemoryStorage([$base, $leaf]), - namingContexts: ['dc=example,dc=com'], - ); + $backend = new WritableStorageBackend(new InMemoryStorage([$base, $leaf])); $backend->delete( new DeleteCommand(new Dn('cn=Alice,dc=example,dc=com')), @@ -375,6 +374,27 @@ public function test_delete_allows_non_naming_context_entries_when_naming_contex self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); } + public function test_naming_contexts_delegates_to_storage(): void + { + $storage = new InMemoryStorage([ + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + new Entry(new Dn('cn=Alice,dc=example,dc=com'), new Attribute('cn', 'Alice')), + new Entry(new Dn('dc=other,dc=org'), new Attribute('dc', 'other')), + ]); + $backend = new WritableStorageBackend($storage); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $backend->namingContexts(), + ); + sort($contexts); + + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + public function test_update_add_attribute_value(): void { $this->subject->update( @@ -676,6 +696,14 @@ private function context(): WriteContext ); } + private function systemContext(): WriteContext + { + return WriteContext::system( + new AnonToken(), + new ControlBag(), + ); + } + /** * @return Generator */ diff --git a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php index f05a5163..f4d0a9c6 100644 --- a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php +++ b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php @@ -36,7 +36,7 @@ public function test_it_yields_the_version_header_first_when_enabled(): void { $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], ); $chunks = iterator_to_array( @@ -55,7 +55,7 @@ public function test_it_omits_the_version_header_when_disabled(): void $writer = new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)); $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], writer: $writer, ); @@ -74,7 +74,7 @@ public function test_it_iterates_entries_across_naming_contexts_when_no_base_is_ { $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -101,7 +101,7 @@ public function test_it_restricts_to_the_options_base_dn_when_set(): void { $dumper = new DirectoryDumper( $this->backendWithEntries(), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -150,7 +150,7 @@ public function test_it_re_evaluates_the_filter_when_the_stream_is_not_preFilter $dumper = new DirectoryDumper( new WritableStorageBackend($storage), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], $evaluator, new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -185,7 +185,7 @@ public function test_it_does_not_re_evaluate_the_filter_when_the_stream_is_preFi $dumper = new DirectoryDumper( new WritableStorageBackend($storage), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], $evaluator, new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -213,7 +213,7 @@ public function test_it_passes_match_all_to_storage_when_no_filter_is_set(): voi $dumper = new DirectoryDumper( new WritableStorageBackend($storage), - ['dc=foo,dc=bar'], + [new Dn('dc=foo,dc=bar')], ); iterator_to_array( diff --git a/tests/unit/Server/RequestHandler/ProxyBackendTest.php b/tests/unit/Server/RequestHandler/ProxyBackendTest.php index c57fd840..4f83407e 100644 --- a/tests/unit/Server/RequestHandler/ProxyBackendTest.php +++ b/tests/unit/Server/RequestHandler/ProxyBackendTest.php @@ -13,6 +13,7 @@ namespace Tests\Unit\FreeDSx\Ldap\Server\RequestHandler; +use FreeDSx\Ldap\ClientOptions; use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; @@ -272,4 +273,30 @@ public function test_move_sends_modify_dn_request_to_upstream(): void $this->context(), ); } + + public function test_naming_contexts_returns_configured_dns_normalised(): void + { + $subject = new ProxyBackend( + new ClientOptions(), + ['DC=Example,DC=Com', 'dc=other,dc=org'], + ); + + $contexts = array_map( + fn(Dn $dn): string => $dn->toString(), + $subject->namingContexts(), + ); + + self::assertSame( + ['dc=example,dc=com', 'dc=other,dc=org'], + $contexts, + ); + } + + public function test_naming_contexts_is_empty_when_not_configured(): void + { + self::assertSame( + [], + $this->subject->namingContexts(), + ); + } } diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index 0e5df3b9..631360d1 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -303,24 +303,6 @@ public function test_it_can_set_subschema_entry(): void ); } - public function test_dse_naming_contexts_has_a_default(): void - { - self::assertSame( - ['dc=FreeDSx,dc=local'], - $this->subject->getDseNamingContexts(), - ); - } - - public function test_it_can_set_dse_naming_contexts(): void - { - $this->subject->setDseNamingContexts('dc=example,dc=com', 'dc=other,dc=com'); - - self::assertSame( - ['dc=example,dc=com', 'dc=other,dc=com'], - $this->subject->getDseNamingContexts(), - ); - } - public function test_dse_vendor_name_defaults_to_freedsx(): void { self::assertSame(