Files
Classeo/backend/tests/Unit/Shared/Infrastructure/Console/ArchiveAuditLogsCommandTest.php
Mathias STRASSER 2ed60fdcc1 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
2026-02-04 00:11:58 +01:00

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());
}
}