feat: Observabilité et monitoring complet
Implémentation complète de la stack d'observabilité pour le monitoring de la plateforme multi-tenant Classeo. ## Error Tracking (GlitchTip) - Intégration Sentry SDK avec GlitchTip auto-hébergé - Scrubber PII avant envoi (RGPD: emails, tokens JWT, NIR français) - Contexte enrichi: tenant_id, user_id, correlation_id - Configuration backend (sentry.yaml) et frontend (sentry.ts) ## Metrics (Prometheus) - Endpoint /metrics avec restriction IP en production - Métriques HTTP: requests_total, request_duration_seconds (histogramme) - Métriques sécurité: login_failures_total par tenant - Métriques santé: health_check_status (postgres, redis, rabbitmq) - Storage Redis pour persistance entre requêtes ## Logs (Loki) - Processors Monolog: CorrelationIdLogProcessor, PiiScrubberLogProcessor - Détection PII: emails, téléphones FR, tokens JWT, NIR français - Labels structurés: tenant_id, correlation_id, level ## Dashboards (Grafana) - Dashboard principal: latence P50/P95/P99, error rate, RPS - Dashboard par tenant: métriques isolées par sous-domaine - Dashboard infrastructure: santé postgres/redis/rabbitmq - Datasources avec UIDs fixes pour portabilité ## Alertes (Alertmanager) - HighApiLatencyP95/P99: SLA monitoring (200ms/500ms) - HighErrorRate: error rate > 1% pendant 2 min - ExcessiveLoginFailures: détection brute force - ApplicationUnhealthy: health check failures ## Infrastructure - InfrastructureHealthChecker: service partagé (DRY) - HealthCheckController: endpoint /health pour load balancers - Pre-push hook: make ci && make e2e avant push
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Monitoring\CorrelationIdLogProcessor;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @see Story 1.8 - T5.2: Processor to add correlation_id automatically
|
||||
* @see Story 1.8 - T5.3: Processor to add tenant_id automatically
|
||||
*/
|
||||
#[CoversClass(CorrelationIdLogProcessor::class)]
|
||||
final class CorrelationIdLogProcessorTest extends TestCase
|
||||
{
|
||||
private TenantContext $tenantContext;
|
||||
private CorrelationIdLogProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->processor = new CorrelationIdLogProcessor($this->tenantContext);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
$this->tenantContext->clear();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAddsCorrelationIdToLogRecord(): void
|
||||
{
|
||||
$correlationId = CorrelationId::fromString('01234567-89ab-cdef-0123-456789abcdef');
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$record = $this->createLogRecord();
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertArrayHasKey('correlation_id', $result->extra);
|
||||
self::assertSame('01234567-89ab-cdef-0123-456789abcdef', $result->extra['correlation_id']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAddsTenantIdToLogRecord(): void
|
||||
{
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'test-school',
|
||||
databaseUrl: 'postgresql://test@localhost/test',
|
||||
);
|
||||
$this->tenantContext->setCurrentTenant($tenantConfig);
|
||||
|
||||
$record = $this->createLogRecord();
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertArrayHasKey('tenant_id', $result->extra);
|
||||
self::assertSame('test-school', $result->extra['tenant_id']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAddsBothCorrelationIdAndTenantId(): void
|
||||
{
|
||||
$correlationId = CorrelationId::fromString('11111111-2222-3333-4444-555555555555');
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: TenantId::fromString('66666666-7777-8888-9999-aaaaaaaaaaaa'),
|
||||
subdomain: 'school',
|
||||
databaseUrl: 'postgresql://test@localhost/school',
|
||||
);
|
||||
$this->tenantContext->setCurrentTenant($tenantConfig);
|
||||
|
||||
$record = $this->createLogRecord();
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('11111111-2222-3333-4444-555555555555', $result->extra['correlation_id']);
|
||||
self::assertSame('school', $result->extra['tenant_id']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotAddCorrelationIdWhenNotSet(): void
|
||||
{
|
||||
$record = $this->createLogRecord();
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertArrayNotHasKey('correlation_id', $result->extra);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotAddTenantIdWhenNoTenant(): void
|
||||
{
|
||||
$record = $this->createLogRecord();
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertArrayNotHasKey('tenant_id', $result->extra);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPreservesExistingExtraData(): void
|
||||
{
|
||||
$correlationId = CorrelationId::fromString('abcdef12-3456-7890-abcd-ef1234567890');
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$record = $this->createLogRecord(extra: ['existing_key' => 'existing_value']);
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('existing_value', $result->extra['existing_key']);
|
||||
self::assertSame('abcdef12-3456-7890-abcd-ef1234567890', $result->extra['correlation_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private function createLogRecord(array $extra = []): LogRecord
|
||||
{
|
||||
return new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: 'Test message',
|
||||
context: [],
|
||||
extra: $extra,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Monitoring\HealthCheckController;
|
||||
use App\Shared\Infrastructure\Monitoring\InfrastructureHealthCheckerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Stub for InfrastructureHealthCheckerInterface.
|
||||
*/
|
||||
final class InfrastructureHealthCheckerStub implements InfrastructureHealthCheckerInterface
|
||||
{
|
||||
/**
|
||||
* @param array{postgres: bool, redis: bool, rabbitmq: bool} $checks
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $checks = ['postgres' => true, 'redis' => true, 'rabbitmq' => true],
|
||||
) {
|
||||
}
|
||||
|
||||
public function checkPostgres(): bool
|
||||
{
|
||||
return $this->checks['postgres'];
|
||||
}
|
||||
|
||||
public function checkRedis(): bool
|
||||
{
|
||||
return $this->checks['redis'];
|
||||
}
|
||||
|
||||
public function checkRabbitMQ(): bool
|
||||
{
|
||||
return $this->checks['rabbitmq'];
|
||||
}
|
||||
|
||||
public function checkAll(): array
|
||||
{
|
||||
return $this->checks;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Story 1.8 - T7: Health Check Endpoint
|
||||
*/
|
||||
#[CoversClass(HealthCheckController::class)]
|
||||
final class HealthCheckControllerTest extends TestCase
|
||||
{
|
||||
private function createController(
|
||||
?InfrastructureHealthCheckerInterface $healthChecker = null,
|
||||
): HealthCheckController {
|
||||
$healthChecker ??= new InfrastructureHealthCheckerStub();
|
||||
|
||||
return new HealthCheckController($healthChecker);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsHealthyWhenAllServicesAreUp(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
|
||||
$response = $controller();
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
|
||||
$data = json_decode($response->getContent(), true);
|
||||
self::assertSame('healthy', $data['status']);
|
||||
self::assertTrue($data['checks']['postgres']);
|
||||
self::assertTrue($data['checks']['redis']);
|
||||
self::assertTrue($data['checks']['rabbitmq']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsUnhealthyWhenPostgresIsDown(): void
|
||||
{
|
||||
$checker = new InfrastructureHealthCheckerStub([
|
||||
'postgres' => false,
|
||||
'redis' => true,
|
||||
'rabbitmq' => true,
|
||||
]);
|
||||
$controller = $this->createController($checker);
|
||||
|
||||
$response = $controller();
|
||||
|
||||
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
|
||||
|
||||
$data = json_decode($response->getContent(), true);
|
||||
self::assertSame('unhealthy', $data['status']);
|
||||
self::assertFalse($data['checks']['postgres']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIncludesTimestampInResponse(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
|
||||
$response = $controller();
|
||||
$data = json_decode($response->getContent(), true);
|
||||
|
||||
self::assertArrayHasKey('timestamp', $data);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2}$/',
|
||||
$data['timestamp'],
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsAllServiceChecks(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
|
||||
$response = $controller();
|
||||
$data = json_decode($response->getContent(), true);
|
||||
|
||||
self::assertArrayHasKey('postgres', $data['checks']);
|
||||
self::assertArrayHasKey('redis', $data['checks']);
|
||||
self::assertArrayHasKey('rabbitmq', $data['checks']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsUnhealthyWhenRabbitmqIsDown(): void
|
||||
{
|
||||
$checker = new InfrastructureHealthCheckerStub([
|
||||
'postgres' => true,
|
||||
'redis' => true,
|
||||
'rabbitmq' => false,
|
||||
]);
|
||||
$controller = $this->createController($checker);
|
||||
|
||||
$response = $controller();
|
||||
|
||||
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
|
||||
|
||||
$data = json_decode($response->getContent(), true);
|
||||
self::assertFalse($data['checks']['rabbitmq']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsUnhealthyWhenRedisIsDown(): void
|
||||
{
|
||||
$checker = new InfrastructureHealthCheckerStub([
|
||||
'postgres' => true,
|
||||
'redis' => false,
|
||||
'rabbitmq' => true,
|
||||
]);
|
||||
$controller = $this->createController($checker);
|
||||
|
||||
$response = $controller();
|
||||
|
||||
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
|
||||
|
||||
$data = json_decode($response->getContent(), true);
|
||||
self::assertFalse($data['checks']['redis']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Counter;
|
||||
use Prometheus\Histogram;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.8 - T3.4: Custom metrics (requests_total, request_duration_seconds)
|
||||
*/
|
||||
#[CoversClass(MetricsCollector::class)]
|
||||
final class MetricsCollectorTest extends TestCase
|
||||
{
|
||||
private CollectorRegistry $registry;
|
||||
private TenantContext $tenantContext;
|
||||
private MetricsCollector $collector;
|
||||
private Counter $requestsCounter;
|
||||
private Histogram $durationHistogram;
|
||||
private Counter $loginFailuresCounter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->requestsCounter = $this->createMock(Counter::class);
|
||||
$this->durationHistogram = $this->createMock(Histogram::class);
|
||||
$this->loginFailuresCounter = $this->createMock(Counter::class);
|
||||
|
||||
$this->registry = $this->createMock(CollectorRegistry::class);
|
||||
$this->registry->method('getOrRegisterCounter')
|
||||
->willReturnCallback(fn (string $ns, string $name) => match ($name) {
|
||||
'http_requests_total' => $this->requestsCounter,
|
||||
'login_failures_total' => $this->loginFailuresCounter,
|
||||
default => $this->createMock(Counter::class),
|
||||
});
|
||||
$this->registry->method('getOrRegisterHistogram')
|
||||
->willReturn($this->durationHistogram);
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->collector = new MetricsCollector($this->registry, $this->tenantContext);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->tenantContext->clear();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsRequestMetricsWithoutTenant(): void
|
||||
{
|
||||
$request = Request::create('/api/users', 'GET');
|
||||
$request->attributes->set('_route', 'get_users');
|
||||
$response = new Response('', 200);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
// Simulate request start
|
||||
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
$this->collector->onKernelRequest($requestEvent);
|
||||
|
||||
// Expect metrics to be recorded with tenant_id="none"
|
||||
$this->requestsCounter->expects(self::once())
|
||||
->method('inc')
|
||||
->with(['GET', 'get_users', '200', 'none']);
|
||||
|
||||
$this->durationHistogram->expects(self::once())
|
||||
->method('observe')
|
||||
->with(
|
||||
self::greaterThan(0),
|
||||
['GET', 'get_users', 'none'],
|
||||
);
|
||||
|
||||
// Simulate request end
|
||||
$terminateEvent = new TerminateEvent($kernel, $request, $response);
|
||||
$this->collector->onKernelTerminate($terminateEvent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsRequestMetricsWithTenant(): void
|
||||
{
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://test@localhost/test',
|
||||
);
|
||||
$this->tenantContext->setCurrentTenant($tenantConfig);
|
||||
|
||||
$request = Request::create('/api/users', 'POST');
|
||||
$request->attributes->set('_route', 'create_user');
|
||||
$response = new Response('', 201);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
$this->collector->onKernelRequest($requestEvent);
|
||||
|
||||
$this->requestsCounter->expects(self::once())
|
||||
->method('inc')
|
||||
->with(['POST', 'create_user', '201', 'ecole-alpha']);
|
||||
|
||||
$terminateEvent = new TerminateEvent($kernel, $request, $response);
|
||||
$this->collector->onKernelTerminate($terminateEvent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSkipsMetricsEndpoint(): void
|
||||
{
|
||||
$request = Request::create('/metrics', 'GET');
|
||||
$request->attributes->set('_route', 'prometheus_metrics');
|
||||
$response = new Response('', 200);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
$this->collector->onKernelRequest($requestEvent);
|
||||
|
||||
// Should NOT record metrics for /metrics endpoint
|
||||
$this->requestsCounter->expects(self::never())->method('inc');
|
||||
$this->durationHistogram->expects(self::never())->method('observe');
|
||||
|
||||
$terminateEvent = new TerminateEvent($kernel, $request, $response);
|
||||
$this->collector->onKernelTerminate($terminateEvent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSkipsHealthEndpoint(): void
|
||||
{
|
||||
$request = Request::create('/health', 'GET');
|
||||
$request->attributes->set('_route', 'health_check');
|
||||
$response = new Response('', 200);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
$this->collector->onKernelRequest($requestEvent);
|
||||
|
||||
$this->requestsCounter->expects(self::never())->method('inc');
|
||||
|
||||
$terminateEvent = new TerminateEvent($kernel, $request, $response);
|
||||
$this->collector->onKernelTerminate($terminateEvent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsLoginFailureWithoutTenant(): void
|
||||
{
|
||||
$this->loginFailuresCounter->expects(self::once())
|
||||
->method('inc')
|
||||
->with(['none', 'invalid_credentials']);
|
||||
|
||||
$this->collector->recordLoginFailure();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsLoginFailureWithTenant(): void
|
||||
{
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'postgresql://test@localhost/test',
|
||||
);
|
||||
$this->tenantContext->setCurrentTenant($tenantConfig);
|
||||
|
||||
$this->loginFailuresCounter->expects(self::once())
|
||||
->method('inc')
|
||||
->with(['ecole-beta', 'rate_limited']);
|
||||
|
||||
$this->collector->recordLoginFailure('rate_limited');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIgnoresSubrequests(): void
|
||||
{
|
||||
$request = Request::create('/api/test', 'GET');
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
// Subrequest should be ignored
|
||||
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);
|
||||
$this->collector->onKernelRequest($requestEvent);
|
||||
|
||||
$this->requestsCounter->expects(self::never())->method('inc');
|
||||
|
||||
$response = new Response('', 200);
|
||||
$terminateEvent = new TerminateEvent($kernel, $request, $response);
|
||||
$this->collector->onKernelTerminate($terminateEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface;
|
||||
use App\Shared\Infrastructure\Monitoring\MetricsController;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\MetricFamilySamples;
|
||||
use Prometheus\RenderTextFormat;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* Stub for HealthMetricsCollectorInterface that doesn't make real connections.
|
||||
*/
|
||||
final class HealthMetricsCollectorStub implements HealthMetricsCollectorInterface
|
||||
{
|
||||
private bool $collected = false;
|
||||
|
||||
public function collect(): void
|
||||
{
|
||||
$this->collected = true;
|
||||
}
|
||||
|
||||
public function wasCollectCalled(): bool
|
||||
{
|
||||
return $this->collected;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Story 1.8 - T3.3: Expose /metrics endpoint in backend
|
||||
*/
|
||||
#[CoversClass(MetricsController::class)]
|
||||
final class MetricsControllerTest extends TestCase
|
||||
{
|
||||
private function createController(
|
||||
?CollectorRegistry $registry = null,
|
||||
string $appEnv = 'dev',
|
||||
): MetricsController {
|
||||
$registry ??= $this->createMock(CollectorRegistry::class);
|
||||
$registry->method('getMetricFamilySamples')->willReturn([]);
|
||||
|
||||
$healthMetrics = new HealthMetricsCollectorStub();
|
||||
|
||||
return new MetricsController($registry, $healthMetrics, $appEnv);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsMetricsWithCorrectContentType(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
$request = Request::create('/metrics');
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame(RenderTextFormat::MIME_TYPE, $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRendersMetricsFromRegistry(): void
|
||||
{
|
||||
$sample = new MetricFamilySamples([
|
||||
'name' => 'test_counter',
|
||||
'type' => 'counter',
|
||||
'help' => 'A test counter',
|
||||
'labelNames' => [],
|
||||
'samples' => [
|
||||
[
|
||||
'name' => 'test_counter',
|
||||
'labelNames' => [],
|
||||
'labelValues' => [],
|
||||
'value' => 42,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$registry = $this->createMock(CollectorRegistry::class);
|
||||
$registry->method('getMetricFamilySamples')->willReturn([$sample]);
|
||||
|
||||
$healthMetrics = new HealthMetricsCollectorStub();
|
||||
$controller = new MetricsController($registry, $healthMetrics);
|
||||
$request = Request::create('/metrics');
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
$content = $response->getContent();
|
||||
self::assertStringContainsString('test_counter', $content);
|
||||
self::assertStringContainsString('42', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyResponseWhenNoMetrics(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
$request = Request::create('/metrics');
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertIsString($response->getContent());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsInternalIpInProduction(): void
|
||||
{
|
||||
$controller = $this->createController(appEnv: 'prod');
|
||||
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '172.18.0.5']);
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itBlocksExternalIpInProduction(): void
|
||||
{
|
||||
$registry = $this->createMock(CollectorRegistry::class);
|
||||
$healthMetrics = new HealthMetricsCollectorStub();
|
||||
$controller = new MetricsController($registry, $healthMetrics, 'prod');
|
||||
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('Metrics endpoint is restricted to internal networks.');
|
||||
|
||||
$controller($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsAnyIpInDev(): void
|
||||
{
|
||||
$controller = $this->createController(appEnv: 'dev');
|
||||
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsLocalhostInProduction(): void
|
||||
{
|
||||
$controller = $this->createController(appEnv: 'prod');
|
||||
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '127.0.0.1']);
|
||||
|
||||
$response = $controller($request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCollectsHealthMetricsBeforeRendering(): void
|
||||
{
|
||||
$registry = $this->createMock(CollectorRegistry::class);
|
||||
$registry->method('getMetricFamilySamples')->willReturn([]);
|
||||
|
||||
$healthMetrics = new HealthMetricsCollectorStub();
|
||||
$controller = new MetricsController($registry, $healthMetrics);
|
||||
$request = Request::create('/metrics');
|
||||
|
||||
$controller($request);
|
||||
|
||||
self::assertTrue($healthMetrics->wasCollectCalled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Monitoring\PiiScrubberLogProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @see Story 1.8 - T5.4: Filter PII from logs (scrubber processor)
|
||||
*/
|
||||
#[CoversClass(PiiScrubberLogProcessor::class)]
|
||||
final class PiiScrubberLogProcessorTest extends TestCase
|
||||
{
|
||||
private PiiScrubberLogProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->processor = new PiiScrubberLogProcessor();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRedactsEmailInContext(): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
context: ['email' => 'user@example.com'],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('[REDACTED]', $result->context['email']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRedactsPasswordInContext(): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
context: ['password' => 'secret123'],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('[REDACTED]', $result->context['password']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRedactsNestedPii(): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
context: [
|
||||
'user' => [
|
||||
'id' => 'uuid-123',
|
||||
'email' => 'user@example.com',
|
||||
'name' => 'John Doe',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('uuid-123', $result->context['user']['id']);
|
||||
self::assertSame('[REDACTED]', $result->context['user']['email']);
|
||||
self::assertSame('[REDACTED]', $result->context['user']['name']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRedactsJwtTokensByValue(): void
|
||||
{
|
||||
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
|
||||
|
||||
$record = $this->createLogRecord(
|
||||
context: ['auth_header' => $jwt],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('[REDACTED]', $result->context['auth_header']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRedactsEmailValues(): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
context: ['some_field' => 'contact@school.fr'],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('[REDACTED]', $result->context['some_field']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPreservesSafeValues(): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
context: [
|
||||
'correlation_id' => '01234567-89ab-cdef-0123-456789abcdef',
|
||||
'tenant_id' => 'tenant-uuid',
|
||||
'event_type' => 'UserCreated',
|
||||
'count' => 42,
|
||||
],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('01234567-89ab-cdef-0123-456789abcdef', $result->context['correlation_id']);
|
||||
self::assertSame('tenant-uuid', $result->context['tenant_id']);
|
||||
self::assertSame('UserCreated', $result->context['event_type']);
|
||||
self::assertSame(42, $result->context['count']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRedactsPiiInExtra(): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
extra: ['user_email' => 'admin@classeo.fr'],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('[REDACTED]', $result->extra['user_email']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('piiKeyProvider')]
|
||||
public function itRedactsVariousPiiKeys(string $key): void
|
||||
{
|
||||
$record = $this->createLogRecord(
|
||||
context: [$key => 'sensitive_value'],
|
||||
);
|
||||
|
||||
$result = ($this->processor)($record);
|
||||
|
||||
self::assertSame('[REDACTED]', $result->context[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function piiKeyProvider(): iterable
|
||||
{
|
||||
yield 'email' => ['email'];
|
||||
yield 'password' => ['password'];
|
||||
yield 'token' => ['token'];
|
||||
yield 'secret' => ['secret'];
|
||||
yield 'authorization' => ['authorization'];
|
||||
yield 'cookie' => ['cookie'];
|
||||
yield 'phone' => ['phone'];
|
||||
yield 'address' => ['address'];
|
||||
yield 'nom' => ['nom'];
|
||||
yield 'prenom' => ['prenom'];
|
||||
yield 'name' => ['name'];
|
||||
yield 'firstname' => ['firstname'];
|
||||
yield 'lastname' => ['lastname'];
|
||||
yield 'ip' => ['ip'];
|
||||
yield 'user_agent' => ['user_agent'];
|
||||
yield 'user_email' => ['user_email'];
|
||||
yield 'auth_token' => ['auth_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private function createLogRecord(
|
||||
string $message = 'Test message',
|
||||
array $context = [],
|
||||
array $extra = [],
|
||||
): LogRecord {
|
||||
return new LogRecord(
|
||||
datetime: new DateTimeImmutable(),
|
||||
channel: 'test',
|
||||
level: Level::Info,
|
||||
message: $message,
|
||||
context: $context,
|
||||
extra: $extra,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||
|
||||
use App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Sentry\Event;
|
||||
|
||||
/**
|
||||
* Tests for PII scrubbing in Sentry events.
|
||||
*
|
||||
* Critical for RGPD compliance: ensures no personal data is sent to error tracking.
|
||||
*
|
||||
* @see Story 1.8 - T1.4: Filter PII before send (scrubber)
|
||||
*/
|
||||
#[CoversClass(SentryBeforeSendCallback::class)]
|
||||
final class SentryBeforeSendCallbackTest extends TestCase
|
||||
{
|
||||
private SentryBeforeSendCallback $callback;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->callback = new SentryBeforeSendCallback();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itNeverDropsEvents(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertSame($event, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersEmailFromExtra(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra(['user_email' => 'john@example.com', 'action' => 'login']);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra['user_email']);
|
||||
self::assertSame('login', $extra['action']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersPasswordFromExtra(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra(['password' => 'secret123', 'user_id' => 'john']);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra['password']);
|
||||
self::assertSame('john', $extra['user_id']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersTokenFromExtra(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra(['auth_token' => 'abc123xyz', 'status' => 'active']);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra['auth_token']);
|
||||
self::assertSame('active', $extra['status']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersJwtTokensByValue(): void
|
||||
{
|
||||
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra(['data' => $jwt]);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra['data']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersEmailValues(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra(['contact' => 'user@example.com']);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra['contact']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersNestedPii(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra([
|
||||
'user' => [
|
||||
'email' => 'nested@example.com',
|
||||
'id' => 123,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra['user']['email']);
|
||||
self::assertSame(123, $extra['user']['id']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersPiiFromTags(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setTags(['user_email' => 'tagged@example.com', 'environment' => 'prod']);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$tags = $result->getTags();
|
||||
self::assertSame('[FILTERED]', $tags['user_email']);
|
||||
self::assertSame('prod', $tags['environment']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('piiKeysProvider')]
|
||||
public function itFiltersVariousPiiKeys(string $key): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra([$key => 'sensitive_value']);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('[FILTERED]', $extra[$key], "Key '$key' should be filtered");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function piiKeysProvider(): iterable
|
||||
{
|
||||
yield 'email' => ['email'];
|
||||
yield 'password' => ['password'];
|
||||
yield 'token' => ['token'];
|
||||
yield 'secret' => ['secret'];
|
||||
yield 'key' => ['api_key'];
|
||||
yield 'authorization' => ['authorization'];
|
||||
yield 'cookie' => ['cookie'];
|
||||
yield 'session' => ['session_id'];
|
||||
yield 'phone' => ['phone'];
|
||||
yield 'address' => ['address'];
|
||||
yield 'ip' => ['client_ip'];
|
||||
yield 'nom' => ['nom'];
|
||||
yield 'prenom' => ['prenom'];
|
||||
yield 'name' => ['name'];
|
||||
yield 'firstname' => ['firstname'];
|
||||
yield 'lastname' => ['lastname'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPreservesSafeValues(): void
|
||||
{
|
||||
$event = Event::createEvent();
|
||||
$event->setExtra([
|
||||
'error_code' => 'E001',
|
||||
'count' => 42,
|
||||
'enabled' => true,
|
||||
'data' => ['a', 'b', 'c'],
|
||||
]);
|
||||
|
||||
$result = ($this->callback)($event, null);
|
||||
|
||||
$extra = $result->getExtra();
|
||||
self::assertSame('E001', $extra['error_code']);
|
||||
self::assertSame(42, $extra['count']);
|
||||
self::assertTrue($extra['enabled']);
|
||||
self::assertSame(['a', 'b', 'c'], $extra['data']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user