feat(core) : expose read-only audit-logs api with dbal provider and pagination
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Application\DTO;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* DTO de sortie pour une ligne d'audit.
|
||||
*
|
||||
* Readonly : aucune mutation possible apres hydration. La resource API
|
||||
* Platform expose directement ce DTO (pas d'entite sous-jacente car la
|
||||
* table audit_log n'est pas geree par l'ORM).
|
||||
*/
|
||||
final readonly class AuditLogOutput
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $entityType,
|
||||
public string $entityId,
|
||||
public string $action,
|
||||
/** @var array<string, mixed> */
|
||||
public array $changes,
|
||||
public string $performedBy,
|
||||
public DateTimeImmutable $performedAt,
|
||||
public ?string $ipAddress,
|
||||
public ?string $requestId,
|
||||
) {}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ final class CoreModule implements ModuleInterface
|
||||
['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'],
|
||||
['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'],
|
||||
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
|
||||
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
|
||||
|
||||
use ApiPlatform\State\Pagination\PaginatorInterface;
|
||||
use ArrayIterator;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
|
||||
/**
|
||||
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
|
||||
*
|
||||
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
|
||||
* automatiquement la section `hydra:view` (first / next / previous / last)
|
||||
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
|
||||
*
|
||||
* @template T of object
|
||||
*
|
||||
* @implements PaginatorInterface<T>
|
||||
*/
|
||||
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
|
||||
{
|
||||
/**
|
||||
* @param list<T> $items Items deja decoupes sur la page courante
|
||||
* @param int $currentPage Page courante (1-indexee)
|
||||
* @param int $itemsPerPage Limite appliquee a la requete SQL
|
||||
* @param int $totalItems Resultat du COUNT(*) sans limite
|
||||
*/
|
||||
public function __construct(
|
||||
private array $items,
|
||||
private int $currentPage,
|
||||
private int $itemsPerPage,
|
||||
private int $totalItems,
|
||||
) {}
|
||||
|
||||
public function getCurrentPage(): float
|
||||
{
|
||||
return (float) $this->currentPage;
|
||||
}
|
||||
|
||||
public function getLastPage(): float
|
||||
{
|
||||
if ($this->itemsPerPage <= 0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
|
||||
}
|
||||
|
||||
public function getItemsPerPage(): float
|
||||
{
|
||||
return (float) $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function getTotalItems(): float
|
||||
{
|
||||
return (float) $this->totalItems;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->items);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Traversable<int, T>
|
||||
*/
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
|
||||
|
||||
/**
|
||||
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
|
||||
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
|
||||
* d'audit). La liste evolue automatiquement avec les nouvelles entites
|
||||
* `#[Auditable]` au fil des ecritures.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'AuditLogEntityTypes',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/audit-log-entity-types',
|
||||
security: "is_granted('core.audit_log.view')",
|
||||
provider: AuditLogEntityTypesProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class AuditLogEntityTypesResource
|
||||
{
|
||||
/** @param list<string> $entityTypes */
|
||||
public function __construct(
|
||||
public readonly string $id = 'entity-types',
|
||||
public readonly array $entityTypes = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Core\Application\DTO\AuditLogOutput;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
||||
|
||||
/**
|
||||
* Resource API Platform en lecture seule sur le journal d'audit.
|
||||
*
|
||||
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
|
||||
* conformement au caractere append-only de la table `audit_log`.
|
||||
*
|
||||
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
|
||||
* provider lit via DBAL et retourne directement des instances du DTO
|
||||
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
|
||||
* l'ORM : aucune entite Doctrine n'est necessaire ici.
|
||||
*
|
||||
* Filtres query-param supportes par le provider :
|
||||
* ?entity_type=core.User
|
||||
* ?entity_id=42
|
||||
* ?action=update
|
||||
* ?performed_by=admin
|
||||
* ?performed_at[after]=2026-04-01T00:00:00Z
|
||||
* ?performed_at[before]=2026-04-30T23:59:59Z
|
||||
*
|
||||
* La pagination herite du standard global (10 items / page, max 50, cf.
|
||||
* `config/packages/api_platform.yaml`). Elle est materialisee par le
|
||||
* DbalPaginator du provider qui implemente PaginatorInterface — API Platform
|
||||
* genere automatiquement hydra:view sans construction manuelle.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'AuditLog',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/audit-logs',
|
||||
security: "is_granted('core.audit_log.view')",
|
||||
provider: AuditLogProvider::class,
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/audit-logs/{id}',
|
||||
requirements: ['id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
|
||||
security: "is_granted('core.audit_log.view')",
|
||||
provider: AuditLogProvider::class,
|
||||
),
|
||||
],
|
||||
output: AuditLogOutput::class,
|
||||
)]
|
||||
final class AuditLogResource {}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
|
||||
*
|
||||
* @implements ProviderInterface<AuditLogEntityTypesResource>
|
||||
*/
|
||||
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
||||
private Connection $connection,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
|
||||
{
|
||||
/** @var list<string> $types */
|
||||
$types = $this->connection
|
||||
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
|
||||
->fetchFirstColumn()
|
||||
;
|
||||
|
||||
return new AuditLogEntityTypesResource(entityTypes: $types);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Core\Application\DTO\AuditLogOutput;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Query\QueryBuilder;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Provider API Platform pour la resource AuditLog.
|
||||
*
|
||||
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
|
||||
* - une instance unique d'AuditLogOutput (operation Get) ;
|
||||
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
|
||||
*
|
||||
* Le paginator implementant PaginatorInterface laisse API Platform generer
|
||||
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
|
||||
*
|
||||
* Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
|
||||
* reservee a l'ecriture hors transaction ORM).
|
||||
*/
|
||||
final readonly class AuditLogProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
||||
private Connection $connection,
|
||||
private Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
|
||||
{
|
||||
if (!$operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideItem((string) $uriVariables['id']);
|
||||
}
|
||||
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
private function provideItem(string $id): ?AuditLogOutput
|
||||
{
|
||||
/** @var array<string, mixed>|false $row */
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
|
||||
FROM audit_log WHERE id = :id',
|
||||
['id' => $id],
|
||||
);
|
||||
|
||||
if (false === $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
||||
{
|
||||
// Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
|
||||
// ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
|
||||
// toujours forcee. `audit_log` est une table append-only a croissance
|
||||
// infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
|
||||
// usage front (pas de <select> alimente par l'audit). Le flag global
|
||||
// `pagination_client_enabled: true` reste donc volontairement inerte ici.
|
||||
//
|
||||
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
|
||||
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
||||
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
||||
// minimum a 1 cote provider.
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$itemsPerPage = $this->pagination->getLimit($operation, $context);
|
||||
$offset = ($page - 1) * $itemsPerPage;
|
||||
$filters = $this->extractFilters($context['filters'] ?? []);
|
||||
|
||||
$dataQuery = $this->buildBaseQuery()
|
||||
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
|
||||
->orderBy('performed_at', 'DESC')
|
||||
// Tie-breaker sur `id` (UUID v7 monotone) : garantit un tri
|
||||
// totalement deterministe quand plusieurs lignes partagent la
|
||||
// meme timestamp (ex: batch fixture, bulk flush < 1µs).
|
||||
->addOrderBy('id', 'DESC')
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($itemsPerPage)
|
||||
;
|
||||
|
||||
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
|
||||
|
||||
$this->applyFilters($dataQuery, $filters);
|
||||
$this->applyFilters($countQuery, $filters);
|
||||
|
||||
/** @var list<array<string, mixed>> $rows */
|
||||
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
|
||||
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
|
||||
|
||||
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
|
||||
|
||||
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
|
||||
}
|
||||
|
||||
private function buildBaseQuery(): QueryBuilder
|
||||
{
|
||||
return $this->connection->createQueryBuilder()->from('audit_log');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
*
|
||||
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
|
||||
*/
|
||||
private function extractFilters(array $raw): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
// `entity_type` accepte soit une chaine, soit une liste (query syntax
|
||||
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
|
||||
// multi-selection cote front. On normalise en list<string> non-vide.
|
||||
if (isset($raw['entity_type'])) {
|
||||
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
|
||||
$filters['entity_type'] = $raw['entity_type'];
|
||||
} elseif (is_array($raw['entity_type'])) {
|
||||
$cleaned = array_values(array_filter(
|
||||
$raw['entity_type'],
|
||||
static fn ($v): bool => is_string($v) && '' !== $v,
|
||||
));
|
||||
if ([] !== $cleaned) {
|
||||
$filters['entity_type'] = $cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['entity_id', 'performed_by'] as $key) {
|
||||
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
||||
$filters[$key] = $raw[$key];
|
||||
}
|
||||
}
|
||||
|
||||
// `action` : whitelist stricte. Un input hors-liste provoquait avant
|
||||
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
|
||||
// le log applicatif a chaque variation ; on rejette en 400 explicite.
|
||||
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
|
||||
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
|
||||
throw new BadRequestHttpException(
|
||||
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
|
||||
);
|
||||
}
|
||||
$filters['action'] = $raw['action'];
|
||||
}
|
||||
|
||||
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
|
||||
// Sans validation, un input malforme remonte jusqu'a Postgres qui
|
||||
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
|
||||
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
|
||||
// On valide en amont et on rejette en 400 explicite.
|
||||
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
|
||||
$range = $raw['performed_at'];
|
||||
foreach (['after', 'before'] as $bound) {
|
||||
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
|
||||
continue;
|
||||
}
|
||||
if (false === strtotime($range[$bound])) {
|
||||
throw new BadRequestHttpException(sprintf(
|
||||
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
|
||||
$bound,
|
||||
));
|
||||
}
|
||||
$filters['performed_at_'.$bound] = $range[$bound];
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>|string> $filters
|
||||
*/
|
||||
private function applyFilters(QueryBuilder $qb, array $filters): void
|
||||
{
|
||||
if (isset($filters['entity_type'])) {
|
||||
if (is_array($filters['entity_type'])) {
|
||||
$qb->andWhere('entity_type IN (:entity_types)')
|
||||
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
|
||||
;
|
||||
} else {
|
||||
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
|
||||
}
|
||||
}
|
||||
if (isset($filters['entity_id'])) {
|
||||
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
|
||||
}
|
||||
if (isset($filters['action'])) {
|
||||
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
|
||||
}
|
||||
if (isset($filters['performed_by'])) {
|
||||
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
|
||||
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
|
||||
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
|
||||
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
|
||||
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
|
||||
$qb->andWhere('performed_by ILIKE :performed_by')
|
||||
->setParameter('performed_by', '%'.$escaped.'%')
|
||||
;
|
||||
}
|
||||
if (isset($filters['performed_at_after'])) {
|
||||
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
|
||||
}
|
||||
if (isset($filters['performed_at_before'])) {
|
||||
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): AuditLogOutput
|
||||
{
|
||||
/** @var string $rawChanges */
|
||||
$rawChanges = $row['changes'] ?? '{}';
|
||||
|
||||
/** @var array<string, mixed> $changes */
|
||||
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return new AuditLogOutput(
|
||||
id: (string) $row['id'],
|
||||
entityType: (string) $row['entity_type'],
|
||||
entityId: (string) $row['entity_id'],
|
||||
action: (string) $row['action'],
|
||||
changes: $changes,
|
||||
performedBy: (string) $row['performed_by'],
|
||||
performedAt: new DateTimeImmutable((string) $row['performed_at']),
|
||||
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
|
||||
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Core;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLogApiTest extends WebTestCase
|
||||
{
|
||||
public function testGetCollectionRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$this->seedAuditLog();
|
||||
|
||||
$client->request('GET', '/api/audit-logs');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testUserWithoutPermissionIsForbidden(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$this->seedAuditLog();
|
||||
$this->loginUser($client, 'alice');
|
||||
|
||||
$client->request('GET', '/api/audit-logs');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAdminCanListAuditLogs(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$this->seedAuditLog();
|
||||
$this->loginUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/api/audit-logs');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertArrayHasKey('member', $data);
|
||||
self::assertArrayHasKey('totalItems', $data);
|
||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
||||
}
|
||||
|
||||
public function testFilterByActionReturnsOnlyMatchingEntries(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$this->seedAuditLog();
|
||||
$this->loginUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/api/audit-logs?action=update');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertNotEmpty($data['member']);
|
||||
foreach ($data['member'] as $entry) {
|
||||
self::assertSame('update', $entry['action']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testFilterByEntityType(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$this->seedAuditLog();
|
||||
$this->loginUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/api/audit-logs?entity_type=core.User');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertNotEmpty($data['member']);
|
||||
foreach ($data['member'] as $entry) {
|
||||
self::assertSame('core.User', $entry['entityType']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testInvalidActionFilterIsRejected(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$this->seedAuditLog();
|
||||
$this->loginUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/api/audit-logs?action=bogus');
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert deterministic audit rows directly through the audit connection.
|
||||
*
|
||||
* The table audit_log has no ORM entity (written via raw DBAL), so we
|
||||
* clean and seed it via the dedicated connection. These tests are not
|
||||
* wrapped in a rolled-back transaction, hence the upfront DELETE.
|
||||
*/
|
||||
private function seedAuditLog(): void
|
||||
{
|
||||
/** @var Connection $audit */
|
||||
$audit = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$audit->executeStatement('DELETE FROM audit_log');
|
||||
|
||||
$rows = [
|
||||
['core.User', 'create', '{"username":"seed-create"}'],
|
||||
['core.User', 'update', '{"firstName":{"old":"a","new":"b"}}'],
|
||||
['core.Role', 'delete', '{}'],
|
||||
];
|
||||
|
||||
$performedAt = '2026-04-22 10:00:00';
|
||||
foreach ($rows as $i => [$entityType, $action, $changes]) {
|
||||
$audit->insert('audit_log', [
|
||||
'id' => Uuid::v7()->toRfc4122(),
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => (string) ($i + 1),
|
||||
'action' => $action,
|
||||
'changes' => $changes,
|
||||
'performed_by' => 'admin',
|
||||
'performed_at' => $performedAt,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'request_id' => 'req-'.$i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function loginUser(KernelBrowser $client, string $username): void
|
||||
{
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
$client->loginUser($user);
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,12 @@ final class CoreModuleTest extends TestCase
|
||||
self::assertArrayHasKey('label', $permission);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPermissionsExposeAuditLogView(): void
|
||||
{
|
||||
$codes = array_column(CoreModule::permissions(), 'code');
|
||||
|
||||
self::assertCount(6, $codes);
|
||||
self::assertContains('core.audit_log.view', $codes);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user