From 1c3100536486fc95b5f7acff92487a405d3c7b67 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Fri, 29 May 2026 09:01:02 -0400 Subject: [PATCH] Add support to dump the current storage to an LDIF. --- src/FreeDSx/Ldap/LdapServer.php | 30 +++ src/FreeDSx/Ldap/Ldif/LdifWriter.php | 18 +- .../Ldap/Ldif/Output/FileLdifOutput.php | 70 +++++ .../Ldap/Ldif/Output/LdifOutputInterface.php | 27 ++ .../Ldap/Ldif/Output/StringLdifOutput.php | 46 ++++ .../Factory/ProtocolHandlerProvider.php | 4 +- .../Storage/Export/DirectoryDumper.php | 106 ++++++++ .../Backend/Storage/Export/DumpOptions.php | 53 ++++ .../Ldap/Server/HandlerFactoryInterface.php | 6 - .../Server/RequestHandler/HandlerFactory.php | 10 - src/FreeDSx/Ldap/ServerOptions.php | 5 +- tests/integration/LdapServerTest.php | 2 +- tests/integration/Ldif/LdapDumpServerTest.php | 156 +++++++++++ tests/support/LdapServerCommand.php | 14 + tests/unit/LdapServerTest.php | 98 +++++++ tests/unit/Ldif/Output/FileLdifOutputTest.php | 87 +++++++ .../unit/Ldif/Output/StringLdifOutputTest.php | 70 +++++ .../Factory/ProtocolHandlerProviderTest.php | 4 - .../Storage/Export/DirectoryDumperTest.php | 244 ++++++++++++++++++ .../Storage/Export/DumpOptionsTest.php | 68 +++++ .../RequestHandler/HandlerFactoryTest.php | 23 -- .../unit/Server/ServerProtocolFactoryTest.php | 5 - tests/unit/ServerOptionsTest.php | 8 +- 23 files changed, 1094 insertions(+), 60 deletions(-) create mode 100644 src/FreeDSx/Ldap/Ldif/Output/FileLdifOutput.php create mode 100644 src/FreeDSx/Ldap/Ldif/Output/LdifOutputInterface.php create mode 100644 src/FreeDSx/Ldap/Ldif/Output/StringLdifOutput.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Export/DumpOptions.php create mode 100644 tests/integration/Ldif/LdapDumpServerTest.php create mode 100644 tests/unit/Ldif/Output/FileLdifOutputTest.php create mode 100644 tests/unit/Ldif/Output/StringLdifOutputTest.php create mode 100644 tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php create mode 100644 tests/unit/Server/Backend/Storage/Export/DumpOptionsTest.php diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 1bf80fa2..30cfe35f 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Ldif\LdifParser; use FreeDSx\Ldap\Ldif\Loader\LdifLoaderInterface; +use FreeDSx\Ldap\Ldif\Output\LdifOutputInterface; use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; @@ -30,6 +31,8 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DirectoryDumper; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions; use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; @@ -220,6 +223,33 @@ public function applyChanges(LdifLoaderInterface $loader): self return $this; } + /** + * Streams the configured storage backend's entries as RFC 2849 LDIF content records to the given output. + * + * Symmetric with {@see seed()}: the produced LDIF re-seeds the directory verbatim, including operational + * attributes. + * + * @throws RuntimeException when no storage backend is configured + */ + public function dump( + LdifOutputInterface $output, + DumpOptions $options = new DumpOptions(), + ): self { + $backend = $this->options->getBackend(); + + if (!$backend instanceof WritableStorageBackend) { + throw new RuntimeException('dump() requires a storage backend configured via useStorage().'); + } + + $output->write((new DirectoryDumper( + $backend, + $this->options->getDseNamingContexts(), + $this->options->getFilterEvaluator(), + ))->dump($options)); + + return $this; + } + private function buildSchemaValidator(): ?SchemaValidator { $mode = $this->options->getSchemaValidationMode(); diff --git a/src/FreeDSx/Ldap/Ldif/LdifWriter.php b/src/FreeDSx/Ldap/Ldif/LdifWriter.php index 52a8d848..ad853924 100644 --- a/src/FreeDSx/Ldap/Ldif/LdifWriter.php +++ b/src/FreeDSx/Ldap/Ldif/LdifWriter.php @@ -55,20 +55,28 @@ public function write(iterable $requests): string $blocks = []; foreach ($requests as $request) { - $blocks[] = $this->requestBlock($request); + $blocks[] = $this->writeOne($request); } - $body = implode($this->options->getLineEnding(), $blocks); + return $this->versionHeader() . implode($this->options->getLineEnding(), $blocks); + } + /** + * Returns the LDIF "version: 1" header (with trailing blank line) when enabled, otherwise empty. + */ + public function versionHeader(): string + { return $this->options->isIncludeVersion() - ? 'version: 1' . $this->options->getLineEnding() . $this->options->getLineEnding() . $body - : $body; + ? 'version: 1' . $this->options->getLineEnding() . $this->options->getLineEnding() + : ''; } /** + * Serializes a single request to its LDIF block (ending with the configured line ending). + * * @throws InvalidArgumentException */ - private function requestBlock(RequestInterface $request): string + public function writeOne(RequestInterface $request): string { return match (true) { $request instanceof AddRequest => $this->addBlock($request), diff --git a/src/FreeDSx/Ldap/Ldif/Output/FileLdifOutput.php b/src/FreeDSx/Ldap/Ldif/Output/FileLdifOutput.php new file mode 100644 index 00000000..1b6c6d87 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Output/FileLdifOutput.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Output; + +use FreeDSx\Ldap\Exception\RuntimeException; + +use function dirname; +use function fclose; +use function fopen; +use function fwrite; +use function is_dir; +use function is_writable; +use function sprintf; + +/** + * Streams LDIF chunks to a file path. + * + * @author Chad Sikorra + */ +final readonly class FileLdifOutput implements LdifOutputInterface +{ + public function __construct(private string $path) {} + + /** + * @param iterable $chunks + * @throws RuntimeException when the file cannot be opened or written + */ + public function write(iterable $chunks): void + { + $dir = dirname($this->path); + + if (!is_dir($dir) || !is_writable($dir)) { + throw new RuntimeException(sprintf( + 'Unable to open "%s" for writing.', + $this->path, + )); + } + + $handle = fopen($this->path, 'w'); + + if ($handle === false) { + throw new RuntimeException(sprintf( + 'Unable to open "%s" for writing.', + $this->path, + )); + } + + try { + foreach ($chunks as $chunk) { + fwrite( + $handle, + $chunk, + ); + } + } finally { + fclose($handle); + } + } +} diff --git a/src/FreeDSx/Ldap/Ldif/Output/LdifOutputInterface.php b/src/FreeDSx/Ldap/Ldif/Output/LdifOutputInterface.php new file mode 100644 index 00000000..5121904d --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Output/LdifOutputInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Output; + +/** + * Destination for streamed LDIF chunks produced by {@see \FreeDSx\Ldap\LdapServer::dump()}. + * + * @author Chad Sikorra + */ +interface LdifOutputInterface +{ + /** + * @param iterable $chunks + */ + public function write(iterable $chunks): void; +} diff --git a/src/FreeDSx/Ldap/Ldif/Output/StringLdifOutput.php b/src/FreeDSx/Ldap/Ldif/Output/StringLdifOutput.php new file mode 100644 index 00000000..47acca78 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Output/StringLdifOutput.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Output; + +use Stringable; + +/** + * Accumulates LDIF chunks into an in-memory string. + * + * @author Chad Sikorra + */ +final class StringLdifOutput implements LdifOutputInterface, Stringable +{ + private string $ldif = ''; + + /** + * @param iterable $chunks + */ + public function write(iterable $chunks): void + { + foreach ($chunks as $chunk) { + $this->ldif .= $chunk; + } + } + + public function getLdif(): string + { + return $this->ldif; + } + + public function __toString(): string + { + return $this->ldif; + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php index f6b7d86d..ac92171d 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php @@ -112,7 +112,7 @@ private function getSearchHandler(): ServerProtocolHandler\ServerSearchHandler return new ServerProtocolHandler\ServerSearchHandler( queue: $this->queue, backend: $this->handlerFactory->makeBackend(), - filterEvaluator: $this->handlerFactory->makeFilterEvaluator(), + filterEvaluator: $this->options->getFilterEvaluator(), accessControl: $this->options->getAccessControl(), schema: $this->options->getSchema(), limits: $this->options->makeSearchLimits(), @@ -151,7 +151,7 @@ private function getPagingHandler(): ServerProtocolHandler\ServerPagingHandler return new ServerProtocolHandler\ServerPagingHandler( queue: $this->queue, backend: $this->handlerFactory->makeBackend(), - filterEvaluator: $this->handlerFactory->makeFilterEvaluator(), + filterEvaluator: $this->options->getFilterEvaluator(), accessControl: $this->options->getAccessControl(), requestHistory: $this->requestHistory, schema: $this->options->getSchema(), diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php new file mode 100644 index 00000000..1281c50a --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Export; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Ldif\LdifWriter; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use Generator; + +/** + * Streams the entries of a writable storage backend as LDIF content-record chunks for backup/export. + * + * @author Chad Sikorra + */ +final readonly class DirectoryDumper +{ + /** + * @param string[] $namingContexts dump roots when DumpOptions::baseDn is not set + */ + public function __construct( + private WritableStorageBackend $backend, + private array $namingContexts, + private FilterEvaluatorInterface $filterEvaluator = new FilterEvaluator(), + private LdifWriter $writer = new LdifWriter(), + ) {} + + /** + * @return iterable + */ + public function dump(DumpOptions $options = new DumpOptions()): iterable + { + $header = $this->writer->versionHeader(); + + if ($header !== '') { + yield $header; + } + + $filter = $options->getFilter(); + + foreach ($this->resolveBases($options) as $base) { + yield from $this->streamNamingContext( + $base, + $filter, + ); + } + } + + /** + * @return list + */ + private function resolveBases(DumpOptions $options): array + { + if ($options->getBaseDn() !== null) { + return [$options->getBaseDn()]; + } + + return array_values(array_map( + fn(string $namingContext): Dn => new Dn($namingContext), + $this->namingContexts, + )); + } + + /** + * Stream the entries from a naming context to LDIF. + * + * @return Generator + */ + private function streamNamingContext( + Dn $base, + ?FilterInterface $filter, + ): Generator { + $listOptions = new StorageListOptions( + baseDn: $base, + subtree: true, + filter: $filter ?? new AndFilter(), + ); + $stream = $this->backend->getStorage()->list($listOptions); + + foreach ($stream->entries as $entry) { + if (!$stream->isPreFiltered && $filter !== null) { + if (!$this->filterEvaluator->evaluate($entry, $filter)) { + continue; + } + } + + yield $this->writer->writeOne(Operations::add($entry)); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DumpOptions.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DumpOptions.php new file mode 100644 index 00000000..a25b6c3d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DumpOptions.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Export; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Search\Filter\FilterInterface; + +/** + * Options for {@see DirectoryDumper}. Optional filter and subtree restriction. + * + * @author Chad Sikorra + */ +final class DumpOptions +{ + private ?FilterInterface $filter = null; + + private ?Dn $baseDn = null; + + public function getFilter(): ?FilterInterface + { + return $this->filter; + } + + public function setFilter(?FilterInterface $filter): self + { + $this->filter = $filter; + + return $this; + } + + public function getBaseDn(): ?Dn + { + return $this->baseDn; + } + + public function setBaseDn(?Dn $baseDn): self + { + $this->baseDn = $baseDn; + + return $this; + } +} diff --git a/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php b/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php index 8f4886e4..ac64231b 100644 --- a/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php +++ b/src/FreeDSx/Ldap/Server/HandlerFactoryInterface.php @@ -19,7 +19,6 @@ use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; /** * Responsible for instantiating classes needed by the core server logic. @@ -33,11 +32,6 @@ interface HandlerFactoryInterface */ public function makeBackend(): LdapBackendInterface; - /** - * Return the configured filter evaluator, or the default FilterEvaluator. - */ - public function makeFilterEvaluator(): FilterEvaluatorInterface; - /** * Return the optional root DSE handler, or null if not configured. */ diff --git a/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php b/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php index 0344d3e0..03a00487 100644 --- a/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php +++ b/src/FreeDSx/Ldap/Server/RequestHandler/HandlerFactory.php @@ -21,9 +21,7 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticator; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\HandlerFactoryInterface; @@ -47,14 +45,6 @@ public function makeBackend(): LdapBackendInterface return $this->options->getBackend() ?? new WritableStorageBackend(new InMemoryStorage()); } - /** - * @inheritDoc - */ - public function makeFilterEvaluator(): FilterEvaluatorInterface - { - return $this->options->getFilterEvaluator() ?? new FilterEvaluator($this->options->getSchema()); - } - /** * @inheritDoc */ diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 3b951a28..699d04b9 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -26,6 +26,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\QualityCheck\DefaultPasswordQualityChecker; use FreeDSx\Ldap\Server\PasswordPolicy\QualityCheck\PasswordQualityCheckerInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\AccessControl\AclRules; @@ -456,9 +457,9 @@ public function addWriteHandler(WriteHandlerInterface $handler): self return $this; } - public function getFilterEvaluator(): ?FilterEvaluatorInterface + public function getFilterEvaluator(): FilterEvaluatorInterface { - return $this->filterEvaluator; + return $this->filterEvaluator ??= new FilterEvaluator($this->getSchema()); } public function setFilterEvaluator(?FilterEvaluatorInterface $filterEvaluator): self diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index ac60bf26..3acbc968 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -179,7 +179,7 @@ public function testItCanRetrieveTheRootDSE(): void $this->assertSame( [ 'namingContexts' => [ - 'dc=FreeDSx,dc=local', + 'dc=foo,dc=bar', ], 'subschemaSubentry' => [ 'cn=Subschema', diff --git a/tests/integration/Ldif/LdapDumpServerTest.php b/tests/integration/Ldif/LdapDumpServerTest.php new file mode 100644 index 00000000..8402a7a3 --- /dev/null +++ b/tests/integration/Ldif/LdapDumpServerTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Ldif; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; +use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader; +use FreeDSx\Ldap\Ldif\LdifParser; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\ServerOptions; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; + +final class LdapDumpServerTest extends ServerTestCase +{ + private const SEED_LDIF = __DIR__ . '/../../resources/seed/seed-test.ldif'; + + private static string $dumpPath; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::$dumpPath = sys_get_temp_dir() . '/freedsx-ldap-dump-integration.ldif'; + + if (!extension_loaded('pcntl')) { + return; + } + + if (file_exists(self::$dumpPath)) { + unlink(self::$dumpPath); + } + + static::initSharedServer( + 'ldap-server', + 'tcp', + [ + '--storage=sqlite', + '--seed=' . self::SEED_LDIF, + '--dump=' . self::$dumpPath, + ], + ); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::tearDownSharedServer(); + + if (file_exists(self::$dumpPath)) { + unlink(self::$dumpPath); + } + } + + public function setUp(): void + { + $this->setServerMode('ldap-server'); + + parent::setUp(); + } + + public function test_the_dump_file_was_written_and_starts_with_the_version_header(): void + { + self::assertFileExists(self::$dumpPath); + $contents = (string) file_get_contents(self::$dumpPath); + self::assertStringStartsWith( + 'version: 1', + $contents, + ); + } + + public function test_the_dump_file_parses_into_the_seeded_entries(): void + { + $loader = new FileLdifLoader(self::$dumpPath); + $parsed = (new LdifParser())->parse($loader->load()); + + $dns = []; + foreach ($parsed->entries() as $entry) { + $dns[] = $entry->getDn()->toString(); + } + + self::assertContains( + 'dc=foo,dc=bar', + $dns, + ); + self::assertContains( + 'cn=user,dc=foo,dc=bar', + $dns, + ); + self::assertContains( + 'cn=alice,dc=foo,dc=bar', + $dns, + ); + self::assertContains( + 'cn=bob,dc=foo,dc=bar', + $dns, + ); + } + + 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'))) + ->useStorage($storage) + ->seed(new StringLdifLoader((string) file_get_contents(self::$dumpPath))); + + $alice = $storage->find(new Dn('cn=alice,dc=foo,dc=bar')); + self::assertNotNull($alice); + self::assertSame( + ['Anderson'], + $alice->get('sn')?->getValues(), + ); + + $bob = $storage->find(new Dn('cn=bob,dc=foo,dc=bar')); + self::assertNotNull($bob); + self::assertSame( + ['Builder'], + $bob->get('sn')?->getValues(), + ); + } + + public function test_the_dump_preserves_operational_attributes_for_round_trip(): void + { + $loader = new FileLdifLoader(self::$dumpPath); + $parsed = (new LdifParser())->parse($loader->load()); + + $alice = null; + foreach ($parsed->entries() as $entry) { + if ($entry->getDn()->toString() === 'cn=alice,dc=foo,dc=bar') { + $alice = $entry; + break; + } + } + + self::assertNotNull($alice); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $alice->get('entryUUID')?->firstValue() ?? '', + ); + self::assertMatchesRegularExpression( + '/^\d{14}Z$/', + $alice->get('createTimestamp')?->firstValue() ?? '', + ); + } +} diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index d4839e43..dd01d243 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -7,6 +7,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; +use FreeDSx\Ldap\Ldif\Output\FileLdifOutput; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; @@ -82,6 +83,13 @@ protected function configure(): void InputOption::VALUE_REQUIRED, 'After seeding, replay an LDIF changelog file via LdapServer::applyChanges()', '', + ) + ->addOption( + 'dump', + null, + InputOption::VALUE_REQUIRED, + 'After seeding/applying changes, dump the directory to an LDIF file via LdapServer::dump()', + '', ); } @@ -97,6 +105,7 @@ protected function execute( $allowAnonymous = $input->getOption('allow-anonymous') === true; $seedFile = $this->getStringOption($input, 'seed'); $changesFile = $this->getStringOption($input, 'changes'); + $dumpFile = $this->getStringOption($input, 'dump'); $useSsl = false; if (!in_array($storageType, self::VALID_STORAGE, true)) { @@ -134,6 +143,7 @@ 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)); @@ -159,6 +169,10 @@ protected function execute( $server->applyChanges(new FileLdifLoader($changesFile)); } + if ($dumpFile !== '') { + $server->dump(new FileLdifOutput($dumpFile)); + } + $server->run(); return Command::SUCCESS; diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index db91098c..f4c1d236 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -17,7 +17,10 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader; +use FreeDSx\Ldap\Ldif\Output\StringLdifOutput; +use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; @@ -313,4 +316,99 @@ public function test_it_should_throw_when_applying_changes_without_a_writable_ba $this->subject->applyChanges(new StringLdifLoader("dn: cn=x,dc=x\nchangetype: delete\n")); } + + public function test_it_should_throw_when_dumping_without_a_storage_backend(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires a storage backend'); + + $this->subject->dump(new StringLdifOutput()); + } + + public function test_it_should_dump_seeded_entries_to_the_given_output(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + $output = new StringLdifOutput(); + $this->subject->dump( + $output, + (new DumpOptions())->setBaseDn(new Dn('dc=example,dc=com')), + ); + + $ldif = $output->getLdif(); + self::assertStringStartsWith( + 'version: 1', + $ldif, + ); + self::assertStringContainsString( + 'dn: dc=example,dc=com', + $ldif, + ); + self::assertStringContainsString( + 'dn: cn=foo,dc=example,dc=com', + $ldif, + ); + } + + public function test_dump_seed_round_trip_preserves_entryUUID_and_create_timestamp(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + $originalFoo = $storage->find(new Dn('cn=foo,dc=example,dc=com')); + self::assertNotNull($originalFoo); + $originalUuid = $originalFoo->get('entryUUID')?->firstValue(); + $originalTimestamp = $originalFoo->get('createTimestamp')?->firstValue(); + self::assertNotNull($originalUuid); + self::assertNotNull($originalTimestamp); + + $output = new StringLdifOutput(); + $this->subject->dump( + $output, + (new DumpOptions())->setBaseDn(new Dn('dc=example,dc=com')), + ); + + $restoredStorage = new InMemoryStorage(); + (new LdapServer()) + ->useStorage($restoredStorage) + ->seed(new StringLdifLoader($output->getLdif())); + + $restoredFoo = $restoredStorage->find(new Dn('cn=foo,dc=example,dc=com')); + self::assertNotNull($restoredFoo); + self::assertSame( + $originalUuid, + $restoredFoo->get('entryUUID')?->firstValue(), + ); + self::assertSame( + $originalTimestamp, + $restoredFoo->get('createTimestamp')?->firstValue(), + ); + } + + public function test_it_should_apply_the_dump_options_filter(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + $output = new StringLdifOutput(); + $this->subject->dump( + $output, + (new DumpOptions()) + ->setBaseDn(new Dn('dc=example,dc=com')) + ->setFilter(Filters::equal('objectClass', 'person')), + ); + + self::assertStringContainsString( + 'cn=foo,dc=example,dc=com', + $output->getLdif(), + ); + self::assertStringNotContainsString( + 'dn: dc=example,dc=com', + $output->getLdif(), + ); + } } diff --git a/tests/unit/Ldif/Output/FileLdifOutputTest.php b/tests/unit/Ldif/Output/FileLdifOutputTest.php new file mode 100644 index 00000000..b902ad2d --- /dev/null +++ b/tests/unit/Ldif/Output/FileLdifOutputTest.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Ldif\Output; + +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Ldif\Output\FileLdifOutput; +use PHPUnit\Framework\TestCase; + +final class FileLdifOutputTest extends TestCase +{ + private string $path; + + protected function setUp(): void + { + $this->path = (string) tempnam(sys_get_temp_dir(), 'ldif-output-test-'); + } + + protected function tearDown(): void + { + if (file_exists($this->path)) { + unlink($this->path); + } + } + + public function test_it_writes_chunks_to_the_file_in_order(): void + { + (new FileLdifOutput($this->path))->write([ + "version: 1\n\n", + "dn: cn=a,dc=x\n", + "cn: a\n", + ]); + + self::assertSame( + "version: 1\n\ndn: cn=a,dc=x\ncn: a\n", + (string) file_get_contents($this->path), + ); + } + + public function test_it_consumes_a_generator(): void + { + $generator = (function (): iterable { + yield "dn: cn=a,dc=x\n"; + yield "cn: a\n"; + })(); + + (new FileLdifOutput($this->path))->write($generator); + + self::assertSame( + "dn: cn=a,dc=x\ncn: a\n", + (string) file_get_contents($this->path), + ); + } + + public function test_it_truncates_an_existing_file(): void + { + file_put_contents( + $this->path, + 'pre-existing content', + ); + + (new FileLdifOutput($this->path))->write(["dn: cn=fresh,dc=x\n"]); + + self::assertSame( + "dn: cn=fresh,dc=x\n", + (string) file_get_contents($this->path), + ); + } + + public function test_it_throws_when_the_file_cannot_be_opened(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to open'); + + (new FileLdifOutput('/nonexistent-dir/should-fail.ldif'))->write([]); + } +} diff --git a/tests/unit/Ldif/Output/StringLdifOutputTest.php b/tests/unit/Ldif/Output/StringLdifOutputTest.php new file mode 100644 index 00000000..46d6e746 --- /dev/null +++ b/tests/unit/Ldif/Output/StringLdifOutputTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Ldif\Output; + +use FreeDSx\Ldap\Ldif\Output\StringLdifOutput; +use PHPUnit\Framework\TestCase; + +final class StringLdifOutputTest extends TestCase +{ + public function test_it_concatenates_chunks_in_order(): void + { + $output = new StringLdifOutput(); + + $output->write([ + "version: 1\n\n", + "dn: cn=a,dc=x\n", + "cn: a\n", + ]); + + self::assertSame( + "version: 1\n\ndn: cn=a,dc=x\ncn: a\n", + $output->getLdif(), + ); + } + + public function test_stringable_returns_the_same_value_as_getLdif(): void + { + $output = new StringLdifOutput(); + $output->write(["dn: cn=a,dc=x\n"]); + + self::assertSame( + $output->getLdif(), + (string) $output, + ); + } + + public function test_repeated_writes_accumulate(): void + { + $output = new StringLdifOutput(); + $output->write(["first\n"]); + $output->write(["second\n"]); + + self::assertSame( + "first\nsecond\n", + $output->getLdif(), + ); + } + + public function test_empty_writes_produce_an_empty_string(): void + { + $output = new StringLdifOutput(); + $output->write([]); + + self::assertSame( + '', + $output->getLdif(), + ); + } +} diff --git a/tests/unit/Protocol/Factory/ProtocolHandlerProviderTest.php b/tests/unit/Protocol/Factory/ProtocolHandlerProviderTest.php index 762ebeee..469e2627 100644 --- a/tests/unit/Protocol/Factory/ProtocolHandlerProviderTest.php +++ b/tests/unit/Protocol/Factory/ProtocolHandlerProviderTest.php @@ -37,7 +37,6 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Clock\SystemClock; @@ -71,9 +70,6 @@ protected function setUp(): void $this->mockHandlerFactory ->method('makeBackend') ->willReturn($backend); - $this->mockHandlerFactory - ->method('makeFilterEvaluator') - ->willReturn(new FilterEvaluator()); $this->mockHandlerFactory ->method('makeWriteDispatcher') ->willReturn(new WriteOperationDispatcher()); diff --git a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php new file mode 100644 index 00000000..f05a5163 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php @@ -0,0 +1,244 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Export; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Ldif\LdifOutputOptions; +use FreeDSx\Ldap\Ldif\LdifWriter; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DirectoryDumper; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use PHPUnit\Framework\TestCase; + +final class DirectoryDumperTest extends TestCase +{ + public function test_it_yields_the_version_header_first_when_enabled(): void + { + $dumper = new DirectoryDumper( + $this->backendWithEntries(), + ['dc=foo,dc=bar'], + ); + + $chunks = iterator_to_array( + $dumper->dump(new DumpOptions()), + false, + ); + + self::assertSame( + "version: 1\n\n", + $chunks[0], + ); + } + + 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'], + writer: $writer, + ); + + $chunks = iterator_to_array( + $dumper->dump(new DumpOptions()), + false, + ); + + self::assertStringStartsWith( + 'dn: ', + $chunks[0], + ); + } + + public function test_it_iterates_entries_across_naming_contexts_when_no_base_is_set(): void + { + $dumper = new DirectoryDumper( + $this->backendWithEntries(), + ['dc=foo,dc=bar'], + writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), + ); + + $ldif = implode('', iterator_to_array( + $dumper->dump(new DumpOptions()), + false, + )); + + self::assertStringContainsString( + 'dn: dc=foo,dc=bar', + $ldif, + ); + self::assertStringContainsString( + 'dn: cn=alice,dc=foo,dc=bar', + $ldif, + ); + self::assertStringContainsString( + 'dn: cn=bob,dc=foo,dc=bar', + $ldif, + ); + } + + public function test_it_restricts_to_the_options_base_dn_when_set(): void + { + $dumper = new DirectoryDumper( + $this->backendWithEntries(), + ['dc=foo,dc=bar'], + writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), + ); + + $ldif = implode('', iterator_to_array( + $dumper->dump((new DumpOptions())->setBaseDn(new Dn('cn=alice,dc=foo,dc=bar'))), + false, + )); + + self::assertStringContainsString( + 'dn: cn=alice,dc=foo,dc=bar', + $ldif, + ); + self::assertStringNotContainsString( + 'dn: cn=bob,dc=foo,dc=bar', + $ldif, + ); + self::assertStringNotContainsString( + 'dn: dc=foo,dc=bar', + $ldif, + ); + } + + public function test_it_re_evaluates_the_filter_when_the_stream_is_not_preFiltered(): void + { + $alice = Entry::create( + 'cn=alice,dc=foo,dc=bar', + ['cn' => 'alice'], + ); + $bob = Entry::create( + 'cn=bob,dc=foo,dc=bar', + ['cn' => 'bob'], + ); + $storage = $this->createMock(EntryStorageInterface::class); + $storage->method('list')->willReturn(new EntryStream( + entries: (function () use ($alice, $bob): iterable { + yield $alice; + yield $bob; + })(), + isPreFiltered: false, + )); + $evaluator = $this->createMock(FilterEvaluatorInterface::class); + $evaluator->method('evaluate')->willReturnCallback( + fn(Entry $entry, FilterInterface $filter): bool + => $entry->getDn()->toString() === 'cn=alice,dc=foo,dc=bar', + ); + + $dumper = new DirectoryDumper( + new WritableStorageBackend($storage), + ['dc=foo,dc=bar'], + $evaluator, + new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), + ); + + $ldif = implode('', iterator_to_array( + $dumper->dump((new DumpOptions())->setFilter(Filters::equal('cn', 'alice'))), + false, + )); + + self::assertStringContainsString( + 'cn=alice,dc=foo,dc=bar', + $ldif, + ); + self::assertStringNotContainsString( + 'cn=bob,dc=foo,dc=bar', + $ldif, + ); + } + + public function test_it_does_not_re_evaluate_the_filter_when_the_stream_is_preFiltered(): void + { + $alice = Entry::create('cn=alice,dc=foo,dc=bar', ['cn' => 'alice']); + $storage = $this->createMock(EntryStorageInterface::class); + $storage->method('list')->willReturn(new EntryStream( + entries: (function () use ($alice): iterable { + yield $alice; + })(), + isPreFiltered: true, + )); + $evaluator = $this->createMock(FilterEvaluatorInterface::class); + $evaluator->expects(self::never())->method('evaluate'); + + $dumper = new DirectoryDumper( + new WritableStorageBackend($storage), + ['dc=foo,dc=bar'], + $evaluator, + new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), + ); + + iterator_to_array( + $dumper->dump((new DumpOptions())->setFilter(Filters::equal('cn', 'alice'))), + false, + ); + } + + public function test_it_passes_match_all_to_storage_when_no_filter_is_set(): void + { + $storage = $this->createMock(EntryStorageInterface::class); + $storage->expects(self::once()) + ->method('list') + ->with(self::callback( + fn(StorageListOptions $opts): bool + => $opts->subtree === true && $opts->baseDn->toString() === 'dc=foo,dc=bar', + )) + ->willReturn(new EntryStream( + entries: (function (): iterable { + yield from []; + })(), + )); + + $dumper = new DirectoryDumper( + new WritableStorageBackend($storage), + ['dc=foo,dc=bar'], + ); + + iterator_to_array( + $dumper->dump(new DumpOptions()), + false, + ); + } + + private function backendWithEntries(): WritableStorageBackend + { + return new WritableStorageBackend(new InMemoryStorage([ + new Entry( + new Dn('dc=foo,dc=bar'), + new Attribute('dc', 'foo'), + ), + new Entry( + new Dn('cn=alice,dc=foo,dc=bar'), + new Attribute('cn', 'alice'), + new Attribute('sn', 'Anderson'), + ), + new Entry( + new Dn('cn=bob,dc=foo,dc=bar'), + new Attribute('cn', 'bob'), + new Attribute('sn', 'Builder'), + ), + ])); + } +} diff --git a/tests/unit/Server/Backend/Storage/Export/DumpOptionsTest.php b/tests/unit/Server/Backend/Storage/Export/DumpOptionsTest.php new file mode 100644 index 00000000..02cf0b54 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Export/DumpOptionsTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Export; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions; +use PHPUnit\Framework\TestCase; + +final class DumpOptionsTest extends TestCase +{ + public function test_defaults_filter_and_base_dn_to_null(): void + { + $options = new DumpOptions(); + + self::assertNull($options->getFilter()); + self::assertNull($options->getBaseDn()); + } + + public function test_filter_round_trips_through_setter(): void + { + $filter = Filters::equal('objectClass', 'person'); + + $options = (new DumpOptions())->setFilter($filter); + + self::assertSame( + $filter, + $options->getFilter(), + ); + } + + public function test_base_dn_round_trips_through_setter(): void + { + $base = new Dn('ou=people,dc=foo,dc=bar'); + + $options = (new DumpOptions())->setBaseDn($base); + + self::assertSame( + $base, + $options->getBaseDn(), + ); + } + + public function test_setters_return_self_for_fluent_chaining(): void + { + $options = new DumpOptions(); + + self::assertSame( + $options, + $options->setFilter(null), + ); + self::assertSame( + $options, + $options->setBaseDn(null), + ); + } +} diff --git a/tests/unit/Server/RequestHandler/HandlerFactoryTest.php b/tests/unit/Server/RequestHandler/HandlerFactoryTest.php index ead70a68..e6ba5abd 100644 --- a/tests/unit/Server/RequestHandler/HandlerFactoryTest.php +++ b/tests/unit/Server/RequestHandler/HandlerFactoryTest.php @@ -21,8 +21,6 @@ use FreeDSx\Ldap\Server\RequestHandler\HandlerFactory; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -52,27 +50,6 @@ public function test_it_should_allow_a_backend_as_an_object(): void ); } - public function test_it_should_return_a_default_filter_evaluator_when_none_is_configured(): void - { - $this->subject = new HandlerFactory(new ServerOptions()); - - self::assertInstanceOf( - FilterEvaluator::class, - $this->subject->makeFilterEvaluator(), - ); - } - - public function test_it_should_allow_a_filter_evaluator_as_an_object(): void - { - $evaluator = $this->createMock(FilterEvaluatorInterface::class); - $this->subject = new HandlerFactory((new ServerOptions())->setFilterEvaluator($evaluator)); - - self::assertSame( - $evaluator, - $this->subject->makeFilterEvaluator(), - ); - } - public function test_it_should_allow_a_rootdse_handler_as_an_object(): void { $rootDseHandler = new ProxyHandler(new LdapClient()); diff --git a/tests/unit/Server/ServerProtocolFactoryTest.php b/tests/unit/Server/ServerProtocolFactoryTest.php index 0606d258..5a723b12 100644 --- a/tests/unit/Server/ServerProtocolFactoryTest.php +++ b/tests/unit/Server/ServerProtocolFactoryTest.php @@ -30,7 +30,6 @@ use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; use FreeDSx\Ldap\Server\ServerProtocolFactory; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\ServerOptions; use FreeDSx\Socket\Socket; use PHPUnit\Framework\MockObject\MockObject; @@ -53,10 +52,6 @@ protected function setUp(): void ->method('makeBackend') ->willReturn(new WritableStorageBackend(new InMemoryStorage())); - $this->mockHandlerFactory - ->method('makeFilterEvaluator') - ->willReturn(new FilterEvaluator()); - $options = new ServerOptions(); $writeDispatcher = new WriteOperationDispatcher(); diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index b14cabb8..0e5df3b9 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -24,6 +24,7 @@ use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; @@ -444,9 +445,12 @@ public function test_it_can_add_write_handlers(): void ); } - public function test_filter_evaluator_is_null_by_default(): void + public function test_filter_evaluator_defaults_to_a_filter_evaluator_instance(): void { - self::assertNull($this->subject->getFilterEvaluator()); + self::assertInstanceOf( + FilterEvaluator::class, + $this->subject->getFilterEvaluator(), + ); } public function test_it_can_set_filter_evaluator(): void