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());
+ }
+ }
+}