Story 1.7 - Implémente un système complet d'audit trail pour tracer toutes les actions sensibles (authentification, modifications de données, exports) avec immuabilité garantie par PostgreSQL. Fonctionnalités principales: - Table audit_log append-only avec contraintes PostgreSQL (RULE) - AuditLogger centralisé avec injection automatique du contexte - Correlation ID pour traçabilité distribuée (HTTP + async) - Handlers pour événements d'authentification - Commande d'archivage des logs anciens - Pas de PII dans les logs (emails/IPs hashés) Infrastructure: - Middlewares Messenger pour propagation du Correlation ID - HTTP middleware pour génération/propagation du Correlation ID - Support multi-tenant avec TenantResolver
355 lines
12 KiB
PHP
355 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
|
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\CorrelationId;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Audit\AuditLogger;
|
|
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfrastructureTenantId;
|
|
use DateTimeImmutable;
|
|
use Doctrine\DBAL\Connection;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Ramsey\Uuid\Uuid;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
|
|
|
/**
|
|
* @see Story 1.7 - T2: AuditLogger Service
|
|
*/
|
|
final class AuditLoggerTest extends TestCase
|
|
{
|
|
private Connection&MockObject $connection;
|
|
private TenantContext $tenantContext;
|
|
private TokenStorageInterface&MockObject $tokenStorage;
|
|
private RequestStack $requestStack;
|
|
private Clock&MockObject $clock;
|
|
private AuditLogger $auditLogger;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->connection = $this->createMock(Connection::class);
|
|
$this->tenantContext = new TenantContext();
|
|
$this->tokenStorage = $this->createMock(TokenStorageInterface::class);
|
|
$this->requestStack = new RequestStack();
|
|
$this->clock = $this->createMock(Clock::class);
|
|
|
|
$this->auditLogger = new AuditLogger(
|
|
$this->connection,
|
|
$this->tenantContext,
|
|
$this->tokenStorage,
|
|
$this->requestStack,
|
|
$this->clock,
|
|
'test-secret',
|
|
);
|
|
|
|
CorrelationIdHolder::clear();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
CorrelationIdHolder::clear();
|
|
$this->tenantContext->clear();
|
|
}
|
|
|
|
public function testLogAuthenticationInsertsIntoDatabase(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$userId = Uuid::uuid4();
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($userId): bool {
|
|
return $data['aggregate_type'] === 'User'
|
|
&& $data['aggregate_id'] === $userId->toString()
|
|
&& $data['event_type'] === 'TestEvent'
|
|
&& $data['payload']['test_key'] === 'test_value';
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('TestEvent', $userId, ['test_key' => 'test_value']);
|
|
}
|
|
|
|
public function testLogAuthenticationWithNullUserIdSetsNullAggregateId(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static fn (array $data): bool => $data['aggregate_id'] === null),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('FailedLogin', null, []);
|
|
}
|
|
|
|
public function testLogDataChangeIncludesOldAndNewValues(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$aggregateId = Uuid::uuid4();
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($aggregateId): bool {
|
|
$payload = $data['payload'];
|
|
|
|
return $data['aggregate_type'] === 'Note'
|
|
&& $data['aggregate_id'] === $aggregateId->toString()
|
|
&& $data['event_type'] === 'NoteModified'
|
|
&& $payload['old_values']['value'] === 12.5
|
|
&& $payload['new_values']['value'] === 14.0
|
|
&& $payload['reason'] === 'Correction';
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logDataChange(
|
|
'Note',
|
|
$aggregateId,
|
|
'NoteModified',
|
|
['value' => 12.5],
|
|
['value' => 14.0],
|
|
'Correction',
|
|
);
|
|
}
|
|
|
|
public function testLogExportIncludesExportDetails(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data): bool {
|
|
$payload = $data['payload'];
|
|
|
|
return $data['aggregate_type'] === 'Export'
|
|
&& $data['event_type'] === 'ExportGenerated'
|
|
&& $payload['export_type'] === 'CSV'
|
|
&& $payload['record_count'] === 150
|
|
&& $payload['target'] === 'students_list';
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logExport('CSV', 150, 'students_list');
|
|
}
|
|
|
|
public function testLogAccessIncludesResourceAndContext(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$resourceId = Uuid::uuid4();
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($resourceId): bool {
|
|
$payload = $data['payload'];
|
|
|
|
return $data['aggregate_type'] === 'Student'
|
|
&& $data['aggregate_id'] === $resourceId->toString()
|
|
&& $data['event_type'] === 'ResourceAccessed'
|
|
&& $payload['screen'] === 'profile'
|
|
&& $payload['action'] === 'view';
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAccess('Student', $resourceId, ['screen' => 'profile', 'action' => 'view']);
|
|
}
|
|
|
|
public function testMetadataIncludesTenantIdWhenAvailable(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$tenantId = TenantId::generate();
|
|
$this->setCurrentTenant($tenantId);
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($tenantId): bool {
|
|
$metadata = $data['metadata'];
|
|
|
|
return $metadata['tenant_id'] === (string) $tenantId;
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('Test', null, []);
|
|
}
|
|
|
|
public function testMetadataIncludesCorrelationIdWhenSet(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$correlationId = CorrelationId::generate();
|
|
CorrelationIdHolder::set($correlationId);
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($correlationId): bool {
|
|
$metadata = $data['metadata'];
|
|
|
|
return $metadata['correlation_id'] === $correlationId->value();
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('Test', null, []);
|
|
}
|
|
|
|
public function testMetadataIncludesHashedIpFromRequest(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$request = Request::create('/test');
|
|
$request->server->set('REMOTE_ADDR', '192.168.1.100');
|
|
$this->requestStack->push($request);
|
|
|
|
$expectedIpHash = hash('sha256', '192.168.1.100test-secret');
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($expectedIpHash): bool {
|
|
$metadata = $data['metadata'];
|
|
|
|
return $metadata['ip_hash'] === $expectedIpHash;
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('Test', null, []);
|
|
}
|
|
|
|
public function testMetadataIncludesUserAgentHash(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$request = Request::create('/test');
|
|
$request->headers->set('User-Agent', 'Mozilla/5.0 TestBrowser');
|
|
$this->requestStack->push($request);
|
|
|
|
$expectedUaHash = hash('sha256', 'Mozilla/5.0 TestBrowser');
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($expectedUaHash): bool {
|
|
$metadata = $data['metadata'];
|
|
|
|
return $metadata['user_agent_hash'] === $expectedUaHash;
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('Test', null, []);
|
|
}
|
|
|
|
public function testLogAuthenticationWithTenantIdOverride(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
$overrideTenantId = 'override-tenant-uuid-1234';
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($overrideTenantId): bool {
|
|
$metadata = $data['metadata'];
|
|
|
|
return $metadata['tenant_id'] === $overrideTenantId;
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
// No TenantContext set, but override should be used
|
|
$this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
|
|
}
|
|
|
|
public function testTenantIdOverrideTakesPrecedenceOverContext(): void
|
|
{
|
|
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
|
$this->clock->method('now')->willReturn($now);
|
|
$this->tokenStorage->method('getToken')->willReturn(null);
|
|
|
|
// Set a tenant in context
|
|
$contextTenantId = TenantId::generate();
|
|
$this->setCurrentTenant($contextTenantId);
|
|
|
|
// But use a different override
|
|
$overrideTenantId = 'override-tenant-uuid-5678';
|
|
|
|
$this->connection->expects($this->once())
|
|
->method('insert')
|
|
->with(
|
|
'audit_log',
|
|
$this->callback(static function (array $data) use ($overrideTenantId): bool {
|
|
$metadata = $data['metadata'];
|
|
|
|
// Override should take precedence over context
|
|
return $metadata['tenant_id'] === $overrideTenantId;
|
|
}),
|
|
$this->anything(),
|
|
);
|
|
|
|
$this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
|
|
}
|
|
|
|
private function setCurrentTenant(TenantId $tenantId): void
|
|
{
|
|
$config = new TenantConfig(
|
|
tenantId: InfrastructureTenantId::fromString((string) $tenantId),
|
|
subdomain: 'test-tenant',
|
|
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_test',
|
|
);
|
|
$this->tenantContext->setCurrentTenant($config);
|
|
}
|
|
}
|