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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user