- Test token validation (invalid token returns 404) - Test request validation (missing body, missing 'd' field, invalid JSON return 400) - Test decryption validation (invalid encrypted data returns 403) - Test new visitor creation with full fields, optional fields, mobile/tablet UA - Test page view dispatch with valid hash, default values - Test page view rejection with invalid/missing hash (403) - Test setUser dispatch with valid hash - Test visitor UID format (UUID v4), IP hash, UA truncation, language truncation - Test response hash is verifiable by crypto service Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
461 lines
18 KiB
PHP
461 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Controller;
|
|
|
|
use App\Controller\AnalyticsController;
|
|
use App\Entity\AnalyticsUniqId;
|
|
use App\Message\AnalyticsMessage;
|
|
use App\Service\AnalyticsCryptoService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\Messenger\Envelope;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
|
|
class AnalyticsControllerTest extends TestCase
|
|
{
|
|
private const ANALYTICS_SECRET = 'test_analytics_secret';
|
|
|
|
private AnalyticsCryptoService $crypto;
|
|
private string $validToken;
|
|
private AnalyticsController $controller;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->crypto = new AnalyticsCryptoService(self::ANALYTICS_SECRET);
|
|
$this->validToken = substr(hash('sha256', self::ANALYTICS_SECRET.'_endpoint'), 0, 8);
|
|
$this->controller = new AnalyticsController();
|
|
}
|
|
|
|
private function createRequest(string $body, string $userAgent = 'TestAgent/1.0'): Request
|
|
{
|
|
$request = Request::create('/t/'.$this->validToken, 'POST', [], [], [], [
|
|
'HTTP_USER_AGENT' => $userAgent,
|
|
], $body);
|
|
$request->headers->set('Content-Type', 'application/json');
|
|
|
|
return $request;
|
|
}
|
|
|
|
private function createMockEm(?AnalyticsUniqId $persisted = null): EntityManagerInterface
|
|
{
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
if ($persisted) {
|
|
$em->expects(self::once())->method('persist')->with(self::isInstanceOf(AnalyticsUniqId::class));
|
|
$em->expects(self::once())->method('flush');
|
|
}
|
|
|
|
return $em;
|
|
}
|
|
|
|
private function createMockBus(): MessageBusInterface
|
|
{
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
$bus->method('dispatch')->willReturnCallback(fn ($msg) => new Envelope($msg));
|
|
|
|
return $bus;
|
|
}
|
|
|
|
public function testInvalidTokenReturns404(): void
|
|
{
|
|
$request = $this->createRequest(json_encode(['d' => 'anything']));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track('badtoken', self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(404, $response->getStatusCode());
|
|
}
|
|
|
|
public function testMissingBodyReturns400(): void
|
|
{
|
|
$request = $this->createRequest('');
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(400, $response->getStatusCode());
|
|
}
|
|
|
|
public function testMissingDFieldReturns400(): void
|
|
{
|
|
$request = $this->createRequest(json_encode(['foo' => 'bar']));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(400, $response->getStatusCode());
|
|
}
|
|
|
|
public function testInvalidJsonBodyReturns400(): void
|
|
{
|
|
$request = $this->createRequest('not-json');
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(400, $response->getStatusCode());
|
|
}
|
|
|
|
public function testInvalidEncryptedDataReturns403(): void
|
|
{
|
|
$request = $this->createRequest(json_encode(['d' => 'invalid_encrypted_payload']));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(403, $response->getStatusCode());
|
|
}
|
|
|
|
public function testNewVisitorWithoutUidCreatesVisitorAndReturnsUid(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([
|
|
'sw' => 1920,
|
|
'sh' => 1080,
|
|
'l' => 'fr-FR',
|
|
]);
|
|
$request = $this->createRequest(
|
|
json_encode(['d' => $encrypted]),
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
);
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertInstanceOf(JsonResponse::class, $response);
|
|
|
|
$body = json_decode($response->getContent(), true);
|
|
self::assertArrayHasKey('d', $body);
|
|
|
|
$decrypted = $this->crypto->decrypt($body['d']);
|
|
self::assertArrayHasKey('uid', $decrypted);
|
|
self::assertArrayHasKey('h', $decrypted);
|
|
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertSame(1920, $persistedVisitor->getScreenWidth());
|
|
self::assertSame(1080, $persistedVisitor->getScreenHeight());
|
|
self::assertSame('fr-FR', $persistedVisitor->getLanguage());
|
|
self::assertSame('desktop', $persistedVisitor->getDeviceType());
|
|
self::assertSame('Windows', $persistedVisitor->getOs());
|
|
self::assertSame('Chrome', $persistedVisitor->getBrowser());
|
|
}
|
|
|
|
public function testNewVisitorWithoutOptionalFieldsCreatesVisitor(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertNull($persistedVisitor->getScreenWidth());
|
|
self::assertNull($persistedVisitor->getScreenHeight());
|
|
self::assertNull($persistedVisitor->getLanguage());
|
|
}
|
|
|
|
public function testNewVisitorMobileUserAgent(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(
|
|
json_encode(['d' => $encrypted]),
|
|
'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
|
|
);
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertSame('mobile', $persistedVisitor->getDeviceType());
|
|
self::assertSame('Android', $persistedVisitor->getOs());
|
|
self::assertSame('Chrome', $persistedVisitor->getBrowser());
|
|
}
|
|
|
|
public function testNewVisitorTabletUserAgent(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(
|
|
json_encode(['d' => $encrypted]),
|
|
'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
|
|
);
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertSame('tablet', $persistedVisitor->getDeviceType());
|
|
}
|
|
|
|
public function testPageViewWithValidHashDispatches204(): void
|
|
{
|
|
$uid = 'test-uid-123';
|
|
$hash = $this->crypto->generateVisitorHash($uid);
|
|
$encrypted = $this->crypto->encrypt([
|
|
'uid' => $uid,
|
|
'h' => $hash,
|
|
'u' => '/test-page',
|
|
't' => 'Test Page',
|
|
'r' => 'https://google.com',
|
|
]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
$bus->expects(self::once())->method('dispatch')->with(self::callback(
|
|
fn (AnalyticsMessage $msg) => $msg->uid === $uid
|
|
&& 'page_view' === $msg->action
|
|
&& '/test-page' === $msg->payload['url']
|
|
&& 'Test Page' === $msg->payload['title']
|
|
&& 'https://google.com' === $msg->payload['referrer']
|
|
))->willReturnCallback(fn ($msg) => new Envelope($msg));
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(204, $response->getStatusCode());
|
|
}
|
|
|
|
public function testPageViewWithDefaultValues(): void
|
|
{
|
|
$uid = 'test-uid-456';
|
|
$hash = $this->crypto->generateVisitorHash($uid);
|
|
$encrypted = $this->crypto->encrypt([
|
|
'uid' => $uid,
|
|
'h' => $hash,
|
|
]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
$bus->expects(self::once())->method('dispatch')->with(self::callback(
|
|
fn (AnalyticsMessage $msg) => '/' === $msg->payload['url']
|
|
&& null === $msg->payload['title']
|
|
&& null === $msg->payload['referrer']
|
|
))->willReturnCallback(fn ($msg) => new Envelope($msg));
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(204, $response->getStatusCode());
|
|
}
|
|
|
|
public function testPageViewWithInvalidHashReturns403(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([
|
|
'uid' => 'some-uid',
|
|
'h' => 'invalid_hash',
|
|
'u' => '/test',
|
|
]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
$bus->expects(self::never())->method('dispatch');
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(403, $response->getStatusCode());
|
|
}
|
|
|
|
public function testPageViewWithMissingHashReturns403(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([
|
|
'uid' => 'some-uid',
|
|
'u' => '/test',
|
|
]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
$bus->expects(self::never())->method('dispatch');
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(403, $response->getStatusCode());
|
|
}
|
|
|
|
public function testSetUserWithValidHashDispatches204(): void
|
|
{
|
|
$uid = 'test-uid-789';
|
|
$hash = $this->crypto->generateVisitorHash($uid);
|
|
$encrypted = $this->crypto->encrypt([
|
|
'uid' => $uid,
|
|
'h' => $hash,
|
|
'setUser' => 42,
|
|
]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
$bus->expects(self::once())->method('dispatch')->with(self::callback(
|
|
fn (AnalyticsMessage $msg) => $msg->uid === $uid
|
|
&& 'set_user' === $msg->action
|
|
&& 42 === $msg->payload['userId']
|
|
))->willReturnCallback(fn ($msg) => new Envelope($msg));
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertSame(204, $response->getStatusCode());
|
|
}
|
|
|
|
public function testVisitorUidFormatIsValid(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertMatchesRegularExpression(
|
|
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
|
|
$persistedVisitor->getUid()
|
|
);
|
|
}
|
|
|
|
public function testVisitorIpHashIsSet(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertSame(64, \strlen($persistedVisitor->getIpHash()));
|
|
}
|
|
|
|
public function testVisitorUserAgentIsTruncatedTo512(): void
|
|
{
|
|
$longUa = str_repeat('A', 600);
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]), $longUa);
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertSame(512, \strlen($persistedVisitor->getUserAgent()));
|
|
}
|
|
|
|
public function testVisitorLanguageIsTruncatedTo10(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt(['l' => 'fr-FR-extra-long']);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
|
|
$persistedVisitor = null;
|
|
$em = $this->createMock(EntityManagerInterface::class);
|
|
$em->expects(self::once())->method('persist')->with(self::callback(
|
|
function (AnalyticsUniqId $v) use (&$persistedVisitor) {
|
|
$persistedVisitor = $v;
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$em->expects(self::once())->method('flush');
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
self::assertNotNull($persistedVisitor);
|
|
self::assertSame(10, \strlen($persistedVisitor->getLanguage()));
|
|
}
|
|
|
|
public function testResponseHashIsVerifiable(): void
|
|
{
|
|
$encrypted = $this->crypto->encrypt([]);
|
|
$request = $this->createRequest(json_encode(['d' => $encrypted]));
|
|
$em = $this->createMockEm(new AnalyticsUniqId());
|
|
$bus = $this->createMock(MessageBusInterface::class);
|
|
|
|
$response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus);
|
|
|
|
$body = json_decode($response->getContent(), true);
|
|
$decrypted = $this->crypto->decrypt($body['d']);
|
|
|
|
self::assertTrue($this->crypto->verifyVisitorHash($decrypted['uid'], $decrypted['h']));
|
|
}
|
|
}
|