diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f5a57a..9552a0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,15 @@ # Dependabot only reads this file from the default branch, 2.x, which is also the only branch it watches. # -# Only facebook/php-business-sdk is watched. The Graph API version the client talks to is ApiConfig::APIVersion from that -# package, and Meta ships a new major of it for every Graph API version, a few times a year. Without a PR that widens the -# constraint, users of this library stay capped at the previous Graph API version. +# Two packages are watched: +# +# - facebook/php-business-sdk: the Graph API version the client talks to is ApiConfig::APIVersion from that package, and +# Meta ships a new major of it for every Graph API version, a few times a year. Without a PR that widens the +# constraint, users of this library stay capped at the previous Graph API version. +# - facebook/capi-param-builder-php: a dev dependency used as a test oracle for the _fbc/_fbp cookie format, see +# tests/ValueObject/ParamBuilderConformanceTest.php. A new major should be tested against as soon as it exists. # # No composer.lock is committed, so a PR is only opened when a release falls outside the allowed range, i.e. for a new -# major. The other dependencies are left out on purpose: their ranges already float, and the dev tools are held on the +# major. Releases inside the range produce no PR; the highest jobs install them the next time CI runs. The other dependencies are left out on purpose: their ranges already float, and the dev tools are held on the # majors that still support PHP 8.1. version: 2 @@ -16,6 +20,7 @@ updates: interval: "weekly" allow: - dependency-name: "facebook/php-business-sdk" + - dependency-name: "facebook/capi-param-builder-php" versioning-strategy: "widen" labels: - "dependencies" diff --git a/CLAUDE.md b/CLAUDE.md index cda4349..72db8d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,4 +62,6 @@ So to add a field: add the public property, map it in `getMapping()`, and regist **Value objects (`src/ValueObject/`)** — `Fbc`/`Fbp` (extending `Fb`) model the `_fbc`/`_fbp` cookie values with `fromString()` validation and `value()` serialization; assignable to `User::$fbc`/`$fbp` as either the typed object or a raw string. Both accept the optional trailing appendix segment that Meta's parameter builder writes (`getAppendix()`/`withAppendix()`) and write it back unchanged, so a cookie value round-trips byte for byte. -The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). Dependabot (`.github/dependabot.yml`) watches this one package on `2.x` and opens a PR that widens the constraint when Meta releases a new major; `1.x` is not watched. CI covers the normalization and hashing on the new major; run `LiveClientTest` once against it before tagging a release. +The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). Dependabot (`.github/dependabot.yml`) watches this package and `facebook/capi-param-builder-php` on `2.x` and opens a PR that widens the constraint when Meta releases a new major; `1.x` is not watched. CI covers the normalization and hashing on the new major; run `LiveClientTest` once against it before tagging a release. + +`facebook/capi-param-builder-php` is a **dev** dependency used as a test oracle, never in `src/`. `tests/ValueObject/ParamBuilderConformanceTest.php` asserts that every `_fbc`/`_fbp` value Meta's own builder writes is parsed by `Fbc`/`Fbp` and written back byte for byte. The check is deliberately one-directional: the builder's own parser is only structural (it accepts `a.b.c.d`), so the value objects stay stricter, and they are more lenient about the two-character appendix, where the builder only accepts six language tokens. No `composer.lock` is committed, so a builder release inside the allowed range produces no Dependabot PR; the `highest` CI jobs install it the next time CI runs. diff --git a/composer.json b/composer.json index e10bcf7..d22c8a2 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ }, "require-dev": { "ergebnis/composer-normalize": "^2.50", + "facebook/capi-param-builder-php": "^1.3.1", "infection/infection": "^0.29", "jangregor/phpstan-prophecy": "^2.3", "nyholm/psr7": "^1.8", diff --git a/src/ValueObject/Fb.php b/src/ValueObject/Fb.php index ab99bfe..3554782 100644 --- a/src/ValueObject/Fb.php +++ b/src/ValueObject/Fb.php @@ -9,6 +9,11 @@ abstract class Fb { + /** + * The subdomain index is the number of dots in the domain the cookie is set on. These are the three values Meta + * documents, but a cookie on a deeper domain has a higher index: Meta's own parameter builder writes fb.3. for + * a.b.example.co.uk + */ public const SUBDOMAIN_INDEX_COM = 0; public const SUBDOMAIN_INDEX_FACEBOOK_COM = 1; @@ -60,11 +65,7 @@ public function getSubdomainIndex(): int */ public function withSubdomainIndex(int $subdomainIndex): self { - Assert::oneOf($subdomainIndex, [ - self::SUBDOMAIN_INDEX_COM, - self::SUBDOMAIN_INDEX_FACEBOOK_COM, - self::SUBDOMAIN_INDEX_WWW_FACEBOOK_COM, - ]); + Assert::greaterThanEq($subdomainIndex, 0); $obj = clone $this; $obj->subdomainIndex = $subdomainIndex; diff --git a/src/ValueObject/Fbc.php b/src/ValueObject/Fbc.php index 7b5ad3b..4fdc6fe 100644 --- a/src/ValueObject/Fbc.php +++ b/src/ValueObject/Fbc.php @@ -18,7 +18,7 @@ final class Fbc extends Fb * - fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA * - fb.1.1788781160733.IwAR1a-b_c.AQECAQMB */ - private const REGEXP_FBC = '/^fb\.([012])\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; + private const REGEXP_FBC = '/^fb\.(0|[1-9]\d{0,2})\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; private string $clickId; diff --git a/src/ValueObject/Fbp.php b/src/ValueObject/Fbp.php index e5a1ede..341d724 100644 --- a/src/ValueObject/Fbp.php +++ b/src/ValueObject/Fbp.php @@ -25,7 +25,7 @@ public static function fromString(string $value): self { // Must match something like this: fb.1.1656874832584.1088522659 or fb.1.1656874832584.1088522659.AQEAAQMB // NOTICE we match for 13 digits for the creation time. That number will be 14 digits in year 2286, so I guess it's safe to test for a specific number of digits ;) - if (preg_match('/^fb\.([012])\.(\d{13})\.(\d+)(?:\.([A-Za-z0-9_-]{2,8}))?$/', $value, $matches) !== 1) { + if (preg_match('/^fb\.(0|[1-9]\d{0,2})\.(\d{13})\.(\d+)(?:\.([A-Za-z0-9_-]{2,8}))?$/', $value, $matches) !== 1) { throw new InvalidArgumentException(sprintf('The value "%s" didn\'t match the expected pattern for fbp', $value)); } diff --git a/tests/ValueObject/FbTest.php b/tests/ValueObject/FbTest.php index 307d9ed..1a10b80 100644 --- a/tests/ValueObject/FbTest.php +++ b/tests/ValueObject/FbTest.php @@ -50,6 +50,7 @@ public static function subdomainIndexes(): \Generator yield 'com' => [Fb::SUBDOMAIN_INDEX_COM]; yield 'facebook.com' => [Fb::SUBDOMAIN_INDEX_FACEBOOK_COM]; yield 'www.facebook.com' => [Fb::SUBDOMAIN_INDEX_WWW_FACEBOOK_COM]; + yield 'a deeper domain' => [3]; } /** @@ -70,7 +71,7 @@ public function it_rejects_an_invalid_subdomain_index(int $subdomainIndex): void public static function invalidSubdomainIndexes(): \Generator { yield [-1]; - yield [3]; + yield [\PHP_INT_MIN]; } /** diff --git a/tests/ValueObject/FbcTest.php b/tests/ValueObject/FbcTest.php index fd7f057..83fa1e8 100644 --- a/tests/ValueObject/FbcTest.php +++ b/tests/ValueObject/FbcTest.php @@ -139,4 +139,38 @@ public function it_keeps_the_appendix_through_the_immutable_setters(): void self::assertSame('AQECAQMB', $fbc->withClickId('Other')->getAppendix()); self::assertSame('fb.2.1657051589577.IwAR1a-b_c.AQECAQMB', $fbc->withSubdomainIndex(2)->value()); } + + /** + * @test + */ + public function it_accepts_a_subdomain_index_above_two(): void + { + $fbc = Fbc::fromString('fb.3.1657051589577.ClickId'); + + self::assertSame(3, $fbc->getSubdomainIndex()); + self::assertSame('fb.3.1657051589577.ClickId', $fbc->value()); + } + + /** + * @test + * + * @dataProvider invalidSubdomainIndexes + */ + public function it_rejects_an_invalid_subdomain_index(string $subdomainIndex): void + { + $this->expectException(InvalidArgumentException::class); + + Fbc::fromString(sprintf('fb.%s.1657051589577.ClickId', $subdomainIndex)); + } + + /** + * @return \Generator + */ + public static function invalidSubdomainIndexes(): \Generator + { + yield 'a leading zero, which could not be written back unchanged' => ['01']; + yield 'negative' => ['-1']; + yield 'more digits than a domain can have dots' => ['1000']; + yield 'not a number' => ['x']; + } } diff --git a/tests/ValueObject/FbpTest.php b/tests/ValueObject/FbpTest.php index c0f4038..b992fbe 100644 --- a/tests/ValueObject/FbpTest.php +++ b/tests/ValueObject/FbpTest.php @@ -101,4 +101,38 @@ public function it_keeps_the_appendix_through_the_immutable_setters(): void self::assertSame('fb.1.1656874832584.123123.AQEAAQMB', $fbp->withRandomNumber(123123)->value()); } + + /** + * @test + */ + public function it_accepts_a_subdomain_index_above_two(): void + { + $fbp = Fbp::fromString('fb.3.1656874832584.1088522659'); + + self::assertSame(3, $fbp->getSubdomainIndex()); + self::assertSame('fb.3.1656874832584.1088522659', $fbp->value()); + } + + /** + * @test + * + * @dataProvider invalidSubdomainIndexes + */ + public function it_rejects_an_invalid_subdomain_index(string $subdomainIndex): void + { + $this->expectException(InvalidArgumentException::class); + + Fbp::fromString(sprintf('fb.%s.1656874832584.1088522659', $subdomainIndex)); + } + + /** + * @return \Generator + */ + public static function invalidSubdomainIndexes(): \Generator + { + yield 'a leading zero, which could not be written back unchanged' => ['01']; + yield 'negative' => ['-1']; + yield 'more digits than a domain can have dots' => ['1000']; + yield 'not a number' => ['x']; + } } diff --git a/tests/ValueObject/ParamBuilderConformanceTest.php b/tests/ValueObject/ParamBuilderConformanceTest.php new file mode 100644 index 0000000..04f5f26 --- /dev/null +++ b/tests/ValueObject/ParamBuilderConformanceTest.php @@ -0,0 +1,223 @@ + $clickId], []); + $after = (int) ceil(microtime(true) * 1000); + + $fbc = Fbc::fromString($value); + + self::assertSame($value, $fbc->value()); + self::assertSame($clickId, $fbc->getClickId()); + self::assertSame(1, $fbc->getSubdomainIndex()); + self::assertGreaterThanOrEqual($before, $fbc->getCreationTime()); + self::assertLessThanOrEqual($after, $fbc->getCreationTime()); + self::assertNotNull($fbc->getAppendix()); + } + + /** + * @return \Generator + */ + public static function clickIds(): \Generator + { + yield 'letters and digits' => ['IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA']; + yield 'base64url with a dash and an underscore' => ['IwZXh0bgNhZW0CMTAAAR-uK_5w']; + yield 'ending in an underscore' => ['IwY2xjawMxabc123_']; + yield 'short' => ['abc']; + } + + /** + * @test + */ + public function it_parses_the_fbc_the_builder_writes_when_the_fbclid_changes(): void + { + $value = self::fbc('www.example.com', ['fbclid' => 'NewClickId'], ['_fbc' => 'fb.1.1657051589577.OldClickId.AQECAQMB']); + + $fbc = Fbc::fromString($value); + + self::assertSame($value, $fbc->value()); + self::assertSame('NewClickId', $fbc->getClickId()); + self::assertGreaterThan(1657051589577, $fbc->getCreationTime()); + } + + /** + * @test + * + * @dataProvider hosts + * + * @param list|null $domains + */ + public function it_parses_the_fbp_the_builder_generates(string $host, ?array $domains, int $expectedSubdomainIndex): void + { + // the builder generates a random number of varying length, so a few rounds exercise more than one shape + for ($i = 0; $i < 20; ++$i) { + $value = self::fbp($host, [], [], $domains); + + $fbp = Fbp::fromString($value); + + self::assertSame($value, $fbp->value()); + self::assertSame($expectedSubdomainIndex, $fbp->getSubdomainIndex()); + self::assertSame(explode('.', $value)[3], (string) $fbp->getRandomNumber()); + self::assertNotNull($fbp->getAppendix()); + } + } + + /** + * @test + * + * @dataProvider hosts + * + * @param list|null $domains + */ + public function it_agrees_with_the_builder_on_the_subdomain_index_of_an_fbc(string $host, ?array $domains, int $expectedSubdomainIndex): void + { + $value = self::fbc($host, ['fbclid' => 'ClickId'], [], $domains); + + $fbc = Fbc::fromString($value); + + self::assertSame($value, $fbc->value()); + self::assertSame($expectedSubdomainIndex, $fbc->getSubdomainIndex()); + } + + /** + * @return \Generator|null, int}> + */ + public static function hosts(): \Generator + { + yield 'a registrable domain' => ['example.com', null, 1]; + yield 'a subdomain' => ['www.example.com', null, 1]; + yield 'a host with a port' => ['www.example.com:8443', null, 1]; + yield 'a two-label public suffix, given the list of domains' => ['shop.example.co.uk', ['example.co.uk'], 2]; + yield 'a deep host without a list of domains' => ['a.b.example.co.uk', null, 3]; + yield 'a host without dots' => ['localhost', null, 0]; + yield 'an ip address' => ['127.0.0.1', null, 0]; + } + + /** + * @test + */ + public function it_parses_the_legacy_cookies_the_builder_upgrades_with_an_appendix(): void + { + $cookies = ['_fbc' => 'fb.1.1657051589577.ClickId', '_fbp' => 'fb.1.1656874832584.1088522659']; + + $fbcValue = self::fbc('www.example.com', [], $cookies); + $fbpValue = self::fbp('www.example.com', [], $cookies); + + self::assertStringStartsWith('fb.1.1657051589577.ClickId.', $fbcValue); + self::assertStringStartsWith('fb.1.1656874832584.1088522659.', $fbpValue); + + $fbc = Fbc::fromString($fbcValue); + self::assertSame($fbcValue, $fbc->value()); + self::assertSame(1657051589577, $fbc->getCreationTime()); + self::assertSame('ClickId', $fbc->getClickId()); + self::assertSame(substr($fbcValue, (int) strrpos($fbcValue, '.') + 1), $fbc->getAppendix()); + + $fbp = Fbp::fromString($fbpValue); + self::assertSame($fbpValue, $fbp->value()); + self::assertSame(1656874832584, $fbp->getCreationTime()); + self::assertSame(1088522659, $fbp->getRandomNumber()); + self::assertSame(substr($fbpValue, (int) strrpos($fbpValue, '.') + 1), $fbp->getAppendix()); + } + + /** + * @test + * + * @dataProvider appendixes + */ + public function it_parses_the_cookies_the_builder_passes_through_unchanged(string $appendix): void + { + $cookies = [ + '_fbc' => sprintf('fb.1.1657051589577.ClickId.%s', $appendix), + '_fbp' => sprintf('fb.1.1656874832584.1088522659.%s', $appendix), + ]; + + // the builder accepts them as they are ... + self::assertSame($cookies['_fbc'], self::fbc('www.example.com', [], $cookies)); + self::assertSame($cookies['_fbp'], self::fbp('www.example.com', [], $cookies)); + + // ... and so do we + self::assertSame($cookies['_fbc'], Fbc::fromString($cookies['_fbc'])->value()); + self::assertSame($appendix, Fbc::fromString($cookies['_fbc'])->getAppendix()); + self::assertSame($cookies['_fbp'], Fbp::fromString($cookies['_fbp'])->value()); + self::assertSame($appendix, Fbp::fromString($cookies['_fbp'])->getAppendix()); + } + + /** + * @return \Generator + */ + public static function appendixes(): \Generator + { + yield 'eight characters: a new value' => ['AQECAQMB']; + yield 'eight characters: an unchanged value' => ['AQEAAQMB']; + yield 'eight characters: a modified value' => ['AQEDAQMB']; + + // the two-character language tokens written by earlier versions of Meta's builders + foreach (['AQ', 'Ag', 'Aw', 'BA', 'BQ', 'Bg'] as $languageToken) { + yield sprintf('the language token %s', $languageToken) => [$languageToken]; + } + } + + /** + * @param array $query + * @param array $cookies + * @param list|null $domains + */ + private static function fbc(string $host, array $query, array $cookies, ?array $domains = null): string + { + $value = self::process($host, $query, $cookies, $domains)->getFbc(); + self::assertIsString($value); + + return $value; + } + + /** + * @param array $query + * @param array $cookies + * @param list|null $domains + */ + private static function fbp(string $host, array $query, array $cookies, ?array $domains = null): string + { + $value = self::process($host, $query, $cookies, $domains)->getFbp(); + self::assertIsString($value); + + return $value; + } + + /** + * @param array $query + * @param array $cookies + * @param list|null $domains + */ + private static function process(string $host, array $query, array $cookies, ?array $domains): ParamBuilder + { + $paramBuilder = new ParamBuilder($domains); + $paramBuilder->processRequest($host, $query, $cookies); + + return $paramBuilder; + } +}