diff --git a/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php index 38003b47..f0bb448c 100644 --- a/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php @@ -85,11 +85,12 @@ use FreeDSx\Ldap\Server\Backend\Write\Operation\ComputeUpdateHandler; use FreeDSx\Ldap\Server\Backend\Write\Operation\DeleteEntryHandler; use FreeDSx\Ldap\Server\Backend\Write\Operation\DeleteSubtreeHandler; -use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeChunkRemover; +use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete\SubtreeChunkRemover; use FreeDSx\Ldap\Server\Backend\Write\Operation\EntryMutation; use FreeDSx\Ldap\Server\Backend\Write\Operation\EntryPlacementGuard; use FreeDSx\Ldap\Server\Backend\Write\Operation\MoveEntryHandler; -use FreeDSx\Ldap\Server\Backend\Write\Operation\LinkedChanges; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChangeReplay; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChanges; use FreeDSx\Ldap\Server\Backend\Write\Operation\UpdateEntryHandler; use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolationGate; use FreeDSx\Ldap\Server\Subentry\SubentryPlacementGuard; @@ -439,6 +440,7 @@ private function makeUpdateEntryHandler(Container $container): UpdateEntryHandle mutation: $container->get(EntryMutation::class), placement: $container->get(EntryPlacementGuard::class), linkedChanges: $container->get(LinkedChanges::class), + replay: $container->get(LinkedChangeReplay::class), ); } diff --git a/src/FreeDSx/Ldap/Container/Provider/StorageContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/StorageContainerProvider.php index d6db1612..5d421b38 100644 --- a/src/FreeDSx/Ldap/Container/Provider/StorageContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/StorageContainerProvider.php @@ -18,7 +18,6 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\EntryIndexReindexer; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Schema\Matching\EqualityComparatorResolver; -use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Schema\Validation\Syntax\AttributeSyntaxResolver; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Query\EntryLister; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Query\EntryReader; @@ -33,7 +32,8 @@ use FreeDSx\Ldap\Server\Backend\Storage\Capability\RowLockableInterface; use FreeDSx\Ldap\Server\Backend\Storage\Capability\UnlockedRows; use FreeDSx\Ldap\Server\Backend\Storage\Filter\LinkedLeafWitness; -use FreeDSx\Ldap\Server\Backend\Write\Operation\LinkedChanges; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChangeReplay; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChanges; use FreeDSx\Ldap\Server\Backend\Storage\Contract\ListEntryInterface; use FreeDSx\Ldap\Server\Backend\Storage\Contract\ReadEntryInterface; use FreeDSx\Ldap\Server\Backend\Storage\Contract\TransactionalWriteInterface; @@ -76,6 +76,7 @@ public function factories(): array LinkedValueLookupInterface::class => $this->makeLinkedValueLookup(...), LinkedLeafWitness::class => $this->makeLinkedLeafWitness(...), LinkedChanges::class => $this->makeLinkedChanges(...), + LinkedChangeReplay::class => $this->makeLinkedChangeReplay(...), EntryIndexReindexer::class => $this->makeEntryIndexReindexer(...), ChangeJournalInterface::class => $this->makeChangeJournal(...), ]; @@ -146,10 +147,16 @@ private function makeLinkedChanges(Container $container): LinkedChanges { return new LinkedChanges( $container->get(LinkedAttributes::class), + $container->get(ServerOptions::class)->getSchema(), + ); + } + + private function makeLinkedChangeReplay(Container $container): LinkedChangeReplay + { + return new LinkedChangeReplay( $this->isPdo($container) ? $container->get(EntryLinkWriter::class) : $container->get(InMemoryStorage::class), - $container->get(SchemaValidator::class), ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php index 5625d91f..75d9c262 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -438,6 +438,28 @@ private function linkedValuesOf( ))); } + /** + * @param array $held + * @param list $removed + * + * @return list + */ + private static function withoutLinkValues( + array $held, + array $removed, + ): array { + $gone = []; + + foreach ($removed as $value) { + $gone[Dn::normalizedOrNull($value) ?? $value] = true; + } + + return array_values(array_filter( + $held, + static fn(string $value): bool => !isset($gone[Dn::normalizedOrNull($value) ?? $value]), + )); + } + /** * The entry carrying the linked values the one it replaces held. */ @@ -479,7 +501,7 @@ private function withLinks( foreach ($links->names() as $name) { $values = array_merge( - array_diff( + self::withoutLinkValues( $previous?->get($name, true)?->getValues() ?? [], $links->removed($name), ), diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Writer/EntryLinkWriter.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Writer/EntryLinkWriter.php index 105f9e34..2bf89730 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Writer/EntryLinkWriter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Writer/EntryLinkWriter.php @@ -172,14 +172,25 @@ public function heldLinkValues( return []; } $held = []; - $params = [ - $owner->normalizedString(), - Attribute::normalizeName($attribute), - ...array_keys($asked), - ]; - - foreach ($this->rowsOf($this->dialect->queryHeldLinkValues(count($asked)), $params) as $row) { - $held[] = $asked[$this->stringColumn($row['lc_dn'] ?? null)] ?? null; + + $chunks = array_chunk( + array_keys($asked), + self::DNS_PER_STATEMENT, + ); + + foreach ($chunks as $chunk) { + $rows = $this->rowsOf( + $this->dialect->queryHeldLinkValues(count($chunk)), + [ + $owner->normalizedString(), + Attribute::normalizeName($attribute), + ...$chunk, + ], + ); + + foreach ($rows as $row) { + $held[] = $asked[$this->stringColumn($row['lc_dn'] ?? null)] ?? null; + } } return array_values(array_filter($held)); diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/AppliesEntryUpdate.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/AppliesEntryUpdate.php index 2a3ad954..b056a359 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/AppliesEntryUpdate.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/AppliesEntryUpdate.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDelta; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; @@ -38,11 +39,13 @@ private function updated( UpdateCommand $command, WriteContext $context, Entry $current, + LinkDelta $links = new LinkDelta(), ): Entry { $updated = $this->mutation->forUpdate( $current, $command, $context, + $links, ); $this->placement->assertUpdatePlacement( $updated, diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeleteSubtreeHandler.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeleteSubtreeHandler.php index 61ed036d..1b3a926f 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeleteSubtreeHandler.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeleteSubtreeHandler.php @@ -18,6 +18,9 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteSubtreeCommand; +use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete\DeletionQueue; +use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete\SubtreeChunkRemover; +use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete\SubtreeDeleteProgress; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; use FreeDSx\Ldap\Server\Clock\Sleeper\SleeperInterface; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/EntryMutation.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/EntryMutation.php index 04dd4398..7dbf0ab3 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/EntryMutation.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/EntryMutation.php @@ -13,9 +13,11 @@ namespace FreeDSx\Ldap\Server\Backend\Write\Operation; +use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Operation\WriteEntryOperationHandler; +use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDelta; use FreeDSx\Ldap\Server\Backend\Write\OperationalAttributeGenerator; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; @@ -36,17 +38,23 @@ public function __construct( ) {} /** + * @param LinkDelta $links Linked values applied apart from the entry, whose additions are held to the schema with it. + * * @throws OperationException */ public function forUpdate( Entry $entry, UpdateCommand $command, WriteContext $context, + LinkDelta $links = new LinkDelta(), ): Entry { $updated = $this->entryHandler->apply($entry, $command); $this->schemaGate->assertModifyAllowed( $command, - $updated, + self::withLinkedAdditions( + $updated, + $links, + ), $context, ); $this->operationalAttrs->applyForModify( @@ -78,4 +86,29 @@ public function forMove( return $moved; } + + private static function withLinkedAdditions( + Entry $entry, + LinkDelta $links, + ): Entry { + if ($links->isEmpty()) { + return $entry; + } + $copy = $entry->makeCopy(); + + foreach ($links->names() as $name) { + $added = $links->added($name); + + if ($added === []) { + continue; + } + + $copy->add(new Attribute( + $name, + ...$added, + )); + } + + return $copy; + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/Linked/LinkedChangeReplay.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/Linked/LinkedChangeReplay.php new file mode 100644 index 00000000..8c7f25dc --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/Linked/LinkedChangeReplay.php @@ -0,0 +1,276 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write\Operation\Linked; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Storage\Capability\LinkedValueLookupInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDelta; +use FreeDSx\Ldap\Server\Backend\Storage\Search\EntryProjection; + +use function array_diff_key; +use function array_values; +use function sprintf; + +/** + * Applies a modify's linked changes in request order to what the owner already links. + * + * @internal + * + * @author Chad Sikorra + */ +readonly class LinkedChangeReplay +{ + public function __construct(private LinkedValueLookupInterface $lookup) {} + + /** + * What of the target the modify needs loaded: none of its links when changes are applied apart, all otherwise. + * + * @param list $changes + */ + public function projectionFor(array $changes): EntryProjection + { + return $changes === [] + ? EntryProjection::unbounded() + : new EntryProjection(linkCap: 0); + } + + /** + * RFC 4511 4.6: each change applies to the state the ones before it left. + * + * @param list $changes + * + * @throws OperationException when a value is already linked or is not linked + */ + public function settle( + array $changes, + Dn $owner, + ): LinkDelta { + if ($changes === []) { + return new LinkDelta(); + } + $before = $this->heldBefore( + $changes, + $owner, + ); + + return $this->netOf( + $before, + $this->replayed( + $before, + $changes, + ), + ); + } + + /** + * @param array> $state + * @param list $changes + * + * @return array> + * + * @throws OperationException + */ + private function replayed( + array $state, + array $changes, + ): array { + foreach ($changes as $change) { + $name = Attribute::normalizeName($change->getAttribute()->getName()); + $values = array_values($change->getAttribute()->getValues()); + + $state[$name] = $change->isAdd() + ? $this->withAdded( + $name, + $state[$name] ?? [], + $values, + ) + : $this->withRemoved( + $name, + $state[$name] ?? [], + $values, + ); + } + + return $state; + } + + /** + * What the owner links of every value the changes name. + * + * @param list $changes + * + * @return array> + */ + private function heldBefore( + array $changes, + Dn $owner, + ): array { + $named = []; + + foreach ($changes as $change) { + $name = Attribute::normalizeName($change->getAttribute()->getName()); + + foreach ($change->getAttribute()->getValues() as $value) { + $named[$name][self::keyOf($value)] = $value; + } + } + $held = []; + + foreach ($named as $name => $values) { + $heldValues = $this->lookup->heldLinkValues( + $owner, + $name, + array_values($values), + ); + + foreach ($heldValues as $value) { + $held[$name][self::keyOf($value)] = $value; + } + } + + return $held; + } + + /** + * @param array $current + * @param list $values + * + * @return array + * + * @throws OperationException + */ + private function withAdded( + string $name, + array $current, + array $values, + ): array { + $adding = []; + + foreach ($values as $value) { + $key = self::keyOf($value); + + if (isset($adding[$key])) { + throw new OperationException( + sprintf( + 'The attribute "%s" was given the same value twice.', + $name, + ), + ResultCode::ATTRIBUTE_OR_VALUE_EXISTS, + ); + } + if (isset($current[$key])) { + throw new OperationException( + sprintf( + 'The entry already holds a value given for the attribute "%s".', + $name, + ), + ResultCode::ATTRIBUTE_OR_VALUE_EXISTS, + ); + } + + $adding[$key] = $value; + } + + return $current + $adding; + } + + /** + * Every value is checked against the state before the change. + * + * @param array $current + * @param list $values + * + * @return array + * + * @throws OperationException + */ + private function withRemoved( + string $name, + array $current, + array $values, + ): array { + $removing = []; + + foreach ($values as $value) { + $key = self::keyOf($value); + + if (!isset($current[$key])) { + throw new OperationException( + sprintf( + 'The entry does not hold a value given for the attribute "%s".', + $name, + ), + ResultCode::NO_SUCH_ATTRIBUTE, + ); + } + + $removing[$key] = true; + } + + return array_diff_key( + $current, + $removing, + ); + } + + /** + * The values the replay left changed. + * + * @param array> $before + * @param array> $after + */ + private function netOf( + array $before, + array $after, + ): LinkDelta { + $added = []; + $removed = []; + + foreach ($after as $name => $values) { + $held = $before[$name] ?? []; + $linking = array_values(array_diff_key( + $values, + $held, + )); + $unlinking = array_values(array_diff_key( + $held, + $values, + )); + + if ($linking !== []) { + $added[$name] = $linking; + } + if ($unlinking !== []) { + $removed[$name] = $unlinking; + } + } + + // Changes that cancel out leave the stored links as they are. + return $added === [] && $removed === [] + ? LinkDelta::untouched() + : new LinkDelta( + $added, + $removed, + ); + } + + private static function keyOf(string $value): string + { + return Dn::normalizedOrNull($value) ?? $value; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/Linked/LinkedChanges.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/Linked/LinkedChanges.php new file mode 100644 index 00000000..cb7bb1c0 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/Linked/LinkedChanges.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write\Operation\Linked; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; +use FreeDSx\Ldap\Schema\Schema; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteContext; + +use function array_filter; +use function array_values; +use function strtolower; + +/** + * Separates the linked values a modify changes from the changes its entry is still stored whole for. + * + * @author Chad Sikorra + */ +final readonly class LinkedChanges +{ + public function __construct( + private LinkedAttributes $linked, + private Schema $schema, + ) {} + + /** + * The linked changes applied apart from the entry, or none when the modify has to be applied to the whole entry. + * + * @return list + */ + public function changesApart( + UpdateCommand $command, + WriteContext $context, + ): array { + // A control reading the entry needs every value it holds, which only the whole-entry path materializes. + if ($this->linked->isEmpty() || $context->controlEvaluator()?->inspectsEntry() === true) { + return []; + } + $apart = []; + + foreach ($command->changes as $change) { + // Changed classes are held to every linked value the entry keeps, which only the whole entry carries. + if (self::changesObjectClass($change)) { + return []; + } + if (!$this->linked->links($change->getAttribute())) { + continue; + } + + // One change the attribute has to be written whole for settles it for every value of that attribute. + if (!$this->isApplicable($change, $command->dn)) { + return []; + } + + $apart[] = $change; + } + + return $apart; + } + + /** + * The command without the changes applied apart, which is what the entry is still stored for. + * + * @param list $apart + */ + public function remaining( + UpdateCommand $command, + array $apart, + ): UpdateCommand { + if ($apart === []) { + return $command; + } + + return new UpdateCommand( + $command->dn, + array_values(array_filter( + $command->changes, + fn(Change $change): bool => !$this->linked->links($change->getAttribute()), + )), + $command->systemChanges, + ); + } + + private static function changesObjectClass(Change $change): bool + { + return Attribute::normalizeName($change->getAttribute()->getName()) + === strtolower(AttributeTypeOid::NAME_OBJECT_CLASS); + } + + /** + * Whether the change names the values it changes, rather than restating what the attribute holds. + */ + private function isApplicable( + Change $change, + Dn $dn, + ): bool { + // Naming the entry, so the attribute is held to the RDN it spells rather than changed on its own. + if ($dn->getRdn()->has($change->getAttribute()->getName())) { + return false; + } + if ($this->isHeldToEveryValue($change)) { + return false; + } + + if ($change->isAdd()) { + return $change->getAttribute()->getValues() !== []; + } + + // A delete naming no value empties the attribute, which takes knowing everything it holds. + return $change->isDelete() && !$change->isReset(); + } + + private function isHeldToEveryValue(Change $change): bool + { + $type = $this->schema->getAttributeType($change->getAttribute()->getName()); + + return $type !== null + && ($type->singleValue || $type->noUserModification); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/LinkedChanges.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/LinkedChanges.php deleted file mode 100644 index 80d76a73..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/LinkedChanges.php +++ /dev/null @@ -1,212 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Write\Operation; - -use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Change; -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Schema\Validation\SchemaValidator; -use FreeDSx\Ldap\Server\Backend\Storage\Capability\LinkedValueLookupInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDelta; -use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; -use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\WriteContext; - -use function array_diff; -use function array_filter; -use function array_values; -use function sprintf; - -/** - * Separates the linked values a modify changes from the changes its entry is still stored whole for. - * - * @author Chad Sikorra - */ -final readonly class LinkedChanges -{ - public function __construct( - private LinkedAttributes $linked, - private LinkedValueLookupInterface $lookup, - private SchemaValidator $schema, - ) {} - - /** - * Holds the delta to what the owner links, which the entry is no longer carrying for the validator to see. - * - * @throws OperationException when a value is already linked, is not linked, or is not a DN - */ - public function assertApplicable( - LinkDelta $delta, - Dn $owner, - ): void { - foreach ($delta->names() as $name) { - $added = $delta->added($name); - $removed = $delta->removed($name); - - $this->assertDistinctDns($name, $added); - $this->assertDistinctDns($name, $removed); - - $this->refuse( - $name, - $this->lookup->heldLinkValues($owner, $name, $added), - 'already holds', - ResultCode::ATTRIBUTE_OR_VALUE_EXISTS, - ); - $this->refuse( - $name, - array_values(array_diff( - $removed, - $this->lookup->heldLinkValues($owner, $name, $removed), - )), - 'does not hold', - ResultCode::NO_SUCH_ATTRIBUTE, - ); - } - } - - /** - * The values the modify links and unlinks or nothing when it has to be applied to the whole entry. - */ - public function delta( - UpdateCommand $command, - WriteContext $context, - ): LinkDelta { - // A control reading the entry needs every value it holds, which only the whole-entry path materializes. - if ($this->linked->isEmpty() || $context->controlEvaluator()?->inspectsEntry() === true) { - return new LinkDelta(); - } - $added = []; - $removed = []; - - foreach ($command->changes as $change) { - if (!$this->linked->links($change->getAttribute())) { - continue; - } - - // One change the attribute has to be written whole for settles it for every value of that attribute. - if (!$this->isApplicable($change, $command->dn)) { - return new LinkDelta(); - } - $name = Attribute::normalizeName($change->getAttribute()->getName()); - $values = array_values($change->getAttribute()->getValues()); - - if ($change->isAdd()) { - $added[$name] = [...$added[$name] ?? [], ...$values]; - - continue; - } - - $removed[$name] = [...$removed[$name] ?? [], ...$values]; - } - - return new LinkDelta($added, $removed); - } - - /** - * The command without the changes the delta carries, which is what the entry is still stored for. - */ - public function remaining( - UpdateCommand $command, - LinkDelta $delta, - ): UpdateCommand { - if ($delta->isEmpty()) { - return $command; - } - - return new UpdateCommand( - $command->dn, - array_values(array_filter( - $command->changes, - fn(Change $change): bool => !$this->linked->links($change->getAttribute()), - )), - $command->systemChanges, - ); - } - - /** - * Every value has to be a DN the attribute does not already name twice in the one request. - * - * @param list $values - * - * @throws OperationException - */ - private function assertDistinctDns( - string $name, - array $values, - ): void { - if ($values === []) { - return; - } - - // Held to the same syntax the whole entry would have been, which no longer carries these values. - $this->schema->validateValues(new Attribute($name, ...$values)); - $seen = []; - - foreach ($values as $value) { - $normalized = Dn::normalizedOrNull($value) ?? $value; - - if (isset($seen[$normalized])) { - throw new OperationException( - sprintf('The attribute "%s" was given the same value twice.', $name), - ResultCode::ATTRIBUTE_OR_VALUE_EXISTS, - ); - } - - $seen[$normalized] = true; - } - } - - /** - * @param list $offending - * - * @throws OperationException - */ - private function refuse( - string $name, - array $offending, - string $reason, - int $code, - ): void { - if ($offending === []) { - return; - } - - throw new OperationException( - sprintf('The entry %s a value given for the attribute "%s".', $reason, $name), - $code, - ); - } - - /** - * Whether the change names the values it changes, rather than restating what the attribute holds. - */ - private function isApplicable( - Change $change, - Dn $dn, - ): bool { - // Naming the entry, so the attribute is held to the RDN it spells rather than changed on its own. - if ($dn->getRdn()->has($change->getAttribute()->getName())) { - return false; - } - - if ($change->isAdd()) { - return $change->getAttribute()->getValues() !== []; - } - - // A delete naming no value empties the attribute, which takes knowing everything it holds. - return $change->isDelete() && !$change->isReset(); - } -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/ModifiedEntry.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/ModifiedEntry.php new file mode 100644 index 00000000..53495e39 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/ModifiedEntry.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write\Operation; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDelta; + +/** + * The entry a modify leaves behind and the linked values stored apart from it. + * + * @internal + * + * @author Chad Sikorra + */ +readonly class ModifiedEntry +{ + /** + * @param LinkDelta $links Stated by every producer, since a plain delta rewrites the links from the entry itself. + */ + public function __construct( + public Entry $entry, + public LinkDelta $links, + ) {} +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/ChunkOutcome.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/ChunkOutcome.php similarity index 92% rename from src/FreeDSx/Ldap/Server/Backend/Write/Operation/ChunkOutcome.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/ChunkOutcome.php index 63a5b688..03c48b44 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/ChunkOutcome.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/ChunkOutcome.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write\Operation; +namespace FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete; /** * What one transaction of a subtree delete removed, and what it left for a later one. diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeletionQueue.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/DeletionQueue.php similarity index 97% rename from src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeletionQueue.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/DeletionQueue.php index 916beccd..68c622c4 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/DeletionQueue.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/DeletionQueue.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write\Operation; +namespace FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete; use ArrayIterator; use FreeDSx\Ldap\Entry\Dn; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeChunkRemover.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/SubtreeChunkRemover.php similarity index 97% rename from src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeChunkRemover.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/SubtreeChunkRemover.php index a9322040..f3e6a420 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeChunkRemover.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/SubtreeChunkRemover.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write\Operation; +namespace FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; @@ -21,6 +21,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorderInterface; use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\UnrecordedChanges; use FreeDSx\Ldap\Server\Backend\Storage\Search\EntryProjection; +use FreeDSx\Ldap\Server\Backend\Write\Operation\LockedEntryAccess; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use function array_filter; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDeleteProgress.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/SubtreeDeleteProgress.php similarity index 95% rename from src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDeleteProgress.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/SubtreeDeleteProgress.php index ef39db6f..4dcc6f61 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDeleteProgress.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/SubtreeDelete/SubtreeDeleteProgress.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write\Operation; +namespace FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete; use FreeDSx\Ldap\Control\SubtreeDeleteProgressControl; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/TransactionalEntryWrite.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/TransactionalEntryWrite.php index e9d5af23..13dde840 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/TransactionalEntryWrite.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/TransactionalEntryWrite.php @@ -70,8 +70,8 @@ public function add( /** * Stores the entry a modify leaves behind, with the target locked and loaded for the producer to work from. * - * @param LinkDelta $links Linked values the modify changes, which its entry is then left without. - * @param Closure(Entry): ?Entry $produce Answers null when the change turns out to be nothing to write. + * @param EntryProjection $projection What of the target the producer needs loaded. + * @param Closure(Entry): ?ModifiedEntry $produce Answers null when the change turns out to be nothing to write. * * @return ?Entry what was stored, or null when the producer had nothing to write * @@ -79,22 +79,25 @@ public function add( */ public function update( Dn $dn, - LinkDelta $links, + EntryProjection $projection, WriteContext $context, Closure $produce, ): ?Entry { return $this->locked->withEntry( $dn, $context, - fn(Entry $current): ?Entry => $this->stored( - $produce($current), - $links, - $context, - ), - // Only a write the entry itself is the whole truth for has to read what it links. - $links->isEmpty() && !$links->isUntouched() - ? EntryProjection::unbounded() - : new EntryProjection(linkCap: 0), + function (Entry $current) use ($produce, $context): ?Entry { + $modified = $produce($current); + + return $modified === null + ? null + : $this->stored( + $modified->entry, + $modified->links, + $context, + ); + }, + $projection, ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/UpdateEntryHandler.php b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/UpdateEntryHandler.php index d5eebc08..c06fcb73 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Operation/UpdateEntryHandler.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Operation/UpdateEntryHandler.php @@ -16,6 +16,8 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChangeReplay; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChanges; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; /** @@ -32,6 +34,7 @@ public function __construct( private EntryMutation $mutation, private EntryPlacementGuard $placement, private LinkedChanges $linkedChanges, + private LinkedChangeReplay $replay, ) {} /** @@ -41,23 +44,33 @@ public function handle( UpdateCommand $command, WriteContext $context, ): void { - $links = $this->linkedChanges->delta($command, $context); - $remaining = $this->linkedChanges->remaining($command, $links); + $apart = $this->linkedChanges->changesApart( + $command, + $context, + ); + $remaining = $this->linkedChanges->remaining( + $command, + $apart, + ); $this->writes->update( $command->dn->normalize(), - $links, + $this->replay->projectionFor($apart), $context, - function (Entry $current) use ($remaining, $links, $context): Entry { - $this->linkedChanges->assertApplicable( - $links, + function (Entry $current) use ($apart, $remaining, $context): ModifiedEntry { + $links = $this->replay->settle( + $apart, $current->getDn(), ); - return $this->updated( - $remaining, - $context, - $current, + return new ModifiedEntry( + $this->updated( + $remaining, + $context, + $current, + $links, + ), + $links, ); }, ); diff --git a/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php b/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php index 5cdda50f..65ca8281 100644 --- a/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php +++ b/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php @@ -13,6 +13,7 @@ namespace Tests\Integration\FreeDSx\Ldap\Storage\Concern; +use FreeDSx\Ldap\Controls; use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; @@ -39,7 +40,10 @@ public function testAddingAMemberKeepsThoseAlreadyLinked(): void $this->ldapClient()->send(Operations::modify( $group, - Change::add('member', self::LINKED_ADMIN), + Change::add( + 'member', + self::LINKED_ADMIN, + ), )); self::assertEqualsCanonicalizing( @@ -54,11 +58,17 @@ public function testDeletingAMemberLeavesTheRestLinked(): void $this->ldapClient()->send(Operations::modify( $group, - Change::add('member', self::LINKED_ADMIN), + Change::add( + 'member', + self::LINKED_ADMIN, + ), )); $this->ldapClient()->send(Operations::modify( $group, - Change::delete('member', self::LINKED_USER), + Change::delete( + 'member', + self::LINKED_USER, + ), )); self::assertSame( @@ -67,13 +77,177 @@ public function testDeletingAMemberLeavesTheRestLinked(): void ); } + public function testDeletingAMemberInAnotherSpellingRemovesIt(): void + { + $group = $this->seedGroup( + 'link-delete-spelling', + self::LINKED_ADMIN, + ); + + $this->ldapClient()->send(Operations::modify( + $group, + Change::delete( + 'member', + 'CN=User, DC=foo, DC=bar', + ), + )); + + self::assertSame( + [self::LINKED_ADMIN], + $this->membersOf($group), + ); + } + + public function testAddingAndDeletingAMemberInOneModifyKeepsTheOthers(): void + { + $group = $this->seedGroup( + 'link-add-delete', + self::LINKED_ADMIN, + ); + + $this->ldapClient()->send(Operations::modify( + $group, + Change::add( + 'member', + self::LINKED_ALICE, + ), + Change::delete( + 'member', + self::LINKED_ALICE, + ), + )); + + self::assertEqualsCanonicalizing( + [self::LINKED_USER, self::LINKED_ADMIN], + $this->membersOf($group), + ); + } + + public function testDeletingAndAddingAMemberInOneModifyKeepsIt(): void + { + $group = $this->seedGroup( + 'link-delete-add', + self::LINKED_ADMIN, + ); + + $this->ldapClient()->send(Operations::modify( + $group, + Change::delete( + 'member', + self::LINKED_USER, + ), + Change::add( + 'member', + self::LINKED_USER, + ), + )); + + self::assertEqualsCanonicalizing( + [self::LINKED_USER, self::LINKED_ADMIN], + $this->membersOf($group), + ); + } + + public function testDeletingAMemberTwiceInOneModifyIsRefused(): void + { + $group = $this->seedGroup('link-delete-twice'); + + $this->expectOperationCode(ResultCode::NO_SUCH_ATTRIBUTE); + + $this->ldapClient()->send(Operations::modify( + $group, + Change::delete( + 'member', + self::LINKED_USER, + ), + Change::delete( + 'member', + self::LINKED_USER, + ), + )); + } + + public function testALinkedValueTheObjectClassesDoNotPermitIsRefused(): void + { + $person = $this->seedPerson('link-person'); + + $this->expectOperationCode(ResultCode::OBJECT_CLASS_VIOLATION); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::add( + 'member', + self::LINKED_ADMIN, + ), + )); + } + + public function testALinkedValueTheObjectClassesDoNotPermitIsRefusedWhenAControlReadsTheEntry(): void + { + $person = $this->seedPerson('link-person-read'); + + $this->expectOperationCode(ResultCode::OBJECT_CLASS_VIOLATION); + + $this->ldapClient()->send( + Operations::modify( + $person, + Change::add( + 'member', + self::LINKED_ADMIN, + ), + ), + Controls::preRead('cn'), + ); + } + + public function testAUserAddingAPasswordPolicyPointerIsRefused(): void + { + $person = $this->seedPerson('link-policy'); + + $this->expectOperationCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::add( + 'pwdPolicySubentry', + self::LINKED_ADMIN, + ), + )); + } + + public function testDroppingTheClassThatPermitsAKeptLinkedValueIsRefused(): void + { + $person = $this->seedPerson( + 'link-classes', + ['extensibleObject'], + ['member' => self::LINKED_USER], + ); + + $this->expectOperationCode(ResultCode::OBJECT_CLASS_VIOLATION); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::delete( + 'objectClass', + 'extensibleObject', + ), + Change::add( + 'seeAlso', + self::LINKED_ADMIN, + ), + )); + } + public function testReplacingTheMembersWritesThemWhole(): void { $group = $this->seedGroup('link-replace'); $this->ldapClient()->send(Operations::modify( $group, - Change::replace('member', self::LINKED_ADMIN), + Change::replace( + 'member', + self::LINKED_ADMIN, + ), )); self::assertSame( @@ -90,7 +264,10 @@ public function testAddingAMemberAlreadyLinkedIsRefused(): void $this->ldapClient()->send(Operations::modify( $group, - Change::add('member', self::LINKED_USER), + Change::add( + 'member', + self::LINKED_USER, + ), )); } @@ -102,7 +279,10 @@ public function testDeletingAMemberThatIsNotLinkedIsRefused(): void $this->ldapClient()->send(Operations::modify( $group, - Change::delete('member', self::LINKED_ADMIN), + Change::delete( + 'member', + self::LINKED_ADMIN, + ), )); } @@ -114,7 +294,11 @@ public function testTheSameMemberGivenTwiceIsRefused(): void $this->ldapClient()->send(Operations::modify( $group, - Change::add('member', self::LINKED_ADMIN, self::LINKED_ADMIN), + Change::add( + 'member', + self::LINKED_ADMIN, + self::LINKED_ADMIN, + ), )); } @@ -126,7 +310,10 @@ public function testAMemberThatIsNotADistinguishedNameIsRefused(): void $this->ldapClient()->send(Operations::modify( $group, - Change::add('member', 'not a dn'), + Change::add( + 'member', + 'not a dn', + ), )); } @@ -137,7 +324,10 @@ public function testARefusedMemberChangeLeavesTheGroupAlone(): void try { $this->ldapClient()->send(Operations::modify( $group, - Change::add('member', self::LINKED_USER), + Change::add( + 'member', + self::LINKED_USER, + ), )); } catch (OperationException) { } @@ -289,6 +479,31 @@ private function seedGroup( return $dn; } + /** + * @param list $auxiliaries + * @param array $attributes + */ + private function seedPerson( + string $cn, + array $auxiliaries = [], + array $attributes = [], + ): string { + $this->authenticateAdmin(); + $dn = "cn={$cn},dc=foo,dc=bar"; + + $this->ldapClient()->create(Entry::fromArray( + $dn, + [ + 'cn' => $cn, + 'sn' => $cn, + 'objectClass' => ['person', ...$auxiliaries], + ...$attributes, + ], + )); + + return $dn; + } + /** * @return list */ diff --git a/tests/integration/Storage/Pdo/EntryLinkWriterTest.php b/tests/integration/Storage/Pdo/EntryLinkWriterTest.php new file mode 100644 index 00000000..10a67596 --- /dev/null +++ b/tests/integration/Storage/Pdo/EntryLinkWriterTest.php @@ -0,0 +1,94 @@ + + * + * 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\Pdo; + +use FreeDSx\Ldap\Container; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Writer\EntryLinkWriter; +use FreeDSx\Ldap\Server\Backend\Storage\Import\LdapImporter; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\Server\Configuration\TestServerOptions; + +final class EntryLinkWriterTest extends TestCase +{ + private const BASE = 'dc=example,dc=com'; + + private const ADMINS = 'cn=Admins,dc=example,dc=com'; + + private const BOB = 'cn=Bob,dc=example,dc=com'; + + private EntryLinkWriter $subject; + + protected function setUp(): void + { + $container = Container::forServer(TestServerOptions::sqlite()); + $this->subject = $container->get(EntryLinkWriter::class); + + $container->get(LdapImporter::class)->importEntries([ + Entry::fromArray( + self::BASE, + ['dc' => ['example']], + ), + Entry::fromArray( + self::BOB, + [ + 'objectClass' => ['person'], + 'cn' => ['Bob'], + 'sn' => ['Bob'], + ], + ), + Entry::fromArray( + self::ADMINS, + [ + 'objectClass' => ['groupOfNames'], + 'cn' => ['Admins'], + 'member' => [self::BOB], + ], + ), + ]); + } + + public function test_a_held_value_is_found_past_the_first_statement_of_values(): void + { + $asked = []; + + for ($i = 0; $i < 1200; $i++) { + $asked[] = $i === 1100 + ? 'CN=Bob, DC=example, DC=com' + : "cn=nobody{$i}," . self::BASE; + } + + self::assertSame( + ['CN=Bob, DC=example, DC=com'], + $this->subject->heldLinkValues( + new Dn(self::ADMINS), + 'member', + $asked, + ), + ); + } + + public function test_a_value_the_owner_does_not_link_is_not_held(): void + { + self::assertSame( + [], + $this->subject->heldLinkValues( + new Dn(self::ADMINS), + 'member', + ['cn=Carol,' . self::BASE], + ), + ); + } +} diff --git a/tests/integration/Storage/Pdo/UpdateEntryHandlerTest.php b/tests/integration/Storage/Pdo/UpdateEntryHandlerTest.php index 91078748..ea659166 100644 --- a/tests/integration/Storage/Pdo/UpdateEntryHandlerTest.php +++ b/tests/integration/Storage/Pdo/UpdateEntryHandlerTest.php @@ -81,6 +81,25 @@ public function test_deleting_a_member_leaves_the_rest_linked(): void ); } + public function test_adding_and_deleting_a_member_in_one_modify_keeps_the_others(): void + { + $this->modify( + Change::add( + 'member', + $this->dnOf('Carol'), + ), + Change::delete( + 'member', + $this->dnOf('Carol'), + ), + ); + + self::assertSame( + [$this->dnOf('Bob')], + $this->members(), + ); + } + public function test_replacing_the_members_writes_the_attribute_whole(): void { $this->modify(new Change(Change::TYPE_REPLACE, 'member', $this->dnOf('Carol'))); diff --git a/tests/integration/Storage/Pdo/ValidatedUpdateEntryHandlerTest.php b/tests/integration/Storage/Pdo/ValidatedUpdateEntryHandlerTest.php new file mode 100644 index 00000000..87ffcfbe --- /dev/null +++ b/tests/integration/Storage/Pdo/ValidatedUpdateEntryHandlerTest.php @@ -0,0 +1,98 @@ + + * + * 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\Pdo; + +use FreeDSx\Ldap\Container; +use FreeDSx\Ldap\Control\ControlBag; +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Schema\SchemaResource; +use FreeDSx\Ldap\Schema\SchemaValidationMode; +use FreeDSx\Ldap\Server\Backend\Storage\Import\LdapImporter; +use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\Operation\UpdateEntryHandler; +use FreeDSx\Ldap\Server\Backend\Write\WriteContext; +use FreeDSx\Ldap\Server\Token\AnonToken; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\Server\Configuration\TestServerOptions; + +final class ValidatedUpdateEntryHandlerTest extends TestCase +{ + private const BASE = 'o=example'; + + private const BOB = 'cn=Bob,o=example'; + + private UpdateEntryHandler $subject; + + protected function setUp(): void + { + $container = Container::forServer(TestServerOptions::validatedSqlite( + SchemaValidationMode::Strict, + SchemaResource::Core, + SchemaResource::PasswordPolicy, + )); + $this->subject = $container->get(UpdateEntryHandler::class); + + $container->get(LdapImporter::class)->importEntries([ + Entry::fromArray( + self::BASE, + [ + 'objectClass' => ['organization'], + 'o' => ['example'], + ], + ), + Entry::fromArray( + self::BOB, + [ + 'objectClass' => ['person'], + 'cn' => ['Bob'], + 'sn' => ['Bob'], + ], + ), + ]); + } + + public function test_a_system_write_adding_a_second_password_policy_pointer_is_refused(): void + { + $this->systemModify(Change::add( + 'pwdPolicySubentry', + self::BASE, + )); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->systemModify(Change::add( + 'pwdPolicySubentry', + self::BOB, + )); + } + + private function systemModify(Change ...$changes): void + { + $this->subject->handle( + new UpdateCommand( + new Dn(self::BOB), + $changes, + ), + WriteContext::system( + new AnonToken(), + new ControlBag(), + ), + ); + } +} diff --git a/tests/support/Server/Configuration/TestServerOptions.php b/tests/support/Server/Configuration/TestServerOptions.php index 5c8e53d4..8b609ec4 100644 --- a/tests/support/Server/Configuration/TestServerOptions.php +++ b/tests/support/Server/Configuration/TestServerOptions.php @@ -43,11 +43,37 @@ public static function unvalidatedCore(): ServerOptions */ public static function validatedCore(SchemaValidationMode $mode): ServerOptions { - return new ServerOptions( + return self::validated( + $mode, + SchemaResource::Core, + ); + } + + /** + * The given definitions enforced at the given mode, merged in order. + */ + public static function validated( + SchemaValidationMode $mode, + SchemaSourceInterface ...$sources, + ): ServerOptions { + return self::validatedOn( self::transientStorage(), - schemaConfig: (new SchemaConfig()) - ->setSources(SchemaResource::Core) - ->setValidationMode($mode), + $mode, + ...$sources, + ); + } + + /** + * A transient database whose writes are held to the given definitions at the given mode. + */ + public static function validatedSqlite( + SchemaValidationMode $mode, + SchemaSourceInterface ...$sources, + ): ServerOptions { + return self::validatedOn( + PdoConfig::forSqlite(':memory:'), + $mode, + ...$sources, ); } @@ -110,6 +136,19 @@ public static function cheaplyHashed(): ServerOptions ); } + private static function validatedOn( + StorageConfigInterface $storageConfig, + SchemaValidationMode $mode, + SchemaSourceInterface ...$sources, + ): ServerOptions { + return new ServerOptions( + $storageConfig, + schemaConfig: (new SchemaConfig()) + ->setSources(...$sources) + ->setValidationMode($mode), + ); + } + private static function unvalidatedCoreSchema(): SchemaConfig { return (new SchemaConfig()) diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php index 651265d4..5f2f0429 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php @@ -144,6 +144,32 @@ public function test_a_delta_applies_against_the_members_already_stored(): void ); } + public function test_a_delta_removes_a_member_named_in_another_spelling(): void + { + $this->subject->store(EntryFixture::withUuid(Entry::fromArray( + 'cn=admins,dc=ex,dc=com', + [ + 'cn' => ['admins'], + 'member' => ['cn=bob,dc=ex,dc=com', 'cn=carol,dc=ex,dc=com'], + ], + ))); + + $this->subject->store( + EntryFixture::withUuid(Entry::fromArray( + 'cn=admins,dc=ex,dc=com', + ['cn' => ['admins']], + )), + links: new LinkDelta(removed: ['member' => ['CN=Bob, DC=ex, DC=com']]), + ); + + self::assertSame( + ['cn=carol,dc=ex,dc=com'], + $this->subject->find(new Dn('cn=admins,dc=ex,dc=com')) + ?->get('member') + ?->getValues(), + ); + } + public function test_a_delta_emptying_an_attribute_drops_it(): void { $this->subject->store(EntryFixture::withUuid(Entry::fromArray( diff --git a/tests/unit/Server/Backend/Write/Operation/DeleteSubtreeHandlerTest.php b/tests/unit/Server/Backend/Write/Operation/DeleteSubtreeHandlerTest.php index 55b8a9c8..8c27e2e8 100644 --- a/tests/unit/Server/Backend/Write/Operation/DeleteSubtreeHandlerTest.php +++ b/tests/unit/Server/Backend/Write/Operation/DeleteSubtreeHandlerTest.php @@ -41,7 +41,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Operation\DeleteSubtreeHandler; use FreeDSx\Ldap\Server\Backend\Write\Operation\EntryPlacementGuard; use FreeDSx\Ldap\Server\Backend\Write\Operation\LockedEntryAccess; -use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeChunkRemover; +use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete\SubtreeChunkRemover; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; use FreeDSx\Ldap\Server\Clock\Sleeper\SleeperInterface; diff --git a/tests/unit/Server/Backend/Write/Operation/Linked/LinkedChangeReplayTest.php b/tests/unit/Server/Backend/Write/Operation/Linked/LinkedChangeReplayTest.php new file mode 100644 index 00000000..6405ad24 --- /dev/null +++ b/tests/unit/Server/Backend/Write/Operation/Linked/LinkedChangeReplayTest.php @@ -0,0 +1,207 @@ + + * + * 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\Write\Operation\Linked; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDelta; +use FreeDSx\Ldap\Server\Backend\Write\Operation\Linked\LinkedChangeReplay; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\ServerContainerTrait; +use Tests\Support\FreeDSx\Ldap\Storage\EntryFixture; + +final class LinkedChangeReplayTest extends TestCase +{ + use ServerContainerTrait; + + private const ADMINS = 'cn=Admins,dc=example,dc=com'; + + private const BOB = 'cn=Bob,dc=example,dc=com'; + + private const CAROL = 'cn=Carol,dc=example,dc=com'; + + private LinkedChangeReplay $subject; + + protected function setUp(): void + { + $storage = EntryFixture::inMemoryStorage(Entry::fromArray( + self::ADMINS, + [ + 'objectClass' => ['groupOfNames'], + 'cn' => ['Admins'], + 'member' => [self::BOB], + ], + )); + $this->subject = $this->containerFor($storage)->get(LinkedChangeReplay::class); + } + + public function test_changes_applied_apart_load_none_of_the_links(): void + { + self::assertSame( + 0, + $this->subject->projectionFor([self::adding(self::CAROL)])->linkCap, + ); + } + + public function test_no_changes_applied_apart_load_every_link(): void + { + self::assertNull($this->subject->projectionFor([])->linkCap); + } + + public function test_no_changes_leave_the_entry_the_whole_truth_for_its_links(): void + { + $delta = $this->settle(); + + self::assertTrue($delta->isEmpty()); + self::assertFalse($delta->isUntouched()); + } + + public function test_a_delete_and_an_add_net_to_both(): void + { + $delta = $this->settle( + self::deleting(self::BOB), + self::adding(self::CAROL), + ); + + self::assertSame( + [self::CAROL], + $delta->added('member'), + ); + self::assertSame( + [self::BOB], + $delta->removed('member'), + ); + } + + public function test_adding_then_deleting_a_value_leaves_the_links_untouched(): void + { + $delta = $this->settle( + self::adding(self::CAROL), + self::deleting(self::CAROL), + ); + + self::assertTrue($delta->isUntouched()); + } + + public function test_deleting_then_adding_a_held_value_leaves_the_links_untouched(): void + { + $delta = $this->settle( + self::deleting(self::BOB), + self::adding(self::BOB), + ); + + self::assertTrue($delta->isUntouched()); + } + + public function test_adding_a_value_again_after_deleting_it_links_it(): void + { + $delta = $this->settle( + self::adding(self::CAROL), + self::deleting(self::CAROL), + self::adding(self::CAROL), + ); + + self::assertSame( + [self::CAROL], + $delta->added('member'), + ); + self::assertSame( + [], + $delta->removed('member'), + ); + } + + public function test_adding_a_value_an_earlier_change_added_is_refused(): void + { + $this->expectOperationCode(ResultCode::ATTRIBUTE_OR_VALUE_EXISTS); + + $this->settle( + self::adding(self::CAROL), + self::adding(self::CAROL), + ); + } + + public function test_the_same_value_twice_in_one_add_is_refused(): void + { + $this->expectOperationCode(ResultCode::ATTRIBUTE_OR_VALUE_EXISTS); + + $this->settle(self::adding( + self::CAROL, + 'CN=Carol, DC=example, DC=com', + )); + } + + public function test_deleting_a_value_an_earlier_change_deleted_is_refused(): void + { + $this->expectOperationCode(ResultCode::NO_SUCH_ATTRIBUTE); + + $this->settle( + self::deleting(self::BOB), + self::deleting(self::BOB), + ); + } + + public function test_a_value_named_twice_in_one_delete_is_removed_once(): void + { + $delta = $this->settle(self::deleting( + self::BOB, + 'CN=Bob, DC=example, DC=com', + )); + + self::assertCount( + 1, + $delta->removed('member'), + ); + } + + public function test_deleting_a_value_that_is_not_a_dn_is_answered_as_not_held(): void + { + $this->expectOperationCode(ResultCode::NO_SUCH_ATTRIBUTE); + + $this->settle(self::deleting('not a dn')); + } + + private static function adding(string ...$members): Change + { + return Change::add( + 'member', + ...$members, + ); + } + + private static function deleting(string ...$members): Change + { + return Change::delete( + 'member', + ...$members, + ); + } + + private function settle(Change ...$changes): LinkDelta + { + return $this->subject->settle( + array_values($changes), + new Dn(self::ADMINS), + ); + } + + private function expectOperationCode(int $code): void + { + $this->expectException(OperationException::class); + $this->expectExceptionCode($code); + } +} diff --git a/tests/unit/Server/Backend/Write/Operation/DeletionQueueTest.php b/tests/unit/Server/Backend/Write/Operation/SubtreeDelete/DeletionQueueTest.php similarity index 97% rename from tests/unit/Server/Backend/Write/Operation/DeletionQueueTest.php rename to tests/unit/Server/Backend/Write/Operation/SubtreeDelete/DeletionQueueTest.php index 3e952caa..742fd7b4 100644 --- a/tests/unit/Server/Backend/Write/Operation/DeletionQueueTest.php +++ b/tests/unit/Server/Backend/Write/Operation/SubtreeDelete/DeletionQueueTest.php @@ -11,12 +11,12 @@ * file that was distributed with this source code. */ -namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Write\Operation; +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Server\Backend\Storage\Directory\SubtreeEnumerator; -use FreeDSx\Ldap\Server\Backend\Write\Operation\DeletionQueue; +use FreeDSx\Ldap\Server\Backend\Write\Operation\SubtreeDelete\DeletionQueue; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Storage\EntryFixture; diff --git a/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php b/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php index c40c72e0..f1e904d8 100644 --- a/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php +++ b/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php @@ -23,6 +23,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\Storage\Contract\TransactionalWriteInterface; @@ -90,6 +91,48 @@ public function test_it_removes_only_the_linked_value_it_names(): void ); } + public function test_adding_then_deleting_a_member_in_one_modify_keeps_the_others(): void + { + $this->seedAdmins(); + + $this->modifyAdmins( + Change::add( + 'member', + self::ALICE, + ), + Change::delete( + 'member', + self::ALICE, + ), + ); + + self::assertSame( + ['cn=Bob,ou=People,dc=example,dc=com'], + array_values($this->find(self::ADMINS)?->get('member')?->getValues() ?? []), + ); + } + + public function test_deleting_then_adding_a_member_in_one_modify_keeps_it(): void + { + $this->seedAdmins(self::ALICE); + + $this->modifyAdmins( + Change::delete( + 'member', + self::ALICE, + ), + Change::add( + 'member', + self::ALICE, + ), + ); + + self::assertEqualsCanonicalizing( + ['cn=Bob,ou=People,dc=example,dc=com', self::ALICE], + $this->find(self::ADMINS)?->get('member')?->getValues(), + ); + } + public function test_it_replaces_a_linked_attribute_through_the_whole_entry(): void { $this->seedAdmins(self::ALICE); @@ -361,6 +404,110 @@ public function test_a_system_write_still_enforces_a_single_valued_attribute(): ); } + public function test_a_linked_value_the_object_classes_do_not_permit_is_refused(): void + { + $this->validatedGraph(SchemaValidationMode::Strict); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::OBJECT_CLASS_VIOLATION); + + $this->modify(Change::add( + 'member', + self::ALICE, + )); + } + + public function test_a_linked_value_that_is_not_a_dn_is_refused_for_its_syntax(): void + { + $this->validatedGraph(SchemaValidationMode::Strict); + $this->storage->store(EntryFixture::withUuid(Entry::fromArray( + self::ADMINS, + [ + 'objectClass' => ['groupOfNames'], + 'cn' => ['Admins'], + 'member' => [self::ALICE], + ], + ))); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::INVALID_ATTRIBUTE_SYNTAX); + + $this->modifyAdmins(Change::add( + 'member', + 'not a dn', + )); + } + + public function test_a_linked_value_the_object_classes_do_not_permit_is_refused_when_a_control_reads_the_entry(): void + { + $this->validatedGraph(SchemaValidationMode::Strict); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::OBJECT_CLASS_VIOLATION); + + $this->updates()->handle( + new UpdateCommand( + new Dn(self::ALICE), + [ + Change::add( + 'member', + self::ALICE, + ), + ], + ), + $this->controlledContext(Controls::preRead('cn')), + ); + } + + public function test_a_user_adding_a_password_policy_pointer_is_refused(): void + { + $this->validatedGraph( + SchemaValidationMode::Strict, + SchemaResource::PasswordPolicy, + ); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->modify(Change::add( + 'pwdPolicySubentry', + 'dc=example,dc=com', + )); + } + + public function test_a_system_write_adding_a_second_password_policy_pointer_is_refused(): void + { + $this->validatedGraph( + SchemaValidationMode::Strict, + SchemaResource::PasswordPolicy, + ); + $this->storage->store(EntryFixture::withUuid(Entry::fromArray( + self::ALICE, + [ + 'objectClass' => ['top', 'person'], + 'cn' => ['Alice'], + 'sn' => ['Smith'], + 'pwdPolicySubentry' => ['dc=example,dc=com'], + ], + ))); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->updates()->handle( + new UpdateCommand( + new Dn(self::ALICE), + [ + Change::add( + 'pwdPolicySubentry', + self::ALICE, + ), + ], + ), + $this->systemContext(), + ); + } + public function test_it_is_journaled(): void { $journal = $this->journaledGraph(); @@ -436,8 +583,10 @@ private function modify(Change ...$changes): void /** * A validated fixture needs an entry the core schema actually accepts. */ - private function validatedGraph(SchemaValidationMode $mode): void - { + private function validatedGraph( + SchemaValidationMode $mode, + SchemaResource ...$extra, + ): void { $this->writeGraph( EntryFixture::inMemoryStorage( new Entry( @@ -452,7 +601,11 @@ private function validatedGraph(SchemaValidationMode $mode): void new Attribute('sn', 'Smith'), ), ), - TestServerOptions::validatedCore($mode), + TestServerOptions::validated( + $mode, + SchemaResource::Core, + ...$extra, + ), ); }