405). * * Seed : on insere 3 lignes temoins directement via DBAL (pas via l'ORM) * pour eviter la recursion du listener. Les lignes sont supprimees en * tearDown par le request_id tag specifique au run. * * @internal */ final class AuditLogApiTest extends AbstractApiTestCase { private Connection $auditConnection; private string $runTag; protected function setUp(): void { parent::setUp(); self::bootKernel(); /** @var Connection $conn */ $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); $this->auditConnection = $conn; $this->runTag = 'api_audit_'.bin2hex(random_bytes(4)); $this->seedAuditLog(); } protected function tearDown(): void { $this->auditConnection->executeStatement( 'DELETE FROM audit_log WHERE request_id = :tag', ['tag' => $this->runTag], ); parent::tearDown(); } public function testUnauthenticatedRequestGets401(): void { $client = self::createClient(); $response = $client->request('GET', '/api/audit-logs'); self::assertSame(401, $response->getStatusCode()); } public function testAuthenticatedUserWithoutPermissionGets403(): void { // Utilise `core.users.view` comme permission non-liee (l'user n'a pas audit_log.view). $credentials = $this->createUserWithPermission('core.users.view'); $client = $this->authenticatedClient($credentials['username'], $credentials['password']); $response = $client->request('GET', '/api/audit-logs'); self::assertSame(403, $response->getStatusCode()); } public function testAuthenticatedUserWithPermissionGets200(): void { $credentials = $this->createUserWithPermission('core.audit_log.view'); $client = $this->authenticatedClient($credentials['username'], $credentials['password']); $response = $client->request('GET', '/api/audit-logs'); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); self::assertArrayHasKey('member', $data); self::assertArrayHasKey('totalItems', $data); } public function testAdminGets200(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs'); self::assertSame(200, $response->getStatusCode()); } public function testFilterByEntityType(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs?entity_type=core.User&action=update'); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); $members = $data['member']; // On verifie qu'il n'y a que des lignes matching nos filtres dans les resultats de notre run // (d'autres lignes antérieures au run peuvent exister, mais le filtre doit etre respecte). foreach ($members as $member) { self::assertSame('core.User', $member['entityType']); self::assertSame('update', $member['action']); } } public function testOrderedByPerformedAtDesc(): void { $client = $this->authenticatedClient('admin', 'admin'); // On cible les 3 lignes seedees via le filtre `entity_id=999` (unique a ce test). $response = $client->request('GET', '/api/audit-logs?'.http_build_query(['entity_type' => 'core.User', 'entity_id' => '999'])); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); $members = array_values(array_filter( $data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag, )); self::assertCount(3, $members, 'Les 3 lignes seedees doivent etre visibles'); // Tri DESC : le plus recent d'abord. $timestamps = array_map(fn (array $m) => strtotime((string) $m['performedAt']), $members); $sortedDesc = $timestamps; rsort($sortedDesc); self::assertSame($sortedDesc, $timestamps, 'Les lignes doivent etre triees par performedAt DESC'); } public function testItemEndpointReturns200WithPermission(): void { $row = $this->auditConnection->fetchAssociative( 'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1', ['tag' => $this->runTag], ); self::assertIsArray($row); $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs/'.$row['id']); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); self::assertSame($row['id'], $data['id']); } public function testPostIsNotAllowed(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('POST', '/api/audit-logs', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['entityType' => 'core.User', 'entityId' => '1', 'action' => 'create', 'changes' => []], ]); self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)'); } /** * Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur. */ private function seedAuditLog(): void { $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $fixtures = [ [ 'entity_type' => 'core.User', 'entity_id' => '999', 'action' => 'update', 'changes' => ['isAdmin' => ['old' => false, 'new' => true]], 'performed_by' => 'admin', 'performed_at' => $now->modify('-2 hours'), ], [ 'entity_type' => 'core.User', 'entity_id' => '999', 'action' => 'update', 'changes' => ['username' => ['old' => 'x', 'new' => 'y']], 'performed_by' => 'admin', 'performed_at' => $now->modify('-1 hour'), ], [ 'entity_type' => 'core.User', 'entity_id' => '999', 'action' => 'delete', 'changes' => ['username' => 'y'], 'performed_by' => 'admin', 'performed_at' => $now, ], ]; foreach ($fixtures as $row) { $this->auditConnection->insert('audit_log', [ 'id' => Uuid::v7()->toRfc4122(), 'entity_type' => $row['entity_type'], 'entity_id' => $row['entity_id'], 'action' => $row['action'], 'changes' => json_encode($row['changes'], JSON_THROW_ON_ERROR), 'performed_by' => $row['performed_by'], 'performed_at' => $row['performed_at']->format('Y-m-d H:i:sO'), 'ip_address' => null, 'request_id' => $this->runTag, ]); } } }