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 { // Proprietes nullable : si `bootKernel()` ou l'acces container echoue, // `tearDown` se declenche quand meme et doit survivre a un setUp incomplet // (sinon on masque l'exception d'origine avec un "typed property must not // be accessed before initialization"). private ?Connection $auditConnection = null; private ?string $runTag = null; protected function setUp(): void { parent::setUp(); self::bootKernel(); /** @var Connection $conn */ $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); $this->auditConnection = $conn; $this->runTag = 'apiaudit'.bin2hex(random_bytes(4)); $this->seedAuditLog(); } protected function tearDown(): void { if (null !== $this->auditConnection && null !== $this->runTag) { $this->auditConnection->executeStatement( 'DELETE FROM audit_log WHERE request_id = :tag', ['tag' => $this->runTag], ); // Close explicite pour liberer la connexion PG : en test, le // kernel reboote et les connexions pendantes saturent le pool // sur une suite de 200+ tests qui ouvrent 2 connexions chacun. $this->auditConnection->close(); } 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); } /** * Le frontend force `Accept: application/ld+json` dans `useAuditLog` pour * recuperer les cles prefixees `hydra:*` (et `hydra:view` pour la * pagination). Ce test verrouille ce contrat : sans lui, un changement * de configuration API Platform cassant le JSON-LD passerait inaperçu * et le tableau admin apparaitrait silencieusement vide en production. */ /** * Le frontend demande explicitement `application/ld+json` dans `useAuditLog` * pour obtenir l'objet Hydra complet (`member`, `totalItems`, `view`). Sous * `application/json`, API Platform 4 renvoie un tableau plat sans ces * metadonnees, ce qui casserait la pagination prev/next. Ce test verrouille * le contrat : un changement de format par defaut ou une desactivation de * JSON-LD produirait un 200 trompeur mais un tableau admin vide. */ public function testJsonLdFormatExposesHydraEnvelope(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs', [ 'headers' => ['Accept' => 'application/ld+json'], ]); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('application/ld+json', $response->getHeaders()['content-type'][0]); $data = $response->toArray(); self::assertArrayHasKey('member', $data); self::assertArrayHasKey('totalItems', $data); // `view` n'est presente que si une pagination est active (plus d'items // que la limite par page). Avec paginationItemsPerPage=30 et les 3 // lignes seedees (+ d'autres lignes de tests precedents), la collection // peut excelder 30. Si presente, elle doit porter au moins @id. if (isset($data['view'])) { self::assertArrayHasKey('@id', $data['view']); } } 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, ]); } } }