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:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Unit\Shared\Domain;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
@@ -48,4 +49,19 @@ final class CorrelationIdTest extends TestCase
|
||||
|
||||
$this->assertNotSame($id1->value(), $id2->value());
|
||||
}
|
||||
|
||||
public function testFromStringRejectsInvalidUuid(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid correlation ID format');
|
||||
|
||||
CorrelationId::fromString('not-a-valid-uuid');
|
||||
}
|
||||
|
||||
public function testFromStringRejectsEmptyString(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
CorrelationId::fromString('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\AuditLogEntry;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T9: Requetes d'investigation
|
||||
*/
|
||||
final class AuditLogEntryTest extends TestCase
|
||||
{
|
||||
public function testFromDatabaseRow(): void
|
||||
{
|
||||
$id = Uuid::uuid4()->toString();
|
||||
$aggregateId = Uuid::uuid4()->toString();
|
||||
$row = [
|
||||
'id' => $id,
|
||||
'aggregate_type' => 'User',
|
||||
'aggregate_id' => $aggregateId,
|
||||
'event_type' => 'ConnexionReussie',
|
||||
'payload' => '{"email_hash":"abc123","result":"success"}',
|
||||
'metadata' => '{"tenant_id":"tenant-1","user_id":"user-1","correlation_id":"corr-1"}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '42',
|
||||
];
|
||||
|
||||
$entry = AuditLogEntry::fromDatabaseRow($row);
|
||||
|
||||
$this->assertEquals($id, $entry->id->toString());
|
||||
$this->assertSame('User', $entry->aggregateType);
|
||||
$this->assertEquals($aggregateId, $entry->aggregateId?->toString());
|
||||
$this->assertSame('ConnexionReussie', $entry->eventType);
|
||||
$this->assertSame(['email_hash' => 'abc123', 'result' => 'success'], $entry->payload);
|
||||
$this->assertSame('tenant-1', $entry->tenantId());
|
||||
$this->assertSame('user-1', $entry->userId());
|
||||
$this->assertSame('corr-1', $entry->correlationId());
|
||||
$this->assertSame(42, $entry->sequenceNumber);
|
||||
}
|
||||
|
||||
public function testFromDatabaseRowWithNullAggregateId(): void
|
||||
{
|
||||
$row = [
|
||||
'id' => Uuid::uuid4()->toString(),
|
||||
'aggregate_type' => 'Export',
|
||||
'aggregate_id' => null,
|
||||
'event_type' => 'ExportGenerated',
|
||||
'payload' => '{}',
|
||||
'metadata' => '{}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '1',
|
||||
];
|
||||
|
||||
$entry = AuditLogEntry::fromDatabaseRow($row);
|
||||
|
||||
$this->assertNull($entry->aggregateId);
|
||||
}
|
||||
|
||||
public function testMetadataAccessorsReturnNullWhenMissing(): void
|
||||
{
|
||||
$row = [
|
||||
'id' => Uuid::uuid4()->toString(),
|
||||
'aggregate_type' => 'User',
|
||||
'aggregate_id' => null,
|
||||
'event_type' => 'Test',
|
||||
'payload' => '{}',
|
||||
'metadata' => '{}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '1',
|
||||
];
|
||||
|
||||
$entry = AuditLogEntry::fromDatabaseRow($row);
|
||||
|
||||
$this->assertNull($entry->tenantId());
|
||||
$this->assertNull($entry->userId());
|
||||
$this->assertNull($entry->correlationId());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdHolderTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testInitiallyReturnsNull(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testSetAndGetCorrelationId(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$this->assertSame($correlationId, CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testClearRemovesCorrelationId(): void
|
||||
{
|
||||
CorrelationIdHolder::set(CorrelationId::generate());
|
||||
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testSetOverwritesPreviousValue(): void
|
||||
{
|
||||
$first = CorrelationId::generate();
|
||||
$second = CorrelationId::generate();
|
||||
|
||||
CorrelationIdHolder::set($first);
|
||||
CorrelationIdHolder::set($second);
|
||||
|
||||
$this->assertSame($second, CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testGetOrGenerateReturnsExistingId(): void
|
||||
{
|
||||
$existing = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($existing);
|
||||
|
||||
$result = CorrelationIdHolder::getOrGenerate();
|
||||
|
||||
$this->assertSame($existing, $result);
|
||||
}
|
||||
|
||||
public function testGetOrGenerateCreatesNewIdWhenNoneSet(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$result = CorrelationIdHolder::getOrGenerate();
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertInstanceOf(CorrelationId::class, $result);
|
||||
}
|
||||
|
||||
public function testGetOrGenerateStoresGeneratedId(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$first = CorrelationIdHolder::getOrGenerate();
|
||||
$second = CorrelationIdHolder::getOrGenerate();
|
||||
|
||||
// Should return the same generated ID, not create a new one each time
|
||||
$this->assertSame($first, $second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Middleware\CorrelationIdMiddleware;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* @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 testGeneratesCorrelationIdWhenHeaderMissing(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($correlationId);
|
||||
$this->assertTrue(Uuid::isValid($correlationId->value()));
|
||||
}
|
||||
|
||||
public function testUsesExistingCorrelationIdFromHeader(): void
|
||||
{
|
||||
$existingId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$request = Request::create('/test');
|
||||
$request->headers->set('X-Correlation-Id', $existingId);
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($correlationId);
|
||||
$this->assertSame($existingId, $correlationId->value());
|
||||
}
|
||||
|
||||
public function testGeneratesNewIdWhenHeaderContainsInvalidUuid(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$request->headers->set('X-Correlation-Id', 'not-a-valid-uuid');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($correlationId);
|
||||
// Should have generated a new valid UUID, not kept the invalid one
|
||||
$this->assertTrue(Uuid::isValid($correlationId->value()));
|
||||
$this->assertNotSame('not-a-valid-uuid', $correlationId->value());
|
||||
}
|
||||
|
||||
public function testStoresCorrelationIdInRequestAttributes(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$this->assertInstanceOf(CorrelationId::class, $request->attributes->get('correlation_id'));
|
||||
}
|
||||
|
||||
public function testIgnoresSubRequests(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, false);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testAddsCorrelationIdHeaderToResponse(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$response = new Response();
|
||||
$event = $this->createResponseEvent($request, $response, true);
|
||||
|
||||
$this->middleware->onKernelResponse($event);
|
||||
|
||||
$this->assertSame(
|
||||
$correlationId->value(),
|
||||
$response->headers->get('X-Correlation-Id'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDoesNotAddHeaderToSubRequestResponse(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$response = new Response();
|
||||
$event = $this->createResponseEvent($request, $response, false);
|
||||
|
||||
$this->middleware->onKernelResponse($event);
|
||||
|
||||
$this->assertNull($response->headers->get('X-Correlation-Id'));
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdOnTerminate(): void
|
||||
{
|
||||
CorrelationIdHolder::set(CorrelationId::generate());
|
||||
|
||||
$request = Request::create('/test');
|
||||
$response = new Response();
|
||||
$event = $this->createTerminateEvent($request, $response);
|
||||
|
||||
$this->middleware->onKernelTerminate($event);
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testDefensiveClearOnNewRequest(): void
|
||||
{
|
||||
// Simulate a stale correlation ID from a previous request that didn't clean up
|
||||
$staleId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($staleId);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
// Should have a NEW correlation ID, not the stale one
|
||||
$newId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($newId);
|
||||
$this->assertNotSame($staleId->value(), $newId->value());
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request, bool $isMainRequest): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
$isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
private function createResponseEvent(Request $request, Response $response, bool $isMainRequest): ResponseEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new ResponseEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
$isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST,
|
||||
$response,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTerminateEvent(Request $request, Response $response): TerminateEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new TerminateEvent($kernel, $request, $response);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Console\ArchiveAuditLogsCommand;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T8: Archivage
|
||||
*/
|
||||
final class ArchiveAuditLogsCommandTest extends TestCase
|
||||
{
|
||||
private Connection&MockObject $connection;
|
||||
private Clock&MockObject $clock;
|
||||
private ArchiveAuditLogsCommand $command;
|
||||
private CommandTester $commandTester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->clock = $this->createMock(Clock::class);
|
||||
|
||||
$this->command = new ArchiveAuditLogsCommand(
|
||||
$this->connection,
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
$this->commandTester = new CommandTester($this->command);
|
||||
}
|
||||
|
||||
public function testCommandNameIsCorrect(): void
|
||||
{
|
||||
$this->assertSame('app:audit:archive', $this->command->getName());
|
||||
}
|
||||
|
||||
public function testCommandDescription(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'Archive audit log entries older than 5 years',
|
||||
$this->command->getDescription(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testNoEntriesToArchiveReturnsSuccess(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(0); // COUNT returns 0
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('No entries to archive', $output);
|
||||
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDryRunDoesNotCallArchiveFunction(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('fetchOne')
|
||||
->willReturn(100); // 100 entries to archive
|
||||
|
||||
// archive_audit_entries should NOT be called in dry-run mode
|
||||
$this->connection->expects($this->never())
|
||||
->method('executeStatement');
|
||||
|
||||
$this->commandTester->execute(['--dry-run' => true]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('DRY RUN', $output);
|
||||
$this->assertStringContainsString('Would archive 100 entries', $output);
|
||||
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testArchivesBatchesUntilComplete(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
// First call: COUNT returns 150
|
||||
// Subsequent calls: archive_audit_entries returns batch counts
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
150, // COUNT query
|
||||
100, // First batch (full)
|
||||
50, // Second batch (partial, stops)
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '100']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Successfully archived 150', $output);
|
||||
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testCustomRetentionYears(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->method('fetchOne')->willReturn(0);
|
||||
|
||||
$this->commandTester->execute(['--retention-years' => '3']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('(3 years retention)', $output);
|
||||
}
|
||||
|
||||
public function testCustomBatchSize(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
// Return 500 entries to archive, then archive in 500-entry batches
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
500, // COUNT
|
||||
500, // First batch (equal to batch size)
|
||||
0, // Second batch (none left)
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '500']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Successfully archived 500', $output);
|
||||
}
|
||||
|
||||
public function testShowsProgressBar(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
50, // COUNT
|
||||
50, // First batch
|
||||
);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
// Progress bar output includes percentage
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Successfully archived 50', $output);
|
||||
}
|
||||
|
||||
public function testCalculatesCutoffDateCorrectly(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$capturedCutoff = null;
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnCallback(static function (string $sql, array $params) use (&$capturedCutoff) {
|
||||
if (str_contains($sql, 'COUNT')) {
|
||||
$capturedCutoff = $params['cutoff'];
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
$this->commandTester->execute(['--retention-years' => '5']);
|
||||
|
||||
// Cutoff should be 5 years before now (2021-02-03)
|
||||
$this->assertNotNull($capturedCutoff);
|
||||
$this->assertStringContainsString('2021-02-03', $capturedCutoff);
|
||||
}
|
||||
|
||||
public function testZeroBatchSizeReturnsFailure(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '0']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Batch size must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNegativeBatchSizeReturnsFailure(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '-5']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Batch size must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testZeroRetentionYearsReturnsFailure(): void
|
||||
{
|
||||
$this->commandTester->execute(['--retention-years' => '0']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Retention years must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNegativeRetentionYearsReturnsFailure(): void
|
||||
{
|
||||
$this->commandTester->execute(['--retention-years' => '-5']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Retention years must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -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