diff --git a/apps/dav/appinfo/v1/caldav.php b/apps/dav/appinfo/v1/caldav.php index d1c1381be0a6e..98861fd1b2ee8 100644 --- a/apps/dav/appinfo/v1/caldav.php +++ b/apps/dav/appinfo/v1/caldav.php @@ -34,6 +34,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory as IL10NFactory; +use OCP\Mail\IMailer; use OCP\Security\Bruteforce\IThrottler; use OCP\Security\ISecureRandom; use OCP\Server; @@ -58,6 +59,8 @@ Server::get(KnownUserService::class), Server::get(IConfig::class), Server::get(IL10NFactory::class), + Server::get(IMailer::class), + Server::get(LoggerInterface::class), 'principals/' ); $db = Server::get(IDBConnection::class); diff --git a/apps/dav/appinfo/v1/carddav.php b/apps/dav/appinfo/v1/carddav.php index d883bae450bbb..3f4d77c0d30e7 100644 --- a/apps/dav/appinfo/v1/carddav.php +++ b/apps/dav/appinfo/v1/carddav.php @@ -31,6 +31,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory as IL10nFactory; +use OCP\Mail\IMailer; use OCP\Security\Bruteforce\IThrottler; use OCP\Server; use Psr\Log\LoggerInterface; @@ -55,6 +56,8 @@ Server::get(KnownUserService::class), Server::get(IConfig::class), Server::get(IL10nFactory::class), + Server::get(IMailer::class), + Server::get(LoggerInterface::class), 'principals/' ); $db = Server::get(IDBConnection::class); diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index 52f45f8aa31e4..927053cd62779 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -2,6 +2,7 @@ /** * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-only */ @@ -214,6 +215,36 @@ public function getChild($name) { throw new NotFound('Node with name \'' . $name . '\' could not be found'); } + /** + * @inheritdoc + * + * Extends the default ACL to grant proxy principals access to list this + * calendar home. Individual Calendar objects already have their own proxy + * ACL entries; this entry allows the PROPFIND on the home collection itself. + * + * @return array + */ + public function getACL(): array { + $acl = parent::getACL(); + $ownerPrincipal = $this->principalInfo['uri']; + + // Write-proxy delegates may list and read the calendar home so they can + // discover which calendars are available. + $acl[] = [ + 'privilege' => '{DAV:}read', + 'principal' => $ownerPrincipal . '/calendar-proxy-write', + 'protected' => true, + ]; + // Read-proxy delegates may also list the calendar home. + $acl[] = [ + 'privilege' => '{DAV:}read', + 'principal' => $ownerPrincipal . '/calendar-proxy-read', + 'protected' => true, + ]; + + return $acl; + } + /** * @param array $filters * @param integer|null $limit diff --git a/apps/dav/lib/CalDAV/Plugin.php b/apps/dav/lib/CalDAV/Plugin.php index 24448ae71ab3c..f4d14f2d269da 100644 --- a/apps/dav/lib/CalDAV/Plugin.php +++ b/apps/dav/lib/CalDAV/Plugin.php @@ -17,10 +17,20 @@ class Plugin extends \Sabre\CalDAV\Plugin { * This function should return null in case a principal did not have * a calendar home. * + * For calendar-proxy group principals (e.g. principals/users/alice/calendar-proxy-write), + * this returns the calendar home of the principal owner (alice), so that CalDAV clients + * can discover and access the delegated calendar home correctly. + * * @param string $principalUrl * @return string|null */ public function getCalendarHomeForPrincipal($principalUrl) { + // calendar-proxy group principals must resolve to the owner's calendar home + if (str_ends_with($principalUrl, '/calendar-proxy-write') || str_ends_with($principalUrl, '/calendar-proxy-read')) { + $ownerPrincipalUrl = substr($principalUrl, 0, strrpos($principalUrl, '/')); + return $this->getCalendarHomeForPrincipal($ownerPrincipalUrl); + } + if (strrpos($principalUrl, 'principals/users', -strlen($principalUrl)) !== false) { [, $principalId] = \Sabre\Uri\split($principalUrl); return self::CALENDAR_ROOT . '/' . $principalId; diff --git a/apps/dav/lib/Command/CreateCalendar.php b/apps/dav/lib/Command/CreateCalendar.php index c48db3bb65cec..d23d7a425bde1 100644 --- a/apps/dav/lib/Command/CreateCalendar.php +++ b/apps/dav/lib/Command/CreateCalendar.php @@ -23,6 +23,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\IMailer; use OCP\Security\ISecureRandom; use OCP\Server; use Psr\Log\LoggerInterface; @@ -68,6 +69,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int Server::get(KnownUserService::class), Server::get(IConfig::class), Server::get(IFactory::class), + Server::get(IMailer::class), + Server::get(LoggerInterface::class), ); $random = Server::get(ISecureRandom::class); $logger = Server::get(LoggerInterface::class); diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index e0abc82c6cc4d..1b6387162ce62 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -2,6 +2,7 @@ /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-only */ @@ -25,8 +26,10 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\IMailer; use OCP\Share\IManager as IShareManager; use Psr\Container\ContainerExceptionInterface; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception; use Sabre\DAV\PropPatch; use Sabre\DAVACL\PrincipalBackend\BackendInterface; @@ -53,6 +56,8 @@ public function __construct( private KnownUserService $knownUserService, private IConfig $config, private IFactory $languageFactory, + private IMailer $mailer, + private LoggerInterface $logger, string $principalPrefix = 'principals/users/', ) { $this->principalPrefix = trim($principalPrefix, '/'); @@ -61,6 +66,7 @@ public function __construct( use PrincipalProxyTrait { getGroupMembership as protected traitGetGroupMembership; + setGroupMemberSet as protected traitSetGroupMemberSet; } /** @@ -124,6 +130,20 @@ public function getPrincipalPropertiesByPath($path, ?array $propertyFilter = nul if ($user !== null) { return [ 'uri' => 'principals/users/' . $user->getUID() . '/' . $name, + // Only the principal owner may modify their own proxy group. + // Any authenticated user may read it (needed by Sabre internals). + '{DAV:}acl' => [ + [ + 'privilege' => '{DAV:}read', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}write', + 'principal' => 'principals/users/' . $user->getUID(), + 'protected' => true, + ], + ], ]; } return null; @@ -222,6 +242,89 @@ public function updatePrincipal($path, PropPatch $propPatch) { return 0; } + /** + * Updates the list of group members for a group principal. + * + * Overrides the trait implementation to send email notifications when new + * write-proxy delegates are added. + * + * @param string $principal + * @param string[] $members + */ + public function setGroupMemberSet($principal, array $members): void { + [$principalUri, $target] = \Sabre\Uri\split($principal); + + // Snapshot the current write-proxy members before applying changes so + // we can diff and notify only the newly added ones. + $oldMemberUids = []; + if ($target === 'calendar-proxy-write') { + $oldProxies = $this->proxyMapper->getProxiesOf($principalUri); + foreach ($oldProxies as $proxy) { + if ($proxy->getPermissions() === (ProxyMapper::PERMISSION_READ | ProxyMapper::PERMISSION_WRITE)) { + $oldMemberUids[] = $proxy->getProxyId(); + } + } + } + + // Apply the new member set via the trait implementation. + $this->traitSetGroupMemberSet($principal, $members); + + // Notify newly added write-proxy delegates. + if ($target === 'calendar-proxy-write') { + [, $ownerUid] = \Sabre\Uri\split($principalUri); + $addedMembers = array_diff($members, $oldMemberUids); + foreach ($addedMembers as $memberUri) { + [, $delegateUid] = \Sabre\Uri\split($memberUri); + $this->sendDelegationNotification($ownerUid, $delegateUid); + } + } + } + + /** + * Send an email to a newly added delegate informing them of the delegation. + * + * @param string $ownerUid User ID of the calendar owner who granted access + * @param string $delegateUid User ID of the user who was just granted access + */ + private function sendDelegationNotification(string $ownerUid, string $delegateUid): void { + $delegateUser = $this->userManager->get($delegateUid); + $ownerUser = $this->userManager->get($ownerUid); + + if ($delegateUser === null || $ownerUser === null) { + return; + } + + $delegateEmail = $delegateUser->getEMailAddress(); + if ($delegateEmail === null || $delegateEmail === '') { + return; // No email address on file — skip silently. + } + + $l = $this->languageFactory->get('dav'); + + $ownerDisplayName = $ownerUser->getDisplayName() ?: $ownerUid; + $delegateDisplayName = $delegateUser->getDisplayName() ?: $delegateUid; + + $subject = $l->t('%s has granted you access to their calendars', [$ownerDisplayName]); + $bodyText = $l->t( + "Hello %1\$s,\n\n%2\$s has added you as a calendar delegate. You can now view and manage their\ncalendars in the Nextcloud Calendar app under the \"Delegated\" section.\n\nTo remove yourself as a delegate, ask %2\$s to revoke your access in their\nCalendar settings.", + [$delegateDisplayName, $ownerDisplayName] + ); + + try { + $message = $this->mailer->createMessage(); + $message->setTo([$delegateEmail => $delegateDisplayName]); + $message->setSubject($subject); + $message->setPlainBody($bodyText); + $this->mailer->send($message); + } catch (\Exception $e) { + // Notification failure must never block the PROPPATCH response. + $this->logger->warning( + 'Could not send delegation notification email', + ['owner' => $ownerUid, 'delegate' => $delegateUid, 'error' => $e->getMessage()] + ); + } + } + /** * Search user principals * diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 4226cc9579604..8f072fc60afdf 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -38,6 +38,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\IMailer; use OCP\SabrePluginEvent; use OCP\SystemTag\ISystemTagManager; use OCP\SystemTag\ISystemTagObjectMapper; @@ -258,6 +259,8 @@ private function initRootCollection(SimpleCollection $rootCollection, Directory| \OCP\Server::get(KnownUserService::class), \OCP\Server::get(IConfig::class), \OCP\Server::get(IFactory::class), + \OCP\Server::get(IMailer::class), + $this->logger, ); // Mount the share collection at /public.php/dav/files/ diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php index 08e945f47ad37..b5fa7c5eb1135 100644 --- a/apps/dav/lib/RootCollection.php +++ b/apps/dav/lib/RootCollection.php @@ -43,6 +43,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\IMailer; use OCP\Security\ISecureRandom; use OCP\Server; use OCP\SystemTag\ISystemTagManager; @@ -76,7 +77,9 @@ public function __construct() { $proxyMapper, Server::get(KnownUserService::class), Server::get(IConfig::class), - Server::get(IFactory::class) + Server::get(IFactory::class), + Server::get(IMailer::class), + $logger ); $groupPrincipalBackend = new GroupPrincipalBackend($groupManager, $userSession, $shareManager, $config); diff --git a/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php b/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php index e515e6fbea05a..c985769337ada 100644 --- a/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php +++ b/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php @@ -430,4 +430,38 @@ public function testGetChildrenFederatedCalendars(): void { $this->assertInstanceOf(FederatedCalendar::class, $actual[3]); $this->assertInstanceOf(FederatedCalendar::class, $actual[4]); } + + public function testGetAclContainsProxyWritePrincipal(): void { + $acl = $this->calendarHome->getACL(); + + $principals = array_column($acl, 'principal'); + $this->assertContains('user-principal-123/calendar-proxy-write', $principals); + + // Find the specific entry and verify all fields + foreach ($acl as $entry) { + if ($entry['principal'] === 'user-principal-123/calendar-proxy-write') { + $this->assertSame('{DAV:}read', $entry['privilege']); + $this->assertTrue($entry['protected']); + return; + } + } + $this->fail('ACL entry for calendar-proxy-write not found'); + } + + public function testGetAclContainsProxyReadPrincipal(): void { + $acl = $this->calendarHome->getACL(); + + $principals = array_column($acl, 'principal'); + $this->assertContains('user-principal-123/calendar-proxy-read', $principals); + + // Find the specific entry and verify all fields + foreach ($acl as $entry) { + if ($entry['principal'] === 'user-principal-123/calendar-proxy-read') { + $this->assertSame('{DAV:}read', $entry['privilege']); + $this->assertTrue($entry['protected']); + return; + } + } + $this->fail('ACL entry for calendar-proxy-read not found'); + } } diff --git a/apps/dav/tests/unit/CalDAV/PluginTest.php b/apps/dav/tests/unit/CalDAV/PluginTest.php index a9e868c688f4a..84dc839b395fb 100644 --- a/apps/dav/tests/unit/CalDAV/PluginTest.php +++ b/apps/dav/tests/unit/CalDAV/PluginTest.php @@ -33,6 +33,17 @@ public static function linkProvider(): array { 'principals/calendar-rooms/Room-ABC', 'system-calendars/calendar-rooms/Room-ABC', ], + // calendar-proxy-write and calendar-proxy-read group principals must + // resolve to the owner's calendar home so that delegates can discover + // the delegated calendar home via PROPFIND on the proxy group principal. + [ + 'principals/users/alice/calendar-proxy-write', + 'calendars/alice', + ], + [ + 'principals/users/alice/calendar-proxy-read', + 'calendars/alice', + ], ]; } diff --git a/apps/dav/tests/unit/Connector/Sabre/PrincipalTest.php b/apps/dav/tests/unit/Connector/Sabre/PrincipalTest.php index 1be48b85c3b65..94e93a86c9b48 100644 --- a/apps/dav/tests/unit/Connector/Sabre/PrincipalTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/PrincipalTest.php @@ -25,8 +25,11 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\IMailer; +use OCP\Mail\IMessage; use OCP\Share\IManager; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception; use Sabre\DAV\PropPatch; use Test\TestCase; @@ -42,6 +45,8 @@ class PrincipalTest extends TestCase { private KnownUserService&MockObject $knownUserService; private IConfig&MockObject $config; private IFactory&MockObject $languageFactory; + private IMailer&MockObject $mailer; + private LoggerInterface&MockObject $logger; private Principal $connector; protected function setUp(): void { @@ -57,6 +62,8 @@ protected function setUp(): void { $this->knownUserService = $this->createMock(KnownUserService::class); $this->config = $this->createMock(IConfig::class); $this->languageFactory = $this->createMock(IFactory::class); + $this->mailer = $this->createMock(IMailer::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->connector = new Principal( $this->userManager, @@ -68,7 +75,9 @@ protected function setUp(): void { $this->proxyMapper, $this->knownUserService, $this->config, - $this->languageFactory + $this->languageFactory, + $this->mailer, + $this->logger ); } @@ -934,4 +943,244 @@ public function testGetEmailAddressesOfPrincipal(): void { $actual = $this->connector->getEmailAddressesOfPrincipal($principal); $this->assertEquals($expected, $actual); } + + public function testGetProxyPrincipalHasAcl(): void { + $aliceUser = $this->createMock(IUser::class); + $aliceUser->method('getUID')->willReturn('alice'); + + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($aliceUser); + + $result = $this->connector->getPrincipalByPath('principals/users/alice/calendar-proxy-write'); + + $this->assertNotNull($result); + $this->assertArrayHasKey('{DAV:}acl', $result); + + $acl = $result['{DAV:}acl']; + $this->assertCount(2, $acl); + + // First entry: any authenticated user may read + $this->assertSame('{DAV:}read', $acl[0]['privilege']); + $this->assertSame('{DAV:}authenticated', $acl[0]['principal']); + $this->assertTrue($acl[0]['protected']); + + // Second entry: only the owner may write + $this->assertSame('{DAV:}write', $acl[1]['privilege']); + $this->assertSame('principals/users/alice', $acl[1]['principal']); + $this->assertTrue($acl[1]['protected']); + } + + public function testGetProxyReadPrincipalHasAcl(): void { + $aliceUser = $this->createMock(IUser::class); + $aliceUser->method('getUID')->willReturn('alice'); + + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('alice') + ->willReturn($aliceUser); + + $result = $this->connector->getPrincipalByPath('principals/users/alice/calendar-proxy-read'); + + $this->assertNotNull($result); + $this->assertArrayHasKey('{DAV:}acl', $result); + $this->assertCount(2, $result['{DAV:}acl']); + } + + public function testDelegationNotificationEmailIsSentToNewDelegate(): void { + $ownerUser = $this->createMock(IUser::class); + $ownerUser->method('getUID')->willReturn('alice'); + $ownerUser->method('getDisplayName')->willReturn('Alice'); + + $delegateUser = $this->createMock(IUser::class); + $delegateUser->method('getUID')->willReturn('bob'); + $delegateUser->method('getDisplayName')->willReturn('Bob'); + $delegateUser->method('getEMailAddress')->willReturn('bob@example.com'); + + $this->userManager->method('get') + ->willReturnMap([ + ['alice', $ownerUser], + ['bob', $delegateUser], + ]); + + // accountManager is called during userToPrincipal for both alice and bob + $accountPropertyCollection = $this->createMock(IAccountPropertyCollection::class); + $accountPropertyCollection->method('getProperties')->willReturn([]); + $account = $this->createMock(IAccount::class); + $account->method('getPropertyCollection')->willReturn($accountPropertyCollection); + $this->accountManager->method('getAccount')->willReturn($account); + + // No existing proxies for alice + $this->proxyMapper->method('getProxiesOf') + ->with('principals/users/alice') + ->willReturn([]); + + $this->proxyMapper->method('insert')->willReturnArgument(0); + + $l10n = $this->createMock(\OCP\IL10N::class); + $l10n->method('t')->willReturnCallback(function (string $text, array $params = []) { + return vsprintf($text, $params); + }); + $this->languageFactory->method('get')->with('dav')->willReturn($l10n); + + $message = $this->createMock(IMessage::class); + $message->method('setTo')->willReturnSelf(); + $message->method('setSubject')->willReturnSelf(); + $message->method('setPlainBody')->willReturnSelf(); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $this->connector->setGroupMemberSet( + 'principals/users/alice/calendar-proxy-write', + ['principals/users/bob'] + ); + } + + public function testDelegationNotificationNotSentForExistingDelegate(): void { + $ownerUser = $this->createMock(IUser::class); + $ownerUser->method('getUID')->willReturn('alice'); + $ownerUser->method('getDisplayName')->willReturn('Alice'); + + $delegateUser = $this->createMock(IUser::class); + $delegateUser->method('getUID')->willReturn('bob'); + $delegateUser->method('getDisplayName')->willReturn('Bob'); + $delegateUser->method('getEMailAddress')->willReturn('bob@example.com'); + + $this->userManager->method('get') + ->willReturnMap([ + ['alice', $ownerUser], + ['bob', $delegateUser], + ]); + + // accountManager is called during userToPrincipal + $accountPropertyCollection = $this->createMock(IAccountPropertyCollection::class); + $accountPropertyCollection->method('getProperties')->willReturn([]); + $account = $this->createMock(IAccount::class); + $account->method('getPropertyCollection')->willReturn($accountPropertyCollection); + $this->accountManager->method('getAccount')->willReturn($account); + + // Bob is already a write proxy for alice + $existingProxy = new Proxy(); + $existingProxy->setOwnerId('principals/users/alice'); + $existingProxy->setProxyId('principals/users/bob'); + $existingProxy->setPermissions(ProxyMapper::PERMISSION_READ | ProxyMapper::PERMISSION_WRITE); + + $this->proxyMapper->method('getProxiesOf') + ->with('principals/users/alice') + ->willReturn([$existingProxy]); + + $this->proxyMapper->method('update')->willReturnArgument(0); + + // No email should be sent since bob is already a delegate + $this->mailer->expects($this->never())->method('createMessage'); + $this->mailer->expects($this->never())->method('send'); + + $this->connector->setGroupMemberSet( + 'principals/users/alice/calendar-proxy-write', + ['principals/users/bob'] + ); + } + + public function testDelegationNotificationSkippedWhenNoEmail(): void { + $ownerUser = $this->createMock(IUser::class); + $ownerUser->method('getUID')->willReturn('alice'); + $ownerUser->method('getDisplayName')->willReturn('Alice'); + + $delegateUser = $this->createMock(IUser::class); + $delegateUser->method('getUID')->willReturn('bob'); + $delegateUser->method('getDisplayName')->willReturn('Bob'); + $delegateUser->method('getEMailAddress')->willReturn(null); + + $this->userManager->method('get') + ->willReturnMap([ + ['alice', $ownerUser], + ['bob', $delegateUser], + ]); + + // accountManager is called during userToPrincipal + $accountPropertyCollection = $this->createMock(IAccountPropertyCollection::class); + $accountPropertyCollection->method('getProperties')->willReturn([]); + $account = $this->createMock(IAccount::class); + $account->method('getPropertyCollection')->willReturn($accountPropertyCollection); + $this->accountManager->method('getAccount')->willReturn($account); + + $this->proxyMapper->method('getProxiesOf') + ->with('principals/users/alice') + ->willReturn([]); + + $this->proxyMapper->method('insert')->willReturnArgument(0); + + // No email should be sent since bob has no email address + $this->mailer->expects($this->never())->method('createMessage'); + $this->mailer->expects($this->never())->method('send'); + + $this->connector->setGroupMemberSet( + 'principals/users/alice/calendar-proxy-write', + ['principals/users/bob'] + ); + } + + public function testDelegationNotificationFailureDoesNotBlockProppatch(): void { + $ownerUser = $this->createMock(IUser::class); + $ownerUser->method('getUID')->willReturn('alice'); + $ownerUser->method('getDisplayName')->willReturn('Alice'); + + $delegateUser = $this->createMock(IUser::class); + $delegateUser->method('getUID')->willReturn('bob'); + $delegateUser->method('getDisplayName')->willReturn('Bob'); + $delegateUser->method('getEMailAddress')->willReturn('bob@example.com'); + + $this->userManager->method('get') + ->willReturnMap([ + ['alice', $ownerUser], + ['bob', $delegateUser], + ]); + + // accountManager is called during userToPrincipal + $accountPropertyCollection = $this->createMock(IAccountPropertyCollection::class); + $accountPropertyCollection->method('getProperties')->willReturn([]); + $account = $this->createMock(IAccount::class); + $account->method('getPropertyCollection')->willReturn($accountPropertyCollection); + $this->accountManager->method('getAccount')->willReturn($account); + + $this->proxyMapper->method('getProxiesOf') + ->with('principals/users/alice') + ->willReturn([]); + + $this->proxyMapper->expects($this->once()) + ->method('insert'); + + $l10n = $this->createMock(\OCP\IL10N::class); + $l10n->method('t')->willReturnCallback(function (string $text, array $params = []) { + return vsprintf($text, $params); + }); + $this->languageFactory->method('get')->with('dav')->willReturn($l10n); + + $message = $this->createMock(IMessage::class); + $message->method('setTo')->willReturnSelf(); + $message->method('setSubject')->willReturnSelf(); + $message->method('setPlainBody')->willReturnSelf(); + + $this->mailer->method('createMessage')->willReturn($message); + $this->mailer->method('send')->willThrowException(new \RuntimeException('SMTP error')); + + // Logger should record the warning + $this->logger->expects($this->once()) + ->method('warning'); + + // Must not throw — delegation itself should succeed + $this->connector->setGroupMemberSet( + 'principals/users/alice/calendar-proxy-write', + ['principals/users/bob'] + ); + } } diff --git a/apps/files_versions/lib/AppInfo/Application.php b/apps/files_versions/lib/AppInfo/Application.php index 2915827641500..06fefc36c4aa4 100644 --- a/apps/files_versions/lib/AppInfo/Application.php +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -45,6 +45,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\IMailer; use OCP\Server; use OCP\Share\IManager as IShareManager; use Psr\Container\ContainerInterface; @@ -80,6 +81,8 @@ public function register(IRegistrationContext $context): void { $server->get(KnownUserService::class), $server->get(IConfig::class), $server->get(IFactory::class), + $server->get(IMailer::class), + $server->get(LoggerInterface::class), ); });