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 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']); } /** * Symetrique de `testAuthenticatedUserWithoutPermissionGets403` mais sur * l'endpoint item. Le `security: is_granted('core.audit_log.view')` declare * sur `Get` dans `AuditLogResource` doit refuser 403 (pas 200, pas 404). */ public function testItemEndpointWithoutPermissionGets403(): void { $row = $this->auditConnection->fetchAssociative( 'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1', ['tag' => $this->runTag], ); self::assertIsArray($row); // Permission "voisine" : prouve que l'auth seule ne suffit pas. $credentials = $this->createUserWithPermission('core.users.view'); $client = $this->authenticatedClient($credentials['username'], $credentials['password']); $response = $client->request('GET', '/api/audit-logs/'.$row['id']); self::assertSame(403, $response->getStatusCode()); } /** * `?page=0` provoquait historiquement un OFFSET negatif → 500 PG * `SQLSTATE[22023] OFFSET must not be negative`. API Platform 4 valide * desormais `page >= 1` en amont (rejette 400) avant que le provider ne * soit appele ; le clamp `max(1, ...)` cote provider reste en place comme * defense-in-depth si un futur upgrade ou un changement de configuration * leve cette validation. Ce test verrouille l'invariant fonctionnel : * aucun 500 PG quel que soit le mecanisme protecteur en place. */ public function testPageZeroDoesNotProduceServerError(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs?page=0'); self::assertContains( $response->getStatusCode(), [200, 400], 'page=0 doit etre traite proprement (clamp 200 ou 400 explicite), jamais 500 PG.', ); } /** * Validation des filtres : un input malforme doit retourner un 400 * explicite, pas un 500 (Postgres qui rejette le cast timestamp) ni * un match silencieux sur une valeur inattendue. */ public function testInvalidPerformedAtFilterReturns400(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs?performed_at[after]=pas-une-date'); self::assertSame(400, $response->getStatusCode()); } public function testInvalidActionFilterReturns400(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs?action=dropTable'); self::assertSame(400, $response->getStatusCode()); } 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)'); } /** * Filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` : l'union des * deux types est retournee. On seed 2 types differents (core.User et * core.Role) et on verifie que les deux apparaissent sous notre runTag, * et qu'une valeur non existante (`core.Nonexistent`) n'ajoute rien. * * On interroge avec itemsPerPage=100 pour englober nos 5 lignes quel * que soit le bruit de lignes preexistantes dans audit_log. */ public function testFilterByMultipleEntityTypes(): void { // Seed 2 lignes supplementaires avec un autre entity_type. $this->seedExtraRow('core.Role', '1001', 'create'); $this->seedExtraRow('core.Role', '1002', 'update'); $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs?'.http_build_query([ 'entity_type' => ['core.User', 'core.Role', 'core.Nonexistent'], 'itemsPerPage' => 100, ])); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); // Filtre sur notre runTag pour isoler nos 5 lignes (3 User + 2 Role) // independamment des entrees pre-existantes de la table. $ours = array_values(array_filter( $data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag, )); self::assertCount(5, $ours, 'Les 3 lignes core.User + 2 lignes core.Role doivent etre retournees.'); $types = array_unique(array_map(fn (array $m) => $m['entityType'], $ours)); sort($types); self::assertSame(['core.Role', 'core.User'], $types); // Verifier qu'aucune ligne hors filtre n'apparait dans la reponse. foreach ($data['member'] as $member) { self::assertContains($member['entityType'], ['core.User', 'core.Role']); } } /** * Recherche partielle insensible a la casse sur `performed_by` via ILIKE. * Le seed utilise `performed_by=admin` ; on cherche `ADM` pour tester * a la fois la casse et le wildcard contains. */ public function testFilterByPerformedByPartialMatch(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-logs?performed_by=ADM&entity_id=999'); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); self::assertGreaterThan(0, count($ours), 'La recherche ILIKE doit matcher "ADM" -> "admin".'); } /** * Les caracteres wildcard PostgreSQL (`%`, `_`) saisis par l'utilisateur * doivent etre echappes et traites comme caracteres litteraux, pas comme * des metacaracteres LIKE. `\` est le caractere d'echappement LIKE par * defaut en PostgreSQL (pas de clause ESCAPE explicite) : on le double * dans le motif pour qu'il soit aussi traite comme litteral. */ public function testFilterByPerformedByEscapesWildcards(): void { $client = $this->authenticatedClient('admin', 'admin'); // `%` seul doit matcher 0 ligne (personne n'a `%` dans performed_by). $response = $client->request('GET', '/api/audit-logs?performed_by=%25&entity_id=999'); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); self::assertCount(0, $ours, '% doit etre traite comme literal, pas wildcard.'); // `_` seul (wildcard single-char en LIKE) doit aussi matcher 0 ligne. $response = $client->request('GET', '/api/audit-logs?performed_by=_&entity_id=999'); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); self::assertCount(0, $ours, '_ doit etre traite comme literal, pas wildcard single-char.'); // `\` (backslash, echappement LIKE par defaut en PG) : un seul `\` // dans l'entree utilisateur est double en `\\` et doit etre interprete // comme litteral. On attend une reponse 200 (pas 500), resultat vide. $response = $client->request('GET', '/api/audit-logs?performed_by=%5C&entity_id=999'); self::assertSame(200, $response->getStatusCode(), 'Un backslash dans le filtre ne doit pas produire de 500.'); } /** * L'endpoint `/api/audit-log-entity-types` retourne la liste des valeurs * distinctes de `entity_type` presentes dans la table. La presence du * seed runTag garantit au moins `core.User`. */ public function testEntityTypesEndpointReturnsDistinctTypes(): void { $client = $this->authenticatedClient('admin', 'admin'); $response = $client->request('GET', '/api/audit-log-entity-types'); self::assertSame(200, $response->getStatusCode()); $data = $response->toArray(); self::assertArrayHasKey('entityTypes', $data); self::assertIsArray($data['entityTypes']); self::assertContains('core.User', $data['entityTypes']); } public function testEntityTypesEndpointRequiresPermission(): void { $credentials = $this->createUserWithPermission('core.users.view'); $client = $this->authenticatedClient($credentials['username'], $credentials['password']); $response = $client->request('GET', '/api/audit-log-entity-types'); self::assertSame(403, $response->getStatusCode()); } /** * Helper interne pour seeder une ligne additionnelle avec un entity_type * arbitraire, taggee runTag pour nettoyage en tearDown. */ private function seedExtraRow(string $entityType, string $entityId, string $action): void { $this->auditConnection->insert('audit_log', [ 'id' => Uuid::v7()->toRfc4122(), 'entity_type' => $entityType, 'entity_id' => $entityId, 'action' => $action, 'changes' => json_encode(['field' => ['old' => 1, 'new' => 2]], JSON_THROW_ON_ERROR), 'performed_by' => 'admin', 'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format('Y-m-d H:i:sO'), 'ip_address' => null, 'request_id' => $this->runTag, ]); } /** * 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', // Offsets faibles (secondes) : garantit que les 3 lignes // restent parmi les plus recentes de audit_log meme quand la // table contient plusieurs centaines de lignes historiques. 'performed_at' => $now->modify('-2 seconds'), ], [ 'entity_type' => 'core.User', 'entity_id' => '999', 'action' => 'update', 'changes' => ['username' => ['old' => 'x', 'new' => 'y']], 'performed_by' => 'admin', 'performed_at' => $now->modify('-1 second'), ], [ '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, ]); } } }