diff --git a/src/ApiResource/AuditLogResource.php b/src/ApiResource/AuditLogResource.php index 684809f..b76d1ba 100644 --- a/src/ApiResource/AuditLogResource.php +++ b/src/ApiResource/AuditLogResource.php @@ -19,6 +19,12 @@ use App\State\AuditLogProvider; new QueryParameter(key: 'from'), new QueryParameter(key: 'to'), new QueryParameter(key: 'entityType'), + new QueryParameter(key: 'action'), + new QueryParameter(key: 'username'), + new QueryParameter(key: 'ip'), + new QueryParameter(key: 'device'), + new QueryParameter(key: 'page'), + new QueryParameter(key: 'perPage'), ], security: "is_granted('ROLE_SUPER_ADMIN')" ), diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index 50215a2..7afde5a 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -8,6 +8,7 @@ use App\Entity\AuditLog; use App\Repository\Contract\AuditLogReadRepositoryInterface; use DateTimeImmutable; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -20,14 +21,15 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL parent::__construct($registry, AuditLog::class); } - /** - * @return list - */ public function findByFilters( ?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, - ?string $entityType = null, + ?array $entityTypes = null, + ?array $actions = null, + ?string $username = null, + ?string $ip = null, + ?string $device = null, int $limit = 50, int $offset = 0, ): array { @@ -36,30 +38,7 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL ->setMaxResults($limit) ->setFirstResult($offset) ; - - if (null !== $employeeId) { - $qb->andWhere('a.employee = :employeeId') - ->setParameter('employeeId', $employeeId) - ; - } - - if (null !== $from) { - $qb->andWhere('a.affectedDate >= :from') - ->setParameter('from', $from) - ; - } - - if (null !== $to) { - $qb->andWhere('a.affectedDate <= :to') - ->setParameter('to', $to) - ; - } - - if (null !== $entityType) { - $qb->andWhere('a.entityType = :entityType') - ->setParameter('entityType', $entityType) - ; - } + $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device); return $qb->getQuery()->getResult(); } @@ -68,36 +47,58 @@ final class AuditLogRepository extends ServiceEntityRepository implements AuditL ?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, - ?string $entityType = null, + ?array $entityTypes = null, + ?array $actions = null, + ?string $username = null, + ?string $ip = null, + ?string $device = null, ): int { - $qb = $this->createQueryBuilder('a') - ->select('COUNT(a.id)') - ; - - if (null !== $employeeId) { - $qb->andWhere('a.employee = :employeeId') - ->setParameter('employeeId', $employeeId) - ; - } - - if (null !== $from) { - $qb->andWhere('a.affectedDate >= :from') - ->setParameter('from', $from) - ; - } - - if (null !== $to) { - $qb->andWhere('a.affectedDate <= :to') - ->setParameter('to', $to) - ; - } - - if (null !== $entityType) { - $qb->andWhere('a.entityType = :entityType') - ->setParameter('entityType', $entityType) - ; - } + $qb = $this->createQueryBuilder('a')->select('COUNT(a.id)'); + $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device); return (int) $qb->getQuery()->getSingleScalarResult(); } + + /** + * @param null|list $entityTypes + * @param null|list $actions + */ + private function applyFilters( + QueryBuilder $qb, + ?int $employeeId, + ?DateTimeImmutable $from, + ?DateTimeImmutable $to, + ?array $entityTypes, + ?array $actions, + ?string $username, + ?string $ip, + ?string $device, + ): void { + if (null !== $employeeId) { + $qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId); + } + if (null !== $from) { + $qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from); + } + if (null !== $to) { + $qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to); + } + if (null !== $entityTypes && [] !== $entityTypes) { + $qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes); + } + if (null !== $actions && [] !== $actions) { + $qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions); + } + if (null !== $username && '' !== $username) { + $qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%'); + } + if (null !== $ip && '' !== $ip) { + $qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%'); + } + if (null !== $device && '' !== $device) { + $qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)') + ->setParameter('device', '%'.mb_strtolower($device).'%') + ; + } + } } diff --git a/src/Repository/Contract/AuditLogReadRepositoryInterface.php b/src/Repository/Contract/AuditLogReadRepositoryInterface.php index 82ece79..0b8eecb 100644 --- a/src/Repository/Contract/AuditLogReadRepositoryInterface.php +++ b/src/Repository/Contract/AuditLogReadRepositoryInterface.php @@ -10,21 +10,36 @@ use DateTimeImmutable; interface AuditLogReadRepositoryInterface { /** + * @param null|list $entityTypes + * @param null|list $actions + * * @return list */ public function findByFilters( ?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, - ?string $entityType = null, + ?array $entityTypes = null, + ?array $actions = null, + ?string $username = null, + ?string $ip = null, + ?string $device = null, int $limit = 50, int $offset = 0, ): array; + /** + * @param null|list $entityTypes + * @param null|list $actions + */ public function countByFilters( ?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, - ?string $entityType = null, + ?array $entityTypes = null, + ?array $actions = null, + ?string $username = null, + ?string $ip = null, + ?string $device = null, ): int; } diff --git a/src/State/AuditLogProvider.php b/src/State/AuditLogProvider.php index 9736030..2cd9b80 100644 --- a/src/State/AuditLogProvider.php +++ b/src/State/AuditLogProvider.php @@ -14,7 +14,8 @@ use Symfony\Component\HttpFoundation\RequestStack; class AuditLogProvider implements ProviderInterface { - private const PER_PAGE = 50; + private const DEFAULT_PER_PAGE = 50; + private const ALLOWED_PER_PAGE = [25, 50, 100]; public function __construct( private readonly RequestStack $requestStack, @@ -28,20 +29,32 @@ class AuditLogProvider implements ProviderInterface return new JsonResponse(['items' => [], 'total' => 0]); } - $employeeId = $request->query->get('employeeId'); - $from = $request->query->get('from'); - $to = $request->query->get('to'); - $entityType = $request->query->get('entityType'); - $page = max(1, (int) $request->query->get('page', '1')); + $query = $request->query; + $all = $query->all(); + + $employeeId = $query->get('employeeId'); + $from = $query->get('from'); + $to = $query->get('to'); + $page = max(1, (int) $query->get('page', '1')); + + $perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE); + if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) { + $perPage = self::DEFAULT_PER_PAGE; + } + + $entityTypes = $this->normalizeList($all['entityType'] ?? null); + $actions = $this->normalizeList($all['action'] ?? null); + $username = $this->normalizeString($query->get('username')); + $ip = $this->normalizeString($query->get('ip')); + $device = $this->normalizeString($query->get('device')); $empId = $employeeId ? (int) $employeeId : null; - $fromDt = $from ? new DateTimeImmutable($from) : null; - $toDt = $to ? new DateTimeImmutable($to) : null; - $type = $entityType ?: null; - $offset = ($page - 1) * self::PER_PAGE; + $fromDt = $from ? new DateTimeImmutable((string) $from) : null; + $toDt = $to ? new DateTimeImmutable((string) $to) : null; + $offset = ($page - 1) * $perPage; - $total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type); - $logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset); + $total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device); + $logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $perPage, $offset); $items = []; foreach ($logs as $log) { @@ -72,7 +85,27 @@ class AuditLogProvider implements ProviderInterface 'items' => $items, 'total' => $total, 'page' => $page, - 'perPage' => self::PER_PAGE, + 'perPage' => $perPage, ]); } + + /** + * @return null|list + */ + private function normalizeList(mixed $value): ?array + { + $list = array_values(array_filter( + (array) ($value ?? []), + static fn ($v): bool => is_string($v) && '' !== trim($v), + )); + + return [] === $list ? null : $list; + } + + private function normalizeString(mixed $value): ?string + { + $trimmed = trim((string) ($value ?? '')); + + return '' === $trimmed ? null : $trimmed; + } } diff --git a/tests/State/AuditLogProviderTest.php b/tests/State/AuditLogProviderTest.php index d699f01..54c7ea8 100644 --- a/tests/State/AuditLogProviderTest.php +++ b/tests/State/AuditLogProviderTest.php @@ -8,7 +8,9 @@ use ApiPlatform\Metadata\Operation; use App\Entity\AuditLog; use App\Repository\Contract\AuditLogReadRepositoryInterface; use App\State\AuditLogProvider; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -17,7 +19,7 @@ use Symfony\Component\HttpFoundation\RequestStack; */ final class AuditLogProviderTest extends TestCase { - public function testProvideExposesForensicFields(): void + public function testExposesForensicFields(): void { $log = new AuditLog() ->setUsername('usine') @@ -30,22 +32,90 @@ final class AuditLogProviderTest extends TestCase ->setDeviceId('device-abc') ; - $repo = $this->createStub(AuditLogReadRepositoryInterface::class); - $repo->method('countByFilters')->willReturn(1); - $repo->method('findByFilters')->willReturn([$log]); - - $stack = new RequestStack(); - $stack->push(Request::create('/api/audit-logs', 'GET')); - - $provider = new AuditLogProvider($stack, $repo); - $response = $provider->provide($this->createStub(Operation::class)); - - $data = json_decode((string) $response->getContent(), true); - $item = $data['items'][0]; + $response = $this->provideWith($this->spyRepository([$log], 1), []); + $item = json_decode((string) $response->getContent(), true)['items'][0]; self::assertSame('203.0.113.7', $item['ipAddress']); self::assertSame('UA-string', $item['userAgent']); self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']); self::assertSame('device-abc', $item['deviceId']); } + + public function testPassesNewFiltersToRepository(): void + { + $repo = $this->spyRepository(); + $this->provideWith($repo, [ + 'employeeId' => '5', + 'username' => 'usine', + 'ip' => '10.0.', + 'device' => 'android', + 'entityType' => ['work_hour', 'absence'], + 'action' => ['create'], + 'perPage' => '25', + 'page' => '2', + ]); + + self::assertSame(5, $repo->findArgs['employeeId']); + self::assertSame('usine', $repo->findArgs['username']); + self::assertSame('10.0.', $repo->findArgs['ip']); + self::assertSame('android', $repo->findArgs['device']); + self::assertSame(['work_hour', 'absence'], $repo->findArgs['entityTypes']); + self::assertSame(['create'], $repo->findArgs['actions']); + self::assertSame(25, $repo->findArgs['limit']); + self::assertSame(25, $repo->findArgs['offset']); // page 2, perPage 25 -> offset 25 + } + + public function testBlankFiltersBecomeNull(): void + { + $repo = $this->spyRepository(); + $this->provideWith($repo, ['username' => ' ', 'ip' => '', 'device' => '']); + + self::assertNull($repo->findArgs['username']); + self::assertNull($repo->findArgs['ip']); + self::assertNull($repo->findArgs['device']); + self::assertNull($repo->findArgs['entityTypes']); + self::assertNull($repo->findArgs['actions']); + } + + public function testPerPageOutOfRangeFallsBackTo50(): void + { + $repo = $this->spyRepository(); + $response = $this->provideWith($repo, ['perPage' => '999']); + + self::assertSame(50, $repo->findArgs['limit']); + self::assertSame(50, json_decode((string) $response->getContent(), true)['perPage']); + } + + private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface + { + return new class($items, $count) implements AuditLogReadRepositoryInterface { + public array $findArgs = []; + public array $countArgs = []; + + public function __construct(private array $items, private int $count) {} + + public function findByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, int $limit = 50, int $offset = 0): array + { + $this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'limit', 'offset'); + + return $this->items; + } + + public function countByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null): int + { + $this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device'); + + return $this->count; + } + }; + } + + private function provideWith(AuditLogReadRepositoryInterface $repo, array $query): JsonResponse + { + $stack = new RequestStack(); + $stack->push(Request::create('/api/audit-logs', 'GET', $query)); + $provider = new AuditLogProvider($stack, $repo); + + return $provider->provide($this->createStub(Operation::class)); + } }