From cc4db888f2ed79e987e36ad44962d3f12aedc484 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Tue, 7 Jul 2026 13:19:42 +0200 Subject: [PATCH] feat(dav): Add `occ dav:show-listeners` to allow better insides into DAV stack Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- apps/dav/appinfo/info.xml | 1 + .../composer/composer/autoload_classmap.php | 2 + .../dav/composer/composer/autoload_static.php | 2 + apps/dav/lib/Command/ListenerIntrospector.php | 283 +++++++++++++++++ apps/dav/lib/Command/ShowListeners.php | 246 ++++++++++++++ .../unit/Command/ListenerIntrospectorTest.php | 300 ++++++++++++++++++ .../tests/unit/Command/ShowListenersTest.php | 93 ++++++ 7 files changed, 927 insertions(+) create mode 100644 apps/dav/lib/Command/ListenerIntrospector.php create mode 100644 apps/dav/lib/Command/ShowListeners.php create mode 100644 apps/dav/tests/unit/Command/ListenerIntrospectorTest.php create mode 100644 apps/dav/tests/unit/Command/ShowListenersTest.php diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 393dc33df5b7d..1df38c7d7b4d6 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -80,6 +80,7 @@ OCA\DAV\Command\RetentionCleanupCommand OCA\DAV\Command\SendEventReminders OCA\DAV\Command\SetAbsenceCommand + OCA\DAV\Command\ShowListeners OCA\DAV\Command\SyncBirthdayCalendar OCA\DAV\Command\SyncSystemAddressBook diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2ca5cf66f901f..043413cedaf08 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -197,11 +197,13 @@ 'OCA\\DAV\\Command\\ListCalendarShares' => $baseDir . '/../lib/Command/ListCalendarShares.php', 'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php', 'OCA\\DAV\\Command\\ListSubscriptions' => $baseDir . '/../lib/Command/ListSubscriptions.php', + 'OCA\\DAV\\Command\\ListenerIntrospector' => $baseDir . '/../lib/Command/ListenerIntrospector.php', 'OCA\\DAV\\Command\\MoveCalendar' => $baseDir . '/../lib/Command/MoveCalendar.php', 'OCA\\DAV\\Command\\RemoveInvalidShares' => $baseDir . '/../lib/Command/RemoveInvalidShares.php', 'OCA\\DAV\\Command\\RetentionCleanupCommand' => $baseDir . '/../lib/Command/RetentionCleanupCommand.php', 'OCA\\DAV\\Command\\SendEventReminders' => $baseDir . '/../lib/Command/SendEventReminders.php', 'OCA\\DAV\\Command\\SetAbsenceCommand' => $baseDir . '/../lib/Command/SetAbsenceCommand.php', + 'OCA\\DAV\\Command\\ShowListeners' => $baseDir . '/../lib/Command/ShowListeners.php', 'OCA\\DAV\\Command\\SyncBirthdayCalendar' => $baseDir . '/../lib/Command/SyncBirthdayCalendar.php', 'OCA\\DAV\\Command\\SyncSystemAddressBook' => $baseDir . '/../lib/Command/SyncSystemAddressBook.php', 'OCA\\DAV\\Comments\\CommentNode' => $baseDir . '/../lib/Comments/CommentNode.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c35dd97c02c0e..25917895fa6a6 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -212,11 +212,13 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Command\\ListCalendarShares' => __DIR__ . '/..' . '/../lib/Command/ListCalendarShares.php', 'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php', 'OCA\\DAV\\Command\\ListSubscriptions' => __DIR__ . '/..' . '/../lib/Command/ListSubscriptions.php', + 'OCA\\DAV\\Command\\ListenerIntrospector' => __DIR__ . '/..' . '/../lib/Command/ListenerIntrospector.php', 'OCA\\DAV\\Command\\MoveCalendar' => __DIR__ . '/..' . '/../lib/Command/MoveCalendar.php', 'OCA\\DAV\\Command\\RemoveInvalidShares' => __DIR__ . '/..' . '/../lib/Command/RemoveInvalidShares.php', 'OCA\\DAV\\Command\\RetentionCleanupCommand' => __DIR__ . '/..' . '/../lib/Command/RetentionCleanupCommand.php', 'OCA\\DAV\\Command\\SendEventReminders' => __DIR__ . '/..' . '/../lib/Command/SendEventReminders.php', 'OCA\\DAV\\Command\\SetAbsenceCommand' => __DIR__ . '/..' . '/../lib/Command/SetAbsenceCommand.php', + 'OCA\\DAV\\Command\\ShowListeners' => __DIR__ . '/..' . '/../lib/Command/ShowListeners.php', 'OCA\\DAV\\Command\\SyncBirthdayCalendar' => __DIR__ . '/..' . '/../lib/Command/SyncBirthdayCalendar.php', 'OCA\\DAV\\Command\\SyncSystemAddressBook' => __DIR__ . '/..' . '/../lib/Command/SyncSystemAddressBook.php', 'OCA\\DAV\\Comments\\CommentNode' => __DIR__ . '/..' . '/../lib/Comments/CommentNode.php', diff --git a/apps/dav/lib/Command/ListenerIntrospector.php b/apps/dav/lib/Command/ListenerIntrospector.php new file mode 100644 index 0000000000000..ae5321d0bf981 --- /dev/null +++ b/apps/dav/lib/Command/ListenerIntrospector.php @@ -0,0 +1,283 @@ + + */ + public function collectListeners(SabreServer $server): array { + $reflection = new \ReflectionObject($server); + + // Query-monitoring wrappers keep the original callbacks in a side map on + // the Nextcloud server subclass. Read them so wrapped listeners can be + // resolved back to the real plugin method rather than the wrapper. + $wrappedListeners = $this->readListenerMap($reflection, $server, 'wrappedListeners'); + $originalListeners = $this->readListenerMap($reflection, $server, 'originalListeners'); + + $rows = []; + foreach (['listeners' => false, 'wildcardListeners' => true] as $property => $isWildcard) { + if (!$reflection->hasProperty($property)) { + continue; + } + $value = $reflection->getProperty($property)->getValue($server); + foreach ($value as $eventName => $registrations) { + foreach ($registrations as $registration) { + // Each registration is a [priority, callable] pair. + [$priority, $callBack] = $registration; + // Array keys degrade numeric strings to int, so cast for strict_types. + [$callBack, $monitored] = $this->unwrapCallback((string)$eventName, $callBack, $wrappedListeners, $originalListeners); + + $rows[] = [ + 'event' => $eventName . ($isWildcard ? '*' : ''), + 'priority' => (int)$priority, + 'listener' => $this->describeCallable($callBack) . ($monitored ? ' (query-monitored)' : ''), + ]; + } + } + } + + usort($rows, static function (array $a, array $b): int { + return [$a['event'], $a['priority']] <=> [$b['event'], $b['priority']]; + }); + + return $rows; + } + + /** + * Resolves the firing order for a single concrete event name into printable + * rows (priority, registration origin and resolved listener), in the exact + * order Sabre fires them. + * + * @return list + */ + public function resolveFiringOrder(SabreServer $server, string $eventName): array { + $reflection = new \ReflectionObject($server); + $wrappedListeners = $this->readListenerMap($reflection, $server, 'wrappedListeners'); + $originalListeners = $this->readListenerMap($reflection, $server, 'originalListeners'); + + $rows = []; + foreach ($this->resolveFiringSequence($server, $eventName) as $i => $registration) { + [$priority, $callBack, $registeredOn] = $registration; + [$callBack, $monitored] = $this->unwrapCallback($eventName, $callBack, $wrappedListeners, $originalListeners); + $rows[] = [ + 'order' => $i + 1, + 'priority' => (int)$priority, + 'registeredOn' => $registeredOn, + 'listener' => $this->describeCallable($callBack) . ($monitored ? ' (query-monitored)' : ''), + ]; + } + + return $rows; + } + + /** + * Returns the [priority, callable] registrations for a concrete event name in + * the exact order Sabre fires them. + * + * The order is taken directly from $server->listeners($eventName) — the very + * list Sabre\DAV\Server::emit() iterates at request time — rather than + * re-sorted here. This matters because WildcardEmitterTrait::listeners() sorts + * with array_multisort($priorities, SORT_NUMERIC, $callbacks), so equal + * priorities are tie-broken by comparing the callbacks themselves; a plain + * stable priority sort would not reproduce that. The priority is then looked + * up per callback from the raw registration maps for display only. + * + * @return list [priority, callable, event name the listener was registered on] + */ + public function resolveFiringSequence(SabreServer $server, string $eventName): array { + $ordered = $server->listeners($eventName); + $registrations = $this->rawRegistrationsFor($server, $eventName); + + $result = []; + foreach ($ordered as $callBack) { + $priority = self::DEFAULT_PRIORITY; + $registeredOn = $eventName; + foreach ($registrations as $key => [$prio, $cb, $origin]) { + if ($cb === $callBack) { + $priority = $prio; + $registeredOn = $origin; + // Consume the match so duplicate registrations of the same + // callback map to distinct priorities. + unset($registrations[$key]); + break; + } + } + $result[] = [(int)$priority, $callBack, $registeredOn]; + } + + return $result; + } + + /** + * Finds the WebDAV server's deferred "beforeMethod:*" handlers (defined in + * $sourceFile) and returns them so they can be invoked to register the + * plugins they add lazily. + * + * @return list<\Closure> + */ + public function findDeferredHandlers(SabreServer $server, string $sourceFile): array { + $reflection = new \ReflectionObject($server); + if (!$reflection->hasProperty('wildcardListeners')) { + return []; + } + + // WildcardEmitterTrait stores "beforeMethod:*" under the key without the + // trailing wildcard character. + $wildcard = $reflection->getProperty('wildcardListeners')->getValue($server); + $handlers = []; + foreach ($wildcard['beforeMethod:'] ?? [] as [, $callBack]) { + if (!$callBack instanceof \Closure) { + continue; + } + $function = new \ReflectionFunction($callBack); + if ($function->getFileName() === $sourceFile) { + $handlers[] = $callBack; + } + } + + return $handlers; + } + + /** + * Collects the raw registrations that apply to a concrete event name: the + * exact listeners plus every wildcard whose (star-stripped) key is a prefix + * of the event name, each annotated with the event name it was registered + * on. Order/priority here is not authoritative — it is only used as a + * lookup table in resolveFiringSequence(). + * + * @return list [priority, callable, event name the listener was registered on] + */ + private function rawRegistrationsFor(SabreServer $server, string $eventName): array { + $reflection = new \ReflectionObject($server); + $exactAll = $reflection->hasProperty('listeners') + ? $reflection->getProperty('listeners')->getValue($server) + : []; + $wildcardAll = $reflection->hasProperty('wildcardListeners') + ? $reflection->getProperty('wildcardListeners')->getValue($server) + : []; + + $merged = []; + foreach ($exactAll[$eventName] ?? [] as [$priority, $callBack]) { + $merged[] = [$priority, $callBack, $eventName]; + } + foreach ($wildcardAll as $wcEvent => $wcListeners) { + if (str_starts_with($eventName, (string)$wcEvent)) { + foreach ($wcListeners as [$priority, $callBack]) { + $merged[] = [$priority, $callBack, $wcEvent . '*']; + } + } + } + + return $merged; + } + + /** + * Resolves a possibly query-monitored callback back to the original listener. + * + * @param array> $wrappedListeners + * @param array> $originalListeners + * @return array{0: callable, 1: bool} the real callback and whether it was wrapped + */ + private function unwrapCallback(string $eventName, callable $callBack, array $wrappedListeners, array $originalListeners): array { + if (isset($wrappedListeners[$eventName])) { + $index = array_search($callBack, $wrappedListeners[$eventName], true); + if ($index !== false && isset($originalListeners[$eventName][$index])) { + return [$originalListeners[$eventName][$index], true]; + } + } + + return [$callBack, false]; + } + + /** + * @return array> + */ + private function readListenerMap(\ReflectionObject $reflection, SabreServer $server, string $property): array { + if (!$reflection->hasProperty($property)) { + return []; + } + $value = $reflection->getProperty($property)->getValue($server); + return \is_array($value) ? $value : []; + } + + /** + * Produces a human-readable description of a callable so it can be traced + * back to the plugin that registered it. + */ + private function describeCallable(callable $callBack): string { + if (\is_string($callBack)) { + return $callBack; + } + + if (\is_array($callBack)) { + $class = \is_object($callBack[0]) ? $callBack[0]::class : $callBack[0]; + return $class . '::' . $callBack[1]; + } + + if ($callBack instanceof \Closure) { + $function = new \ReflectionFunction($callBack); + $name = $function->getName(); + $scope = $function->getClosureScopeClass()?->getName(); + $file = $function->getFileName(); + $line = $function->getStartLine(); + $location = ($file !== false && $line !== false) ? $file . ':' . $line : null; + + $suffix = $location !== null ? ' (' . $location . ')' : ''; + + // A first-class callable (e.g. "$this->afterDownload(...)") reports a + // bare function/method reference rather than a "{closure}" marker. + // Wrap it so it is clearly marked as a closure and not confused with + // a plain method callable. + if (!str_contains($name, '{closure')) { + $target = ($scope !== null && !str_contains($name, '::')) ? $scope . '::' . $name : $name; + return '{closure:' . $target . '}'; + } + + // PHP 8.4+ returns a descriptive name that already starts with + // "{closure:" and includes the enclosing method and line, e.g. + // "{closure:OCA\DAV\Server::__construct():289}". Drop the duplicate + // trailing line and append the file so the full location is visible. + if (str_starts_with($name, '{closure:')) { + return (preg_replace('/:\d+}$/', '}', $name) ?? $name) . $suffix; + } + + // Older PHP returns "{closure}", prefixed with the namespace when + // defined inside one (e.g. "OCA\DAV\{closure}"); replace it with the + // class the closure was defined in (usually the plugin). + if ($scope !== null) { + return '{closure:' . $scope . '}' . $suffix; + } + + return '{closure}' . $suffix; + } + + // After ruling out strings, arrays and closures, the only remaining + // callable form is an invokable object. + return $callBack::class . '::__invoke'; + } +} diff --git a/apps/dav/lib/Command/ShowListeners.php b/apps/dav/lib/Command/ShowListeners.php new file mode 100644 index 0000000000000..36a994923c6d6 --- /dev/null +++ b/apps/dav/lib/Command/ShowListeners.php @@ -0,0 +1,246 @@ +setName('dav:show-listeners') + ->setDescription('Show all Sabre event listeners with their event and priority') + ->addOption( + 'uri', + null, + InputOption::VALUE_REQUIRED, + 'DAV request path the server is built for; controls which subtree plugins are loaded (e.g. "calendars/admin", "addressbooks/admin", "files/admin")', + '', + ) + ->addOption( + 'user', + null, + InputOption::VALUE_REQUIRED, + 'Set up the given user\'s session and filesystem so that plugins registered lazily during an authenticated request are listed too', + ) + ->addOption( + 'event', + null, + InputOption::VALUE_REQUIRED, + 'Only show listeners whose event name contains this string (case-insensitive)', + ) + ->addOption( + 'method', + null, + InputOption::VALUE_REQUIRED, + 'Show the resolved firing order for the given HTTP method (e.g. "GET"), merging wildcard and method-specific listeners the way Sabre does at emit time', + ); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $uri = (string)$input->getOption('uri'); + $user = $input->getOption('user'); + $eventFilter = (string)($input->getOption('event') ?? ''); + + $withLazy = $user !== null && $user !== ''; + if ($withLazy) { + $userObject = $this->userManager->get((string)$user); + if ($userObject === null) { + $output->writeln('User "' . $user . '" does not exist.'); + return self::FAILURE; + } + try { + $this->userSession->setUser($userObject); + $this->setupManager->tearDown(); + $this->setupManager->setupForUser($userObject); + } catch (\Throwable $e) { + $output->writeln('Could not set up the filesystem for "' . $user . '": ' . $e->getMessage() . ''); + $output->writeln('Continuing without lazily-registered listeners.'); + $withLazy = false; + } + } + + try { + $davServer = new Server($this->buildRequest($uri), '/'); + if ($withLazy) { + $this->triggerDeferredListeners($davServer->server, $output); + } + } catch (\Throwable $e) { + $output->writeln('Could not build the DAV server: ' . $e->getMessage() . ''); + return self::FAILURE; + } + + $method = (string)($input->getOption('method') ?? ''); + if ($method !== '') { + if ($eventFilter !== '') { + $output->writeln('--event is ignored when --method is given.'); + } + return $this->showFiringOrder($davServer->server, strtoupper($method), $output); + } + + $rows = $this->introspector->collectListeners($davServer->server); + $registeredCount = count($rows); + + if ($eventFilter !== '') { + $rows = array_values(array_filter( + $rows, + static fn (array $row): bool => stripos($row['event'], $eventFilter) !== false, + )); + } + + if ($rows === []) { + if ($registeredCount > 0) { + $output->writeln('No listeners match --event="' . $eventFilter . '" (' . $registeredCount . ' listener(s) registered for the given request).'); + } else { + $output->writeln('No listeners registered for the given request.'); + } + return self::SUCCESS; + } + + $table = new Table($output); + $table->setHeaders(['Event', 'Priority', 'Listener']); + $table->setRows(array_map( + static fn (array $row): array => [$row['event'], $row['priority'], $row['listener']], + $rows, + )); + $table->render(); + + $output->writeln(''); + $output->writeln('' . count($rows) . ' listener(s) shown for request URI "' . ($uri === '' ? '/' : $uri) . '".'); + if (!$withLazy) { + $output->writeln('Note: some plugins (e.g. FilesPlugin, QuotaPlugin) register their listeners lazily inside a "beforeMethod:*" handler during an authenticated request. Pass --user= to list those too.'); + } + + return self::SUCCESS; + } + + /** + * Builds and prints the resolved order in which listeners fire for the given + * HTTP method, across the method-scoped families and the method-independent + * lifecycle events that complete a request. + */ + private function showFiringOrder(SabreServer $server, string $method, OutputInterface $output): int { + $this->renderChain($server, 'beforeMethod:' . $method, $output, + 'Always runs first. A listener returning false or throwing stops the request here (see skip rules below).'); + $this->renderChain($server, 'method:' . $method, $output, + 'Runs only if beforeMethod completed and preconditions held; a handler here serves the request.'); + $this->renderChain($server, 'afterMethod:' . $method, $output, + 'Runs only after the method was served successfully (skipped on the early-exit paths listed below).'); + + $output->writeln(''); + $output->writeln('At most one of the two chains below normally runs per request — several early-exit paths run neither:'); + $this->renderChain($server, 'afterResponse', $output, 'Runs on the success path, after the response has been sent.'); + $this->renderChain($server, 'exception', $output, 'Runs on the failure path, when a listener or handler throws. If an afterResponse listener throws, both chains run.'); + + $output->writeln(''); + $output->writeln('When chains are skipped for this request:'); + $output->writeln(' * a beforeMethod listener returns false -> method / afterMethod / afterResponse are skipped; that listener owns the response, and the exception chain does NOT run.'); + $output->writeln(' * a beforeMethod listener throws -> method / afterMethod / afterResponse are skipped; the exception chain runs and Sabre sends an error response.'); + $output->writeln(' * conditional GET matches (304) -> the 304 response is sent directly; method / afterMethod / afterResponse AND the exception chain are all skipped.'); + $output->writeln(' * any other precondition fails -> PreconditionFailed is thrown, so the exception chain runs and Sabre sends a 412 response.'); + $output->writeln(' * no handler serves the method -> NotImplemented is thrown, so the exception chain runs.'); + $output->writeln(' * an afterMethod listener returns false -> the response is never sent and afterResponse is skipped; the exception chain does NOT run.'); + + return self::SUCCESS; + } + + /** + * Renders the resolved firing order for a single event name as a table. An + * optional description clarifies when the chain runs and when it is skipped. + */ + private function renderChain(SabreServer $server, string $eventName, OutputInterface $output, ?string $description = null): void { + $chain = $this->introspector->resolveFiringOrder($server, $eventName); + + $output->writeln(''); + $output->writeln('' . $eventName . ' — ' . count($chain) . ' listener(s), executed top to bottom (lowest priority first):'); + if ($description !== null) { + $output->writeln(' ' . $description . ''); + } + if ($chain === []) { + $output->writeln(' (no listeners)'); + return; + } + + $table = new Table($output); + $table->setHeaders(['#', 'Priority', 'Registered on', 'Listener']); + $table->setRows(array_map( + static fn (array $row): array => [$row['order'], $row['priority'], $row['registeredOn'], $row['listener']], + $chain, + )); + $table->render(); + } + + /** + * Invokes the deferred registration handlers so file-related plugins (which + * the server only adds once auth and the filesystem are ready) register + * their listeners and become visible. + */ + private function triggerDeferredListeners(SabreServer $server, OutputInterface $output): void { + $sourceFile = (new \ReflectionClass(Server::class))->getFileName(); + if ($sourceFile === false) { + return; + } + + foreach ($this->introspector->findDeferredHandlers($server, $sourceFile) as $handler) { + try { + $handler(); + } catch (\Throwable $e) { + $output->writeln('Could not fully trigger lazy listeners: ' . $e->getMessage() . ''); + } + } + } + + private function buildRequest(string $uri): Request { + $uri = '/' . ltrim($uri, '/'); + return new Request( + [ + 'server' => [ + 'REQUEST_URI' => $uri, + 'REQUEST_METHOD' => 'PROPFIND', + 'SCRIPT_NAME' => '', + ], + 'method' => 'PROPFIND', + ], + $this->requestId, + $this->config, + ); + } +} diff --git a/apps/dav/tests/unit/Command/ListenerIntrospectorTest.php b/apps/dav/tests/unit/Command/ListenerIntrospectorTest.php new file mode 100644 index 0000000000000..1b0f4e18d893b --- /dev/null +++ b/apps/dav/tests/unit/Command/ListenerIntrospectorTest.php @@ -0,0 +1,300 @@ +introspector = new ListenerIntrospector(); + } + + public function testCollectListenersReturnsWellFormedRows(): void { + $server = new Server(); + // A fresh Sabre server registers its own core listeners; assert we get a + // well-formed list rather than a specific count. + foreach ($this->introspector->collectListeners($server) as $row) { + $this->assertArrayHasKey('event', $row); + $this->assertArrayHasKey('priority', $row); + $this->assertArrayHasKey('listener', $row); + $this->assertIsInt($row['priority']); + } + $this->addToAssertionCount(1); + } + + public function testCollectListenersSortsByEventThenPriority(): void { + $server = new Server(); + $this->stripListeners($server); + + $server->on('beforeMethod:*', static function (): void { + }, 200); + $server->on('afterMethod:GET', [$this, 'sampleHandler'], 50); + $server->on('afterMethod:GET', static function (): void { + }, 10); + + $rows = $this->introspector->collectListeners($server); + + $this->assertCount(3, $rows); + + // afterMethod:GET sorts before beforeMethod:*, and within the same event + // priority 10 sorts before priority 50. + $this->assertSame('afterMethod:GET', $rows[0]['event']); + $this->assertSame(10, $rows[0]['priority']); + + $this->assertSame('afterMethod:GET', $rows[1]['event']); + $this->assertSame(50, $rows[1]['priority']); + $this->assertSame(self::class . '::sampleHandler', $rows[1]['listener']); + + $this->assertSame('beforeMethod:*', $rows[2]['event']); + $this->assertSame(200, $rows[2]['priority']); + } + + public function testCollectListenersDescribesCallableTypes(): void { + $server = new Server(); + $this->stripListeners($server); + + $server->on('event:closure', static function (): void { + }); + $server->on('event:method', [$this, 'sampleHandler']); + $server->on('event:static', [self::class, 'staticHandler']); + $server->on('event:invokable', new SampleInvokableHandler()); + $server->on('event:fcc', $this->sampleHandler(...)); + + $byEvent = []; + foreach ($this->introspector->collectListeners($server) as $row) { + $byEvent[$row['event']] = $row['listener']; + } + + // PHP 8.4+ renders "{closure:Class::method()} (file:line)", older PHP + // renders "{closure:Class} (file:line)" — both name the defining class + // and include the file location. + $this->assertStringContainsString(self::class, $byEvent['event:closure']); + $this->assertStringContainsString('closure', $byEvent['event:closure']); + $this->assertStringContainsString('ListenerIntrospectorTest.php:', $byEvent['event:closure']); + $this->assertSame(self::class . '::sampleHandler', $byEvent['event:method']); + $this->assertSame(self::class . '::staticHandler', $byEvent['event:static']); + $this->assertSame(SampleInvokableHandler::class . '::__invoke', $byEvent['event:invokable']); + // A first-class callable resolves to the method it wraps, marked as a + // closure so it is distinguishable from a plain method callable. + $this->assertSame('{closure:' . self::class . '::sampleHandler}', $byEvent['event:fcc']); + } + + public function testCollectListenersMarksWildcardEvents(): void { + $server = new Server(); + $this->stripListeners($server); + + $server->on('beforeMethod:*', static function (): void { + }); + + $rows = $this->introspector->collectListeners($server); + + $this->assertCount(1, $rows); + $this->assertSame('beforeMethod:*', $rows[0]['event']); + } + + public function testCollectListenersUnwrapsQueryMonitoredListeners(): void { + $server = new MonitoringTestServer(); + $this->stripListeners($server); + + // Mimic OCA\DAV\Connector\Sabre\Server::monitorPropfindQueries(), which + // registers a wrapper closure and records the original in a side map at + // the same index. + $original = [$this, 'sampleHandler']; + $wrapped = static function (): void { + }; + $server->originalListeners['propFind'][] = $original; + $server->wrappedListeners['propFind'][] = $wrapped; + $server->on('propFind', $wrapped, 100); + + $rows = $this->introspector->collectListeners($server); + + $this->assertCount(1, $rows); + $this->assertSame('propFind', $rows[0]['event']); + $this->assertSame(self::class . '::sampleHandler (query-monitored)', $rows[0]['listener']); + } + + public function testFindDeferredHandlersReturnsOnlyClosuresFromSourceFile(): void { + $server = new Server(); + $this->stripListeners($server); + + $deferred = static function (): void { + }; + $server->on('beforeMethod:*', $deferred, 100); + // A non-closure listener and a closure from a different file must be + // ignored. + $server->on('beforeMethod:*', [$this, 'sampleHandler'], 100); + + $handlers = $this->introspector->findDeferredHandlers($server, __FILE__); + $this->assertCount(1, $handlers); + $this->assertSame($deferred, $handlers[0]); + + $this->assertSame([], $this->introspector->findDeferredHandlers($server, '/some/other/file.php')); + } + + public function testResolveFiringOrderMergesWildcardAndExactByPriority(): void { + $server = new Server(); + $this->stripListeners($server); + + $closure = static function (): void { + }; + $server->on('beforeMethod:GET', [$this, 'sampleHandler'], 100); // exact, prio 100 + $server->on('beforeMethod:*', $closure, 10); // wildcard, prio 10 + $server->on('beforeMethod:*', [self::class, 'staticHandler'], 100); // wildcard, prio 100 + // An unrelated wildcard family must not leak into beforeMethod:GET. + $server->on('afterMethod:*', static function (): void { + }, 5); + + $chain = $this->introspector->resolveFiringOrder($server, 'beforeMethod:GET'); + + // Wildcard and exact listeners are merged; the unrelated afterMethod:* + // listener does not leak in. + $this->assertCount(3, $chain); + + // Sorted by priority ascending, numbered 1..n. + $this->assertSame([10, 100, 100], array_column($chain, 'priority')); + $this->assertSame([1, 2, 3], array_column($chain, 'order')); + + // The lowest-priority (wildcard) listener fires first. + $this->assertStringContainsString('closure', $chain[0]['listener']); + $this->assertSame('beforeMethod:*', $chain[0]['registeredOn']); + + // Both priority-100 listeners are present. Their relative order for equal + // priority is decided by Sabre's array_multisort tie-break, not by us, so + // we do not assert it here (see the listeners() equality test below). + $listeners = array_column($chain, 'listener'); + $this->assertContains(self::class . '::sampleHandler', $listeners); + $this->assertContains(self::class . '::staticHandler', $listeners); + + // Each row is attributed to the event name it was registered on. + $registeredOnByListener = array_column($chain, 'registeredOn', 'listener'); + $this->assertSame('beforeMethod:GET', $registeredOnByListener[self::class . '::sampleHandler']); + $this->assertSame('beforeMethod:*', $registeredOnByListener[self::class . '::staticHandler']); + } + + public function testResolveFiringSequenceMatchesSabresOwnListenerOrder(): void { + $server = new Server(); + $this->stripListeners($server); + + // A representative mix: wildcard + exact, varied and duplicate priorities, + // and different callable kinds. + $server->on('beforeMethod:GET', [$this, 'sampleHandler'], 100); + $server->on('beforeMethod:*', static function (): void { + }, 10); + $server->on('beforeMethod:*', [self::class, 'staticHandler'], 100); + $server->on('beforeMethod:GET', new SampleInvokableHandler(), 100); + $server->on('beforeMethod:*', $this->sampleHandler(...), 5); + + $sequence = $this->introspector->resolveFiringSequence($server, 'beforeMethod:GET'); + $callables = array_map(static fn (array $entry): callable => $entry[1], $sequence); + + // Sabre\DAV\Server::emit() iterates exactly $server->listeners($event), so + // matching that list callable-for-callable proves the printed order is the + // real execution order. + $this->assertSame($server->listeners('beforeMethod:GET'), $callables); + } + + public function testResolvedOrderMatchesRealDispatchEndToEnd(): void { + $server = new Server(); + $this->stripListeners($server); + + $log = []; + $listen = static function (string $marker) use (&$log): callable { + return static function () use (&$log, $marker): void { + $log[] = $marker; + }; + }; + + // Interleave wildcard and exact registrations across all three method + // families, with priorities chosen so that "wildcards first, then exact" + // (or any other separate execution) would produce a different order. + $server->on('beforeMethod:GET', $listen('before:exact@10'), 10); + $server->on('beforeMethod:*', $listen('before:wildcard@50'), 50); + $server->on('beforeMethod:GET', $listen('before:exact@100'), 100); + $server->on('beforeMethod:*', $listen('before:wildcard@150'), 150); + + $server->on('method:*', $listen('method:wildcard@90'), 90); + $server->on('method:GET', static function ($request, $response) use (&$log): bool { + $log[] = 'method:exact@100(serves)'; + $response->setStatus(200); + return false; // handled — stops the (merged) chain + }, 100); + // If wildcard listeners ran as a separate list, this one would still be + // invoked; in the merged chain the serving handler above stops it. + $server->on('method:*', $listen('method:wildcard@300'), 300); + + $server->on('afterMethod:*', $listen('after:wildcard@60'), 60); + $server->on('afterMethod:GET', $listen('after:exact@110'), 110); + + // Real dispatch through Sabre's own request pipeline. + $server->invokeMethod(new \Sabre\HTTP\Request('GET', '/'), new \Sabre\HTTP\Response(), false); + + $this->assertSame([ + 'before:exact@10', + 'before:wildcard@50', + 'before:exact@100', + 'before:wildcard@150', + 'method:wildcard@90', + 'method:exact@100(serves)', + // no 'method:wildcard@300' — the merged chain stopped + 'after:wildcard@60', + 'after:exact@110', + ], $log); + + // The introspector displays the same merged order the dispatch used, + // including the listener that did not run because the chain stopped. + $this->assertSame( + [10, 50, 100, 150], + array_column($this->introspector->resolveFiringOrder($server, 'beforeMethod:GET'), 'priority'), + ); + $this->assertSame( + [90, 100, 300], + array_column($this->introspector->resolveFiringOrder($server, 'method:GET'), 'priority'), + ); + } + + public function sampleHandler(): void { + } + + public static function staticHandler(): void { + } + + /** + * Removes the listeners Sabre registers by default so tests can assert on an + * exact set of registrations. + */ + private function stripListeners(Server $server): void { + $server->removeAllListeners(); + } +} + +class SampleInvokableHandler { + public function __invoke(): void { + } +} + +/** + * Mimics the side maps that OCA\DAV\Connector\Sabre\Server keeps for its + * query-monitoring wrappers, without pulling in the full DAV server. + */ +class MonitoringTestServer extends Server { + /** @var array> */ + public array $originalListeners = []; + /** @var array> */ + public array $wrappedListeners = []; +} diff --git a/apps/dav/tests/unit/Command/ShowListenersTest.php b/apps/dav/tests/unit/Command/ShowListenersTest.php new file mode 100644 index 0000000000000..ac83286201bb7 --- /dev/null +++ b/apps/dav/tests/unit/Command/ShowListenersTest.php @@ -0,0 +1,93 @@ +config = $this->createMock(IConfig::class); + $this->requestId = $this->createMock(IRequestId::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->setupManager = $this->createMock(ISetupManager::class); + + $this->command = new ShowListeners( + $this->config, + $this->requestId, + $this->userManager, + $this->userSession, + $this->setupManager, + new ListenerIntrospector(), + ); + } + + public function testExecuteFailsForUnknownUser(): void { + $this->userManager->expects($this->once()) + ->method('get') + ->with('nope') + ->willReturn(null); + $this->userSession->expects($this->never()) + ->method('setUser'); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['--user' => 'nope']); + + $this->assertSame(ShowListeners::FAILURE, $exitCode); + $this->assertStringContainsString('User "nope" does not exist.', $tester->getDisplay()); + } + + public function testExecuteSetsUpSessionForExistingUser(): void { + $user = $this->createMock(IUser::class); + $this->userManager->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($user); + $this->userSession->expects($this->once()) + ->method('setUser') + ->with($user); + + $tester = new CommandTester($this->command); + // Building the full DAV server is expected to fail in a bare unit test + // environment; asserting on the session setup and the graceful error is + // all this test is for. + $exitCode = $tester->execute(['--user' => 'alice']); + + if ($exitCode === ShowListeners::FAILURE) { + $this->assertStringContainsString('Could not', $tester->getDisplay()); + } + } +}