Files
Coltura/tests/Module/Core/Api/AuditLogApiTest.php
matthieu e0624eace0 fix(audit-log) : reset pendingLogs sur onFlush + valide filtres + documente contrat rollback
Trois corrections issues du code review multi-agent sur la PR audit-log :

- AuditListener : reset defensif de pendingLogs en debut de onFlush. Si
  un flush precedent a leve une exception avant postFlush (qui n'est
  jamais appele sur un flush rate), le state listener gardait des
  changements jamais committes, ecrits a tort par le prochain postFlush
  reussi — audit_log pouvait donc contenir des lignes decrivant des
  evenements qui n'ont pas eu lieu en DB. Test de regression via
  Reflection pour injecter un log orphelin et verifier qu'il n'arrive
  pas dans audit_log.

- AuditLogProvider : validation explicite des filtres performed_at[after]
  et performed_at[before] (strtotime) + whitelist stricte sur `action`
  (create|update|delete). Avant, un input malforme remontait jusqu'a
  Postgres et faisait un 500 (SQLSTATE[22007]). Desormais 400 explicite,
  pas de log pollue.

- doc/audit-log.md : ajoute une section "Contrat" qui explicite ce que
  audit_log garantit (journal des intentions appliquees par l'ORM) et ne
  garantit PAS (reflet exact du commit outermost — une ligne audit peut
  persister si une transaction outermost rollback apres un flush inner
  reussi, parce que l'audit ecrit sur une connexion DBAL dediee).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:21:49 +02:00

410 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Symfony\Component\Uid\Uuid;
/**
* Tests fonctionnels de l'API `/api/audit-logs`.
*
* Invariants testes :
* - 401 sans authentification ;
* - 403 pour un user authentifie sans permission `core.audit_log.view` ;
* - 200 + JSON-LD pagine pour admin et user avec la permission ;
* - filtres `entity_type`, `action` operants ;
* - ordre `performed_at DESC` ;
* - aucune operation d'ecriture exposee (POST -> 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']);
}
/**
* 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,
]);
}
}
}