Add first-party analytics tracker with encrypted transmissions
Core system: - AnalyticsUniqId entity (visitor identity with device/os/browser parsing) - AnalyticsEvent entity (page views linked to visitor) - POST /t endpoint with AES-256-GCM encrypted payloads - HMAC-SHA256 visitor hash for anti-tampering - Async processing via Messenger - JS module: auto page_view tracking, setAuth for logged users - Encryption key shared via data-k attribute on body - setAuth only triggers when cookie consent is accepted - Clean CSP: remove old tracker domains (Cloudflare, Umami) 100% first-party, no cookies, invisible to adblockers, RGPD-friendly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
tests/Entity/AnalyticsUniqIdTest.php
Normal file
44
tests/Entity/AnalyticsUniqIdTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AnalyticsUniqIdTest extends TestCase
|
||||
{
|
||||
public function testParseDeviceTypeMobile(): void
|
||||
{
|
||||
self::assertSame('mobile', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'));
|
||||
self::assertSame('mobile', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0 Mobile Safari/537.36'));
|
||||
}
|
||||
|
||||
public function testParseDeviceTypeTablet(): void
|
||||
{
|
||||
self::assertSame('tablet', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)'));
|
||||
}
|
||||
|
||||
public function testParseDeviceTypeDesktop(): void
|
||||
{
|
||||
self::assertSame('desktop', AnalyticsUniqId::parseDeviceType('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'));
|
||||
}
|
||||
|
||||
public function testParseOs(): void
|
||||
{
|
||||
self::assertSame('Windows', AnalyticsUniqId::parseOs('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'));
|
||||
self::assertSame('macOS', AnalyticsUniqId::parseOs('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'));
|
||||
self::assertSame('iOS', AnalyticsUniqId::parseOs('Mozilla/5.0 (iPhone; CPU iPhone OS 16_0)'));
|
||||
self::assertSame('Android', AnalyticsUniqId::parseOs('Mozilla/5.0 (Linux; Android 13)'));
|
||||
self::assertSame('Linux', AnalyticsUniqId::parseOs('Mozilla/5.0 (X11; Linux x86_64)'));
|
||||
self::assertNull(AnalyticsUniqId::parseOs('UnknownBot/1.0'));
|
||||
}
|
||||
|
||||
public function testParseBrowser(): void
|
||||
{
|
||||
self::assertSame('Chrome', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Windows NT 10.0) Chrome/112.0.0.0 Safari/537.36'));
|
||||
self::assertSame('Firefox', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Windows NT 10.0) Gecko/20100101 Firefox/112.0'));
|
||||
self::assertSame('Safari', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 Safari/605.1.15'));
|
||||
self::assertSame('Edge', AnalyticsUniqId::parseBrowser('Mozilla/5.0 (Windows NT 10.0) Chrome/112.0 Edg/112.0'));
|
||||
self::assertNull(AnalyticsUniqId::parseBrowser('UnknownBot/1.0'));
|
||||
}
|
||||
}
|
||||
89
tests/MessageHandler/AnalyticsMessageHandlerTest.php
Normal file
89
tests/MessageHandler/AnalyticsMessageHandlerTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\MessageHandler;
|
||||
|
||||
use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\User;
|
||||
use App\Message\AnalyticsMessage;
|
||||
use App\MessageHandler\AnalyticsMessageHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AnalyticsMessageHandlerTest extends TestCase
|
||||
{
|
||||
private function createVisitor(): AnalyticsUniqId
|
||||
{
|
||||
$visitor = new AnalyticsUniqId();
|
||||
$visitor->setUid('test-uid');
|
||||
$visitor->setHash('test-hash');
|
||||
$visitor->setIpHash('test-ip');
|
||||
$visitor->setUserAgent('test-ua');
|
||||
|
||||
return $visitor;
|
||||
}
|
||||
|
||||
public function testPageViewCreatesEvent(): void
|
||||
{
|
||||
$visitor = $this->createVisitor();
|
||||
|
||||
$visitorRepo = $this->createMock(EntityRepository::class);
|
||||
$visitorRepo->method('findOneBy')->with(['uid' => 'test-uid'])->willReturn($visitor);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturn($visitorRepo);
|
||||
$em->expects(self::once())->method('persist')->with(self::callback(
|
||||
fn (AnalyticsEvent $e) => 'page_view' === $e->getEventName() && '/test' === $e->getUrl() && 'Test' === $e->getTitle()
|
||||
));
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$handler = new AnalyticsMessageHandler($em);
|
||||
$handler(new AnalyticsMessage('test-uid', 'page_view', [
|
||||
'url' => '/test',
|
||||
'title' => 'Test',
|
||||
'referrer' => 'https://google.com',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testSetUserLinksVisitorToUser(): void
|
||||
{
|
||||
$visitor = $this->createVisitor();
|
||||
$user = new User();
|
||||
$user->setEmail('test@test.fr');
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setPassword('hashed');
|
||||
|
||||
$visitorRepo = $this->createMock(EntityRepository::class);
|
||||
$visitorRepo->method('findOneBy')->willReturn($visitor);
|
||||
|
||||
$userRepo = $this->createMock(EntityRepository::class);
|
||||
$userRepo->method('find')->with(42)->willReturn($user);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturnCallback(function (string $class) use ($visitorRepo, $userRepo) {
|
||||
return AnalyticsUniqId::class === $class ? $visitorRepo : $userRepo;
|
||||
});
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$handler = new AnalyticsMessageHandler($em);
|
||||
$handler(new AnalyticsMessage('test-uid', 'set_user', ['userId' => 42]));
|
||||
|
||||
self::assertSame($user, $visitor->getUser());
|
||||
}
|
||||
|
||||
public function testUnknownVisitorIsIgnored(): void
|
||||
{
|
||||
$repo = $this->createMock(EntityRepository::class);
|
||||
$repo->method('findOneBy')->willReturn(null);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturn($repo);
|
||||
$em->expects(self::never())->method('persist');
|
||||
$em->expects(self::never())->method('flush');
|
||||
|
||||
$handler = new AnalyticsMessageHandler($em);
|
||||
$handler(new AnalyticsMessage('unknown', 'page_view', ['url' => '/']));
|
||||
}
|
||||
}
|
||||
85
tests/Service/AnalyticsCryptoServiceTest.php
Normal file
85
tests/Service/AnalyticsCryptoServiceTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\AnalyticsCryptoService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AnalyticsCryptoServiceTest extends TestCase
|
||||
{
|
||||
private AnalyticsCryptoService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->service = new AnalyticsCryptoService('test_secret_key_for_analytics');
|
||||
}
|
||||
|
||||
public function testEncryptDecryptRoundTrip(): void
|
||||
{
|
||||
$data = ['uid' => 'abc-123', 'url' => '/test', 'title' => 'Test Page'];
|
||||
|
||||
$encrypted = $this->service->encrypt($data);
|
||||
self::assertNotEmpty($encrypted);
|
||||
|
||||
$decrypted = $this->service->decrypt($encrypted);
|
||||
self::assertSame($data, $decrypted);
|
||||
}
|
||||
|
||||
public function testDecryptInvalidPayloadReturnsNull(): void
|
||||
{
|
||||
self::assertNull($this->service->decrypt('invalid_base64'));
|
||||
self::assertNull($this->service->decrypt(base64_encode('short')));
|
||||
}
|
||||
|
||||
public function testDecryptTamperedPayloadReturnsNull(): void
|
||||
{
|
||||
$encrypted = $this->service->encrypt(['test' => true]);
|
||||
$tampered = substr($encrypted, 0, -2).'AA';
|
||||
|
||||
self::assertNull($this->service->decrypt($tampered));
|
||||
}
|
||||
|
||||
public function testGenerateAndVerifyVisitorHash(): void
|
||||
{
|
||||
$uid = 'test-uid-123';
|
||||
$hash = $this->service->generateVisitorHash($uid);
|
||||
|
||||
self::assertNotEmpty($hash);
|
||||
self::assertTrue($this->service->verifyVisitorHash($uid, $hash));
|
||||
}
|
||||
|
||||
public function testVerifyVisitorHashRejectsTampered(): void
|
||||
{
|
||||
$uid = 'test-uid-123';
|
||||
$hash = $this->service->generateVisitorHash($uid);
|
||||
|
||||
self::assertFalse($this->service->verifyVisitorHash('different-uid', $hash));
|
||||
self::assertFalse($this->service->verifyVisitorHash($uid, 'wrong_hash'));
|
||||
}
|
||||
|
||||
public function testGetKeyForJsReturnsBase64(): void
|
||||
{
|
||||
$key = $this->service->getKeyForJs();
|
||||
self::assertNotEmpty($key);
|
||||
self::assertNotFalse(base64_decode($key, true));
|
||||
}
|
||||
|
||||
public function testDifferentSecretsProduceDifferentKeys(): void
|
||||
{
|
||||
$service2 = new AnalyticsCryptoService('different_secret');
|
||||
|
||||
$uid = 'test-uid';
|
||||
$hash1 = $this->service->generateVisitorHash($uid);
|
||||
$hash2 = $service2->generateVisitorHash($uid);
|
||||
|
||||
self::assertNotSame($hash1, $hash2);
|
||||
}
|
||||
|
||||
public function testEncryptedDataCannotBeDecryptedByDifferentKey(): void
|
||||
{
|
||||
$service2 = new AnalyticsCryptoService('different_secret');
|
||||
|
||||
$encrypted = $this->service->encrypt(['test' => true]);
|
||||
self::assertNull($service2->decrypt($encrypted));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user