From e2e9b1586731b5c90ce64d5e1acd4396162f2e62 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 18 Jul 2026 09:37:54 -0400 Subject: [PATCH 1/5] Add the core classes / interface needed for consolidated queue writing. --- .../Ldap/Protocol/Queue/ConnectionControl.php | 27 +++++ .../Protocol/Queue/Response/Cancellation.php | 48 +++++++++ .../Queue/Response/QueueWriterConfig.php | 37 +++++++ .../Queue/Response/ResponseStream.php | 100 ++++++++++++++++++ .../Ldap/Protocol/Queue/ServerQueue.php | 2 +- 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php create mode 100644 src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php create mode 100644 src/FreeDSx/Ldap/Protocol/Queue/Response/QueueWriterConfig.php create mode 100644 src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php diff --git a/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php b/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php new file mode 100644 index 00000000..12a5f42e --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Queue; + +/** + * A narrow connection-lifecycle seam handlers use for post-write socket actions without the queue. + * + * @internal + * @author Chad Sikorra + */ +interface ConnectionControl +{ + public function isEncrypted(): bool; + + public function encrypt(): static; +} diff --git a/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php b/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php new file mode 100644 index 00000000..cecb3cd5 --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Queue\Response; + +use FreeDSx\Ldap\Protocol\LdapMessageRequest; + +/** + * The control channel between the response writer (which polls the queue) and a streaming producer + * (which reads the offered abandon/cancel signal after each yield). + * + * @internal + * @author Chad Sikorra + */ +final class Cancellation +{ + private ?LdapMessageRequest $signal = null; + + /** + * The writer offers each poll result; nulls are ignored so a real signal is never overwritten. + */ + public function offer(?LdapMessageRequest $signal): void + { + if ($signal !== null) { + $this->signal = $signal; + } + } + + public function isSignalled(): bool + { + return $this->signal !== null; + } + + public function signal(): ?LdapMessageRequest + { + return $this->signal; + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Queue/Response/QueueWriterConfig.php b/src/FreeDSx/Ldap/Protocol/Queue/Response/QueueWriterConfig.php new file mode 100644 index 00000000..ed436821 --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Queue/Response/QueueWriterConfig.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\Protocol\Queue\Response; + +/** + * Tells the response writer how to drive a stream; defaults to the fully-batched, unpolled fast path. + * + * @internal + * @author Chad Sikorra + */ +final readonly class QueueWriterConfig +{ + /** + * @param int $signalInterval poll for a cancel/abandon signal every N messages; 0 disables it. + * @param bool $flushPerMessage flush the socket after each message instead of the default buffering. + */ + public function __construct( + public int $signalInterval = 0, + public bool $flushPerMessage = false, + ) {} + + public function mustFlushOrSignal(): bool + { + return $this->signalInterval > 0 || $this->flushPerMessage; + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php b/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php new file mode 100644 index 00000000..a864faaf --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Queue\Response; + +use Closure; +use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Server\Operation\OperationResult; +use Generator; + +/** + * The messages a handler wants written, plus the outcome resolved once they are drained. + * + * @internal + * @author Chad Sikorra + */ +final readonly class ResponseStream +{ + /** + * @param iterable $messages streamed to the socket in order. + * @param Closure(): OperationResult $outcome resolved after the stream is fully drained. + * @param QueueWriterConfig $writerConfig how the writer batches, flushes, and polls this stream. + * @param ?Cancellation $cancellation the writer offers polled signals here; the producer reads it. + * @param ?Closure(): void $onComplete run by the writer once the stream is drained and flushed. + */ + private function __construct( + public iterable $messages, + private Closure $outcome, + public QueueWriterConfig $writerConfig = new QueueWriterConfig(), + public ?Cancellation $cancellation = null, + public ?Closure $onComplete = null, + ) {} + + public function outcome(): OperationResult + { + return ($this->outcome)(); + } + + /** + * A response whose outcome is already known; the default writer config bulk-sends it. + * + * @param iterable $messages + * @param ?Closure(): void $onComplete + */ + public static function of( + iterable $messages, + OperationResult $outcome, + ?Closure $onComplete = null, + ): self { + return new self( + messages: $messages, + outcome: static fn(): OperationResult => $outcome, + onComplete: $onComplete, + ); + } + + /** + * A stepped streaming response whose outcome is resolved after draining. + * + * @param Generator $messages + * @param Closure(): OperationResult $outcome + */ + public static function streaming( + Generator $messages, + Closure $outcome, + QueueWriterConfig $writerConfig, + ?Cancellation $cancellation = null, + ): self { + return new self( + messages: $messages, + outcome: $outcome, + writerConfig: $writerConfig, + cancellation: $cancellation, + ); + } + + /** + * @param ?Closure(): void $onComplete + */ + public static function none( + OperationResult $outcome, + ?Closure $onComplete = null, + ): self { + return self::of( + messages: [], + outcome: $outcome, + onComplete: $onComplete, + ); + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Queue/ServerQueue.php b/src/FreeDSx/Ldap/Protocol/Queue/ServerQueue.php index 689be923..b420c044 100644 --- a/src/FreeDSx/Ldap/Protocol/Queue/ServerQueue.php +++ b/src/FreeDSx/Ldap/Protocol/Queue/ServerQueue.php @@ -42,7 +42,7 @@ * * @author Chad Sikorra */ -class ServerQueue extends LdapQueue +class ServerQueue extends LdapQueue implements ConnectionControl { /** * @var LdapMessageRequest[] From 64e946994a007e7820f4f119a25271e029c97160 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 18 Jul 2026 10:31:17 -0400 Subject: [PATCH 2/5] Convert all server protocol handlers to use the ResponseStream for consolidated writes. --- .../Ldap/Protocol/Queue/ConnectionControl.php | 2 + .../Protocol/Queue/Response/Cancellation.php | 12 +++ .../Queue/Response/ResponseStream.php | 81 ++++++++++++--- .../SearchResultState.php | 5 - .../ServerAbandonHandler.php | 6 +- .../ServerCancelHandler.php | 22 ++--- .../ServerDispatchHandler.php | 53 +++++----- .../ServerMonitorHandler.php | 23 ++--- .../ServerPagingHandler.php | 27 ++--- .../ServerPasswordModifyHandler.php | 67 +++++-------- .../ServerPasswordPolicyForwardHandler.php | 18 ++-- .../ServerProtocolHandlerInterface.php | 6 +- .../ServerRootDseHandler.php | 28 ++---- .../ServerSearchHandler.php | 70 ++++--------- .../ServerSearchTrait.php | 31 +++--- .../ServerStartTlsHandler.php | 98 ++++++++++--------- .../ServerSubschemaHandler.php | 27 ++--- .../ServerSyncHandler.php | 10 +- .../ServerUnbindHandler.php | 18 ++-- .../ServerUnsupportedExtendedHandler.php | 23 ++--- .../ServerWhoAmIHandler.php | 28 +++--- .../Ldap/Server/ConnectionHandlerBuilder.php | 5 +- .../Middleware/Pipeline/HandlerInvoker.php | 74 +++++++++++++- 23 files changed, 368 insertions(+), 366 deletions(-) diff --git a/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php b/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php index 12a5f42e..deca4b3a 100644 --- a/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php +++ b/src/FreeDSx/Ldap/Protocol/Queue/ConnectionControl.php @@ -24,4 +24,6 @@ interface ConnectionControl public function isEncrypted(): bool; public function encrypt(): static; + + public function close(): void; } diff --git a/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php b/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php index cecb3cd5..08ec8086 100644 --- a/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php +++ b/src/FreeDSx/Ldap/Protocol/Queue/Response/Cancellation.php @@ -13,6 +13,8 @@ namespace FreeDSx\Ldap\Protocol\Queue\Response; +use FreeDSx\Ldap\Operation\Request\AbandonRequest; +use FreeDSx\Ldap\Operation\Request\CancelRequest; use FreeDSx\Ldap\Protocol\LdapMessageRequest; /** @@ -41,6 +43,16 @@ public function isSignalled(): bool return $this->signal !== null; } + public function isAbandoned(): bool + { + return $this->signal?->getRequest() instanceof AbandonRequest; + } + + public function isCanceled(): bool + { + return $this->signal?->getRequest() instanceof CancelRequest; + } + public function signal(): ?LdapMessageRequest { return $this->signal; diff --git a/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php b/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php index a864faaf..61425b73 100644 --- a/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php +++ b/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseStream.php @@ -14,10 +14,16 @@ namespace FreeDSx\Ldap\Protocol\Queue\Response; use Closure; +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Operation\Response\ResponseInterface; +use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Protocol\Queue\ConnectionControl; use FreeDSx\Ldap\Server\Operation\OperationResult; use Generator; +use function array_map; + /** * The messages a handler wants written, plus the outcome resolved once they are drained. * @@ -31,7 +37,8 @@ * @param Closure(): OperationResult $outcome resolved after the stream is fully drained. * @param QueueWriterConfig $writerConfig how the writer batches, flushes, and polls this stream. * @param ?Cancellation $cancellation the writer offers polled signals here; the producer reads it. - * @param ?Closure(): void $onComplete run by the writer once the stream is drained and flushed. + * @param ?Closure(ConnectionControl): void $onComplete a post-write connection action the writer + * runs once the stream is drained and flushed. */ private function __construct( public iterable $messages, @@ -46,26 +53,74 @@ public function outcome(): OperationResult return ($this->outcome)(); } + /** + * A copy of this stream with a post-write connection action the writer runs after draining. + * + * @param Closure(ConnectionControl): void $onComplete + */ + public function withOnComplete(Closure $onComplete): self + { + return new self( + $this->messages, + $this->outcome, + $this->writerConfig, + $this->cancellation, + $onComplete, + ); + } + + /** + * The message generator for a stepped stream; only a generator can be polled and flushed per message. + * + * @return Generator + * @throws RuntimeException if the stream was not built for stepping via streaming(). + */ + public function generator(): Generator + { + if (!$this->messages instanceof Generator) { + throw new RuntimeException('A stepped response stream must carry a generator.'); + } + + return $this->messages; + } + /** * A response whose outcome is already known; the default writer config bulk-sends it. * * @param iterable $messages - * @param ?Closure(): void $onComplete */ public static function of( iterable $messages, OperationResult $outcome, - ?Closure $onComplete = null, ): self { return new self( messages: $messages, outcome: static fn(): OperationResult => $outcome, - onComplete: $onComplete, ); } /** - * A stepped streaming response whose outcome is resolved after draining. + * One or more inner responses, each wrapped in an envelope addressed to the request's message ID. + */ + public static function reply( + LdapMessageRequest $message, + OperationResult $outcome, + ResponseInterface ...$responses, + ): self { + $messageId = $message->getMessageId(); + + return self::of( + array_map( + static fn(ResponseInterface $response): LdapMessageResponse + => new LdapMessageResponse($messageId, $response), + $responses, + ), + $outcome, + ); + } + + /** + * A streaming response whose outcome is resolved after draining; steps only when the config asks. * * @param Generator $messages * @param Closure(): OperationResult $outcome @@ -73,7 +128,7 @@ public static function of( public static function streaming( Generator $messages, Closure $outcome, - QueueWriterConfig $writerConfig, + QueueWriterConfig $writerConfig = new QueueWriterConfig(), ?Cancellation $cancellation = null, ): self { return new self( @@ -84,17 +139,11 @@ public static function streaming( ); } - /** - * @param ?Closure(): void $onComplete - */ - public static function none( - OperationResult $outcome, - ?Closure $onComplete = null, - ): self { + public static function none(OperationResult $outcome): self + { return self::of( - messages: [], - outcome: $outcome, - onComplete: $onComplete, + [], + $outcome, ); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/SearchResultState.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/SearchResultState.php index 2a780608..d68128f0 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/SearchResultState.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/SearchResultState.php @@ -14,17 +14,12 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Protocol\LdapMessageRequest; /** * Late-bound result code / diagnostic for a streaming search. */ final class SearchResultState { - public bool $isAbandoned = false; - - public ?LdapMessageRequest $cancelSignal = null; - public int $entriesReturned = 0; public function __construct( diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerAbandonHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerAbandonHandler.php index d2f9a14b..c119eafb 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerAbandonHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerAbandonHandler.php @@ -14,8 +14,8 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -28,7 +28,7 @@ final class ServerAbandonHandler implements ServerProtocolHandlerInterface public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { - return OperationOutcomeResult::succeeded(); + ): ResponseStream { + return ResponseStream::none(OperationOutcomeResult::succeeded()); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerCancelHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerCancelHandler.php index c26b2658..c032707b 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerCancelHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerCancelHandler.php @@ -13,15 +13,12 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -31,21 +28,14 @@ */ readonly class ServerCancelHandler implements ServerProtocolHandlerInterface { - public function __construct(private ServerQueue $queue) {} - - /** - * {@inheritDoc} - * @throws EncoderException - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { - $this->queue->sendMessage(new LdapMessageResponse( - $message->getMessageId(), + ): ResponseStream { + return ResponseStream::reply( + $message, + OperationOutcomeResult::failed(ResultCode::NO_SUCH_OPERATION), new ExtendedResponse(new LdapResult(ResultCode::NO_SUCH_OPERATION)), - )); - - return OperationOutcomeResult::failed(ResultCode::NO_SUCH_OPERATION); + ); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php index acb2d5d8..95f9e278 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php @@ -22,7 +22,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Operation\OperationType; @@ -34,7 +34,6 @@ use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Operation\CompareOperationResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Operation\WriteOperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -48,7 +47,6 @@ private ReadEntryControlHandler $readEntryControlHandler; public function __construct( - private ServerQueue $queue, private LdapBackendInterface $backend, private WriteOperationDispatcher $writeDispatcher, private AccessControlInterface $accessControl, @@ -71,7 +69,7 @@ public function __construct( public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $schemaViolations = new SchemaViolations(); $request = $message->getRequest(); $controls = $message->controls(); @@ -99,21 +97,23 @@ public function handleRequest( private function handleCompare( LdapMessageRequest $message, Request\CompareRequest $request, - ): OperationResult { + ): ResponseStream { $match = $this->backend->compare( $request->getDn(), $request->getFilter(), ); - $this->queue->sendMessage($this->responseFactory->getStandardResponse( - $message, - $match - ? ResultCode::COMPARE_TRUE - : ResultCode::COMPARE_FALSE, - )); - return CompareOperationResult::completed( - $message, - $match, + return ResponseStream::of( + [$this->responseFactory->getStandardResponse( + $message, + $match + ? ResultCode::COMPARE_TRUE + : ResultCode::COMPARE_FALSE, + )], + CompareOperationResult::completed( + $message, + $match, + ), ); } @@ -127,7 +127,7 @@ private function handleWrite( ControlBag $controls, TokenInterface $token, SchemaViolations $schemaViolations, - ): OperationResult { + ): ResponseStream { $preRead = $this->readEntryControlHandler->preReadFor($request, $controls); $this->dispatchWrite( @@ -139,17 +139,18 @@ private function handleWrite( $postRead = $this->readEntryControlHandler->postReadFor($request, $controls); - $this->queue->sendMessage($this->responseFactory->getStandardResponse( - $message, - ResultCode::SUCCESS, - '', - null, - ...$this->successControls($preRead, $postRead), - )); - - return WriteOperationResult::success( - $message, - $schemaViolations, + return ResponseStream::of( + [$this->responseFactory->getStandardResponse( + $message, + ResultCode::SUCCESS, + '', + null, + ...$this->successControls($preRead, $postRead), + )], + WriteOperationResult::success( + $message, + $schemaViolations, + ), ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php index fd7c84e7..f45a71aa 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php @@ -18,12 +18,10 @@ use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Metrics\MetricsSnapshotProvider; use FreeDSx\Ldap\Server\Metrics\Snapshot\MetricsSnapshot; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\ServerRunner\CoroutineServerRunnerInterface; use FreeDSx\Ldap\Server\ServerRunner\PcntlServerRunner; use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; @@ -47,31 +45,24 @@ class ServerMonitorHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerOptions $options, - private readonly ServerQueue $queue, private readonly MetricsSnapshotProvider $snapshots, ) {} public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $entry = Entry::fromArray( self::DN, $this->attributes($this->snapshots->snapshot()), ); - $this->queue->sendMessage( - new LdapMessageResponse( - $message->getMessageId(), - new SearchResultEntry($entry), - ), - new LdapMessageResponse( - $message->getMessageId(), - new SearchResultDone(ResultCode::SUCCESS), - ), + return ResponseStream::reply( + $message, + OperationOutcomeResult::succeeded(), + new SearchResultEntry($entry), + new SearchResultDone(ResultCode::SUCCESS), ); - - return OperationOutcomeResult::succeeded(); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index 78e45f3a..d63dadfb 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -21,7 +21,8 @@ use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\Cancellation; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; @@ -30,7 +31,6 @@ use FreeDSx\Ldap\Server\Paging\PagingResponse; use FreeDSx\Ldap\Server\RequestHistory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -48,7 +48,6 @@ class ServerPagingHandler implements ServerProtocolHandlerInterface use MatchedDnAccessFilterTrait; public function __construct( - private readonly ServerQueue $queue, private readonly LdapBackendInterface $backend, private readonly FilterEvaluatorInterface $filterEvaluator, private readonly AccessControlInterface $accessControl, @@ -65,7 +64,7 @@ public function __construct( public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $pagingRequest = $this->findOrMakePagingRequest($message); $searchRequest = $this->getSearchRequestFromMessage($message); @@ -144,14 +143,7 @@ public function handleRequest( $this->requestHistory->removePagingGenerator($pagingRequest->getNextCookie()); } - $this->sendEntriesToClient( - $searchResult, - $message, - $this->queue, - ...$controls, - ); - - return $failure !== null + $outcome = $failure !== null ? SearchOperationResult::failure( $message, $failure, @@ -160,6 +152,17 @@ public function handleRequest( $message, $entriesReturned, ); + + // A page is pre-collected and bounded, so it is bulk-sent without mid-page cancel polling. + return ResponseStream::of( + $this->buildResponseStream( + $searchResult, + $message->getMessageId(), + new Cancellation(), + ...$controls, + ), + $outcome, + ); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php index eda32963..fe6a8632 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php @@ -13,7 +13,6 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; @@ -22,9 +21,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\PasswordModifyOperationResult; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyResult; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyService; @@ -32,56 +29,51 @@ use FreeDSx\Ldap\Server\Token\TokenInterface; /** - * Adapts RFC 3062 Password Modify requests to {@see PasswordModifyService}: decode, delegate, encode the response. + * Adapts RFC 3062 Password Modify requests to {@see PasswordModifyService}: decode, delegate, build the response. * * @author Chad Sikorra */ readonly class ServerPasswordModifyHandler implements ServerProtocolHandlerInterface { public function __construct( - private ServerQueue $queue, private PasswordModifyService $service, private ResponseFactory $responseFactory = new ResponseFactory(), ) {} - /** - * {@inheritDoc} - * - * @throws EncoderException - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { - $targetDn = null; - + ): ResponseStream { try { $result = $this->changePassword( $message, $token, ); - $targetDn = $result->targetDn; - $this->sendSuccess( - $message, - $result, - ); } catch (OperationException $e) { - $this->queue->sendMessage($this->responseFactory->getStandardResponse( - $message, - $e->getCode(), - $e->getMessage(), - )); - - return PasswordModifyOperationResult::failure( - $message, - $e, - $targetDn, + return ResponseStream::of( + [$this->responseFactory->getStandardResponse( + $message, + $e->getCode(), + $e->getMessage(), + )], + PasswordModifyOperationResult::failure( + $message, + $e, + null, + ), ); } - return PasswordModifyOperationResult::success( + return ResponseStream::reply( $message, - $targetDn, + PasswordModifyOperationResult::success( + $message, + $result->targetDn, + ), + new PasswordModifyResponse( + new LdapResult(ResultCode::SUCCESS), + $result->generatedPassword, + ), ); } @@ -108,17 +100,4 @@ private function changePassword( $message->controls(), ); } - - private function sendSuccess( - LdapMessageRequest $message, - PasswordModifyResult $result, - ): void { - $this->queue->sendMessage(new LdapMessageResponse( - $message->getMessageId(), - new PasswordModifyResponse( - new LdapResult(ResultCode::SUCCESS), - $result->generatedPassword, - ), - )); - } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandler.php index 811a946c..93218e8a 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandler.php @@ -13,7 +13,6 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; @@ -22,12 +21,10 @@ use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyResolver; use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; @@ -43,7 +40,6 @@ readonly class ServerPasswordPolicyForwardHandler implements ServerProtocolHandlerInterface { public function __construct( - private ServerQueue $queue, private WritableLdapBackendInterface $backend, private PasswordPolicyResolver $policyResolver, private PasswordPolicyEngine $engine, @@ -52,13 +48,12 @@ public function __construct( /** * {@inheritDoc} * - * @throws EncoderException * @throws OperationException */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $request = $message->getRequest(); if (!$request instanceof ForwardPasswordPolicyStateRequest) { throw new OperationException( @@ -69,12 +64,11 @@ public function handleRequest( $this->apply($request); - $this->queue->sendMessage(new LdapMessageResponse( - $message->getMessageId(), + return ResponseStream::reply( + $message, + OperationOutcomeResult::succeeded(), new ExtendedResponse(new LdapResult(ResultCode::SUCCESS)), - )); - - return OperationOutcomeResult::succeeded(); + ); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerProtocolHandlerInterface.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerProtocolHandlerInterface.php index 068dce77..260bec6e 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerProtocolHandlerInterface.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerProtocolHandlerInterface.php @@ -15,7 +15,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Socket\Exception\ConnectionException; @@ -28,7 +28,7 @@ interface ServerProtocolHandlerInterface { /** - * Handle protocol actions specific to the request received and return its outcome. + * Handle a request and return the responses to write plus the resolved outcome. * * @throws OperationException * @throws ConnectionException @@ -36,5 +36,5 @@ interface ServerProtocolHandlerInterface public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult; + ): ResponseStream; } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php index 5803c5c5..ffa0149b 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php @@ -13,7 +13,6 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; @@ -22,10 +21,8 @@ use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\RequestContext; @@ -54,20 +51,15 @@ class ServerRootDseHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerOptions $options, - private readonly ServerQueue $queue, private readonly LdapBackendInterface $backend, private readonly ?RootDseHandlerInterface $rootDseHandler = null, private readonly bool $supportsSync = false, ) {} - /** - * {@inheritDoc} - * @throws EncoderException - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $entry = Entry::fromArray('', [ 'namingContexts' => array_map( fn(Dn $dn): string => $dn->toString(), @@ -135,18 +127,12 @@ public function handleRequest( $this->filterEntryAttributes($request, $entry); - $this->queue->sendMessage( - new LdapMessageResponse( - $message->getMessageId(), - new SearchResultEntry($entry), - ), - new LdapMessageResponse( - $message->getMessageId(), - new SearchResultDone(ResultCode::SUCCESS), - ), + return ResponseStream::reply( + $message, + OperationOutcomeResult::succeeded(), + new SearchResultEntry($entry), + new SearchResultDone(ResultCode::SUCCESS), ); - - return OperationOutcomeResult::succeeded(); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index a1336b38..fe7b2c4c 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -14,18 +14,17 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Entry\Entry; -use FreeDSx\Ldap\Operation\Request\AbandonRequest; -use FreeDSx\Ldap\Operation\Request\CancelRequest; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\Cancellation; +use FreeDSx\Ldap\Protocol\Queue\Response\QueueWriterConfig; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -43,7 +42,6 @@ class ServerSearchHandler implements ServerProtocolHandlerInterface private const CANCEL_CHECK_INTERVAL = 50; public function __construct( - private readonly ServerQueue $queue, private readonly LdapBackendInterface $backend, private readonly FilterEvaluatorInterface $filterEvaluator, private readonly AccessControlInterface $accessControl, @@ -57,9 +55,10 @@ public function __construct( public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $request = $this->getSearchRequestFromMessage($message); $state = new SearchResultState(); + $cancellation = new Cancellation(); $this->assertBaseDnProvided($request); @@ -80,7 +79,6 @@ public function handleRequest( $request, $state, $token, - $message->getMessageId(), $projection, ), (string) $request->getBaseDn(), @@ -95,24 +93,25 @@ public function handleRequest( ? [$sortResponse] : []; - $this->sendEntriesToClient( - $searchResult, - $message, - $this->queue, - ...$responseControls, - ); - - return SearchOperationResult::success( - $message, - $state->entriesReturned, + return ResponseStream::streaming( + $this->buildResponseStream( + $searchResult, + $message->getMessageId(), + $cancellation, + ...$responseControls, + ), + static fn(): SearchOperationResult => SearchOperationResult::success( + $message, + $state->entriesReturned, + ), + new QueueWriterConfig(signalInterval: self::CANCEL_CHECK_INTERVAL), + $cancellation, ); } /** * Streams filtered + attribute-projected entries from the backend. * - * Checks for cancel signals periodically. - * * @return Generator */ private function filteredEntryStream( @@ -120,7 +119,6 @@ private function filteredEntryStream( SearchRequest $request, SearchResultState $state, TokenInterface $token, - int $messageId, AttributeProjection $projection, ): Generator { $sizeLimit = $this->effectiveSizeLimit( @@ -131,10 +129,6 @@ private function filteredEntryStream( $emitted = 0; foreach ($backend->entries as $entry) { - if ($this->shouldCancelSearch($emitted, $messageId, $state)) { - return; - } - $filtered = $this->accessControl->filterEntry( $token, $entry, @@ -159,32 +153,4 @@ private function filteredEntryStream( } } } - - private function shouldCancelSearch( - int $emitted, - int $messageId, - SearchResultState $state, - ): bool { - if ($emitted === 0 || $emitted % self::CANCEL_CHECK_INTERVAL !== 0) { - return false; - } - - $signal = $this->queue->peekForCancelSignal($messageId); - if ($signal === null) { - return false; - } - - $request = $signal->getRequest(); - if ($request instanceof AbandonRequest) { - $state->isAbandoned = true; - - return true; - } - - if ($request instanceof CancelRequest) { - $state->cancelSignal = $signal; - } - - return true; - } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php index f5dd1eec..2348d2f9 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php @@ -22,7 +22,6 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Operation\LdapResult; -use FreeDSx\Ldap\Operation\Request\CancelRequest; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Operation\Response\SearchResultDone; @@ -30,25 +29,12 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\Cancellation; use FreeDSx\Ldap\Schema\Schema; use Generator; trait ServerSearchTrait { - private function sendEntriesToClient( - SearchResult $searchResult, - LdapMessageRequest $message, - ServerQueue $queue, - Control ...$controls, - ): void { - $queue->sendMessages($this->buildResponseStream( - $searchResult, - $message->getMessageId(), - ...$controls, - )); - } - /** * Yields a SearchResultEntry per backend entry followed by the terminal SearchResultDone. * Yields nothing for abandoned requests; yields CANCELED + SUCCESS for cancelled requests. @@ -58,23 +44,28 @@ private function sendEntriesToClient( private function buildResponseStream( SearchResult $searchResult, int $messageId, + Cancellation $cancellation, Control ...$controls, ): Generator { + $state = $searchResult->getState(); + foreach ($searchResult->getEntries() as $entry) { yield new LdapMessageResponse( $messageId, new SearchResultEntry($entry), ); - } - $state = $searchResult->getState(); + if ($cancellation->isSignalled()) { + break; + } + } - if ($state->isAbandoned) { + if ($cancellation->isAbandoned()) { return; } - $cancelSignal = $state->cancelSignal; + $cancelSignal = $cancellation->signal(); - if ($cancelSignal !== null && $cancelSignal->getRequest() instanceof CancelRequest) { + if ($cancelSignal !== null && $cancellation->isCanceled()) { yield new LdapMessageResponse( $messageId, new SearchResultDone( diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerStartTlsHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerStartTlsHandler.php index fae72c88..6da6c682 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerStartTlsHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerStartTlsHandler.php @@ -13,22 +13,19 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\ConnectionControl; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Logging\EventContext; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\ServerEvent; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\ServerOptions; -use FreeDSx\Socket\Exception\ConnectionException; use function extension_loaded; @@ -43,7 +40,7 @@ class ServerStartTlsHandler implements ServerProtocolHandlerInterface public function __construct( private readonly ServerOptions $options, - private readonly ServerQueue $queue, + private readonly ConnectionControl $connection, private readonly EventLogger $eventLogger = new EventLogger(null), ) { if (self::$hasOpenssl === null) { @@ -51,64 +48,69 @@ public function __construct( } } - /** - * {@inheritDoc} - * @throws ConnectionException - * @throws EncoderException - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { # RFC 4511 ยง4.14.2: return unavailable (not protocolError) when the server cannot negotiate TLS. if ($this->options->getSslCert() === null || !self::$hasOpenssl) { - $this->queue->sendMessage(new LdapMessageResponse($message->getMessageId(), new ExtendedResponse( - new LdapResult( - ResultCode::UNAVAILABLE, - '', - 'The server is not configured to provide TLS.', - ), - ExtendedRequest::OID_START_TLS, - ))); - $this->eventLogger->record( - ServerEvent::StartTlsFailed, - [ - EventContext::RESULT_CODE => ResultCode::UNAVAILABLE, - EventContext::REASON => 'The server is not configured to provide TLS.', - ], - message: $message, + return $this->failure( + $message, + ResultCode::UNAVAILABLE, + 'The server is not configured to provide TLS.', ); - - return OperationOutcomeResult::failed(ResultCode::UNAVAILABLE); } # If we are already encrypted, then consider this an operations error... - if ($this->queue->isEncrypted()) { - $this->queue->sendMessage(new LdapMessageResponse($message->getMessageId(), new ExtendedResponse( - new LdapResult(ResultCode::OPERATIONS_ERROR, '', 'The current LDAP session is already encrypted.'), + if ($this->connection->isEncrypted()) { + return $this->failure( + $message, + ResultCode::OPERATIONS_ERROR, + 'The current LDAP session is already encrypted.', + ); + } + + # The socket is upgraded in onComplete, after the writer has flushed this SUCCESS in plaintext. + return ResponseStream::reply( + $message, + OperationOutcomeResult::succeeded(), + new ExtendedResponse( + new LdapResult(ResultCode::SUCCESS), ExtendedRequest::OID_START_TLS, - ))); + ), + )->withOnComplete(function (ConnectionControl $connection) use ($message): void { + $connection->encrypt(); $this->eventLogger->record( - ServerEvent::StartTlsFailed, - [ - EventContext::RESULT_CODE => ResultCode::OPERATIONS_ERROR, - EventContext::REASON => 'The current LDAP session is already encrypted.', - ], + ServerEvent::StartTlsSucceeded, message: $message, ); + }); + } - return OperationOutcomeResult::failed(ResultCode::OPERATIONS_ERROR); - } - - $this->queue->sendMessage(new LdapMessageResponse($message->getMessageId(), new ExtendedResponse( - new LdapResult(ResultCode::SUCCESS), - ExtendedRequest::OID_START_TLS, - ))); - $this->queue->encrypt(); + private function failure( + LdapMessageRequest $message, + int $resultCode, + string $reason, + ): ResponseStream { $this->eventLogger->record( - ServerEvent::StartTlsSucceeded, + ServerEvent::StartTlsFailed, + [ + EventContext::RESULT_CODE => $resultCode, + EventContext::REASON => $reason, + ], message: $message, ); - return OperationOutcomeResult::succeeded(); + return ResponseStream::reply( + $message, + OperationOutcomeResult::failed($resultCode), + new ExtendedResponse( + new LdapResult( + $resultCode, + '', + $reason, + ), + ExtendedRequest::OID_START_TLS, + ), + ); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php index 59265b44..77cc06cf 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php @@ -18,13 +18,11 @@ use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Schema\Definition\ObjectClassOid; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\ServerOptions; @@ -35,15 +33,12 @@ */ class ServerSubschemaHandler implements ServerProtocolHandlerInterface { - public function __construct( - private readonly ServerOptions $options, - private readonly ServerQueue $queue, - ) {} + public function __construct(private readonly ServerOptions $options) {} public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $schemaDn = $this->options->getSubschemaEntry(); $rdn = $schemaDn->getRdn(); $schema = $this->options->getSchema(); @@ -76,18 +71,12 @@ public function handleRequest( ]), ); - $this->queue->sendMessage( - new LdapMessageResponse( - $message->getMessageId(), - new SearchResultEntry($entry), - ), - new LdapMessageResponse( - $message->getMessageId(), - new SearchResultDone(ResultCode::SUCCESS), - ), + return ResponseStream::reply( + $message, + OperationOutcomeResult::succeeded(), + new SearchResultEntry($entry), + new SearchResultDone(ResultCode::SUCCESS), ); - - return OperationOutcomeResult::succeeded(); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSyncHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSyncHandler.php index 3b0c088f..d562e1ae 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSyncHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSyncHandler.php @@ -26,10 +26,10 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\Journal\Read\ChangeStream; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -60,12 +60,14 @@ public function __construct( ) {} /** + * Phase 1: sync (refresh + persist) stays a direct writer; it is folded into the writer in Phase 2. + * * @throws OperationException */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $request = $this->getSearchRequestFromMessage($message); $control = $this->syncRequestControl($message); $stream = $this->changeStream; @@ -118,10 +120,10 @@ public function handleRequest( )); } - return SearchOperationResult::success( + return ResponseStream::none(SearchOperationResult::success( $message, $state->entriesReturned, - ); + )); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnbindHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnbindHandler.php index 5e5b5f45..acb7ddd0 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnbindHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnbindHandler.php @@ -14,9 +14,9 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\ConnectionControl; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -26,17 +26,13 @@ */ class ServerUnbindHandler implements ServerProtocolHandlerInterface { - public function __construct(private readonly ServerQueue $queue) {} - - /** - * {@inheritDoc} - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { - $this->queue->close(); - - return OperationOutcomeResult::succeeded(); + ): ResponseStream { + return ResponseStream::none(OperationOutcomeResult::succeeded()) + ->withOnComplete(static function (ConnectionControl $connection): void { + $connection->close(); + }); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandler.php index 87736aac..351022ad 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandler.php @@ -13,17 +13,14 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -33,25 +30,19 @@ */ final readonly class ServerUnsupportedExtendedHandler implements ServerProtocolHandlerInterface { - public function __construct(private ServerQueue $queue) {} - - /** - * {@inheritDoc} - * - * @throws EncoderException - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $request = $message->getRequest(); if (!$request instanceof ExtendedRequest) { throw new RuntimeException('The extended operation handler was routed a non-extended request.'); } - $this->queue->sendMessage(new LdapMessageResponse( - $message->getMessageId(), + return ResponseStream::reply( + $message, + OperationOutcomeResult::failed(ResultCode::PROTOCOL_ERROR), new ExtendedResponse( new LdapResult( ResultCode::PROTOCOL_ERROR, @@ -60,8 +51,6 @@ public function handleRequest( ), $request->getName(), ), - )); - - return OperationOutcomeResult::failed(ResultCode::PROTOCOL_ERROR); + ); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerWhoAmIHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerWhoAmIHandler.php index 864023c3..0163d5aa 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerWhoAmIHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerWhoAmIHandler.php @@ -13,15 +13,12 @@ namespace FreeDSx\Ldap\Protocol\ServerProtocolHandler; -use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; -use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -32,27 +29,24 @@ */ class ServerWhoAmIHandler implements ServerProtocolHandlerInterface { - public function __construct(private readonly ServerQueue $queue) {} - - /** - * {@inheritDoc} - * @throws EncoderException - */ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, - ): OperationResult { + ): ResponseStream { $userId = null; if ($token instanceof AuthenticatedTokenInterface) { $userId = $token->getAuthzId()->toString(); } - $this->queue->sendMessage(new LdapMessageResponse( - $message->getMessageId(), - new ExtendedResponse(new LdapResult(ResultCode::SUCCESS), null, $userId), - )); - - return OperationOutcomeResult::succeeded(); + return ResponseStream::reply( + $message, + OperationOutcomeResult::succeeded(), + new ExtendedResponse( + new LdapResult(ResultCode::SUCCESS), + null, + $userId, + ), + ); } } diff --git a/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php b/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php index 2ae77ff5..af8cae34 100644 --- a/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php +++ b/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php @@ -410,7 +410,10 @@ private function makeRequestPipeline( $this->container->get(OperationAuthorizationMiddleware::class), $this->container->get(AssertionMiddleware::class), ], - new HandlerInvoker($handlerProvider), + new HandlerInvoker( + $handlerProvider, + $queue, + ), ); } } diff --git a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php index 7842e64e..68fcb839 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php +++ b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php @@ -14,17 +14,24 @@ namespace FreeDSx\Ldap\Server\Middleware\Pipeline; use FreeDSx\Ldap\Protocol\Factory\ProtocolHandlerProviderInterface; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Server\Operation\OperationResult; +use function count; + /** - * Terminal handler that resolves and invokes the per-request protocol handler. + * Terminal handler that resolves the per-request handler, writes its response, and returns its outcome. * * @internal * @author Chad Sikorra */ final readonly class HandlerInvoker implements MiddlewareHandlerInterface { - public function __construct(private ProtocolHandlerProviderInterface $protocolHandlerProvider) {} + public function __construct( + private ProtocolHandlerProviderInterface $protocolHandlerProvider, + private ServerQueue $queue, + ) {} public function handle(ServerRequestContext $context): OperationResult { @@ -34,9 +41,70 @@ public function handle(ServerRequestContext $context): OperationResult $context->searchLimits(), ); - return $handler->handleRequest( + $stream = $handler->handleRequest( $context->message, $context->tokenOrFail(), ); + $this->write( + $stream, + $context->message->getMessageId(), + ); + + return $stream->outcome(); + } + + private function write( + ResponseStream $stream, + int $messageId, + ): void { + if ($stream->writerConfig->mustFlushOrSignal()) { + $this->step( + $stream, + $messageId, + ); + } else { + $this->queue->sendMessages($stream->messages); + } + + // Post-write connection side effect (e.g. StartTLS encrypt) โ€” the bytes are on the wire now. + if ($stream->onComplete !== null) { + ($stream->onComplete)($this->queue); + } + } + + /** + * Walk the generator, flushing in batches and offering polled cancel signals at the configured interval. + */ + private function step( + ResponseStream $stream, + int $messageId, + ): void { + $config = $stream->writerConfig; + // Flush per message for liveness, else batch up to the poll interval. + $flushEvery = $config->flushPerMessage + ? 1 + : max(1, $config->signalInterval); + $chunk = []; + $sincePoll = 0; + + // The producer reads the offered signal from the Cancellation token when foreach resumes it. + foreach ($stream->generator() as $message) { + $chunk[] = $message; + $sincePoll++; + + if (count($chunk) >= $flushEvery) { + $this->queue->sendMessages($chunk); + $chunk = []; + } + + if ($config->signalInterval > 0 && $sincePoll >= $config->signalInterval) { + $sincePoll = 0; + $stream->cancellation?->offer($this->queue->peekForCancelSignal($messageId)); + } + } + + if ($chunk !== []) { + $this->queue->sendMessages($chunk); + } } } From 1046740db525f4f2e47353c5fa29c6ec5f43d98b Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 18 Jul 2026 10:48:44 -0400 Subject: [PATCH 3/5] Fix the handler constructions in the container. --- .../Container/HandlerContainerProvider.php | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/src/FreeDSx/Ldap/Container/HandlerContainerProvider.php b/src/FreeDSx/Ldap/Container/HandlerContainerProvider.php index 372a96db..54449463 100644 --- a/src/FreeDSx/Ldap/Container/HandlerContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/HandlerContainerProvider.php @@ -71,33 +71,29 @@ private function makeFactoryMap(Container $container): ProtocolHandlerFactoryMap return new ProtocolHandlerFactoryMap([ HandlerId::Abandon->value => static fn(): ServerProtocolHandlerInterface => new ServerAbandonHandler(), - HandlerId::Cancel->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface - => new ServerCancelHandler($context->queue), - HandlerId::WhoAmI->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface - => new ServerWhoAmIHandler($context->queue), + HandlerId::Cancel->value => static fn(): ServerProtocolHandlerInterface + => new ServerCancelHandler(), + HandlerId::WhoAmI->value => static fn(): ServerProtocolHandlerInterface + => new ServerWhoAmIHandler(), HandlerId::PasswordModify->value => fn(HandlerContext $context): ServerProtocolHandlerInterface => $this->makePasswordModifyHandler($container, $context), - HandlerId::PasswordPolicyForward->value => fn(HandlerContext $context): ServerProtocolHandlerInterface - => $this->makeForwardHandler($container, $context), + HandlerId::PasswordPolicyForward->value => fn(): ServerProtocolHandlerInterface + => $this->makeForwardHandler($container), HandlerId::StartTls->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface => new ServerStartTlsHandler( options: $container->get(ServerOptions::class), - queue: $context->queue, + connection: $context->queue, eventLogger: $context->eventLogger, ), - HandlerId::UnsupportedExtended->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface - => new ServerUnsupportedExtendedHandler($context->queue), - HandlerId::RootDse->value => fn(HandlerContext $context): ServerProtocolHandlerInterface - => $this->makeRootDseHandler($container, $context), - HandlerId::Subschema->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface - => new ServerSubschemaHandler( - options: $container->get(ServerOptions::class), - queue: $context->queue, - ), - HandlerId::Monitor->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface + HandlerId::UnsupportedExtended->value => static fn(): ServerProtocolHandlerInterface + => new ServerUnsupportedExtendedHandler(), + HandlerId::RootDse->value => fn(): ServerProtocolHandlerInterface + => $this->makeRootDseHandler($container), + HandlerId::Subschema->value => static fn(): ServerProtocolHandlerInterface + => new ServerSubschemaHandler($container->get(ServerOptions::class)), + HandlerId::Monitor->value => static fn(): ServerProtocolHandlerInterface => new ServerMonitorHandler( options: $container->get(ServerOptions::class), - queue: $context->queue, snapshots: $container->get(MetricsSnapshotProvider::class), ), HandlerId::Paging->value => fn(HandlerContext $context, ?SearchLimits $limits): ServerProtocolHandlerInterface @@ -105,9 +101,9 @@ private function makeFactoryMap(Container $container): ProtocolHandlerFactoryMap HandlerId::Sync->value => fn(HandlerContext $context, ?SearchLimits $limits): ServerProtocolHandlerInterface => $this->makeSyncHandler($container, $context, $limits), HandlerId::Search->value => fn(HandlerContext $context, ?SearchLimits $limits): ServerProtocolHandlerInterface - => $this->makeSearchHandler($container, $context, $limits), - HandlerId::Unbind->value => static fn(HandlerContext $context): ServerProtocolHandlerInterface - => new ServerUnbindHandler($context->queue), + => $this->makeSearchHandler($container, $limits), + HandlerId::Unbind->value => static fn(): ServerProtocolHandlerInterface + => new ServerUnbindHandler(), HandlerId::Dispatch->value => fn(HandlerContext $context): ServerProtocolHandlerInterface => $this->makeDispatchHandler($container, $context), ]); @@ -120,7 +116,6 @@ private function makePasswordModifyHandler( $policyComponentFactory = $container->get(PasswordPolicyComponentFactory::class); return new ServerPasswordModifyHandler( - queue: $context->queue, service: new PasswordModifyService( targetResolver: $container->get(PasswordModifyTargetResolver::class), accessControl: $container->get(ServerOptions::class)->getAccessControl(), @@ -135,33 +130,27 @@ private function makePasswordModifyHandler( ); } - private function makeForwardHandler( - Container $container, - HandlerContext $context, - ): ServerProtocolHandlerInterface { + private function makeForwardHandler(Container $container): ServerProtocolHandlerInterface + { $backend = $container->get(HandlerFactoryInterface::class)->makeBackend(); if (!$backend instanceof WritableLdapBackendInterface) { - return new ServerUnsupportedExtendedHandler($context->queue); + return new ServerUnsupportedExtendedHandler(); } return new ServerPasswordPolicyForwardHandler( - queue: $context->queue, backend: $backend, policyResolver: $container->get(PasswordPolicyComponentFactory::class)->makeResolver(), engine: $container->get(PasswordPolicyEngine::class), ); } - private function makeRootDseHandler( - Container $container, - HandlerContext $context, - ): ServerRootDseHandler { + private function makeRootDseHandler(Container $container): ServerRootDseHandler + { $handlerFactory = $container->get(HandlerFactoryInterface::class); $backend = $handlerFactory->makeBackend(); return new ServerRootDseHandler( options: $container->get(ServerOptions::class), - queue: $context->queue, backend: $backend, rootDseHandler: $handlerFactory->makeRootDseHandler(), supportsSync: $this->syncJournalFor($container, $backend) !== null, @@ -223,7 +212,6 @@ private function makeDispatchHandler( ); return new ServerDispatchHandler( - queue: $context->queue, backend: $handlerFactory->makeBackend(), writeDispatcher: $policyWriteHandler !== null ? $handlerFactory->makeWriteDispatcher($policyWriteHandler) @@ -235,13 +223,11 @@ private function makeDispatchHandler( private function makeSearchHandler( Container $container, - HandlerContext $context, ?SearchLimits $searchLimits, ): ServerSearchHandler { $options = $container->get(ServerOptions::class); return new ServerSearchHandler( - queue: $context->queue, backend: $container->get(HandlerFactoryInterface::class)->makeBackend(), filterEvaluator: $options->getFilterEvaluator(), accessControl: $options->getAccessControl(), @@ -258,7 +244,6 @@ private function makePagingHandler( $options = $container->get(ServerOptions::class); return new ServerPagingHandler( - queue: $context->queue, backend: $container->get(HandlerFactoryInterface::class)->makeBackend(), filterEvaluator: $options->getFilterEvaluator(), accessControl: $options->getAccessControl(), From 7a9f70fe84193f91d08b39fbc9b3d964df820b54 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 18 Jul 2026 10:55:13 -0400 Subject: [PATCH 4/5] Use the ResponseWriter so the proxy pipeline with start tls stays consistent. --- .../Queue/Response/ResponseWriter.php | 87 +++++++++++++++++++ .../Ldap/Server/ConnectionHandlerBuilder.php | 3 +- .../Middleware/Pipeline/HandlerInvoker.php | 67 +------------- .../Server/Proxy/ProxyProtocolFactory.php | 2 + .../Server/Proxy/ProxyRequestPipeline.php | 9 +- 5 files changed, 103 insertions(+), 65 deletions(-) create mode 100644 src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseWriter.php diff --git a/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseWriter.php b/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseWriter.php new file mode 100644 index 00000000..55b41051 --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Queue/Response/ResponseWriter.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Queue\Response; + +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Server\Operation\OperationResult; + +use function count; + +/** + * Drains a handler's ResponseStream to the queue and resolves its outcome. + * + * @internal + * @author Chad Sikorra + */ +final readonly class ResponseWriter +{ + public function __construct(private ServerQueue $queue) {} + + public function write( + ResponseStream $stream, + int $messageId, + ): OperationResult { + if ($stream->writerConfig->mustFlushOrSignal()) { + $this->step( + $stream, + $messageId, + ); + } else { + $this->queue->sendMessages($stream->messages); + } + + // Post-write connection side effect (e.g. StartTLS encrypt) โ€” the bytes are on the wire now. + if ($stream->onComplete !== null) { + ($stream->onComplete)($this->queue); + } + + return $stream->outcome(); + } + + /** + * Walk the generator, flushing in batches and offering polled cancel signals at the configured interval. + */ + private function step( + ResponseStream $stream, + int $messageId, + ): void { + $config = $stream->writerConfig; + // Flush per message for liveness, else batch up to the poll interval. + $flushEvery = $config->flushPerMessage + ? 1 + : max(1, $config->signalInterval); + $chunk = []; + $sincePoll = 0; + + // The producer reads the offered signal from the Cancellation token when foreach resumes it. + foreach ($stream->generator() as $message) { + $chunk[] = $message; + $sincePoll++; + + if (count($chunk) >= $flushEvery) { + $this->queue->sendMessages($chunk); + $chunk = []; + } + + if ($config->signalInterval > 0 && $sincePoll >= $config->signalInterval) { + $sincePoll = 0; + $stream->cancellation?->offer($this->queue->peekForCancelSignal($messageId)); + } + } + + if ($chunk !== []) { + $this->queue->sendMessages($chunk); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php b/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php index af8cae34..d868a957 100644 --- a/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php +++ b/src/FreeDSx/Ldap/Server/ConnectionHandlerBuilder.php @@ -33,6 +33,7 @@ use FreeDSx\Ldap\Protocol\Factory\ServerProtocolHandlerFactory; use FreeDSx\Ldap\Protocol\Queue\Response\MetricsResponseInterceptor; use FreeDSx\Ldap\Protocol\Queue\Response\PasswordPolicyResponseInterceptor; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerAuthorization; use FreeDSx\Ldap\Protocol\ServerProtocolHandler; @@ -412,7 +413,7 @@ private function makeRequestPipeline( ], new HandlerInvoker( $handlerProvider, - $queue, + new ResponseWriter($queue), ), ); } diff --git a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php index 68fcb839..58bc4700 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php +++ b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php @@ -14,12 +14,9 @@ namespace FreeDSx\Ldap\Server\Middleware\Pipeline; use FreeDSx\Ldap\Protocol\Factory\ProtocolHandlerProviderInterface; -use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Server\Operation\OperationResult; -use function count; - /** * Terminal handler that resolves the per-request handler, writes its response, and returns its outcome. * @@ -30,7 +27,7 @@ { public function __construct( private ProtocolHandlerProviderInterface $protocolHandlerProvider, - private ServerQueue $queue, + private ResponseWriter $responseWriter, ) {} public function handle(ServerRequestContext $context): OperationResult @@ -45,66 +42,10 @@ public function handle(ServerRequestContext $context): OperationResult $context->message, $context->tokenOrFail(), ); - $this->write( + + return $this->responseWriter->write( $stream, $context->message->getMessageId(), ); - - return $stream->outcome(); - } - - private function write( - ResponseStream $stream, - int $messageId, - ): void { - if ($stream->writerConfig->mustFlushOrSignal()) { - $this->step( - $stream, - $messageId, - ); - } else { - $this->queue->sendMessages($stream->messages); - } - - // Post-write connection side effect (e.g. StartTLS encrypt) โ€” the bytes are on the wire now. - if ($stream->onComplete !== null) { - ($stream->onComplete)($this->queue); - } - } - - /** - * Walk the generator, flushing in batches and offering polled cancel signals at the configured interval. - */ - private function step( - ResponseStream $stream, - int $messageId, - ): void { - $config = $stream->writerConfig; - // Flush per message for liveness, else batch up to the poll interval. - $flushEvery = $config->flushPerMessage - ? 1 - : max(1, $config->signalInterval); - $chunk = []; - $sincePoll = 0; - - // The producer reads the offered signal from the Cancellation token when foreach resumes it. - foreach ($stream->generator() as $message) { - $chunk[] = $message; - $sincePoll++; - - if (count($chunk) >= $flushEvery) { - $this->queue->sendMessages($chunk); - $chunk = []; - } - - if ($config->signalInterval > 0 && $sincePoll >= $config->signalInterval) { - $sincePoll = 0; - $stream->cancellation?->offer($this->queue->peekForCancelSignal($messageId)); - } - } - - if ($chunk !== []) { - $this->queue->sendMessages($chunk); - } } } diff --git a/src/FreeDSx/Ldap/Server/Proxy/ProxyProtocolFactory.php b/src/FreeDSx/Ldap/Server/Proxy/ProxyProtocolFactory.php index 2ae25f54..7e51b726 100644 --- a/src/FreeDSx/Ldap/Server/Proxy/ProxyProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/Proxy/ProxyProtocolFactory.php @@ -17,6 +17,7 @@ use FreeDSx\Ldap\Protocol\Authenticator; use FreeDSx\Ldap\Protocol\Authorization\DispatchAuthorizer; use FreeDSx\Ldap\Protocol\Bind\SimpleBind; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Protocol\ServerAuthorization; use FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerStartTlsHandler; @@ -90,6 +91,7 @@ public function make( $upstream, $queue, ), + new ResponseWriter($queue), ), ); diff --git a/src/FreeDSx/Ldap/Server/Proxy/ProxyRequestPipeline.php b/src/FreeDSx/Ldap/Server/Proxy/ProxyRequestPipeline.php index eb3d3cd5..e3270c4f 100644 --- a/src/FreeDSx/Ldap/Server/Proxy/ProxyRequestPipeline.php +++ b/src/FreeDSx/Ldap/Server/Proxy/ProxyRequestPipeline.php @@ -14,6 +14,7 @@ namespace FreeDSx\Ldap\Server\Proxy; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerProtocolHandlerInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareHandlerInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\ServerRequestContext; @@ -29,6 +30,7 @@ public function __construct( private ServerProtocolHandlerInterface $startTlsHandler, private MiddlewareHandlerInterface $forwarder, + private ResponseWriter $responseWriter, ) {} /** @@ -39,10 +41,15 @@ public function handle(ServerRequestContext $context): OperationResult $request = $context->message->getRequest(); if ($request instanceof ExtendedRequest && $request->getName() === ExtendedRequest::OID_START_TLS) { - return $this->startTlsHandler->handleRequest( + $stream = $this->startTlsHandler->handleRequest( $context->message, $context->tokenOrFail(), ); + + return $this->responseWriter->write( + $stream, + $context->message->getMessageId(), + ); } return $this->forwarder->handle($context); From 9d381b70bbc64d67c063bd5873a8787fb59f4bdc Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 18 Jul 2026 11:26:38 -0400 Subject: [PATCH 5/5] Fix the tests now that the handlers return response streams. --- .../PasswordPolicyChangeEnforcementTest.php | 49 +++-- ...sswordPolicyPlainModifyEnforcementTest.php | 34 ++- .../ServerAbandonHandlerTest.php | 15 +- .../ServerCancelHandlerTest.php | 24 +- .../ServerDispatchHandlerTest.php | 20 +- .../ServerMonitorHandlerTest.php | 68 ++---- .../ServerPagingHandlerTest.php | 91 ++++---- .../ServerPasswordModifyHandlerTest.php | 103 ++++----- ...ServerPasswordPolicyForwardHandlerTest.php | 25 +-- .../ServerRootDseHandlerTest.php | 208 ++++++++---------- .../ServerSearchHandlerTest.php | 145 ++++++------ .../ServerStartTlsHandlerTest.php | 95 ++++---- .../ServerSubschemaHandlerTest.php | 86 +++----- .../ServerSyncHandlerTest.php | 2 +- .../ServerUnbindHandlerTest.php | 38 ++-- .../ServerUnsupportedExtendedHandlerTest.php | 35 ++- .../ServerWhoAmIHandlerTest.php | 93 ++++---- .../Server/Proxy/ProxyRequestPipelineTest.php | 6 +- 18 files changed, 524 insertions(+), 613 deletions(-) diff --git a/tests/integration/Security/PasswordPolicyChangeEnforcementTest.php b/tests/integration/Security/PasswordPolicyChangeEnforcementTest.php index d18f1eec..e8b3e281 100644 --- a/tests/integration/Security/PasswordPolicyChangeEnforcementTest.php +++ b/tests/integration/Security/PasswordPolicyChangeEnforcementTest.php @@ -25,7 +25,6 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\Response\PasswordPolicyResponseInterceptor; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Schema\Definition\GeneralizedTime; use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; @@ -55,6 +54,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordQualityRules; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPasswordModifyHandler; use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; @@ -90,7 +90,8 @@ public function test_reused_password_is_rejected_with_history_control(): void [PasswordPolicyOid::NAME_PWD_HISTORY => $this->historyValue('previous-pass')], ); - $handler->handleRequest( + $this->handle( + $handler, $this->request(self::OLD_PASSWORD, 'previous-pass'), $this->selfToken(), ); @@ -106,7 +107,8 @@ public function test_change_within_min_age_is_rejected(): void [PasswordPolicyOid::NAME_PWD_CHANGED_TIME => $this->minutesAgo(30)], ); - $handler->handleRequest( + $this->handle( + $handler, $this->request(self::OLD_PASSWORD, 'a-fresh-password'), $this->selfToken(), ); @@ -121,7 +123,8 @@ public function test_safe_modify_without_old_password_is_rejected(): void new PasswordPolicy(change: new PasswordChangeRules(safeModify: true)), ); - $handler->handleRequest( + $this->handle( + $handler, $this->request(null, 'a-fresh-password'), $this->selfToken(), ); @@ -136,7 +139,8 @@ public function test_self_change_blocked_when_not_allowed(): void new PasswordPolicy(change: new PasswordChangeRules(allowUserChange: false)), ); - $handler->handleRequest( + $this->handle( + $handler, $this->request(self::OLD_PASSWORD, 'a-fresh-password'), $this->selfToken(), ); @@ -152,7 +156,8 @@ public function test_successful_change_persists_password_and_clears_reset(): voi [PasswordPolicyOid::NAME_PWD_RESET => 'TRUE'], ); - $handler->handleRequest( + $this->handle( + $handler, $this->request(self::OLD_PASSWORD, 'a-fresh-password'), $this->selfToken(), ); @@ -185,7 +190,8 @@ public function test_admin_reset_sets_pwd_reset_when_policy_requires_change(): v new PasswordPolicy(change: new PasswordChangeRules(mustChange: true)), ); - $handler->handleRequest( + $this->handle( + $handler, $this->request( null, 'admin-set-password', @@ -214,7 +220,8 @@ public function test_self_change_clears_pwd_reset_even_under_must_change_policy( [PasswordPolicyOid::NAME_PWD_RESET => 'TRUE'], ); - $handler->handleRequest( + $this->handle( + $handler, $this->request( self::OLD_PASSWORD, 'a-fresh-password', @@ -239,7 +246,8 @@ public function test_self_change_lifts_the_session_must_change_restriction(): vo $token = $this->selfToken(); $token->markMustChangePassword(); - $handler->handleRequest( + $this->handle( + $handler, $this->request( self::OLD_PASSWORD, 'a-fresh-password', @@ -284,7 +292,6 @@ private function handlerFor( ])); return new ServerPasswordModifyHandler( - queue: $this->capturingQueue(), service: new PasswordModifyService( targetResolver: new PasswordModifyTargetResolver( $this->backend, @@ -321,19 +328,15 @@ private function engine(): PasswordPolicyEngine ); } - private function capturingQueue(): ServerQueue - { - $interceptor = new PasswordPolicyResponseInterceptor($this->context); - $queue = $this->createMock(ServerQueue::class); - $queue - ->method('sendMessage') - ->willReturnCallback(function (LdapMessageResponse $response) use ($queue, $interceptor): ServerQueue { - $this->response = $interceptor->intercept($response); - - return $queue; - }); - - return $queue; + private function handle( + ServerPasswordModifyHandler $handler, + LdapMessageRequest $request, + TokenInterface $token, + ): void { + $stream = $handler->handleRequest($request, $token); + $messages = [...$stream->messages]; + self::assertCount(1, $messages); + $this->response = (new PasswordPolicyResponseInterceptor($this->context))->intercept($messages[0]); } private function request( diff --git a/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php b/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php index 385d14e1..29136e5e 100644 --- a/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php +++ b/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php @@ -25,7 +25,6 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\Response\PasswordPolicyResponseInterceptor; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Schema\Schema; @@ -53,6 +52,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordChangeRules; use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordQualityRules; use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; @@ -117,7 +117,8 @@ public function test_successful_change_persists_and_records_bookkeeping(): void [PasswordPolicyOid::NAME_PWD_RESET => 'TRUE'], ); - $handler->handleRequest( + $this->handle( + $handler, $this->modify('a-fresh-password'), $this->token(), ); @@ -213,7 +214,8 @@ public function test_safe_modify_with_correct_old_password_succeeds(): void new PasswordPolicy(change: new PasswordChangeRules(safeModify: true)), ); - $handler->handleRequest( + $this->handle( + $handler, $this->modifyWithOld('original-pass', 'a-fresh-password'), $this->token(), ); @@ -239,7 +241,8 @@ public function test_self_password_modify_lifts_the_session_must_change_restrict $token = $this->token(); $token->markMustChangePassword(); - $handler->handleRequest( + $this->handle( + $handler, $this->modify('a-fresh-password'), $token, ); @@ -311,7 +314,6 @@ private function dispatchHandler( ); return new ServerDispatchHandler( - queue: $this->capturingQueue(), backend: $this->backend, writeDispatcher: new WriteOperationDispatcher( $policyWriteHandler, @@ -322,19 +324,15 @@ private function dispatchHandler( ); } - private function capturingQueue(): ServerQueue - { - $interceptor = new PasswordPolicyResponseInterceptor($this->context); - $queue = $this->createMock(ServerQueue::class); - $queue - ->method('sendMessage') - ->willReturnCallback(function (LdapMessageResponse $response) use ($queue, $interceptor): ServerQueue { - $this->response = $interceptor->intercept($response); - - return $queue; - }); - - return $queue; + private function handle( + ServerDispatchHandler $handler, + LdapMessageRequest $request, + TokenInterface $token, + ): void { + $stream = $handler->handleRequest($request, $token); + $messages = [...$stream->messages]; + self::assertCount(1, $messages); + $this->response = (new PasswordPolicyResponseInterceptor($this->context))->intercept($messages[0]); } private function modify(string $newPassword): LdapMessageRequest diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerAbandonHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerAbandonHandlerTest.php index d5425054..c74d125c 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerAbandonHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerAbandonHandlerTest.php @@ -15,7 +15,6 @@ use FreeDSx\Ldap\Operation\Request\AbandonRequest; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerAbandonHandler; use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -23,31 +22,29 @@ final class ServerAbandonHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private TokenInterface&MockObject $mockToken; private ServerAbandonHandler $subject; protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockToken = $this->createMock(TokenInterface::class); $this->subject = new ServerAbandonHandler(); } public function test_it_sends_no_response_per_rfc4511(): void { - $this->mockQueue - ->expects(self::never()) - ->method('sendMessage'); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( new LdapMessageRequest( 2, new AbandonRequest(1), ), $this->mockToken, ); + + $this->assertSame( + [], + [...$stream->messages], + ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerCancelHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerCancelHandlerTest.php index 16014a3d..31cfa4a0 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerCancelHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerCancelHandlerTest.php @@ -19,7 +19,6 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerCancelHandler; use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -27,32 +26,29 @@ final class ServerCancelHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private TokenInterface&MockObject $mockToken; private ServerCancelHandler $subject; protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockToken = $this->createMock(TokenInterface::class); - $this->subject = new ServerCancelHandler($this->mockQueue); + $this->subject = new ServerCancelHandler(); } public function test_it_sends_no_such_operation_when_target_already_completed(): void { - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::equalTo(new LdapMessageResponse( - 3, - new ExtendedResponse(new LdapResult(ResultCode::NO_SUCH_OPERATION)), - ))); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( new LdapMessageRequest(3, new CancelRequest(2)), $this->mockToken, ); + + $this->assertEquals( + [new LdapMessageResponse( + 3, + new ExtendedResponse(new LdapResult(ResultCode::NO_SUCH_OPERATION)), + )], + [...$stream->messages], + ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php index 1fc848dd..d370c65e 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php @@ -23,7 +23,6 @@ use FreeDSx\Ldap\Operation\Request\DeleteRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerDispatchHandler; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Search\Filter\EqualityFilter; @@ -48,8 +47,6 @@ final class ServerDispatchHandlerTest extends TestCase private WriteHandlerInterface&MockObject $mockWriteHandler; - private ServerQueue&MockObject $mockQueue; - private TokenInterface&MockObject $mockToken; private AccessControlInterface&MockObject $mockAccessControl; @@ -57,21 +54,15 @@ final class ServerDispatchHandlerTest extends TestCase protected function setUp(): void { $this->mockToken = $this->createMock(TokenInterface::class); - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockBackend = $this->createMock(LdapBackendInterface::class); $this->mockWriteHandler = $this->createMock(WriteHandlerInterface::class); $this->mockAccessControl = $this->createMock(AccessControlInterface::class); - $this->mockQueue - ->method('sendMessage') - ->willReturnSelf(); - $this->mockWriteHandler ->method('supports') ->willReturn(true); $this->subject = new ServerDispatchHandler( - queue: $this->mockQueue, backend: $this->mockBackend, writeDispatcher: new WriteOperationDispatcher($this->mockWriteHandler), accessControl: $this->mockAccessControl, @@ -88,12 +79,12 @@ public function test_it_dispatches_write_requests_through_the_write_handler(): v ->method('handle') ->with(self::isInstanceOf(WriteRequestInterface::class)); - $result = $this->subject->handleRequest($add, $this->mockToken); + $outcome = $this->subject->handleRequest($add, $this->mockToken)->outcome(); - self::assertInstanceOf(WriteOperationResult::class, $result); + self::assertInstanceOf(WriteOperationResult::class, $outcome); self::assertSame( OperationOutcome::Succeeded, - $result->outcome(), + $outcome->outcome(), ); } @@ -132,9 +123,9 @@ public function test_it_delegates_compare_to_the_backend(): void ) ->willReturn(true); - $result = $this->subject->handleRequest($compare, $this->mockToken); + $outcome = $this->subject->handleRequest($compare, $this->mockToken)->outcome(); - self::assertInstanceOf(CompareOperationResult::class, $result); + self::assertInstanceOf(CompareOperationResult::class, $outcome); } public function test_it_lets_operation_exceptions_from_backend_compare_bubble(): void @@ -157,7 +148,6 @@ public function test_it_lets_operation_exceptions_from_backend_compare_bubble(): public function test_it_throws_when_no_write_handler_supports_the_operation(): void { $subject = new ServerDispatchHandler( - queue: $this->mockQueue, backend: $this->mockBackend, writeDispatcher: new WriteOperationDispatcher(), accessControl: $this->mockAccessControl, diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php index 9f3c5e58..7ba28e8c 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php @@ -21,7 +21,6 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerMonitorHandler; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Metrics\Observation\ConnectionObservation; @@ -36,8 +35,6 @@ final class ServerMonitorHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private TokenInterface&MockObject $mockToken; private InMemoryMetricsRecorder $metrics; @@ -46,41 +43,39 @@ final class ServerMonitorHandlerTest extends TestCase protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockToken = $this->createMock(TokenInterface::class); $this->metrics = new InMemoryMetricsRecorder(); $this->subject = new ServerMonitorHandler( options: new ServerOptions(), - queue: $this->mockQueue, snapshots: $this->metrics, ); } public function test_it_serves_the_cn_monitor_entry(): void { - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(static function (LdapMessageResponse $response): bool { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $entry = $result->getEntry(); - - return $entry->getDn()->toString() === 'cn=monitor' - && ($entry->get('objectClass')?->has('extensibleObject') ?? false); - }), - self::equalTo(new LdapMessageResponse( - 1, - new SearchResultDone(ResultCode::SUCCESS), - )), - ); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $this->makeMessage(), $this->mockToken, ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $entry = $result->getEntry(); + + self::assertSame( + 'cn=monitor', + $entry->getDn()->toString(), + ); + self::assertTrue($entry->get('objectClass')?->has('extensibleObject') ?? false); + self::assertEquals( + new LdapMessageResponse( + 1, + new SearchResultDone(ResultCode::SUCCESS), + ), + $messages[1], + ); } public function test_it_reports_the_live_connection_gauges(): void @@ -310,7 +305,6 @@ public function test_it_reports_in_flight_operations_under_a_coroutine_runner(): $subject = new ServerMonitorHandler( options: (new ServerOptions())->setUseSwooleRunner(true), - queue: $this->mockQueue, snapshots: $this->metrics, ); @@ -334,29 +328,15 @@ private function makeMessage(): LdapMessageRequest private function handleAndCaptureEntry(?ServerMonitorHandler $subject = null): Entry { - $captured = null; - - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(static function (LdapMessageResponse $response) use (&$captured): bool { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $captured = $result->getEntry(); - - return true; - }), - self::anything(), - ); - - ($subject ?? $this->subject)->handleRequest( + $stream = ($subject ?? $this->subject)->handleRequest( $this->makeMessage(), $this->mockToken, ); + $messages = [...$stream->messages]; - assert($captured instanceof Entry); + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); - return $captured; + return $result->getEntry(); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index ddfb96f9..b33e24ec 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -28,6 +28,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPagingHandler; use FreeDSx\Ldap\Schema\Schema; @@ -40,6 +41,7 @@ use FreeDSx\Ldap\Server\RequestHistory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Operation\OperationOutcome; +use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -104,7 +106,6 @@ protected function setUp(): void }); $this->subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $this->mockAccessControl, @@ -126,9 +127,9 @@ public function test_it_should_call_the_backend_search_on_paging_start_and_retur ->with(self::isInstanceOf(SearchRequest::class)) ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); - $result = $this->subject->handleRequest( + $result = $this->drive( + $this->subject, $message, - $this->mockToken, ); self::assertEquals( @@ -160,9 +161,9 @@ public function test_it_should_store_the_generator_and_return_a_cookie_when_more ->method('search') ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); self::assertEquals( @@ -186,7 +187,7 @@ public function test_it_should_continue_from_the_stored_generator_on_subsequent_ ->method('search') ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); - $this->subject->handleRequest($firstMessage, $this->mockToken); + $this->drive($this->subject, $firstMessage); $capturedCookie = $this->donePagingControl()->getCookie(); self::assertNotSame('', $capturedCookie); @@ -199,7 +200,7 @@ public function test_it_should_continue_from_the_stored_generator_on_subsequent_ searchRequest: $pagingReq->getSearchRequest(), ); - $this->subject->handleRequest($secondMessage, $this->mockToken); + $this->drive($this->subject, $secondMessage); } public function test_it_should_send_the_correct_response_if_paging_is_abandoned(): void @@ -215,9 +216,9 @@ public function test_it_should_send_the_correct_response_if_paging_is_abandoned( ->expects(self::never()) ->method('search'); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); self::assertSame([], $this->entryMessages()); @@ -237,9 +238,9 @@ public function test_it_sends_a_result_code_error_in_SearchResultDone_if_the_old ->expects(self::never()) ->method('search'); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); self::assertEquals( @@ -282,9 +283,9 @@ public function test_it_sends_an_operations_error_when_the_paging_generator_has_ ->expects(self::never()) ->method('search'); - $result = $this->subject->handleRequest( + $result = $this->drive( + $this->subject, $message, - $this->mockToken, ); self::assertSame([], $this->entryMessages()); @@ -330,7 +331,7 @@ public function test_it_should_return_size_limit_exceeded_on_first_page_when_lim ->method('search') ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); - $this->subject->handleRequest($message, $this->mockToken); + $this->drive($this->subject, $message); self::assertEquals( [new LdapMessageResponse(2, new SearchResultEntry($entry1))], @@ -360,9 +361,9 @@ public function test_size_limit_applies_per_page_not_cumulatively(): void ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2, $entry3, $entry4))); // First page: pageSize=1, sizeLimit=2 โ€” gets entry1, stores generator - $this->subject->handleRequest( + $this->drive( + $this->subject, $this->makeSearchMessage(size: 1, searchRequest: $searchRequest), - $this->mockToken, ); $capturedCookie = $this->donePagingControl()->getCookie(); @@ -370,9 +371,9 @@ public function test_size_limit_applies_per_page_not_cumulatively(): void // Second page: pageSize=10, sizeLimit=2 โ€” gets entry2+entry3 (hits limit), entry4 still in generator โ†’ SIZE_LIMIT_EXCEEDED $pagingReq = $this->requestHistory->pagingRequest()->findByNextCookie($capturedCookie); - $this->subject->handleRequest( + $this->drive( + $this->subject, $this->makeSearchMessage(size: 10, cookie: $capturedCookie, searchRequest: $pagingReq->getSearchRequest()), - $this->mockToken, ); $sizeLimitExceededSeen = false; @@ -403,7 +404,6 @@ public function test_server_max_search_size_applies_to_paged_search_when_client_ ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $this->mockAccessControl, @@ -411,7 +411,7 @@ public function test_server_max_search_size_applies_to_paged_search_when_client_ schema: $this->schema, limits: new SearchLimits(maxSearchSize: 1), ); - $subject->handleRequest($message, $this->mockToken); + $this->drive($subject, $message); self::assertEquals( [new LdapMessageResponse(2, new SearchResultEntry($entry1))], @@ -435,7 +435,6 @@ public function test_server_max_page_size_caps_client_page_size(): void ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $this->mockAccessControl, @@ -443,7 +442,7 @@ public function test_server_max_page_size_caps_client_page_size(): void schema: $this->schema, limits: new SearchLimits(maxSearchPageSize: 1), ); - $subject->handleRequest($message, $this->mockToken); + $this->drive($subject, $message); // Only 1 entry returned despite client requesting page size 10. self::assertEquals( @@ -468,7 +467,6 @@ public function test_server_max_page_size_is_used_when_client_sends_zero(): void ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2, $entry3))); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $this->mockAccessControl, @@ -476,7 +474,7 @@ public function test_server_max_page_size_is_used_when_client_sends_zero(): void schema: $this->schema, limits: new SearchLimits(maxSearchPageSize: 2), ); - $subject->handleRequest($message, $this->mockToken); + $this->drive($subject, $message); // Server applies its max of 2 entries per page. self::assertCount(2, $this->entryMessages()); @@ -496,7 +494,6 @@ public function test_client_page_size_is_honoured_when_below_server_max(): void ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $this->mockAccessControl, @@ -504,7 +501,7 @@ public function test_client_page_size_is_honoured_when_below_server_max(): void schema: $this->schema, limits: new SearchLimits(maxSearchPageSize: 5), ); - $subject->handleRequest($message, $this->mockToken); + $this->drive($subject, $message); // Client requested 1 per page; server max is 5 โ€” client's lower value wins. self::assertEquals( @@ -541,14 +538,13 @@ public function test_it_should_suppress_entry_when_filter_entry_returns_null(): ))); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $mockAccessControl, requestHistory: $this->requestHistory, schema: $this->schema, ); - $subject->handleRequest($message, $this->mockToken); + $this->drive($subject, $message); self::assertEquals( [new LdapMessageResponse(2, new SearchResultEntry($entry2))], @@ -583,14 +579,13 @@ public function test_it_should_skip_entry_when_filter_no_longer_matches_after_ac ->willReturn(new EntryStream($this->makeGenerator($entry))); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $mockFilterEvaluator, accessControl: $mockAccessControl, requestHistory: $this->requestHistory, schema: $this->schema, ); - $subject->handleRequest($message, $this->mockToken); + $this->drive($subject, $message); self::assertSame([], $this->entryMessages()); } @@ -608,9 +603,9 @@ public function test_sort_control_appends_sorting_response_control_to_done_messa ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); $sortControl = $this->doneMessage()->controls()->get(Control::OID_SORTING_RESPONSE); @@ -637,9 +632,9 @@ public function test_sort_by_unknown_attribute_reports_no_such_attribute(): void ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); $sortControl = $this->doneMessage()->controls()->get(Control::OID_SORTING_RESPONSE); @@ -665,9 +660,9 @@ public function test_no_sort_control_does_not_append_sorting_response_control(): ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); self::assertNull( @@ -694,9 +689,9 @@ public function test_matched_dn_from_exception_is_used_in_SearchResultDone_when_ ->method('get') ->willReturn($matchedEntry); - $this->subject->handleRequest( + $this->drive( + $this->subject, $message, - $this->mockToken, ); $done = $this->doneMessage()->getResponse(); @@ -736,7 +731,6 @@ public function test_matched_dn_is_dropped_when_access_control_hides_ancestor_on ->willReturn(null); $subject = new ServerPagingHandler( - queue: $this->mockQueue, backend: $this->mockBackend, filterEvaluator: $this->mockFilterEvaluator, accessControl: $mockAccessControl, @@ -744,9 +738,9 @@ public function test_matched_dn_is_dropped_when_access_control_hides_ancestor_on schema: $this->schema, ); - $subject->handleRequest( + $this->drive( + $subject, $message, - $this->mockToken, ); $done = $this->doneMessage()->getResponse(); @@ -761,6 +755,21 @@ public function test_matched_dn_is_dropped_when_access_control_hides_ancestor_on ); } + private function drive( + ServerPagingHandler $subject, + LdapMessageRequest $message, + ): OperationResult { + $stream = $subject->handleRequest( + $message, + $this->mockToken, + ); + + return (new ResponseWriter($this->mockQueue))->write( + $stream, + $message->getMessageId(), + ); + } + private function makeGenerator(Entry ...$entries): Generator { yield from $entries; diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php index e65bf3e0..3e2b884a 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php @@ -16,12 +16,12 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Request\PasswordModifyRequest; use FreeDSx\Ldap\Operation\Response\PasswordModifyResponse; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPasswordModifyHandler; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; @@ -39,13 +39,11 @@ use PHPUnit\Framework\TestCase; /** - * Covers the transport seam: decoding, response encoding, and error mapping. The use case itself is exercised by + * Covers the transport seam: decoding, response building, and error mapping. The use case itself is exercised by * {@see \Tests\Unit\FreeDSx\Ldap\Server\PasswordModify\PasswordModifyServiceTest}. */ final class ServerPasswordModifyHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private LdapBackendInterface&MockObject $mockBackend; private ServerPasswordModifyHandler $subject; @@ -56,11 +54,9 @@ final class ServerPasswordModifyHandlerTest extends TestCase protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockBackend = $this->createMock(LdapBackendInterface::class); $mockWriteHandler = $this->createMock(WriteHandlerInterface::class); - $this->mockQueue->method('sendMessage')->willReturnSelf(); $mockWriteHandler->method('supports')->willReturn(true); $this->userEntry = new Entry( @@ -72,7 +68,6 @@ protected function setUp(): void ); $this->subject = new ServerPasswordModifyHandler( - queue: $this->mockQueue, service: new PasswordModifyService( targetResolver: new PasswordModifyTargetResolver( $this->mockBackend, @@ -91,16 +86,7 @@ public function test_self_service_change_sends_success_response(): void ->method('get') ->willReturn($this->userEntry); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::callback( - fn(LdapMessageResponse $r) - => $r->getResponse() instanceof PasswordModifyResponse - && $r->getResponse()->getGeneratedPassword() === null, - )); - - $result = $this->subject->handleRequest( + $stream = $this->subject->handleRequest( new LdapMessageRequest( 1, new PasswordModifyRequest(null, '12345', 'newpass'), @@ -108,10 +94,15 @@ public function test_self_service_change_sends_success_response(): void $this->userToken, ); - self::assertInstanceOf(PasswordModifyOperationResult::class, $result); + $response = $this->singleResponse($stream->messages); + self::assertInstanceOf(PasswordModifyResponse::class, $response); + self::assertNull($response->getGeneratedPassword()); + + $outcome = $stream->outcome(); + self::assertInstanceOf(PasswordModifyOperationResult::class, $outcome); self::assertSame( OperationOutcome::Succeeded, - $result->outcome(), + $outcome->outcome(), ); } @@ -121,39 +112,33 @@ public function test_server_generated_password_is_returned_in_response(): void ->method('get') ->willReturn($this->userEntry); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::callback( - fn(LdapMessageResponse $r) - => $r->getResponse() instanceof PasswordModifyResponse - && strlen((string) $r->getResponse()->getGeneratedPassword()) === 16, - )); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( new LdapMessageRequest( 1, new PasswordModifyRequest(null, '12345', null), ), $this->userToken, ); + + $response = $this->singleResponse($stream->messages); + self::assertInstanceOf(PasswordModifyResponse::class, $response); + self::assertSame( + 16, + strlen((string) $response->getGeneratedPassword()), + ); } public function test_anonymous_token_is_rejected_with_unwilling_to_perform(): void { - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::callback( - fn(LdapMessageResponse $r) - => method_exists($r->getResponse(), 'getResultCode') - && $r->getResponse()->getResultCode() === ResultCode::UNWILLING_TO_PERFORM, - )); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( new LdapMessageRequest(1, new PasswordModifyRequest()), new AnonToken(), ); + + self::assertSame( + ResultCode::UNWILLING_TO_PERFORM, + $this->singleResponse($stream->messages)->getResultCode(), + ); } public function test_an_operation_error_is_sent_as_a_standard_response(): void @@ -162,16 +147,7 @@ public function test_an_operation_error_is_sent_as_a_standard_response(): void ->method('get') ->willReturn($this->userEntry); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::callback( - fn(LdapMessageResponse $r) - => method_exists($r->getResponse(), 'getResultCode') - && $r->getResponse()->getResultCode() === ResultCode::INVALID_CREDENTIALS, - )); - - $result = $this->subject->handleRequest( + $stream = $this->subject->handleRequest( new LdapMessageRequest( 1, new PasswordModifyRequest(null, 'wrongpass', 'newpass'), @@ -179,10 +155,35 @@ public function test_an_operation_error_is_sent_as_a_standard_response(): void $this->userToken, ); - self::assertInstanceOf(PasswordModifyOperationResult::class, $result); + self::assertSame( + ResultCode::INVALID_CREDENTIALS, + $this->singleResponse($stream->messages)->getResultCode(), + ); + + $outcome = $stream->outcome(); + self::assertInstanceOf(PasswordModifyOperationResult::class, $outcome); self::assertSame( OperationOutcome::Failed, - $result->outcome(), + $outcome->outcome(), + ); + } + + /** + * @param iterable $messages + */ + private function singleResponse(iterable $messages): LdapResult + { + $messages = [...$messages]; + self::assertCount( + 1, + $messages, ); + $response = $messages[0]->getResponse(); + self::assertInstanceOf( + LdapResult::class, + $response, + ); + + return $response; } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php index c8d07b68..a089d273 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php @@ -23,7 +23,7 @@ use FreeDSx\Ldap\Operation\Request\ForwardPasswordPolicyStateRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerPasswordPolicyForwardHandler; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; @@ -43,18 +43,16 @@ final class ServerPasswordPolicyForwardHandlerTest extends TestCase private const DN = 'cn=user,dc=foo,dc=bar'; - private ServerQueue&MockObject $queue; - private WritableLdapBackendInterface&MockObject $backend; private ServerPasswordPolicyForwardHandler $subject; + private ResponseStream $lastStream; + protected function setUp(): void { - $this->queue = $this->createMock(ServerQueue::class); $this->backend = $this->createMock(WritableLdapBackendInterface::class); $this->subject = new ServerPasswordPolicyForwardHandler( - $this->queue, $this->backend, new PasswordPolicyResolver( $this->backend, @@ -77,15 +75,16 @@ public function test_it_unions_forwarded_failures_via_an_atomic_update(): void '2026-05-20 11:59:00', new DateTimeZone('UTC'), ); - $this->queue - ->expects(self::once()) - ->method('sendMessage'); $changes = $this->captureComputedChanges( new ForwardPasswordPolicyStateRequest(self::DN, 'uuid', [$time]), Entry::fromArray(self::DN, ['cn' => ['user']]), ); + self::assertCount( + 1, + [...$this->lastStream->messages], + ); self::assertSame( 'pwdFailureTime', $changes[0]->getAttribute()->getName(), @@ -123,10 +122,6 @@ public function test_a_forwarded_success_clears_the_superseded_failures(): void public function test_it_still_acks_and_does_not_write_when_no_policy_applies(): void { - $this->queue - ->expects(self::once()) - ->method('sendMessage'); - $changes = $this->captureComputedChanges( new ForwardPasswordPolicyStateRequest(self::DN, 'uuid', [ new DateTimeImmutable('2026-05-20 11:59:00', new DateTimeZone('UTC')), @@ -134,6 +129,10 @@ public function test_it_still_acks_and_does_not_write_when_no_policy_applies(): null, ); + self::assertCount( + 1, + [...$this->lastStream->messages], + ); self::assertSame( [], $changes, @@ -186,7 +185,7 @@ private function captureComputedChanges( }), ); - $this->subject->handleRequest( + $this->lastStream = $this->subject->handleRequest( $this->messageFor($request), $this->createMock(TokenInterface::class), ); diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php index f8513d12..fa4d54b6 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php @@ -23,7 +23,6 @@ use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerRootDseHandler; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; @@ -36,8 +35,6 @@ final class ServerRootDseHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private ServerRootDseHandler $subject; private TokenInterface&MockObject $mockToken; @@ -52,7 +49,6 @@ protected function setUp(): void { $this->options = new ServerOptions(); $this->mockToken = $this->createMock(TokenInterface::class); - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockDseHandler = $this->createMock(RootDseHandlerInterface::class); $this->withBackendNamingContexts([]); } @@ -67,11 +63,14 @@ public function test_it_should_send_back_a_RootDSE(): void (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::equalTo(new LdapMessageResponse(1, new SearchResultEntry(Entry::create('', [ + $stream = $this->subject->handleRequest( + $search, + $this->mockToken, + ); + + $this->assertEquals( + [ + new LdapMessageResponse(1, new SearchResultEntry(Entry::create('', [ 'namingContexts' => 'dc=Foo,dc=Bar', 'subschemaSubentry' => ['cn=Subschema'], 'supportedControl' => [ @@ -96,13 +95,10 @@ public function test_it_should_send_back_a_RootDSE(): void ], 'supportedLDAPVersion' => ['3'], 'vendorName' => 'Foo', - ])))), - self::equalTo(new LdapMessageResponse(1, new SearchResultDone(0))), - ); - - $this->subject->handleRequest( - $search, - $this->mockToken, + ]))), + new LdapMessageResponse(1, new SearchResultDone(0)), + ], + [...$stream->messages], ); } @@ -113,32 +109,24 @@ public function test_it_always_advertises_paging_and_password_modify(): void (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $entry = $result->getEntry(); - - return $entry->get('supportedControl')?->has(Control::OID_PAGING) === true - && $entry->get('supportedExtension')?->has(ExtendedRequest::OID_PWD_MODIFY) === true; - }), - self::anything(), - ); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $search, $this->mockToken, ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $entry = $result->getEntry(); + + self::assertTrue($entry->get('supportedControl')?->has(Control::OID_PAGING) === true); + self::assertTrue($entry->get('supportedExtension')?->has(ExtendedRequest::OID_PWD_MODIFY) === true); } public function test_it_advertises_the_sync_control_when_sync_is_enabled(): void { $this->subject = new ServerRootDseHandler( $this->options, - $this->mockQueue, $this->mockBackend, null, true, @@ -149,23 +137,16 @@ public function test_it_advertises_the_sync_control_when_sync_is_enabled(): void (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - - return $result->getEntry()->get('supportedControl')?->has(Control::OID_SYNC_REQUEST) === true; - }), - self::anything(), - ); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $search, $this->mockToken, ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + + self::assertTrue($result->getEntry()->get('supportedControl')?->has(Control::OID_SYNC_REQUEST) === true); } public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements_a_rootdse_aware_interface(): void @@ -175,7 +156,6 @@ public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements $this->subject = new ServerRootDseHandler( $this->options, - $this->mockQueue, $this->mockBackend, $this->mockDseHandler, ); @@ -224,18 +204,18 @@ public function test_it_should_send_a_request_to_the_dispatcher_if_it_implements ) ->willReturn($handlerRootDse); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - new LdapMessageResponse(1, new SearchResultEntry($handlerRootDse)), - new LdapMessageResponse(1, new SearchResultDone(0)), - ); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $search, $this->mockToken, ); + + $this->assertEquals( + [ + new LdapMessageResponse(1, new SearchResultEntry($handlerRootDse)), + new LdapMessageResponse(1, new SearchResultDone(0)), + ], + [...$stream->messages], + ); } public function test_it_should_include_supported_sasl_mechanisms_when_configured(): void @@ -248,29 +228,26 @@ public function test_it_should_include_supported_sasl_mechanisms_when_configured (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $search */ - $search = $response->getResponse(); - $attribute = $search->getEntry()->get('supportedSaslMechanisms'); - - return $attribute !== null - && $attribute->has(ServerOptions::SASL_PLAIN) - && $attribute->has(ServerOptions::SASL_CRAM_MD5); - }), - new LdapMessageResponse( - 1, - new SearchResultDone(0), - ), - ); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $search, $this->mockToken, ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $attribute = $result->getEntry()->get('supportedSaslMechanisms'); + + self::assertNotNull($attribute); + self::assertTrue($attribute->has(ServerOptions::SASL_PLAIN)); + self::assertTrue($attribute->has(ServerOptions::SASL_CRAM_MD5)); + self::assertEquals( + new LdapMessageResponse( + 1, + new SearchResultDone(0), + ), + $messages[1], + ); } public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_requested(): void @@ -286,10 +263,13 @@ public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_r ->setAttributesOnly(true), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( + $stream = $this->subject->handleRequest( + $search, + $this->mockToken, + ); + + $this->assertEquals( + [ new LdapMessageResponse(1, new SearchResultEntry(Entry::create('', [ 'namingContexts' => [], 'subschemaSubentry' => [], @@ -300,11 +280,8 @@ public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_r 'vendorName' => [], ]))), new LdapMessageResponse(1, new SearchResultDone(0)), - ); - - $this->subject->handleRequest( - $search, - $this->mockToken, + ], + [...$stream->messages], ); } @@ -315,21 +292,15 @@ public function test_it_advertises_subschema_subentry_in_rootdse(): void (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $attr = $result->getEntry()->get('subschemaSubentry'); + $stream = $this->subject->handleRequest($search, $this->mockToken); + $messages = [...$stream->messages]; - return $attr !== null && $attr->has('cn=Subschema'); - }), - self::anything(), - ); + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $attr = $result->getEntry()->get('subschemaSubentry'); - $this->subject->handleRequest($search, $this->mockToken); + self::assertNotNull($attr); + self::assertTrue($attr->has('cn=Subschema')); } public function test_it_uses_configured_subschema_entry_dn(): void @@ -341,21 +312,15 @@ public function test_it_uses_configured_subschema_entry_dn(): void (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $attr = $result->getEntry()->get('subschemaSubentry'); + $stream = $this->subject->handleRequest($search, $this->mockToken); + $messages = [...$stream->messages]; - return $attr !== null && $attr->has('cn=schema,dc=example,dc=com'); - }), - self::anything(), - ); + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $attr = $result->getEntry()->get('subschemaSubentry'); - $this->subject->handleRequest($search, $this->mockToken); + self::assertNotNull($attr); + self::assertTrue($attr->has('cn=schema,dc=example,dc=com')); } public function test_it_should_only_return_specific_attributes_from_the_RootDSE_if_requested(): void @@ -376,20 +341,20 @@ public function test_it_should_only_return_specific_attributes_from_the_RootDSE_ $entry->changes()->reset(); $entry->get('namingContexts')?->equals(new Attribute('foo')); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( + $stream = $this->subject->handleRequest( + $search, + $this->mockToken, + ); + + $this->assertEquals( + [ new LdapMessageResponse( 1, new SearchResultEntry($entry), ), new LdapMessageResponse(1, new SearchResultDone(0)), - ); - - $this->subject->handleRequest( - $search, - $this->mockToken, + ], + [...$stream->messages], ); } @@ -408,7 +373,6 @@ private function withBackendNamingContexts(array $dns): void $this->subject = new ServerRootDseHandler( $this->options, - $this->mockQueue, $this->mockBackend, null, ); diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php index 1d61cb46..1ae32485 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php @@ -29,6 +29,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSearchHandler; use FreeDSx\Ldap\Schema\Schema; @@ -39,6 +40,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Operation\OperationOutcome; +use FreeDSx\Ldap\Server\Operation\OperationResult; use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -95,13 +97,7 @@ protected function setUp(): void return $this->mockQueue; }); - $this->subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $this->mockAccessControl, - schema: $this->schema, - ); + $this->subject = $this->makeHandler($this->mockAccessControl); } public function test_it_should_send_entries_from_the_backend_to_the_client(): void @@ -124,9 +120,9 @@ public function test_it_should_send_entries_from_the_backend_to_the_client(): vo ->method('evaluate') ->willReturn(true); - $result = $this->subject->handleRequest( + $result = $this->drive( + $this->subject, $search, - $this->mockToken, ); $this->assertSentMessages([ @@ -157,13 +153,7 @@ public function test_entry_stripped_by_acl_is_excluded_when_it_no_longer_matches $mockAccessControl = $this->createMock(AccessControlInterface::class); $mockAccessControl->method('filterEntry')->willReturn($stripped); - $subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $mockAccessControl, - schema: $this->schema, - ); + $subject = $this->makeHandler($mockAccessControl); $this->mockBackend ->method('search') @@ -173,9 +163,9 @@ public function test_entry_stripped_by_acl_is_excluded_when_it_no_longer_matches ->method('evaluate') ->willReturn(false); - $subject->handleRequest( + $this->drive( + $subject, $search, - $this->mockToken, ); $this->assertSentMessages([ @@ -235,7 +225,7 @@ public function test_it_should_return_size_limit_exceeded_with_partial_results_w ->method('evaluate') ->willReturn(true); - $this->subject->handleRequest($search, $this->mockToken); + $this->drive($this->subject, $search); $this->assertSentMessages([ new LdapMessageResponse(2, new SearchResultEntry($entry1)), @@ -267,7 +257,7 @@ public function test_it_should_not_enforce_size_limit_when_zero(): void ->method('evaluate') ->willReturn(true); - $this->subject->handleRequest($search, $this->mockToken); + $this->drive($this->subject, $search); $this->assertSentMessages([ new LdapMessageResponse(2, new SearchResultEntry($entry1)), @@ -296,15 +286,11 @@ public function test_server_max_search_size_applies_when_client_requests_no_limi ->method('evaluate') ->willReturn(true); - $subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $this->mockAccessControl, - schema: $this->schema, - limits: new SearchLimits(maxSearchSize: 1), + $subject = $this->makeHandler( + $this->mockAccessControl, + new SearchLimits(maxSearchSize: 1), ); - $subject->handleRequest($search, $this->mockToken); + $this->drive($subject, $search); $this->assertSentMessages([ new LdapMessageResponse(2, new SearchResultEntry($entry1)), @@ -332,15 +318,11 @@ public function test_client_size_limit_is_used_when_below_server_max(): void ->method('evaluate') ->willReturn(true); - $subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $this->mockAccessControl, - schema: $this->schema, - limits: new SearchLimits(maxSearchSize: 5), + $subject = $this->makeHandler( + $this->mockAccessControl, + new SearchLimits(maxSearchSize: 5), ); - $subject->handleRequest($search, $this->mockToken); + $this->drive($subject, $search); $this->assertSentMessages([ new LdapMessageResponse(2, new SearchResultEntry($entry1)), @@ -368,15 +350,11 @@ public function test_server_max_search_size_caps_client_limit_when_exceeded(): v ->method('evaluate') ->willReturn(true); - $subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $this->mockAccessControl, - schema: $this->schema, - limits: new SearchLimits(maxSearchSize: 1), + $subject = $this->makeHandler( + $this->mockAccessControl, + new SearchLimits(maxSearchSize: 1), ); - $subject->handleRequest($search, $this->mockToken); + $this->drive($subject, $search); $this->assertSentMessages([ new LdapMessageResponse(2, new SearchResultEntry($entry1)), @@ -396,9 +374,9 @@ public function test_it_should_send_a_successful_SearchResultDone_when_no_entrie ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $search, - $this->mockToken, ); $this->assertSentMessages([ @@ -443,14 +421,8 @@ public function test_it_should_suppress_entry_when_filter_entry_returns_null(): ->method('evaluate') ->willReturn(true); - $subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $mockAccessControl, - schema: $this->schema, - ); - $subject->handleRequest($search, $this->mockToken); + $subject = $this->makeHandler($mockAccessControl); + $this->drive($subject, $search); $this->assertSentMessages([ new LdapMessageResponse(2, new SearchResultEntry($entry2)), @@ -490,14 +462,8 @@ public function test_it_should_skip_entry_when_filter_no_longer_matches_after_ac ->method('evaluate') ->willReturnCallback(static fn(Entry $e): bool => $e === $entry); - $subject = new ServerSearchHandler( - queue: $this->mockQueue, - backend: $this->mockBackend, - filterEvaluator: $this->mockFilterEvaluator, - accessControl: $mockAccessControl, - schema: $this->schema, - ); - $subject->handleRequest($search, $this->mockToken); + $subject = $this->makeHandler($mockAccessControl); + $this->drive($subject, $search); $this->assertSentMessages([ new LdapMessageResponse( @@ -533,7 +499,7 @@ public function test_abandon_mid_stream_stops_entries_and_sends_no_response(): v ->method('peekForCancelSignal') ->willReturn($abandonSignal); - $this->subject->handleRequest($search, $this->mockToken); + $this->drive($this->subject, $search); $sentDone = array_filter( $this->sentMessages, @@ -569,7 +535,7 @@ public function test_cancel_mid_stream_stops_entries_and_sends_canceled_plus_suc ->method('peekForCancelSignal') ->willReturn($cancelSignal); - $this->subject->handleRequest($search, $this->mockToken); + $this->drive($this->subject, $search); $nonEntryMessages = array_values(array_filter( $this->sentMessages, @@ -604,9 +570,9 @@ public function test_non_critical_unsupported_control_does_not_cause_an_error(): ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $search, - $this->mockToken, ); $this->assertSentMessages([ @@ -629,9 +595,9 @@ public function test_sort_control_appends_sorting_response_control_to_done_messa ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $search, - $this->mockToken, ); $done = end($this->sentMessages); @@ -659,9 +625,9 @@ public function test_sort_by_unknown_attribute_reports_no_such_attribute(): void ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $search, - $this->mockToken, ); $done = end($this->sentMessages); @@ -693,9 +659,9 @@ public function test_sort_by_attribute_without_ordering_rule_reports_inappropria ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $search, - $this->mockToken, ); $done = end($this->sentMessages); @@ -722,9 +688,9 @@ public function test_no_sort_control_does_not_append_sorting_response_control(): ->method('search') ->willReturn(new EntryStream($this->makeGenerator())); - $this->subject->handleRequest( + $this->drive( + $this->subject, $search, - $this->mockToken, ); $done = end($this->sentMessages); @@ -732,6 +698,37 @@ public function test_no_sort_control_does_not_append_sorting_response_control(): self::assertNull($done->controls()->get(Control::OID_SORTING_RESPONSE)); } + private function makeHandler( + AccessControlInterface $accessControl, + ?SearchLimits $limits = null, + ): ServerSearchHandler { + return new ServerSearchHandler( + backend: $this->mockBackend, + filterEvaluator: $this->mockFilterEvaluator, + accessControl: $accessControl, + schema: $this->schema, + limits: $limits ?? new SearchLimits(), + ); + } + + /** + * Drives the handler's stream through the writer so it flushes and polls for cancellation. + */ + private function drive( + ServerSearchHandler $subject, + LdapMessageRequest $search, + ): OperationResult { + $stream = $subject->handleRequest( + $search, + $this->mockToken, + ); + + return (new ResponseWriter($this->mockQueue))->write( + $stream, + $search->getMessageId(), + ); + } + private function makeGenerator(Entry ...$entries): Generator { yield from $entries; diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerStartTlsHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerStartTlsHandlerTest.php index f54fce8a..89df6d71 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerStartTlsHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerStartTlsHandlerTest.php @@ -19,7 +19,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\ConnectionControl; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerStartTlsHandler; use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\ServerOptions; @@ -30,7 +30,7 @@ final class ServerStartTlsHandlerTest extends TestCase { private ServerStartTlsHandler $subject; - private ServerQueue&MockObject $mockQueue; + private ConnectionControl&MockObject $mockConnection; private TokenInterface&MockObject $mockToken; @@ -39,12 +39,12 @@ final class ServerStartTlsHandlerTest extends TestCase protected function setUp(): void { $this->mockToken = $this->createMock(TokenInterface::class); - $this->mockQueue = $this->createMock(ServerQueue::class); + $this->mockConnection = $this->createMock(ConnectionControl::class); $this->options = new ServerOptions(); $this->subject = new ServerStartTlsHandler( $this->options, - $this->mockQueue, + $this->mockConnection, ); } @@ -52,78 +52,89 @@ public function test_it_should_handle_a_start_tls_request(): void { $this->options->setSslCert('foo'); - $this->mockQueue + $this->mockConnection ->method('isEncrypted') ->willReturn(false); - $this->mockQueue - ->method('encrypt') - ->willReturnSelf(); + $startTls = new LdapMessageRequest(1, new ExtendedRequest(ExtendedRequest::OID_START_TLS)); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(new LdapMessageResponse( + $stream = $this->subject->handleRequest( + $startTls, + $this->mockToken, + ); + + $this->assertEquals( + [new LdapMessageResponse( 1, new ExtendedResponse( new LdapResult(0), ExtendedRequest::OID_START_TLS, ), - )); + )], + [...$stream->messages], + ); - $startTls = new LdapMessageRequest(1, new ExtendedRequest(ExtendedRequest::OID_START_TLS)); + // The socket is only encrypted once the writer runs onComplete, after the SUCCESS is flushed. + $this->mockConnection + ->expects(self::once()) + ->method('encrypt') + ->willReturnSelf(); - $this->subject->handleRequest( - $startTls, - $this->mockToken, - ); + $this->assertNotNull($stream->onComplete); + ($stream->onComplete)($this->mockConnection); } public function test_it_should_send_back_an_error_if_the_queue_is_already_encrypted(): void { $this->options->setSslCert('foo'); - $this->mockQueue + $this->mockConnection ->method('isEncrypted') ->willReturn(true); - $this->mockQueue + $this->mockConnection ->expects(self::never()) ->method('encrypt'); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::equalTo(new LdapMessageResponse( + $startTls = new LdapMessageRequest(1, new ExtendedRequest(ExtendedRequest::OID_START_TLS)); + + $stream = $this->subject->handleRequest( + $startTls, + $this->mockToken, + ); + + $this->assertEquals( + [new LdapMessageResponse( 1, new ExtendedResponse( new LdapResult(ResultCode::OPERATIONS_ERROR, '', 'The current LDAP session is already encrypted.'), ExtendedRequest::OID_START_TLS, ), - ))); - - $startTls = new LdapMessageRequest(1, new ExtendedRequest(ExtendedRequest::OID_START_TLS)); - - $this->subject->handleRequest( - $startTls, - $this->mockToken, + )], + [...$stream->messages], ); + $this->assertNull($stream->onComplete); } public function test_it_should_send_back_an_error_if_encryption_is_not_supported(): void { - $this->mockQueue + $this->mockConnection ->method('isEncrypted') ->willReturn(false); - $this->mockQueue + $this->mockConnection ->expects(self::never()) ->method('encrypt'); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::equalTo(new LdapMessageResponse( + $startTls = new LdapMessageRequest(1, new ExtendedRequest(ExtendedRequest::OID_START_TLS)); + + $stream = $this->subject->handleRequest( + $startTls, + $this->mockToken, + ); + + $this->assertEquals( + [new LdapMessageResponse( 1, new ExtendedResponse( new LdapResult( @@ -133,13 +144,9 @@ public function test_it_should_send_back_an_error_if_encryption_is_not_supported ), ExtendedRequest::OID_START_TLS, ), - ))); - - $startTls = new LdapMessageRequest(1, new ExtendedRequest(ExtendedRequest::OID_START_TLS)); - - $this->subject->handleRequest( - $startTls, - $this->mockToken, + )], + [...$stream->messages], ); + $this->assertNull($stream->onComplete); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php index 19cbb27c..21d2dd71 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php @@ -20,7 +20,6 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerSubschemaHandler; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Operation\Request\SearchRequest; @@ -31,8 +30,6 @@ final class ServerSubschemaHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private TokenInterface&MockObject $mockToken; private ServerOptions $options; @@ -42,56 +39,45 @@ final class ServerSubschemaHandlerTest extends TestCase protected function setUp(): void { $this->options = new ServerOptions(); - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockToken = $this->createMock(TokenInterface::class); $this->subject = new ServerSubschemaHandler( options: $this->options, - queue: $this->mockQueue, ); } public function test_it_returns_a_stub_subschema_entry(): void { - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $entry = $result->getEntry(); - - return $entry->getDn()->toString() === 'cn=Subschema' - && ($entry->get('objectClass')?->has('subschema') ?? false) - && ($entry->get('cn')?->has('Subschema') ?? false); - }), - self::equalTo(new LdapMessageResponse(1, new SearchResultDone(ResultCode::SUCCESS))), - ); - - $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + $stream = $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $entry = $result->getEntry(); + + self::assertSame( + 'cn=Subschema', + $entry->getDn()->toString(), + ); + self::assertTrue($entry->get('objectClass')?->has('subschema') ?? false); + self::assertTrue($entry->get('cn')?->has('Subschema') ?? false); + self::assertEquals( + new LdapMessageResponse(1, new SearchResultDone(ResultCode::SUCCESS)), + $messages[1], + ); } public function test_it_uses_the_configured_subschema_entry_dn(): void { $this->options->setSubschemaEntry(new Dn('cn=schema,dc=example,dc=com')); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(function (LdapMessageResponse $response) { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $entry = $result->getEntry(); - - return $entry->getDn()->toString() === 'cn=schema,dc=example,dc=com' - && ($entry->get('cn')?->has('schema') ?? false); - }), - self::anything(), - ); - - $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + $entry = $this->handleAndCaptureEntry(); + + self::assertSame( + 'cn=schema,dc=example,dc=com', + $entry->getDn()->toString(), + ); + self::assertTrue($entry->get('cn')?->has('schema') ?? false); } public function test_it_returns_non_empty_attribute_types_in_rfc4512_format(): void @@ -153,26 +139,12 @@ private function makeMessage(): LdapMessageRequest private function handleAndCaptureEntry(): Entry { - $captured = null; - - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with( - self::callback(static function (LdapMessageResponse $response) use (&$captured): bool { - /** @var SearchResultEntry $result */ - $result = $response->getResponse(); - $captured = $result->getEntry(); - - return true; - }), - self::anything(), - ); - - $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + $stream = $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + $messages = [...$stream->messages]; - assert($captured instanceof Entry); + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); - return $captured; + return $result->getEntry(); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php index b0fd8aa9..70d62a99 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php @@ -520,7 +520,7 @@ private function handle( return $this->subject->handleRequest( $this->syncMessage($cookie, $mode, $reloadHint), $this->token, - ); + )->outcome(); } private function syncMessage( diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerUnbindHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerUnbindHandlerTest.php index 86158e11..7ab99143 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerUnbindHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerUnbindHandlerTest.php @@ -15,37 +15,47 @@ use FreeDSx\Ldap\Operation\Request\UnbindRequest; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Protocol\Queue\ConnectionControl; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerUnbindHandler; use FreeDSx\Ldap\Server\Token\TokenInterface; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class ServerUnbindHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private ServerUnbindHandler $subject; protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); - - $this->subject = new ServerUnbindHandler($this->mockQueue); + $this->subject = new ServerUnbindHandler(); } public function test_it_should_handle_an_unbind_request(): void { - $this->mockQueue - ->expects($this->once()) - ->method('close'); - $this->mockQueue - ->expects($this->never()) - ->method('sendMessage'); + $stream = $this->subject->handleRequest( + new LdapMessageRequest(1, new UnbindRequest()), + $this->createMock(TokenInterface::class), + ); - $this->subject->handleRequest( + $this->assertSame( + [], + [...$stream->messages], + ); + $this->assertNotNull($stream->onComplete); + } + + public function test_it_should_close_the_connection_on_completion(): void + { + $stream = $this->subject->handleRequest( new LdapMessageRequest(1, new UnbindRequest()), $this->createMock(TokenInterface::class), ); + + $connection = $this->createMock(ConnectionControl::class); + $connection + ->expects($this->once()) + ->method('close'); + + $this->assertNotNull($stream->onComplete); + ($stream->onComplete)($connection); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandlerTest.php index 77cdbc89..57fc86eb 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerUnsupportedExtendedHandlerTest.php @@ -20,7 +20,6 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerUnsupportedExtendedHandler; use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -30,15 +29,12 @@ final class ServerUnsupportedExtendedHandlerTest extends TestCase { private ServerUnsupportedExtendedHandler $subject; - private ServerQueue&MockObject $mockQueue; - private TokenInterface&MockObject $mockToken; protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); $this->mockToken = $this->createMock(TokenInterface::class); - $this->subject = new ServerUnsupportedExtendedHandler($this->mockQueue); + $this->subject = new ServerUnsupportedExtendedHandler(); } public function test_it_returns_protocol_error_with_response_name_echoing_the_request(): void @@ -49,10 +45,13 @@ public function test_it_returns_protocol_error_with_response_name_echoing_the_re new ExtendedRequest($oid), ); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage') - ->with(self::equalTo(new LdapMessageResponse( + $stream = $this->subject->handleRequest( + $request, + $this->mockToken, + ); + + $this->assertEquals( + [new LdapMessageResponse( 42, new ExtendedResponse( new LdapResult( @@ -62,11 +61,8 @@ public function test_it_returns_protocol_error_with_response_name_echoing_the_re ), $oid, ), - ))); - - $this->subject->handleRequest( - $request, - $this->mockToken, + )], + [...$stream->messages], ); } @@ -78,13 +74,14 @@ public function test_it_ignores_non_critical_unsupported_controls(): void new Control('1.2.3.4', false), ); - $this->mockQueue - ->expects(self::once()) - ->method('sendMessage'); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $request, $this->mockToken, ); + + $this->assertCount( + 1, + [...$stream->messages], + ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerWhoAmIHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerWhoAmIHandlerTest.php index 6f3115d4..0c2dd615 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerWhoAmIHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerWhoAmIHandlerTest.php @@ -19,44 +19,38 @@ use FreeDSx\Ldap\Operation\Response\ExtendedResponse; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; -use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerWhoAmIHandler; use FreeDSx\Ldap\Server\Token\AnonToken; use FreeDSx\Ldap\Server\Token\BindToken; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; final class ServerWhoAmIHandlerTest extends TestCase { - private ServerQueue&MockObject $mockQueue; - private ServerWhoAmIHandler $subject; protected function setUp(): void { - $this->mockQueue = $this->createMock(ServerQueue::class); - - $this->subject = new ServerWhoAmIHandler($this->mockQueue); + $this->subject = new ServerWhoAmIHandler(); } public function test_it_should_handle_a_who_am_i_when_there_is_a_token_with_a_DN_name(): void { $request = new LdapMessageRequest(2, new ExtendedRequest(ExtendedRequest::OID_WHOAMI)); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with($this->equalTo(new LdapMessageResponse( - 2, - new ExtendedResponse(new LdapResult(0), null, 'dn:cn=foo,dc=foo,dc=bar'), - ))); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $request, BindToken::fromDn( 'cn=foo,dc=foo,dc=bar', ), ); + + $this->assertEquals( + [new LdapMessageResponse( + 2, + new ExtendedResponse(new LdapResult(0), null, 'dn:cn=foo,dc=foo,dc=bar'), + )], + [...$stream->messages], + ); } public function test_it_should_use_resolved_dn_when_it_differs_from_username(): void @@ -66,24 +60,24 @@ public function test_it_should_use_resolved_dn_when_it_differs_from_username(): new ExtendedRequest(ExtendedRequest::OID_WHOAMI), ); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with($this->equalTo(new LdapMessageResponse( + $stream = $this->subject->handleRequest( + $request, + BindToken::fromSasl( + 'uid=alice', + new Dn('cn=Alice,dc=example,dc=com'), + ), + ); + + $this->assertEquals( + [new LdapMessageResponse( 2, new ExtendedResponse( new LdapResult(0), null, 'dn:cn=Alice,dc=example,dc=com', ), - ))); - - $this->subject->handleRequest( - $request, - BindToken::fromSasl( - 'uid=alice', - new Dn('cn=Alice,dc=example,dc=com'), - ), + )], + [...$stream->messages], ); } @@ -91,44 +85,37 @@ public function test_it_should_handle_a_who_am_i_when_there_is_a_token_with_a_no { $request = new LdapMessageRequest(2, new ExtendedRequest(ExtendedRequest::OID_WHOAMI)); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with($this->equalTo(new LdapMessageResponse( - 2, - new ExtendedResponse(new LdapResult(0), null, 'u:foo@bar.local'), - ))); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $request, BindToken::fromUsername( 'foo@bar.local', ), ); + + $this->assertEquals( + [new LdapMessageResponse( + 2, + new ExtendedResponse(new LdapResult(0), null, 'u:foo@bar.local'), + )], + [...$stream->messages], + ); } public function test_it_should_handle_a_who_am_i_when_there_is_no_token_yet(): void { $request = new LdapMessageRequest(2, new ExtendedRequest(ExtendedRequest::OID_WHOAMI)); - $this->mockQueue - ->expects($this->once()) - ->method('sendMessage') - ->with($this->equalTo(new LdapMessageResponse( - 2, - new ExtendedResponse(new LdapResult(0), null, ''), - ))); - - $this->mockQueue - ->method('getMessage') - ->willReturn(new LdapMessageRequest( - 2, - new ExtendedRequest(ExtendedRequest::OID_WHOAMI), - )); - - $this->subject->handleRequest( + $stream = $this->subject->handleRequest( $request, new AnonToken(), ); + + $this->assertEquals( + [new LdapMessageResponse( + 2, + new ExtendedResponse(new LdapResult(0), null, ''), + )], + [...$stream->messages], + ); } } diff --git a/tests/unit/Server/Proxy/ProxyRequestPipelineTest.php b/tests/unit/Server/Proxy/ProxyRequestPipelineTest.php index 333b6a78..fecbd0e7 100644 --- a/tests/unit/Server/Proxy/ProxyRequestPipelineTest.php +++ b/tests/unit/Server/Proxy/ProxyRequestPipelineTest.php @@ -8,6 +8,9 @@ use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; +use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerProtocolHandlerInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareHandlerInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\ServerRequestContext; @@ -32,6 +35,7 @@ protected function setUp(): void $this->subject = new ProxyRequestPipeline( $this->startTlsHandler, $this->forwarder, + new ResponseWriter($this->createMock(ServerQueue::class)), ); } @@ -40,7 +44,7 @@ public function test_it_handles_start_tls_locally(): void $this->startTlsHandler ->expects(self::once()) ->method('handleRequest') - ->willReturn(OperationOutcomeResult::succeeded()); + ->willReturn(ResponseStream::none(OperationOutcomeResult::succeeded())); $this->forwarder ->expects(self::never()) ->method('handle');