From af4b44030c15f496a51444730fa4fab9ba422ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 7 Sep 2026 13:54:28 +0200 Subject: [PATCH] Allow configuring the cookie domain Meta writes the fbp and fbc cookies on the registrable domain and encodes that level in the value. The bundle wrote host-only cookies while always claiming level 1, so apex and www got different cookies whose values disagreed with where they were set. Add cookies.domain and cookies.lifetime, and derive the subdomain index from the domain the cookie is actually written on. Fixes #29 --- README.md | 10 +++ UPGRADE.md | 60 +++++++++++++++ src/Context/Fbc/QueryBasedFbcContext.php | 5 +- src/Context/Fbp/GeneratedFbpContext.php | 8 +- src/Cookie/CookieDomain.php | 48 ++++++++++++ src/DependencyInjection/Configuration.php | 16 ++++ .../SetonoMetaConversionsApiExtension.php | 6 +- src/EventSubscriber/StoreFbcSubscriber.php | 7 +- src/EventSubscriber/StoreFbpSubscriber.php | 7 +- src/Resources/config/services/context.xml | 7 ++ .../config/services/event_subscriber.xml | 6 ++ .../Context/Fbc/QueryBasedFbcContextTest.php | 22 +++++- .../Context/Fbp/GeneratedFbpContextTest.php | 38 +++++++++ tests/Unit/Cookie/CookieDomainTest.php | 77 +++++++++++++++++++ .../StoreFbcSubscriberTest.php | 16 ++++ .../StoreFbpSubscriberTest.php | 29 ++++++- 16 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 src/Cookie/CookieDomain.php create mode 100644 tests/Unit/Context/Fbp/GeneratedFbpContextTest.php create mode 100644 tests/Unit/Cookie/CookieDomainTest.php diff --git a/README.md b/README.md index 1b12896..6d9b7d5 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,16 @@ setono_meta_conversions_api: - id: '%env(META_PIXEL_ID)%' access_token: '%env(META_ACCESS_TOKEN)%' + # The bundle writes the _fbp and _fbc cookies server side, so a visitor is recognised even without the + # browser pixel. Both are only written when at least one pixel is available + cookies: + # The domain to write them on. Meta's own pixel uses the registrable domain, so set this to yours if your + # site is reachable on both the apex and www, or spans several subdomains. Null scopes them to the current + # host, which means apex and www get different cookies + domain: null + # Anything \DateTimeImmutable understands. Meta keeps these for 90 days + lifetime: '+90 days' + # The PSR-18 http client used to send events. Defaults to Symfony's default http client, which means requests # to Meta show up in the profiler and honour the options you configured. Point it at a scoped client to give # Meta its own timeout diff --git a/UPGRADE.md b/UPGRADE.md index 79208e1..5dee617 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -81,6 +81,66 @@ Enabling client side tracking without the tag bag bundle now throws `\LogicExcep `\InvalidArgumentException`, which is what Symfony uses for "this bundle needs that bundle". Adjust your test if you asserted on the old type. +## Cookies + +New `cookies` options: + +```yaml +setono_meta_conversions_api: + cookies: + domain: null # e.g. example.com + lifetime: '+90 days' +``` + +The `_fbp` and `_fbc` cookies are now only written when at least one pixel is available, because every `Set-Cookie` +header makes a response uncacheable for shared caches and there is nothing to send events to anyway. + +The subdomain index encoded in the value (the `1` in `fb.1.…`) is now derived from the domain the cookie is actually +written on, the same way Meta's parameter builder does it, instead of always being `1`. On a host-only cookie on +`www.example.com` the value is now `fb.2.…`. Set `cookies.domain` to your registrable domain to get `fb.1.…` and one +cookie shared between the apex and `www`. + +## The SendEvent command changed shape + +`SendEvent` no longer carries the `Setono\MetaConversionsApi\Event\Event` object. It carries the finished payload +instead: + +```php +new SendEvent( + string $eventName, + string $eventId, + array $payload, // already normalized and hashed by the SDK + array $pixelIds, // ids only, no access tokens + ?string $testEventCode = null, +); +``` + +Build one from an event with `SendEvent::fromEvent($event)`. + +**Why:** when the command is routed to a transport it is written to that transport's storage, and to the failure +transport when it fails. Previously that storage received the Conversions API access token and every raw email +address, phone number and name the application had attached, because hashing only happened later inside +`Client::sendEvent()`. Failure transports are often kept indefinitely, which made that a retention problem too. + +Access tokens are now resolved when the event is sent, through the new +`Setono\MetaConversionsApiBundle\AccessTokenResolver\AccessTokenResolverInterface`. The default implementation reads +them from the `pixels` configuration. If your pixels come from your own `PixelProviderInterface`, alias the resolver +as well: + +```yaml +services: + Setono\MetaConversionsApiBundle\AccessTokenResolver\AccessTokenResolverInterface: '@App\Provider\MyAccessTokenResolver' +``` + +Note that the resolver runs in the worker, so it must not depend on the current request. + +If you wrote your own handler or middleware for `SendEvent`, read `$message->payload` and `$message->pixelIds` +instead of `$message->event`. + +`SendEventHandler::__construct()` takes the resolver as its second argument, so its signature changed from +`(ClientInterface $client, ?LoggerInterface $logger)` to +`(ClientInterface $client, AccessTokenResolverInterface $accessTokenResolver, ?LoggerInterface $logger)`. Update the +service definition if you decorated or redefined it. ## Removed container parameters `setono_meta_conversions_api.client_side.enabled` and `setono_meta_conversions_api.server_side.enabled` are gone. No diff --git a/src/Context/Fbc/QueryBasedFbcContext.php b/src/Context/Fbc/QueryBasedFbcContext.php index 883ba2e..0f6a6bf 100644 --- a/src/Context/Fbc/QueryBasedFbcContext.php +++ b/src/Context/Fbc/QueryBasedFbcContext.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbc; use Setono\MetaConversionsApi\ValueObject\Fbc; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Symfony\Component\HttpFoundation\RequestStack; final class QueryBasedFbcContext implements FbcContextInterface @@ -20,6 +21,7 @@ final class QueryBasedFbcContext implements FbcContextInterface public function __construct( private readonly FbcContextInterface $decorated, private readonly RequestStack $requestStack, + private readonly CookieDomain $cookieDomain, ) { } @@ -35,6 +37,7 @@ public function getFbc(): ?Fbc return $this->decorated->getFbc(); } - return new Fbc($facebookClickId); + // The click id is about to be written as a cookie, so it has to say which level it was set at + return (new Fbc($facebookClickId))->withSubdomainIndex($this->cookieDomain->subdomainIndex()); } } diff --git a/src/Context/Fbp/GeneratedFbpContext.php b/src/Context/Fbp/GeneratedFbpContext.php index d028765..e593858 100644 --- a/src/Context/Fbp/GeneratedFbpContext.php +++ b/src/Context/Fbp/GeneratedFbpContext.php @@ -5,11 +5,17 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbp; use Setono\MetaConversionsApi\ValueObject\Fbp; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; final class GeneratedFbpContext implements FbpContextInterface { + public function __construct(private readonly CookieDomain $cookieDomain) + { + } + public function getFbp(): Fbp { - return new Fbp(); + // The generated value is about to be written as a cookie, so it has to say which level it was set at + return (new Fbp())->withSubdomainIndex($this->cookieDomain->subdomainIndex()); } } diff --git a/src/Cookie/CookieDomain.php b/src/Cookie/CookieDomain.php new file mode 100644 index 0000000..92d7266 --- /dev/null +++ b/src/Cookie/CookieDomain.php @@ -0,0 +1,48 @@ +domain; + } + + /** + * The number of dots in the domain the cookie ends up on, which is how Meta's own parameter builder + * computes it (`substr_count($etldPlus1, '.')`) + */ + public function subdomainIndex(): int + { + $domain = $this->domain ?? $this->requestStack->getMainRequest()?->getHost(); + if (null === $domain || '' === $domain) { + return 0; + } + + // A leading dot is the classic way of writing a domain cookie and says nothing about the level + return min(2, substr_count(ltrim($domain, '.'), '.')); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index d0e4401..7822bc9 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApiBundle\DependencyInjection; use Setono\Consent\DefaultConsents; +use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -66,6 +67,21 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('cookies') + ->info('How the _fbp and _fbc cookies are written') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('domain') + ->info('The domain to write the cookies on, e.g. example.com, so the apex and www share one cookie the way Meta\'s own pixel does. Null scopes them to the current host') + ->defaultNull() + ->end() + ->scalarNode('lifetime') + ->info('Anything \DateTimeImmutable understands. Meta keeps these for 90 days') + ->defaultValue(Cookies::LIFETIME) + ->cannotBeEmpty() + ->end() + ->end() + ->end() ->scalarNode('http_client') ->info('The PSR-18 http client used to send events. Defaults to the application\'s psr18.http_client, i.e. the default Symfony http client. Point it at a scoped client to give Meta its own timeout') ->defaultValue('psr18.http_client') diff --git a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php index 6f2bca1..fa59554 100644 --- a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php +++ b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php @@ -50,7 +50,7 @@ public function getConfiguration(array $config, ContainerBuilder $container): Co public function load(array $configs, ContainerBuilder $container): void { /** - * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, filters: array{user_agent: list}} $config + * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, cookies: array{domain: string|null, lifetime: string}, filters: array{user_agent: list}} $config */ $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); // The XML format is deprecated since Symfony 7.4 and removed in 8.0. Migrate to PHP config before adding Symfony 8 support @@ -61,6 +61,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('setono_meta_conversions_api.pixels', $config['pixels']); $container->setParameter('setono_meta_conversions_api.filters.user_agent', $config['filters']['user_agent']); + $cookieDomain = $config['cookies']['domain']; + $container->setParameter('setono_meta_conversions_api.cookies.domain', '' === $cookieDomain ? null : $cookieDomain); + $container->setParameter('setono_meta_conversions_api.cookies.lifetime', $config['cookies']['lifetime']); + $testEventCode = $config['test_event_code']['value']; $container->setParameter('setono_meta_conversions_api.test_event_code.value', '' === $testEventCode ? null : $testEventCode); $container->setParameter('setono_meta_conversions_api.test_event_code.query_parameter', $config['test_event_code']['query_parameter']); diff --git a/src/EventSubscriber/StoreFbcSubscriber.php b/src/EventSubscriber/StoreFbcSubscriber.php index b0b408c..bbb9192 100644 --- a/src/EventSubscriber/StoreFbcSubscriber.php +++ b/src/EventSubscriber/StoreFbcSubscriber.php @@ -6,6 +6,7 @@ use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -24,6 +25,8 @@ public function __construct( private readonly FbcContextInterface $fbcContext, private readonly ConsentCheckerInterface $consentChecker, private readonly PixelProviderInterface $pixelProvider, + private readonly CookieDomain $cookieDomain, + private readonly string $lifetime = Cookies::LIFETIME, ) { } @@ -64,7 +67,9 @@ public function store(ResponseEvent $event): void $response->headers->setCookie(Cookie::create( Cookies::FBC, $fbc->value(), - new \DateTimeImmutable(Cookies::LIFETIME), + new \DateTimeImmutable($this->lifetime), + '/', + $this->cookieDomain->domain(), )->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value } } diff --git a/src/EventSubscriber/StoreFbpSubscriber.php b/src/EventSubscriber/StoreFbpSubscriber.php index 733805f..28a518a 100644 --- a/src/EventSubscriber/StoreFbpSubscriber.php +++ b/src/EventSubscriber/StoreFbpSubscriber.php @@ -7,6 +7,7 @@ use Setono\MetaConversionsApi\ValueObject\Fbp; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -26,6 +27,8 @@ public function __construct( private readonly FbpContextInterface $fbpContext, private readonly ConsentCheckerInterface $consentChecker, private readonly PixelProviderInterface $pixelProvider, + private readonly CookieDomain $cookieDomain, + private readonly string $lifetime = Cookies::LIFETIME, ) { } @@ -62,7 +65,9 @@ public function store(ResponseEvent $event): void $response->headers->setCookie(Cookie::create( Cookies::FBP, $fbp->value(), - new \DateTimeImmutable(Cookies::LIFETIME), + new \DateTimeImmutable($this->lifetime), + '/', + $this->cookieDomain->domain(), )->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value } diff --git a/src/Resources/config/services/context.xml b/src/Resources/config/services/context.xml index f53346a..3c940e9 100644 --- a/src/Resources/config/services/context.xml +++ b/src/Resources/config/services/context.xml @@ -3,6 +3,11 @@ + + + %setono_meta_conversions_api.cookies.domain% + + @@ -25,6 +30,7 @@ decorates="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface" decoration-priority="64"> + @@ -32,6 +38,7 @@ alias="Setono\MetaConversionsApiBundle\Context\Fbp\GeneratedFbpContext"/> + + + %setono_meta_conversions_api.cookies.lifetime% @@ -64,6 +66,8 @@ + + %setono_meta_conversions_api.cookies.lifetime% @@ -72,6 +76,8 @@ + + %setono_meta_conversions_api.cookies.lifetime% diff --git a/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php b/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php index 37c0109..7384ae1 100644 --- a/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php +++ b/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php @@ -11,6 +11,7 @@ use Setono\MetaConversionsApi\ValueObject\Fbc; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\QueryBasedFbcContext; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -22,7 +23,7 @@ public function it_falls_back_to_the_decorated_context_without_a_request(): void { $fbc = new Fbc('decorated'); - $context = new QueryBasedFbcContext(self::decorated($fbc), new RequestStack()); + $context = new QueryBasedFbcContext(self::decorated($fbc), new RequestStack(), new CookieDomain(new RequestStack())); self::assertSame($fbc, $context->getFbc()); } @@ -31,7 +32,7 @@ public function it_falls_back_to_the_decorated_context_without_a_request(): void #[DataProvider('validClickIds')] public function it_uses_a_valid_click_id_from_the_query(string $clickId): void { - $context = new QueryBasedFbcContext(self::decorated(null), self::requestStack($clickId)); + $context = new QueryBasedFbcContext(self::decorated(null), self::requestStack($clickId), new CookieDomain(new RequestStack())); $fbc = $context->getFbc(); @@ -56,7 +57,7 @@ public function it_falls_back_to_the_decorated_context_for_an_invalid_click_id(s { $decorated = new Fbc('decorated'); - $context = new QueryBasedFbcContext(self::decorated($decorated), self::requestStack($clickId)); + $context = new QueryBasedFbcContext(self::decorated($decorated), self::requestStack($clickId), new CookieDomain(new RequestStack())); self::assertSame($decorated, $context->getFbc()); } @@ -73,6 +74,21 @@ public static function invalidClickIds(): iterable yield 'dot' => ['fb.1.123.abc']; } + #[Test] + public function it_records_the_level_the_cookie_will_be_written_at(): void + { + $context = new QueryBasedFbcContext( + self::decorated(null), + self::requestStack('IwAR0rmfgHgx'), + new CookieDomain(new RequestStack(), 'www.example.com'), + ); + + $fbc = $context->getFbc(); + + self::assertInstanceOf(Fbc::class, $fbc); + self::assertSame(2, $fbc->getSubdomainIndex()); + } + private static function decorated(?Fbc $fbc): FbcContextInterface { return new class($fbc) implements FbcContextInterface { diff --git a/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php b/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php new file mode 100644 index 0000000..98fc33e --- /dev/null +++ b/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php @@ -0,0 +1,38 @@ +getFbp()->value(), $context->getFbp()->value()); + } + + #[Test] + public function it_records_the_level_the_cookie_will_be_written_at(): void + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('https://www.example.com/')); + + // Host-only cookie on www.example.com, so the value has to say fb.2. + self::assertSame(2, (new GeneratedFbpContext(new CookieDomain($requestStack)))->getFbp()->getSubdomainIndex()); + + // ... but a cookie written on example.com is fb.1. + self::assertSame(1, (new GeneratedFbpContext(new CookieDomain($requestStack, 'example.com')))->getFbp()->getSubdomainIndex()); + } +} diff --git a/tests/Unit/Cookie/CookieDomainTest.php b/tests/Unit/Cookie/CookieDomainTest.php new file mode 100644 index 0000000..fd7a5c7 --- /dev/null +++ b/tests/Unit/Cookie/CookieDomainTest.php @@ -0,0 +1,77 @@ +domain()); + } + + #[Test] + public function it_returns_the_configured_domain(): void + { + self::assertSame('example.com', (new CookieDomain(new RequestStack(), 'example.com'))->domain()); + } + + /** + * Meta's own parameter builder computes this as the number of dots in the domain the cookie ends up on + */ + #[Test] + #[DataProvider('configuredDomains')] + public function it_derives_the_subdomain_index_from_the_configured_domain(string $domain, int $expected): void + { + self::assertSame($expected, (new CookieDomain(new RequestStack(), $domain))->subdomainIndex()); + } + + /** + * @return iterable + */ + public static function configuredDomains(): iterable + { + yield 'single label' => ['localhost', 0]; + yield 'registrable domain' => ['example.com', 1]; + yield 'leading dot' => ['.example.com', 1]; + yield 'subdomain' => ['www.example.com', 2]; + yield 'deep subdomain is capped' => ['shop.eu.example.com', 2]; + } + + #[Test] + #[DataProvider('hosts')] + public function it_falls_back_to_the_request_host(string $host, int $expected): void + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('https://' . $host . '/')); + + self::assertSame($expected, (new CookieDomain($requestStack))->subdomainIndex()); + } + + /** + * @return iterable + */ + public static function hosts(): iterable + { + yield 'single label' => ['localhost', 0]; + yield 'registrable domain' => ['example.com', 1]; + yield 'subdomain' => ['www.example.com', 2]; + } + + #[Test] + public function it_returns_zero_without_a_request_or_a_domain(): void + { + self::assertSame(0, (new CookieDomain(new RequestStack()))->subdomainIndex()); + } +} diff --git a/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php index fcf43ff..2a301c9 100644 --- a/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php +++ b/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php @@ -11,11 +11,13 @@ use Setono\MetaConversionsApi\ValueObject\Fbc; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbcSubscriber; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -88,6 +90,18 @@ public function it_ignores_sub_requests(): void self::assertNull(self::cookie($event)); } + #[Test] + public function it_writes_the_cookie_on_the_configured_domain(): void + { + $event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response()); + + self::subscriber(domain: 'example.com')->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertSame('example.com', $cookie->getDomain()); + } + private static function cookie(ResponseEvent $event): ?Cookie { foreach ($event->getResponse()->headers->getCookies() as $cookie) { @@ -106,6 +120,7 @@ private static function subscriber( ?Fbc $fbc = new Fbc('IwAR0rmfgHgx'), bool $consentGranted = true, ?array $pixels = null, + ?string $domain = null, ): StoreFbcSubscriber { return new StoreFbcSubscriber( new class($fbc) implements FbcContextInterface { @@ -141,6 +156,7 @@ public function getPixels(): array return $this->pixels; } }, + new CookieDomain(new RequestStack(), $domain), ); } diff --git a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php index edad9bc..9a3cce0 100644 --- a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php +++ b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php @@ -11,11 +11,13 @@ use Setono\MetaConversionsApi\ValueObject\Fbp; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbpSubscriber; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -102,6 +104,30 @@ public function it_renews_a_cookie_older_than_two_hours(): void self::assertNotNull(self::cookie($event)); } + #[Test] + public function it_writes_a_host_only_cookie_by_default(): void + { + $event = self::event(new Request(), new Response()); + + self::subscriber()->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertNull($cookie->getDomain()); + } + + #[Test] + public function it_writes_the_cookie_on_the_configured_domain(): void + { + $event = self::event(new Request(), new Response()); + + self::subscriber(domain: 'example.com')->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertSame('example.com', $cookie->getDomain()); + } + private static function cookie(ResponseEvent $event): ?Cookie { foreach ($event->getResponse()->headers->getCookies() as $cookie) { @@ -116,7 +142,7 @@ private static function cookie(ResponseEvent $event): ?Cookie /** * @param list|null $pixels */ - private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null): StoreFbpSubscriber + private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null, ?string $domain = null): StoreFbpSubscriber { return new StoreFbpSubscriber( new class($fbp ?? new Fbp()) implements FbpContextInterface { @@ -152,6 +178,7 @@ public function getPixels(): array return $this->pixels; } }, + new CookieDomain(new RequestStack(), $domain), ); }