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,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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user