diff --git a/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php index 31db50af..61748881 100644 --- a/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php @@ -33,6 +33,8 @@ use FreeDSx\Ldap\Server\Backend\Storage\Config\InMemoryStorageConfig; use FreeDSx\Ldap\Server\Backend\Storage\Config\JsonStorageConfig; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Export\DirectoryDumper; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestReplayer; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit\AuditingChangeJournal; @@ -99,11 +101,13 @@ public function factories(): array PasswordAuthenticatableInterface::class => $this->makePasswordAuthenticator(...), FilterEvaluatorInterface::class => $this->makeFilterEvaluator(...), EntryStorageInterface::class => $this->makeStorage(...), + DirectoryDumper::class => $this->makeDirectoryDumper(...), OperationalAttributeGenerator::class => $this->makeOperationalAttributeGenerator(...), SearchStreamBuilder::class => $this->makeSearchStreamBuilder(...), LdapImporter::class => $this->makeLdapImporter(...), PdoBackendBuilder::class => $this->makePdoBackendBuilder(...), WritableStorageBackend::class => $this->makeBackend(...), + WriteRequestReplayer::class => $this->makeWriteRequestReplayer(...), ServerProtocolFactory::class => $this->makeServerProtocolFactory(...), ServerProtocolFactoryInterface::class => static fn(Container $c): ServerProtocolFactoryInterface => $c->get(ServerProtocolFactory::class), ServerProtocolHandlerFactory::class => $this->makeServerProtocolHandlerFactory(...), @@ -166,6 +170,22 @@ private function makeFilterEvaluator(Container $container): FilterEvaluator return new FilterEvaluator($container->get(ServerOptions::class)->getSchema()); } + private function makeWriteRequestReplayer(Container $container): WriteRequestReplayer + { + return new WriteRequestReplayer($container->get(WritableStorageBackend::class)); + } + + private function makeDirectoryDumper(Container $container): DirectoryDumper + { + $storage = $container->get(EntryStorageInterface::class); + + return new DirectoryDumper( + $storage, + $storage->namingContexts(), + $container->get(FilterEvaluatorInterface::class), + ); + } + /** * Build the runner-appropriate storage backend from the configured StorageConfigInterface. */ @@ -290,6 +310,7 @@ private function makeBackend(Container $container): WritableStorageBackend $options->getSchema(), $options->makeSearchLimits(), ), + filterEvaluator: $container->get(FilterEvaluatorInterface::class), operationalAttrs: $container->get(OperationalAttributeGenerator::class), changeRecorder: $this->changeRecorderFor($container, $storage), ); diff --git a/src/FreeDSx/Ldap/Entry/Options.php b/src/FreeDSx/Ldap/Entry/Options.php index c124a819..0e562191 100644 --- a/src/FreeDSx/Ldap/Entry/Options.php +++ b/src/FreeDSx/Ldap/Entry/Options.php @@ -123,17 +123,20 @@ public function last(): ?Option */ public function toString(bool $sortedlc = false): string { - $opts = $this->options; - if ($sortedlc) { - sort($opts); - } + // Sorting the rendered options rather than the objects, so ordering follows the text and not their internals. + $options = array_map( + static fn(Option $option): string => $option->toString($sortedlc), + $this->options, + ); - $options = ''; - foreach ($opts as $option) { - $options .= ($options === '') ? $option->toString($sortedlc) : ';' . $option->toString($sortedlc); + if ($sortedlc) { + sort($options); } - return $options; + return implode( + ';', + $options, + ); } /** diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index 6e2cc95c..3b03f63a 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -23,12 +23,9 @@ use FreeDSx\Ldap\Ldif\Loader\LdifLoaderInterface; use FreeDSx\Ldap\Ldif\Output\LdifOutputInterface; use FreeDSx\Ldap\Operation\Request\AddRequest; -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\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; -use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\WriteRequestReplayer; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Socket\Exception\ConnectionException; @@ -105,9 +102,7 @@ public function seed( */ public function applyChanges(LdifLoaderInterface $loader): self { - $backend = $this->backend(); - - (new WriteRequestReplayer($backend)) + $this->container->get(WriteRequestReplayer::class) ->apply((new LdifParser())->parse($loader)); return $this; @@ -123,25 +118,13 @@ public function dump( LdifOutputInterface $output, DumpOptions $options = new DumpOptions(), ): self { - $storage = $this->container->get(EntryStorageInterface::class); - - $output->write((new DirectoryDumper( - $storage, - $storage->namingContexts(), - $this->container->get(FilterEvaluatorInterface::class), - ))->dump($options)); + $output->write( + $this->container->get(DirectoryDumper::class)->dump($options), + ); return $this; } - /** - * The assembled storage backend from the container. - */ - private function backend(): WritableStorageBackend - { - return $this->container->get(WritableStorageBackend::class); - } - /** * @return Generator * @throws RuntimeException when the LDIF contains a non-add change record diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php index def0a476..7a9e6e3d 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php @@ -83,29 +83,45 @@ public static function forRequest( public function project(Entry $entry): Entry { - $attributes = $entry->getAttributes(); - $filteredAttributes = []; - - if (!$this->returnNone) { - foreach ($attributes as $attribute) { - if (!$this->shouldInclude($attribute)) { - continue; - } - - $filteredAttributes[] = $this->typesOnly - ? new Attribute($attribute->getName()) - : $attribute; - } + if ($this->returnNone) { + return Entry::raw( + $entry->getDn(), + [], + ); } + $attributes = $entry->getAttributes(); + $selected = array_filter( + $attributes, + $this->shouldInclude(...), + ); + // Nothing was withheld, so the entry already is its own projection. - if (!$this->typesOnly && count($filteredAttributes) === count($attributes)) { + if (!$this->typesOnly && count($selected) === count($attributes)) { return $entry; } return Entry::raw( $entry->getDn(), - $filteredAttributes, + array_values( + $this->typesOnly + ? $this->withoutValues($selected) + : $selected, + ), + ); + } + + /** + * Options are part of the description a client asked for, so they survive a types-only request. + * + * @param array $attributes + * @return array + */ + private function withoutValues(array $attributes): array + { + return array_map( + static fn(Attribute $attribute): Attribute => new Attribute($attribute->getDescription()), + $attributes, ); } @@ -121,11 +137,37 @@ private function decideInclude(Attribute $attribute): bool return true; } + if ($this->isNamedType($attribute)) { + return true; + } + return $this->isOperational($attribute) ? $this->wantsOperational : $this->wantsUser; } + /** + * Whether a requested name asks for this attribute: its own type, or one it descends from. + * + * A type may be named by any of its names or its OID, and naming it asks for the values held under its options + * and its subtypes too (RFC 4511 4.5.1.8, RFC 4512 2.5.2). + */ + private function isNamedType(Attribute $attribute): bool + { + $type = Attribute::normalizeName($attribute->getDescription()); + + foreach ($this->names as $name) { + if ($name === $type) { + return true; + } + if ($this->schema->isTypeOrSubtypeOf($type, $name)) { + return true; + } + } + + return false; + } + private function isOperational(Attribute $attribute): bool { $key = strtolower($attribute->getName()); diff --git a/src/FreeDSx/Ldap/Schema/Matching/Comparator/IntegerComparator.php b/src/FreeDSx/Ldap/Schema/Matching/Comparator/IntegerComparator.php index bfa57696..c215ae7c 100644 --- a/src/FreeDSx/Ldap/Schema/Matching/Comparator/IntegerComparator.php +++ b/src/FreeDSx/Ldap/Schema/Matching/Comparator/IntegerComparator.php @@ -25,14 +25,25 @@ public function equals( string $a, string $b, ): bool { - return (int) $a === (int) $b; + $left = self::canonical($a); + $right = self::canonical($b); + + return $left !== null + && $left === $right; } public function compare( string $a, string $b, ): int { - return (int) $a <=> (int) $b; + $left = self::canonical($a); + $right = self::canonical($b); + + if ($left === null || $right === null) { + return strcmp($a, $b); + } + + return self::compareCanonical($left, $right); } public function substringMatches( @@ -41,4 +52,41 @@ public function substringMatches( ): bool { return false; } + + /** + * An integer reduced to one spelling, or null when the value is not one. + * + * Values can exceed the platform integer, so they stay as digits rather than being cast. + */ + private static function canonical(string $value): ?string + { + if (preg_match('/^[+-]?\d+$/', $value) !== 1) { + return null; + } + + $isNegative = $value[0] === '-'; + $digits = ltrim(ltrim($value, '+-'), '0'); + + if ($digits === '') { + return '0'; + } + + return ($isNegative ? '-' : '') . $digits; + } + + private static function compareCanonical( + string $a, + string $b, + ): int { + $aNegative = $a[0] === '-'; + $bNegative = $b[0] === '-'; + + if ($aNegative !== $bNegative) { + return $aNegative ? -1 : 1; + } + + $magnitude = strlen($a) <=> strlen($b) ?: strcmp($a, $b); + + return $aNegative ? -$magnitude : $magnitude; + } } diff --git a/src/FreeDSx/Ldap/Schema/Matching/Comparator/NumericStringComparator.php b/src/FreeDSx/Ldap/Schema/Matching/Comparator/NumericStringComparator.php index 7168507f..444b419c 100644 --- a/src/FreeDSx/Ldap/Schema/Matching/Comparator/NumericStringComparator.php +++ b/src/FreeDSx/Ldap/Schema/Matching/Comparator/NumericStringComparator.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Schema\Matching\Comparator; use FreeDSx\Ldap\Schema\Matching\MatchingRuleComparatorInterface; +use FreeDSx\Ldap\Schema\Matching\StringPrep; use FreeDSx\Ldap\Schema\Matching\SubstringAssertion; /** @@ -21,6 +22,10 @@ */ final class NumericStringComparator implements MatchingRuleComparatorInterface { + public function __construct( + private readonly StringPrep $prep = new StringPrep(), + ) {} + public function equals( string $a, string $b, @@ -54,12 +59,15 @@ public function substringMatches( ); } + /** + * RFC 4518 2.6.2: every space is insignificant, so the profile's space handling is simply undone here. + */ private function normalize(string $value): string { return str_replace( ' ', '', - $value, + $this->prep->prepareForEquality($value), ); } } diff --git a/src/FreeDSx/Ldap/Schema/Matching/Comparator/TelephoneNumberComparator.php b/src/FreeDSx/Ldap/Schema/Matching/Comparator/TelephoneNumberComparator.php index 819d1dac..d5aefbbb 100644 --- a/src/FreeDSx/Ldap/Schema/Matching/Comparator/TelephoneNumberComparator.php +++ b/src/FreeDSx/Ldap/Schema/Matching/Comparator/TelephoneNumberComparator.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Schema\Matching\Comparator; use FreeDSx\Ldap\Schema\Matching\MatchingRuleComparatorInterface; +use FreeDSx\Ldap\Schema\Matching\StringPrep; use FreeDSx\Ldap\Schema\Matching\SubstringAssertion; /** @@ -21,6 +22,24 @@ */ final class TelephoneNumberComparator implements MatchingRuleComparatorInterface { + /** + * The hyphens and the space RFC 4518 2.6.3 calls insignificant. + */ + private const INSIGNIFICANT = [ + ' ', + "\u{002D}", + "\u{058A}", + "\u{2010}", + "\u{2011}", + "\u{2212}", + "\u{FE63}", + "\u{FF0D}", + ]; + + public function __construct( + private readonly StringPrep $prep = new StringPrep(), + ) {} + public function equals( string $a, string $b, @@ -55,12 +74,15 @@ public function substringMatches( ); } + /** + * The preparation profile only rearranges spaces, which are removed here along with the hyphens. + */ private function normalize(string $value): string { - return strtolower(str_replace( - [' ', '-'], + return str_replace( + self::INSIGNIFICANT, '', - $value, - )); + $this->prep->prepareForEquality($value), + ); } } diff --git a/src/FreeDSx/Ldap/Schema/Schema.php b/src/FreeDSx/Ldap/Schema/Schema.php index 177b046d..f87b95df 100644 --- a/src/FreeDSx/Ldap/Schema/Schema.php +++ b/src/FreeDSx/Ldap/Schema/Schema.php @@ -41,6 +41,13 @@ final class Schema */ private array $attributeTypes = []; + /** + * Every OID named as a SUP, built on first use and dropped whenever the type set changes. + * + * @var array|null + */ + private ?array $superTypeOids = null; + /** * @var array */ @@ -62,6 +69,7 @@ public function addAttributeType(AttributeType $type): static foreach ($type->names as $name) { $this->attributeTypes[strtolower($name)] = $type; } + $this->superTypeOids = null; return $this; } @@ -118,6 +126,61 @@ public function isIntegerOrdered(string $nameOrOid): ?bool return $attributeType->syntaxOid === SyntaxOid::OID_INTEGER; } + /** + * Whether one attribute type is the other, or descends from it through the SUP chain. + * + * Either side may be given as an OID or any of the names the type is known by. + */ + public function isTypeOrSubtypeOf( + string $nameOrOid, + string $ofNameOrOid, + ): bool { + $type = $this->getAttributeType($nameOrOid); + $of = $this->getAttributeType($ofNameOrOid); + + if ($type === null || $of === null) { + return false; + } + + $seen = []; + while ($type !== null) { + if ($type->oid === $of->oid) { + return true; + } + if ($type->superTypeOid === null || isset($seen[$type->superTypeOid])) { + return false; + } + $seen[$type->superTypeOid] = true; + $type = $this->getAttributeType($type->superTypeOid); + } + + return false; + } + + /** + * Whether any other attribute type names this one as its SUP, so an assertion on it must also cover theirs. + */ + public function hasSubtypes(string $nameOrOid): bool + { + $attributeType = $this->getAttributeType($nameOrOid); + + if ($attributeType === null) { + return false; + } + + if ($this->superTypeOids === null) { + $oids = []; + foreach ($this->attributeTypes as $type) { + if ($type->superTypeOid !== null) { + $oids[$type->superTypeOid] = true; + } + } + $this->superTypeOids = $oids; + } + + return isset($this->superTypeOids[$attributeType->oid]); + } + /** * Whether the attribute matches without regard to case, or null when it is not defined here. */ diff --git a/src/FreeDSx/Ldap/Search/Filter/MatchingRuleFilter.php b/src/FreeDSx/Ldap/Search/Filter/MatchingRuleFilter.php index b5d3e4df..bb8ecc14 100644 --- a/src/FreeDSx/Ldap/Search/Filter/MatchingRuleFilter.php +++ b/src/FreeDSx/Ldap/Search/Filter/MatchingRuleFilter.php @@ -42,6 +42,18 @@ class MatchingRuleFilter implements FilterInterface, FilterAttributeInterface, S { protected const CHOICE_TAG = 9; + /** + * The universal type behind each context tag of the assertion, needed to decode them from BER. + */ + private const CHILD_TAG_MAP = [ + AbstractType::TAG_CLASS_CONTEXT_SPECIFIC => [ + 1 => AbstractType::TAG_TYPE_OCTET_STRING, + 2 => AbstractType::TAG_TYPE_OCTET_STRING, + 3 => AbstractType::TAG_TYPE_OCTET_STRING, + 4 => AbstractType::TAG_TYPE_BOOLEAN, + ], + ]; + private ?string $matchingRule; private ?string $attribute; @@ -151,16 +163,17 @@ public function toAsn1(): SequenceType */ public function toString(): string { + // RFC 4515 3: attr [":dn"] [":" matchingrule] ":=" value. $filter = ''; if ($this->attribute !== null) { $filter = $this->attribute; } - if ($this->matchingRule !== null) { - $filter .= ':' . $this->matchingRule; - } if ($this->useDnAttributes) { $filter .= ':dn'; } + if ($this->matchingRule !== null) { + $filter .= ':' . $this->matchingRule; + } return self::PAREN_LEFT . $filter @@ -177,7 +190,14 @@ public function toString(): string */ public static function fromAsn1(AbstractType $type): self { - $type = $type instanceof IncompleteType ? (new LdapEncoder())->complete($type, AbstractType::TAG_TYPE_SEQUENCE) : $type; + // The children are context tagged primitives, so their universal types must be supplied to decode them. + $type = $type instanceof IncompleteType + ? (new LdapEncoder())->complete( + $type, + AbstractType::TAG_TYPE_SEQUENCE, + self::CHILD_TAG_MAP, + ) + : $type; if (!($type instanceof SequenceType && (count($type) >= 1 && count($type) <= 4))) { throw new ProtocolException('The matching rule filter is malformed'); } diff --git a/src/FreeDSx/Ldap/Search/FilterParser.php b/src/FreeDSx/Ldap/Search/FilterParser.php index 88800d47..80897b1f 100644 --- a/src/FreeDSx/Ldap/Search/FilterParser.php +++ b/src/FreeDSx/Ldap/Search/FilterParser.php @@ -27,7 +27,10 @@ */ class FilterParser { - private const MATCHING_RULE = '/^([a-zA-Z0-9\.]+)?(\:dn)?(\:([a-zA-Z0-9\.]+))?$/'; + /** + * ABNF string literals are case insensitive (RFC 4234 2.3), so ":dn" may be given in any case. + */ + private const MATCHING_RULE = '/^([a-zA-Z0-9\.]+)?(\:dn)?(\:([a-zA-Z0-9\.]+))?$/i'; private string $filter; @@ -241,7 +244,8 @@ private function validateParsedFilter( $startsAt, )); } - if ($startValue === null || $startValue === $endAt - 1) { + // RFC 4515 3: valueencoding permits an empty assertion value, as in the "(seeAlso=)" example of section 4. + if ($startValue === null) { throw new FilterParseException(sprintf( 'Expected a value after "%s" at position %s, but got none.', $filterType, @@ -293,7 +297,8 @@ private function getMatchingRuleFilterObject( $matchingRule = $matches[4] ?? ''; $attrName = $matches[1] ?? ''; - $useDnAttr = isset($matches[2]); + // An optional group that did not take part still reports as set, holding an empty string. + $useDnAttr = ($matches[2] ?? '') !== ''; # RFC 4511, 4.5.1.7.7: If the matchingRule field is absent, the type field MUST be present [..] if ($matchingRule === '' && $attrName === '') { @@ -335,10 +340,11 @@ private function getSubstringFilterObject( $substringValue = $this->unescapeValue($substring[0]); $substringType = (int) $substring[1]; + // The offset and the length both have to be of the raw fragment for the end of the value to line up. if ($substringType === 0) { - $filter->setStartsWith($substring[0]); - } elseif (($substringType + strlen($substringValue)) === strlen($value)) { - $filter->setEndsWith($substring[0]); + $filter->setStartsWith($substringValue); + } elseif (($substringType + strlen($substring[0])) === strlen($value)) { + $filter->setEndsWith($substringValue); } else { $contains[] = $substringValue; } @@ -355,13 +361,8 @@ private function getNotFilterObject( int $startAt, int $endAt, ): FilterInterface { - if ($this->isAtFilterContainer($startAt + 2)) { - throw new FilterParseException(sprintf( - 'The "not" filter at position %s cannot contain multiple filters.', - $startAt, - )); - } - $info = $this->parseComparisonFilter($startAt + 2); + // RFC 4515 3: "not" negates any single filter, including a nested container. + $info = $this->parseFilterString($startAt + 2); if (($info[0] + 1) !== $endAt) { throw new FilterParseException(sprintf( 'The value after the "not" filter value was unexpected: %s', diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php index 70d9184a..3c9ab313 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectTrait.php @@ -203,8 +203,11 @@ public function sortedQuery( // key once into a derived table, then order by the materialised column (single evaluation per candidate). foreach ($sortKeys as $index => $sortKey) { $alias = '__sk' . $index; + $value = $sortKey->numeric + ? 'CAST(eav.value_lower AS SIGNED)' + : 'eav.value_lower'; $projections[] = << */ @@ -23,5 +23,6 @@ public function __construct( public string $attributeLower, public string $direction, + public bool $numeric = false, ) {} } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php index a4b6ca60..90f74427 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php @@ -110,8 +110,11 @@ public function sortedQuery( $nulls = $sortKey->direction === 'ASC' ? 'NULLS LAST' : 'NULLS FIRST'; + $value = $sortKey->numeric + ? 'CAST(eav.value_lower AS INTEGER)' + : 'eav.value_lower'; $terms[] = <<direction} {$nulls} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php index 64807e96..dd3bb0a8 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandler.php @@ -30,9 +30,12 @@ public function apply( Entry $entry, WriteRequestInterface $command, ): Entry { + // Work on a copy so a command rejected by later validation leaves the stored entry untouched. + $target = $entry->makeCopy(); + return match (true) { - $command instanceof UpdateCommand => (new UpdateOperation())->execute($entry, $command), - $command instanceof MoveCommand => (new MoveOperation())->execute($entry, $command), + $command instanceof UpdateCommand => (new UpdateOperation())->execute($target, $command), + $command instanceof MoveCommand => (new MoveOperation())->execute($target, $command), default => throw new LogicException( sprintf('No entry operation handler for %s', $command::class), ), diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Query/PdoListQueryBuilder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Query/PdoListQueryBuilder.php index 20977004..2aa7892f 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Query/PdoListQueryBuilder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Query/PdoListQueryBuilder.php @@ -13,7 +13,6 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Query; -use FreeDSx\Ldap\Control\Sorting\SortKey; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SortKeySpec; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterResult; @@ -34,7 +33,7 @@ public function __construct( ) {} /** - * @param SortKey[] $sortKeys + * @param list $sortKeys */ public function build( string $base, @@ -154,7 +153,7 @@ public function buildStreamingQuery( * A single drivable sidecar leaf under a bounded, unsorted subtree/root search drives off the sidecar index so the * limit short-circuits candidate scanning. * - * @param SortKey[] $sortKeys + * @param list $sortKeys */ private function tryBuildStreamingQuery( string $base, @@ -298,24 +297,16 @@ private function subentryCondition( } /** - * @param SortKey[] $sortKeys + * @param list $sortKeys */ private function applySort( SqlQuery $query, array $sortKeys, ): SqlQuery { - $specs = array_values(array_map( - static fn(SortKey $sortKey): SortKeySpec => new SortKeySpec( - strtolower($sortKey->getAttribute()), - $sortKey->getUseReverseOrder() ? 'DESC' : 'ASC', - ), - $sortKeys, - )); - $sorted = $this->dialect->sortedQuery( $query->sql, $query->params, - $specs, + $sortKeys, ); return new SqlQuery( diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php index 3cd82b06..887302aa 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -17,7 +17,9 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Control\Sorting\SortKey; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SortKeySpec; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Connection\PdoConnectionProviderInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\EntryIndexWriter; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Query\PdoListQueryBuilder; @@ -184,8 +186,7 @@ public function list(StorageListOptions $options): EntryStream $filterResult = $this->translator->translate( $options->filter, - $options->isIntegerOrdered(...), - $options->isCaseInsensitive(...), + $options, ); // A composed filter with a selective drivable leaf streams off that leaf; PHP re-evaluates the full filter. @@ -218,7 +219,7 @@ public function list(StorageListOptions $options): EntryStream $options->subtree, $filterResult, $sqlLimit, - $options->sortKeys, + $this->sortSpecs($options), $options->subentries, ); @@ -415,6 +416,23 @@ private function probeLeafSelectivity(SidecarLeaf $leaf): int /** * @return Generator */ + /** + * Resolves each sort key against the schema here, since the query layer has no view of it. + * + * @return list + */ + private function sortSpecs(StorageListOptions $options): array + { + return array_values(array_map( + static fn(SortKey $sortKey): SortKeySpec => new SortKeySpec( + strtolower($sortKey->getAttribute()), + $sortKey->getUseReverseOrder() ? 'DESC' : 'ASC', + $options->isIntegerOrdered($sortKey->getAttribute()) === true, + ), + $options->sortKeys, + )); + } + /** * @param list|null $attributes */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php index 0989007a..c0a10ea9 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/FilterTranslatorInterface.php @@ -14,7 +14,7 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; use FreeDSx\Ldap\Search\Filter\FilterInterface; -use Closure; +use FreeDSx\Ldap\Server\Backend\Storage\FilterAttributeContextInterface; /** * Translates FilterInterface into a SQL WHERE fragment. @@ -26,13 +26,8 @@ */ interface FilterTranslatorInterface { - /** - * @param (\Closure(string): (bool|null))|null $isIntegerOrdered Resolves whether an attribute orders numerically. - * @param (\Closure(string): (bool|null))|null $isCaseInsensitive Resolves whether an attribute matches without regard to case. - */ public function translate( FilterInterface $filter, - ?Closure $isIntegerOrdered = null, - ?Closure $isCaseInsensitive = null, + ?FilterAttributeContextInterface $attributeContext = null, ): ?SqlFilterResult; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterResult.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterResult.php index d8b7b42d..0cfe3da7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterResult.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterResult.php @@ -29,7 +29,6 @@ final class SqlFilterResult /** * @param list $params - * @param list $referencedAttributes Attributes whose absence makes the filter undefined under RFC 4511 * @param ?string $sidecarCondition Single drivable leaf's sidecar WHERE body for the streaming fast path. * @param list $drivableLeaves A composed filter's drivable child leaves, for composed-filter streaming. * @param ?string $correlatedSql Explicit correlated form for composites; leaves derive it from $sidecarCondition. @@ -38,7 +37,6 @@ public function __construct( public readonly string $sql, public readonly array $params, public readonly bool $isExact = true, - public readonly array $referencedAttributes = [], public readonly ?string $sidecarCondition = null, public readonly array $drivableLeaves = [], ?string $correlatedSql = null, diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php index d4b1f475..ca7efbef 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqlFilterTranslatorTrait.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\ApproximateFilter; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Search\Filter\FilterAttributeInterface; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filter\GreaterThanOrEqualFilter; use FreeDSx\Ldap\Search\Filter\LessThanOrEqualFilter; @@ -26,7 +27,8 @@ use FreeDSx\Ldap\Search\Filter\PresentFilter; use FreeDSx\Ldap\Search\Filter\SubstringFilter; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; -use Closure; +use FreeDSx\Ldap\Server\Backend\Storage\AttributeFilterSupport; +use FreeDSx\Ldap\Server\Backend\Storage\FilterAttributeContextInterface; /** * Translates LDAP filters to SQL against the `entry_attribute_values` sidecar index. @@ -38,32 +40,52 @@ trait SqlFilterTranslatorTrait private ?SubstringIndexInterface $substringIndex = null; /** - * @var (\Closure(string): (bool|null))|null Resolves whether an attribute orders numerically, for the current call. + * Schema derived answers for the current call, or null when the caller had no schema. */ - private ?Closure $integerOrderedResolver = null; + private ?FilterAttributeContextInterface $attributeContext = null; - /** - * @var (\Closure(string): (bool|null))|null Resolves whether an attribute ignores case, for the current call. - */ - private ?Closure $caseInsensitiveResolver = null; - - /** - * @param (\Closure(string): (bool|null))|null $isIntegerOrdered Resolves numeric ordering; null = unknown. - * @param (\Closure(string): (bool|null))|null $isCaseInsensitive Resolves case-insensitive matching; null = unknown. - */ public function translate( FilterInterface $filter, - ?Closure $isIntegerOrdered = null, - ?Closure $isCaseInsensitive = null, + ?FilterAttributeContextInterface $attributeContext = null, ): ?SqlFilterResult { - $this->integerOrderedResolver = $isIntegerOrdered; - $this->caseInsensitiveResolver = $isCaseInsensitive; + $this->attributeContext = $attributeContext; return $this->dispatch($filter); } + /** + * How faithfully SQL can answer an assertion on this attribute; Exact when the caller had no schema. + */ + private function filterSupport(string $attribute): AttributeFilterSupport + { + return $this->attributeContext?->filterSupport($attribute) + ?? AttributeFilterSupport::Exact; + } + private function dispatch(FilterInterface $filter): ?SqlFilterResult { + $attribute = $filter instanceof FilterAttributeInterface + ? $filter->getAttribute() + : null; + $support = $attribute !== null + ? $this->filterSupport($attribute) + : AttributeFilterSupport::Exact; + + // Rows under this name alone are not the whole answer, so leave the item to the evaluator. + if ($support === AttributeFilterSupport::NeedsEvaluator) { + return null; + } + + // Undefined for every entry, so on its own it selects nothing. It stays inexact because Undefined only + // behaves like false until a negation is layered over it, and SQL has no third value to carry that. + if ($support === AttributeFilterSupport::NeverMatches) { + return new SqlFilterResult( + '1 = 0', + [], + isExact: false, + ); + } + return match (true) { $filter instanceof AndFilter => $this->translateAnd($filter), $filter instanceof OrFilter => $this->translateOr($filter), @@ -134,13 +156,32 @@ private function translateEquality(EqualityFilter $filter): ?SqlFilterResult $alias = $this->valueAlias(); $value = $filter->getValue(); + // Stored text and the assertion can spell the same integer differently, so those compare as numbers. The + // cast saturates at the platform integer, making it a superset the evaluator still has to check. + if ($this->attributeContext?->isIntegerOrdered($filter->getAttribute()) === true) { + $condition = sprintf( + '%s = %s', + $this->castToNumeric($alias), + $this->castToNumeric('?'), + ); + + return new SqlFilterResult( + $this->buildValueExists($attribute, $condition), + [$this->prepareMatchValue($value)], + isExact: false, + sidecarCondition: $this->sidecarCondition( + $attribute, + $condition, + ), + ); + } + return new SqlFilterResult( $this->buildValueExists($attribute, "$alias = ?"), [$this->prepareMatchValue($value)], isExact: $this->isExactEquality($value) && $this->matchesCaseFolded($attribute) && !$this->attributeHasOption($filter->getAttribute()), - referencedAttributes: [$attribute], sidecarCondition: $this->sidecarCondition( $attribute, "$alias = ?", @@ -162,7 +203,6 @@ private function translateApproximate(ApproximateFilter $filter): ?SqlFilterResu isExact: $this->isExactEquality($value) && $this->matchesCaseFolded($attribute) && !$this->attributeHasOption($filter->getAttribute()), - referencedAttributes: [$attribute], sidecarCondition: $this->sidecarCondition( $attribute, "$alias = ?", @@ -208,9 +248,8 @@ private function translateOrdered( ): SqlFilterResult { $attribute = $this->validateAttribute($rawAttribute); $hasOption = $this->attributeHasOption($rawAttribute); - $resolver = $this->integerOrderedResolver; - if ($resolver !== null && $resolver($rawAttribute) === true) { + if ($this->attributeContext?->isIntegerOrdered($rawAttribute) === true) { $condition = sprintf( '%s %s %s', $this->castToNumeric($this->valueAlias()), @@ -222,7 +261,6 @@ private function translateOrdered( $this->buildValueExists($attribute, $condition), [$this->prepareMatchValue($value)], isExact: !$hasOption, - referencedAttributes: [$attribute], sidecarCondition: $this->sidecarCondition($attribute, $condition), ); } @@ -236,7 +274,6 @@ private function translateOrdered( && $this->isExactOrdered($value) && $this->matchesCaseFolded($attribute) && !$hasOption, - referencedAttributes: [$attribute], sidecarCondition: $this->sidecarCondition( $attribute, $condition, @@ -304,7 +341,6 @@ private function translateSubstring(SubstringFilter $filter): ?SqlFilterResult $sql, $params, isExact: $isExact, - referencedAttributes: [$attribute], sidecarCondition: $sidecar, ); } @@ -365,11 +401,7 @@ private function isExactEquality(string $value): bool */ private function matchesCaseFolded(string $attribute): bool { - if ($this->caseInsensitiveResolver === null) { - return true; - } - - return ($this->caseInsensitiveResolver)($attribute) ?? true; + return $this->attributeContext?->isCaseInsensitive($attribute) ?? true; } /** @@ -533,73 +565,19 @@ private function translateNot(NotFilter $filter): ?SqlFilterResult return null; } - // NOT(present) is the one negation that legitimately matches absent - // attributes, so no presence guard is needed. - if ($inner instanceof PresentFilter) { - return new SqlFilterResult( - 'NOT (' . $result->sql . ')', - $result->params, - isExact: $result->isExact, - correlatedSql: $result->correlatedSql !== null - ? 'NOT (' . $result->correlatedSql . ')' - : null, - ); - } - - // RFC 4511 §4.5.1.7: NOT(undefined) = undefined. SQL `NOT EXISTS(...)` - // returns TRUE for rows missing the attribute, so for value-bearing - // simple filters (those that populated referencedAttributes) we AND - // in a presence guard so missing-attribute rows are excluded. - if ($result->referencedAttributes !== []) { - $attributes = array_values(array_unique($result->referencedAttributes)); - $guards = array_map( - fn(string $attribute): string => $this->buildPresenceCheck($attribute), - $attributes, - ); - - return new SqlFilterResult( - '(NOT (' . $result->sql . ') AND ' . implode(' AND ', $guards) . ')', - $result->params, - isExact: $result->isExact, - correlatedSql: $result->correlatedSql !== null - ? '(NOT (' . $result->correlatedSql . ') AND ' . implode(' AND ', $this->correlatedGuards($attributes)) . ')' - : null, - ); - } - - // Composite inner (AND/OR/NOT): tracking three-valued logic precisely - // through SQL composition is fragile. The plain `NOT (...)` SQL is a - // SUPERSET of the correct LDAP result for missing-attribute rows, so - // marking it inexact lets the PHP FilterEvaluator strip false positives. + // An assertion the schema defines is false when the entry lacks the attribute, so negating it legitimately + // matches those rows and plain `NOT (...)` is precise. Types the schema does not define never reach here; + // dispatch() has already answered them. return new SqlFilterResult( 'NOT (' . $result->sql . ')', $result->params, - isExact: false, + isExact: $result->isExact, correlatedSql: $result->correlatedSql !== null ? 'NOT (' . $result->correlatedSql . ')' : null, ); } - /** - * Correlated `EXISTS` presence guards matching the IN-form presence guards for a NOT(value) filter. - * - * @param list $attributes - * @return list - */ - private function correlatedGuards(array $attributes): array - { - return array_map( - fn(string $attribute): string => SqlFilterResult::correlatedLeaf( - $this->sidecarCondition( - $attribute, - null, - ), - ), - $attributes, - ); - } - /** * Validates an LDAP attribute description against the RFC 4512 syntax: * diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php index 3f3cf8fc..dc918143 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php @@ -178,7 +178,6 @@ public function buildSubstringPredicate( self::MATCH_SQL, [$attributeLower, implode(' AND ', $terms)], isExact: false, - referencedAttributes: [$attributeLower], ); } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php index 3109989d..40a5a781 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php @@ -150,7 +150,6 @@ public function buildSubstringPredicate( ), [$attributeLower, ...$trigrams], isExact: false, - referencedAttributes: [$attributeLower], ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php index 9886b0f2..bc6e0963 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php @@ -76,7 +76,7 @@ private function sortedStreamFromArray( false, ); - yield from (new SortKeyComparator())->sort( + yield from (new SortKeyComparator($options->schema))->sort( $collected, $options->sortKeys, ); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/SortKeyComparator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/SortKeyComparator.php index 935d60f6..fed5dabc 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/SortKeyComparator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/SortKeyComparator.php @@ -15,6 +15,9 @@ use FreeDSx\Ldap\Control\Sorting\SortKey; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\Definition\MatchingRuleOid; +use FreeDSx\Ldap\Schema\Matching\MatchingRuleComparatorInterface; +use FreeDSx\Ldap\Schema\Schema; /** * Sorts a list of entries in-place by an ordered list of SortKeys. @@ -23,6 +26,8 @@ */ final class SortKeyComparator { + public function __construct(private readonly ?Schema $schema = null) {} + /** * Returns a new sorted array. * @@ -83,17 +88,48 @@ private function compareByKey( $b, $sortKey->getAttribute(), ), + $this->orderingFor($sortKey), ); return $sortKey->getUseReverseOrder() ? -$cmp : $cmp; } + /** + * The rule the key asks for, else the one its attribute declares; null when neither resolves. + */ + private function orderingFor(SortKey $sortKey): ?MatchingRuleComparatorInterface + { + $schema = $this->schema; + + if ($schema === null) { + return null; + } + + $rule = $sortKey->getOrderingRule(); + + if ($rule !== null) { + return $schema->getComparator($rule); + } + + $orderingOid = $schema->getAttributeType($sortKey->getAttribute())?->orderingOid; + + if ($orderingOid !== null) { + return $schema->getComparator($orderingOid); + } + + // A type can order numerically through its syntax alone, without naming an ordering rule. + return $schema->isIntegerOrdered($sortKey->getAttribute()) === true + ? $schema->getComparator(MatchingRuleOid::OID_INTEGER_ORDERING_MATCH) + : null; + } + /** * Compares two values treating NULL (a missing attribute) as the largest value, per RFC 2891 §2.2. */ private function rawCompare( ?string $aValue, ?string $bValue, + ?MatchingRuleComparatorInterface $ordering, ): int { if ($aValue === null && $bValue === null) { return 0; @@ -107,6 +143,13 @@ private function rawCompare( return -1; } + if ($ordering !== null) { + return $ordering->compare( + $aValue, + $bValue, + ); + } + return strcasecmp( $aValue, $bValue, diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/AttributeFilterSupport.php b/src/FreeDSx/Ldap/Server/Backend/Storage/AttributeFilterSupport.php new file mode 100644 index 00000000..2a25e228 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/AttributeFilterSupport.php @@ -0,0 +1,38 @@ + + * + * 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; + +/** + * How faithfully SQL can answer an assertion on an attribute, which only the schema can decide. + * + * @author Chad Sikorra + */ +enum AttributeFilterSupport +{ + /** + * The stored rows answer the assertion exactly. + */ + case Exact; + + /** + * The schema does not define the type, so every assertion on it is Undefined and no entry can match it, negated + * or not (RFC 4511 4.5.1.7). + */ + case NeverMatches; + + /** + * Other types name this one as their SUP, so the rows for this name alone are an incomplete answer. + */ + case NeedsEvaluator; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php index f7aa3fe1..83150c03 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Export/DirectoryDumper.php @@ -19,7 +19,6 @@ use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; use Generator; @@ -37,7 +36,7 @@ public function __construct( private EntryStorageInterface $storage, private array $namingContexts, - private FilterEvaluatorInterface $filterEvaluator = new FilterEvaluator(), + private FilterEvaluatorInterface $filterEvaluator, private LdifWriter $writer = new LdifWriter(), ) {} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php new file mode 100644 index 00000000..fb5a923f --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterAttributeContextInterface.php @@ -0,0 +1,37 @@ + + * + * 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; + +/** + * The schema derived facts a filter translator needs, without handing it the schema itself. + * + * @author Chad Sikorra + */ +interface FilterAttributeContextInterface +{ + /** + * Whether the attribute orders numerically, or null when it cannot be resolved. + */ + public function isIntegerOrdered(string $attribute): ?bool; + + /** + * Whether the attribute matches without regard to case, or null when it cannot be resolved. + */ + public function isCaseInsensitive(string $attribute): ?bool; + + /** + * How faithfully SQL alone can answer an assertion on the attribute. + */ + public function filterSupport(string $attribute): AttributeFilterSupport; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php index 4f597fbc..0331c39b 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/FilterEvaluator.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Schema\Definition\SyntaxOid; @@ -56,7 +57,7 @@ final class FilterEvaluator implements FilterEvaluatorInterface /** * Flattened RDN components of the current entry's DN; scoped to one evaluate() call. * - * @var array<\FreeDSx\Ldap\Entry\Rdn>|null + * @var array|null */ private ?array $cachedDnRdns = null; @@ -81,7 +82,7 @@ final class FilterEvaluator implements FilterEvaluatorInterface private readonly IntegerComparator $integerComparator; - public function __construct(private readonly ?Schema $schema = null) + public function __construct(private readonly Schema $schema) { $this->defaultComparator = new CaseIgnoreComparator(); $this->integerComparator = new IntegerComparator(); @@ -110,8 +111,8 @@ private function evaluateFilter( $filter instanceof PresentFilter => $this->evaluatePresent($entry, $filter), $filter instanceof EqualityFilter => $this->evaluateEquality($entry, $filter), $filter instanceof SubstringFilter => $this->evaluateSubstring($entry, $filter), - $filter instanceof GreaterThanOrEqualFilter => $this->evaluateGreaterOrEqual($entry, $filter), - $filter instanceof LessThanOrEqualFilter => $this->evaluateLessOrEqual($entry, $filter), + $filter instanceof GreaterThanOrEqualFilter, + $filter instanceof LessThanOrEqualFilter => $this->evaluateOrdered($entry, $filter), $filter instanceof ApproximateFilter => $this->evaluateApproximate($entry, $filter), $filter instanceof MatchingRuleFilter => $this->evaluateMatchingRule($entry, $filter), default => throw new OperationException( @@ -184,25 +185,29 @@ private function evaluatePresent( Entry $entry, PresentFilter $filter, ): FilterResult { - return $this->lookupAttribute($entry, $filter->getAttribute()) !== null + // RFC 4511 4.5.1.7.5: true when the type or a subtype of it is present. + return $this->valuesForAssertion($entry, $filter->getAttribute()) !== [] ? FilterResult::True - : FilterResult::False; + : $this->absentResult($filter->getAttribute()); } private function evaluateEquality( Entry $entry, EqualityFilter $filter, ): FilterResult { - $attribute = $this->lookupAttribute($entry, $filter->getAttribute()); + $values = $this->valuesForAssertion( + $entry, + $filter->getAttribute(), + ); - if ($attribute === null) { - return FilterResult::Undefined; + if ($values === []) { + return $this->absentResult($filter->getAttribute()); } $comparator = $this->resolveEqualityComparator($filter->getAttribute()); $filterValue = $filter->getValue(); - foreach ($attribute->getValues() as $value) { + foreach ($values as $value) { if ($comparator->equals($value, $filterValue)) { return FilterResult::True; } @@ -215,16 +220,16 @@ private function evaluateSubstring( Entry $entry, SubstringFilter $filter, ): FilterResult { - $attribute = $this->lookupAttribute($entry, $filter->getAttribute()); + $values = $this->valuesForAssertion($entry, $filter->getAttribute()); - if ($attribute === null) { - return FilterResult::Undefined; + if ($values === []) { + return $this->absentResult($filter->getAttribute()); } $comparator = $this->resolveSubstringComparator($filter->getAttribute()); $assertion = $this->buildSubstringAssertion($filter); - foreach ($attribute->getValues() as $value) { + foreach ($values as $value) { if ($comparator->substringMatches($value, $assertion)) { return FilterResult::True; } @@ -233,51 +238,32 @@ private function evaluateSubstring( return FilterResult::False; } - private function evaluateGreaterOrEqual( - Entry $entry, - GreaterThanOrEqualFilter $filter, - ): FilterResult { - $attribute = $this->lookupAttribute($entry, $filter->getAttribute()); - - if ($attribute === null) { - return FilterResult::Undefined; - } - - $filterValue = $filter->getValue(); - $comparator = $this->resolveOrderingComparator($filter->getAttribute()); - $filterIsDigit = $comparator === null && $this->orderedFilterValueIsDigit($filter); - - foreach ($attribute->getValues() as $value) { - $cmp = $comparator?->compare($value, $filterValue) - ?? $this->compareOrdered($value, $filterValue, $filterIsDigit); - - if ($cmp >= 0) { - return FilterResult::True; - } - } - - return FilterResult::False; - } - - private function evaluateLessOrEqual( + /** + * Both ordered filters agree on everything but the direction of the final comparison. + */ + private function evaluateOrdered( Entry $entry, - LessThanOrEqualFilter $filter, + GreaterThanOrEqualFilter|LessThanOrEqualFilter $filter, ): FilterResult { - $attribute = $this->lookupAttribute($entry, $filter->getAttribute()); + $values = $this->valuesForAssertion( + $entry, + $filter->getAttribute(), + ); - if ($attribute === null) { - return FilterResult::Undefined; + if ($values === []) { + return $this->absentResult($filter->getAttribute()); } $filterValue = $filter->getValue(); $comparator = $this->resolveOrderingComparator($filter->getAttribute()); $filterIsDigit = $comparator === null && $this->orderedFilterValueIsDigit($filter); + $atLeast = $filter instanceof GreaterThanOrEqualFilter; - foreach ($attribute->getValues() as $value) { + foreach ($values as $value) { $cmp = $comparator?->compare($value, $filterValue) ?? $this->compareOrdered($value, $filterValue, $filterIsDigit); - if ($cmp <= 0) { + if ($atLeast ? $cmp >= 0 : $cmp <= 0) { return FilterResult::True; } } @@ -301,16 +287,19 @@ private function evaluateApproximate( Entry $entry, ApproximateFilter $filter, ): FilterResult { - $attribute = $this->lookupAttribute($entry, $filter->getAttribute()); + $values = $this->valuesForAssertion($entry, $filter->getAttribute()); - if ($attribute === null) { - return FilterResult::Undefined; + if ($values === []) { + return $this->absentResult($filter->getAttribute()); } + // No approximate rule is implemented, so this falls back to the type's own equality rather than a case + // insensitive default, which would answer differently than the equality filter for the same assertion. + $comparator = $this->resolveEqualityComparator($filter->getAttribute()); $filterValue = $filter->getValue(); - foreach ($attribute->getValues() as $value) { - if ($this->defaultComparator->equals($value, $filterValue)) { + foreach ($values as $value) { + if ($comparator->equals($value, $filterValue)) { return FilterResult::True; } } @@ -322,15 +311,25 @@ private function evaluateMatchingRule( Entry $entry, MatchingRuleFilter $filter, ): FilterResult { + // RFC 4511 4.5.1.7: an unrecognized rule makes the item Undefined, and a server must not answer with an error. + $matcher = $this->resolveRuleMatcher( + $filter->getMatchingRule(), + $filter->getAttribute(), + ); + + if ($matcher === null) { + return FilterResult::Undefined; + } + $filterValue = $filter->getValue(); $values = $this->collectValuesToTest($entry, $filter); if ($values === []) { - return FilterResult::Undefined; + return FilterResult::False; } foreach ($values as $value) { - if ($this->matchByRule($filter->getMatchingRule(), $value, $filterValue)) { + if ($matcher($value, $filterValue)) { return FilterResult::True; } } @@ -396,50 +395,51 @@ private function collectDnValues( ); } + // RDN values carry their escaping, which is a property of the DN string rather than of the value asserted. return array_map( - fn($component) => $component->getValue(), + fn($component) => Rdn::unescape($component->getValue()), $components, ); } - private function matchByRule( + /** + * Resolve the assertion for a rule or null when the rule cannot be applied, so the item is Undefined. + * + * @return null|callable(string, string): bool + */ + private function resolveRuleMatcher( ?string $rule, - string $value, - string $filterValue, - ): bool { + ?string $attribute, + ): ?callable { + // RFC 4511 4.5.1.7.7: an absent matchingRule means the EQUALITY rule of the attribute type. if ($rule === null) { - return $this->defaultComparator->equals( - $value, - $filterValue, - ); + $comparator = $attribute !== null + ? $this->resolveEqualityComparator($attribute) + : $this->defaultComparator; + + return $comparator->equals(...); } - $schemaComparator = $this->schema?->getComparator($rule); + $schemaComparator = $this->schema->getComparator($rule); if ($schemaComparator !== null) { - return $schemaComparator->equals( - $value, - $filterValue, - ); + return $schemaComparator->equals(...); } return match ($rule) { - self::MATCHING_RULE_CASE_IGNORE => strtolower($value) === strtolower($filterValue), - self::MATCHING_RULE_CASE_EXACT => $value === $filterValue, - self::MATCHING_RULE_BIT_AND => ((int) $value & (int) $filterValue) === (int) $filterValue, - self::MATCHING_RULE_BIT_OR => ((int) $value & (int) $filterValue) !== 0, - default => throw new OperationException( - sprintf('Unsupported matching rule: %s', $rule), - ResultCode::INAPPROPRIATE_MATCHING, - ), + self::MATCHING_RULE_CASE_IGNORE => static fn(string $v, string $a): bool + => strtolower($v) === strtolower($a), + self::MATCHING_RULE_CASE_EXACT => static fn(string $v, string $a): bool + => $v === $a, + self::MATCHING_RULE_BIT_AND => static fn(string $v, string $a): bool + => ((int) $v & (int) $a) === (int) $a, + self::MATCHING_RULE_BIT_OR => static fn(string $v, string $a): bool + => ((int) $v & (int) $a) !== 0, + default => null, }; } private function resolveEqualityComparator(string $attrName): MatchingRuleComparatorInterface { - if ($this->schema === null) { - return $this->defaultComparator; - } - $attrType = $this->schema->getAttributeType($attrName); $comparator = $attrType?->equalityOid !== null ? $this->schema->getComparator($attrType->equalityOid) @@ -450,10 +450,6 @@ private function resolveEqualityComparator(string $attrName): MatchingRuleCompar private function resolveSubstringComparator(string $attrName): MatchingRuleComparatorInterface { - if ($this->schema === null) { - return $this->defaultComparator; - } - $attrType = $this->schema->getAttributeType($attrName); $comparator = $attrType?->substringOid !== null ? $this->schema->getComparator($attrType->substringOid) @@ -463,14 +459,10 @@ private function resolveSubstringComparator(string $attrName): MatchingRuleCompa } /** - * Returns null when schema is unavailable or the attribute is unknown — caller falls back to the digit heuristic. + * Returns null when the attribute is unknown, so the caller falls back to the digit heuristic. */ private function resolveOrderingComparator(string $attrName): ?MatchingRuleComparatorInterface { - if ($this->schema === null) { - return null; - } - $attrType = $this->schema->getAttributeType($attrName); if ($attrType === null) { @@ -502,13 +494,21 @@ private function buildSubstringAssertion(SubstringFilter $filter): SubstringAsse } /** - * Defers to Entry::get() when the filter attribute has options, to preserve Attribute::equals() options-matching. + * Whether a description carries options, without paying for an Attribute to ask Attribute::hasOptions(). + */ + private static function descriptionHasOptions(string $attributeDescription): bool + { + return str_contains($attributeDescription, ';'); + } + + /** + * Defers to Entry::get() when the filter attribute has options to preserve Attribute::equals() options-matching. */ private function lookupAttribute( Entry $entry, string $filterAttributeName, ): ?Attribute { - if (str_contains($filterAttributeName, ';')) { + if (self::descriptionHasOptions($filterAttributeName)) { return $entry->get($filterAttributeName); } @@ -524,6 +524,61 @@ private function lookupAttribute( return $this->attributeIndex[strtolower($filterAttributeName)] ?? null; } + /** + * Every value an assertion on this description must be tested against. + * + * RFC 4512 2.5.2 makes an assertion on an attribute type cover its tagged variants and its subtypes. + * + * @return array + */ + private function valuesForAssertion( + Entry $entry, + string $filterAttributeName, + ): array { + if (self::descriptionHasOptions($filterAttributeName)) { + return $entry->get($filterAttributeName)?->getValues() ?? []; + } + + $wanted = strtolower($filterAttributeName); + $values = []; + + foreach ($entry->getAttributes() as $attribute) { + if ($this->describesSameType($attribute->getName(), $wanted)) { + array_push($values, ...$attribute->getValues()); + } + } + + return $values; + } + + /** + * Whether an entry attribute is the wanted type, or a subtype of it through the SUP chain. + */ + private function describesSameType( + string $entryAttributeName, + string $wantedLowerName, + ): bool { + if (strtolower($entryAttributeName) === $wantedLowerName) { + return true; + } + + return $this->schema->isTypeOrSubtypeOf( + $entryAttributeName, + $wantedLowerName, + ); + } + + /** + * An item is Undefined only when the server cannot determine an answer, which for an attribute description means + * the schema does not define it (RFC 4511 4.5.1.7). A defined type the entry simply lacks is False. + */ + private function absentResult(string $filterAttributeName): FilterResult + { + return $this->schema->getAttributeType(Attribute::normalizeName($filterAttributeName)) === null + ? FilterResult::Undefined + : FilterResult::False; + } + private function orderedFilterValueIsDigit( GreaterThanOrEqualFilter|LessThanOrEqualFilter $filter, ): bool { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php index c73a6c59..35c8c29d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; use FreeDSx\Ldap\Server\SearchLimits; @@ -79,6 +80,10 @@ public function buildForList( $generator = $this->wrapWithHasSubordinates($generator); } + if ($this->requestsEntryDn($request)) { + $generator = $this->wrapWithEntryDn($generator); + } + return new EntryStream( $generator, true, @@ -102,6 +107,49 @@ private function isHasSubordinatesAttribute(Attribute $attr): bool || strcasecmp($attr->getName(), 'hasSubordinates') === 0; } + private function requestsEntryDn(SearchRequest $request): bool + { + foreach ($request->getAttributes() as $attr) { + if ($this->isEntryDnAttribute($attr)) { + return true; + } + } + + return false; + } + + private function isEntryDnAttribute(Attribute $attr): bool + { + return strcasecmp($attr->getName(), '+') === 0 + || strcasecmp($attr->getName(), AttributeTypeOid::NAME_ENTRY_DN) === 0 + || $attr->getName() === AttributeTypeOid::OID_ENTRY_DN; + } + + /** + * @param Generator $generator + * @return Generator + */ + private function wrapWithEntryDn(Generator $generator): Generator + { + foreach ($generator as $entry) { + yield $this->injectEntryDn($entry); + } + } + + /** + * RFC 5020: a copy of the entry's DN, derived on read so a rename cannot leave it stale. + */ + private function injectEntryDn(Entry $entry): Entry + { + $copy = $entry->makeCopy(); + $copy->set( + AttributeTypeOid::NAME_ENTRY_DN, + $entry->getDn()->toString(), + ); + + return $copy; + } + private function injectHasSubordinates(Entry $entry): Entry { $copy = $entry->makeCopy(); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php index 4bb7be2c..4548fdc9 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptions.php @@ -14,24 +14,24 @@ namespace FreeDSx\Ldap\Server\Backend\Storage; use FreeDSx\Ldap\Control\Sorting\SortKey; +use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Search\Filter\AndFilter; use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\Subentry\SubentryVisibility; -use Closure; /** * DTO for EntryStorageInterface::list(), decoupled from LDAP protocol objects. * * @author Chad Sikorra */ -final readonly class StorageListOptions +final readonly class StorageListOptions implements FilterAttributeContextInterface { /** * @param SortKey[] $sortKeys * @param list|null $attributes Lowercase base attribute names to materialize, or null for all. - * @param (\Closure(string): (bool|null))|null $isIntegerOrderedResolver Resolves whether an attribute orders numerically. - * @param (\Closure(string): (bool|null))|null $isCaseInsensitiveResolver Resolves whether an attribute matches without regard to case. + * @param ?Schema $schema Answers the attribute questions below; callers with no schema leave it null. */ public function __construct( public Dn $baseDn, @@ -42,19 +42,37 @@ public function __construct( public array $sortKeys = [], public int $lookthroughLimit = 0, public ?array $attributes = null, - public ?Closure $isIntegerOrderedResolver = null, - public ?Closure $isCaseInsensitiveResolver = null, public SubentryVisibility $subentries = SubentryVisibility::All, + public ?Schema $schema = null, ) {} + /** + * How faithfully SQL alone can answer an assertion on the attribute. + */ + public function filterSupport(string $attribute): AttributeFilterSupport + { + if ($this->schema === null) { + return AttributeFilterSupport::Exact; + } + + // Options are not part of the type, so they are dropped before asking the schema about it. + $type = Attribute::normalizeName($attribute); + + if ($this->schema->getAttributeType($type) === null) { + return AttributeFilterSupport::NeverMatches; + } + + return $this->schema->hasSubtypes($type) + ? AttributeFilterSupport::NeedsEvaluator + : AttributeFilterSupport::Exact; + } + /** * Whether the attribute orders numerically. null when unresolved (no schema was supplied). */ public function isIntegerOrdered(string $attribute): ?bool { - return $this->isIntegerOrderedResolver !== null - ? ($this->isIntegerOrderedResolver)($attribute) - : null; + return $this->schema?->isIntegerOrdered($attribute); } /** @@ -62,9 +80,7 @@ public function isIntegerOrdered(string $attribute): ?bool */ public function isCaseInsensitive(string $attribute): ?bool { - return $this->isCaseInsensitiveResolver !== null - ? ($this->isCaseInsensitiveResolver)($attribute) - : null; + return $this->schema?->isCaseInsensitiveMatched($attribute); } /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptionsFactory.php b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptionsFactory.php index a93e98d1..4e3186f8 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptionsFactory.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/StorageListOptionsFactory.php @@ -66,9 +66,8 @@ public function make( : [], lookthroughLimit: $limits->maxSearchLookthrough, attributes: $this->materializedAttributes($request), - isIntegerOrderedResolver: fn(string $attribute): ?bool => $this->schema->isIntegerOrdered($attribute), - isCaseInsensitiveResolver: fn(string $attribute): ?bool => $this->schema->isCaseInsensitiveMatched($attribute), subentries: $subentries, + schema: $this->schema, ); } @@ -122,7 +121,16 @@ private function materializedAttributes(SearchRequest $request): ?array continue; } + // Values of a subtype are stored under their own name, so nothing narrower than everything is safe. + if ($this->schema->hasSubtypes($name)) { + return null; + } $materialized[$name] = true; + + // A type may be asked for by its OID or any of its names, while it is stored under just one of them. + foreach ($this->schema->getAttributeType($name)->names ?? [] as $alias) { + $materialized[strtolower($alias)] = true; + } } foreach ($filterAttributes as $attribute) { $materialized[$attribute] = true; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 6f99c6fd..14632f12 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -66,6 +66,7 @@ public function __construct( private readonly SearchStreamBuilder $searchStream, private readonly SchemaValidator $validator, private readonly StorageListOptionsFactory $listOptions, + private readonly FilterEvaluatorInterface $filterEvaluator, private readonly OperationalAttributeGenerator $operationalAttrs = new OperationalAttributeGenerator(), private readonly WriteEntryOperationHandler $entryHandler = new WriteEntryOperationHandler(), private readonly ?ChangeRecorder $changeRecorder = null, @@ -100,19 +101,11 @@ public function compare( ); } - $attribute = $entry->get($filter->getAttribute()); - - if ($attribute === null) { - return false; - } - - foreach ($attribute->getValues() as $value) { - if (strcasecmp($value, $filter->getValue()) === 0) { - return true; - } - } - - return false; + // A comparison is an equality assertion, so it answers through the same evaluation a filter would get. + return $this->filterEvaluator->evaluate( + $entry, + $filter, + ); } /** diff --git a/src/FreeDSx/Ldap/Server/Subentry/GoverningSubentryResolver.php b/src/FreeDSx/Ldap/Server/Subentry/GoverningSubentryResolver.php index 2898a4f0..d4fc09c3 100644 --- a/src/FreeDSx/Ldap/Server/Subentry/GoverningSubentryResolver.php +++ b/src/FreeDSx/Ldap/Server/Subentry/GoverningSubentryResolver.php @@ -36,7 +36,7 @@ { public function __construct( private LdapBackendInterface $backend, - private SubtreeSpecificationEvaluator $evaluator = new SubtreeSpecificationEvaluator(), + private SubtreeSpecificationEvaluator $evaluator, private SubtreeSpecificationParser $parser = new SubtreeSpecificationParser(), ) {} diff --git a/src/FreeDSx/Ldap/Server/Subentry/SubtreeSpecificationEvaluator.php b/src/FreeDSx/Ldap/Server/Subentry/SubtreeSpecificationEvaluator.php index 03fc31c2..01577c53 100644 --- a/src/FreeDSx/Ldap/Server/Subentry/SubtreeSpecificationEvaluator.php +++ b/src/FreeDSx/Ldap/Server/Subentry/SubtreeSpecificationEvaluator.php @@ -15,7 +15,6 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; /** @@ -26,7 +25,7 @@ final readonly class SubtreeSpecificationEvaluator { public function __construct( - private FilterEvaluatorInterface $filterEvaluator = new FilterEvaluator(), + private FilterEvaluatorInterface $filterEvaluator, ) {} /** diff --git a/tests/integration/Storage/Concern/BindTestsTrait.php b/tests/integration/Storage/Concern/BindTestsTrait.php new file mode 100644 index 00000000..8d2ac84a --- /dev/null +++ b/tests/integration/Storage/Concern/BindTestsTrait.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 Tests\Integration\FreeDSx\Ldap\Storage\Concern; + +use FreeDSx\Ldap\Exception\BindException; + +/** + * Authentication against the backend. + */ +trait BindTestsTrait +{ + public function testBindWithCorrectCredentials(): void + { + // No exception thrown — bind succeeded; verify the session is usable + $this->authenticateUser(); + + self::assertTrue( + $this->ldapClient()->compare('cn=user,dc=foo,dc=bar', 'cn', 'user'), + ); + } + + public function testBindWithWrongCredentials(): void + { + $this->expectException(BindException::class); + + $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', 'wrongpassword'); + } + + public function testBindWithUnknownDn(): void + { + $this->expectException(BindException::class); + + $this->ldapClient()->bind('cn=nobody,dc=foo,dc=bar', '12345'); + } +} diff --git a/tests/integration/Storage/Concern/ControlTestsTrait.php b/tests/integration/Storage/Concern/ControlTestsTrait.php new file mode 100644 index 00000000..49d74655 --- /dev/null +++ b/tests/integration/Storage/Concern/ControlTestsTrait.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Storage\Concern; + +use FreeDSx\Ldap\Control\Sorting\SortingControl; +use FreeDSx\Ldap\Control\Sorting\SortKey; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; + +/** + * Search controls the backend participates in: paged results and server side sorting. + */ +trait ControlTestsTrait +{ + public function testPagingReturnsAllEntriesAcrossMultiplePages(): void + { + $this->authenticateUser(); + + $search = Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + $paging = $this->ldapClient()->paging($search, 2); + + $allEntries = []; + + while ($paging->hasEntries()) { + foreach ($paging->getEntries() as $entry) { + $allEntries[] = $entry->getDn()->toString(); + } + } + + self::assertCount( + 8, + $allEntries, + ); + } + + public function testPagingCanBeAbandoned(): void + { + $this->authenticateUser(); + + $search = Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + $paging = $this->ldapClient()->paging($search, 1); + + // Get the first page only, then abandon + $paging->getEntries(); + $paging->end(); + + // After abandonment, hasEntries() must return false + self::assertFalse($paging->hasEntries()); + } + + public function testSortControlAscendingOrdersResults(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('sn')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + new SortingControl(SortKey::ascending('sn')), + ); + + $sns = array_map( + static fn(Entry $e): string => $e->get('sn')?->getValues()[0] ?? '', + $entries->toArray(), + ); + + // Seed: cn=user and cn=admin (sn=Admin), cn=alice (sn=Smith). Admin < Smith ascending. + self::assertSame( + ['Admin', 'Admin', 'Smith'], + $sns, + ); + } + + public function testSortControlDescendingOrdersResults(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('sn')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + new SortingControl(SortKey::descending('sn')), + ); + + $sns = array_map( + static fn(Entry $e): string => $e->get('sn')?->getValues()[0] ?? '', + $entries->toArray(), + ); + + // Seed: cn=user and cn=admin (sn=Admin), cn=alice (sn=Smith). Smith > Admin descending. + self::assertSame( + ['Smith', 'Admin', 'Admin'], + $sns, + ); + } + + public function testSortControlOrdersAnIntegerAttributeNumerically(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('uidNumber'), 'uidNumber') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + new SortingControl(SortKey::ascending('uidNumber')), + ); + + $values = array_map( + static fn(Entry $e): string => $e->get('uidNumber')?->firstValue() ?? '', + $entries->toArray(), + ); + + // Ordered as text, '100' would come first. + self::assertSame( + ['99', '100'], + $values, + ); + } + + public function testSortControlPlacesMissingAttributeLastWhenAscending(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('cn')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + new SortingControl(SortKey::ascending('sn')), + )->toArray(); + + // More than one seed entry lacks 'sn', so assert the ordering rather than which of them sorts last. + self::assertNull($entries[count($entries) - 1]->get('sn')); + self::assertNotNull($entries[0]->get('sn')); + } + + public function testSortControlPlacesMissingAttributeFirstWhenDescending(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('cn')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + new SortingControl(SortKey::descending('sn')), + )->toArray(); + + // More than one seed entry lacks 'sn', so assert the ordering rather than which of them sorts first. + self::assertNull($entries[0]->get('sn')); + self::assertNotNull($entries[count($entries) - 1]->get('sn')); + } +} diff --git a/tests/integration/Storage/Concern/DefaultAclTestsTrait.php b/tests/integration/Storage/Concern/DefaultAclTestsTrait.php new file mode 100644 index 00000000..aab640a5 --- /dev/null +++ b/tests/integration/Storage/Concern/DefaultAclTestsTrait.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Storage\Concern; + +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; + +/** + * Behavior enforced by the shipped default ACL, across the search and paging paths. + */ +trait DefaultAclTestsTrait +{ + public function testUserPasswordIsNotReturnedUnderTheDefaultAcl(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'user')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ->setAttributes('cn', 'userPassword'), + ); + + // The shipped default marks userPassword confidential and grants nobody access to it. + $user = $entries->first(); + self::assertNotNull($user); + self::assertNull($user->get('userPassword')); + } + + public function testFilteringOnUserPasswordMatchesNothingUnderTheDefaultAcl(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('userPassword', '{SHA}' . base64_encode(sha1('12345', true)))) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + 0, + $entries, + ); + } + + public function testNegatingAWithheldAssertionMatchesEveryEntry(): void + { + $this->authenticateUser(); + + $all = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + // Withheld reads as absent, so negating it holds for every entry rather than none. + $negated = $this->ldapClient()->search( + Operations::search(Filters::not(Filters::equal('userPassword', 'anything'))) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + count($all), + $negated, + ); + } + + public function testAConjunctionWithAWithheldAssertionMatchesNothing(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::and( + Filters::equal('cn', 'user'), + Filters::equal('userPassword', 'anything'), + )) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + 0, + $entries, + ); + } + + public function testPagingWithholdsConfidentialAttributes(): void + { + $this->authenticateUser(); + + // Paging strips results on its own loop, separate from the one a plain search uses. + $search = Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ->setAttributes('cn', 'userPassword'); + + $paging = $this->ldapClient()->paging($search, 2); + $withPassword = 0; + + while ($paging->hasEntries()) { + foreach ($paging->getEntries() as $entry) { + if ($entry->get('userPassword') !== null) { + $withPassword++; + } + } + } + + self::assertSame( + 0, + $withPassword, + ); + } + + public function testPagingOnAWithheldFilterReturnsNothing(): void + { + $this->authenticateUser(); + + $search = Operations::search(Filters::equal('userPassword', 'anything')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + $paging = $this->ldapClient()->paging($search, 2); + $found = 0; + + while ($paging->hasEntries()) { + $found += count($paging->getEntries()); + } + + self::assertSame( + 0, + $found, + ); + } +} diff --git a/tests/integration/Storage/Concern/QueryTestsTrait.php b/tests/integration/Storage/Concern/QueryTestsTrait.php new file mode 100644 index 00000000..c962c77a --- /dev/null +++ b/tests/integration/Storage/Concern/QueryTestsTrait.php @@ -0,0 +1,860 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Storage\Concern; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filter\FilterInterface; +use FreeDSx\Ldap\Search\Filters; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Read operations: scope, filter evaluation, projection, compare, and search limits. + */ +trait QueryTestsTrait +{ + /** + * RFC 4511 4.5.1 evaluation, driven over a real connection so every assertion travels as BER. + * + * The seed holds seven entries; only cn=alice carries uidNumber, mail and employeeNumber. Each backend runs these, + * since the PDO adapters translate filters to SQL and only re-check inexact results in PHP. + * + * @return iterable + */ + public static function filterProvider(): iterable + { + yield 'present' => [ + Filters::present('uidNumber'), + 2, + ]; + yield 'approximate' => [ + Filters::approximate('cn', 'alice'), + 1, + ]; + yield 'and' => [ + Filters::and( + Filters::equal('cn', 'alice'), + Filters::present('sn'), + ), + 1, + ]; + yield 'or' => [ + Filters::or( + Filters::equal('cn', 'alice'), + Filters::equal('cn', 'nosn'), + ), + 2, + ]; + yield 'not' => [ + Filters::not(Filters::equal('cn', 'alice')), + 7, + ]; + + // RFC 4526. + yield 'absolute true' => [ + Filters::and(), + 8, + ]; + yield 'absolute false' => [ + Filters::or(), + 0, + ]; + + yield 'a non numeric assertion cannot match an integer' => [ + Filters::equal('uidNumber', 'abc'), + 0, + ]; + // Casting to an integer would read this as the stored value and match it. + yield 'an integer assertion with trailing text matches nothing' => [ + Filters::equal('uidNumber', '99abc'), + 0, + ]; + yield 'leading zeros are the same integer' => [ + Filters::equal('uidNumber', '099'), + 1, + ]; + + // RFC 4511 4.5.1.7: an assertion on an unrecognized attribute type is Undefined, and NOT of Undefined stays + // Undefined, so no entry may be returned. A present filter on a known type is False, never Undefined. + yield 'negating an unrecognized attribute type matches nothing' => [ + Filters::not(Filters::present('shoeSize')), + 0, + ]; + yield 'negating an absent but defined attribute matches everything' => [ + Filters::not(Filters::present('telephoneNumber')), + 8, + ]; + yield 'negating a value assertion on an unrecognized type matches nothing' => [ + Filters::not(Filters::equal('shoeSize', '12')), + 0, + ]; + yield 'negating a value assertion on an absent but defined attribute' => [ + Filters::not(Filters::equal('telephoneNumber', '555')), + 8, + ]; + yield 'negating a conjunction' => [ + Filters::not(Filters::and( + Filters::equal('cn', 'alice'), + Filters::present('sn'), + )), + 7, + ]; + // A conjunction is false as soon as one branch is false, so every entry failing the recognized branch is + // negated to true. Only the entry that satisfies it is left Undefined by the branch that cannot be resolved. + yield 'negating a conjunction holding an unrecognized type' => [ + Filters::not(Filters::and( + Filters::equal('cn', 'alice'), + Filters::equal('shoeSize', '12'), + )), + 7, + ]; + + // RFC 4512 2.5.2: an assertion on the base type covers its tagged variants and its subtypes. + yield 'the base type matches a value held under an option' => [ + Filters::equal('mail', 'alice-en@foo.bar'), + 1, + ]; + yield 'a supertype matches a value held by its subtype' => [ + Filters::equal('name', 'alice'), + 1, + ]; + + // RFC 4511 4.5.1.7.7. + yield 'extensible with an explicit matching rule' => [ + Filters::extensible('cn', 'ALICE', '2.5.13.2', false), + 1, + ]; + yield 'extensible without a rule uses the type EQUALITY' => [ + Filters::extensible('employeeNumber', 'A1b2C3', null, false), + 1, + ]; + yield 'extensible without a rule respects a case exact EQUALITY' => [ + Filters::extensible('employeeNumber', 'a1b2c3', null, false), + 0, + ]; + yield 'extensible with an unrecognized rule is Undefined' => [ + Filters::extensible('cn', 'alice', '9.9.9.9', false), + 0, + ]; + yield 'extensible against the DN' => [ + Filters::extensible('cn', 'alice', null, true), + 1, + ]; + // The RDN stores this value escaped, but the assertion is against the value itself. + yield 'extensible against a DN whose value needs escaping' => [ + Filters::extensible('cn', 'Smith, John', null, true), + 1, + ]; + + // RFC 4518 appendix B: a space at the edge of a fragment stays significant, so these must not match a value + // holding no space there. Collapsing and trimming both sides alike would wrongly match all three. + yield 'an initial and final space cannot match a value without them' => [ + Filters::startsWith('cn', 'al ')->setEndsWith(' ice'), + 0, + ]; + yield 'a leading space in an any fragment is significant' => [ + Filters::contains('cn', ' alice '), + 0, + ]; + yield 'the same assertion split across fragments agrees' => [ + Filters::startsWith('cn', ' ')->setContains('alice')->setEndsWith(' '), + 0, + ]; + yield 'a fragment spanning a space matches a value holding one' => [ + Filters::contains('cn', 'Smith, John'), + 1, + ]; + + // No approximate rule is implemented, so it must answer as the type's equality rule does. + yield 'approximate on a case exact type rejects a case difference' => [ + Filters::approximate('employeeNumber', 'a1b2c3'), + 0, + ]; + yield 'approximate on a case exact type accepts the exact value' => [ + Filters::approximate('employeeNumber', 'A1b2C3'), + 1, + ]; + } + + #[DataProvider('filterProvider')] + public function test_filter_evaluation_over_the_wire( + FilterInterface $filter, + int $expected, + ): void { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search($filter) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + $expected, + $entries, + ); + } + + public function testRequestingABaseTypeReturnsItsTaggedVariants(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice'), 'mail') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + $alice = $entries->first(); + self::assertNotNull($alice); + self::assertSame( + ['alice@foo.bar'], + $alice->get(new Attribute('mail'), true)?->getValues(), + ); + self::assertSame( + ['alice-en@foo.bar'], + $alice->get(new Attribute('mail;lang-en'), true)?->getValues(), + ); + } + + public function testRequestingEntryDnReturnsTheEntryDn(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice'), 'entryDN') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertSame( + ['cn=alice,ou=people,dc=foo,dc=bar'], + $entries->first()?->get(new Attribute('entryDN'), true)?->getValues(), + ); + } + + public function testRequestingAllOperationalAttributesReturnsEntryDn(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice'), '+') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertSame( + ['cn=alice,ou=people,dc=foo,dc=bar'], + $entries->first()?->get(new Attribute('entryDN'), true)?->getValues(), + ); + } + + public function testRequestingEntryDnByItsOidReturnsIt(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice'), '1.3.6.1.1.20') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertSame( + ['cn=alice,ou=people,dc=foo,dc=bar'], + $entries->first()?->get(new Attribute('entryDN'), true)?->getValues(), + ); + } + + public function testEntryDnIsOperationalSoItIsNotReturnedByDefault(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertNull( + $entries->first()?->get(new Attribute('entryDN'), true), + ); + } + + public function testRequestingASupertypeReturnsItsSubtypeValues(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice'), 'name') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + $alice = $entries->first(); + self::assertNotNull($alice); + self::assertSame( + ['alice'], + $alice->get(new Attribute('cn'), true)?->getValues(), + ); + self::assertSame( + ['Smith'], + $alice->get(new Attribute('sn'), true)?->getValues(), + ); + } + + public function testRequestingATypeByItsOidReturnsIt(): void + { + $this->authenticateUser(); + + // Filtering on a different attribute, so only the OID can be what asks for cn. + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('sn', 'Smith'), '2.5.4.3') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertSame( + ['alice'], + $entries->first()?->get(new Attribute('cn'), true)?->getValues(), + ); + } + + public function testATypesOnlyRequestKeepsAttributeOptions(): void + { + $this->authenticateUser(); + + $request = Operations::search(Filters::equal('cn', 'alice'), 'mail') + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + $request->setAttributesOnly(true); + + $alice = $this->ldapClient()->search($request)->first(); + self::assertNotNull($alice); + + $descriptions = array_map( + static fn(Attribute $attribute): string => $attribute->getDescription(), + $alice->getAttributes(), + ); + sort($descriptions); + self::assertSame( + ['mail', 'mail;lang-en'], + $descriptions, + ); + } + + public function testAnUnrecognizedMatchingRuleDoesNotFailTheWholeSearch(): void + { + $this->authenticateUser(); + + // The bad assertion is Undefined, so the disjunction still returns what its other branch matches. + $entries = $this->ldapClient()->search( + Operations::search(Filters::or( + Filters::equal('cn', 'alice'), + Filters::extensible('cn', 'alice', '9.9.9.9', false), + )) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + 1, + $entries, + ); + } + + public function testSearchBaseObjectReturnsBaseEntry(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useBaseScope(), + ); + + self::assertCount(1, $entries); + self::assertSame('dc=foo,dc=bar', $entries->first()?->getDn()->toString()); + } + + public function testSearchSingleLevelReturnsDirectChildrenOnly(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass')) + ->base('dc=foo,dc=bar') + ->useSingleLevelScope(), + ); + + self::assertCount( + 6, + $entries, + ); + } + + public function testSearchSubtreeWithFilterReturnsMatchingEntry(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=alice,ou=people,dc=foo,dc=bar', + $entries->first()?->getDn()->toString(), + ); + } + + public function testSearchReturnsAttributeValues(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + $alice = $entries->first(); + self::assertNotNull($alice); + self::assertSame(['Smith'], $alice->get('sn')?->getValues()); + } + + public function testSearchTypesOnlyReturnsAttributeNamesWithoutValues(): void + { + $this->authenticateUser(); + + $request = Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + $request->setAttributesOnly(true); + + $entries = $this->ldapClient()->search($request); + + $alice = $entries->first(); + self::assertNotNull($alice); + // sn attribute should be present but with no values + $sn = $alice->get('sn'); + self::assertNotNull($sn); + self::assertEmpty($sn->getValues()); + } + + public function testSearchWithNoMatchReturnsEmptyResult(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'nobody')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount(0, $entries); + } + + public function testSearchFilterAppliesTheSchemaDeclaredMatchingRule(): void + { + $this->authenticateUser(); + + $exact = $this->ldapClient()->search( + Operations::search(Filters::equal('employeeNumber', 'A1b2C3')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + // The schema matches employeeNumber case-exactly. Storage post-filters results itself, so a case-folded + // value matching here would mean that path evaluated without the schema. + $caseFolded = $this->ldapClient()->search( + Operations::search(Filters::equal('employeeNumber', 'a1b2c3')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount( + 1, + $exact, + ); + self::assertCount( + 0, + $caseFolded, + ); + } + + public function testInexactFilterOnUnrequestedAttributeStillMatchesAndProjects(): void + { + $this->authenticateAdmin(); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=hazard,dc=foo,dc=bar', + [ + 'cn' => 'hazard', + 'sn' => 'Smithers', + 'mail' => 'hazard@foo.bar', + 'objectClass' => 'inetOrgPerson', + ], + )); + + // Substring is inexact: SQL yields candidates and PHP re-evaluates (sn) on the hydrated entry, so storage must + // materialize sn (filter-referenced) even though only cn was requested; projection then drops it. + $entries = $this->ldapClient()->search( + Operations::search(Filters::contains('sn', 'mither')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ->select('cn'), + ); + + $entry = $entries->first(); + self::assertNotNull($entry); + self::assertSame( + 'cn=hazard,dc=foo,dc=bar', + $entry->getDn()->toString(), + ); + self::assertSame( + ['hazard'], + $entry->get(new Attribute('cn'), true)?->getValues(), + ); + self::assertNull($entry->get(new Attribute('sn'), true)); + } + + public function testNoAttributesRequestStillMatchesAnInexactFilter(): void + { + $this->authenticateAdmin(); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=noattr,dc=foo,dc=bar', + [ + 'cn' => 'noattr', + 'sn' => 'Jones', + 'objectClass' => 'inetOrgPerson', + ], + )); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::contains('sn', 'one')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ->select('1.1'), + ); + + $entry = $entries->first(); + self::assertNotNull($entry); + self::assertSame( + 'cn=noattr,dc=foo,dc=bar', + $entry->getDn()->toString(), + ); + self::assertCount( + 0, + $entry->getAttributes(), + ); + } + + public function testCompareReturnsTrueForMatchingValue(): void + { + $this->authenticateUser(); + + $result = $this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'sn', + 'Smith', + ); + + self::assertTrue($result); + } + + public function testCompareAppliesTheSchemaDeclaredMatchingRule(): void + { + $this->authenticateUser(); + + // employeeNumber matches case exactly, so a case folded comparison must not be true. + self::assertTrue($this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'employeeNumber', + 'A1b2C3', + )); + self::assertFalse($this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'employeeNumber', + 'a1b2c3', + )); + } + + public function testCompareAgreesWithTheSameAssertionAsAFilter(): void + { + $this->authenticateUser(); + + foreach (['A1b2C3', 'a1b2c3'] as $assertion) { + $matched = $this->ldapClient()->search( + Operations::search(Filters::equal('employeeNumber', $assertion)) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertSame( + count($matched) === 1, + $this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'employeeNumber', + $assertion, + ), + ); + } + } + + public function testCompareIsFalseWhenTheEntryLacksTheAttribute(): void + { + $this->authenticateUser(); + + self::assertFalse($this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'telephoneNumber', + '555', + )); + } + + public function testCompareIsFalseForAnUnrecognizedAttributeType(): void + { + $this->authenticateUser(); + + // The assertion is Undefined rather than false, but neither is a match. + self::assertFalse($this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'shoeSize', + '12', + )); + } + + public function testCompareCoversValuesHeldBySubtypes(): void + { + $this->authenticateUser(); + + // RFC 4512 2.5.2: cn is a subtype of name, so its value answers a comparison against the supertype. + self::assertTrue($this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'name', + 'alice', + )); + } + + public function testCompareReturnsFalseForNonMatchingValue(): void + { + $this->authenticateUser(); + + $result = $this->ldapClient()->compare( + 'cn=alice,ou=people,dc=foo,dc=bar', + 'sn', + 'Jones', + ); + + self::assertFalse($result); + } + + public function testSubstringStartsWithMatches(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::startsWith('cn', 'al')) + ->base('ou=people,dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=alice,ou=people,dc=foo,dc=bar', + $entries->first()?->getDn()->toString(), + ); + } + + public function testSubstringContainsMatches(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::contains('cn', 'lic')) + ->base('ou=people,dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=alice,ou=people,dc=foo,dc=bar', + $entries->first()?->getDn()->toString(), + ); + } + + public function testSubstringEndsWithMatches(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::endsWith('cn', 'ice')) + ->base('ou=people,dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=alice,ou=people,dc=foo,dc=bar', + $entries->first()?->getDn()->toString(), + ); + } + + public function testGteAsciiExcludesLowerValue(): void + { + $this->authenticateUser(); + + // Scope to ou=people so cn=user (which would match cn >= 'alicf') is excluded. + $entries = $this->ldapClient()->search( + Operations::search(Filters::gte('cn', 'alicf')) + ->base('ou=people,dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + // 'alice' < 'alicf' lexicographically + self::assertCount(0, $entries); + } + + public function testLteAsciiIncludesMatchingValue(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::and( + Filters::present('cn'), + Filters::lte('cn', 'alice'), + )) + ->base('ou=people,dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=alice,ou=people,dc=foo,dc=bar', + $entries->first()?->getDn()->toString(), + ); + } + + /** + * uidNumber declares the INTEGER syntax, so 99 is below 100 rather than above it bytewise. + */ + public function testGteOnAnIntegerAttributeOrdersNumerically(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::gte('uidNumber', '100')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + // Bytewise, '99' would also be at or above '100'. + self::assertCount(1, $entries); + self::assertSame( + 'cn=Smith\\, John,dc=foo,dc=bar', + $entries->first()?->getDn()->toString(), + ); + } + + public function testLteOnAnIntegerAttributeOrdersNumerically(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::lte('uidNumber', '100')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + // Bytewise, '99' would sort above '100' and be excluded. + self::assertCount(2, $entries); + } + + public function testNotEqualityExcludesMatches(): void + { + $this->authenticateUser(); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::and( + Filters::present('cn'), + Filters::not(Filters::equal('cn', 'alice')), + )) + ->base('ou=people,dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + // Under ou=people only alice exists in the seed; NOT-equal alice leaves zero matches. + self::assertCount(0, $entries); + } + + public function testInexactSearchTripsLookthroughLimit(): void + { + $this->stopServer(); + $this->createServerProcess( + 'tcp', + [ + ...static::storageExtraArgs(), + '--seed-entries=10', + '--max-search-lookthrough=3', + ], + ); + $this->authenticateUser(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); + + $this->ldapClient()->search( + Operations::search(Filters::endsWith('cn', 'zzz')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + } + + public function testSearchDeclinesAliasDereferencing(): void + { + $this->stopServer(); + $this->createServerProcess('tcp', static::storageExtraArgs()); + $this->authenticateAdmin(); + + $this->ldapClient()->create(Entry::fromArray('cn=ref,dc=foo,dc=bar', [ + 'objectClass' => ['top', 'alias', 'extensibleObject'], + 'cn' => 'ref', + 'aliasedObjectName' => 'cn=user,dc=foo,dc=bar', + ])); + + $neverRequest = Operations::search(Filters::equal('cn', 'ref')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + self::assertCount( + 1, + $this->ldapClient()->search($neverRequest), + ); + + $derefRequest = Operations::search(Filters::equal('cn', 'ref')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ->setDereferenceAliases(SearchRequest::DEREF_ALWAYS); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ALIAS_DEREFERENCING_PROBLEM); + $this->ldapClient()->search($derefRequest); + } +} diff --git a/tests/integration/Storage/Concern/WriteTestsTrait.php b/tests/integration/Storage/Concern/WriteTestsTrait.php new file mode 100644 index 00000000..06f955ea --- /dev/null +++ b/tests/integration/Storage/Concern/WriteTestsTrait.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Storage\Concern; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; + +/** + * Add, delete, modify, and rename against the backend. + */ +trait WriteTestsTrait +{ + public function testAddStoresEntry(): void + { + $this->authenticateAdmin(); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=charlie,dc=foo,dc=bar', + ['cn' => 'charlie', 'sn' => 'Charlie', 'objectClass' => 'inetOrgPerson'], + )); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'charlie')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + self::assertCount(1, $entries); + } + + public function testAddPreservesAttributeOptionsOnRoundTrip(): void + { + $this->authenticateAdmin(); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=tagged,dc=foo,dc=bar', + [ + 'cn' => 'tagged', + 'cn;lang-en' => 'Tagged EN', + 'sn' => 'Tag', + 'objectClass' => 'inetOrgPerson', + ], + )); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn;lang-en', 'Tagged EN')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + + $tagged = $entries->first(); + self::assertNotNull($tagged); + self::assertSame( + ['Tagged EN'], + $tagged->get(new Attribute('cn;lang-en'), true)?->getValues(), + ); + self::assertSame( + ['tagged'], + $tagged->get(new Attribute('cn'), true)?->getValues(), + ); + } + + public function testAddDuplicateDnFails(): void + { + $this->authenticateAdmin(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=user,dc=foo,dc=bar', + ['cn' => 'user', 'sn' => 'User', 'objectClass' => 'inetOrgPerson'], + )); + } + + public function testDeleteRemovesEntry(): void + { + $this->authenticateAdmin(); + $this->ldapClient()->delete('cn=alice,ou=people,dc=foo,dc=bar'); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + self::assertCount(0, $entries); + } + + public function testDeleteNonLeafEntryFails(): void + { + $this->authenticateAdmin(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); + + // ou=people still has cn=alice as a child + $this->ldapClient()->delete('ou=people,dc=foo,dc=bar'); + } + + public function testModifyReplacesAttributeValue(): void + { + $this->authenticateAdmin(); + + $entry = Entry::fromArray('cn=alice,ou=people,dc=foo,dc=bar'); + $entry->set('sn', 'Jones'); + $this->ldapClient()->update($entry); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::equal('sn', 'Jones')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + self::assertCount(1, $entries); + self::assertSame(['Jones'], $entries->first()?->get('sn')?->getValues()); + } + + public function testRejectedModifyLeavesTheEntryUnchanged(): void + { + $this->authenticateAdmin(); + + // cn=user has no extensibleObject class, so removing a required attribute is actually refused. + $entry = Entry::fromArray('cn=user,dc=foo,dc=bar'); + $entry->reset('sn'); + + try { + $this->ldapClient()->update($entry); + self::fail('The schema violating modify should have been rejected.'); + } catch (OperationException $e) { + self::assertSame( + ResultCode::OBJECT_CLASS_VIOLATION, + $e->getCode(), + ); + } + + $found = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass'), 'sn') + ->base('cn=user,dc=foo,dc=bar') + ->useBaseScope(), + ); + self::assertSame( + ['Admin'], + $found->first()?->get('sn')?->getValues(), + ); + } + + public function testRenameChangesRdn(): void + { + $this->authenticateAdmin(); + $this->ldapClient()->rename('cn=alice,ou=people,dc=foo,dc=bar', 'cn=bob', true); + + $found = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'bob')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + self::assertCount(1, $found); + self::assertSame('cn=bob,ou=people,dc=foo,dc=bar', $found->first()?->getDn()->toString()); + + // Old DN should no longer exist + $notFound = $this->ldapClient()->search( + Operations::search(Filters::equal('cn', 'alice')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(), + ); + self::assertCount(0, $notFound); + } +} diff --git a/tests/integration/Storage/LdapBackendSqliteStorageTest.php b/tests/integration/Storage/LdapBackendSqliteStorageTest.php index 5033eb4e..c7debabe 100644 --- a/tests/integration/Storage/LdapBackendSqliteStorageTest.php +++ b/tests/integration/Storage/LdapBackendSqliteStorageTest.php @@ -139,7 +139,8 @@ public function testGteDigitFilterExcludesLowerNumericValueEvenThoughBytewiseCom ->useSubtreeScope(), ); - self::assertCount(0, $entries); + // Only the entry holding 100 qualifies; bytewise, 99 would qualify too. + self::assertCount(1, $entries); } public function testGteAsciiNonDigitValueMatchesLexicographically(): void diff --git a/tests/integration/Storage/LdapBackendStorageTest.php b/tests/integration/Storage/LdapBackendStorageTest.php index 8acc358b..08f184af 100644 --- a/tests/integration/Storage/LdapBackendStorageTest.php +++ b/tests/integration/Storage/LdapBackendStorageTest.php @@ -13,20 +13,27 @@ namespace Tests\Integration\FreeDSx\Ldap\Storage; -use FreeDSx\Ldap\Control\Sorting\SortingControl; -use FreeDSx\Ldap\Control\Sorting\SortKey; -use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Exception\BindException; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\SearchRequest; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Operations; -use FreeDSx\Ldap\Search\Filters; use Tests\Integration\FreeDSx\Ldap\ServerTestCase; +use Tests\Integration\FreeDSx\Ldap\Storage\Concern\BindTestsTrait; +use Tests\Integration\FreeDSx\Ldap\Storage\Concern\ControlTestsTrait; +use Tests\Integration\FreeDSx\Ldap\Storage\Concern\DefaultAclTestsTrait; +use Tests\Integration\FreeDSx\Ldap\Storage\Concern\QueryTestsTrait; +use Tests\Integration\FreeDSx\Ldap\Storage\Concern\WriteTestsTrait; +/** + * Backend behavior shared by every storage adapter. + * + * Subclasses override storageExtraArgs() to route the shared server at a different backend, so the same cases run + * against each one. Cases live in the Concern traits grouped by the behavior they cover. + */ class LdapBackendStorageTest extends ServerTestCase { + use BindTestsTrait; + use QueryTestsTrait; + use DefaultAclTestsTrait; + use WriteTestsTrait; + use ControlTestsTrait; + public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); @@ -55,805 +62,6 @@ public function setUp(): void parent::setUp(); } - public function testBindWithCorrectCredentials(): void - { - // No exception thrown — bind succeeded; verify the session is usable - $this->authenticateUser(); - - self::assertTrue( - $this->ldapClient()->compare('cn=user,dc=foo,dc=bar', 'cn', 'user'), - ); - } - - public function testBindWithWrongCredentials(): void - { - $this->expectException(BindException::class); - - $this->ldapClient()->bind('cn=user,dc=foo,dc=bar', 'wrongpassword'); - } - - public function testBindWithUnknownDn(): void - { - $this->expectException(BindException::class); - - $this->ldapClient()->bind('cn=nobody,dc=foo,dc=bar', '12345'); - } - - public function testSearchBaseObjectReturnsBaseEntry(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::present('objectClass')) - ->base('dc=foo,dc=bar') - ->useBaseScope(), - ); - - self::assertCount(1, $entries); - self::assertSame('dc=foo,dc=bar', $entries->first()?->getDn()->toString()); - } - - public function testSearchSingleLevelReturnsDirectChildrenOnly(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::present('objectClass')) - ->base('dc=foo,dc=bar') - ->useSingleLevelScope(), - ); - - self::assertCount( - 5, - $entries, - ); - } - - public function testSearchSubtreeWithFilterReturnsMatchingEntry(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'alice')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(1, $entries); - self::assertSame( - 'cn=alice,ou=people,dc=foo,dc=bar', - $entries->first()?->getDn()->toString(), - ); - } - - public function testSearchReturnsAttributeValues(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'alice')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - $alice = $entries->first(); - self::assertNotNull($alice); - self::assertSame(['Smith'], $alice->get('sn')?->getValues()); - } - - public function testSearchTypesOnlyReturnsAttributeNamesWithoutValues(): void - { - $this->authenticateUser(); - - $request = Operations::search(Filters::equal('cn', 'alice')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(); - $request->setAttributesOnly(true); - - $entries = $this->ldapClient()->search($request); - - $alice = $entries->first(); - self::assertNotNull($alice); - // sn attribute should be present but with no values - $sn = $alice->get('sn'); - self::assertNotNull($sn); - self::assertEmpty($sn->getValues()); - } - - public function testSearchWithNoMatchReturnsEmptyResult(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'nobody')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(0, $entries); - } - - public function testUserPasswordIsNotReturnedUnderTheDefaultAcl(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'user')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope() - ->setAttributes('cn', 'userPassword'), - ); - - // The shipped default marks userPassword confidential and grants nobody access to it. - $user = $entries->first(); - self::assertNotNull($user); - self::assertNull($user->get('userPassword')); - } - - public function testFilteringOnUserPasswordMatchesNothingUnderTheDefaultAcl(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('userPassword', '{SHA}' . base64_encode(sha1('12345', true)))) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount( - 0, - $entries, - ); - } - - public function testNegatingAWithheldAssertionMatchesEveryEntry(): void - { - $this->authenticateUser(); - - $all = $this->ldapClient()->search( - Operations::search(Filters::present('objectClass')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - // Withheld reads as absent, so negating it holds for every entry rather than none. - $negated = $this->ldapClient()->search( - Operations::search(Filters::not(Filters::equal('userPassword', 'anything'))) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount( - count($all), - $negated, - ); - } - - public function testAConjunctionWithAWithheldAssertionMatchesNothing(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::and( - Filters::equal('cn', 'user'), - Filters::equal('userPassword', 'anything'), - )) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount( - 0, - $entries, - ); - } - - public function testSearchFilterAppliesTheSchemaDeclaredMatchingRule(): void - { - $this->authenticateUser(); - - $exact = $this->ldapClient()->search( - Operations::search(Filters::equal('employeeNumber', 'A1b2C3')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - // The schema matches employeeNumber case-exactly. Storage post-filters results itself, so a case-folded - // value matching here would mean that path evaluated without the schema. - $caseFolded = $this->ldapClient()->search( - Operations::search(Filters::equal('employeeNumber', 'a1b2c3')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount( - 1, - $exact, - ); - self::assertCount( - 0, - $caseFolded, - ); - } - - public function testAddStoresEntry(): void - { - $this->authenticateAdmin(); - - $this->ldapClient()->create(Entry::fromArray( - 'cn=charlie,dc=foo,dc=bar', - ['cn' => 'charlie', 'sn' => 'Charlie', 'objectClass' => 'inetOrgPerson'], - )); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'charlie')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - self::assertCount(1, $entries); - } - - public function testAddPreservesAttributeOptionsOnRoundTrip(): void - { - $this->authenticateAdmin(); - - $this->ldapClient()->create(Entry::fromArray( - 'cn=tagged,dc=foo,dc=bar', - [ - 'cn' => 'tagged', - 'cn;lang-en' => 'Tagged EN', - 'sn' => 'Tag', - 'objectClass' => 'inetOrgPerson', - ], - )); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn;lang-en', 'Tagged EN')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - $tagged = $entries->first(); - self::assertNotNull($tagged); - self::assertSame( - ['Tagged EN'], - $tagged->get(new Attribute('cn;lang-en'), true)?->getValues(), - ); - self::assertSame( - ['tagged'], - $tagged->get(new Attribute('cn'), true)?->getValues(), - ); - } - - public function testInexactFilterOnUnrequestedAttributeStillMatchesAndProjects(): void - { - $this->authenticateAdmin(); - - $this->ldapClient()->create(Entry::fromArray( - 'cn=hazard,dc=foo,dc=bar', - [ - 'cn' => 'hazard', - 'sn' => 'Smithers', - 'mail' => 'hazard@foo.bar', - 'objectClass' => 'inetOrgPerson', - ], - )); - - // Substring is inexact: SQL yields candidates and PHP re-evaluates (sn) on the hydrated entry, so storage must - // materialize sn (filter-referenced) even though only cn was requested; projection then drops it. - $entries = $this->ldapClient()->search( - Operations::search(Filters::contains('sn', 'mither')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope() - ->select('cn'), - ); - - $entry = $entries->first(); - self::assertNotNull($entry); - self::assertSame( - 'cn=hazard,dc=foo,dc=bar', - $entry->getDn()->toString(), - ); - self::assertSame( - ['hazard'], - $entry->get(new Attribute('cn'), true)?->getValues(), - ); - self::assertNull($entry->get(new Attribute('sn'), true)); - } - - public function testNoAttributesRequestStillMatchesAnInexactFilter(): void - { - $this->authenticateAdmin(); - - $this->ldapClient()->create(Entry::fromArray( - 'cn=noattr,dc=foo,dc=bar', - [ - 'cn' => 'noattr', - 'sn' => 'Jones', - 'objectClass' => 'inetOrgPerson', - ], - )); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::contains('sn', 'one')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope() - ->select('1.1'), - ); - - $entry = $entries->first(); - self::assertNotNull($entry); - self::assertSame( - 'cn=noattr,dc=foo,dc=bar', - $entry->getDn()->toString(), - ); - self::assertCount( - 0, - $entry->getAttributes(), - ); - } - - public function testAddDuplicateDnFails(): void - { - $this->authenticateAdmin(); - - $this->expectException(OperationException::class); - $this->expectExceptionCode(ResultCode::ENTRY_ALREADY_EXISTS); - - $this->ldapClient()->create(Entry::fromArray( - 'cn=user,dc=foo,dc=bar', - ['cn' => 'user', 'sn' => 'User', 'objectClass' => 'inetOrgPerson'], - )); - } - - public function testDeleteRemovesEntry(): void - { - $this->authenticateAdmin(); - $this->ldapClient()->delete('cn=alice,ou=people,dc=foo,dc=bar'); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'alice')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - self::assertCount(0, $entries); - } - - public function testDeleteNonLeafEntryFails(): void - { - $this->authenticateAdmin(); - - $this->expectException(OperationException::class); - $this->expectExceptionCode(ResultCode::NOT_ALLOWED_ON_NON_LEAF); - - // ou=people still has cn=alice as a child - $this->ldapClient()->delete('ou=people,dc=foo,dc=bar'); - } - - public function testModifyReplacesAttributeValue(): void - { - $this->authenticateAdmin(); - - $entry = Entry::fromArray('cn=alice,ou=people,dc=foo,dc=bar'); - $entry->set('sn', 'Jones'); - $this->ldapClient()->update($entry); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::equal('sn', 'Jones')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - self::assertCount(1, $entries); - self::assertSame(['Jones'], $entries->first()?->get('sn')?->getValues()); - } - - public function testRenameChangesRdn(): void - { - $this->authenticateAdmin(); - $this->ldapClient()->rename('cn=alice,ou=people,dc=foo,dc=bar', 'cn=bob', true); - - $found = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'bob')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - self::assertCount(1, $found); - self::assertSame('cn=bob,ou=people,dc=foo,dc=bar', $found->first()?->getDn()->toString()); - - // Old DN should no longer exist - $notFound = $this->ldapClient()->search( - Operations::search(Filters::equal('cn', 'alice')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - self::assertCount(0, $notFound); - } - - public function testCompareReturnsTrueForMatchingValue(): void - { - $this->authenticateUser(); - - $result = $this->ldapClient()->compare( - 'cn=alice,ou=people,dc=foo,dc=bar', - 'sn', - 'Smith', - ); - - self::assertTrue($result); - } - - public function testCompareReturnsFalseForNonMatchingValue(): void - { - $this->authenticateUser(); - - $result = $this->ldapClient()->compare( - 'cn=alice,ou=people,dc=foo,dc=bar', - 'sn', - 'Jones', - ); - - self::assertFalse($result); - } - - public function testPagingReturnsAllEntriesAcrossMultiplePages(): void - { - $this->authenticateUser(); - - $search = Operations::search(Filters::present('objectClass')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(); - - $paging = $this->ldapClient()->paging($search, 2); - - $allEntries = []; - - while ($paging->hasEntries()) { - foreach ($paging->getEntries() as $entry) { - $allEntries[] = $entry->getDn()->toString(); - } - } - - self::assertCount( - 7, - $allEntries, - ); - } - - public function testPagingWithholdsConfidentialAttributes(): void - { - $this->authenticateUser(); - - // Paging strips results on its own loop, separate from the one a plain search uses. - $search = Operations::search(Filters::present('objectClass')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope() - ->setAttributes('cn', 'userPassword'); - - $paging = $this->ldapClient()->paging($search, 2); - $withPassword = 0; - - while ($paging->hasEntries()) { - foreach ($paging->getEntries() as $entry) { - if ($entry->get('userPassword') !== null) { - $withPassword++; - } - } - } - - self::assertSame( - 0, - $withPassword, - ); - } - - public function testPagingOnAWithheldFilterReturnsNothing(): void - { - $this->authenticateUser(); - - $search = Operations::search(Filters::equal('userPassword', 'anything')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(); - - $paging = $this->ldapClient()->paging($search, 2); - $found = 0; - - while ($paging->hasEntries()) { - $found += count($paging->getEntries()); - } - - self::assertSame( - 0, - $found, - ); - } - - public function testPagingCanBeAbandoned(): void - { - $this->authenticateUser(); - - $search = Operations::search(Filters::present('objectClass')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(); - - $paging = $this->ldapClient()->paging($search, 1); - - // Get the first page only, then abandon - $paging->getEntries(); - $paging->end(); - - // After abandonment, hasEntries() must return false - self::assertFalse($paging->hasEntries()); - } - - public function testSubstringStartsWithMatches(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::startsWith('cn', 'al')) - ->base('ou=people,dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(1, $entries); - self::assertSame( - 'cn=alice,ou=people,dc=foo,dc=bar', - $entries->first()?->getDn()->toString(), - ); - } - - public function testSubstringContainsMatches(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::contains('cn', 'lic')) - ->base('ou=people,dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(1, $entries); - self::assertSame( - 'cn=alice,ou=people,dc=foo,dc=bar', - $entries->first()?->getDn()->toString(), - ); - } - - public function testSubstringEndsWithMatches(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::endsWith('cn', 'ice')) - ->base('ou=people,dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(1, $entries); - self::assertSame( - 'cn=alice,ou=people,dc=foo,dc=bar', - $entries->first()?->getDn()->toString(), - ); - } - - public function testGteAsciiExcludesLowerValue(): void - { - $this->authenticateUser(); - - // Scope to ou=people so cn=user (which would match cn >= 'alicf') is excluded. - $entries = $this->ldapClient()->search( - Operations::search(Filters::gte('cn', 'alicf')) - ->base('ou=people,dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - // 'alice' < 'alicf' lexicographically - self::assertCount(0, $entries); - } - - public function testLteAsciiIncludesMatchingValue(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::and( - Filters::present('cn'), - Filters::lte('cn', 'alice'), - )) - ->base('ou=people,dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(1, $entries); - self::assertSame( - 'cn=alice,ou=people,dc=foo,dc=bar', - $entries->first()?->getDn()->toString(), - ); - } - - /** - * uidNumber declares the INTEGER syntax, so '99' is below '100' rather than above it bytewise. - */ - public function testGteOnAnIntegerAttributeOrdersNumerically(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::gte('uidNumber', '100')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(0, $entries); - } - - /** - * Bytewise, '99' would sort above '100' and be excluded. - */ - public function testLteOnAnIntegerAttributeOrdersNumerically(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::lte('uidNumber', '100')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - self::assertCount(1, $entries); - self::assertSame( - 'cn=alice,ou=people,dc=foo,dc=bar', - $entries->first()?->getDn()->toString(), - ); - } - - public function testNotEqualityExcludesMatches(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::and( - Filters::present('cn'), - Filters::not(Filters::equal('cn', 'alice')), - )) - ->base('ou=people,dc=foo,dc=bar') - ->useSubtreeScope(), - ); - - // Under ou=people only alice exists in the seed; NOT-equal alice leaves zero matches. - self::assertCount(0, $entries); - } - - public function testSortControlAscendingOrdersResults(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::present('sn')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - new SortingControl(SortKey::ascending('sn')), - ); - - $sns = array_map( - static fn(Entry $e): string => $e->get('sn')?->getValues()[0] ?? '', - $entries->toArray(), - ); - - // Seed: cn=user and cn=admin (sn=Admin), cn=alice (sn=Smith). Admin < Smith ascending. - self::assertSame( - ['Admin', 'Admin', 'Smith'], - $sns, - ); - } - - public function testSortControlDescendingOrdersResults(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::present('sn')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - new SortingControl(SortKey::descending('sn')), - ); - - $sns = array_map( - static fn(Entry $e): string => $e->get('sn')?->getValues()[0] ?? '', - $entries->toArray(), - ); - - // Seed: cn=user and cn=admin (sn=Admin), cn=alice (sn=Smith). Smith > Admin descending. - self::assertSame( - ['Smith', 'Admin', 'Admin'], - $sns, - ); - } - - public function testSortControlPlacesMissingAttributeLastWhenAscending(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::present('cn')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - new SortingControl(SortKey::ascending('sn')), - )->toArray(); - - // More than one seed entry lacks 'sn', so assert the ordering rather than which of them sorts last. - self::assertNull($entries[count($entries) - 1]->get('sn')); - self::assertNotNull($entries[0]->get('sn')); - } - - public function testSortControlPlacesMissingAttributeFirstWhenDescending(): void - { - $this->authenticateUser(); - - $entries = $this->ldapClient()->search( - Operations::search(Filters::present('cn')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - new SortingControl(SortKey::descending('sn')), - )->toArray(); - - // More than one seed entry lacks 'sn', so assert the ordering rather than which of them sorts first. - self::assertNull($entries[0]->get('sn')); - self::assertNotNull($entries[count($entries) - 1]->get('sn')); - } - - public function testInexactSearchTripsLookthroughLimit(): void - { - $this->stopServer(); - $this->createServerProcess( - 'tcp', - [ - ...static::storageExtraArgs(), - '--seed-entries=10', - '--max-search-lookthrough=3', - ], - ); - $this->authenticateUser(); - - $this->expectException(OperationException::class); - $this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); - - $this->ldapClient()->search( - Operations::search(Filters::endsWith('cn', 'zzz')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(), - ); - } - - public function testSearchDeclinesAliasDereferencing(): void - { - $this->stopServer(); - $this->createServerProcess('tcp', static::storageExtraArgs()); - $this->authenticateAdmin(); - - $this->ldapClient()->create(Entry::fromArray('cn=ref,dc=foo,dc=bar', [ - 'objectClass' => ['top', 'alias', 'extensibleObject'], - 'cn' => 'ref', - 'aliasedObjectName' => 'cn=user,dc=foo,dc=bar', - ])); - - $neverRequest = Operations::search(Filters::equal('cn', 'ref')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope(); - - self::assertCount( - 1, - $this->ldapClient()->search($neverRequest), - ); - - $derefRequest = Operations::search(Filters::equal('cn', 'ref')) - ->base('dc=foo,dc=bar') - ->useSubtreeScope() - ->setDereferenceAliases(SearchRequest::DEREF_ALWAYS); - - $this->expectException(OperationException::class); - $this->expectExceptionCode(ResultCode::ALIAS_DEREFERENCING_PROBLEM); - $this->ldapClient()->search($derefRequest); - } - /** * Hook for subclasses to route the shared server through a different backend. * diff --git a/tests/resources/seed/backend-storage-seed.ldif b/tests/resources/seed/backend-storage-seed.ldif index 106972f8..8c7ac20e 100644 --- a/tests/resources/seed/backend-storage-seed.ldif +++ b/tests/resources/seed/backend-storage-seed.ldif @@ -37,6 +37,8 @@ objectClass: extensibleObject cn: alice sn: Smith mail: alice@foo.bar +# Only reachable through its base type, so it shows whether attribute options are honored. +mail;lang-en: alice-en@foo.bar uidNumber: 99 # Matched case-exactly by the schema, so searches on it prove the configured schema is in play. employeeNumber: A1b2C3 @@ -45,3 +47,12 @@ dn: cn=nosn,dc=foo,dc=bar objectClass: groupOfNames cn: nosn member: cn=user,dc=foo,dc=bar + +# The RDN needs escaping, so it shows whether a DN assertion compares the value or its escaped spelling. Its +# uidNumber sorts below alice's textually and above it numerically, so ordering shows which rule was applied. +dn: cn=Smith\, John,dc=foo,dc=bar +objectClass: groupOfNames +objectClass: extensibleObject +cn: Smith, John +member: cn=user,dc=foo,dc=bar +uidNumber: 100 diff --git a/tests/support/Backend/Storage/BackendFactoryTrait.php b/tests/support/Backend/Storage/BackendFactoryTrait.php index 7d9ad604..60ebfcf0 100644 --- a/tests/support/Backend/Storage/BackendFactoryTrait.php +++ b/tests/support/Backend/Storage/BackendFactoryTrait.php @@ -14,6 +14,7 @@ namespace Tests\Support\FreeDSx\Ldap\Backend\Storage; use FreeDSx\Ldap\Schema\Schema; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; @@ -30,7 +31,8 @@ trait BackendFactoryTrait { /** - * Defaults to an empty schema with validation off, which is what most tests want. + * Defaults to the core schema with validation off: filter evaluation reads the schema to tell an unrecognized + * attribute type from one an entry merely lacks, so an empty schema would make every assertion Undefined. */ private static function makeWritableBackend( EntryStorageInterface $storage, @@ -39,15 +41,16 @@ private static function makeWritableBackend( ?SearchLimits $limits = null, ?ChangeRecorder $changeRecorder = null, ): WritableStorageBackend { - $schema ??= new Schema(); + $schema ??= SchemaResource::Core->load(); $limits ??= new SearchLimits(); + $filterEvaluator = new FilterEvaluator($schema); return new WritableStorageBackend( storage: $storage, searchStream: new SearchStreamBuilder( $storage, $limits, - new FilterEvaluator($schema), + $filterEvaluator, ), validator: $validator ?? new SchemaValidator( $schema, @@ -57,6 +60,7 @@ private static function makeWritableBackend( $schema, $limits, ), + filterEvaluator: $filterEvaluator, changeRecorder: $changeRecorder, ); } diff --git a/tests/unit/Entry/ChangesSpec.php b/tests/unit/Entry/ChangesTest.php similarity index 98% rename from tests/unit/Entry/ChangesSpec.php rename to tests/unit/Entry/ChangesTest.php index a7cd02fe..fb97fd0d 100644 --- a/tests/unit/Entry/ChangesSpec.php +++ b/tests/unit/Entry/ChangesTest.php @@ -17,7 +17,7 @@ use FreeDSx\Ldap\Entry\Changes; use PHPUnit\Framework\TestCase; -class ChangesSpec extends TestCase +class ChangesTest extends TestCase { private Changes $subject; diff --git a/tests/unit/Entry/OptionsSpec.php b/tests/unit/Entry/OptionsTest.php similarity index 83% rename from tests/unit/Entry/OptionsSpec.php rename to tests/unit/Entry/OptionsTest.php index d70e9982..cb0b5021 100644 --- a/tests/unit/Entry/OptionsSpec.php +++ b/tests/unit/Entry/OptionsTest.php @@ -17,7 +17,7 @@ use FreeDSx\Ldap\Entry\Options; use PHPUnit\Framework\TestCase; -class OptionsSpec extends TestCase +class OptionsTest extends TestCase { private Options $subject; @@ -77,6 +77,25 @@ public function test_it_should_sort_and_lowercase_the_string_representation_if_r ); } + public function test_it_should_sort_the_same_regardless_of_case_or_order(): void + { + self::assertSame( + (new Options('lang-en', 'lang-DE'))->toString(true), + (new Options('lang-EN', 'lang-de'))->toString(true), + ); + } + + public function test_it_should_sort_the_same_whether_or_not_an_option_was_read_first(): void + { + $warmed = new Options('lang-en', 'lang-DE'); + $warmed->has('lang-en'); + + self::assertSame( + (new Options('lang-en', 'lang-DE'))->toString(true), + $warmed->toString(true), + ); + } + public function test_it_should_have_a_string_representation(): void { self::assertSame( diff --git a/tests/unit/Protocol/ServerProtocolHandler/AssertionEvaluatorTest.php b/tests/unit/Protocol/ServerProtocolHandler/AssertionEvaluatorTest.php index d1f8a9dd..dfe3e0a4 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/AssertionEvaluatorTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/AssertionEvaluatorTest.php @@ -17,6 +17,7 @@ use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\AccessControl\Target\Target; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Token\BindToken; @@ -40,7 +41,7 @@ protected function setUp(): void $this->targetDn = new Dn('cn=foo,dc=ex,dc=com'); $this->token = BindToken::fromDn('cn=foo,dc=ex,dc=com'); $this->subject = new AssertionEvaluator( - new FilterEvaluator(), + new FilterEvaluator(SchemaResource::Core->load()), $this->backend, new RuleBasedAccessControl(AclRules::fromEmpty()), ); @@ -162,7 +163,7 @@ public function test_a_readable_attribute_still_asserts_normally_under_the_same_ private function denyingUserPassword(): AssertionEvaluator { return new AssertionEvaluator( - new FilterEvaluator(), + new FilterEvaluator(SchemaResource::Core->load()), $this->backend, new RuleBasedAccessControl(AclRules::fromEmpty(attributes: [ AttributeRule::deny( diff --git a/tests/unit/Schema/Matching/Comparator/IntegerComparatorTest.php b/tests/unit/Schema/Matching/Comparator/IntegerComparatorTest.php index 7f9809d3..920f9547 100644 --- a/tests/unit/Schema/Matching/Comparator/IntegerComparatorTest.php +++ b/tests/unit/Schema/Matching/Comparator/IntegerComparatorTest.php @@ -68,6 +68,87 @@ public function test_compare_greater_than(): void self::assertGreaterThan(0, $result); } + public function test_equals_rejects_a_value_with_trailing_text(): void + { + self::assertFalse($this->subject->equals( + '99', + '99abc', + )); + } + + public function test_equals_rejects_a_non_numeric_value(): void + { + self::assertFalse($this->subject->equals( + '0', + 'abc', + )); + self::assertFalse($this->subject->equals( + 'abc', + 'abc', + )); + } + + public function test_equals_beyond_the_platform_integer(): void + { + self::assertFalse($this->subject->equals( + '9223372036854775808', + '9223372036854775809', + )); + self::assertTrue($this->subject->equals( + '9223372036854775808', + '09223372036854775808', + )); + } + + public function test_equals_signed_values(): void + { + self::assertTrue($this->subject->equals( + '-42', + '-42', + )); + self::assertTrue($this->subject->equals( + '+42', + '42', + )); + self::assertFalse($this->subject->equals( + '-42', + '42', + )); + self::assertTrue($this->subject->equals( + '-0', + '0', + )); + } + + public function test_compare_orders_negatives_below_positives(): void + { + self::assertLessThan( + 0, + $this->subject->compare( + '-100', + '3', + ), + ); + self::assertGreaterThan( + 0, + $this->subject->compare( + '-3', + '-100', + ), + ); + } + + public function test_compare_beyond_the_platform_integer(): void + { + self::assertLessThan( + 0, + $this->subject->compare( + '9223372036854775807', + '9223372036854775808', + ), + ); + } + public function test_substring_always_returns_false(): void { $result = $this->subject->substringMatches( diff --git a/tests/unit/Schema/Matching/Comparator/NumericStringComparatorTest.php b/tests/unit/Schema/Matching/Comparator/NumericStringComparatorTest.php index e2a906d8..cdb58e25 100644 --- a/tests/unit/Schema/Matching/Comparator/NumericStringComparatorTest.php +++ b/tests/unit/Schema/Matching/Comparator/NumericStringComparatorTest.php @@ -36,6 +36,14 @@ public function test_equals_treats_spaces_as_insignificant(): void self::assertTrue($result); } + public function test_equals_folds_compatibility_digits(): void + { + self::assertTrue($this->subject->equals( + "\u{FF11}\u{FF12}\u{FF13}", + '123', + )); + } + public function test_equals_does_not_match_different_digits(): void { $result = $this->subject->equals( diff --git a/tests/unit/Schema/Matching/Comparator/TelephoneNumberComparatorTest.php b/tests/unit/Schema/Matching/Comparator/TelephoneNumberComparatorTest.php index 4b893f7c..9379c575 100644 --- a/tests/unit/Schema/Matching/Comparator/TelephoneNumberComparatorTest.php +++ b/tests/unit/Schema/Matching/Comparator/TelephoneNumberComparatorTest.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Schema\Matching\Comparator\TelephoneNumberComparator; use FreeDSx\Ldap\Schema\Matching\SubstringAssertion; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class TelephoneNumberComparatorTest extends TestCase @@ -36,6 +37,40 @@ public function test_equals_strips_spaces_and_hyphens(): void self::assertTrue($result); } + /** + * RFC 4518 2.6.3 names seven hyphens, not just the ASCII one. + */ + #[DataProvider('hyphenProvider')] + public function test_equals_strips_every_insignificant_hyphen(string $hyphen): void + { + self::assertTrue($this->subject->equals( + '555' . $hyphen . '1234', + '5551234', + )); + } + + /** + * @return iterable + */ + public static function hyphenProvider(): iterable + { + yield 'hyphen-minus' => ["\u{002D}"]; + yield 'armenian hyphen' => ["\u{058A}"]; + yield 'hyphen' => ["\u{2010}"]; + yield 'non-breaking hyphen' => ["\u{2011}"]; + yield 'minus sign' => ["\u{2212}"]; + yield 'small hyphen-minus' => ["\u{FE63}"]; + yield 'fullwidth hyphen-minus' => ["\u{FF0D}"]; + } + + public function test_equals_folds_compatibility_digits(): void + { + self::assertTrue($this->subject->equals( + "\u{FF15}\u{FF15}\u{FF15}", + '555', + )); + } + public function test_equals_case_insensitive(): void { $result = $this->subject->equals('1-800-FOO', '1800foo'); diff --git a/tests/unit/Search/Filter/MatchingRuleFilterTest.php b/tests/unit/Search/Filter/MatchingRuleFilterTest.php index 4254404c..c3d127c4 100644 --- a/tests/unit/Search/Filter/MatchingRuleFilterTest.php +++ b/tests/unit/Search/Filter/MatchingRuleFilterTest.php @@ -144,12 +144,15 @@ public function test_it_should_get_the_string_filter_representation(): void ); } + /** + * RFC 4515 3 orders the components as attr [":dn"] [":" matchingrule] ":=" value. + */ public function test_it_should_get_the_filter_representation_with_a_dn_match(): void { $this->subject->setUseDnAttributes(true); self::assertSame( - '(bar:foo:dn:=foobar)', + '(bar:dn:foo:=foobar)', $this->subject->toString(), ); } diff --git a/tests/unit/Search/FilterParserTest.php b/tests/unit/Search/FilterParserTest.php index 6f20b79f..6f215ab1 100644 --- a/tests/unit/Search/FilterParserTest.php +++ b/tests/unit/Search/FilterParserTest.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Exception\FilterParseException; use FreeDSx\Ldap\Search\Filter\AndFilter; +use FreeDSx\Ldap\Search\Filter\MatchingRuleFilter; use FreeDSx\Ldap\Search\Filter\OrFilter; use FreeDSx\Ldap\Search\FilterParser; use FreeDSx\Ldap\Search\Filters; @@ -213,6 +214,55 @@ public function test_it_should_not_allow_a_not_filter_to_contain_more_than_one_f FilterParser::parse('(!(foo=bar)(bar=baz))'); } + /** + * RFC 4515 3 defines "not" over any single filter, which includes a nested container. + */ + public function test_it_should_parse_a_not_filter_wrapping_a_container(): void + { + self::assertEquals( + Filters::not(Filters::and( + Filters::equal('foo', 'bar'), + Filters::equal('bar', 'baz'), + )), + FilterParser::parse('(!(&(foo=bar)(bar=baz)))'), + ); + } + + public function test_it_should_unescape_the_initial_substring(): void + { + self::assertEquals( + Filters::startsWith('cn', '*a'), + FilterParser::parse('(cn=\2aa*)'), + ); + } + + public function test_it_should_unescape_the_final_substring(): void + { + self::assertEquals( + Filters::endsWith('cn', 'a*'), + FilterParser::parse('(cn=*a\2a)'), + ); + } + + public function test_it_should_only_use_dn_attributes_when_asked_to(): void + { + $withoutDn = FilterParser::parse('(cn:2.5.13.5:=foo)'); + $withDn = FilterParser::parse('(cn:dn:2.5.13.5:=foo)'); + + self::assertInstanceOf(MatchingRuleFilter::class, $withoutDn); + self::assertInstanceOf(MatchingRuleFilter::class, $withDn); + self::assertFalse($withoutDn->getUseDnAttributes()); + self::assertTrue($withDn->getUseDnAttributes()); + } + + public function test_it_should_accept_dn_attributes_in_any_case(): void + { + $filter = FilterParser::parse('(:DN:2.4.6.8.10:=Dino)'); + + self::assertInstanceOf(MatchingRuleFilter::class, $filter); + self::assertTrue($filter->getUseDnAttributes()); + } + public function test_it_should_decode_hex_encoded_values(): void { self::assertEquals( @@ -258,11 +308,15 @@ public function test_it_should_error_on_unrecognized_values_at_the_end_of_the_fi FilterParser::parse($filter); } - public function test_it_should_error_on_empty_values(): void + /** + * RFC 4515 3 permits an empty assertionvalue, and section 4 gives "(seeAlso=)" as an example. + */ + public function test_it_should_accept_an_empty_value(): void { - self::expectException(FilterParseException::class); - - FilterParser::parse('(foo=)'); + self::assertSame( + '(foo=)', + FilterParser::parse('(foo=)')->toString(), + ); } public function test_it_should_error_on_unrecognized_operators(): void diff --git a/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php index e79f4811..574e6acc 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/MysqlFilterTranslatorTest.php @@ -24,7 +24,9 @@ use FreeDSx\Ldap\Search\Filter\PresentFilter; use FreeDSx\Ldap\Search\Filter\SubstringFilter; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\MysqlFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\AttributeFilterSupport; use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; +use FreeDSx\Ldap\Server\Backend\Storage\FilterAttributeContextInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -206,9 +208,13 @@ public function test_gte_filter_emits_sidecar_value_gte(): void public function test_gte_emits_numeric_cast_for_integer_ordered_attribute(): void { + $context = $this->createMock(FilterAttributeContextInterface::class); + $context->method('isIntegerOrdered')->willReturn(true); + $context->method('filterSupport')->willReturn(AttributeFilterSupport::Exact); + $result = $this->subject->translate( new GreaterThanOrEqualFilter('uidNumber', '30'), - fn(string $attribute): bool => true, + $context, ); self::assertNotNull($result); @@ -625,7 +631,7 @@ public function test_not_present_emits_plain_not_without_presence_guard(): void ); } - public function test_not_equality_adds_presence_guard(): void + public function test_not_equality_emits_plain_not_for_the_evaluator_to_refine(): void { $result = $this->subject->translate( new NotFilter(new EqualityFilter( @@ -636,7 +642,7 @@ public function test_not_equality_adds_presence_guard(): void self::assertNotNull($result); self::assertStringStartsWith( - '(NOT (', + 'NOT (', $result->sql, ); self::assertStringContainsString( @@ -663,7 +669,7 @@ public function test_not_equality_with_non_ascii_value_is_inexact(): void self::assertFalse($result->isExact); } - public function test_not_composite_inner_is_inexact(): void + public function test_not_composite_inner_stays_exact_when_every_leaf_is(): void { $result = $this->subject->translate( new NotFilter(new AndFilter( @@ -679,7 +685,7 @@ public function test_not_composite_inner_is_inexact(): void ); self::assertNotNull($result); - self::assertFalse($result->isExact); + self::assertTrue($result->isExact); self::assertStringStartsWith( 'NOT (', $result->sql, diff --git a/tests/unit/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandlerTest.php b/tests/unit/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandlerTest.php new file mode 100644 index 00000000..55f115f9 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/Operation/WriteEntryOperationHandlerTest.php @@ -0,0 +1,120 @@ + + * + * 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\Adapter\Operation; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; +use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; +use LogicException; +use PHPUnit\Framework\TestCase; + +final class WriteEntryOperationHandlerTest extends TestCase +{ + private WriteEntryOperationHandler $subject; + + private Entry $entry; + + protected function setUp(): void + { + $this->subject = new WriteEntryOperationHandler(); + $this->entry = new Entry( + new Dn('cn=alice,dc=example,dc=com'), + new Attribute('cn', 'alice'), + new Attribute('mail', 'alice@example.com'), + new Attribute('sn', 'Anderson'), + ); + } + + public function test_an_update_does_not_modify_the_source_entry(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [Change::replace(new Attribute('sn'))], + ); + + $this->subject->apply($this->entry, $command); + + self::assertSame( + ['Anderson'], + $this->entry->get('sn')?->getValues(), + ); + } + + public function test_an_update_adding_a_value_does_not_modify_the_source_entry(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'second@example.com')], + ); + + $this->subject->apply($this->entry, $command); + + self::assertSame( + ['alice@example.com'], + $this->entry->get('mail')?->getValues(), + ); + } + + public function test_a_move_deleting_the_old_rdn_does_not_modify_the_source_entry(): void + { + $command = new MoveCommand( + new Dn('cn=alice,dc=example,dc=com'), + new Rdn('cn', 'bob'), + true, + null, + ); + + $this->subject->apply($this->entry, $command); + + self::assertSame( + ['alice'], + $this->entry->get('cn')?->getValues(), + ); + self::assertSame( + 'cn=alice,dc=example,dc=com', + $this->entry->getDn()->toString(), + ); + } + + public function test_an_update_returns_the_applied_changes_on_the_copy(): void + { + $command = new UpdateCommand( + new Dn('cn=alice,dc=example,dc=com'), + [new Change(Change::TYPE_ADD, 'mail', 'second@example.com')], + ); + + $result = $this->subject->apply($this->entry, $command); + + self::assertSame( + ['alice@example.com', 'second@example.com'], + $result->get('mail')?->getValues(), + ); + } + + public function test_an_unsupported_command_is_rejected(): void + { + $this->expectException(LogicException::class); + + $this->subject->apply( + $this->entry, + new class implements WriteRequestInterface {}, + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/Pdo/PdoListQueryBuilderTest.php b/tests/unit/Server/Backend/Storage/Adapter/Pdo/PdoListQueryBuilderTest.php index 3b5547a4..8cc5a665 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/Pdo/PdoListQueryBuilderTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/Pdo/PdoListQueryBuilderTest.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Control\Sorting\SortKey; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\MysqlDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SortKeySpec; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Query\PdoListQueryBuilder; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterResult; @@ -46,6 +47,38 @@ public function test_sqlite_ascending_orders_nulls_last_with_one_param(): void self::assertSame(['cn'], $params); } + public function test_sqlite_orders_a_numeric_key_as_a_number(): void + { + $query = (new PdoListQueryBuilder(new SqliteDialect()))->build( + '', + true, + null, + null, + [self::spec(SortKey::ascending('uidNumber'), numeric: true)], + ); + + self::assertStringContainsString( + 'MIN(CAST(eav.value_lower AS INTEGER))', + $query->sql, + ); + } + + public function test_mysql_orders_a_numeric_key_as_a_number(): void + { + $query = (new PdoListQueryBuilder(new MysqlDialect()))->build( + '', + true, + null, + null, + [self::spec(SortKey::ascending('uidNumber'), numeric: true)], + ); + + self::assertStringContainsString( + 'MIN(CAST(eav.value_lower AS SIGNED))', + $query->sql, + ); + } + public function test_sqlite_descending_orders_nulls_first_with_one_param(): void { [$sql, $params] = $this->rootQuery( @@ -219,7 +252,7 @@ public function test_sort_keys_disable_the_streaming_fast_path(): void true, $this->sidecarLeaf(), 500, - [SortKey::ascending('cn')], + [self::spec(SortKey::ascending('cn'))], ); self::assertStringNotContainsString( @@ -361,9 +394,23 @@ private function rootQuery( true, $filter, null, - $sortKeys, + array_values(array_map( + self::spec(...), + $sortKeys, + )), ); return [$query->sql, $query->params]; } + + private static function spec( + SortKey $sortKey, + bool $numeric = false, + ): SortKeySpec { + return new SortKeySpec( + strtolower($sortKey->getAttribute()), + $sortKey->getUseReverseOrder() ? 'DESC' : 'ASC', + $numeric, + ); + } } diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php index 393123d9..f64f5cd1 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php @@ -25,7 +25,9 @@ use FreeDSx\Ldap\Search\Filter\SubstringFilter; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; +use FreeDSx\Ldap\Server\Backend\Storage\AttributeFilterSupport; use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; +use FreeDSx\Ldap\Server\Backend\Storage\FilterAttributeContextInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -143,7 +145,7 @@ public function test_or_composes_correlated_exists_with_or(): void ); } - public function test_not_value_correlated_sql_keeps_the_presence_guard(): void + public function test_not_value_correlated_sql_negates_the_value_exists(): void { $result = $this->subject->translate(new NotFilter( new EqualityFilter('cn', 'Alice'), @@ -155,9 +157,8 @@ public function test_not_value_correlated_sql_keeps_the_presence_guard(): void 'NOT (', $result->correlatedSql, ); - // Presence guard EXISTS plus the negated value EXISTS. self::assertSame( - 2, + 1, substr_count($result->correlatedSql, 'EXISTS ('), ); } @@ -249,11 +250,51 @@ public function test_equality_value_is_lowercased_and_truncated(): void ); } + public function test_an_attribute_the_schema_does_not_define_can_never_match(): void + { + $result = $this->subject->translate( + new EqualityFilter('shoeSize', '12'), + $this->attributeContext(support: AttributeFilterSupport::NeverMatches), + ); + + self::assertNotNull($result); + self::assertSame( + '1 = 0', + $result->sql, + ); + // Undefined only behaves like false until a negation is layered over it, so the evaluator must still run. + self::assertFalse($result->isExact); + } + + public function test_negating_an_attribute_the_schema_does_not_define_can_never_match(): void + { + $result = $this->subject->translate( + new NotFilter(new EqualityFilter('shoeSize', '12')), + $this->attributeContext(support: AttributeFilterSupport::NeverMatches), + ); + + self::assertNotNull($result); + self::assertStringContainsString( + '1 = 0', + $result->sql, + ); + } + + public function test_an_attribute_with_subtypes_is_left_to_the_evaluator(): void + { + $result = $this->subject->translate( + new EqualityFilter('name', 'alice'), + $this->attributeContext(support: AttributeFilterSupport::NeedsEvaluator), + ); + + self::assertNull($result); + } + public function test_gte_emits_numeric_cast_for_integer_ordered_attribute(): void { $result = $this->subject->translate( new GreaterThanOrEqualFilter('uidNumber', '30'), - fn(string $attribute): bool => true, + $this->attributeContext(integerOrdered: true), ); self::assertNotNull($result); @@ -272,7 +313,7 @@ public function test_gte_emits_lexical_comparison_for_non_integer_attribute(): v { $result = $this->subject->translate( new GreaterThanOrEqualFilter('cn', '30'), - fn(string $attribute): bool => false, + $this->attributeContext(integerOrdered: false), ); self::assertNotNull($result); @@ -321,7 +362,7 @@ public function test_lte_emits_numeric_cast_for_integer_ordered_attribute(): voi { $result = $this->subject->translate( new LessThanOrEqualFilter('uidNumber', '50'), - fn(string $attribute): bool => true, + $this->attributeContext(integerOrdered: true), ); self::assertNotNull($result); @@ -752,7 +793,7 @@ public function test_not_present_emits_plain_not(): void self::assertTrue($result->isExact); } - public function test_not_equality_adds_presence_guard(): void + public function test_not_equality_emits_plain_not_for_the_evaluator_to_refine(): void { $result = $this->subject->translate( new NotFilter(new EqualityFilter( @@ -763,7 +804,7 @@ public function test_not_equality_adds_presence_guard(): void self::assertNotNull($result); self::assertStringStartsWith( - '(NOT (', + 'NOT (', $result->sql, ); self::assertStringContainsString( @@ -982,4 +1023,15 @@ public function test_or_exposes_no_drivable_leaves(): void $result->drivableLeaves, ); } + + private function attributeContext( + ?bool $integerOrdered = null, + AttributeFilterSupport $support = AttributeFilterSupport::Exact, + ): FilterAttributeContextInterface { + $context = $this->createMock(FilterAttributeContextInterface::class); + $context->method('isIntegerOrdered')->willReturn($integerOrdered); + $context->method('filterSupport')->willReturn($support); + + return $context; + } } diff --git a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php index cb1c7c15..dbf09081 100644 --- a/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php +++ b/tests/unit/Server/Backend/Storage/Export/DirectoryDumperTest.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Ldif\LdifOutputOptions; use FreeDSx\Ldap\Ldif\LdifWriter; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; @@ -25,6 +26,7 @@ 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\FilterEvaluator; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; use PHPUnit\Framework\TestCase; @@ -33,10 +35,7 @@ final class DirectoryDumperTest extends TestCase { public function test_it_yields_the_version_header_first_when_enabled(): void { - $dumper = new DirectoryDumper( - $this->storageWithEntries(), - [new Dn('dc=foo,dc=bar')], - ); + $dumper = $this->makeDumper(); $chunks = iterator_to_array( $dumper->dump(new DumpOptions()), @@ -52,11 +51,7 @@ public function test_it_yields_the_version_header_first_when_enabled(): void public function test_it_omits_the_version_header_when_disabled(): void { $writer = new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)); - $dumper = new DirectoryDumper( - $this->storageWithEntries(), - [new Dn('dc=foo,dc=bar')], - writer: $writer, - ); + $dumper = $this->makeDumper(writer: $writer); $chunks = iterator_to_array( $dumper->dump(new DumpOptions()), @@ -71,9 +66,7 @@ public function test_it_omits_the_version_header_when_disabled(): void public function test_it_iterates_entries_across_naming_contexts_when_no_base_is_set(): void { - $dumper = new DirectoryDumper( - $this->storageWithEntries(), - [new Dn('dc=foo,dc=bar')], + $dumper = $this->makeDumper( writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -98,9 +91,7 @@ public function test_it_iterates_entries_across_naming_contexts_when_no_base_is_ public function test_it_restricts_to_the_options_base_dn_when_set(): void { - $dumper = new DirectoryDumper( - $this->storageWithEntries(), - [new Dn('dc=foo,dc=bar')], + $dumper = $this->makeDumper( writer: new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -147,9 +138,8 @@ public function test_it_re_evaluates_the_filter_when_the_stream_is_not_preFilter => $entry->getDn()->toString() === 'cn=alice,dc=foo,dc=bar', ); - $dumper = new DirectoryDumper( + $dumper = $this->makeDumper( $storage, - [new Dn('dc=foo,dc=bar')], $evaluator, new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -182,9 +172,8 @@ public function test_it_does_not_re_evaluate_the_filter_when_the_stream_is_preFi $evaluator = $this->createMock(FilterEvaluatorInterface::class); $evaluator->expects(self::never())->method('evaluate'); - $dumper = new DirectoryDumper( + $dumper = $this->makeDumper( $storage, - [new Dn('dc=foo,dc=bar')], $evaluator, new LdifWriter((new LdifOutputOptions())->setIncludeVersion(false)), ); @@ -210,10 +199,7 @@ public function test_it_passes_match_all_to_storage_when_no_filter_is_set(): voi })(), )); - $dumper = new DirectoryDumper( - $storage, - [new Dn('dc=foo,dc=bar')], - ); + $dumper = $this->makeDumper($storage); iterator_to_array( $dumper->dump(new DumpOptions()), @@ -221,6 +207,22 @@ public function test_it_passes_match_all_to_storage_when_no_filter_is_set(): voi ); } + /** + * The subject, with only the collaborator a test cares about supplied. + */ + private function makeDumper( + ?EntryStorageInterface $storage = null, + ?FilterEvaluatorInterface $filterEvaluator = null, + ?LdifWriter $writer = null, + ): DirectoryDumper { + return new DirectoryDumper( + $storage ?? $this->storageWithEntries(), + [new Dn('dc=foo,dc=bar')], + $filterEvaluator ?? new FilterEvaluator(SchemaResource::Core->load()), + $writer ?? new LdifWriter(), + ); + } + private function storageWithEntries(): InMemoryStorage { return new InMemoryStorage([ diff --git a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php index a329d482..63ddbc0e 100644 --- a/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php +++ b/tests/unit/Server/Backend/Storage/FilterEvaluatorTest.php @@ -36,7 +36,7 @@ final class FilterEvaluatorTest extends TestCase protected function setUp(): void { - $this->subject = new FilterEvaluator(); + $this->subject = new FilterEvaluator(SchemaResource::Core->load()); $this->entry = new Entry( new Dn('cn=Alice,dc=example,dc=com'), @@ -491,14 +491,14 @@ public function test_matching_rule_bit_and_no_match(): void )); } - public function test_matching_rule_unknown_throws_inappropriate_matching(): void + public function test_matching_rule_unknown_is_undefined_rather_than_an_error(): void { - $this->expectException(OperationException::class); - $this->expectExceptionCode(ResultCode::INAPPROPRIATE_MATCHING); - - $this->subject->evaluate( - $this->entry, - new MatchingRuleFilter('1.2.3.4.5.unknown', 'cn', 'Alice'), + // RFC 4511 4.5.1.7: "Servers MUST NOT return errors if ... matching rule ids are not recognized". + self::assertFalse( + $this->subject->evaluate( + $this->entry, + new MatchingRuleFilter('1.2.3.4.5.unknown', 'cn', 'Alice'), + ), ); } @@ -583,11 +583,11 @@ public function test_matching_rule_null_attribute_matches_all(): void )); } - public function test_not_returns_false_when_attribute_is_absent(): void + public function test_not_returns_true_when_a_recognizable_attribute_is_absent(): void { - // RFC 4511 §4.5.1: NOT(UNDEFINED) = UNDEFINED, which maps to false. - // An entry missing "description" must NOT match (!(description=test)). - self::assertFalse( + // Undefined is for what the server cannot determine (RFC 4511 4.5.1.7); an absent value is simply false, + // exactly as it already is for a present filter on the same attribute. + self::assertTrue( $this->subject->evaluate( $this->entry, Filters::not(Filters::equal('description', 'test')), @@ -893,18 +893,6 @@ public function test_schema_equality_uses_octet_string_match_for_userpassword(): ); } - public function test_equality_without_schema_falls_back_to_case_ignore_for_userpassword(): void - { - $entry = new Entry( - new Dn('cn=Test,dc=example,dc=com'), - new Attribute('userPassword', 'Secret'), - ); - - self::assertTrue( - $this->subject->evaluate($entry, Filters::equal('userPassword', 'secret')), - ); - } - public function test_schema_equality_collapses_whitespace_for_cn(): void { $subject = new FilterEvaluator(SchemaResource::Core->load()); @@ -955,14 +943,13 @@ public function test_schema_matching_rule_filter_resolves_non_hardcoded_oid(): v )); } - public function test_without_schema_non_hardcoded_matching_rule_throws_inappropriate_matching(): void + public function test_without_schema_a_non_hardcoded_matching_rule_is_undefined(): void { - $this->expectException(OperationException::class); - $this->expectExceptionCode(ResultCode::INAPPROPRIATE_MATCHING); - - $this->subject->evaluate( - $this->entry, - new MatchingRuleFilter('2.5.13.14', 'cn', 'Alice'), + self::assertFalse( + $this->subject->evaluate( + $this->entry, + new MatchingRuleFilter('2.5.13.14', 'cn', 'Alice'), + ), ); } diff --git a/tests/unit/Server/Middleware/AssertionMiddlewareTest.php b/tests/unit/Server/Middleware/AssertionMiddlewareTest.php index 1ddad9f1..9fc41691 100644 --- a/tests/unit/Server/Middleware/AssertionMiddlewareTest.php +++ b/tests/unit/Server/Middleware/AssertionMiddlewareTest.php @@ -27,6 +27,7 @@ use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\AccessControl\AclRules; use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Middleware\AssertionMiddleware; @@ -56,7 +57,7 @@ protected function setUp(): void )); $this->subject = new AssertionMiddleware(new AssertionEvaluator( - new FilterEvaluator(), + new FilterEvaluator(SchemaResource::Core->load()), $this->backend, new RuleBasedAccessControl(AclRules::fromEmpty()), )); diff --git a/tests/unit/Server/PasswordPolicy/PasswordPolicyResolverTest.php b/tests/unit/Server/PasswordPolicy/PasswordPolicyResolverTest.php index 85a226c0..553c7c19 100644 --- a/tests/unit/Server/PasswordPolicy/PasswordPolicyResolverTest.php +++ b/tests/unit/Server/PasswordPolicy/PasswordPolicyResolverTest.php @@ -22,7 +22,10 @@ use FreeDSx\Ldap\Schema\Definition\ObjectClassOid; use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Schema\SchemaResource; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Subentry\GoverningSubentryResolver; +use FreeDSx\Ldap\Server\Subentry\SubtreeSpecificationEvaluator; use Generator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -292,7 +295,12 @@ private function governedBy( yield $subentry; })())); - return new GoverningSubentryResolver($backend); + return new GoverningSubentryResolver( + $backend, + new SubtreeSpecificationEvaluator( + new FilterEvaluator(SchemaResource::Core->load()), + ), + ); } /** diff --git a/tests/unit/Server/Subentry/SubtreeSpecificationEvaluatorTest.php b/tests/unit/Server/Subentry/SubtreeSpecificationEvaluatorTest.php index 549c6118..134c916b 100644 --- a/tests/unit/Server/Subentry/SubtreeSpecificationEvaluatorTest.php +++ b/tests/unit/Server/Subentry/SubtreeSpecificationEvaluatorTest.php @@ -15,6 +15,8 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\SchemaResource; +use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Subentry\SubtreeSpecificationEvaluator; use FreeDSx\Ldap\Server\Subentry\SubtreeSpecificationParser; use PHPUnit\Framework\TestCase; @@ -29,7 +31,9 @@ final class SubtreeSpecificationEvaluatorTest extends TestCase protected function setUp(): void { - $this->subject = new SubtreeSpecificationEvaluator(); + $this->subject = new SubtreeSpecificationEvaluator( + new FilterEvaluator(SchemaResource::Core->load()), + ); $this->parser = new SubtreeSpecificationParser(); }