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:
Serreau Jovann
2026-03-26 11:52:07 +01:00
parent 3a85b6ef68
commit 6438afadbf
17 changed files with 1007 additions and 12 deletions

View 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' => '/']));
}
}