feat: Audit trail pour actions sensibles

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
This commit is contained in:
2026-02-04 00:11:58 +01:00
parent b823479658
commit 2ed60fdcc1
38 changed files with 4179 additions and 81 deletions

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Audit\AuditLogEntry;
use App\Shared\Infrastructure\Audit\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Result;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
/**
* @see Story 1.7 - T9: Requetes d'investigation
*/
final class AuditLogRepositoryTest extends TestCase
{
private Connection&MockObject $connection;
private AuditLogRepository $repository;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->repository = new AuditLogRepository($this->connection);
}
public function testFindByUserReturnsAuditLogEntries(): void
{
$userId = Uuid::uuid4();
$tenantId = TenantId::generate();
$rows = [
$this->createRowData(),
$this->createRowData(),
];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByUser($userId, $tenantId);
$this->assertCount(2, $result);
$this->assertContainsOnlyInstancesOf(AuditLogEntry::class, $result);
}
public function testFindByUserAppliesFilters(): void
{
$userId = Uuid::uuid4();
$tenantId = TenantId::generate();
$from = new DateTimeImmutable('2026-01-01');
$to = new DateTimeImmutable('2026-02-01');
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('from')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();
$queryBuilder->method('orderBy')->willReturnSelf();
// Verify andWhere is called for each filter
$queryBuilder->expects($this->atLeast(4))
->method('andWhere')
->willReturnSelf();
// Verify setParameter is called with expected parameters
$capturedParams = [];
$queryBuilder->method('setParameter')
->willReturnCallback(static function (string $key, mixed $value) use ($queryBuilder, &$capturedParams) {
$capturedParams[$key] = $value;
return $queryBuilder;
});
// Verify pagination
$queryBuilder->expects($this->once())
->method('setMaxResults')
->with(50)
->willReturnSelf();
$queryBuilder->expects($this->once())
->method('setFirstResult')
->with(10)
->willReturnSelf();
$result = $this->createMock(Result::class);
$result->method('fetchAllAssociative')->willReturn([]);
$queryBuilder->method('executeQuery')->willReturn($result);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$this->repository->findByUser(
$userId,
$tenantId,
$from,
$to,
'ConnexionReussie',
50,
10,
);
// Verify the parameters were captured
$this->assertArrayHasKey('user_id', $capturedParams);
$this->assertArrayHasKey('tenant_id', $capturedParams);
$this->assertArrayHasKey('from', $capturedParams);
$this->assertArrayHasKey('to', $capturedParams);
$this->assertArrayHasKey('event_type', $capturedParams);
$this->assertSame('ConnexionReussie', $capturedParams['event_type']);
}
public function testFindByResourceReturnsResults(): void
{
$aggregateId = Uuid::uuid4();
$tenantId = TenantId::generate();
$rows = [$this->createRowData()];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByResource('Note', $aggregateId, $tenantId);
$this->assertCount(1, $result);
}
public function testFindByCorrelationIdReturnsResults(): void
{
$correlationId = Uuid::uuid4()->toString();
$tenantId = TenantId::generate();
$rows = [$this->createRowData()];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByCorrelationId($correlationId, $tenantId);
$this->assertCount(1, $result);
}
public function testSearchWithAllFilters(): void
{
$tenantId = TenantId::generate();
$from = new DateTimeImmutable('2026-01-01');
$to = new DateTimeImmutable('2026-02-01');
$rows = [$this->createRowData()];
$queryBuilder = $this->createQueryBuilderMock($rows);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->search(
$tenantId,
$from,
$to,
'ConnexionReussie',
'User',
100,
0,
);
$this->assertCount(1, $result);
}
public function testEmptyResultReturnsEmptyArray(): void
{
$userId = Uuid::uuid4();
$tenantId = TenantId::generate();
$queryBuilder = $this->createQueryBuilderMock([]);
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$result = $this->repository->findByUser($userId, $tenantId);
$this->assertSame([], $result);
}
/**
* @return array<string, mixed>
*/
private function createRowData(): array
{
return [
'id' => Uuid::uuid4()->toString(),
'aggregate_type' => 'User',
'aggregate_id' => Uuid::uuid4()->toString(),
'event_type' => 'ConnexionReussie',
'payload' => '{"email_hash":"abc123"}',
'metadata' => '{"tenant_id":"tenant-1","user_id":"user-1"}',
'occurred_at' => '2026-02-03T10:30:00+00:00',
'sequence_number' => '1',
];
}
/**
* @param list<array<string, mixed>> $rows
*/
private function createQueryBuilderMock(array $rows): QueryBuilder&MockObject
{
$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('from')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();
$queryBuilder->method('andWhere')->willReturnSelf();
$queryBuilder->method('setParameter')->willReturnSelf();
$queryBuilder->method('orderBy')->willReturnSelf();
$queryBuilder->method('setMaxResults')->willReturnSelf();
$queryBuilder->method('setFirstResult')->willReturnSelf();
$result = $this->createMock(Result::class);
$result->method('fetchAllAssociative')->willReturn($rows);
$queryBuilder->method('executeQuery')->willReturn($result);
return $queryBuilder;
}
}