diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php index 4dae36e42..e07895782 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarMSGraphSyncController.php @@ -47,7 +47,7 @@ class RemoteCalendarMSGraphSyncController throw new BadRequestHttpException('could not decode json', $e); } - $this->messageBus->dispatch(new MSGraphChangeNotificationMessage($body)); + $this->messageBus->dispatch(new MSGraphChangeNotificationMessage($body, $userId)); return new Response('', Response::HTTP_ACCEPTED); } diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php index 78846cd83..5bda987fc 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php @@ -63,7 +63,7 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface */ private ?User $user = null; - public function getCalendar(): Calendar + public function getCalendar(): ?Calendar { return $this->calendar; } diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php new file mode 100644 index 000000000..183b26799 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/MSGraphChangeNotificationHandler.php @@ -0,0 +1,80 @@ +userRepository->find($changeNotificationMessage->getUserId()); + + if (null === $user) { + $this->logger->warning(__CLASS__ . ' notification concern non-existent user, skipping'); + + return; + } + + foreach ($changeNotificationMessage->getContent()['value'] as $notification) { + $secret = $this->mapCalendarToUser->getSubscriptionSecret($user); + + if ($secret !== ($notification['clientState'] ?? -1)) { + $this->logger->warning(__CLASS__ . ' could not validate secret, skipping'); + + continue; + } + + $remoteId = $notification['resourceData']['id']; + + // is this a calendar range ? + if (null !== $calendarRange = $this->calendarRangeRepository->findOneBy(['remoteId' => $remoteId])) { + $this->calendarRangeSyncer->handleCalendarRangeSync($calendarRange, $notification, $user); + $this->em->flush(); + } elseif (null !== $calendar = $this->calendarRepository->findOneBy(['remoteId' => $remoteId])) { + $this->remoteToLocalSyncer->handleCalendarSync($calendar, $notification, $user); + $this->em->flush(); + } elseif (null !== $invite = $this->inviteRepository->findOneBy(['remoteId' => $remoteId])) { + $this->remoteToLocalSyncer->handleInviteSync($invite, $notification, $user); + $this->em->flush(); + } + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php index 5fbc2790b..f5e0f98cb 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/MSGraphChangeNotificationMessage.php @@ -13,15 +13,23 @@ namespace Chill\CalendarBundle\Messenger\Message; class MSGraphChangeNotificationMessage { - private array $content = []; + private array $content; - public function __construct(array $content) + private int $userId; + + public function __construct(array $content, int $userId) { $this->content = $content; + $this->userId = $userId; } public function getContent(): array { return $this->content; } + + public function getUserId(): int + { + return $this->userId; + } } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php index 680aaa0a7..32a4cdf01 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MachineHttpClient.php @@ -34,6 +34,10 @@ class MachineHttpClient implements HttpClientInterface $this->machineTokenStorage = $machineTokenStorage; } + /** + * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface + * @throws LogicException if method is not supported + */ public function request(string $method, string $url, array $options = []): ResponseInterface { $options['headers'] = array_merge( diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php index 26eb0c94f..f1d7e883f 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MapCalendarToUser.php @@ -73,6 +73,19 @@ class MapCalendarToUser return $value[0] ?? null; } + public function getSubscriptionSecret(User $user): string + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + throw new LogicException('do not contains msgraph metadata'); + } + + if (!array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY])) { + throw new LogicException('do not contains secret in msgraph'); + } + + return $user->getAttributes()[self::METADATA_KEY][self::SECRET_SUBSCRIPTION_EVENT]; + } + public function getUserByEmail(string $email): ?array { $value = $this->machineHttpClient->request('GET', 'users', [ @@ -105,6 +118,15 @@ class MapCalendarToUser >= (new DateTimeImmutable('now'))->getTimestamp(); } + public function hasSubscriptionSecret(User $user): bool + { + if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { + return false; + } + + return array_key_exists(self::SECRET_SUBSCRIPTION_EVENT, $user->getAttributes()[self::METADATA_KEY]); + } + public function hasUserId(User $user): bool { if (!array_key_exists(self::METADATA_KEY, $user->getAttributes())) { diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php index afd68cdb4..e87952308 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteEventConverter.php @@ -19,6 +19,7 @@ use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Templating\Entity\PersonRenderInterface; use DateTimeImmutable; use DateTimeZone; +use RuntimeException; use Symfony\Component\Templating\EngineInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -28,10 +29,16 @@ use Symfony\Contracts\Translation\TranslatorInterface; */ class RemoteEventConverter { + /** + * valid when the remote string contains also a timezone, like in + * lastModifiedDate + */ public const REMOTE_DATETIMEZONE_FORMAT = 'Y-m-d\\TH:i:s.u?P'; private const REMOTE_DATE_FORMAT = 'Y-m-d\TH:i:s.u0'; + private const REMOTE_DATETIME_WITHOUT_TZ_FORMAT = 'Y-m-d\TH:i:s.u?'; + private DateTimeZone $defaultDateTimeZone; private EngineInterface $engine; @@ -147,6 +154,34 @@ class RemoteEventConverter ); } + public static function convertStringDateWithoutTimezone(string $date): DateTimeImmutable + { + $d = DateTimeImmutable::createFromFormat( + self::REMOTE_DATETIME_WITHOUT_TZ_FORMAT, + $date, + self::getRemoteTimeZone() + ); + + if (false === $d) { + throw new RuntimeException("could not convert string date to datetime: {$date}"); + } + + return $d->setTimezone((new DateTimeImmutable())->getTimezone()); + } + + public static function convertStringDateWithTimezone(string $date): DateTimeImmutable + { + $d = DateTimeImmutable::createFromFormat(self::REMOTE_DATETIMEZONE_FORMAT, $date); + + if (false === $d) { + throw new RuntimeException("could not convert string date to datetime: {$date}"); + } + + $d->setTimezone((new DateTimeImmutable())->getTimezone()); + + return $d; + } + public function convertToRemote(array $event): RemoteEvent { $startDate = diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php new file mode 100644 index 000000000..29d06784c --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/RemoteToLocalSync/CalendarRangeSyncer.php @@ -0,0 +1,97 @@ +em = $em; + $this->logger = $logger; + $this->machineHttpClient = $machineHttpClient; + } + + + public function handleCalendarRangeSync(CalendarRange $calendarRange, array $notification, User $user): void + { + switch ($notification['changeType']) { + case 'deleted': + // test if the notification is not linked to a Calendar + if (null !== $calendarRange->getCalendar()) { + return; + } + + $this->logger->info(__CLASS__ . ' remove a calendar range because deleted on remote calendar'); + $this->em->remove($calendarRange); + + break; + + case 'updated': + try { + $new = $this->machineHttpClient->request( + 'GET', + 'v1.0/' . $notification['resource'] + )->toArray(); + } catch (ClientExceptionInterface $clientException) { + $this->logger->warning(__CLASS__ . ' could not retrieve event from ms graph. Already deleted ?', [ + 'calendarRangeId' => $calendarRange->getId(), + 'remoteEventId' => $notification['resource'], + ]); + } + + $lastModified = RemoteEventConverter::convertStringDateWithTimezone($new['lastModifiedDateTime']); + + if ($calendarRange->getRemoteAttributes()['lastModifiedDateTime'] === $lastModified->getTimestamp()) { + $this->logger->info(__CLASS__ . ' change key is equals. Source is probably a local update', [ + 'calendarRangeId' => $calendarRange->getId(), + 'remoteEventId' => $notification['resource'], + ]); + + return; + } + + $startDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['start']['dateTime']); + $endDate = RemoteEventConverter::convertStringDateWithoutTimezone($new['end']['dateTime']); + + $calendarRange + ->setStartDate($startDate)->setEndDate($endDate) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => $lastModified->getTimestamp(), + 'changeKey' => $new['changeKey'], + ]); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php index ce8078228..df10751ac 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -14,12 +14,15 @@ namespace Chill\CalendarBundle\RemoteCalendar\DependencyInjection; use Chill\CalendarBundle\Command\AzureGrantAdminConsentAndAcquireToken; use Chill\CalendarBundle\Command\MapAndSubscribeUserCalendarCommand; use Chill\CalendarBundle\Controller\RemoteCalendarConnectAzureController; +use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient; +use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineTokenStorage; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector; use Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; use RuntimeException; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Contracts\HttpClient\HttpClientInterface; use TheNetworg\OAuth2\Client\Provider\Azure; class RemoteCalendarCompilerPass implements CompilerPassInterface @@ -30,16 +33,21 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface $connector = null; if (!$config['remote_calendars_sync']['enabled']) { - $connector = MSGraphRemoteCalendarConnector::class; + $connector = NullRemoteCalendarConnector::class; } if ($config['remote_calendars_sync']['microsoft_graph']['enabled']) { $connector = MSGraphRemoteCalendarConnector::class; + + $container->setAlias(HttpClientInterface::class.' $machineHttpClient', MachineHttpClient::class); } else { // remove services which cannot be loaded $container->removeDefinition(MapAndSubscribeUserCalendarCommand::class); $container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class); $container->removeDefinition(RemoteCalendarConnectAzureController::class); + $container->removeDefinition(MachineTokenStorage::class); + $container->removeDefinition(MachineHttpClient::class); + $container->removeDefinition(MSGraphRemoteCalendarConnector::class); } if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) { @@ -58,7 +66,9 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface ->setDecoratedService(RemoteCalendarConnectorInterface::class); } else { // keep the container lighter by removing definitions - $container->removeDefinition($serviceId); + if ($container->hasDefinition($serviceId)) { + $container->removeDefinition($serviceId); + } } } } diff --git a/src/Bundle/ChillCalendarBundle/Tests/Controller/RemoteCalendarMSGraphSyncControllerTest.php b/src/Bundle/ChillCalendarBundle/Tests/Controller/RemoteCalendarMSGraphSyncControllerTest.php new file mode 100644 index 000000000..39b414426 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Controller/RemoteCalendarMSGraphSyncControllerTest.php @@ -0,0 +1,85 @@ +request( + 'POST', + '/public/incoming-hook/calendar/msgraph/events/23', + [], + [], + [], + self::SAMPLE_BODY + ); + + $this->assertResponseIsSuccessful(); + $this->assertResponseStatusCodeSame(202); + + /* @var InMemoryTransport $transport */ + $transport = self::$container->get('messenger.transport.async'); + $this->assertCount(1, $transport->getSent()); + } + + public function testValidateSubscription(): void + { + $client = self::createClient(); + $client->request( + 'POST', + '/public/incoming-hook/calendar/msgraph/events/23?validationToken=something%20to%20decode' + ); + + $this->assertResponseIsSuccessful(); + + $response = $client->getResponse(); + + $this->assertResponseHasHeader('Content-Type'); + $this->assertStringContainsString('text/plain', $response->headers->get('Content-Type')); + $this->assertEquals('something to decode', $response->getContent()); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php new file mode 100644 index 000000000..ba6938821 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/CalendarRangeSyncerTest.php @@ -0,0 +1,228 @@ +prophesize(EntityManagerInterface::class); + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]) + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()) + ->setStartDate(new \DateTimeImmutable('2020-01-01 15:00:00')) + ->setEndDate(new \DateTimeImmutable('2020-01-01 15:30:00')) + ->addRemoteAttributes([ + 'lastModifiedDateTime' => 0, + 'changeKey' => 'abc' + ]); + $notification = json_decode(self::NOTIF_UPDATE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user); + + $this->assertStringContainsString('2022-06-10T15:30:00', + $calendarRange->getStartDate()->format(\DateTimeImmutable::ATOM)); + $this->assertStringContainsString('2022-06-10T17:30:00', + $calendarRange->getEndDate()->format(\DateTimeImmutable::ATOM)); + } + + public function testDeleteCalendarRangeWithoutAssociation(): void + { + $em = $this->prophesize(EntityManagerInterface::class); + $em->remove(Argument::type(CalendarRange::class))->shouldBeCalled(); + + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]) + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()) + ; + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user); + } + + public function testDeleteCalendarRangeWithAssociation(): void + { + $em = $this->prophesize(EntityManagerInterface::class); + $em->remove(Argument::type(CalendarRange::class))->shouldNotBeCalled(); + + $machineHttpClient = new MockHttpClient([ + new MockResponse(self::REMOTE_CALENDAR_RANGE, ['http_code' => 200]) + ]); + + $calendarRangeSyncer = new CalendarRangeSyncer( + $em->reveal(), + new NullLogger(), + $machineHttpClient + ); + + $calendarRange = new CalendarRange(); + $calendarRange + ->setUser($user = new User()) + ; + $calendar = new Calendar(); + $calendar->setCalendarRange($calendarRange); + + $notification = json_decode(self::NOTIF_DELETE, true); + + $calendarRangeSyncer->handleCalendarRangeSync( + $calendarRange, + $notification['value'][0], + $user); + } +} diff --git a/tests/app b/tests/app index 8694ad7c4..5b35e7ccd 160000 --- a/tests/app +++ b/tests/app @@ -1 +1 @@ -Subproject commit 8694ad7c4de306f9d5e35af966d24fb2e3e1c2ff +Subproject commit 5b35e7ccd0735e5593835e28acbf82386c18e1b6