diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1d96c..889a1be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1'] + php-versions: ['8.2'] dependencies: ['latest', 'oldest'] steps: - name: Checkout @@ -63,7 +63,7 @@ jobs: run: composer validate --no-check-all --no-check-lock --strict; - name: Linting all php files run: find src/ tests/ public/ -name ''*.php'' -print0 | xargs -0 -n 1 -P 4 php -l; php -l; - - name: Running unit tests + - name: Running unit tests run: vendor/bin/phpunit tests/Unit/; continue-on-error: ${{matrix.php-versions == '8.0' }} # [temp-php8] - name: Running the integration tests diff --git a/.github/workflows/client-docs.yml b/.github/workflows/client-docs.yml index cbf6eea..b4867be 100644 --- a/.github/workflows/client-docs.yml +++ b/.github/workflows/client-docs.yml @@ -31,7 +31,7 @@ jobs: - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Cache Composer Dependencies diff --git a/.github/workflows/front-docs.yml b/.github/workflows/front-docs.yml index 0b4784c..f5903f1 100644 --- a/.github/workflows/front-docs.yml +++ b/.github/workflows/front-docs.yml @@ -38,7 +38,7 @@ jobs: - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Cache Composer Dependencies diff --git a/.github/workflows/restapi-docs.yml b/.github/workflows/restapi-docs.yml index b5ffc02..81480c0 100644 --- a/.github/workflows/restapi-docs.yml +++ b/.github/workflows/restapi-docs.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP with Composer and Extensions uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 extensions: mbstring, dom, fileinfo, mysql - name: Cache Composer Dependencies diff --git a/composer.json b/composer.json index fafee39..b9f7843 100644 --- a/composer.json +++ b/composer.json @@ -41,8 +41,8 @@ "source": "https://github.com/phpList/rest-api" }, "require": { - "php": "^8.1", - "phplist/core": "dev-main", + "php": "^8.2", + "phplist/core": "dev-dev", "elasticsearch/elasticsearch": "^8.9", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", diff --git a/src/Identity/Controller/AdminNotificationController.php b/src/Identity/Controller/AdminNotificationController.php new file mode 100644 index 0000000..63f5bc5 --- /dev/null +++ b/src/Identity/Controller/AdminNotificationController.php @@ -0,0 +1,83 @@ +requireAuthentication($request); + + $items = array_map( + fn (AdminNotification $notification): array => $this->normalizer->normalize($notification), + $this->collector->collect($administrator) + ); + + return $this->json(['items' => $items], Response::HTTP_OK); + } +} diff --git a/src/Identity/Serializer/AdminNotificationNormalizer.php b/src/Identity/Serializer/AdminNotificationNormalizer.php new file mode 100644 index 0000000..f783a93 --- /dev/null +++ b/src/Identity/Serializer/AdminNotificationNormalizer.php @@ -0,0 +1,60 @@ + $object->code, + 'level' => $object->level, + 'message' => $object->message, + // an object, so that an empty context is encoded as {} and not [] + 'context' => (object) $object->context, + ]; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof AdminNotification; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function getSupportedTypes(?string $format): array + { + return [ + AdminNotification::class => true, + ]; + } +} diff --git a/src/Messaging/Controller/AttachmentController.php b/src/Messaging/Controller/AttachmentController.php index c9754bb..50c1d5b 100644 --- a/src/Messaging/Controller/AttachmentController.php +++ b/src/Messaging/Controller/AttachmentController.php @@ -75,7 +75,7 @@ public function download( } return new StreamedResponse( - callback: function () use ($downloadable) { + function () use ($downloadable) { $stream = $downloadable->content; if ($stream->isSeekable()) { diff --git a/src/Messaging/Controller/CampaignMessageDataController.php b/src/Messaging/Controller/CampaignMessageDataController.php new file mode 100644 index 0000000..e7e693a --- /dev/null +++ b/src/Messaging/Controller/CampaignMessageDataController.php @@ -0,0 +1,128 @@ + '\d+'], + methods: ['PATCH'] + )] + #[OA\Patch( + path: '/api/v2/campaigns/{messageId}/message-data', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Sets campaign settings that are not part of the campaign itself: analytics tracking, notification ' . + 'addresses and excluded lists. Only the fields that are sent are changed.', + summary: 'Set campaign message data.', + requestBody: new OA\RequestBody( + description: 'The settings to change.', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/UpdateMessageDataRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 200, + description: 'Success, returns all the settings of the campaign', + content: new OA\JsonContent(ref: '#/components/schemas/MessageData') + ), + new OA\Response( + response: 401, + description: 'Not authenticated', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ), + ] + )] + public function updateMessageData( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, + ): JsonResponse { + $authUser = $this->requireAuthentication($request); + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + if (!$this->permissionChecker->canEdit($authUser, $message)) { + throw $this->createAccessDeniedException('You are not allowed to update campaigns.'); + } + + /** @var UpdateMessageDataRequest $messageDataRequest */ + $messageDataRequest = $this->validator->validate($request, UpdateMessageDataRequest::class); + + $this->messageDataManager->storeMessageData($message, $messageDataRequest->getDto()); + $this->entityManager->flush(); + + $messageData = new CampaignMessageData(($this->messageDataLoader)($message)); + + return $this->json($this->messageDataNormalizer->normalize($messageData), Response::HTTP_OK); + } +} diff --git a/src/Messaging/Controller/ListMessageController.php b/src/Messaging/Controller/ListMessageController.php index 191281b..c7cfce5 100644 --- a/src/Messaging/Controller/ListMessageController.php +++ b/src/Messaging/Controller/ListMessageController.php @@ -6,6 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; +use PhpList\Core\Domain\Identity\Service\PermissionChecker; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -35,6 +36,7 @@ public function __construct( private readonly SubscriberListNormalizer $subscriberListNormalizer, private readonly MessageNormalizer $messageNormalizer, private readonly EntityManagerInterface $entityManager, + private readonly PermissionChecker $permissionChecker, ) { parent::__construct($authentication, $validator); } @@ -244,7 +246,7 @@ public function associateMessageWithList( #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null, #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $subscriberList = null ): JsonResponse { - $this->requireAuthentication($request); + $authUser = $this->requireAuthentication($request); if ($message === null) { throw $this->createNotFoundException('Message not found.'); @@ -254,6 +256,12 @@ public function associateMessageWithList( throw $this->createNotFoundException('Subscriber list not found.'); } + if (!$this->permissionChecker->canEdit($authUser, $message) + || !$this->permissionChecker->canEdit($authUser, $subscriberList) + ) { + throw $this->createAccessDeniedException('Access denied.'); + } + $listMessage = $this->listMessageManager->associateMessageWithList($message, $subscriberList); $this->entityManager->flush(); diff --git a/src/Messaging/Dto/CampaignMessageData.php b/src/Messaging/Dto/CampaignMessageData.php new file mode 100644 index 0000000..a86afdb --- /dev/null +++ b/src/Messaging/Dto/CampaignMessageData.php @@ -0,0 +1,16 @@ + $values */ + public function __construct(public readonly array $values) + { + } +} diff --git a/src/Messaging/Request/UpdateMessageDataRequest.php b/src/Messaging/Request/UpdateMessageDataRequest.php new file mode 100644 index 0000000..36a42d1 --- /dev/null +++ b/src/Messaging/Request/UpdateMessageDataRequest.php @@ -0,0 +1,133 @@ + $this->notifyStart, 'notifyEnd' => $this->notifyEnd] as $property => $value) { + if ($value === null || $value === '') { + continue; + } + + foreach (array_map('trim', explode(',', $value)) as $address) { + $context->getValidator() + ->inContext($context) + ->atPath($property) + ->validate($address, [new Assert\NotBlank(), new Assert\Email()]); + } + } + } + + /** + * The settings that were sent, by their message data name. + * + * @return array> + */ + public function getDto(): array + { + return array_filter([ + 'google_track' => $this->googleTrack, + 'utm_source' => $this->utmSource, + 'utm_medium' => $this->utmMedium, + 'utm_campaign' => $this->utmCampaign, + 'pk_source' => $this->pkSource, + 'pk_medium' => $this->pkMedium, + 'pk_campaign' => $this->pkCampaign, + 'notify_start' => $this->notifyStart, + 'notify_end' => $this->notifyEnd, + // like phplist3, keyed by list id + 'excludelist' => $this->excludeLists === null + ? null + : array_combine($this->excludeLists, $this->excludeLists), + ], static fn (mixed $value): bool => $value !== null); + } +} diff --git a/src/Messaging/Serializer/MessageDataNormalizer.php b/src/Messaging/Serializer/MessageDataNormalizer.php new file mode 100644 index 0000000..353f4bf --- /dev/null +++ b/src/Messaging/Serializer/MessageDataNormalizer.php @@ -0,0 +1,77 @@ + response field => message data name */ + private const TEXT_FIELDS = [ + 'utm_source' => 'utm_source', + 'utm_medium' => 'utm_medium', + 'utm_campaign' => 'utm_campaign', + 'pk_source' => 'pk_source', + 'pk_medium' => 'pk_medium', + 'pk_campaign' => 'pk_campaign', + 'notify_start' => 'notify_start', + 'notify_end' => 'notify_end', + ]; + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function normalize($object, string $format = null, array $context = []): array + { + if (!$object instanceof CampaignMessageData) { + return []; + } + + $values = $object->values; + $result = ['google_track' => filter_var($values['google_track'] ?? false, FILTER_VALIDATE_BOOL)]; + foreach (self::TEXT_FIELDS as $field => $name) { + $result[$field] = $values[$name] ?? null; + } + $result['exclude_lists'] = array_values(array_map('intval', (array) ($values['excludelist'] ?? []))); + + return $result; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof CampaignMessageData; + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function getSupportedTypes(?string $format): array + { + return [ + CampaignMessageData::class => true, + ]; + } +} diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index 0c4607e..294c4a2 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use DateTimeInterface; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Identity\Model\AccessLevel; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Service\PermissionChecker; use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; @@ -78,8 +79,13 @@ public function buildMessageRequest(Message $message, MessageStatus $status): Up public function getMessages(Request $request, Administrator $administrator): array { + $accessLevel = $this->permissionChecker->accessLevel($administrator, Message::class); + if ($accessLevel === AccessLevel::None) { + throw new AccessDeniedException('You are not allowed to view campaigns.'); + } + $filter = (new MessageFilter()) - ->setOwner($administrator) + ->setOwner($accessLevel === AccessLevel::Owner ? $administrator : null) ->setSubject($request->query->get('subject')) ->setStatus($request->query->get('status')); diff --git a/src/Statistics/Controller/LinkTrackController.php b/src/Statistics/Controller/LinkTrackController.php new file mode 100644 index 0000000..bd40e2a --- /dev/null +++ b/src/Statistics/Controller/LinkTrackController.php @@ -0,0 +1,111 @@ +notFound(); + } + + $metadata = [ + 'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'), + 'HTTP_REFERER' => $request->server->get('HTTP_REFERER'), + 'client_ip' => $request->getClientIp(), + ]; + + try { + $url = $this->linkClickService->trackClick($token, $signature, $metadata); + } catch (Throwable $e) { + $this->logger->error('Failed to track link click', ['exception' => $e]); + + return new Response('Server error', Response::HTTP_INTERNAL_SERVER_ERROR, [ + 'Content-Type' => 'text/plain', + ]); + } + + if ($url === null) { + return $this->notFound(); + } + + try { + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error('Failed to store link click', ['exception' => $e]); + } + + $response = new RedirectResponse($url, Response::HTTP_SEE_OTHER); + $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); + + return $response; + } + + private function notFound(): Response + { + return new Response('Not found', Response::HTTP_NOT_FOUND, ['Content-Type' => 'text/plain']); + } +} diff --git a/src/Subscription/Controller/SubscriberListController.php b/src/Subscription/Controller/SubscriberListController.php index 46366df..38e24e5 100644 --- a/src/Subscription/Controller/SubscriberListController.php +++ b/src/Subscription/Controller/SubscriberListController.php @@ -6,11 +6,12 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Identity\Model\Administrator; +use PhpList\Core\Domain\Identity\Model\AccessLevel; use PhpList\Core\Domain\Messaging\Model\Filter\SubscriberListFilter; use PhpList\Core\Domain\Subscription\Model\SubscriberList; use PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager; use PhpList\Core\Domain\Identity\Service\Authentication; +use PhpList\Core\Domain\Identity\Service\PermissionChecker; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; use PhpList\RestBundle\Common\Validator\RequestValidator; @@ -39,6 +40,7 @@ public function __construct( private readonly SubscriberListManager $subscriberListManager, private readonly PaginatedDataProvider $paginatedDataProvider, private readonly EntityManagerInterface $entityManager, + private readonly PermissionChecker $permissionChecker, ) { parent::__construct($authentication, $validator); } @@ -100,12 +102,17 @@ public function getLists(Request $request): JsonResponse { $admin = $this->requireAuthentication($request); + $accessLevel = $this->permissionChecker->accessLevel($admin, SubscriberList::class); + if ($accessLevel === AccessLevel::None) { + throw $this->createAccessDeniedException('You are not allowed to view subscriber lists.'); + } + return $this->json( $this->paginatedDataProvider->getPaginatedList( request: $request, normalizer: $this->normalizer, className: SubscriberList::class, - filter: (new SubscriberListFilter())->setOwner($admin), + filter: (new SubscriberListFilter())->setOwner($accessLevel === AccessLevel::Owner ? $admin : null), ), Response::HTTP_OK ); @@ -176,7 +183,9 @@ public function getList( throw $this->createNotFoundException('Subscriber list not found.'); } - $this->denyAccessUnlessOwner($list, $authUser); + if (!$this->permissionChecker->canView($authUser, $list)) { + throw $this->createAccessDeniedException('Access denied.'); + } return $this->json($this->normalizer->normalize($list), Response::HTTP_OK); } @@ -236,7 +245,9 @@ public function deleteList( throw $this->createNotFoundException('Subscriber list not found.'); } - $this->denyAccessUnlessOwner($list, $authUser); + if (!$this->permissionChecker->canDelete($authUser, $list)) { + throw $this->createAccessDeniedException('Access denied.'); + } $this->subscriberListManager->delete($list); $this->entityManager->flush(); @@ -287,6 +298,10 @@ public function createList(Request $request): JsonResponse { $authUser = $this->requireAuthentication($request); + if (!$this->permissionChecker->canCreate($authUser, SubscriberList::class)) { + throw $this->createAccessDeniedException('You are not allowed to create subscriber lists.'); + } + /** @var CreateSubscriberListRequest $subscriberListRequest */ $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); $data = $this->subscriberListManager->createSubscriberList($subscriberListRequest->getDto(), $authUser); @@ -356,7 +371,9 @@ public function updateList( throw $this->createNotFoundException('Subscriber list not found.'); } - $this->denyAccessUnlessOwner($list, $authUser); + if (!$this->permissionChecker->canEdit($authUser, $list)) { + throw $this->createAccessDeniedException('Access denied.'); + } /** @var CreateSubscriberListRequest $subscriberListRequest */ $subscriberListRequest = $this->validator->validate($request, CreateSubscriberListRequest::class); @@ -369,17 +386,4 @@ public function updateList( return $this->json($this->normalizer->normalize($data), Response::HTTP_OK); } - - private function denyAccessUnlessOwner(SubscriberList $list, Administrator $user): void - { - if ($list->getOwner() === null) { - return; - } - - if ($list->getOwner()->getId() === $user->getId()) { - return; - } - - throw $this->createAccessDeniedException('Access denied.'); - } } diff --git a/tests/Integration/Identity/Controller/AdminNotificationControllerTest.php b/tests/Integration/Identity/Controller/AdminNotificationControllerTest.php new file mode 100644 index 0000000..dba8ec7 --- /dev/null +++ b/tests/Integration/Identity/Controller/AdminNotificationControllerTest.php @@ -0,0 +1,40 @@ +get(AdminNotificationController::class) + ); + } + + public function testGetNotificationsWithoutSessionKeyReturnsUnauthorized(): void + { + self::getClient()->request('GET', '/api/v2/notifications'); + + $this->assertHttpUnauthorized(); + } + + public function testGetNotificationsWithValidSessionReturnsItems(): void + { + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + + $this->authenticatedJsonRequest('GET', '/api/v2/notifications'); + + $this->assertHttpOkay(); + $content = $this->getDecodedJsonResponseContent(); + self::assertArrayHasKey('items', $content); + self::assertIsArray($content['items']); + } +} diff --git a/tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php b/tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php new file mode 100644 index 0000000..d239238 --- /dev/null +++ b/tests/Integration/Messaging/Controller/CampaignMessageDataControllerTest.php @@ -0,0 +1,99 @@ +get(CampaignMessageDataController::class) + ); + } + + public function testUpdateMessageDataWithoutSessionReturnsUnauthorized(): void + { + $this->loadFixtures([MessageFixture::class]); + + $this->jsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'google_track' => true, + ])); + $this->assertHttpUnauthorized(); + } + + public function testUpdateMessageDataWithInvalidIdReturnsNotFound(): void + { + $this->loadFixtures([AdministratorFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/999/message-data', [], [], [], json_encode([ + 'google_track' => true, + ])); + $this->assertHttpNotFound(); + } + + public function testUpdateMessageDataStoresTheSettingsAndReturnsThem(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class, SubscriberListFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'google_track' => true, + 'utm_source' => 'newsletter', + 'notify_start' => 'a@example.com, b@example.com', + 'exclude_lists' => [1], + ])); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertTrue($response['google_track']); + self::assertSame('newsletter', $response['utm_source']); + self::assertSame('a@example.com, b@example.com', $response['notify_start']); + self::assertSame([1], $response['exclude_lists']); + } + + public function testUpdateMessageDataOnlyChangesTheFieldsThatAreSent(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'utm_source' => 'newsletter', + 'utm_medium' => 'email', + ])); + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'utm_medium' => '', + ])); + $this->assertHttpOkay(); + + $response = $this->getDecodedJsonResponseContent(); + self::assertSame('newsletter', $response['utm_source']); + self::assertSame('', $response['utm_medium']); + } + + public function testUpdateMessageDataWithInvalidValuesReturnsUnprocessableEntity(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'notify_end' => 'not-an-email', + ])); + $this->assertHttpUnprocessableEntity(); + } + + public function testUpdateMessageDataWithUnknownExcludedListReturnsNotFound(): void + { + $this->loadFixtures([AdministratorFixture::class, MessageFixture::class]); + + $this->authenticatedJsonRequest('PATCH', '/api/v2/campaigns/1/message-data', [], [], [], json_encode([ + 'exclude_lists' => [999], + ])); + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Controller/ListMessageControllerTest.php b/tests/Integration/Messaging/Controller/ListMessageControllerTest.php index 87cb32f..92c00e9 100644 --- a/tests/Integration/Messaging/Controller/ListMessageControllerTest.php +++ b/tests/Integration/Messaging/Controller/ListMessageControllerTest.php @@ -4,11 +4,14 @@ namespace PhpList\RestBundle\Tests\Integration\Messaging\Controller; +use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\Manager\ListMessageManager; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\RestBundle\Messaging\Controller\ListMessageController; use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController; +use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture; +use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorTokenFixture; use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\MessageFixture; use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberListFixture; @@ -144,6 +147,29 @@ public function testAssociateMessageWithListWithValidIdsCreatesAssociation(): vo self::assertArrayHasKey('subscriber_list', $responseContent); } + public function testAssociateMessageWithListByAdminWithoutPrivilegesIsForbidden(): void + { + $message = $this->messageRepository->findOneBy([]); + $subscriberList = $this->subscriberListRepository->findOneBy([]); + + $this->loadFixtures([AdministratorFixture::class, AdministratorTokenFixture::class]); + $token = self::getContainer()->get(AdministratorTokenRepository::class) + ->findOneBy(['key' => 'cfdf64eecbbf336628b0f3071adba762']); + $admin = $token->getAdministrator(); + $admin->setSuperUser(false); + self::getContainer()->get('doctrine.orm.entity_manager')->flush(); + + $this->jsonRequest( + 'post', + '/api/v2/list-messages/message/' . $message->getId() . '/list/' . $subscriberList->getId(), + [], + [], + ['PHP_AUTH_USER' => 'unused', 'PHP_AUTH_PW' => 'cfdf64eecbbf336628b0f3071adba762'] + ); + + $this->assertHttpForbidden(); + } + public function testDisassociateMessageFromListWithoutSessionKeyReturnsUnauthorized(): void { $this->jsonRequest('delete', '/api/v2/list-messages/message/1/list/1'); diff --git a/tests/Integration/Statistics/Controller/LinkTrackControllerTest.php b/tests/Integration/Statistics/Controller/LinkTrackControllerTest.php new file mode 100644 index 0000000..f2371b0 --- /dev/null +++ b/tests/Integration/Statistics/Controller/LinkTrackControllerTest.php @@ -0,0 +1,142 @@ +get(LinkTrackController::class) + ); + } + + public function testClickRedirectsToTheOriginalUrlAndIsRecorded(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + UserMessageFixture::class, + ]); + $forwardId = $this->createForward('https://example.org/landing?a=1'); + + self::getClient()->request('GET', '/api/v2/lt?tid=' . $this->token('H')); + + $response = self::getClient()->getResponse(); + self::assertSame(303, $response->getStatusCode()); + self::assertSame('https://example.org/landing?a=1', $response->headers->get('Location')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + + $this->entityManager->clear(); + $ml = $this->entityManager->getRepository(LinkTrackMl::class) + ->findOneBy(['messageId' => 1, 'forwardId' => $forwardId]); + self::assertSame(1, $ml->getClicked()); + self::assertSame(1, $ml->getHtmlClicked()); + $uml = $this->entityManager->getRepository(LinkTrackUmlClick::class) + ->findOneBy(['messageId' => 1, 'userId' => 1, 'forwardId' => $forwardId]); + self::assertSame(1, $uml->getClicked()); + $userMessage = $this->entityManager->getRepository(UserMessage::class)->findOneBy([]); + self::assertTrue($userMessage->isViewed(), 'the first click also counts as a view'); + } + + public function testSecondClickIncrementsTheCounters(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + UserMessageFixture::class, + ]); + $forwardId = $this->createForward('https://example.org/landing'); + + self::getClient()->request('GET', '/api/v2/lt?tid=' . $this->token('H')); + self::getClient()->request('GET', '/api/v2/lt?tid=' . $this->token('T')); + + $this->entityManager->clear(); + $uml = $this->entityManager->getRepository(LinkTrackUmlClick::class) + ->findOneBy(['messageId' => 1, 'userId' => 1, 'forwardId' => $forwardId]); + self::assertSame(2, $uml->getClicked()); + self::assertSame(1, $uml->getHtmlClicked()); + self::assertSame(1, $uml->getTextClicked()); + } + + /** @dataProvider invalidRequestProvider */ + public function testInvalidRequestsReturnNotFound(string $query): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + ]); + $this->createForward('https://example.org/landing'); + + self::getClient()->request('GET', '/api/v2/lt' . $query); + + self::assertSame(404, self::getClient()->getResponse()->getStatusCode()); + self::assertNull(self::getClient()->getResponse()->headers->get('Location')); + } + + public static function invalidRequestProvider(): array + { + $encode = static fn (string $value): string => rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + + return [ + 'no token' => [''], + 'garbage token' => ['?tid=not-a-token'], + 'unknown link' => ['?tid=' . $encode('H|unknown|' . self::MESSAGE_UUID . '|' . self::SUBSCRIBER_UUID)], + 'unknown campaign' => ['?tid=' . $encode('H|' . self::FORWARD_UUID . '|unknown|' . self::SUBSCRIBER_UUID)], + 'unknown subscriber' => [ + '?tid=' . $encode('H|' . self::FORWARD_UUID . '|' . self::MESSAGE_UUID . '|unknown'), + ], + ]; + } + + private function createForward(string $url): int + { + $subscriber = $this->entityManager->find(Subscriber::class, 1); + $subscriber->setUuid(self::SUBSCRIBER_UUID); + + $forward = (new LinkTrackForward()) + ->setUrl($url) + ->setUrlHash(md5($url)) + ->setUuid(self::FORWARD_UUID) + ->setPersonalise(false); + $this->entityManager->persist($forward); + $this->entityManager->flush(); + $this->entityManager->clear(); + + return (int) $forward->getId(); + } + + private function token(string $type): string + { + $value = implode('|', [$type, self::FORWARD_UUID, self::MESSAGE_UUID, self::SUBSCRIBER_UUID]); + + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/tests/Unit/Identity/Serializer/AdminNotificationNormalizerTest.php b/tests/Unit/Identity/Serializer/AdminNotificationNormalizerTest.php new file mode 100644 index 0000000..1be539a --- /dev/null +++ b/tests/Unit/Identity/Serializer/AdminNotificationNormalizerTest.php @@ -0,0 +1,56 @@ +assertTrue($normalizer->supportsNormalization($notification)); + $this->assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testNormalize(): void + { + $normalizer = new AdminNotificationNormalizer(); + $notification = new AdminNotification( + 'campaigns_in_queue', + AdminNotification::LEVEL_WARNING, + 'There are campaigns in the queue', + ['count' => 3] + ); + + $result = $normalizer->normalize($notification); + + $this->assertSame('campaigns_in_queue', $result['code']); + $this->assertSame('warning', $result['level']); + $this->assertSame('There are campaigns in the queue', $result['message']); + $this->assertEquals((object) ['count' => 3], $result['context']); + } + + public function testNormalizeEncodesEmptyContextAsObject(): void + { + $normalizer = new AdminNotificationNormalizer(); + $notification = new AdminNotification('code', AdminNotification::LEVEL_INFO, 'Message', []); + + $this->assertSame('{}', json_encode($normalizer->normalize($notification)['context'])); + } + + public function testNormalizeWithInvalidObjectThrows(): void + { + $this->expectException(InvalidArgumentException::class); + + (new AdminNotificationNormalizer())->normalize(new stdClass()); + } +} diff --git a/tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php b/tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php new file mode 100644 index 0000000..e6ae39b --- /dev/null +++ b/tests/Unit/Messaging/Controller/CampaignMessageDataControllerTest.php @@ -0,0 +1,102 @@ +authentication = $this->createMock(Authentication::class); + $this->authentication->method('authenticateByApiKey')->willReturn($this->createMock(Administrator::class)); + $this->validator = $this->createMock(RequestValidator::class); + $this->messageDataManager = $this->createMock(MessageDataManager::class); + $this->messageDataLoader = $this->createMock(MessageDataLoader::class); + $this->permissionChecker = $this->createMock(PermissionChecker::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->controller = new CampaignMessageDataController( + $this->authentication, + $this->validator, + $this->messageDataManager, + $this->messageDataLoader, + $this->permissionChecker, + $this->entityManager, + new MessageDataNormalizer() + ); + $this->controller->setContainer(new Container()); + $this->message = $this->createMock(Message::class); + } + + public function testMissingCampaignIsNotFound(): void + { + $this->messageDataManager->expects(self::never())->method('storeMessageData'); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->updateMessageData(new Request(), null); + } + + public function testCampaignThatCannotBeEditedIsRejected(): void + { + $this->permissionChecker->method('canEdit')->willReturn(false); + $this->messageDataManager->expects(self::never())->method('storeMessageData'); + + $this->expectException(AccessDeniedException::class); + + $this->controller->updateMessageData(new Request(), $this->message); + } + + public function testSettingsAreStoredFlushedAndTheLoadedMessageDataReturned(): void + { + $this->permissionChecker->method('canEdit')->willReturn(true); + $messageDataRequest = new UpdateMessageDataRequest(); + $messageDataRequest->googleTrack = true; + $messageDataRequest->excludeLists = [4, 6]; + $this->validator->method('validate')->willReturn($messageDataRequest); + + $this->messageDataManager->expects(self::once()) + ->method('storeMessageData') + ->with($this->message, ['google_track' => true, 'excludelist' => [4 => 4, 6 => 6]]); + $this->entityManager->expects(self::once())->method('flush'); + $this->messageDataLoader->method('__invoke')->willReturn([ + 'google_track' => '1', + 'excludelist' => [4 => 4, 6 => 6], + ]); + + $response = $this->controller->updateMessageData(new Request(), $this->message); + + $data = json_decode((string) $response->getContent(), true); + self::assertTrue($data['google_track']); + self::assertSame([4, 6], $data['exclude_lists']); + self::assertNull($data['utm_source']); + } +} diff --git a/tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php b/tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php new file mode 100644 index 0000000..e269790 --- /dev/null +++ b/tests/Unit/Messaging/Request/UpdateMessageDataRequestTest.php @@ -0,0 +1,133 @@ +enableAttributeMapping()->getValidator() + ->validate($request) + ->count(); + } + + public function testNothingIsSetByDefault(): void + { + $request = new UpdateMessageDataRequest(); + + self::assertSame([], $request->getDto()); + self::assertSame(0, $this->violationCount($request)); + } + + public function testOnlyTheFieldsThatWereSentAreReturned(): void + { + $request = new UpdateMessageDataRequest(); + $request->googleTrack = false; + $request->utmSource = 'newsletter'; + $request->utmMedium = ''; + $request->excludeLists = [4, 5]; + + self::assertSame( + [ + 'google_track' => false, + 'utm_source' => 'newsletter', + 'utm_medium' => '', + 'excludelist' => [4 => 4, 5 => 5], + ], + $request->getDto() + ); + } + + /** + * @dataProvider notifyAddressesProvider + */ + public function testNotifyAddressesAreValidated(string $value, int $expectedViolations): void + { + $request = new UpdateMessageDataRequest(); + $request->notifyStart = $value; + $request->notifyEnd = $value; + + self::assertSame($expectedViolations * 2, $this->violationCount($request)); + } + + public static function notifyAddressesProvider(): array + { + return [ + 'empty clears the setting' => ['', 0], + 'one address' => ['a@example.com', 0], + 'several addresses' => ['a@example.com, b@example.com', 0], + 'not an address' => ['nope', 1], + 'one of several is wrong' => ['a@example.com, nope', 1], + 'empty segment between addresses' => ['a@example.com,,b@example.com', 1], + 'trailing comma' => ['a@example.com,', 1], + 'only blanks' => [' ', 1], + ]; + } + + public function testTooLongValuesAreRejected(): void + { + $request = new UpdateMessageDataRequest(); + $request->utmSource = str_repeat('a', 256); + + self::assertSame(1, $this->violationCount($request)); + } + + /** + * @dataProvider invalidExcludeListsProvider + */ + public function testInvalidExcludeListsGiveViolationsInsteadOfNotFound(array $excludeLists): void + { + $repository = $this->createMock(SubscriberListRepository::class); + $repository->method('find')->willReturn(null); + + $factory = new class ($repository) extends ConstraintValidatorFactory { + public function __construct(private readonly SubscriberListRepository $repository) + { + parent::__construct(); + } + + public function getInstance(Constraint $constraint): ConstraintValidatorInterface + { + return $constraint->validatedBy() === ListExistsValidator::class + ? new ListExistsValidator($this->repository) + : parent::getInstance($constraint); + } + }; + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory($factory) + ->getValidator(); + + $request = new UpdateMessageDataRequest(); + $request->excludeLists = $excludeLists; + + try { + $violations = $validator->validate($request); + } catch (NotFoundHttpException) { + self::fail('The list lookup ran for an element that failed the type checks.'); + } + + self::assertSame(1, $violations->count()); + } + + public static function invalidExcludeListsProvider(): array + { + return [ + 'non numeric' => [['abc']], + 'negative' => [[-1]], + 'zero' => [[0]], + ]; + } +} diff --git a/tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php new file mode 100644 index 0000000..d0350dc --- /dev/null +++ b/tests/Unit/Messaging/Serializer/MessageDataNormalizerTest.php @@ -0,0 +1,64 @@ +normalizer = new MessageDataNormalizer(); + } + + public function testSupportsNormalizationOnlyForCampaignMessageData(): void + { + $this->assertTrue($this->normalizer->supportsNormalization(new CampaignMessageData([]))); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsEmptyArrayForOtherObjects(): void + { + $this->assertSame([], $this->normalizer->normalize(new \stdClass())); + } + + public function testNormalizeReturnsOnlyTheSettingsWithNullForTheOnesNotStored(): void + { + $result = $this->normalizer->normalize(new CampaignMessageData([ + 'google_track' => '1', + 'utm_source' => 'newsletter', + 'excludelist' => [4 => 4, 6 => 6], + 'subject' => 'not part of the response', + ])); + + $this->assertSame( + [ + 'google_track' => true, + 'utm_source' => 'newsletter', + 'utm_medium' => null, + 'utm_campaign' => null, + 'pk_source' => null, + 'pk_medium' => null, + 'pk_campaign' => null, + 'notify_start' => null, + 'notify_end' => null, + 'exclude_lists' => [4, 6], + ], + $result + ); + } + + public function testNormalizeHandlesEmptyData(): void + { + $result = $this->normalizer->normalize(new CampaignMessageData([])); + + $this->assertFalse($result['google_track']); + $this->assertSame([], $result['exclude_lists']); + } +} diff --git a/tests/Unit/Messaging/Service/CampaignServiceTest.php b/tests/Unit/Messaging/Service/CampaignServiceTest.php index fd014ca..47effb4 100644 --- a/tests/Unit/Messaging/Service/CampaignServiceTest.php +++ b/tests/Unit/Messaging/Service/CampaignServiceTest.php @@ -7,6 +7,7 @@ use DateTime; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use PhpList\Core\Domain\Identity\Model\AccessLevel; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Service\PermissionChecker; use PhpList\Core\Domain\Messaging\Model\Filter\MessageFilter; @@ -43,6 +44,7 @@ protected function setUp(): void $this->paginatedProvider = $this->createMock(PaginatedDataProvider::class); $this->normalizer = $this->createMock(MessageNormalizer::class); $this->permissionChecker = $this->createMock(PermissionChecker::class); + $this->permissionChecker->method('accessLevel')->willReturn(AccessLevel::Owner); $this->campaignService = new CampaignService( messageManager: $this->messageManager, @@ -77,6 +79,47 @@ public function testGetMessagesReturnsExpectedResult(): void $this->assertSame($expectedResult, $result); } + public function testGetMessagesDoesNotFilterByOwnerForSuperUser(): void + { + $permissionChecker = $this->createMock(PermissionChecker::class); + $permissionChecker->method('accessLevel')->willReturn(AccessLevel::All); + $service = new CampaignService( + messageManager: $this->messageManager, + paginatedProvider: $this->paginatedProvider, + normalizer: $this->normalizer, + entityManager: $this->createMock(EntityManagerInterface::class), + permissionChecker: $permissionChecker, + ); + $this->paginatedProvider->expects($this->once()) + ->method('getPaginatedList') + ->with( + $this->anything(), + $this->anything(), + Message::class, + $this->callback(fn (MessageFilter $filter) => $filter->getOwner() === null) + ) + ->willReturn([]); + + $service->getMessages(new Request(), $this->createMock(Administrator::class)); + } + + public function testGetMessagesThrowsWithoutCampaignsPrivilege(): void + { + $permissionChecker = $this->createMock(PermissionChecker::class); + $permissionChecker->method('accessLevel')->willReturn(AccessLevel::None); + $service = new CampaignService( + messageManager: $this->messageManager, + paginatedProvider: $this->paginatedProvider, + normalizer: $this->normalizer, + entityManager: $this->createMock(EntityManagerInterface::class), + permissionChecker: $permissionChecker, + ); + $this->paginatedProvider->expects($this->never())->method('getPaginatedList'); + + $this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class); + $service->getMessages(new Request(), $this->createMock(Administrator::class)); + } + public function testGetMessagesAppliesStatusAndSortFromQuery(): void { $request = new Request(query: ['status' => 'submitted,prepared', 'sort' => 'desc']);