createMock(SystemAuditService::class); $audit->expects($this->never())->method('record'); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(false); $rateLimiter = $this->createMock(RateLimiterService::class); $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturn([]); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $result = $service->ingest([ 'event_type' => 'frontend.warn_once', 'message' => 'warn', ]); $this->assertSame(['accepted' => false, 'reason' => 'disabled'], $result); } public function testIngestRejectsInvalidPayload(): void { $audit = $this->createMock(SystemAuditService::class); $audit->expects($this->never())->method('record'); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $rateLimiter = $this->createMock(RateLimiterService::class); $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturn([]); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $result = $service->ingest([ 'event_type' => 'unknown', 'message' => '', ]); $this->assertSame(['accepted' => false, 'reason' => 'invalid'], $result); } public function testIngestRecordsSanitizedPayload(): void { $audit = $this->createMock(SystemAuditService::class); $audit->expects($this->once()) ->method('record') ->with( 'frontend.warn_once', 'success', $this->callback(function (array $context): bool { $metadata = $context['metadata'] ?? []; $message = (string) ($metadata['message'] ?? ''); if (!str_contains($message, '[REDACTED_EMAIL]')) { return false; } if (!str_contains($message, 'token=[REDACTED]')) { return false; } $meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : []; $requestPath = (string) ($meta['request_path'] ?? ''); return $requestPath === '/admin/users/:id'; }) ) ->willReturn(5); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']); $gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0); $rateLimiter = $this->createMock(RateLimiterService::class); $rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]); $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturn(['user' => ['id' => 7]]); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $result = $service->ingest([ 'event_type' => 'frontend.warn_once', 'severity' => 'warning', 'message' => 'Problem for user test@example.com token=supersecret1234567890', 'fingerprint' => 'v1_warn_once_123', 'meta' => ['request_path' => '/admin/users/123?email=a@b.de'], 'occurred_at' => '2026-03-05T12:00:00+01:00', ]); $this->assertSame(['accepted' => true, 'reason' => 'logged'], $result); } public function testIngestBuildsFallbackFingerprintUsingRouteBeforeMessage(): void { $audit = $this->createMock(SystemAuditService::class); $audit->expects($this->once()) ->method('record') ->with( 'frontend.warn_once', 'success', $this->callback(static function (array $context): bool { $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; $fingerprintHash = (string) ($metadata['fingerprint_hash'] ?? ''); $expectedFingerprint = 'v1_' . substr(hash('sha256', 'frontend.warn_once|/admin/users/:id|Grid init failed'), 0, 24); return $fingerprintHash === RequestContext::hashValue($expectedFingerprint); }) ) ->willReturn(6); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']); $gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0); $rateLimiter = $this->createMock(RateLimiterService::class); $rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]); $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturn(['user' => ['id' => 7]]); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $result = $service->ingest([ 'event_type' => 'frontend.warn_once', 'severity' => 'warning', 'message' => 'Grid init failed', 'meta' => ['location' => '/admin/users/123'], 'occurred_at' => '2026-03-05T12:00:00+01:00', ]); $this->assertSame(['accepted' => true, 'reason' => 'logged'], $result); } public function testIngestRejectsInvalidPageRequestIdPattern(): void { $audit = $this->createMock(SystemAuditService::class); $audit->expects($this->once()) ->method('record') ->with( 'frontend.warn_once', 'success', $this->callback(static function (array $context): bool { $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; $meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : []; return !array_key_exists('page_request_id', $meta); }) ) ->willReturn(8); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']); $gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0); $rateLimiter = $this->createMock(RateLimiterService::class); $rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]); $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturn(['user' => ['id' => 7]]); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $result = $service->ingest([ 'event_type' => 'frontend.warn_once', 'severity' => 'warning', 'message' => 'Invalid request id should be dropped', 'fingerprint' => 'v1_invalid_request_id', 'meta' => ['page_request_id' => '------------------------------------'], ]); $this->assertSame(['accepted' => true, 'reason' => 'logged'], $result); } public function testIngestUsesSingleSessionStateReadWriteForGuards(): void { $audit = $this->createMock(SystemAuditService::class); $audit->expects($this->once()) ->method('record') ->willReturn(7); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']); $gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0); $rateLimiter = $this->createMock(RateLimiterService::class); $rateLimiter->expects($this->once()) ->method('hit') ->willReturn(['allowed' => true, 'retry_after' => 0]); $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->expects($this->once())->method('all')->willReturn(['user' => ['id' => 7]]); $sessionStore->expects($this->once()) ->method('get') ->with('frontend_telemetry_state', []) ->willReturn([]); $sessionStore->expects($this->once()) ->method('set') ->with( 'frontend_telemetry_state', $this->callback(static function (mixed $value): bool { if (!is_array($value)) { return false; } if (!is_array($value['dedupe'] ?? null)) { return false; } if (!is_array($value['burst'] ?? null)) { return false; } return true; }) ); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $result = $service->ingest([ 'event_type' => 'frontend.warn_once', 'severity' => 'warning', 'message' => 'single state write', 'fingerprint' => 'v1_single_state_write', 'meta' => ['location' => '/admin/test'], ]); $this->assertSame(['accepted' => true, 'reason' => 'logged'], $result); } public function testIngestDropsDuplicateFingerprint(): void { $audit = $this->createMock(SystemAuditService::class); $audit->expects($this->once())->method('record')->willReturn(9); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']); $gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0); $rateLimiter = $this->createMock(RateLimiterService::class); $rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]); $sessionData = ['user' => ['id' => 7]]; $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData); $sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed { return $sessionData[$key] ?? $default; }); $sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void { $sessionData[$key] = $value; }); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $first = $service->ingest([ 'event_type' => 'frontend.ajax_error', 'message' => 'AJAX request failed', 'fingerprint' => 'v1_ajax_error_1', ]); $second = $service->ingest([ 'event_type' => 'frontend.ajax_error', 'message' => 'AJAX request failed', 'fingerprint' => 'v1_ajax_error_1', ]); $this->assertSame(['accepted' => true, 'reason' => 'logged'], $first); $this->assertSame(['accepted' => false, 'reason' => 'duplicate'], $second); } public function testIngestAppliesSessionBurstFastDropBeforeGlobalRateLimit(): void { $recordCalls = 0; $rateLimiterCalls = 0; $audit = $this->createMock(SystemAuditService::class); $audit->method('record')->willReturnCallback(static function () use (&$recordCalls): int { $recordCalls++; return $recordCalls; }); $gateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $gateway->method('isFrontendTelemetryEnabled')->willReturn(true); $gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']); $gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0); $rateLimiter = $this->createMock(RateLimiterService::class); $rateLimiter->method('hit')->willReturnCallback(static function () use (&$rateLimiterCalls): array { $rateLimiterCalls++; return ['allowed' => true, 'retry_after' => 0]; }); $sessionData = ['user' => ['id' => 7]]; $sessionStore = $this->createMock(SessionStoreInterface::class); $sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData); $sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed { return $sessionData[$key] ?? $default; }); $sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void { $sessionData[$key] = $value; }); $service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore); $lastResult = ['accepted' => true, 'reason' => 'logged']; for ($index = 0; $index < 120; $index++) { $lastResult = $service->ingest([ 'event_type' => 'frontend.ajax_error', 'message' => 'AJAX request failed', 'fingerprint' => 'v1_ajax_error_' . $index, ]); } $this->assertSame(['accepted' => false, 'reason' => 'burst_limited'], $lastResult); $this->assertGreaterThan(0, $recordCalls); $this->assertLessThan(120, $recordCalls); $this->assertSame($recordCalls, $rateLimiterCalls); } }