feat : add audit log (table, writer, listener, API, admin UI, timeline)
Implemente le journal d'audit append-only sur toutes les mutations Doctrine des entites portant #[Auditable]. Couvre les 5 tickets de doc/audit-log.md : 1. Table PG audit_log (uuid PK, jsonb changes, index entity/time/performer) + AuditLogWriter (DBAL connexion dediee audit, blacklist defense-in-depth sur password/plainPassword/token/secret) + RequestIdProvider (UUID v4 par requete HTTP principale). 2. Attributs Auditable / AuditIgnore dans Shared/Domain/Attribute/ + AuditListener (onFlush capture + postFlush ecriture hors transaction ORM, pattern swap-and-clear, erreurs loguees jamais propagees). User annote. 3. API Platform read-only /api/audit-logs (permission core.audit_log.view) avec filtres entity_type / entity_id / action / performed_by / plage performed_at + DbalPaginator implementant PaginatorInterface (hydra:view genere automatiquement). 4. Page admin /admin/audit-log : tableau pagine, filtres persistes en query params, row expandable (diff + timeline de l'entite), entree sidebar avec permission. Composable useAuditLog avec resetAuditLog() auto-enregistre sur onAuthSessionCleared. 5. Composant AuditTimeline reutilisable : garde permission, lazy loading, dates relatives FR, skeleton loader. Fix connexe : phpunit.dist.xml forcait APP_ENV=dev via <env> ce qui cablait framework.test=false et rendait test.service_container indisponible ; le JWT_PASSPHRASE ne matchait pas non plus les cles dev. Corrige en meme temps pour debloquer la suite de tests.
This commit is contained in:
210
tests/Module/Core/Api/AuditLogApiTest.php
Normal file
210
tests/Module/Core/Api/AuditLogApiTest.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?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
|
||||
{
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
163
tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php
Normal file
163
tests/Module/Core/Infrastructure/Audit/AuditLogWriterTest.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
|
||||
use App\Module\Core\Infrastructure\Audit\RequestIdProvider;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||
|
||||
/**
|
||||
* Tests unitaires de l'AuditLogWriter.
|
||||
*
|
||||
* Verifie les invariants critiques :
|
||||
* - filtrage des cles sensibles (defense-in-depth par rapport a #[AuditIgnore]) ;
|
||||
* - utilisation du username courant ou "system" en CLI ;
|
||||
* - captation IP + request_id si requete HTTP presente ;
|
||||
* - generation d'un UUID v7 (tri chronologique implicite en PK).
|
||||
*
|
||||
* Aucune BDD : la connexion DBAL est mockee pour capturer l'insert.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
final class AuditLogWriterTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var null|array{0: string, 1: array<string, mixed>, 2: array<string, mixed>}
|
||||
*
|
||||
* Capture de l'appel `insert()` : [$table, $data, $types]
|
||||
*/
|
||||
private ?array $capturedInsert = null;
|
||||
|
||||
private Connection $connection;
|
||||
|
||||
private RequestStack $requestStack;
|
||||
|
||||
private RequestIdProvider $requestIdProvider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->capturedInsert = null;
|
||||
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->connection
|
||||
->method('insert')
|
||||
->willReturnCallback(function (string $table, array $data, array $types = []): int {
|
||||
$this->capturedInsert = [$table, $data, $types];
|
||||
|
||||
return 1;
|
||||
})
|
||||
;
|
||||
|
||||
$this->requestStack = new RequestStack();
|
||||
$this->requestIdProvider = new RequestIdProvider();
|
||||
}
|
||||
|
||||
public function testLogsCreateWithAuthenticatedUser(): void
|
||||
{
|
||||
$security = $this->buildSecurityWithUser('alice');
|
||||
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||
|
||||
$writer->log('core.User', '42', 'create', ['username' => 'alice']);
|
||||
|
||||
$this->assertNotNull($this->capturedInsert);
|
||||
[$table, $data] = $this->capturedInsert;
|
||||
$this->assertSame('audit_log', $table);
|
||||
$this->assertSame('core.User', $data['entity_type']);
|
||||
$this->assertSame('42', $data['entity_id']);
|
||||
$this->assertSame('create', $data['action']);
|
||||
$this->assertSame(['username' => 'alice'], $data['changes']);
|
||||
$this->assertSame('alice', $data['performed_by']);
|
||||
}
|
||||
|
||||
public function testUsesSystemWhenNoAuthenticatedUser(): void
|
||||
{
|
||||
$security = $this->buildSecurityWithUser(null);
|
||||
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||
|
||||
$writer->log('core.User', '1', 'update', ['isAdmin' => ['old' => false, 'new' => true]]);
|
||||
|
||||
$this->assertSame('system', $this->capturedInsert[1]['performed_by']);
|
||||
}
|
||||
|
||||
public function testStripsSensitiveKeys(): void
|
||||
{
|
||||
$security = $this->buildSecurityWithUser('alice');
|
||||
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||
|
||||
$writer->log('core.User', '1', 'create', [
|
||||
'username' => 'bob',
|
||||
'password' => 'topsecrethash',
|
||||
'plainPassword' => 'clear',
|
||||
'token' => 'abc',
|
||||
'secret' => 'xyz',
|
||||
'email' => 'bob@example.com',
|
||||
]);
|
||||
|
||||
$changes = $this->capturedInsert[1]['changes'];
|
||||
$this->assertArrayNotHasKey('password', $changes);
|
||||
$this->assertArrayNotHasKey('plainPassword', $changes);
|
||||
$this->assertArrayNotHasKey('token', $changes);
|
||||
$this->assertArrayNotHasKey('secret', $changes);
|
||||
$this->assertSame('bob', $changes['username']);
|
||||
$this->assertSame('bob@example.com', $changes['email']);
|
||||
}
|
||||
|
||||
public function testCapturesIpAddressWhenRequestPresent(): void
|
||||
{
|
||||
$request = Request::create('/api/users', 'POST');
|
||||
$request->server->set('REMOTE_ADDR', '203.0.113.42');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$security = $this->buildSecurityWithUser('alice');
|
||||
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||
|
||||
$writer->log('core.User', '1', 'create', []);
|
||||
|
||||
$this->assertSame('203.0.113.42', $this->capturedInsert[1]['ip_address']);
|
||||
}
|
||||
|
||||
public function testIpAddressNullInCli(): void
|
||||
{
|
||||
$security = $this->buildSecurityWithUser(null);
|
||||
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||
|
||||
$writer->log('core.User', '1', 'create', []);
|
||||
|
||||
$this->assertNull($this->capturedInsert[1]['ip_address']);
|
||||
$this->assertNull($this->capturedInsert[1]['request_id']);
|
||||
}
|
||||
|
||||
public function testGeneratesUuidV7PrimaryKey(): void
|
||||
{
|
||||
$security = $this->buildSecurityWithUser('alice');
|
||||
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
|
||||
|
||||
$writer->log('core.User', '1', 'create', []);
|
||||
|
||||
$id = $this->capturedInsert[1]['id'];
|
||||
// UUID v7 : le 13e caractere (apres les tirets) vaut "7".
|
||||
// Format : xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
|
||||
$id
|
||||
);
|
||||
}
|
||||
|
||||
private function buildSecurityWithUser(?string $username): Security
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$user = null !== $username ? new InMemoryUser($username, 'pwd') : null;
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
return $security;
|
||||
}
|
||||
}
|
||||
176
tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php
Normal file
176
tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Tests d'integration de l'AuditListener.
|
||||
*
|
||||
* Contrairement aux tests unitaires du writer, on fait tourner le kernel
|
||||
* complet pour verifier que le listener est bien cable et que les attributs
|
||||
* #[Auditable] / #[AuditIgnore] sur User sont respectes jusqu'a l'insert
|
||||
* final dans audit_log.
|
||||
*
|
||||
* Strategie de nettoyage : chaque test supprime ses fixtures dans tearDown
|
||||
* (pas de rollback transactionnel DAMA sur ce projet).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AuditListenerTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
private Connection $auditConnection;
|
||||
|
||||
/** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
|
||||
private array $createdUserIds = [];
|
||||
|
||||
private string $testRunTag;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var EntityManagerInterface $em */
|
||||
$em = self::getContainer()->get('doctrine')->getManager();
|
||||
$this->em = $em;
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
|
||||
// Tag unique par run pour filtrer les lignes audit_log produites
|
||||
// exclusivement par ce test (la table n'a ni truncate ni rollback).
|
||||
$this->testRunTag = 'audit_test_'.bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Suppression explicite des users crees (cascade sur user_role /
|
||||
// user_site via les ORM mappings) + nettoyage des lignes audit
|
||||
// correspondantes pour ne pas polluer les runs suivants.
|
||||
if ([] !== $this->createdUserIds) {
|
||||
foreach ($this->createdUserIds as $id) {
|
||||
$user = $this->em->find(User::class, $id);
|
||||
if (null !== $user) {
|
||||
$this->em->remove($user);
|
||||
}
|
||||
}
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$this->auditConnection->executeStatement(
|
||||
"DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
|
||||
['tag' => $this->testRunTag.'%'],
|
||||
);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testLogsCreateOnUserInsertion(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$this->createdUserIds[] = $user->getId();
|
||||
|
||||
$rows = $this->fetchAuditRows($user->getId());
|
||||
|
||||
$this->assertCount(1, $rows, 'Une ligne audit attendue a la creation');
|
||||
$row = $rows[0];
|
||||
$this->assertSame('core.User', $row['entity_type']);
|
||||
$this->assertSame('create', $row['action']);
|
||||
$this->assertSame((string) $user->getId(), $row['entity_id']);
|
||||
|
||||
$changes = json_decode($row['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertArrayHasKey('username', $changes);
|
||||
$this->assertArrayNotHasKey('password', $changes, 'password doit etre #[AuditIgnore]');
|
||||
$this->assertArrayNotHasKey('plainPassword', $changes, 'plainPassword doit etre #[AuditIgnore]');
|
||||
}
|
||||
|
||||
public function testLogsUpdateWithDiff(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$this->createdUserIds[] = $user->getId();
|
||||
|
||||
// Reset de la baseline : on ne garde que la ligne update.
|
||||
$this->auditConnection->executeStatement(
|
||||
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
|
||||
['id' => (string) $user->getId()],
|
||||
);
|
||||
|
||||
$user->setIsAdmin(true);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchAuditRows($user->getId());
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('update', $rows[0]['action']);
|
||||
|
||||
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertArrayHasKey('isAdmin', $changes);
|
||||
$this->assertSame(false, $changes['isAdmin']['old']);
|
||||
$this->assertSame(true, $changes['isAdmin']['new']);
|
||||
}
|
||||
|
||||
public function testLogsDeleteSnapshot(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$userId = $user->getId();
|
||||
|
||||
$this->em->remove($user);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchAuditRows($userId);
|
||||
// Deux lignes : la creation + la suppression.
|
||||
$actions = array_column($rows, 'action');
|
||||
$this->assertContains('delete', $actions);
|
||||
|
||||
$deleteRow = $rows[array_search('delete', $actions, true)];
|
||||
$changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertArrayHasKey('username', $changes);
|
||||
$this->assertArrayNotHasKey('password', $changes);
|
||||
|
||||
// On nettoie a la main les lignes restantes (user deja delete).
|
||||
$this->auditConnection->executeStatement(
|
||||
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
|
||||
['id' => (string) $userId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
|
||||
*/
|
||||
private function fetchAuditRows(int $userId): array
|
||||
{
|
||||
/** @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows */
|
||||
return $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
|
||||
['type' => 'core.User', 'id' => (string) $userId],
|
||||
);
|
||||
}
|
||||
|
||||
private function makeUser(): User
|
||||
{
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($this->testRunTag.'_'.bin2hex(random_bytes(2)));
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, 'testpass'));
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user