diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 7c5c313f9..1186c17a9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -13,6 +13,7 @@ use OCA\Notifications\Capabilities; use OCA\Notifications\Listener\AddMissingIndicesListener; use OCA\Notifications\Listener\BeforeTemplateRenderedListener; +use OCA\Notifications\Listener\CSPListener; use OCA\Notifications\Listener\UserCreatedListener; use OCA\Notifications\Listener\UserDeletedListener; use OCA\Notifications\Notifier\AdminNotifications; @@ -23,6 +24,7 @@ use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\Notification\IManager; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; use OCP\User\Events\UserCreatedEvent; use OCP\User\Events\UserDeletedEvent; @@ -41,6 +43,7 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(AdminNotifications::class); + $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class); $context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php new file mode 100644 index 000000000..d73560632 --- /dev/null +++ b/lib/Controller/WebController.php @@ -0,0 +1,55 @@ + + * + * 200: The service worker + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/service-worker.js')] + public function serviceWorker(): StreamResponse { + $response = new StreamResponse( + __DIR__ . '/../../service-worker.js', + headers: [ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/' + ] + ); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedConnectDomain("'self'"); + $response->setContentSecurityPolicy($policy); + return $response; + } +} diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 8a4e6bfb7..5ae226489 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -27,6 +27,7 @@ use OCP\ISession; use OCP\IUser; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; enum NewSubStatus: int { @@ -53,6 +54,7 @@ public function __construct( protected IUserSession $userSession, protected IProvider $tokenProvider, protected Manager $identityProof, + protected LoggerInterface $logger, ) { parent::__construct($appName, $request); } @@ -114,13 +116,15 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, } $tokenId = $this->session->get('token-id'); - if (!\is_int($tokenId)) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); - } - try { - $token = $this->tokenProvider->getTokenById($tokenId); - } catch (InvalidTokenException) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + if (\is_null($tokenId)) { + $token = $this->session; + } else { + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + $this->logger->error('Invalid token exception', ['exception' => $e]); + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } } [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); @@ -158,11 +162,16 @@ public function activateWP(string $activationToken): DataResponse { return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - $tokenId = (int)$this->session->get('token-id'); - try { - $token = $this->tokenProvider->getTokenById($tokenId); - } catch (InvalidTokenException) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + $tokenId = $this->session->get('token-id'); + if (\is_null($tokenId)) { + $token = $this->session; + } else { + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + $this->logger->error('Invalid token exception', ['exception' => $e]); + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } } $status = $this->activateSubscription($user, $token, $activationToken); @@ -193,11 +202,16 @@ public function removeWP(): DataResponse { return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - $tokenId = (int)$this->session->get('token-id'); - try { - $token = $this->tokenProvider->getTokenById($tokenId); - } catch (InvalidTokenException) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + $tokenId = $this->session->get('token-id'); + if (\is_null($tokenId)) { + $token = $this->session; + } else { + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + $this->logger->error('Invalid token exception', ['exception' => $e]); + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } } if ($this->deleteSubscription($user, $token)) { @@ -218,12 +232,12 @@ protected function getWPClient(): WebPushClient { * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth * - UPDATED if the subscription has been updated (use to change appTypes) */ - protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array { + protected function saveSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token)))) ->andWhere($query->expr()->eq('endpoint', $query->createNamedParameter($endpoint))) ->andWhere($query->expr()->eq('ua_public', $query->createNamedParameter($uaPublicKey))) ->andWhere($query->expr()->eq('auth', $query->createNamedParameter($auth))) @@ -256,12 +270,12 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint * - NO_TOKEN if we don't have this token * - NO_SUB if we don't have this subscription */ - protected function activateSubscription(IUser $user, IToken $token, string $activationToken): ActivationSubStatus { + protected function activateSubscription(IUser $user, IToken|ISession $token, string $activationToken): ActivationSubStatus { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))); + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token)))); $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -275,7 +289,7 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti $query->update('notifications_webpush') ->set('activated', $query->createNamedParameter(true)) ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT))) ->andWhere($query->expr()->eq('activation_token', $query->createNamedParameter($activationToken))); if ($query->executeStatement() !== 0) { @@ -288,12 +302,12 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti * @param string $appTypes comma separated list of types * @return bool If the entry was created */ - protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $appTypes): bool { + protected function insertSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $appTypes): bool { $query = $this->db->getQueryBuilder(); $query->insert('notifications_webpush') ->values([ 'uid' => $query->createNamedParameter($user->getUID()), - 'token' => $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), + 'token' => $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT), 'endpoint' => $query->createNamedParameter($endpoint), 'ua_public' => $query->createNamedParameter($uaPublicKey), 'auth' => $query->createNamedParameter($auth), @@ -307,7 +321,7 @@ protected function insertSubscription(IUser $user, IToken $token, string $endpoi * @param string $appTypes comma separated list of types * @return bool If the entry was updated */ - protected function updateSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): bool { + protected function updateSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): bool { $query = $this->db->getQueryBuilder(); $query->update('notifications_webpush') ->set('endpoint', $query->createNamedParameter($endpoint)) @@ -315,7 +329,7 @@ protected function updateSubscription(IUser $user, IToken $token, string $endpoi ->set('auth', $query->createNamedParameter($auth)) ->set('app_types', $query->createNamedParameter($appTypes)) ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT))); return $query->executeStatement() !== 0; } @@ -323,12 +337,23 @@ protected function updateSubscription(IUser $user, IToken $token, string $endpoi /** * @return bool If the entry was deleted */ - protected function deleteSubscription(IUser $user, IToken $token): bool { + protected function deleteSubscription(IUser $user, IToken|ISession $token): bool { $query = $this->db->getQueryBuilder(); $query->delete('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT))); return $query->executeStatement() !== 0; } + + /** + * @return Int tokenId from IToken or ISession, negative in case of Session id, positive otherwise + */ + private function getId(IToken|ISession $token): Int { + return match(true) { + $token instanceof IToken => $token->getId(), + // (-id - 1) to avoid session with id = 0 + $token instanceof ISession => -1 - (int)$token->getId(), + }; + } } diff --git a/lib/Listener/CSPListener.php b/lib/Listener/CSPListener.php new file mode 100644 index 000000000..1c1e06503 --- /dev/null +++ b/lib/Listener/CSPListener.php @@ -0,0 +1,42 @@ + + */ +readonly class CSPListener implements IEventListener { + public function __construct( + protected IUserSession $userSession, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof AddContentSecurityPolicyEvent)) { + return; + } + + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return; + } + + $csp = new ContentSecurityPolicy(); + $csp->addAllowedWorkerSrcDomain("'self'"); + $event->addPolicy($csp); + } +} diff --git a/lib/Push.php b/lib/Push.php index 5fbbbdca8..82fea7146 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -780,6 +780,10 @@ protected function sendNotificationsToProxies(): void { } protected function validateToken(int $tokenId, int $maxAge): TokenValidation { + // This is a web session token + if ($tokenId < 0) { + return TokenValidation::VALID; + } $age = $this->cache->get('t' . $tokenId); if ($age === null) { diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..e8420f06c --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +'use strict' + +/** + * @param {string} content received by Nextcloud, splitted to be shown in UI notification + */ +function showBgNotification(content) { + const [title, ...msg] = content.split('\n') + const options = { + body: msg.join('\n'), + } + return self.registration.showNotification(title, options) +} + +/** + * For Chrom* and Apple (who followed Chrome) users: + * We need to show a silent notification that we remove to avoid being unregistered, + * because they require userVisibleOnly=true registrations, and so forbid silent notifs. + */ +function silentNotification() { + const tag = 'silent' + const options = { + silent: true, + tag, + body: 'This site has been updated from the background', + } + return self.registration.pushManager.getSubscription().then((sub) => { + if (sub.options.userVisibleOnly) { + return self.registration.showNotification(location.host, options) + } + }) +} + +self.addEventListener('push', function(event) { + console.info('Received push message') + + if (event.data) { + const content = event.data.json() + console.log('Got ', content) + // Send the event to the last focused page only, + // show a notification with the subject if we don't have any + // active tab + event.waitUntil(self.clients.matchAll() + .then((clientList) => { + const client = clientList[0] + if (client !== undefined) { + console.debug('Sending to client ', client) + client.postMessage({ type: 'push', content }) + // Here, the user has an active tab, we don't need to show a notification from the sw + } else if (content.subject) { + console.debug('No valid client to send notif - showing bg notif') + return showBgNotification(content.subject) + } else { + console.warn('No valid client to send notif') + return silentNotification() + } + }) + .catch((err) => { + console.error("Couldn't send message: ", err) + return silentNotification() + })) + } else { + event.waitUntil(silentNotification()) + } +}) + +self.addEventListener('pushsubscriptionchange', function(event) { + console.log('Push Subscription Change', event) + event.waitUntil(self.clients.matchAll() + .then((clientList) => { + const client = clientList[0] + if (client !== undefined) { + console.debug('Sending to client ', client) + client.postMessage({ type: 'pushEndpoint' }) + } else { + console.warn('No valid client to send notif') + } + }) + .catch((err) => { + console.error("Couldn't send message: ", err) + })) +}) + +self.addEventListener('registration', function() { + console.log('Registered') +}) + +self.addEventListener('install', function(event) { + console.log('Installed') + // Replace currenctly active serviceWorkers with this one + event.waitUntil(self.skipWaiting()) +}) + +self.addEventListener('activate', function(event) { + console.log('Activated') + // Ensure we control the clients + event.waitUntil(self.clients.claim()) +}) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 52e9e75bd..f133fd0ec 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -101,6 +101,9 @@ import { setCurrentTabAsActive, } from './services/notificationsService.js' import { createWebNotification } from './services/webNotificationsService.js' +import { + setWebPush, +} from './services/webPushService.js' const sessionKeepAlive = loadState('core', 'config', { session_keepalive: true }).session_keepalive const hasThrottledPushNotifications = loadState('notifications', 'throttled_push_notifications') @@ -232,8 +235,19 @@ export default { this.hasNotifyPush = true } - // Set up the background checker - this._setPollingInterval(this.pollIntervalBase) + // set the polling interval after checking web push status + // if web push may be configured + if (this.webNotificationsGranted === true) { + // We dont fetch on push if notify_push is enabled, to avoid concurrency fetch. + // We could do the other way: fallback to notify_push only if we don't have + // web push + window.addEventListener('load', () => { + this._setWebPush() + }) + } else { + // Set up the background checker + this._setPollingInterval(this.pollIntervalBase) + } this._watchTabVisibility() subscribe('networkOffline', this.handleNetworkOffline) @@ -256,8 +270,38 @@ export default { } }, + async _setWebPush() { + return setWebPush( + // onActivated= + (hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + // Set polling interval for the default or new value + this._setPollingInterval(this.pollIntervalBase) + }, + // onPush= + () => { + if (!this.hasNotifyPush) { + this._fetchAfterWebPush() + } else { + console.debug('Has notify_push, no need to fetch from web push.') + } + }, + ) + }, + async onOpen() { - this.requestWebNotificationPermissions() + if (this.webNotificationsGranted === null) { + this.requestWebNotificationPermissions() + .then((granted) => { + if (granted) { + this._setWebPutsh() + } + }) + } await setCurrentTabAsActive(this.tabId) await this._fetch() @@ -347,13 +391,26 @@ export default { /** * Performs the AJAX request to retrieve the notifications + * + * Unlike with notify_push, we don't have to check if we are the last tab before + * fetching events. The service worker sends the event to only one tab, that may + * not be the last one */ - async _fetch() { + _fetchAfterWebPush() { + this.backgroundFetching = true + console.debug('Refreshing notifications following web push event') + this._fetch(true) + }, + + /** + * Performs the AJAX request to retrieve the notifications + */ + async _fetch(force = false) { if (this.notifications.length && this.notifications[0].notificationId > this.webNotificationsThresholdId) { this.webNotificationsThresholdId = this.notifications[0].notificationId } - const response = await getNotificationsData(this.tabId, this.lastETag, !this.backgroundFetching, this.hasNotifyPush) + const response = await getNotificationsData(this.tabId, this.lastETag, force || !this.backgroundFetching, this.hasNotifyPush) if (response.status === 204) { // 204 No Content - Intercept when no notifiers are there. @@ -457,7 +514,7 @@ export default { return } - if (window.location.protocol === 'http:') { + if (!window.isSecureContext) { console.debug('Notifications require HTTPS') this.webNotificationsGranted = false return @@ -472,13 +529,13 @@ export default { */ async requestWebNotificationPermissions() { if (this.webNotificationsGranted !== null) { - return + return new Promise((resolve) => resolve(this.webNotificationsGranted)) } console.info('Requesting notifications permissions') - window.Notification.requestPermission() + return window.Notification.requestPermission() .then((permissions) => { - this.webNotificationsGranted = permissions === 'granted' + return this.webNotificationsGranted = permissions === 'granted' }) }, diff --git a/src/services/webPushService.js b/src/services/webPushService.js new file mode 100644 index 000000000..8f70b2a82 --- /dev/null +++ b/src/services/webPushService.js @@ -0,0 +1,138 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import axios from '@nextcloud/axios' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' + +/** + * Load service worker + */ +function loadServiceWorker() { + return navigator.serviceWorker.register( + generateUrl('/apps/notifications/service-worker.js', {}, { noRewrite: true }), + { scope: '/' }, + ).then((registration) => { + console.info('ServiceWorker registered') + return registration + }) +} +/** + * Set Push notification Listener + * + * @param {ServiceWorkerRegistration} registration current SW registration + * @param {function(boolean)} onActivated called when we send activation token + * @param {function()} onPush called when we receive a push notification + */ +function listenForPush(registration, onActivated, onPush) { + navigator.serviceWorker.addEventListener('message', (event) => { + console.debug('Received from serviceWorker: ', JSON.stringify(event.data)) + if (event.data.type === 'push') { + const activationToken = event.data.content.activationToken + if (activationToken) { + const form = new FormData() + form.append('activationToken', activationToken) + axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) + .then((response) => { + onActivated(response.status === 200 || response.status === 202) + }) + } else { + onPush() + } + } else if (event.data.type === 'pushEndpoint') { + registerPush(registration) + .catch((er) => console.error(er)) + } + }) +} + +/** + * + * @param {ServiceWorkerRegistration} registration current SW registration + */ +function registerPush(registration) { + return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) + .then((response) => response.data.ocs.data.vapid) + .then((vapid) => { + console.log('Server vapid key=' + vapid) + const options = { + applicationServerKey: vapid, + userVisibleOnly: false, + } + return registration.pushManager.getSubscription().then((sub) => { + if (sub !== null && b64UrlEncode(sub.options.applicationServerKey) !== vapid) { + console.log('VAPID key changed, unsubscribing first') + return sub.unsubscribe().then(() => { + console.log('Unsubscribed') + return registration.pushManager.subscribe(options) + .catch((er) => { + if (er.name === 'NotAllowedError') { + // if push subscription should set `userVisibleOnly` options (for Chrome) + console.log('Browser probably require `userVisibleOnly=true`') + return registration.pushManager.subscribe({ ...options, userVisibleOnly: true }) + } else { + throw er + } + }) + }) + } else { + return registration.pushManager.subscribe(options) + .catch((er) => { + if (er.name === 'NotAllowedError') { + // if push subscription should set `userVisibleOnly` options (for Chrome) + console.log('Browser probably require `userVisibleOnly=true`') + return registration.pushManager.subscribe({ ...options, userVisibleOnly: true }) + } else { + throw er + } + }) + } + }) + }).then((sub) => { + console.log(sub) + const form = new FormData() + form.append('endpoint', sub.endpoint) + form.append('uaPublicKey', b64UrlEncode(sub.getKey('p256dh'))) + form.append('auth', b64UrlEncode(sub.getKey('auth'))) + form.append('appTypes', 'all') + return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) + }) +} + +/** + * @param {function(reload)} onActivated arg=true if the push notifications has been subscribed (statusCode == 200) + * @param {function()} onPush run everytime we receive a push notification and we need to sync with the server + */ +function setWebPush(onActivated, onPush) { + if ('serviceWorker' in navigator) { + loadServiceWorker() + .then((registration) => { + listenForPush(registration, onActivated, onPush) + return registerPush(registration) + }) + .catch((er) => { + console.error(er) + onActivated(false) + }) + } else { + onActivated(false) + } +} + +/** + * + * @param {Array} inArr input to encode + */ +function b64UrlEncode(inArr) { + return new Uint8Array(inArr) + .toBase64({ alphabet: 'base64url' }) + .replaceAll('=', '') +} + +export { + b64UrlEncode, + listenForPush, + loadServiceWorker, + registerPush, + setWebPush, +} diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index 4433a85f4..c9b520167 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -26,6 +26,7 @@ use OCP\IUserSession; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Test\TestCase; class WebPushControllerTest extends TestCase { @@ -36,6 +37,7 @@ class WebPushControllerTest extends TestCase { protected IUserSession&MockObject $userSession; protected IProvider&MockObject $tokenProvider; protected Manager&MockObject $identityProof; + protected LoggerInterface&MockObject $logger; protected IUser&MockObject $user; protected WebPushController $controller; @@ -53,6 +55,7 @@ protected function setUp(): void { $this->userSession = $this->createMock(IUserSession::class); $this->tokenProvider = $this->createMock(IProvider::class); $this->identityProof = $this->createMock(Manager::class); + $this->logger = $this->createMock(LoggerInterface::class); } protected function getController(array $methods = []): WebPushController|MockObject { @@ -65,7 +68,8 @@ protected function getController(array $methods = []): WebPushController|MockObj $this->session, $this->userSession, $this->tokenProvider, - $this->identityProof + $this->identityProof, + $this->logger, ); } @@ -79,6 +83,7 @@ protected function getController(array $methods = []): WebPushController|MockObj $this->userSession, $this->tokenProvider, $this->identityProof, + $this->logger, ]) ->onlyMethods($methods) ->getMock();