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

@@ -12,6 +12,7 @@ import { initCart } from "./modules/cart.js"
import { initStripePayment } from "./modules/stripe-payment.js"
import { initShare } from "./modules/share.js"
import { initApiEnvSwitcher } from "./modules/api-env-switcher.js"
import { initAnalytics } from "./modules/analytics.js"
document.addEventListener('DOMContentLoaded', () => {
initMobileMenu()
@@ -27,6 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
initStripePayment()
initShare()
initApiEnvSwitcher()
initAnalytics()
document.querySelectorAll('[data-confirm]').forEach(form => {
form.addEventListener('submit', (e) => {

114
assets/modules/analytics.js Normal file
View File

@@ -0,0 +1,114 @@
const ENDPOINT = '/t'
const SK_UID = '_u'
const SK_HASH = '_h'
let encKey = null
async function importKey(b64) {
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
}
async function encrypt(data) {
if (!encKey) return null
const json = new TextEncoder().encode(JSON.stringify(data))
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, encKey, json)
const buf = new Uint8Array(encrypted)
const combined = new Uint8Array(12 + buf.length)
combined.set(iv)
combined.set(buf, 12)
return btoa(String.fromCharCode(...combined))
}
async function decrypt(b64) {
if (!encKey) return null
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
const iv = raw.slice(0, 12)
const data = raw.slice(12)
try {
const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, encKey, data)
return JSON.parse(new TextDecoder().decode(decrypted))
} catch {
return null
}
}
async function send(data, expectResponse = false) {
const d = await encrypt(data)
if (!d) return null
try {
if (!expectResponse && navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, JSON.stringify({ d }))
return null
}
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ d }),
keepalive: true,
})
if (!res.ok || res.status === 204) return null
const json = await res.json()
return json.d ? await decrypt(json.d) : null
} catch {
return null
}
}
async function getOrCreateVisitor() {
let uid = sessionStorage.getItem(SK_UID)
let hash = sessionStorage.getItem(SK_HASH)
if (uid && hash) return { uid, hash }
const resp = await send({
sw: screen.width,
sh: screen.height,
l: navigator.language || null,
}, true)
if (!resp || !resp.uid || !resp.h) return null
sessionStorage.setItem(SK_UID, resp.uid)
sessionStorage.setItem(SK_HASH, resp.h)
return { uid: resp.uid, hash: resp.h }
}
async function trackPageView(visitor) {
await send({
uid: visitor.uid,
h: visitor.hash,
u: location.pathname + location.search,
t: document.title,
r: document.referrer || null,
})
}
export async function initAnalytics() {
const keyB64 = document.body.dataset.k
if (!keyB64 || document.body.dataset.env === 'dev') return
try {
encKey = await importKey(keyB64)
} catch {
return
}
const visitor = await getOrCreateVisitor()
if (!visitor) return
await trackPageView(visitor)
const authUserId = document.body.dataset.uid
if (authUserId) {
await setAuth(parseInt(authUserId, 10))
}
}
export async function setAuth(userId) {
const uid = sessionStorage.getItem(SK_UID)
const hash = sessionStorage.getItem(SK_HASH)
if (!uid || !hash || !encKey) return
await send({ uid, h: hash, setUser: userId })
}

View File

@@ -23,3 +23,4 @@ framework:
Symfony\Component\Notifier\Message\SmsMessage: async
App\Message\MeilisearchMessage: async
App\Message\AnalyticsMessage: async

View File

@@ -26,13 +26,9 @@ nelmio_security:
- 'https://stripe.com'
- 'https://*.stripe.com'
- 'https://js.stripe.com'
- 'https://cloudflare.com'
- 'https://*.cloudflareinsights.com'
- 'https://challenges.cloudflare.com'
script-src:
- 'self'
- 'https://static.cloudflareinsights.com'
- 'https://tools-security.esy-web.dev'
- 'https://challenges.cloudflare.com'
- 'https://cdn.jsdelivr.net'
- 'https://js.stripe.com'
@@ -54,9 +50,6 @@ nelmio_security:
- 'blob:'
connect-src:
- 'self'
- 'https://cloudflareinsights.com'
- 'https://static.cloudflareinsights.com'
- 'https://tools-security.esy-web.dev'
- 'https://challenges.cloudflare.com'
- 'https://nominatim.openstreetmap.org'
- 'https://cdn.jsdelivr.net'
@@ -88,8 +81,6 @@ nelmio_security:
forward_as: redirUrl
log: true
allow_list:
- cloudflareinsights.com
- static.cloudflareinsights.com
- stripe.com
- connect.stripe.com
- checkout.stripe.com

View File

