aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorCôme Chilliet <come.chilliet@nextcloud.com>2024-05-30 17:26:40 +0200
committerCôme Chilliet <91878298+come-nc@users.noreply.github.com>2024-06-11 14:10:29 +0200
commit144bdd73f9ca96174d7de8664b4026b65d3bdf07 (patch)
tree3998e2ec2bfb6c34c02dcb196813d577f5e7c2a4
parent5dd9c2f8e85816f5c588d9539ec33b97d111287e (diff)
downloadnextcloud-server-144bdd73f9ca96174d7de8664b4026b65d3bdf07.tar.gz
nextcloud-server-144bdd73f9ca96174d7de8664b4026b65d3bdf07.zip
feat: Add event filtering to webhooks
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
-rw-r--r--apps/webhooks/composer/composer/autoload_classmap.php1
-rw-r--r--apps/webhooks/composer/composer/autoload_static.php1
-rw-r--r--apps/webhooks/lib/BackgroundJobs/WebhookCall.php7
-rw-r--r--apps/webhooks/lib/Command/Index.php6
-rw-r--r--apps/webhooks/lib/Controller/WebhooksController.php4
-rw-r--r--apps/webhooks/lib/Db/WebhookListener.php1
-rw-r--r--apps/webhooks/lib/Db/WebhookListenerMapper.php4
-rw-r--r--apps/webhooks/lib/Listener/WebhooksEventListener.php30
-rwxr-xr-xapps/webhooks/lib/Migration/Version1000Date20240527153425.php3
-rw-r--r--apps/webhooks/lib/Service/PHPMongoQuery.php340
-rw-r--r--apps/webhooks/tests/Db/WebhookListenerMapperTest.php1
11 files changed, 390 insertions, 8 deletions
diff --git a/apps/webhooks/composer/composer/autoload_classmap.php b/apps/webhooks/composer/composer/autoload_classmap.php
index 3411d0b6b96..efd4a43d58a 100644
--- a/apps/webhooks/composer/composer/autoload_classmap.php
+++ b/apps/webhooks/composer/composer/autoload_classmap.php
@@ -16,5 +16,6 @@ return array(
'OCA\\Webhooks\\Listener\\WebhooksEventListener' => $baseDir . '/../lib/Listener/WebhooksEventListener.php',
'OCA\\Webhooks\\Migration\\Version1000Date20240527153425' => $baseDir . '/../lib/Migration/Version1000Date20240527153425.php',
'OCA\\Webhooks\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
+ 'OCA\\Webhooks\\Service\\PHPMongoQuery' => $baseDir . '/../lib/Service/PHPMongoQuery.php',
'OCA\\Webhooks\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
);
diff --git a/apps/webhooks/composer/composer/autoload_static.php b/apps/webhooks/composer/composer/autoload_static.php
index e631fdfb975..75182423ae6 100644
--- a/apps/webhooks/composer/composer/autoload_static.php
+++ b/apps/webhooks/composer/composer/autoload_static.php
@@ -31,6 +31,7 @@ class ComposerStaticInitWebhooks
'OCA\\Webhooks\\Listener\\WebhooksEventListener' => __DIR__ . '/..' . '/../lib/Listener/WebhooksEventListener.php',
'OCA\\Webhooks\\Migration\\Version1000Date20240527153425' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20240527153425.php',
'OCA\\Webhooks\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
+ 'OCA\\Webhooks\\Service\\PHPMongoQuery' => __DIR__ . '/..' . '/../lib/Service/PHPMongoQuery.php',
'OCA\\Webhooks\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
);
diff --git a/apps/webhooks/lib/BackgroundJobs/WebhookCall.php b/apps/webhooks/lib/BackgroundJobs/WebhookCall.php
index 9b113a5c1fc..469b554a886 100644
--- a/apps/webhooks/lib/BackgroundJobs/WebhookCall.php
+++ b/apps/webhooks/lib/BackgroundJobs/WebhookCall.php
@@ -26,14 +26,11 @@ class WebhookCall extends QueuedJob {
}
protected function run($argument): void {
- [$event, $userId, $webhookId] = $argument;
+ [$data, $webhookId] = $argument;
$webhookListener = $this->mapper->getById($webhookId);
$client = $this->clientService->newClient();
$options = [];
- $options['body'] = json_encode([
- 'event' => $event,
- 'userid' => $userId,
- ]);
+ $options['body'] = json_encode($data);
try {
$response = $client->request($webhookListener->getHttpMethod(), $webhookListener->getUri(), $options);
$statusCode = $response->getStatusCode();
diff --git a/apps/webhooks/lib/Command/Index.php b/apps/webhooks/lib/Command/Index.php
index 900140c8f96..78feda3ec68 100644
--- a/apps/webhooks/lib/Command/Index.php
+++ b/apps/webhooks/lib/Command/Index.php
@@ -31,7 +31,11 @@ class Index extends Base {
protected function execute(InputInterface $input, OutputInterface $output): int {
$webhookListeners = array_map(
- fn (WebhookListener $listener) => $listener->jsonSerialize(),
+ function (WebhookListener $listener): array {
+ $data = $listener->jsonSerialize();
+ $data['eventFilter'] = json_encode($data['eventFilter']);
+ return $data;
+ },
$this->mapper->getAll()
);
$this->writeTableInOutputFormat($input, $output, $webhookListeners);
diff --git a/apps/webhooks/lib/Controller/WebhooksController.php b/apps/webhooks/lib/Controller/WebhooksController.php
index 9d4c7c70279..46da32fd6e2 100644
--- a/apps/webhooks/lib/Controller/WebhooksController.php
+++ b/apps/webhooks/lib/Controller/WebhooksController.php
@@ -91,6 +91,7 @@ class WebhooksController extends OCSController {
string $httpMethod,
string $uri,
string $event,
+ ?array $eventFilter,
?array $headers,
?string $authMethod,
?array $authData,
@@ -101,6 +102,7 @@ class WebhooksController extends OCSController {
$httpMethod,
$uri,
$event,
+ $eventFilter,
$headers,
$authMethod,
$authData,
@@ -142,6 +144,7 @@ class WebhooksController extends OCSController {
string $httpMethod,
string $uri,
string $event,
+ ?array $eventFilter,
?array $headers,
?string $authMethod,
?array $authData,
@@ -153,6 +156,7 @@ class WebhooksController extends OCSController {
$httpMethod,
$uri,
$event,
+ $eventFilter,
$headers,
$authMethod,
$authData,
diff --git a/apps/webhooks/lib/Db/WebhookListener.php b/apps/webhooks/lib/Db/WebhookListener.php
index 0d731e72d7f..a52042aa0a2 100644
--- a/apps/webhooks/lib/Db/WebhookListener.php
+++ b/apps/webhooks/lib/Db/WebhookListener.php
@@ -42,6 +42,7 @@ class WebhookListener extends Entity implements \JsonSerializable {
$this->addType('httpMethod', 'string');
$this->addType('uri', 'string');
$this->addType('event', 'string');
+ $this->addType('eventFilter', 'json');
$this->addType('headers', 'json');
$this->addType('authMethod', 'string');
$this->addType('authData', 'json');
diff --git a/apps/webhooks/lib/Db/WebhookListenerMapper.php b/apps/webhooks/lib/Db/WebhookListenerMapper.php
index 773358d671c..5ce824893ab 100644
--- a/apps/webhooks/lib/Db/WebhookListenerMapper.php
+++ b/apps/webhooks/lib/Db/WebhookListenerMapper.php
@@ -61,6 +61,7 @@ class WebhookListenerMapper extends QBMapper {
string $httpMethod,
string $uri,
string $event,
+ ?array $eventFilter,
?array $headers,
?string $authMethod,
?array $authData,
@@ -71,6 +72,7 @@ class WebhookListenerMapper extends QBMapper {
'httpMethod' => $httpMethod,
'uri' => $uri,
'event' => $event,
+ 'eventFilter' => $eventFilter ?? [],
'headers' => $headers,
'authMethod' => $authMethod ?? 'none',
'authData' => $authData,
@@ -85,6 +87,7 @@ class WebhookListenerMapper extends QBMapper {
string $httpMethod,
string $uri,
string $event,
+ ?array $eventFilter,
?array $headers,
?string $authMethod,
?array $authData,
@@ -96,6 +99,7 @@ class WebhookListenerMapper extends QBMapper {
'httpMethod' => $httpMethod,
'uri' => $uri,
'event' => $event,
+ 'eventFilter' => $eventFilter ?? [],
'headers' => $headers,
'authMethod' => $authMethod,
'authData' => $authData,
diff --git a/apps/webhooks/lib/Listener/WebhooksEventListener.php b/apps/webhooks/lib/Listener/WebhooksEventListener.php
index 8e935f1d0ce..5ea0012d4e9 100644
--- a/apps/webhooks/lib/Listener/WebhooksEventListener.php
+++ b/apps/webhooks/lib/Listener/WebhooksEventListener.php
@@ -11,9 +11,12 @@ namespace OCA\Webhooks\Listener;
use OCA\Webhooks\BackgroundJobs\WebhookCall;
use OCA\Webhooks\Db\WebhookListenerMapper;
+use OCA\Webhooks\Service\PHPMongoQuery;
use OCP\BackgroundJob\IJobList;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
+use OCP\EventDispatcher\JsonSerializer;
+use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
@@ -25,15 +28,31 @@ class WebhooksEventListener implements IEventListener {
private WebhookListenerMapper $mapper,
private IJobList $jobList,
private LoggerInterface $logger,
- private ?string $userId,
+ private IUserSession $userSession,
) {
}
public function handle(Event $event): void {
$webhookListeners = $this->mapper->getByEvent($event::class);
+ /** @var IUser */
+ $user = $this->userSession->getUser();
foreach ($webhookListeners as $webhookListener) {
- $this->jobList->add(WebhookCall::class, [$this->serializeEvent($event), $this->userId, $webhookListener->getId(), time()]);
+ // TODO add group membership to be able to filter on it
+ $data = [
+ 'event' => $this->serializeEvent($event),
+ 'user' => JsonSerializer::serializeUser($user),
+ 'time' => time(),
+ ];
+ if ($this->filterMatch($webhookListener->getEventFilter(), $data)) {
+ $this->jobList->add(
+ WebhookCall::class,
+ [
+ $data,
+ $webhookListener->getId(),
+ ]
+ );
+ }
}
}
@@ -61,4 +80,11 @@ class WebhooksEventListener implements IEventListener {
return $data;
}
}
+
+ private function filterMatch(array $filter, array $data): bool {
+ if ($filter === []) {
+ return true;
+ }
+ return PHPMongoQuery::executeQuery($filter, $data);
+ }
}
diff --git a/apps/webhooks/lib/Migration/Version1000Date20240527153425.php b/apps/webhooks/lib/Migration/Version1000Date20240527153425.php
index 18f8b545ddf..b6c345a22e2 100755
--- a/apps/webhooks/lib/Migration/Version1000Date20240527153425.php
+++ b/apps/webhooks/lib/Migration/Version1000Date20240527153425.php
@@ -46,6 +46,9 @@ class Version1000Date20240527153425 extends SimpleMigrationStep {
$table->addColumn('event', Types::TEXT, [
'notnull' => true,
]);
+ $table->addColumn('event_filter', Types::TEXT, [
+ 'notnull' => false,
+ ]);
$table->addColumn('headers', Types::TEXT, [
'notnull' => false,
]);
diff --git a/apps/webhooks/lib/Service/PHPMongoQuery.php b/apps/webhooks/lib/Service/PHPMongoQuery.php
new file mode 100644
index 00000000000..65ba5775763
--- /dev/null
+++ b/apps/webhooks/lib/Service/PHPMongoQuery.php
@@ -0,0 +1,340 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2013 Akkroo Solutions Ltd
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Webhooks\Service;
+
+use Exception;
+
+/**
+ * PHPMongoQuery implements MongoDB queries in PHP, allowing developers to query
+ * a 'document' (an array containing data) against a Mongo query object,
+ * returning a boolean value for pass or fail
+ */
+abstract class PHPMongoQuery {
+ /**
+ * Execute a mongo query on a set of documents and return the documents that pass the query
+ *
+ * @param array $query A boolean value or an array defining a query
+ * @param array $documents The document to query
+ * @param array $options Any options:
+ * 'debug' - boolean - debug mode, verbose logging
+ * 'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3}
+ * 'unknownOperatorCallback' - a callback to be called if an operator can't be found. The function definition is function($operator, $operatorValue, $field, $document). return true or false.
+ * @throws Exception
+ */
+ public static function find(array $query, array $documents, array $options = []): array {
+ if(empty($documents) || empty($query)) {
+ return [];
+ }
+ $ret = [];
+ $options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface;
+ $options['_debug'] = !empty($options['debug']);
+ foreach ($documents as $doc) {
+ if(static::_executeQuery($query, $doc, $options)) {
+ $ret[] = $doc;
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Execute a Mongo query on a document
+ *
+ * @param mixed $query A boolean value or an array defining a query
+ * @param array $document The document to query
+ * @param array $options Any options:
+ * 'debug' - boolean - debug mode, verbose logging
+ * 'logger' - \Psr\LoggerInterface - A logger instance that implements {@link https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#3-psrlogloggerinterface PSR-3}
+ * 'unknownOperatorCallback' - a callback to be called if an operator can't be found. The function definition is function($operator, $operatorValue, $field, $document). return true or false.
+ * @throws Exception
+ */
+ public static function executeQuery($query, array &$document, array $options = []): bool {
+ $options['_shouldLog'] = !empty($options['logger']) && $options['logger'] instanceof \Psr\Log\LoggerInterface;
+ $options['_debug'] = !empty($options['debug']);
+ if($options['_debug'] && $options['_shouldLog']) {
+ $options['logger']->debug('executeQuery called', ['query' => $query, 'document' => $document, 'options' => $options]);
+ }
+
+ if(!is_array($query)) {
+ return (bool)$query;
+ }
+
+ return self::_executeQuery($query, $document, $options);
+ }
+
+ /**
+ * Internal execute query
+ *
+ * This expects an array from the query and has an additional logical operator (for the root query object the logical operator is always $and so this is not required)
+ *
+ * @throws Exception
+ */
+ private static function _executeQuery(array $query, array &$document, array $options = [], string $logicalOperator = '$and'): bool {
+ if($logicalOperator !== '$and' && (!count($query) || !isset($query[0]))) {
+ throw new Exception($logicalOperator.' requires nonempty array');
+ }
+ if($options['_debug'] && $options['_shouldLog']) {
+ $options['logger']->debug('_executeQuery called', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
+ }
+
+ // for the purpose of querying documents, we are going to specify that an indexed array is an array which
+ // only contains numeric keys, is sequential, the first key is zero, and not empty. This will allow us
+ // to detect an array of key->vals that have numeric IDs vs an array of queries (where keys were not specified)
+ $queryIsIndexedArray = !empty($query) && array_is_list($query);
+
+ foreach($query as $k => $q) {
+ $pass = true;
+ if(is_string($k) && substr($k, 0, 1) === '$') {
+ // key is an operator at this level, except $not, which can be at any level
+ if($k === '$not') {
+ $pass = !self::_executeQuery($q, $document, $options);
+ } else {
+ $pass = self::_executeQuery($q, $document, $options, $k);
+ }
+ } elseif($logicalOperator === '$and') { // special case for $and
+ if($queryIsIndexedArray) { // $q is an array of query objects
+ $pass = self::_executeQuery($q, $document, $options);
+ } elseif(is_array($q)) { // query is array, run all queries on field. All queries must match. e.g { 'age': { $gt: 24, $lt: 52 } }
+ $pass = self::_executeQueryOnElement($q, $k, $document, $options);
+ } else {
+ // key value means equality
+ $pass = self::_executeOperatorOnElement('$e', $q, $k, $document, $options);
+ }
+ } else { // $q is array of query objects e.g '$or' => [{'fullName' => 'Nick'}]
+ $pass = self::_executeQuery($q, $document, $options, '$and');
+ }
+ switch($logicalOperator) {
+ case '$and': // if any fail, query fails
+ if(!$pass) {
+ return false;
+ }
+ break;
+ case '$or': // if one succeeds, query succeeds
+ if($pass) {
+ return true;
+ }
+ break;
+ case '$nor': // if one succeeds, query fails
+ if($pass) {
+ return false;
+ }
+ break;
+ default:
+ if($options['_shouldLog']) {
+ $options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
+ }
+ return false;
+ }
+ }
+ switch($logicalOperator) {
+ case '$and': // all succeeded, query succeeds
+ return true;
+ case '$or': // all failed, query fails
+ return false;
+ case '$nor': // all failed, query succeeded
+ return true;
+ default:
+ if($options['_shouldLog']) {
+ $options['logger']->warning('_executeQuery could not find logical operator', ['query' => $query, 'document' => $document, 'logicalOperator' => $logicalOperator]);
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Execute a query object on an element
+ *
+ * @throws Exception
+ */
+ private static function _executeQueryOnElement(array $query, string $element, array &$document, array $options = []): bool {
+ if($options['_debug'] && $options['_shouldLog']) {
+ $options['logger']->debug('_executeQueryOnElement called', ['query' => $query, 'element' => $element, 'document' => $document]);
+ }
+ // iterate through query operators
+ foreach($query as $op => $opVal) {
+ if(!self::_executeOperatorOnElement($op, $opVal, $element, $document, $options)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Check if an operator is equal to a value
+ *
+ * Equality includes direct equality, regular expression match, and checking if the operator value is one of the values in an array value
+ *
+ * @param mixed $v
+ * @param mixed $operatorValue
+ */
+ private static function _isEqual($v, $operatorValue): bool {
+ if (is_array($v) && is_array($operatorValue)) {
+ return $v == $operatorValue;
+ }
+ if(is_array($v)) {
+ return in_array($operatorValue, $v);
+ }
+ if(is_string($operatorValue) && preg_match('/^\/(.*?)\/([a-z]*)$/i', $operatorValue, $matches)) {
+ return (bool)preg_match('/'.$matches[1].'/'.$matches[2], $v);
+ }
+ return $operatorValue === $v;
+ }
+
+ /**
+ * Execute a Mongo Operator on an element
+ *
+ * @param string $operator The operator to perform
+ * @param mixed $operatorValue The value to provide the operator
+ * @param string $element The target element. Can be an object path eg price.shoes
+ * @param array $document The document in which to find the element
+ * @param array $options Options
+ * @throws Exception Exceptions on invalid operators, invalid unknown operator callback, and invalid operator values
+ */
+ private static function _executeOperatorOnElement(string $operator, $operatorValue, string $element, array &$document, array $options = []): bool {
+ if($options['_debug'] && $options['_shouldLog']) {
+ $options['logger']->debug('_executeOperatorOnElement called', ['operator' => $operator, 'operatorValue' => $operatorValue, 'element' => $element, 'document' => $document]);
+ }
+
+ if($operator === '$not') {
+ return !self::_executeQueryOnElement($operatorValue, $element, $document, $options);
+ }
+
+ $elementSpecifier = explode('.', $element);
+ $v = & $document;
+ $exists = true;
+ foreach($elementSpecifier as $index => $es) {
+ if(empty($v)) {
+ $exists = false;
+ break;
+ }
+ if(isset($v[0])) {
+ // value from document is an array, so we need to iterate through array and test the query on all elements of the array
+ // if any elements match, then return true
+ $newSpecifier = implode('.', array_slice($elementSpecifier, $index));
+ foreach($v as $item) {
+ if(self::_executeOperatorOnElement($operator, $operatorValue, $newSpecifier, $item, $options)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ if(isset($v[$es])) {
+ $v = & $v[$es];
+ } else {
+ $exists = false;
+ break;
+ }
+ }
+
+ switch($operator) {
+ case '$all':
+ if(!$exists) {
+ return false;
+ }
+ if(!is_array($operatorValue)) {
+ throw new Exception('$all requires array');
+ }
+ if(count($operatorValue) === 0) {
+ return false;
+ }
+ if(!is_array($v)) {
+ if(count($operatorValue) === 1) {
+ return $v === $operatorValue[0];
+ }
+ return false;
+ }
+ return count(array_intersect($v, $operatorValue)) === count($operatorValue);
+ case '$e':
+ if(!$exists) {
+ return false;
+ }
+ return self::_isEqual($v, $operatorValue);
+ case '$in':
+ if(!$exists) {
+ return false;
+ }
+ if(!is_array($operatorValue)) {
+ throw new Exception('$in requires array');
+ }
+ if(count($operatorValue) === 0) {
+ return false;
+ }
+ if(is_array($v)) {
+ return count(array_intersect($v, $operatorValue)) > 0;
+ }
+ return in_array($v, $operatorValue);
+ case '$lt': return $exists && $v < $operatorValue;
+ case '$lte': return $exists && $v <= $operatorValue;
+ case '$gt': return $exists && $v > $operatorValue;
+ case '$gte': return $exists && $v >= $operatorValue;
+ case '$ne': return (!$exists && $operatorValue !== null) || ($exists && !self::_isEqual($v, $operatorValue));
+ case '$nin':
+ if(!$exists) {
+ return true;
+ }
+ if(!is_array($operatorValue)) {
+ throw new Exception('$nin requires array');
+ }
+ if(count($operatorValue) === 0) {
+ return true;
+ }
+ if(is_array($v)) {
+ return count(array_intersect($v, $operatorValue)) === 0;
+ }
+ return !in_array($v, $operatorValue);
+
+ case '$exists': return ($operatorValue && $exists) || (!$operatorValue && !$exists);
+ case '$mod':
+ if(!$exists) {
+ return false;
+ }
+ if(!is_array($operatorValue)) {
+ throw new Exception('$mod requires array');
+ }
+ if(count($operatorValue) !== 2) {
+ throw new Exception('$mod requires two parameters in array: divisor and remainder');
+ }
+ return $v % $operatorValue[0] === $operatorValue[1];
+
+ default:
+ if(empty($options['unknownOperatorCallback']) || !is_callable($options['unknownOperatorCallback'])) {
+ throw new Exception('Operator '.$operator.' is unknown');
+ }
+
+ $res = call_user_func($options['unknownOperatorCallback'], $operator, $operatorValue, $element, $document);
+ if($res === null) {
+ throw new Exception('Operator '.$operator.' is unknown');
+ }
+ if(!is_bool($res)) {
+ throw new Exception('Return value of unknownOperatorCallback must be boolean, actual value '.$res);
+ }
+ return $res;
+ }
+ throw new Exception('Didn\'t return in switch');
+ }
+
+ /**
+ * Get the fields this query depends on
+ *
+ * @param array query The query to analyse
+ * @return array An array of fields this query depends on
+ */
+ public static function getDependentFields(array $query) {
+ $fields = [];
+ foreach($query as $k => $v) {
+ if(is_array($v)) {
+ $fields = array_merge($fields, static::getDependentFields($v));
+ }
+ if(is_int($k) || $k[0] === '$') {
+ continue;
+ }
+ $fields[] = $k;
+ }
+ return array_unique($fields);
+ }
+}
diff --git a/apps/webhooks/tests/Db/WebhookListenerMapperTest.php b/apps/webhooks/tests/Db/WebhookListenerMapperTest.php
index eca6b750ae0..4481eb6661e 100644
--- a/apps/webhooks/tests/Db/WebhookListenerMapperTest.php
+++ b/apps/webhooks/tests/Db/WebhookListenerMapperTest.php
@@ -51,6 +51,7 @@ class WebhookListenerMapperTest extends TestCase {
null,
null,
null,
+ null,
);
$listener2 = $this->mapper->getById($listener1->getId());