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,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware;
|
||||
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class AddCorrelationIdStampMiddlewareTest extends TestCase
|
||||
{
|
||||
private AddCorrelationIdStampMiddleware $middleware;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new AddCorrelationIdStampMiddleware();
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testAddsStampWhenCorrelationIdIsSet(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$result = $this->middleware->handle($envelope, $stack);
|
||||
|
||||
$stamp = $result->last(CorrelationIdStamp::class);
|
||||
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
|
||||
$this->assertSame($correlationId->value(), $stamp->correlationId);
|
||||
}
|
||||
|
||||
public function testDoesNotAddStampWhenNoCorrelationId(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$result = $this->middleware->handle($envelope, $stack);
|
||||
|
||||
$stamp = $result->last(CorrelationIdStamp::class);
|
||||
$this->assertNull($stamp);
|
||||
}
|
||||
|
||||
public function testDoesNotOverwriteExistingStamp(): void
|
||||
{
|
||||
$existingId = '11111111-1111-1111-1111-111111111111';
|
||||
$currentId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($currentId);
|
||||
|
||||
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($existingId)]);
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$result = $this->middleware->handle($envelope, $stack);
|
||||
|
||||
$stamp = $result->last(CorrelationIdStamp::class);
|
||||
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
|
||||
// Should keep the existing stamp, not overwrite with current
|
||||
$this->assertSame($existingId, $stamp->correlationId);
|
||||
}
|
||||
|
||||
private function createCapturingStack(): StackInterface
|
||||
{
|
||||
return new class implements StackInterface {
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $envelope;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware;
|
||||
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdMiddlewareTest extends TestCase
|
||||
{
|
||||
private CorrelationIdMiddleware $middleware;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new CorrelationIdMiddleware();
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testUsesCorrelationIdFromStampWhenPresent(): void
|
||||
{
|
||||
$expectedId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($expectedId)]);
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Verify the correlation ID was set during handling
|
||||
$this->assertSame($expectedId, $stack->capturedCorrelationId);
|
||||
}
|
||||
|
||||
public function testGeneratesNewCorrelationIdWhenNoStampAndNoExistingId(): void
|
||||
{
|
||||
// Async worker context: no existing ID, no stamp
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should have generated a valid UUID
|
||||
$this->assertNotNull($stack->capturedCorrelationId);
|
||||
$this->assertTrue(Uuid::isValid($stack->capturedCorrelationId));
|
||||
}
|
||||
|
||||
public function testUsesExistingCorrelationIdDuringSynchronousDispatch(): void
|
||||
{
|
||||
// HTTP context: existing ID is set by HTTP middleware
|
||||
$existingId = '99999999-9999-9999-9999-999999999999';
|
||||
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should use the existing ID, not generate a new one
|
||||
$this->assertSame($existingId, $stack->capturedCorrelationId);
|
||||
}
|
||||
|
||||
public function testDoesNotClearCorrelationIdDuringSynchronousDispatch(): void
|
||||
{
|
||||
// HTTP context: existing ID should be preserved after dispatch
|
||||
$existingId = '99999999-9999-9999-9999-999999999999';
|
||||
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createPassthroughStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should NOT be cleared - HTTP middleware handles that
|
||||
$this->assertNotNull(CorrelationIdHolder::get());
|
||||
$this->assertSame($existingId, CorrelationIdHolder::get()?->value());
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdInAsyncContext(): void
|
||||
{
|
||||
// Async worker context: no existing ID, no stamp
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createPassthroughStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should be cleared after handling in async context
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdWhenStampPresent(): void
|
||||
{
|
||||
// Async worker receiving message from HTTP: has stamp
|
||||
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
|
||||
$stack = $this->createPassthroughStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should be cleared after handling (async context)
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdEvenOnException(): void
|
||||
{
|
||||
// Async worker context
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createThrowingStack();
|
||||
|
||||
try {
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
$this->fail('Expected exception to be thrown');
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Should be cleared even after exception in async context
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testDoesNotLeakBetweenMessages(): void
|
||||
{
|
||||
$envelope1 = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
|
||||
$envelope2 = new Envelope(new stdClass(), [new CorrelationIdStamp('22222222-2222-2222-2222-222222222222')]);
|
||||
|
||||
$stack1 = $this->createCapturingStack();
|
||||
$stack2 = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope1, $stack1);
|
||||
$this->middleware->handle($envelope2, $stack2);
|
||||
|
||||
$this->assertSame('11111111-1111-1111-1111-111111111111', $stack1->capturedCorrelationId);
|
||||
$this->assertSame('22222222-2222-2222-2222-222222222222', $stack2->capturedCorrelationId);
|
||||
}
|
||||
|
||||
private function createCapturingStack(): CapturingStack
|
||||
{
|
||||
return new CapturingStack();
|
||||
}
|
||||
|
||||
private function createPassthroughStack(): StackInterface
|
||||
{
|
||||
return new class implements StackInterface {
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $envelope;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createThrowingStack(): StackInterface
|
||||
{
|
||||
return new class implements StackInterface {
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
throw new RuntimeException('Handler failed');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack that captures the correlation ID during handling.
|
||||
*/
|
||||
final class CapturingStack implements StackInterface
|
||||
{
|
||||
public ?string $capturedCorrelationId = null;
|
||||
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new CapturingMiddleware($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CapturingMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private CapturingStack $stack)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
$this->stack->capturedCorrelationId = CorrelationIdHolder::get()?->value();
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user