Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
186 lines
6.2 KiB
PHP
186 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Shared\Infrastructure\Audit;
|
|
|
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
|
use App\Shared\Application\Port\AuditLogger;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
use const JSON_THROW_ON_ERROR;
|
|
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Ramsey\Uuid\Uuid;
|
|
|
|
/**
|
|
* [P1] Functional tests for audit trail infrastructure.
|
|
*
|
|
* Verifies that the AuditLogger writes to the real audit_log table
|
|
* and that entries contain correct metadata.
|
|
*
|
|
* @see NFR-S7: Audit trail immutable (qui, quoi, quand)
|
|
* @see FR90: Tracage actions sensibles
|
|
*/
|
|
final class AuditTrailFunctionalTest extends ApiTestCase
|
|
{
|
|
protected static ?bool $alwaysBootKernel = true;
|
|
|
|
private Connection $connection;
|
|
private AuditLogger $auditLogger;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
static::bootKernel();
|
|
$container = static::getContainer();
|
|
|
|
/* @var Connection $connection */
|
|
$this->connection = $container->get(Connection::class);
|
|
|
|
/* @var AuditLogger $auditLogger */
|
|
$this->auditLogger = $container->get(AuditLogger::class);
|
|
}
|
|
|
|
#[Test]
|
|
public function logAuthenticationWritesEntryToAuditLogTable(): void
|
|
{
|
|
$userId = Uuid::uuid4();
|
|
|
|
$this->auditLogger->logAuthentication(
|
|
eventType: 'ConnexionReussie',
|
|
userId: $userId,
|
|
payload: [
|
|
'email_hash' => hash('sha256', 'test@example.com'),
|
|
'result' => 'success',
|
|
'method' => 'password',
|
|
],
|
|
);
|
|
|
|
$entry = $this->connection->fetchAssociative(
|
|
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
|
[$userId->toString(), 'ConnexionReussie'],
|
|
);
|
|
|
|
self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication');
|
|
self::assertSame('User', $entry['aggregate_type']);
|
|
self::assertSame($userId->toString(), $entry['aggregate_id']);
|
|
self::assertSame('ConnexionReussie', $entry['event_type']);
|
|
|
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
|
self::assertSame('success', $payload['result']);
|
|
self::assertSame('password', $payload['method']);
|
|
self::assertArrayHasKey('email_hash', $payload);
|
|
}
|
|
|
|
#[Test]
|
|
public function logAuthenticationIncludesMetadataWithTimestamp(): void
|
|
{
|
|
$userId = Uuid::uuid4();
|
|
|
|
$this->auditLogger->logAuthentication(
|
|
eventType: 'ConnexionReussie',
|
|
userId: $userId,
|
|
payload: ['result' => 'success'],
|
|
);
|
|
|
|
$entry = $this->connection->fetchAssociative(
|
|
'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1',
|
|
[$userId->toString()],
|
|
);
|
|
|
|
self::assertNotFalse($entry);
|
|
self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp');
|
|
|
|
$metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR);
|
|
self::assertIsArray($metadata);
|
|
}
|
|
|
|
#[Test]
|
|
public function logFailedAuthenticationWritesWithNullUserId(): void
|
|
{
|
|
$this->auditLogger->logAuthentication(
|
|
eventType: 'ConnexionEchouee',
|
|
userId: null,
|
|
payload: [
|
|
'email_hash' => hash('sha256', 'unknown@example.com'),
|
|
'result' => 'failure',
|
|
'reason' => 'invalid_credentials',
|
|
],
|
|
);
|
|
|
|
$entry = $this->connection->fetchAssociative(
|
|
"SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1",
|
|
);
|
|
|
|
self::assertNotFalse($entry, 'Failed login audit entry should exist');
|
|
self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID');
|
|
self::assertSame('User', $entry['aggregate_type']);
|
|
|
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
|
self::assertSame('failure', $payload['result']);
|
|
self::assertSame('invalid_credentials', $payload['reason']);
|
|
}
|
|
|
|
#[Test]
|
|
public function logDataChangeWritesOldAndNewValues(): void
|
|
{
|
|
$aggregateId = Uuid::uuid4();
|
|
|
|
$this->auditLogger->logDataChange(
|
|
aggregateType: 'Grade',
|
|
aggregateId: $aggregateId,
|
|
eventType: 'GradeModified',
|
|
oldValues: ['value' => 14.0],
|
|
newValues: ['value' => 16.0],
|
|
reason: 'Correction erreur de saisie',
|
|
);
|
|
|
|
$entry = $this->connection->fetchAssociative(
|
|
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
|
[$aggregateId->toString(), 'GradeModified'],
|
|
);
|
|
|
|
self::assertNotFalse($entry);
|
|
self::assertSame('Grade', $entry['aggregate_type']);
|
|
|
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
|
self::assertSame(['value' => 14.0], $payload['old_values']);
|
|
self::assertSame(['value' => 16.0], $payload['new_values']);
|
|
self::assertSame('Correction erreur de saisie', $payload['reason']);
|
|
}
|
|
|
|
#[Test]
|
|
public function auditLogEntriesAreAppendOnly(): void
|
|
{
|
|
$userId = Uuid::uuid4();
|
|
|
|
$this->auditLogger->logAuthentication(
|
|
eventType: 'ConnexionReussie',
|
|
userId: $userId,
|
|
payload: ['result' => 'success'],
|
|
);
|
|
|
|
$countBefore = (int) $this->connection->fetchOne(
|
|
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
|
[$userId->toString()],
|
|
);
|
|
|
|
self::assertSame(1, $countBefore);
|
|
|
|
// Log a second event for the same user
|
|
$this->auditLogger->logAuthentication(
|
|
eventType: 'ConnexionReussie',
|
|
userId: $userId,
|
|
payload: ['result' => 'success'],
|
|
);
|
|
|
|
$countAfter = (int) $this->connection->fetchOne(
|
|
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
|
[$userId->toString()],
|
|
);
|
|
|
|
// Both entries should exist (append-only, no overwrite)
|
|
self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist');
|
|
}
|
|
}
|