From 8ac68ca14d52533f6bd8f2aa10fa6b5aba6054fb Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Wed, 27 May 2026 21:31:56 -0400 Subject: [PATCH] Add an LdifParser based on my original one from LdapTools. Integrate it into the importer. --- .../Ldap/Exception/LdifParseException.php | 52 +++ src/FreeDSx/Ldap/LdapServer.php | 44 ++- src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php | 78 +++++ src/FreeDSx/Ldap/Ldif/LdifParser.php | 311 ++++++++++++++++++ src/FreeDSx/Ldap/Ldif/LdifWriter.php | 133 ++++++++ .../Ldap/Ldif/Loader/FileLdifLoader.php | 54 +++ .../Ldap/Ldif/Loader/LdifLoaderInterface.php | 27 ++ .../Ldap/Ldif/Loader/StringLdifLoader.php | 29 ++ .../Server/Backend/Storage/LdapImporter.php | 69 +++- .../Storage/OperationalAttributeGenerator.php | 71 ++++ .../Storage/WritableStorageBackend.php | 24 ++ tests/integration/Ldif/LdapSeedServerTest.php | 101 ++++++ tests/resources/seed/seed-test.ldif | 26 ++ tests/support/LdapServerCommand.php | 18 +- tests/unit/LdapServerTest.php | 80 +++++ tests/unit/Ldif/LdifParserTest.php | 154 +++++++++ tests/unit/Ldif/LdifWriterTest.php | 135 ++++++++ tests/unit/Ldif/Loader/FileLdifLoaderTest.php | 50 +++ .../Backend/Storage/LdapImporterTest.php | 117 +++++++ .../OperationalAttributeGeneratorTest.php | 115 +++++++ 20 files changed, 1680 insertions(+), 8 deletions(-) create mode 100644 src/FreeDSx/Ldap/Exception/LdifParseException.php create mode 100644 src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php create mode 100644 src/FreeDSx/Ldap/Ldif/LdifParser.php create mode 100644 src/FreeDSx/Ldap/Ldif/LdifWriter.php create mode 100644 src/FreeDSx/Ldap/Ldif/Loader/FileLdifLoader.php create mode 100644 src/FreeDSx/Ldap/Ldif/Loader/LdifLoaderInterface.php create mode 100644 src/FreeDSx/Ldap/Ldif/Loader/StringLdifLoader.php create mode 100644 tests/integration/Ldif/LdapSeedServerTest.php create mode 100644 tests/resources/seed/seed-test.ldif create mode 100644 tests/unit/Ldif/LdifParserTest.php create mode 100644 tests/unit/Ldif/LdifWriterTest.php create mode 100644 tests/unit/Ldif/Loader/FileLdifLoaderTest.php diff --git a/src/FreeDSx/Ldap/Exception/LdifParseException.php b/src/FreeDSx/Ldap/Exception/LdifParseException.php new file mode 100644 index 00000000..f8283c12 --- /dev/null +++ b/src/FreeDSx/Ldap/Exception/LdifParseException.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Exception; + +use Exception; + +/** + * Represents an issue encountered while parsing LDIF. + * + * @author Chad Sikorra + */ +class LdifParseException extends Exception +{ + public function __construct( + string $message, + private readonly int $lineNumber = 0, + private readonly ?string $sourceLine = null, + ) { + parent::__construct( + $lineNumber > 0 + ? sprintf('%s (LDIF line %d).', $message, $lineNumber) + : $message, + ); + } + + /** + * The 1-based LDIF line where parsing failed; 0 when not applicable. + */ + public function getLineNumber(): int + { + return $this->lineNumber; + } + + /** + * The raw LDIF line that triggered the error, if known. + */ + public function getSourceLine(): ?string + { + return $this->sourceLine; + } +} diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 67e27b3c..c02f8e72 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -13,6 +13,13 @@ namespace FreeDSx\Ldap; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Exception\LdifParseException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Ldif\LdifParser; +use FreeDSx\Ldap\Ldif\Loader\LdifLoaderInterface; use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; @@ -23,6 +30,7 @@ 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\LdapImporter; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; @@ -133,12 +141,46 @@ public function useStorage(EntryStorageInterface $storage): self return $this->useBackend(new WritableStorageBackend( storage: $storage, limits: $this->options->makeSearchLimits(), - namingContexts: $this->options->getDseNamingContexts(), validator: $this->buildSchemaValidator(), + namingContexts: $this->options->getDseNamingContexts(), operationalAttrs: new OperationalAttributeGenerator($schema), )); } + /** + * Convenience method to bulk-load LDIF entries into the storage configured via {@see useStorage()}. + * + * The LDIF source is pluggable via {@see LdifLoaderInterface} (a file, a string, a database, etc.). Entries are + * validated and stamped with operational attributes the same way the live write path would. + * + * @param Dn $creatorDn DN recorded as creatorsName/modifiersName on imported entries; defaults to the anonymous (empty) DN. + * @throws LdifParseException when the LDIF cannot be parsed + * @throws RuntimeException when no storage backend is configured (or the loader fails to load) + * @throws InvalidArgumentException when the creator DN is malformed or an entry's parent is missing + * @throws OperationException when an entry violates the schema and validation mode is strict + */ + public function seed( + LdifLoaderInterface $loader, + Dn $creatorDn = new Dn(''), + ): self { + $backend = $this->options->getBackend(); + + if (!$backend instanceof WritableStorageBackend) { + throw new RuntimeException('seed() requires a storage backend configured via useStorage().'); + } + + $entries = (new LdifParser()) + ->parse($loader->load()); + (new LdapImporter( + $backend->getStorage(), + $backend->getOperationalAttributeGenerator(), + $backend->getSchemaValidator(), + $creatorDn, + ))->importEntries($entries->toArray()); + + return $this; + } + private function buildSchemaValidator(): ?SchemaValidator { $mode = $this->options->getSchemaValidationMode(); diff --git a/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php b/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php new file mode 100644 index 00000000..319e68a2 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/LdifOutputOptions.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif; + +/** + * Output options for {@see LdifWriter}. + * + * @author Chad Sikorra + */ +final class LdifOutputOptions +{ + private bool $includeVersion = true; + + private bool $lineFolding = true; + + private int $maxLineLength = 76; + + private string $lineEnding = "\n"; + + public function isIncludeVersion(): bool + { + return $this->includeVersion; + } + + public function setIncludeVersion(bool $includeVersion): self + { + $this->includeVersion = $includeVersion; + + return $this; + } + + public function isLineFolding(): bool + { + return $this->lineFolding; + } + + public function setLineFolding(bool $lineFolding): self + { + $this->lineFolding = $lineFolding; + + return $this; + } + + public function getMaxLineLength(): int + { + return $this->maxLineLength; + } + + public function setMaxLineLength(int $maxLineLength): self + { + $this->maxLineLength = $maxLineLength; + + return $this; + } + + public function getLineEnding(): string + { + return $this->lineEnding; + } + + public function setLineEnding(string $lineEnding): self + { + $this->lineEnding = $lineEnding; + + return $this; + } +} diff --git a/src/FreeDSx/Ldap/Ldif/LdifParser.php b/src/FreeDSx/Ldap/Ldif/LdifParser.php new file mode 100644 index 00000000..b3417c3b --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/LdifParser.php @@ -0,0 +1,311 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif; + +use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\LdifParseException; + +use function base64_decode; +use function count; +use function explode; +use function ltrim; +use function str_replace; +use function strpos; +use function strtolower; +use function substr; + +/** + * Parses RFC 2849 LDIF content records (entries) into {@see Entries}. + * + * @author Chad Sikorra + */ +final class LdifParser +{ + private const COMMENT = '#'; + + private const SEPARATOR = ':'; + + private const URL_MARKER = '<'; + + private const DN = 'dn'; + + private const VERSION = 'version'; + + private const CHANGETYPE = 'changetype'; + + /** + * @var string[] + */ + private array $lines = []; + + private int $pos = 0; + + /** + * @var Entry[] + */ + private array $entries = []; + + /** + * @throws LdifParseException + */ + public function parse(string $ldif): Entries + { + $this->init($ldif); + + while (!$this->atEnd()) { + $this->parseLine(); + } + + return new Entries(...$this->entries); + } + + private function parseLine(): void + { + $line = $this->current(); + + if ($line === '') { + $this->pos++; + + return; + } + if ($this->isComment($line)) { + $this->skipComment(); + + return; + } + + $key = $this->keyOf($line); + + if ($key === self::DN) { + $this->entries[] = $this->parseEntry(); + } elseif ($key === self::VERSION) { + $this->assertVersion(); + } else { + throw $this->error('Expected a "dn:" line to begin an entry'); + } + } + + /** + * @throws LdifParseException + */ + private function parseEntry(): Entry + { + [, $dn] = $this->readDirective(); + + /** @var array $attributes */ + $attributes = []; + + while (!$this->atEnd()) { + $line = $this->current(); + + if ($line === '') { + break; + } + if ($this->isComment($line)) { + $this->skipComment(); + + continue; + } + if ($this->keyOf($line) === self::DN) { + break; + } + + $at = $this->pos; + [$attribute, $value] = $this->readDirective(); + + if (strtolower($attribute) === self::CHANGETYPE) { + throw $this->errorAt( + $at, + 'LDIF change records are not yet supported.', + ); + } + + $attributes[$attribute][] = $value; + } + + return Entry::create( + $dn, + $attributes, + ); + } + + /** + * Read the directive at the cursor, consuming any folded continuation lines. + * + * @return array{0: string, 1: string} + * @throws LdifParseException + */ + private function readDirective(): array + { + $at = $this->pos; + $line = $this->lines[$at]; + $colon = strpos($line, self::SEPARATOR); + + if ($colon === false || $colon === 0) { + throw $this->errorAt( + $at, + 'Expected a "name: value" directive', + ); + } + + $name = substr($line, 0, $colon); + $marker = $line[$colon + 1] ?? ''; + $this->pos++; + + if ($marker === self::SEPARATOR) { + $value = $this->decodeBase64( + $this->readFolded(ltrim( + substr($line, $colon + 2), + ' ', + )), + $at, + ); + } elseif ($marker === self::URL_MARKER) { + throw $this->errorAt( + $at, + 'URL-referenced values ("name:< url") are not yet supported', + ); + } else { + $value = $this->readFolded(ltrim( + substr($line, $colon + 1), + ' ', + )); + } + + return [$name, $value]; + } + + /** + * Append any continuation lines (those beginning with a single space) to the value. + */ + private function readFolded(string $value): string + { + while ($this->isAtContinuation()) { + $value .= substr($this->current(), 1); + $this->pos++; + } + + return $value; + } + + private function skipComment(): void + { + $this->pos++; + + while ($this->isAtContinuation()) { + $this->pos++; + } + } + + /** + * @throws LdifParseException + */ + private function assertVersion(): void + { + $at = $this->pos; + + if (count($this->entries) !== 0) { + throw $this->errorAt($at, 'The version directive must appear before any entries'); + } + + [, $version] = $this->readDirective(); + + if ($version !== '1') { + throw $this->errorAt($at, sprintf('Unsupported LDIF version "%s"', $version)); + } + } + + /** + * @throws LdifParseException + */ + private function decodeBase64( + string $raw, + int $at, + ): string { + $decoded = base64_decode($raw, true); + + if ($decoded === false) { + throw $this->errorAt( + $at, + 'A base64-encoded value is not valid', + ); + } + + return $decoded; + } + + private function keyOf(string $line): string + { + $colon = strpos($line, self::SEPARATOR); + + return $colon === false + ? '' + : strtolower(substr($line, 0, $colon)); + } + + private function isComment(string $line): bool + { + return ($line[0] ?? '') === self::COMMENT; + } + + /** + * A continuation line (RFC 2849 §2): a non-empty current line that begins with a single space. + */ + private function isAtContinuation(): bool + { + return !$this->atEnd() + && $this->current() !== '' + && $this->current()[0] === ' '; + } + + private function atEnd(): bool + { + return $this->pos >= count($this->lines); + } + + private function current(): string + { + return $this->lines[$this->pos]; + } + + private function error(string $message): LdifParseException + { + return $this->errorAt($this->pos, $message); + } + + private function errorAt( + int $at, + string $message, + ): LdifParseException { + return new LdifParseException( + $message, + $at + 1, + $this->lines[$at] ?? null, + ); + } + + private function init(string $ldif): void + { + $this->lines = explode( + "\n", + str_replace( + ["\r\n", "\r"], + "\n", + $ldif, + ), + ); + $this->pos = 0; + } +} diff --git a/src/FreeDSx/Ldap/Ldif/LdifWriter.php b/src/FreeDSx/Ldap/Ldif/LdifWriter.php new file mode 100644 index 00000000..60e80317 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/LdifWriter.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Entry; + +use function array_map; +use function array_merge; +use function array_values; +use function base64_encode; +use function implode; +use function max; +use function preg_match; +use function str_split; +use function strlen; +use function substr; + +/** + * Serializes entries to RFC 2849 LDIF content records. + * + * @author Chad Sikorra + */ +final readonly class LdifWriter +{ + public function __construct(private LdifOutputOptions $options = new LdifOutputOptions()) {} + + /** + * @param iterable $entries + */ + public function write(iterable $entries): string + { + $blocks = []; + + foreach ($entries as $entry) { + $blocks[] = $this->entryBlock($entry); + } + + $body = implode($this->options->getLineEnding(), $blocks); + + return $this->options->isIncludeVersion() + ? 'version: 1' . $this->options->getLineEnding() . $this->options->getLineEnding() . $body + : $body; + } + + private function entryBlock(Entry $entry): string + { + $lines = array_merge( + [$this->line('dn', $entry->getDn()->toString())], + ...array_map( + $this->attributeLines(...), + $entry->getAttributes(), + ), + ); + + return implode($this->options->getLineEnding(), $lines) . $this->options->getLineEnding(); + } + + /** + * @return list + */ + private function attributeLines(Attribute $attribute): array + { + return array_values(array_map( + fn(string $value): string => $this->line( + $attribute->getDescription(), + $value, + ), + $attribute->getValues(), + )); + } + + private function line( + string $attribute, + string $value, + ): string { + if ($value === '') { + return $attribute . ':'; + } + if ($this->needsBase64($value)) { + return $this->fold($attribute . ':: ' . base64_encode($value)); + } + + return $this->fold($attribute . ': ' . $value); + } + + /** + * A value is not SAFE-STRING (RFC 2849 §2) when it begins with a space, ':' or '<', ends with a space, or holds a + * NUL/CR/LF or any non-ASCII byte. + */ + private function needsBase64(string $value): bool + { + $first = $value[0]; + + if ($first === ' ' || $first === ':' || $first === '<') { + return true; + } + if ($value[strlen($value) - 1] === ' ') { + return true; + } + + return preg_match('/[^\x01-\x7F]|[\x0A\x0D]/', $value) === 1; + } + + private function fold(string $line): string + { + $maxLineLength = $this->options->getMaxLineLength(); + + if (!$this->options->isLineFolding() || strlen($line) <= $maxLineLength) { + return $line; + } + + $folded = substr($line, 0, $maxLineLength); + $continuationLength = max(1, $maxLineLength - 1); + + foreach (str_split(substr($line, $maxLineLength), $continuationLength) as $chunk) { + $folded .= $this->options->getLineEnding() . ' ' . $chunk; + } + + return $folded; + } +} diff --git a/src/FreeDSx/Ldap/Ldif/Loader/FileLdifLoader.php b/src/FreeDSx/Ldap/Ldif/Loader/FileLdifLoader.php new file mode 100644 index 00000000..48206afc --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Loader/FileLdifLoader.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Loader; + +use FreeDSx\Ldap\Exception\RuntimeException; + +use function file_get_contents; +use function is_file; +use function is_readable; + +/** + * Loads LDIF text from a file. + * + * @author Chad Sikorra + */ +final readonly class FileLdifLoader implements LdifLoaderInterface +{ + public function __construct(private string $file) {} + + /** + * @throws RuntimeException when the file is missing or unreadable + */ + public function load(): string + { + if (!is_file($this->file) || !is_readable($this->file)) { + throw new RuntimeException(sprintf( + 'The LDIF file "%s" is missing or not readable.', + $this->file, + )); + } + + $ldif = file_get_contents($this->file); + + if ($ldif === false) { + throw new RuntimeException(sprintf( + 'Unable to read the LDIF file "%s".', + $this->file, + )); + } + + return $ldif; + } +} diff --git a/src/FreeDSx/Ldap/Ldif/Loader/LdifLoaderInterface.php b/src/FreeDSx/Ldap/Ldif/Loader/LdifLoaderInterface.php new file mode 100644 index 00000000..ac28ea8a --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Loader/LdifLoaderInterface.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\Loader; + +/** + * Yields LDIF text from some source (file, string, database, etc.) for parsing. + * + * @author Chad Sikorra + */ +interface LdifLoaderInterface +{ + /** + * Return the LDIF text to be parsed. + */ + public function load(): string; +} diff --git a/src/FreeDSx/Ldap/Ldif/Loader/StringLdifLoader.php b/src/FreeDSx/Ldap/Ldif/Loader/StringLdifLoader.php new file mode 100644 index 00000000..95349709 --- /dev/null +++ b/src/FreeDSx/Ldap/Ldif/Loader/StringLdifLoader.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Ldif\Loader; + +/** + * Loads LDIF text held in memory as a string. + * + * @author Chad Sikorra + */ +final readonly class StringLdifLoader implements LdifLoaderInterface +{ + public function __construct(private string $ldif) {} + + public function load(): string + { + return $this->ldif; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/LdapImporter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/LdapImporter.php index babb0849..0e49179e 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/LdapImporter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/LdapImporter.php @@ -16,24 +16,33 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Schema\SchemaValidationMode; +use FreeDSx\Ldap\Schema\Validation\SchemaValidator; /** * Bulk-imports entries into an EntryStorageInterface under a single atomic write. * * @author Chad Sikorra */ -final class LdapImporter +final readonly class LdapImporter { public function __construct( - private readonly EntryStorageInterface $storage, - ) {} + private EntryStorageInterface $storage, + private OperationalAttributeGenerator $operationalAttrs = new OperationalAttributeGenerator(), + private ?SchemaValidator $validator = null, + private Dn $creatorDn = new Dn(''), + ) { + $this->assertValidCreatorDn($creatorDn); + } /** * Persist all entries in one atomic batch; no-op when the list is empty. * * @param Entry[] $entries - * @param bool $ignoreValidation when true, skips basic validation. only do this if you know what you're doing. + * @param bool $ignoreValidation when true, skips parent and schema checks. only do this if you know what you're doing. * @throws InvalidArgumentException when a non-top-level entry's parent is not present in storage yet + * @throws OperationException when an entry violates the schema and validation mode is strict */ public function importEntries( array $entries, @@ -50,9 +59,20 @@ public function importEntries( $this->storage->atomic(function (EntryStorageInterface $storage) use ($entries, $ignoreValidation): void { foreach ($entries as $entry) { if (!$ignoreValidation) { - $this->assertParentExists($storage, $entry->getDn()); + $this->assertParentExists( + $storage, + $entry->getDn(), + ); } + $this->validateForImport( + $entry, + $ignoreValidation, + ); + $this->operationalAttrs->applyForBulkLoad( + $entry, + $this->creatorDn->toString(), + ); $storage->store($entry); } }); @@ -95,4 +115,43 @@ private function assertParentExists( $dn->toString(), )); } + + /** + * Validates a bulk-loaded entry as a system-initiated add; only strict mode rejects violations. + * + * @throws OperationException + */ + private function validateForImport( + Entry $entry, + bool $ignoreValidation, + ): void { + $validator = $this->validator; + + if ($ignoreValidation || $validator === null) { + return; + } + if ($validator->mode() !== SchemaValidationMode::Strict) { + return; + } + + $validator->validateAdd( + $entry, + true, + ); + } + + /** + * @throws InvalidArgumentException when the creator DN is malformed + */ + private function assertValidCreatorDn(Dn $creatorDn): void + { + if (Dn::isValid($creatorDn)) { + return; + } + + throw new InvalidArgumentException(sprintf( + 'The import creator DN "%s" is not a valid DN.', + $creatorDn->toString(), + )); + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/OperationalAttributeGenerator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/OperationalAttributeGenerator.php index d32d486a..e4fa6e2c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/OperationalAttributeGenerator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/OperationalAttributeGenerator.php @@ -73,6 +73,44 @@ public function applyForAdd( } } + /** + * Fills server-managed attributes on a bulk-loaded entry, preserving any the source supplied; creator/modifier + * default to the given actor DN. + */ + public function applyForBulkLoad( + Entry $entry, + string $actorDn = '', + ): void { + $timestamp = $this->generateTimestamp(); + + $this->setIfMissing( + $entry, + AttributeTypeOid::NAME_ENTRY_UUID, + Uuid::v4(), + ); + $this->setIfMissing( + $entry, + AttributeTypeOid::NAME_CREATE_TIMESTAMP, + $timestamp, + ); + $this->setIfMissing( + $entry, + AttributeTypeOid::NAME_MODIFY_TIMESTAMP, + $timestamp, + ); + $this->setIfMissing( + $entry, + AttributeTypeOid::NAME_CREATORS_NAME, + $actorDn, + ); + $this->setIfMissing( + $entry, + AttributeTypeOid::NAME_MODIFIERS_NAME, + $actorDn, + ); + $this->applyStructuralObjectClass($entry); + } + /** * Updates modifyTimestamp and modifiersName only. */ @@ -95,6 +133,39 @@ private function generateTimestamp(): string return gmdate('YmdHis') . 'Z'; } + private function setIfMissing( + Entry $entry, + string $attribute, + string $value, + ): void { + if ($entry->has($attribute)) { + return; + } + + $entry->set( + $attribute, + $value, + ); + } + + private function applyStructuralObjectClass(Entry $entry): void + { + if ($entry->has(AttributeTypeOid::NAME_STRUCTURAL_OBJECT_CLASS)) { + return; + } + + $structuralOc = $this->resolveStructuralObjectClass($entry); + + if ($structuralOc === null) { + return; + } + + $entry->set( + AttributeTypeOid::NAME_STRUCTURAL_OBJECT_CLASS, + $structuralOc, + ); + } + private function resolveStructuralObjectClass(Entry $entry): ?string { if ($this->schema === null) { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 69514b15..2e845a98 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -86,6 +86,30 @@ public function reset(): void } } + /** + * The underlying entry storage, e.g. for bulk seeding via {@see LdapImporter}. + */ + public function getStorage(): EntryStorageInterface + { + return $this->storage; + } + + /** + * The operational-attribute generator, e.g. so a bulk import can stamp the same managed attributes. + */ + public function getOperationalAttributeGenerator(): OperationalAttributeGenerator + { + return $this->operationalAttrs; + } + + /** + * The schema validator (null when validation is off), e.g. so a bulk import can apply the same rules. + */ + public function getSchemaValidator(): ?SchemaValidator + { + return $this->validator; + } + public function get(Dn $dn): ?Entry { return $this->storage->find($dn->normalize()); diff --git a/tests/integration/Ldif/LdapSeedServerTest.php b/tests/integration/Ldif/LdapSeedServerTest.php new file mode 100644 index 00000000..e461c5ea --- /dev/null +++ b/tests/integration/Ldif/LdapSeedServerTest.php @@ -0,0 +1,101 @@ + + * + * 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\Operations; +use FreeDSx\Ldap\Search\Filters; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; + +final class LdapSeedServerTest extends ServerTestCase +{ + private const SEED_LDIF = __DIR__ . '/../../resources/seed/seed-test.ldif'; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer( + 'ldap-server', + 'tcp', + ['--seed=' . self::SEED_LDIF], + ); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::tearDownSharedServer(); + } + + public function setUp(): void + { + $this->setServerMode('ldap-server'); + + parent::setUp(); + + $this->authenticate(); + } + + public function test_it_serves_a_seeded_entry_with_its_attributes(): void + { + $alice = $this->ldapClient()->read('cn=alice,dc=foo,dc=bar'); + + $this->assertNotNull($alice); + $this->assertSame( + 'Anderson', + $alice->get('sn')?->firstValue(), + ); + } + + public function test_it_unfolds_a_folded_value_through_to_the_served_entry(): void + { + $alice = $this->ldapClient()->read('cn=alice,dc=foo,dc=bar'); + + $this->assertNotNull($alice); + $this->assertSame( + 'A folded description value that is intentionally longer than seventy-six characters so the parser must unfold it.', + $alice->get('description')?->firstValue(), + ); + } + + public function test_it_serves_all_seeded_entries_in_a_subtree_search(): void + { + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('objectClass', 'inetOrgPerson')) + ->base('dc=foo,dc=bar'), + ); + + $dns = []; + foreach ($entries as $entry) { + $dns[] = $entry->getDn()->toString(); + } + + $this->assertContains( + 'cn=alice,dc=foo,dc=bar', + $dns, + ); + $this->assertContains( + 'cn=bob,dc=foo,dc=bar', + $dns, + ); + $this->assertContains( + 'cn=user,dc=foo,dc=bar', + $dns, + ); + } +} diff --git a/tests/resources/seed/seed-test.ldif b/tests/resources/seed/seed-test.ldif new file mode 100644 index 00000000..8ddeebea --- /dev/null +++ b/tests/resources/seed/seed-test.ldif @@ -0,0 +1,26 @@ +version: 1 + +# Domain root +dn: dc=foo,dc=bar +objectClass: top +objectClass: domain +dc: foo + +# Bind user used by the integration test +dn: cn=user,dc=foo,dc=bar +objectClass: inetOrgPerson +cn: user +sn: User +userPassword: {SHA}jLIjfQZ5yojbZGTqxg2pY0VROWQ= + +dn: cn=alice,dc=foo,dc=bar +objectClass: inetOrgPerson +cn: alice +sn: Anderson +description: A folded description value that is intentionally + longer than seventy-six characters so the parser must unfold it. + +dn: cn=bob,dc=foo,dc=bar +objectClass: inetOrgPerson +cn: bob +sn: Builder diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 73d4f07d..2e28bf44 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -6,6 +6,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; @@ -67,6 +68,13 @@ protected function configure(): void null, InputOption::VALUE_NONE, 'Allow anonymous bind', + ) + ->addOption( + 'seed', + null, + InputOption::VALUE_REQUIRED, + 'Load directory data from an LDIF file via LdapServer::seed() instead of the built-in entries', + '', ); } @@ -80,6 +88,7 @@ protected function execute( $entryCount = (int) $this->getStringOption($input, 'entries'); $sasl = $input->getOption('sasl') === true; $allowAnonymous = $input->getOption('allow-anonymous') === true; + $seedFile = $this->getStringOption($input, 'seed'); $useSsl = false; if (!in_array($storageType, self::VALID_STORAGE, true)) { @@ -108,8 +117,6 @@ protected function execute( } $storage = $this->createStorage($storageType); - $importer = new LdapImporter($storage); - $importer->importEntries($entries); $options = (new ServerOptions()) ->setPort(10389) @@ -133,6 +140,13 @@ protected function execute( } $server->useStorage($storage); + + if ($seedFile !== '') { + $server->seed(new FileLdifLoader($seedFile)); + } else { + (new LdapImporter($storage))->importEntries($entries); + } + $server->run(); return Command::SUCCESS; diff --git a/tests/unit/LdapServerTest.php b/tests/unit/LdapServerTest.php index fe066818..8a823dd5 100644 --- a/tests/unit/LdapServerTest.php +++ b/tests/unit/LdapServerTest.php @@ -13,9 +13,13 @@ namespace Tests\Unit\FreeDSx\Ldap; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\LdapServer; +use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler; @@ -28,6 +32,19 @@ class LdapServerTest extends TestCase { + private const SEED_LDIF = <<<'LDIF' + dn: dc=example,dc=com + objectClass: top + objectClass: domain + dc: example + + dn: cn=foo,dc=example,dc=com + objectClass: top + objectClass: person + cn: foo + sn: Bar + LDIF; + private LdapServer $subject; private ServerOptions $options; @@ -185,4 +202,67 @@ public function test_it_should_make_a_proxy_server(): void self::assertNull($proxyOptions->getRootDseHandler()); } + + public function test_it_should_seed_entries_into_the_configured_storage(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + self::assertNotNull($storage->find(new Dn('dc=example,dc=com'))); + $foo = $storage->find(new Dn('cn=foo,dc=example,dc=com')); + self::assertNotNull($foo); + self::assertSame( + ['Bar'], + $foo->get('sn')?->getValues(), + ); + } + + public function test_it_should_stamp_operational_attributes_on_seeded_entries(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + + $foo = $storage->find(new Dn('cn=foo,dc=example,dc=com')); + + self::assertNotNull($foo); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $foo->get('entryUUID')?->getValues()[0] ?? '', + ); + self::assertSame( + 'person', + $foo->get('structuralObjectClass')?->getValues()[0], + ); + self::assertSame( + '', + $foo->get('creatorsName')?->getValues()[0], + ); + } + + public function test_it_should_record_the_creator_dn_when_seeding(): void + { + $storage = new InMemoryStorage(); + $this->subject->useStorage($storage); + + $this->subject->seed( + new StringLdifLoader(self::SEED_LDIF), + new Dn('cn=Importer,dc=example,dc=com'), + ); + + self::assertSame( + 'cn=Importer,dc=example,dc=com', + $storage->find(new Dn('cn=foo,dc=example,dc=com'))?->get('creatorsName')?->getValues()[0], + ); + } + + public function test_it_should_throw_when_seeding_without_a_storage_backend(): void + { + $this->expectException(RuntimeException::class); + + $this->subject->seed(new StringLdifLoader(self::SEED_LDIF)); + } } diff --git a/tests/unit/Ldif/LdifParserTest.php b/tests/unit/Ldif/LdifParserTest.php new file mode 100644 index 00000000..e39baaac --- /dev/null +++ b/tests/unit/Ldif/LdifParserTest.php @@ -0,0 +1,154 @@ + + * + * 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; + +use FreeDSx\Ldap\Exception\LdifParseException; +use FreeDSx\Ldap\Ldif\LdifParser; +use PHPUnit\Framework\TestCase; + +final class LdifParserTest extends TestCase +{ + private LdifParser $subject; + + protected function setUp(): void + { + $this->subject = new LdifParser(); + } + + public function test_it_parses_a_single_entry_with_multi_valued_attributes(): void + { + $entries = $this->subject->parse( + "dn: cn=foo,dc=example,dc=com\nobjectClass: top\nobjectClass: person\ncn: foo\nsn: Bar\n", + ); + + self::assertCount(1, $entries); + $entry = $entries->toArray()[0]; + self::assertSame('cn=foo,dc=example,dc=com', $entry->getDn()->toString()); + self::assertSame( + ['top', 'person'], + $entry->get('objectClass')?->getValues(), + ); + self::assertSame(['Bar'], $entry->get('sn')?->getValues()); + } + + public function test_it_parses_multiple_entries_separated_by_blank_lines(): void + { + $entries = $this->subject->parse( + "dn: cn=a,dc=x\ncn: a\n\ndn: cn=b,dc=x\ncn: b\n", + ); + + self::assertCount(2, $entries); + } + + public function test_it_unfolds_continued_lines(): void + { + $entry = $this->subject->parse( + "dn: cn=foo,dc=x\ndescription: this is a long\n description value\n", + )->toArray()[0]; + + self::assertSame( + ['this is a long description value'], + $entry->get('description')?->getValues(), + ); + } + + public function test_it_decodes_a_base64_value(): void + { + $entry = $this->subject->parse( + "dn: cn=foo,dc=x\ncn:: " . base64_encode('Bär') . "\n", + )->toArray()[0]; + + self::assertSame(['Bär'], $entry->get('cn')?->getValues()); + } + + public function test_it_decodes_a_base64_dn(): void + { + $entry = $this->subject->parse( + "dn:: " . base64_encode('cn=Bär,dc=x') . "\ncn: x\n", + )->toArray()[0]; + + self::assertSame('cn=Bär,dc=x', $entry->getDn()->toString()); + } + + public function test_it_skips_comments_including_folded_ones(): void + { + $entries = $this->subject->parse( + "# a top comment\ndn: cn=foo,dc=x\n# inline comment\n# folded\n more comment\ncn: foo\n", + ); + + self::assertCount(1, $entries); + self::assertSame(['foo'], $entries->toArray()[0]->get('cn')?->getValues()); + } + + public function test_it_accepts_a_version_one_header(): void + { + self::assertCount( + 1, + $this->subject->parse("version: 1\ndn: cn=foo,dc=x\ncn: foo\n"), + ); + } + + public function test_it_rejects_an_unsupported_version(): void + { + $this->expectException(LdifParseException::class); + + $this->subject->parse("version: 2\ndn: cn=foo,dc=x\ncn: foo\n"); + } + + public function test_it_rejects_a_version_after_an_entry(): void + { + $this->expectException(LdifParseException::class); + + $this->subject->parse("dn: cn=foo,dc=x\ncn: foo\n\nversion: 1\n"); + } + + public function test_it_rejects_change_records(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('change records'); + + $this->subject->parse("dn: cn=foo,dc=x\nchangetype: add\ncn: foo\n"); + } + + public function test_it_rejects_url_referenced_values(): void + { + $this->expectException(LdifParseException::class); + $this->expectExceptionMessage('URL-referenced'); + + $this->subject->parse("dn: cn=foo,dc=x\njpegPhoto:< file:///tmp/x.jpg\n"); + } + + public function test_it_reports_the_line_number_of_a_malformed_line(): void + { + try { + $this->subject->parse("dn: cn=foo,dc=x\ncn: foo\nthis-has-no-colon\n"); + self::fail('Expected an LdifParseException.'); + } catch (LdifParseException $e) { + self::assertSame(3, $e->getLineNumber()); + self::assertSame('this-has-no-colon', $e->getSourceLine()); + } + } + + public function test_it_parses_an_empty_value(): void + { + $entry = $this->subject->parse("dn: cn=foo,dc=x\ndescription:\n")->toArray()[0]; + + self::assertSame([''], $entry->get('description')?->getValues()); + } + + public function test_it_returns_no_entries_for_empty_input(): void + { + self::assertCount(0, $this->subject->parse('')); + } +} diff --git a/tests/unit/Ldif/LdifWriterTest.php b/tests/unit/Ldif/LdifWriterTest.php new file mode 100644 index 00000000..f84bb4ec --- /dev/null +++ b/tests/unit/Ldif/LdifWriterTest.php @@ -0,0 +1,135 @@ + + * + * 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; + +use FreeDSx\Ldap\Entry\Entries; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Ldif\LdifOutputOptions; +use FreeDSx\Ldap\Ldif\LdifParser; +use FreeDSx\Ldap\Ldif\LdifWriter; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class LdifWriterTest extends TestCase +{ + public function test_it_writes_an_entry_with_a_version_header(): void + { + $ldif = (new LdifWriter())->write([ + Entry::create('cn=foo,dc=x', ['cn' => 'foo', 'sn' => 'Bar']), + ]); + + self::assertStringStartsWith("version: 1\n\ndn: cn=foo,dc=x\n", $ldif); + self::assertStringContainsString("\ncn: foo\n", $ldif); + self::assertStringContainsString("\nsn: Bar\n", $ldif); + } + + public function test_it_omits_the_version_header_when_disabled(): void + { + $ldif = (new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)))->write([ + Entry::create('cn=foo,dc=x', ['cn' => 'foo']), + ]); + + self::assertStringStartsWith('dn: cn=foo,dc=x', $ldif); + } + + #[DataProvider('unsafeValues')] + public function test_it_base64_encodes_values_that_are_not_safe_strings(string $value): void + { + $ldif = (new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)))->write([ + Entry::create('cn=foo,dc=x', ['cn' => $value]), + ]); + + self::assertStringContainsString('cn:: ' . base64_encode($value), $ldif); + } + + /** + * @return array + */ + public static function unsafeValues(): array + { + return [ + 'leading space' => [' leading'], + 'trailing space' => ['trailing '], + 'leading colon' => [':colon'], + 'leading less-than' => [' ['Bär'], + 'embedded newline' => ["line1\nline2"], + ]; + } + + public function test_it_writes_a_safe_value_in_plain_form(): void + { + $ldif = (new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)))->write([ + Entry::create('cn=foo,dc=x', ['cn' => 'plainValue']), + ]); + + self::assertStringContainsString("cn: plainValue\n", $ldif); + } + + public function test_it_folds_lines_longer_than_the_max_length(): void + { + $options = (new LdifOutputOptions()) + ->setIncludeVersion(false) + ->setMaxLineLength(20); + $ldif = (new LdifWriter($options))->write([ + Entry::create('cn=foo,dc=x', ['description' => str_repeat('a', 60)]), + ]); + + self::assertStringContainsString("\n ", $ldif); + foreach (explode("\n", $ldif) as $line) { + self::assertLessThanOrEqual(20, strlen($line)); + } + } + + public function test_it_round_trips_with_the_parser(): void + { + $entries = new Entries( + Entry::create('cn=foo,dc=example,dc=com', [ + 'objectClass' => ['top', 'person'], + 'cn' => 'foo', + 'sn' => ['Bär', ' spaced '], + ]), + Entry::create('cn=baz,dc=example,dc=com', [ + 'cn' => 'baz', + 'description' => str_repeat('x', 200), + ]), + ); + + $ldif = (new LdifWriter())->write($entries); + $parsed = (new LdifParser())->parse($ldif); + + self::assertSame( + $this->normalize($entries), + $this->normalize($parsed), + ); + } + + /** + * @return array> + */ + private function normalize(Entries $entries): array + { + $out = []; + + foreach ($entries->toArray() as $entry) { + $attributes = []; + foreach ($entry->getAttributes() as $attribute) { + $attributes[$attribute->getDescription()] = $attribute->getValues(); + } + $out[$entry->getDn()->toString()] = $attributes; + } + + return $out; + } +} diff --git a/tests/unit/Ldif/Loader/FileLdifLoaderTest.php b/tests/unit/Ldif/Loader/FileLdifLoaderTest.php new file mode 100644 index 00000000..d4a5df20 --- /dev/null +++ b/tests/unit/Ldif/Loader/FileLdifLoaderTest.php @@ -0,0 +1,50 @@ + + * + * 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\Loader; + +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; +use PHPUnit\Framework\TestCase; + +final class FileLdifLoaderTest extends TestCase +{ + public function test_it_loads_the_file_contents(): void + { + $path = tempnam( + sys_get_temp_dir(), + 'ldif', + ); + self::assertIsString($path); + file_put_contents( + $path, + "dn: dc=x\ndc: x\n", + ); + + try { + self::assertSame( + "dn: dc=x\ndc: x\n", + (new FileLdifLoader($path))->load(), + ); + } finally { + unlink($path); + } + } + + public function test_it_throws_for_a_missing_file(): void + { + $this->expectException(RuntimeException::class); + + (new FileLdifLoader('/does/not/exist/seed.ldif'))->load(); + } +} diff --git a/tests/unit/Server/Backend/Storage/LdapImporterTest.php b/tests/unit/Server/Backend/Storage/LdapImporterTest.php index 4de1072e..a6dd85d7 100644 --- a/tests/unit/Server/Backend/Storage/LdapImporterTest.php +++ b/tests/unit/Server/Backend/Storage/LdapImporterTest.php @@ -17,9 +17,13 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Schema\SchemaValidationMode; +use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; +use FreeDSx\Ldap\ServerOptions; use PHPUnit\Framework\TestCase; final class LdapImporterTest extends TestCase @@ -118,4 +122,117 @@ public function test_importEntries_ignoreValidation_skips_parent_check(): void self::assertNotNull($storage->find(new Dn('cn=alice,dc=example,dc=com'))); } + + public function test_importEntries_stamps_operational_attributes_by_default(): void + { + $storage = new InMemoryStorage(); + + (new LdapImporter($storage))->importEntries([ + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + ]); + + $entry = $storage->find(new Dn('dc=example,dc=com')); + + self::assertNotNull($entry); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $entry->get('entryUUID')?->getValues()[0] ?? '', + ); + } + + public function test_importEntries_records_the_creator_dn_on_stamped_entries(): void + { + $storage = new InMemoryStorage(); + + (new LdapImporter( + $storage, + creatorDn: new Dn('cn=Importer,dc=example,dc=com'), + ))->importEntries([ + new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')), + ]); + + self::assertSame( + 'cn=Importer,dc=example,dc=com', + $storage->find(new Dn('dc=example,dc=com'))?->get('creatorsName')?->getValues()[0], + ); + } + + public function test_construction_throws_for_an_invalid_creator_dn(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('The import creator DN "not a dn" is not a valid DN.'); + + new LdapImporter( + new InMemoryStorage(), + creatorDn: new Dn('not a dn'), + ); + } + + public function test_importEntries_rejects_a_schema_violation_in_strict_mode(): void + { + $validator = new SchemaValidator( + (new ServerOptions())->getSchema(), + SchemaValidationMode::Strict, + ); + + self::expectException(OperationException::class); + + (new LdapImporter( + new InMemoryStorage(), + validator: $validator, + ))->importEntries([ + new Entry( + new Dn('dc=example,dc=com'), + new Attribute('cn', 'foo'), + new Attribute('objectClass', 'top', 'person'), + ), + ]); + } + + public function test_importEntries_allows_a_schema_violation_in_lenient_mode(): void + { + $storage = new InMemoryStorage(); + $validator = new SchemaValidator( + (new ServerOptions())->getSchema(), + SchemaValidationMode::Lenient, + ); + + (new LdapImporter( + $storage, + validator: $validator, + ))->importEntries([ + new Entry( + new Dn('dc=example,dc=com'), + new Attribute('cn', 'foo'), + new Attribute('objectClass', 'top', 'person'), + ), + ]); + + self::assertNotNull($storage->find(new Dn('dc=example,dc=com'))); + } + + public function test_importEntries_ignoreValidation_skips_schema_validation_in_strict_mode(): void + { + $storage = new InMemoryStorage(); + $validator = new SchemaValidator( + (new ServerOptions())->getSchema(), + SchemaValidationMode::Strict, + ); + + (new LdapImporter( + $storage, + validator: $validator, + ))->importEntries( + entries: [ + new Entry( + new Dn('dc=example,dc=com'), + new Attribute('cn', 'foo'), + new Attribute('objectClass', 'top', 'person'), + ), + ], + ignoreValidation: true, + ); + + self::assertNotNull($storage->find(new Dn('dc=example,dc=com'))); + } } diff --git a/tests/unit/Server/Backend/Storage/OperationalAttributeGeneratorTest.php b/tests/unit/Server/Backend/Storage/OperationalAttributeGeneratorTest.php index 71887eb4..e135d48b 100644 --- a/tests/unit/Server/Backend/Storage/OperationalAttributeGeneratorTest.php +++ b/tests/unit/Server/Backend/Storage/OperationalAttributeGeneratorTest.php @@ -330,4 +330,119 @@ public function test_timestamp_matches_generalized_time_format(): void 'Timestamp must be in generalized time format YYYYMMDDHHmmssZ.', ); } + + public function test_apply_for_bulk_load_fills_entry_uuid_when_missing(): void + { + $this->subject->applyForBulkLoad($this->entry); + + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $this->entry->get('entryUUID')?->getValues()[0] ?? '', + ); + } + + public function test_apply_for_bulk_load_fills_create_and_modify_timestamps_when_missing(): void + { + $this->subject->applyForBulkLoad($this->entry); + + self::assertMatchesRegularExpression( + '/^\d{14}Z$/', + $this->entry->get('createTimestamp')?->getValues()[0] ?? '', + ); + self::assertSame( + $this->entry->get('createTimestamp')?->getValues()[0], + $this->entry->get('modifyTimestamp')?->getValues()[0], + ); + } + + public function test_apply_for_bulk_load_defaults_creator_and_modifier_to_empty_string(): void + { + $this->subject->applyForBulkLoad($this->entry); + + self::assertSame( + '', + $this->entry->get('creatorsName')?->getValues()[0], + ); + self::assertSame( + '', + $this->entry->get('modifiersName')?->getValues()[0], + ); + } + + public function test_apply_for_bulk_load_attributes_creator_and_modifier_to_the_actor_dn(): void + { + $this->subject->applyForBulkLoad( + $this->entry, + 'cn=Importer,dc=example,dc=com', + ); + + self::assertSame( + 'cn=Importer,dc=example,dc=com', + $this->entry->get('creatorsName')?->getValues()[0], + ); + self::assertSame( + 'cn=Importer,dc=example,dc=com', + $this->entry->get('modifiersName')?->getValues()[0], + ); + } + + public function test_apply_for_bulk_load_preserves_operational_attributes_supplied_by_the_source(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('entryUUID', '11111111-2222-4333-8444-555555555555'), + new Attribute('createTimestamp', '19990101000000Z'), + new Attribute('creatorsName', 'cn=Original,dc=example,dc=com'), + ); + + $this->subject->applyForBulkLoad( + $entry, + 'cn=Importer,dc=example,dc=com', + ); + + self::assertSame( + '11111111-2222-4333-8444-555555555555', + $entry->get('entryUUID')?->getValues()[0], + ); + self::assertSame( + '19990101000000Z', + $entry->get('createTimestamp')?->getValues()[0], + ); + self::assertSame( + 'cn=Original,dc=example,dc=com', + $entry->get('creatorsName')?->getValues()[0], + ); + } + + public function test_apply_for_bulk_load_sets_structural_object_class_with_schema(): void + { + $top = new ObjectClass( + oid: '2.5.6.0', + names: ['top'], + type: ObjectClassType::AbstractClass, + ); + $person = new ObjectClass( + oid: '2.5.6.6', + names: ['person'], + superClassOids: ['2.5.6.0'], + ); + + $schema = (new Schema()) + ->addObjectClass($top) + ->addObjectClass($person); + + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('objectClass', 'top', 'person'), + ); + + (new OperationalAttributeGenerator($schema))->applyForBulkLoad($entry); + + self::assertSame( + 'person', + $entry->get('structuralObjectClass')?->getValues()[0], + ); + } }