@@ -4,8 +4,6 @@ nelmio_security:
script-src:
- 'self'
- 'nonce'
- 'https://static.cloudflareinsights.com'
- 'https://tools-security.esy-web.dev'
# Restreindre les soumissions de formulaires à notre domaine
# et aux redirections OAuth des plateformes de partage social

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260326105040 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE analytics_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, event_name VARCHAR(50) NOT NULL, url VARCHAR(2048) NOT NULL, title VARCHAR(255) DEFAULT NULL, referrer VARCHAR(2048) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, visitor_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_9CD0310A70BEE6D ON analytics_event (visitor_id)');
$this->addSql('CREATE INDEX idx_ae_event ON analytics_event (event_name)');
$this->addSql('CREATE INDEX idx_ae_created ON analytics_event (created_at)');
$this->addSql('CREATE TABLE analytics_uniq_id (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, uid VARCHAR(36) NOT NULL, hash VARCHAR(64) NOT NULL, ip_hash VARCHAR(64) NOT NULL, user_agent VARCHAR(512) NOT NULL, screen_width INT DEFAULT NULL, screen_height INT DEFAULT NULL, language VARCHAR(10) DEFAULT NULL, device_type VARCHAR(10) NOT NULL, os VARCHAR(30) DEFAULT NULL, browser VARCHAR(30) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_65C10CC1539B0606 ON analytics_uniq_id (uid)');
$this->addSql('CREATE INDEX IDX_65C10CC1A76ED395 ON analytics_uniq_id (user_id)');
$this->addSql('CREATE INDEX idx_analytics_ip ON analytics_uniq_id (ip_hash)');
$this->addSql('CREATE INDEX idx_analytics_created ON analytics_uniq_id (created_at)');
$this->addSql('ALTER TABLE analytics_event ADD CONSTRAINT FK_9CD0310A70BEE6D FOREIGN KEY (visitor_id) REFERENCES analytics_uniq_id (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE analytics_uniq_id ADD CONSTRAINT FK_65C10CC1A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE analytics_event DROP CONSTRAINT FK_9CD0310A70BEE6D');
$this->addSql('ALTER TABLE analytics_uniq_id DROP CONSTRAINT FK_65C10CC1A76ED395');
$this->addSql('DROP TABLE analytics_event');
$this->addSql('DROP TABLE analytics_uniq_id');
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Controller;
use App\Entity\AnalyticsUniqId;
use App\Message\AnalyticsMessage;
use App\Service\AnalyticsCryptoService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
class AnalyticsController extends AbstractController
{
#[Route('/t', name: 'app_analytics_track', methods: ['POST'])]
public function track(
Request $request,
AnalyticsCryptoService $crypto,
EntityManagerInterface $em,
MessageBusInterface $bus,
): Response {
$body = $request->getContent();
$envelope = json_decode($body, true);
if (!$envelope || !isset($envelope['d'])) {
return new Response('', 400);
}
$data = $crypto->decrypt($envelope['d']);
if (null === $data) {
return new Response('', 403);
}
$uid = $data['uid'] ?? null;
$hash = $data['h'] ?? null;
// No uid = create new visitor
if (!$uid) {
$visitor = $this->createVisitor($request, $data, $crypto, $em);
$responseData = $crypto->encrypt([
'uid' => $visitor->getUid(),
'h' => $visitor->getHash(),
]);
return new JsonResponse(['d' => $responseData]);
}
// Verify hash
if (!$hash || !$crypto->verifyVisitorHash($uid, $hash)) {
return new Response('', 403);
}
// setUser
if (isset($data['setUser'])) {
$bus->dispatch(new AnalyticsMessage($uid, 'set_user', ['userId' => (int) $data['setUser']]));
return new Response('', 204);
}
// page_view
$bus->dispatch(new AnalyticsMessage($uid, 'page_view', [
'url' => $data['u'] ?? '/',
'title' => $data['t'] ?? null,
'referrer' => $data['r'] ?? null,
]));
return new Response('', 204);
}
private function createVisitor(
Request $request,
array $data,
AnalyticsCryptoService $crypto,
EntityManagerInterface $em,
): AnalyticsUniqId {
$uid = Uuid::v4()->toRfc4122();
$ua = $request->headers->get('User-Agent', '');
$visitor = new AnalyticsUniqId();
$visitor->setUid($uid);
$visitor->setHash($crypto->generateVisitorHash($uid));
$visitor->setIpHash(hash('sha256', $request->getClientIp() ?? ''));
$visitor->setUserAgent(substr($ua, 0, 512));
$visitor->setScreenWidth(isset($data['sw']) ? (int) $data['sw'] : null);
$visitor->setScreenHeight(isset($data['sh']) ? (int) $data['sh'] : null);
$visitor->setLanguage(isset($data['l']) ? substr((string) $data['l'], 0, 10) : null);
$visitor->setDeviceType(AnalyticsUniqId::parseDeviceType($ua));
$visitor->setOs(AnalyticsUniqId::parseOs($ua));
$visitor->setBrowser(AnalyticsUniqId::parseBrowser($ua));
$em->persist($visitor);
$em->flush();
return $visitor;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['event_name'], name: 'idx_ae_event')]
#[ORM\Index(columns: ['created_at'], name: 'idx_ae_created')]
class AnalyticsEvent
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: AnalyticsUniqId::class, inversedBy: 'events')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private AnalyticsUniqId $visitor;
#[ORM\Column(length: 50)]
private string $eventName = 'page_view';
#[ORM\Column(length: 2048)]
private string $url;
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 2048, nullable: true)]
private ?string $referrer = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getVisitor(): AnalyticsUniqId
{
return $this->visitor;
}
public function setVisitor(AnalyticsUniqId $visitor): static
{
$this->visitor = $visitor;
return $this;
}
public function getEventName(): string
{
return $this->eventName;
}
public function setEventName(string $eventName): static
{
$this->eventName = $eventName;
return $this;
}
public function getUrl(): string
{
return $this->url;
}
public function setUrl(string $url): static
{
$this->url = $url;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getReferrer(): ?string
{
return $this->referrer;
}
public function setReferrer(?string $referrer): static
{
$this->referrer = $referrer;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['ip_hash'], name: 'idx_analytics_ip')]
#[ORM\Index(columns: ['created_at'], name: 'idx_analytics_created')]
class AnalyticsUniqId
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 36, unique: true)]
private string $uid;
#[ORM\Column(length: 64)]
private string $hash;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $user = null;
#[ORM\Column(length: 64)]
private string $ipHash;
#[ORM\Column(length: 512)]
private string $userAgent;
#[ORM\Column(nullable: true)]
private ?int $screenWidth = null;
#[ORM\Column(nullable: true)]
private ?int $screenHeight = null;
#[ORM\Column(length: 10, nullable: true)]
private ?string $language = null;
#[ORM\Column(length: 10)]
private string $deviceType = 'desktop';
#[ORM\Column(length: 30, nullable: true)]
private ?string $os = null;
#[ORM\Column(length: 30, nullable: true)]
private ?string $browser = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
/** @var Collection<int, AnalyticsEvent> */
#[ORM\OneToMany(targetEntity: AnalyticsEvent::class, mappedBy: 'visitor', cascade: ['remove'])]
private Collection $events;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->events = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUid(): string
{
return $this->uid;
}
public function setUid(string $uid): static
{
$this->uid = $uid;
return $this;
}
public function getHash(): string
{
return $this->hash;
}
public function setHash(string $hash): static
{
$this->hash = $hash;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getIpHash(): string
{
return $this->ipHash;
}
public function setIpHash(string $ipHash): static
{
$this->ipHash = $ipHash;
return $this;
}
public function getUserAgent(): string
{
return $this->userAgent;
}
public function setUserAgent(string $userAgent): static
{
$this->userAgent = $userAgent;
return $this;
}
public function getScreenWidth(): ?int
{
return $this->screenWidth;
}
public function setScreenWidth(?int $screenWidth): static
{
$this->screenWidth = $screenWidth;
return $this;
}
public function getScreenHeight(): ?int
{
return $this->screenHeight;
}
public function setScreenHeight(?int $screenHeight): static
{
$this->screenHeight = $screenHeight;
return $this;
}
public function getLanguage(): ?string
{
return $this->language;
}
public function setLanguage(?string $language): static
{
$this->language = $language;
return $this;
}
public function getDeviceType(): string
{
return $this->deviceType;
}
public function setDeviceType(string $deviceType): static
{
$this->deviceType = $deviceType;
return $this;
}
public function getOs(): ?string
{
return $this->os;
}
public function setOs(?string $os): static
{
$this->os = $os;
return $this;
}
public function getBrowser(): ?string
{
return $this->browser;
}
public function setBrowser(?string $browser): static
{
$this->browser = $browser;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
/**
* @return Collection<int, AnalyticsEvent>
*/
public function getEvents(): Collection
{
return $this->events;
}
public static function parseDeviceType(string $ua): string
{
$ua = strtolower($ua);
if (preg_match('/tablet|ipad|playbook|silk/i', $ua)) {
return 'tablet';
}
if (preg_match('/mobile|iphone|ipod|android.*mobile|windows phone|blackberry/i', $ua)) {
return 'mobile';
}
return 'desktop';
}
public static function parseOs(string $ua): ?string
{
$patterns = [
'/windows nt/i' => 'Windows',
'/macintosh|mac os x/i' => 'macOS',
'/iphone|ipad|ipod/i' => 'iOS',
'/android/i' => 'Android',
'/linux/i' => 'Linux',
'/cros/i' => 'ChromeOS',
];
foreach ($patterns as $pattern => $name) {
if (preg_match($pattern, $ua)) {
return $name;
}
}
return null;
}
public static function parseBrowser(string $ua): ?string
{
$patterns = [
'/edg(?:e|a)?\/[\d.]+/i' => 'Edge',
'/opr\/[\d.]+|opera/i' => 'Opera',
'/chrome\/[\d.]+(?!.*edg)/i' => 'Chrome',
'/safari\/[\d.]+(?!.*chrome)/i' => 'Safari',
'/firefox\/[\d.]+/i' => 'Firefox',
];
foreach ($patterns as $pattern => $name) {
if (preg_match($pattern, $ua)) {
return $name;
}
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Message;
class AnalyticsMessage
{
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public readonly string $uid,
public readonly string $action,
public readonly array $payload = [],
) {
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\MessageHandler;
use App\Entity\AnalyticsEvent;
use App\Entity\AnalyticsUniqId;
use App\Entity\User;
use App\Message\AnalyticsMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class AnalyticsMessageHandler
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function __invoke(AnalyticsMessage $message): void
{
$visitor = $this->em->getRepository(AnalyticsUniqId::class)->findOneBy(['uid' => $message->uid]);
if (!$visitor) {
return;
}
if ('set_user' === $message->action) {
$userId = $message->payload['userId'] ?? null;
if ($userId) {
$user = $this->em->getRepository(User::class)->find($userId);
if ($user) {
$visitor->setUser($user);
$this->em->flush();
}
}
return;
}
if ('page_view' === $message->action) {
$event = new AnalyticsEvent();
$event->setVisitor($visitor);
$event->setEventName('page_view');
$event->setUrl($message->payload['url'] ?? '/');
$event->setTitle($message->payload['title'] ?? null);
$event->setReferrer($message->payload['referrer'] ?? null);
$this->em->persist($event);
$this->em->flush();
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class AnalyticsCryptoService
{
private string $key;
public function __construct(
#[Autowire('%kernel.secret%')] string $appSecret,
) {
$this->key = substr(hash('sha256', $appSecret.'_analytics', true), 0, 32);
}
public function encrypt(array $data): string
{
$json = json_encode($data, \JSON_THROW_ON_ERROR);
$iv = random_bytes(12);
$encrypted = openssl_encrypt($json, 'aes-256-gcm', $this->key, \OPENSSL_RAW_DATA, $iv, $tag, '', 16);
return base64_encode($iv.$tag.$encrypted);
}
public function decrypt(string $payload): ?array
{
$raw = base64_decode($payload, true);
if (false === $raw || \strlen($raw) < 28) {
return null;
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$encrypted = substr($raw, 28);
$json = openssl_decrypt($encrypted, 'aes-256-gcm', $this->key, \OPENSSL_RAW_DATA, $iv, $tag);
if (false === $json) {
return null;
}
return json_decode($json, true);
}
public function generateVisitorHash(string $uid): string
{
return hash_hmac('sha256', $uid, $this->key);
}
public function verifyVisitorHash(string $uid, string $hash): bool
{
return hash_equals($this->generateVisitorHash($uid), $hash);
}
public function getKeyForJs(): string
{
return base64_encode($this->key);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Twig;
use App\Service\AnalyticsCryptoService;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
class AnalyticsExtension extends AbstractExtension implements GlobalsInterface
{
public function __construct(
private AnalyticsCryptoService $crypto,
) {
}
public function getGlobals(): array
{
return [
'analytics_key' => $this->crypto->getKeyForJs(),
];
}
}

View File

@@ -84,7 +84,7 @@
{% endblock %}
{% block head %}{% endblock %}
</head>
<body class="min-h-screen flex flex-col bg-[#fbfbfb] text-[#111827]" data-env="{{ app.environment }}">
<body class="min-h-screen flex flex-col bg-[#fbfbfb] text-[#111827]" data-env="{{ app.environment }}" data-k="{{ analytics_key }}"{% if app.user and app.user.id is defined and app.request.cookies.get('e_ticket_consent') == 'accepted' %} data-uid="{{ app.user.id }}"{% endif %}>
<header class="sticky top-0 z-50 bg-white border-b-4 border-gray-900">
<nav class="mx-auto px-4 lg:px-8" role="navigation" aria-label="Navigation principale" itemscope itemtype="https://schema.org/SiteNavigationElement">
<div class="flex justify-between items-center h-20">

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

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

View 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));
}
}