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,200 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Audit\Handler;
use App\Administration\Domain\Event\CompteBloqueTemporairement;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Administration\Domain\Event\ConnexionReussie;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Shared\Application\Port\AuditLogger;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler;
use DateTimeImmutable;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
/**
* @see Story 1.7 - T4: Listeners Authentification
*/
final class AuditAuthenticationHandlerTest extends TestCase
{
private AuditLogger&MockObject $auditLogger;
private AuditAuthenticationHandler $handler;
protected function setUp(): void
{
$this->auditLogger = $this->createMock(AuditLogger::class);
$this->handler = new AuditAuthenticationHandler(
$this->auditLogger,
'test-secret',
);
}
public function testHandleConnexionReussieLogsSuccessfulLogin(): void
{
$userId = Uuid::uuid4()->toString();
$event = new ConnexionReussie(
userId: $userId,
email: 'user@example.com',
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('ConnexionReussie'),
$this->callback(static fn ($uuid) => $uuid->toString() === $userId),
$this->callback(static fn ($payload) => $payload['result'] === 'success'
&& $payload['method'] === 'password'
&& isset($payload['email_hash'])
),
);
$this->handler->handleConnexionReussie($event);
}
public function testHandleConnexionEchoueeLogsFailedLogin(): void
{
$tenantId = TenantId::generate();
$event = new ConnexionEchouee(
email: 'user@example.com',
tenantId: $tenantId,
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
reason: 'invalid_credentials',
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('ConnexionEchouee'),
$this->isNull(),
$this->callback(static fn ($payload) => $payload['result'] === 'failure'
&& $payload['reason'] === 'invalid_credentials'
&& isset($payload['email_hash'])
),
$this->equalTo((string) $tenantId),
);
$this->handler->handleConnexionEchouee($event);
}
public function testHandleCompteBloqueTemporairementLogsLockout(): void
{
$tenantId = TenantId::generate();
$event = new CompteBloqueTemporairement(
email: 'user@example.com',
tenantId: $tenantId,
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
blockedForSeconds: 300,
failedAttempts: 5,
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('CompteBloqueTemporairement'),
$this->isNull(),
$this->callback(static fn ($payload) => $payload['blocked_for_seconds'] === 300
&& $payload['failed_attempts'] === 5
&& isset($payload['email_hash'])
),
$this->equalTo((string) $tenantId),
);
$this->handler->handleCompteBloqueTemporairement($event);
}
public function testHandleMotDePasseChangeLogsPasswordChange(): void
{
$userId = Uuid::uuid4()->toString();
$event = new MotDePasseChange(
userId: $userId,
email: 'user@example.com',
tenantId: TenantId::generate(),
occurredOn: new DateTimeImmutable(),
);
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->with(
$this->equalTo('MotDePasseChange'),
$this->callback(static fn ($uuid) => $uuid->toString() === $userId),
$this->callback(static fn ($payload) => isset($payload['email_hash'])),
);
$this->handler->handleMotDePasseChange($event);
}
public function testEmailIsHashedConsistently(): void
{
$email = 'user@example.com';
$expectedHash = hash('sha256', strtolower($email) . 'test-secret');
$capturedPayload = null;
$this->auditLogger->expects($this->once())
->method('logAuthentication')
->willReturnCallback(static function ($eventType, $userId, $payload) use (&$capturedPayload) {
$capturedPayload = $payload;
});
$event = new ConnexionReussie(
userId: Uuid::uuid4()->toString(),
email: $email,
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$this->handler->handleConnexionReussie($event);
$this->assertSame($expectedHash, $capturedPayload['email_hash']);
}
public function testEmailHashIsCaseInsensitive(): void
{
$lowerEmail = 'user@example.com';
$upperEmail = 'USER@EXAMPLE.COM';
$expectedHash = hash('sha256', strtolower($lowerEmail) . 'test-secret');
$payloads = [];
$this->auditLogger->expects($this->exactly(2))
->method('logAuthentication')
->willReturnCallback(static function ($eventType, $userId, $payload) use (&$payloads) {
$payloads[] = $payload;
});
$event1 = new ConnexionReussie(
userId: Uuid::uuid4()->toString(),
email: $lowerEmail,
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$event2 = new ConnexionReussie(
userId: Uuid::uuid4()->toString(),
email: $upperEmail,
tenantId: TenantId::generate(),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
occurredOn: new DateTimeImmutable(),
);
$this->handler->handleConnexionReussie($event1);
$this->handler->handleConnexionReussie($event2);
$this->assertSame($payloads[0]['email_hash'], $payloads[1]['email_hash']);
}
}