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
225 lines
7.8 KiB
PHP
225 lines
7.8 KiB
PHP
<?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());
|
|
}
|
|
}
|