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