diff --git a/.github/assets/easyadmin_integration.png b/.github/assets/easyadmin_integration.png index 1ed3cc7..38b4a13 100644 Binary files a/.github/assets/easyadmin_integration.png and b/.github/assets/easyadmin_integration.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc4cac..fe86fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [2.2.0] + +### 2.2.0 Installation Note + +**Important for UI updates**: Please run the following command to publish the newly added CSS file for the EasyAdmin integration: + +```bash +php bin/console assets:install +``` + +### 2.2.0 New Features + +- **JSON & CSV Export**: Added ability to export filtered audit logs to JSON or CSV directly from the index page via a dropdown action menu. Uses memory-efficient `toIterable()` streaming for large datasets through the new `findAllWithFilters()` repository method. +- **Transaction Drill-down Pagination**: The transaction drill-down view now supports cursor-based (keyset) pagination using `afterId`/`beforeId` for deterministic, offset-free navigation through large transaction groups. +- **Integrity Signature Badge**: Visual integrity verification on the Changes tab — displays "Verified Authentic", "Tampered / Invalid", or "Integrity Disabled" badges with corresponding icons and color coding to instantly alert admins to tampered logs. +- **"Reverted" UI State Protection**: Added an `isReverted()` repository check that powers a new "REVERTED" badge on the detail page. The revert button is now dynamically disabled with an "Already Reverted" state to prevent duplicate reverts of the same log. +- **Conditional EasyAdmin Registration**: The `AuditLogCrudController` is now conditionally registered as a service only when `EasyAdminBundle` is actively installed, checked via `kernel.bundles` at compile time in `AuditTrailExtension`. + +### 2.2.0 Improvements + +- **Optimized `isReverted()` Query**: Replaced N+1 entity hydration with a single `COUNT` + `LIKE` query against the JSON `context` column, eliminating full result set loading for the "Reverted" UI check. +- **Keyset Pagination UUID Typing**: `afterId`/`beforeId` parameters now explicitly use the `'uuid'` Doctrine type constraint for proper cross-database UUID comparison in cursor-based pagination. +- **Soft Delete Detection Fix**: `ChangeProcessor::determineUpdateAction()` now correctly detects soft-deletes when `oldValue` is `null` and `newValue` is not (previously only detected restores). +- **Revert Subscriber Silencing**: Wrapped the entire revert operation in a `try/finally` block to ensure the `ScheduledAuditManagerInterface` subscriber is reliably re-enabled even if the revert fails mid-transaction. +- **Revert Dry-Run Associations**: `RevertValueDenormalizer` now uses `EntityManager::getReference()` instead of `find()` during dry-runs to avoid unnecessary database query overhead for associated entities. +- **Revert Access Action**: `AuditReverter::determineChanges()` now gracefully handles `ACTION_ACCESS` (read-tracking) logs by returning empty changes instead of throwing an exception. + +--- + ## [2.1.0] ### 2.1.0 Breaking Changes diff --git a/SECURITY.md b/SECURITY.md index c37f60f..9497cb3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ The following versions of AuditTrailBundle are currently being supported with se | Version | Supported | | ------- | ------------------ | -| 1.9 | :white_check_mark: | -| < 1.0 | :x: | +| 2.0 | :white_check_mark: | +| < 2.0 | :x: | ## Reporting a Vulnerability diff --git a/docs/integrations.md b/docs/integrations.md index 2ff3f94..54bbafb 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -11,4 +11,12 @@ yield MenuItem::linkToCrud('Audit Logs', 'fas fa-history', AuditLog::class) ->setController(AuditLogCrudController::class); ``` +### Publishing Assets + +To ensure the custom styling for the Audit Log UI (diffs, action badges, and revert modals) loads correctly, you must install the bundle's public assets: + +```bash +php bin/console assets:install +``` + ![EasyAdmin Integration Showcase](../.github/assets/easyadmin_integration.png) diff --git a/src/Contract/AuditExporterInterface.php b/src/Contract/AuditExporterInterface.php index f47866b..96b5907 100644 --- a/src/Contract/AuditExporterInterface.php +++ b/src/Contract/AuditExporterInterface.php @@ -9,9 +9,9 @@ interface AuditExporterInterface { /** - * @param array $audits + * @param iterable $audits */ - public function formatAudits(array $audits, string $format): string; + public function formatAudits(iterable $audits, string $format): string; public function formatFileSize(int $bytes): string; } diff --git a/src/Contract/AuditLogRepositoryInterface.php b/src/Contract/AuditLogRepositoryInterface.php index c7aca2c..9df58c8 100644 --- a/src/Contract/AuditLogRepositoryInterface.php +++ b/src/Contract/AuditLogRepositoryInterface.php @@ -33,10 +33,26 @@ public function deleteOldLogs(DateTimeImmutable $before): int; */ public function findWithFilters(array $filters = [], int $limit = 30): array; + /** + * @param array $filters + * + * @return iterable + */ + public function findAllWithFilters(array $filters = []): iterable; + /** * @return array */ public function findOlderThan(DateTimeImmutable $before): array; public function countOlderThan(DateTimeImmutable $before): int; + + /** + * @param array $criteria + */ + public function count(array $criteria = []): int; + + public function find(mixed $id): ?object; + + public function isReverted(AuditLog $log): bool; } diff --git a/src/Contract/AuditReverterInterface.php b/src/Contract/AuditReverterInterface.php index 2002e75..7e8b2f8 100644 --- a/src/Contract/AuditReverterInterface.php +++ b/src/Contract/AuditReverterInterface.php @@ -30,5 +30,6 @@ public function revert( bool $force = false, array $context = [], bool $silenceSubscriber = true, + bool $verifySignature = true, ): array; } diff --git a/src/Controller/Admin/AuditLogCrudController.php b/src/Controller/Admin/AuditLogCrudController.php index a7f5c22..cc571e0 100644 --- a/src/Controller/Admin/AuditLogCrudController.php +++ b/src/Controller/Admin/AuditLogCrudController.php @@ -4,11 +4,14 @@ namespace Rcsofttech\AuditTrailBundle\Controller\Admin; +use DateTimeImmutable; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; +use EasyCorp\Bundle\EasyAdminBundle\Config\Assets; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; -use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; +use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore; +use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\CodeEditorField; @@ -18,19 +21,31 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Filter\DateTimeFilter; use EasyCorp\Bundle\EasyAdminBundle\Filter\TextFilter; +use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; +use Override; +use Rcsofttech\AuditTrailBundle\Contract\AuditExporterInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditReverterInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; +use Rcsofttech\AuditTrailBundle\Service\RevertPreviewFormatter; +use Rcsofttech\AuditTrailBundle\Service\TransactionDrilldownService; use Rcsofttech\AuditTrailBundle\Util\ClassNameHelperTrait; use Stringable; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use Throwable; -use function is_array; use function is_scalar; - -use const JSON_PRETTY_PRINT; -use const JSON_UNESCAPED_SLASHES; +use function sprintf; /** - * Responsible for providing a read-only view of audit logs in EasyAdmin. + * Provides a rich, read-only view of audit logs in EasyAdmin with: + * - Visual diff view for changes + * - One-click revert with dry-run preview + * - Transaction drill-down + * - Structured context view * * @codeCoverageIgnore * @@ -40,11 +55,26 @@ class AuditLogCrudController extends AbstractCrudController { use ClassNameHelperTrait; + private const int DRILLDOWN_LIMIT = 15; + + public function __construct( + private readonly AuditReverterInterface $reverter, + private readonly AuditLogRepositoryInterface $repository, + private readonly AdminUrlGenerator $adminUrlGenerator, + private readonly AuditIntegrityServiceInterface $integrityService, + private readonly AuditExporterInterface $exporter, + private readonly RevertPreviewFormatter $formatter, + private readonly TransactionDrilldownService $drilldownService, + ) { + } + + #[Override] public static function getEntityFqcn(): string { return AuditLog::class; } + #[Override] public function configureCrud(Crud $crud): Crud { return $crud @@ -52,9 +82,28 @@ public function configureCrud(Crud $crud): Crud ->setEntityLabelInPlural('Audit Logs') ->setDefaultSort(['createdAt' => 'DESC']) ->setSearchFields(['entityClass', 'entityId', 'action', 'username', 'changedFields', 'transactionHash']) - ->setPaginatorPageSize(30); + ->setPaginatorPageSize(30) + ->overrideTemplates([ + 'crud/index' => '@AuditTrail/admin/audit_log/index.html.twig', + 'crud/detail' => '@AuditTrail/admin/audit_log/detail.html.twig', + ]); } + #[Override] + public function configureResponseParameters(KeyValueStore $responseParameters): KeyValueStore + { + if (Crud::PAGE_DETAIL === $responseParameters->get('pageName')) { + /** @var \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto $entityDto */ + $entityDto = $responseParameters->get('entity'); + /** @var AuditLog $log */ + $log = $entityDto->getInstance(); + $responseParameters->set('is_reverted', $this->repository->isReverted($log)); + } + + return $responseParameters; + } + + #[Override] public function configureActions(Actions $actions): Actions { return $actions @@ -62,6 +111,13 @@ public function configureActions(Actions $actions): Actions ->add(Crud::PAGE_INDEX, Action::DETAIL); } + #[Override] + public function configureAssets(Assets $assets): Assets + { + return $assets->addCssFile('bundles/audittrail/css/audit-trail-admin.css'); + } + + #[Override] public function configureFields(string $pageName): iterable { yield from $this->configureIndexFields(); @@ -71,7 +127,173 @@ public function configureFields(string $pageName): iterable } /** - * @return iterable + * Preview revert changes (dry-run) — returns HTML fragment for the modal. + * + * @param AdminContext $context + */ + public function previewRevert(AdminContext $context): Response + { + $auditLog = $this->loadEntityFromContext($context); + + if ($auditLog === null) { + return new Response('
Audit log not found.
', Response::HTTP_NOT_FOUND); + } + + try { + // Optimization: Skip signature verification for preview (it's checked on final revert anyway) + $changes = $this->reverter->revert($auditLog, dryRun: true, force: true, verifySignature: false); + + $formattedChanges = []; + foreach ($changes as $field => $value) { + $formattedChanges[$field] = $this->formatter->format($value); + } + + return $this->render('@AuditTrail/admin/audit_log/_revert_preview.html.twig', [ + 'changes' => $formattedChanges, + ]); + } catch (Throwable $e) { + return new Response( + sprintf('
Failed to load preview
%s
', htmlspecialchars($e->getMessage())), + Response::HTTP_BAD_REQUEST + ); + } + } + + /** + * Execute the revert operation. + * + * @param AdminContext $context + */ + public function revertAuditLog(AdminContext $context): RedirectResponse + { + $auditLog = $this->loadEntityFromContext($context); + + if ($auditLog === null) { + $this->addFlash('danger', 'Audit log not found.'); + + return $this->redirect($this->generateIndexUrl()); + } + + if (!$this->isCsrfTokenValid('revert', $context->getRequest()->request->getString('_token'))) { + $this->addFlash('danger', 'Invalid CSRF token.'); + + return $this->redirect($this->generateIndexUrl()); + } + + try { + $this->reverter->revert($auditLog, force: true); + $this->addFlash('success', sprintf( + 'Successfully reverted %s #%s to its previous state.', + $this->shortenClass($auditLog->entityClass), + $auditLog->entityId, + )); + } catch (Throwable $e) { + $this->addFlash('danger', 'Revert failed: '.$e->getMessage()); + } + + return $this->redirect($this->generateIndexUrl()); + } + + /** + * Transaction drill-down — displays all logs with the same transaction hash. + * + * @param AdminContext $context + */ + public function transactionDrilldown(AdminContext $context): Response + { + $transactionHash = $context->getRequest()->query->getString('transactionHash'); + $afterId = $context->getRequest()->query->getString('afterId'); + $beforeId = $context->getRequest()->query->getString('beforeId'); + + if ($transactionHash === '') { + $this->addFlash('warning', 'No transaction hash provided.'); + + return $this->redirect($this->generateIndexUrl()); + } + + $pageData = $this->drilldownService->getDrilldownPage($transactionHash, $afterId, $beforeId, self::DRILLDOWN_LIMIT); + + return $this->render('@AuditTrail/admin/audit_log/transaction_drilldown.html.twig', [ + 'transactionHash' => $transactionHash, + 'logs' => $pageData['logs'], + 'totalItems' => $pageData['totalItems'], + 'limit' => $pageData['limit'], + 'hasNextPage' => $pageData['hasNextPage'], + 'hasPrevPage' => $pageData['hasPrevPage'], + 'firstId' => $pageData['firstId'], + 'lastId' => $pageData['lastId'], + ]); + } + + /** + * @param AdminContext $context + */ + public function exportJson(AdminContext $context): Response + { + return $this->doExport($context, 'json'); + } + + /** + * @param AdminContext $context + */ + public function exportCsv(AdminContext $context): Response + { + return $this->doExport($context, 'csv'); + } + + /** + * @param AdminContext $context + */ + private function doExport(AdminContext $context, string $format): Response + { + $filters = $this->getFiltersFromRequest($context); + /** @var iterable $audits */ + $audits = $this->repository->findAllWithFilters($filters); + + $content = $this->exporter->formatAudits($audits, $format); + $fileName = sprintf('audit_logs_%s.%s', new DateTimeImmutable()->format('Y-m-d_His'), $format); + + $response = new Response($content); + $response->headers->set('Content-Type', $format === 'json' ? 'application/json' : 'text/csv'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $fileName)); + + return $response; + } + + /** + * @param AdminContext $context + * + * @return array + */ + private function getFiltersFromRequest(AdminContext $context): array + { + $request = $context->getRequest(); + /** @var array $filters */ + $filters = $request->query->all()['filters'] ?? []; + $processedFilters = []; + + foreach ($filters as $property => $data) { + if (isset($data['value']) && $data['value'] !== '') { + $processedFilters[$property] = $data['value']; + } + } + + return $processedFilters; + } + + #[Override] + public function configureFilters(Filters $filters): Filters + { + return $filters + ->add(TextFilter::new('entityClass')) + ->add(TextFilter::new('action')) + ->add(TextFilter::new('username')) + ->add(TextFilter::new('transactionHash')) + ->add(DateTimeFilter::new('createdAt')); + } + + /** + * @return iterable<\EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface> */ private function configureIndexFields(): iterable { @@ -111,7 +333,7 @@ private function configureIndexFields(): iterable } /** - * @return iterable + * @return iterable<\EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface> */ private function configureOverviewTabFields(): iterable { @@ -141,71 +363,80 @@ private function configureOverviewTabFields(): iterable } /** - * @return iterable + * Changed from raw CodeEditorField to a custom diff view template. + * + * @return iterable<\EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface> */ private function configureChangesTabFields(): iterable { yield FormField::addTab('Changes')->setIcon('fa fa-exchange-alt'); yield FormField::addPanel()->setHelp('Visual comparison of the entity state before and after the change.'); - yield FormField::addRow(); - yield $this->createJsonField('changedFields', 'Changed Fields') - ->setColumns(12) - ->setHelp('List of properties that were modified in this transaction.'); + yield TextField::new('signature', 'Integrity Signature') + ->formatValue(function ($value, AuditLog $log): string { + if (!$this->integrityService->isEnabled()) { + return ' Integrity Disabled'; + } - yield FormField::addRow(); - yield $this->createJsonField('oldValues', 'Old Values') - ->setColumns(6) - ->setHelp('State of the entity before the change.'); + if ($this->integrityService->verifySignature($log)) { + return ' Verified Authentic'; + } + + return ' Tampered / Invalid'; + }) + ->renderAsHtml() + ->onlyOnDetail(); - yield $this->createJsonField('newValues', 'New Values') - ->setColumns(6) - ->setHelp('State of the entity after the change.'); + yield FormField::addRow(); + yield CodeEditorField::new('changedFields', 'Visual Diff') + ->setTemplatePath('@AuditTrail/admin/audit_log/field/diff_view.html.twig') + ->setColumns(12) + ->onlyOnDetail(); } /** - * @return iterable + * Technical Context tab — uses transaction link + structured context templates. + * + * @return iterable<\EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface> */ private function configureTechnicalContextTabFields(): iterable { - yield FormField::addTab('Technical Context')->setIcon('fa fa-cogs'); + yield FormField::addTab('Context')->setIcon('fa fa-cogs'); yield FormField::addPanel()->setHelp('Low-level transaction details and custom context metadata.'); yield TextField::new('transactionHash', 'Transaction Hash') + ->setTemplatePath('@AuditTrail/admin/audit_log/field/transaction_link.html.twig') ->onlyOnDetail() - ->setHelp('Unique identifier grouping all changes that happened in the same database transaction.'); + ->setHelp('Click to see all changes in this transaction.'); - yield $this->createJsonField('context', 'Full Context') - ->setHelp('Additional metadata such as impersonation details, request ID, or custom attributes.'); + yield CodeEditorField::new('context', 'Context Details') + ->setTemplatePath('@AuditTrail/admin/audit_log/field/context_view.html.twig') + ->onlyOnDetail() + ->setHelp('Structured view of context metadata.'); } - public function configureFilters(Filters $filters): Filters + /** + * @param AdminContext $context + */ + private function loadEntityFromContext(AdminContext $context): ?AuditLog { - return $filters - ->add(TextFilter::new('entityClass')) - ->add(TextFilter::new('action')) - ->add(TextFilter::new('username')) - ->add(TextFilter::new('transactionHash')) - ->add(DateTimeFilter::new('createdAt')); - } + $entityId = $context->getRequest()->query->getString('entityId'); - private function createJsonField(string $propertyName, string $label): CodeEditorField - { - return CodeEditorField::new($propertyName, $label) - ->setLanguage('javascript') - ->formatValue(fn ($value): string => $this->formatJson($value)) - ->onlyOnDetail(); + if ($entityId === '') { + return null; + } + + /** @var AuditLog|null $auditLog */ + $auditLog = $this->repository->find($entityId); + + return $auditLog; } - private function formatJson(mixed $value): string + private function generateIndexUrl(): string { - return match (true) { - $value === null => '', - is_array($value) => (($json = json_encode( - $value, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES - )) !== false ? $json : ''), - default => (string) (is_scalar($value) || $value instanceof Stringable ? $value : ''), - }; + return $this->adminUrlGenerator + ->setController(static::class) + ->setAction(Action::INDEX) + ->generateUrl(); } } diff --git a/src/DependencyInjection/AuditTrailExtension.php b/src/DependencyInjection/AuditTrailExtension.php index 3ca1fbb..46d7bba 100644 --- a/src/DependencyInjection/AuditTrailExtension.php +++ b/src/DependencyInjection/AuditTrailExtension.php @@ -7,6 +7,7 @@ use LogicException; use Override; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Controller\Admin\AuditLogCrudController; use Rcsofttech\AuditTrailBundle\Factory\AuditLogMessageFactory; use Rcsofttech\AuditTrailBundle\MessageHandler\PersistAuditLogHandler; use Rcsofttech\AuditTrailBundle\Transport\AsyncDatabaseAuditTransport; @@ -90,6 +91,14 @@ public function load(array $configs, ContainerBuilder $container): void new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'))->load('services.yaml'); $this->configureTransports($config, $container); + + $bundles = $container->hasParameter('kernel.bundles') ? $container->getParameter('kernel.bundles') : []; + if (isset($bundles['EasyAdminBundle'])) { + $container->register(AuditLogCrudController::class) + ->setAutowired(true) + ->setAutoconfigured(true) + ->addTag('controller.service_arguments'); + } } #[Override] diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index e17bef5..f7fa39a 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -9,6 +9,7 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Override; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; @@ -92,6 +93,21 @@ public function deleteOldLogs(DateTimeImmutable $before): int return $count; } + #[Override] + public function findAllWithFilters(array $filters = []): iterable + { + $qb = $this->createQueryBuilder('a'); + + $this->applyEntityClassFilter($qb, $filters); + $this->applyScalarFilters($qb, $filters); + $this->applyDateRangeFilters($qb, $filters); + + // For export, we always want the newest first by default + $qb->orderBy('a.createdAt', 'DESC'); + + return $qb->getQuery()->toIterable(); + } + /** * Find audit logs with optional filters using keyset pagination. * @@ -192,13 +208,13 @@ private function applyKeysetPagination(QueryBuilder $qb, array $filters): void if (isset($filters['afterId'])) { // Next page: get records with ID less than the cursor $qb->andWhere('a.id < :afterId') - ->setParameter('afterId', $filters['afterId']); + ->setParameter('afterId', $filters['afterId'], 'uuid'); } if (isset($filters['beforeId'])) { // Previous page: get records with ID greater than the cursor $qb->andWhere('a.id > :beforeId') - ->setParameter('beforeId', $filters['beforeId']); + ->setParameter('beforeId', $filters['beforeId'], 'uuid'); // Temporarily reverse order to fetch correct records $order = 'ASC'; @@ -239,4 +255,26 @@ public function countOlderThan(DateTimeImmutable $before): int ->getQuery() ->getSingleScalarResult(); } + + #[Override] + public function isReverted(AuditLog $log): bool + { + if ($log->id === null) { + return false; + } + + return (int) $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.entityClass = :class') + ->andWhere('a.entityId = :entityId') + ->andWhere('a.action = :action') + ->andWhere('a.context LIKE :revertedId') + ->setParameter('class', $log->entityClass) + ->setParameter('entityId', $log->entityId) + ->setParameter('action', AuditLogInterface::ACTION_REVERT) + ->setParameter('revertedId', '%'.$log->id->toRfc4122().'%') + ->setMaxResults(1) + ->getQuery() + ->getSingleScalarResult() > 0; + } } diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 7eda560..8b92635 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -90,3 +90,4 @@ services: - '../../DependencyInjection/' - '../../Service/AuditIntegrityService.php' - '../../Transport/' + - '../../Controller/' diff --git a/src/Resources/public/css/audit-trail-admin.css b/src/Resources/public/css/audit-trail-admin.css new file mode 100644 index 0000000..e685d93 --- /dev/null +++ b/src/Resources/public/css/audit-trail-admin.css @@ -0,0 +1,629 @@ +/* ========================================================================== + AuditTrailBundle — EasyAdmin Theme-Aware Styles + Uses EasyAdmin CSS variables to support both light and dark themes. + ========================================================================== */ + +:root { + --audit-primary: var(--color-primary); + --audit-success: var(--color-success); + --audit-warning: var(--color-warning); + --audit-danger: var(--color-danger); + --audit-radius: 12px; + --audit-radius-sm: 8px; + --audit-shadow: var(--shadow-md); + --audit-shadow-lg: var(--shadow-lg); +} + + + +/* -------------------------------------------------------------------------- + 2. VISUAL DIFF VIEW + -------------------------------------------------------------------------- */ + +.audit-diff-container { + border-radius: var(--audit-radius); + overflow: hidden; + border: 1px solid var(--border-color); + box-shadow: var(--audit-shadow); + margin-block-end: 1rem; +} + +.audit-diff-header { + display: flex; + align-items: center; + gap: 8px; + padding-block: 0.75rem; + padding-inline: 1rem; + background: var(--secondary-bg); + border-block-end: 1px solid var(--border-color); + font-weight: 600; + font-size: .9rem; + color: var(--text-color); +} + +.audit-diff-header i { + color: var(--audit-primary); +} + +.audit-diff-table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} + +.audit-diff-table thead th { + padding-block: 0.625rem; + padding-inline: 1rem; + font-size: .75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + background: var(--tertiary-bg); + border-block-end: 2px solid var(--border-color); +} + +.audit-diff-table thead th:first-child { + width: 20%; +} + +.audit-diff-table tbody tr { + transition: background .15s ease; +} + +.audit-diff-table tbody tr:hover { + background: var(--table-hover-cell-bg); +} + +.audit-diff-table tbody td { + padding-block: 0.625rem; + padding-inline: 1rem; + border-block-end: 1px solid var(--border-color); + vertical-align: top; + word-break: break-word; +} + +.audit-diff-table td.audit-diff-field { + font-weight: 600; + color: var(--text-color); + font-family: var(--font-family-monospace); + font-size: .8rem; + background: var(--tertiary-bg); +} + +.audit-diff-table td.audit-diff-old { + background: rgba(239, 68, 68, .08); + color: var(--badge-danger-color, #991b1b); +} + +.audit-diff-table td.audit-diff-old .audit-diff-value { + padding-block: 0.25rem; + padding-inline: 0.5rem; + border-radius: 4px; + background: rgba(239, 68, 68, .10); + border-inline-start: 3px solid var(--color-danger); + display: inline-block; + font-family: var(--font-family-monospace); + font-size: .8rem; +} + +.audit-diff-table td.audit-diff-new { + background: rgba(16, 185, 129, .08); + color: var(--badge-success-color, #166534); +} + +.audit-diff-table td.audit-diff-new .audit-diff-value { + padding-block: 0.25rem; + padding-inline: 0.5rem; + border-radius: 4px; + background: rgba(16, 185, 129, .10); + border-inline-start: 3px solid var(--color-success); + display: inline-block; + font-family: var(--font-family-monospace); + font-size: .8rem; +} + +.audit-diff-table td.audit-diff-unchanged { + color: var(--text-muted); + font-style: italic; +} + +:is(.audit-diff-badge-added, .audit-diff-badge-removed) { + display: inline-block; + padding-block: 0.125rem; + padding-inline: 0.5rem; + border-radius: 10px; + font-size: .7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + margin-inline-start: 0.375rem; +} + +.audit-diff-badge-added { + background: var(--badge-success-bg); + color: var(--badge-success-color); +} + +.audit-diff-badge-removed { + background: var(--badge-danger-bg); + color: var(--badge-danger-color); +} + +.audit-diff-empty { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.audit-diff-empty i { + font-size: 2rem; + opacity: .4; + margin-block-end: 0.5rem; + display: block; +} + +/* -------------------------------------------------------------------------- + 3. STRUCTURED CONTEXT VIEW + -------------------------------------------------------------------------- */ + +.audit-context-container { + border-radius: var(--audit-radius); + overflow: hidden; + border: 1px solid var(--border-color); + box-shadow: var(--audit-shadow); +} + +.audit-context-table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} + +.audit-context-table tr { + transition: background .15s ease; +} + +.audit-context-table tr:hover { + background: var(--table-hover-cell-bg); +} + +.audit-context-table td { + padding-block: 0.625rem; + padding-inline: 1rem; + border-block-end: 1px solid var(--border-color); + vertical-align: top; +} + +.audit-context-table td:first-child { + font-weight: 600; + color: var(--text-muted); + width: 35%; + font-family: var(--font-family-monospace); + font-size: .8rem; + background: var(--tertiary-bg); +} + +.audit-context-table td:last-child { + color: var(--text-color); + word-break: break-word; +} + +.audit-context-pre { + margin: 0; + padding: 8px; + border-radius: 6px; + font-size: .8rem; + background: var(--secondary-bg); + color: var(--text-color); + font-family: var(--font-family-monospace); + white-space: pre-wrap; + word-break: break-all; +} + +.audit-context-raw { + margin-block-start: 0.75rem; +} + +.audit-context-raw-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding-block: 0.375rem; + padding-inline: 0.75rem; + border-radius: var(--audit-radius-sm); + border: 1px solid var(--border-color); + background: var(--secondary-bg); + color: var(--text-muted); + font-size: .8rem; + font-weight: 600; + cursor: pointer; + transition: all .2s ease; +} + +.audit-context-raw-toggle:hover { + background: var(--primary-bg); + color: var(--audit-primary); + border-color: var(--audit-primary); +} + +.audit-context-raw-toggle i { + transition: transform .2s ease; +} + +.audit-context-raw-toggle[aria-expanded="true"] i { + transform: rotate(90deg); +} + +.audit-context-raw pre { + margin-block-start: 0.5rem; + padding: 16px; + border-radius: var(--audit-radius-sm); + background: var(--tertiary-bg); + color: var(--text-color); + font-family: var(--font-family-monospace); + font-size: .8rem; + line-height: 1.6; + overflow-x: auto; + border: 1px solid var(--border-color); +} + +/* -------------------------------------------------------------------------- + 4. TRANSACTION DRILL-DOWN + -------------------------------------------------------------------------- */ + +.audit-transaction-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding-block: 0.25rem; + padding-inline: 0.625rem; + border-radius: var(--audit-radius-sm); + background: rgba(99, 102, 241, .08); + color: var(--audit-primary); + font-family: var(--font-family-monospace); + font-size: .8rem; + font-weight: 600; + text-decoration: none; + transition: all .2s ease; + border: 1px solid rgba(99, 102, 241, .2); +} + +.audit-transaction-link:hover { + background: rgba(99, 102, 241, .15); + border-color: var(--audit-primary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(99, 102, 241, .25); + color: var(--audit-primary); + text-decoration: none; +} + +.audit-transaction-link i { + font-size: .7rem; +} + +.audit-drilldown-container { + margin-block: 1.25rem; + border-radius: 12px; + background: var(--secondary-bg, var(--body-bg)); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + overflow: hidden; + border: 1px solid var(--border-color); +} + +.ea-dark-scheme .audit-drilldown-container { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.audit-drilldown-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding-block: 1.5rem; + padding-inline: 1.75rem; + background: linear-gradient(135deg, #6366f1, #8b5cf6, #d946ef); + color: #fff; +} + +.audit-drilldown-header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.audit-drilldown-header .badge { + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(4px); + font-size: 0.85rem; + padding-block: 0.5rem; + padding-inline: 1rem; + border-radius: 20px; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.audit-drilldown-hash-code { + background: rgba(0, 0, 0, 0.25); + padding-block: 0.25rem; + padding-inline: 0.625rem; + border-radius: 6px; + font-family: var(--font-family-monospace); + font-size: 0.95em; + letter-spacing: 0.5px; + display: inline-block; + color: #fff; +} + +.audit-drilldown-body { + background: var(--content-bg, var(--body-bg)); +} + +.audit-drilldown-table { + margin: 0; + width: 100%; +} + +.audit-drilldown-table th { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + background: var(--tertiary-bg); + border-block-end: 2px solid var(--border-color); + padding-block: 0.75rem; + padding-inline: 1rem; + white-space: nowrap; +} + +.audit-drilldown-table td { + padding-block: 0.875rem; + padding-inline: 1rem; + border-block-end: 1px solid var(--border-color); + vertical-align: middle; +} + +.ea-dark-scheme .audit-drilldown-table th { + background: rgba(0, 0, 0, 0.15); +} + +.audit-drilldown-row { + transition: all 0.2s ease; + background: transparent; +} + +.audit-drilldown-row:hover { + background: var(--table-hover-cell-bg, rgba(99, 102, 241, 0.04)); +} + +.ea-dark-scheme .audit-drilldown-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.audit-drilldown-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + margin-inline: auto; + transition: transform 0.2s ease; +} + +.audit-drilldown-row:hover .audit-drilldown-icon { + transform: scale(1.1); +} + +.audit-drilldown-changes-compact { + display: flex; + flex-wrap: wrap; + gap: 4px; + max-width: 300px; +} + +.audit-drilldown-changed-badge { + background: var(--tertiary-bg); + color: var(--text-color); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-family-monospace); + font-size: 0.75em; + border: 1px solid var(--border-color); + white-space: nowrap; +} + +.audit-drilldown-pagination { + background: var(--content-bg, var(--body-bg)); + border-block-start-color: var(--border-color) !important; +} + +/* -------------------------------------------------------------------------- + 5. REVERT PREVIEW + -------------------------------------------------------------------------- */ + +.audit-revert-preview { + border-radius: var(--audit-radius); + overflow: hidden; + border: 1px solid var(--border-color); + margin-block-end: 1rem; +} + +.audit-revert-preview-header { + display: flex; + align-items: center; + gap: 8px; + padding-block: 0.75rem; + padding-inline: 1rem; + background: var(--alert-warning-bg); + border-block-end: 1px solid var(--alert-warning-border-color); + font-weight: 600; + font-size: .9rem; + color: var(--alert-warning-color); +} + +.audit-revert-preview table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} + +.audit-revert-preview table th { + padding-block: 0.625rem; + padding-inline: 1rem; + font-size: .75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + background: var(--tertiary-bg); + border-block-end: 2px solid var(--border-color); +} + +.audit-revert-preview table td { + padding-block: 0.625rem; + padding-inline: 1rem; + border-block-end: 1px solid var(--border-color); + vertical-align: top; + color: var(--text-color); +} + +/* Revert Modal Header */ +.audit-modal-header { + background: var(--alert-warning-bg) !important; + border-block-end: 1px solid var(--alert-warning-border-color) !important; +} + +.audit-modal-title { + color: var(--alert-warning-color) !important; +} + +.audit-revert-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding-block: 0.5rem; + padding-inline: 1rem; + border-radius: var(--audit-radius-sm); + border: none; + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; + font-weight: 600; + font-size: .85rem; + cursor: pointer; + transition: all .2s ease; + box-shadow: 0 2px 8px rgba(245, 158, 11, .3); +} + +.audit-revert-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(245, 158, 11, .4); + background: linear-gradient(135deg, #d97706, #b45309); +} + +.audit-revert-btn:disabled { + opacity: .5; + cursor: not-allowed; + transform: none; +} + +.audit-revert-btn--danger { + background: linear-gradient(135deg, #ef4444, #dc2626); + box-shadow: 0 2px 8px rgba(239, 68, 68, .3); +} + +.audit-revert-btn--danger:hover { + box-shadow: 0 4px 12px rgba(239, 68, 68, .4); + background: linear-gradient(135deg, #dc2626, #b91c1c); +} + +/* -------------------------------------------------------------------------- + 6. ACTION BADGES (enhanced) + -------------------------------------------------------------------------- */ + +.audit-action-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 50px; + font-size: .75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + color: #fff !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.audit-action-badge i { + font-size: .8rem; +} + +.audit-action-badge-success { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); +} + +.audit-action-badge-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); +} + +.audit-action-badge-danger { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); +} + +.audit-action-badge-info { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); +} + +.audit-action-badge-primary { + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); +} + +.audit-action-badge-secondary { + background: linear-gradient(135deg, #64748b 0%, #475569 100%); +} + +.audit-action-badge-light { + background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%); +} + +/* -------------------------------------------------------------------------- + 7. DETAIL PAGE ENHANCEMENTS + -------------------------------------------------------------------------- */ + +.audit-detail-title-badge { + vertical-align: middle; + margin-inline-start: 0.5rem; +} + +/* Smooth panel transitions */ +.audit-panel { + border-radius: var(--audit-radius); + background: var(--content-bg, var(--body-bg)); + box-shadow: var(--audit-shadow); + margin-block-end: 1.25rem; + overflow: hidden; +} + +.audit-panel-header { + padding-block: 0.875rem; + padding-inline: 1.25rem; + background: var(--secondary-bg); + border-block-end: 1px solid var(--border-color); + font-weight: 600; + font-size: .9rem; + color: var(--text-color); + display: flex; + align-items: center; + gap: 8px; +} + +.audit-panel-body { + padding: 1.25rem; +} \ No newline at end of file diff --git a/src/Resources/views/admin/audit_log/_revert_modal.html.twig b/src/Resources/views/admin/audit_log/_revert_modal.html.twig new file mode 100644 index 0000000..b9bfb60 --- /dev/null +++ b/src/Resources/views/admin/audit_log/_revert_modal.html.twig @@ -0,0 +1,95 @@ +{# Revert Confirmation Modal — shows dry-run preview before applying revert #} + + + diff --git a/src/Resources/views/admin/audit_log/_revert_preview.html.twig b/src/Resources/views/admin/audit_log/_revert_preview.html.twig new file mode 100644 index 0000000..b89e06b --- /dev/null +++ b/src/Resources/views/admin/audit_log/_revert_preview.html.twig @@ -0,0 +1,39 @@ +{% if changes is empty %} +
+ +
+ No Changes Detected: The current entity state already matches this audit point. Nothing to revert. +
+
+{% else %} +
+
+ + Preview of Changes ({{ changes|length }}) +
+ + + + + + + + + {% for field, value in changes %} + + + + + {% endfor %} + +
FieldWill Be Restored To
{{ field }} + {% if value is null %} + null + {% elseif value is iterable %} +
{{ audit_format_json(value) }}
+ {% else %} + {{ value }} + {% endif %} +
+
+{% endif %} diff --git a/src/Resources/views/admin/audit_log/detail.html.twig b/src/Resources/views/admin/audit_log/detail.html.twig new file mode 100644 index 0000000..4fbf004 --- /dev/null +++ b/src/Resources/views/admin/audit_log/detail.html.twig @@ -0,0 +1,46 @@ +{# Custom Detail Template — adds revert button and enhanced title #} +{% extends '@EasyAdmin/crud/detail.html.twig' %} + +{% block content_title %} + {{ parent() }} + {% if entity is defined and entity.instance is defined %} + + + {{ entity.instance.action|upper }} + + {% if is_reverted|default(false) %} + + REVERTED + + {% endif %} + {% endif %} +{% endblock %} + +{% block main %} + {# ── Revert Button (for update/create/soft_delete actions only) ── #} + {% if entity is defined and entity.instance is defined %} + {% set revertable_actions = ['update', 'create', 'soft_delete'] %} + {% if entity.instance.action in revertable_actions %} +
+ {% if is_reverted|default(false) %} + + {% else %} + + {% endif %} +
+ {% endif %} + {% endif %} + + {{ parent() }} + + {# ── Revert Confirmation Modal ── #} + {% include '@AuditTrail/admin/audit_log/_revert_modal.html.twig' %} +{% endblock %} diff --git a/src/Resources/views/admin/audit_log/field/context_view.html.twig b/src/Resources/views/admin/audit_log/field/context_view.html.twig new file mode 100644 index 0000000..ac906c8 --- /dev/null +++ b/src/Resources/views/admin/audit_log/field/context_view.html.twig @@ -0,0 +1,42 @@ +{# Structured Context View — renders context as key-value table + collapsible raw JSON #} +{% set context = entity.instance.context ?? {} %} + +{% if context|length > 0 %} +
+ + {% for key, value in context %} + + + + + {% endfor %} +
{{ key }} + {% if value is iterable %} +
{{ audit_format_json(value) }}
+ {% elseif value is same as(true) %} + true + {% elseif value is same as(false) %} + false + {% elseif value is null %} + null + {% else %} + {{ value }} + {% endif %} +
+
+ +
+ +
+
{{ audit_format_json(context) }}
+
+
+{% else %} +
+ No context metadata recorded. +
+{% endif %} diff --git a/src/Resources/views/admin/audit_log/field/diff_view.html.twig b/src/Resources/views/admin/audit_log/field/diff_view.html.twig new file mode 100644 index 0000000..e2eacf0 --- /dev/null +++ b/src/Resources/views/admin/audit_log/field/diff_view.html.twig @@ -0,0 +1,73 @@ +{# Diff View Field Template — renders side-by-side old/new values with highlights #} +{% set old_values = entity.instance.oldValues ?? {} %} +{% set new_values = entity.instance.newValues ?? {} %} +{% set changed = entity.instance.changedFields ?? [] %} + +{% set all_fields = changed|merge(old_values|keys)|merge(new_values|keys)|unique %} + +{% if all_fields is not empty %} +
+
+ + Changes ({{ all_fields|length }} field{{ all_fields|length > 1 ? 's' : '' }}) +
+ + + + + + + + + + {% for f in all_fields %} + {% set old_val = old_values[f] ?? null %} + {% set new_val = new_values[f] ?? null %} + + + + + + {% endfor %} + +
FieldOld ValueNew Value
+ {{ f }} + {% if old_val is null and new_val is not null %} + added + {% elseif old_val is not null and new_val is null %} + removed + {% endif %} + + {% if old_val is not null %} + + {% if old_val is iterable %} + {{ audit_format_json(old_val) }} + {% else %} + {{ old_val }} + {% endif %} + + {% else %} + + {% endif %} + + {% if new_val is not null %} + + {% if new_val is iterable %} + {{ audit_format_json(new_val) }} + {% else %} + {{ new_val }} + {% endif %} + + {% else %} + + {% endif %} +
+
+{% else %} +
+
+ + No field changes recorded for this audit event. +
+
+{% endif %} diff --git a/src/Resources/views/admin/audit_log/field/transaction_link.html.twig b/src/Resources/views/admin/audit_log/field/transaction_link.html.twig new file mode 100644 index 0000000..f054fb5 --- /dev/null +++ b/src/Resources/views/admin/audit_log/field/transaction_link.html.twig @@ -0,0 +1,16 @@ +{# Transaction Link Field — makes transactionHash clickable for drill-down #} +{% set hash = field.value %} + +{% if hash %} + + + {{ hash|length > 12 ? hash[:12] ~ '…' : hash }} + +{% else %} + N/A +{% endif %} diff --git a/src/Resources/views/admin/audit_log/index.html.twig b/src/Resources/views/admin/audit_log/index.html.twig new file mode 100644 index 0000000..924f127 --- /dev/null +++ b/src/Resources/views/admin/audit_log/index.html.twig @@ -0,0 +1,26 @@ +{# Custom Index Template — adds Statistics Summary Widget above the data table #} +{% extends '@EasyAdmin/crud/index.html.twig' %} + + + +{% block filters %} + {{ parent() }} + +
+ + +
+{% endblock %} diff --git a/src/Resources/views/admin/audit_log/transaction_drilldown.html.twig b/src/Resources/views/admin/audit_log/transaction_drilldown.html.twig new file mode 100644 index 0000000..77b08ed --- /dev/null +++ b/src/Resources/views/admin/audit_log/transaction_drilldown.html.twig @@ -0,0 +1,114 @@ +{# Transaction Drill-down page — shows all changes in a single transaction #} +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block content_title %} + + Transaction Drill-down +{% endblock %} + +{% block main %} +
+
+
+ +
+
Transaction
+
{{ transactionHash }}
+
+
+
+ + + {{ totalItems }} change{{ totalItems != 1 ? 's' : '' }} + +
+
+ + {% if logs is not empty %} +
+
+ + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% endfor %} + +
ActionEntityRecord IDModified FieldsUserDate & Time
+ + + {{ log.action|upper }} + + + {{ log.entityClass|audit_short_class }} + + {{ log.entityId }} + + {% if log.changedFields is not empty %} +
+ {% for field in log.changedFields %} + {{ field }} + {% endfor %} +
+ {% else %} + None + {% endif %} +
+ {{ log.username ?? 'System' }} + + {{ log.createdAt|date('d M Y, H:i:s') }} +
+
+ + {% if hasNextPage or hasPrevPage %} +
+ + Showing {{ logs|length }} entries out of {{ totalItems }} total changes + + +
+ {% endif %} +
+ {% else %} +
+ + No audit logs found for this transaction hash. +
+ {% endif %} +
+ + +{% endblock %} diff --git a/src/Service/AuditIntegrityService.php b/src/Service/AuditIntegrityService.php index d89cc25..78ecc83 100644 --- a/src/Service/AuditIntegrityService.php +++ b/src/Service/AuditIntegrityService.php @@ -149,7 +149,10 @@ private function normalizeValues(?array $values, int $depth = 0): ?array $normalized[$key] = $this->normalizeValue($value, $depth + 1); } - ksort($normalized, SORT_STRING); + /** @var array $normalized */ + if (!array_is_list($normalized)) { + ksort($normalized, SORT_STRING); + } return $normalized; } diff --git a/src/Service/AuditReverter.php b/src/Service/AuditReverter.php index e590cb3..a51a669 100644 --- a/src/Service/AuditReverter.php +++ b/src/Service/AuditReverter.php @@ -10,6 +10,7 @@ use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditReverterInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; @@ -34,6 +35,7 @@ public function __construct( private AuditDispatcherInterface $dispatcher, private ValueSerializerInterface $serializer, private ScheduledAuditManagerInterface $auditManager, + private AuditLogRepositoryInterface $repository, ) { } @@ -49,37 +51,53 @@ public function revert( bool $force = false, array $context = [], bool $silenceSubscriber = true, + bool $verifySignature = true, ): array { - if ($this->integrityService->isEnabled() && !$this->integrityService->verifySignature($log)) { + if ($verifySignature && $this->integrityService->isEnabled() && !$this->integrityService->verifySignature($log)) { throw new RuntimeException(sprintf('Audit log #%s has been tampered with and cannot be reverted.', $log->id?->toRfc4122() ?? 'unknown')); } - $entity = $this->findEntity($log->entityClass, $log->entityId); + if ($this->repository->isReverted($log)) { + throw new RuntimeException(sprintf('Audit log #%s has already been reverted.', $log->id?->toRfc4122() ?? 'unknown')); + } - if ($entity === null) { - throw new RuntimeException(sprintf('Entity %s:%s not found.', $log->entityClass, $log->entityId)); + if ($silenceSubscriber) { + $this->auditManager->disable(); } - $changes = $this->determineChanges($log, $entity, $force); + try { + $entity = $this->findEntity($log->entityClass, $log->entityId); - if ($dryRun) { - return $changes; - } + if ($entity === null) { + throw new RuntimeException(sprintf('Entity %s:%s not found.', $log->entityClass, $log->entityId)); + } - $this->applyAndPersist($entity, $log, $changes, $context, $silenceSubscriber); + $changes = $this->determineChanges($log, $entity, $force, $dryRun); - return $changes; + if ($dryRun) { + return $changes; + } + + $this->applyAndPersist($entity, $log, $changes, $context); + + return $changes; + } finally { + if ($silenceSubscriber) { + $this->auditManager->enable(); + } + } } /** * @return array */ - private function determineChanges(AuditLog $log, object $entity, bool $force): array + private function determineChanges(AuditLog $log, object $entity, bool $force, bool $dryRun): array { return match ($log->action) { AuditLogInterface::ACTION_CREATE => $this->handleRevertCreate($force), - AuditLogInterface::ACTION_UPDATE => $this->handleRevertUpdate($log, $entity), + AuditLogInterface::ACTION_UPDATE => $this->handleRevertUpdate($log, $entity, $dryRun), AuditLogInterface::ACTION_SOFT_DELETE => $this->handleRevertSoftDelete($entity), + AuditLogInterface::ACTION_ACCESS => [], // Ignore access attribute during revert default => throw new RuntimeException(sprintf('Reverting action "%s" is not supported.', $log->action)), }; } @@ -93,11 +111,10 @@ private function applyAndPersist( AuditLog $log, array $changes, array $context, - bool $silenceSubscriber, ): void { $isDelete = isset($changes['action']) && $changes['action'] === 'delete'; - $this->em->wrapInTransaction(function () use ($entity, $isDelete, $log, $changes, $context, $silenceSubscriber) { + $this->em->wrapInTransaction(function () use ($entity, $isDelete, $log, $changes, $context) { if ($isDelete) { $this->em->remove($entity); } else { @@ -105,17 +122,7 @@ private function applyAndPersist( $this->em->persist($entity); } - if ($silenceSubscriber) { - $this->auditManager->disable(); - } - - try { - $this->em->flush(); - } finally { - if ($silenceSubscriber) { - $this->auditManager->enable(); - } - } + $this->em->flush(); $this->createRevertAuditLog($entity, $log, $changes, $isDelete, $context); }); @@ -180,14 +187,14 @@ private function handleRevertCreate(bool $force): array /** * @return array */ - private function handleRevertUpdate(AuditLog $log, object $entity): array + private function handleRevertUpdate(AuditLog $log, object $entity, bool $dryRun): array { $oldValues = $log->oldValues ?? []; if ($oldValues === []) { throw new RuntimeException('No old values found in audit log to revert to.'); } - return $this->applyChanges($entity, $oldValues); + return $this->applyChanges($entity, $oldValues, $dryRun); } /** @@ -225,13 +232,13 @@ private function findEntity(string $class, string $id): ?object * * @return array */ - private function applyChanges(object $entity, array $values): array + private function applyChanges(object $entity, array $values, bool $dryRun): array { $metadata = $this->em->getClassMetadata($entity::class); $appliedChanges = []; foreach ($values as $field => $value) { - $denormalizedValue = $this->denormalizer->denormalize($metadata, $field, $value); + $denormalizedValue = $this->denormalizer->denormalize($metadata, $field, $value, $dryRun); if ($this->shouldSkipField($metadata, $field, $entity, $denormalizedValue)) { continue; diff --git a/src/Service/ChangeProcessor.php b/src/Service/ChangeProcessor.php index 7563927..db2527d 100644 --- a/src/Service/ChangeProcessor.php +++ b/src/Service/ChangeProcessor.php @@ -86,6 +86,9 @@ public function determineUpdateAction(array $changeSet): string } [$oldValue, $newValue] = $changeSet[$this->softDeleteField]; + if ($oldValue === null && $newValue !== null) { + return AuditLogInterface::ACTION_SOFT_DELETE; + } return ($oldValue !== null && $newValue === null) ? AuditLogInterface::ACTION_RESTORE diff --git a/src/Service/RevertPreviewFormatter.php b/src/Service/RevertPreviewFormatter.php new file mode 100644 index 0000000..764422c --- /dev/null +++ b/src/Service/RevertPreviewFormatter.php @@ -0,0 +1,52 @@ +format('Y-m-d H:i:s'); + } + + if (is_object($value)) { + if ($value instanceof Stringable || method_exists($value, '__toString')) { + return (string) $value; + } + + $className = new ReflectionClass($value)->getShortName(); + + if (method_exists($value, 'getId')) { + /** @var mixed $id */ + $id = $value->getId(); + + return sprintf('%s#%s', $className, (string) $id); + } + + return $className; + } + + if (is_array($value)) { + return array_map($this->format(...), $value); + } + + return $value; + } +} diff --git a/src/Service/RevertValueDenormalizer.php b/src/Service/RevertValueDenormalizer.php index f2d06f6..28051bb 100644 --- a/src/Service/RevertValueDenormalizer.php +++ b/src/Service/RevertValueDenormalizer.php @@ -27,7 +27,7 @@ public function __construct( /** * @param ClassMetadata $metadata */ - public function denormalize(ClassMetadata $metadata, string $field, mixed $value): mixed + public function denormalize(ClassMetadata $metadata, string $field, mixed $value, bool $dryRun = false): mixed { if ($value === null) { return null; @@ -50,7 +50,7 @@ public function denormalize(ClassMetadata $metadata, string $field, mixed $value } if ($metadata->hasAssociation($field)) { - return $this->denormalizeAssociation($metadata, $field, $value); + return $this->denormalizeAssociation($metadata, $field, $value, $dryRun); } return $value; @@ -118,7 +118,7 @@ private function denormalizeDateTimeFromString(string $value, string $dateTimeCl /** * @param ClassMetadata $metadata */ - private function denormalizeAssociation(ClassMetadata $metadata, string $field, mixed $value): ?object + private function denormalizeAssociation(ClassMetadata $metadata, string $field, mixed $value, bool $dryRun): ?object { $targetClass = $metadata->getAssociationTargetClass($field); @@ -127,7 +127,11 @@ private function denormalizeAssociation(ClassMetadata $metadata, string $field, } if (is_scalar($value) || is_array($value)) { - /* @var class-string $targetClass */ + /** @var class-string $targetClass */ + if ($dryRun) { + return $this->em->getReference($targetClass, $value); + } + return $this->em->find($targetClass, $value); } diff --git a/src/Service/TransactionDrilldownService.php b/src/Service/TransactionDrilldownService.php new file mode 100644 index 0000000..6432bf4 --- /dev/null +++ b/src/Service/TransactionDrilldownService.php @@ -0,0 +1,129 @@ +prepareFilters($transactionHash, $afterId, $beforeId); + $totalItems = $this->repository->count(['transactionHash' => $transactionHash]); + + // Fetch limit + 1 to determine if there are more records + /** @var AuditLog[] $logs */ + $logs = $this->repository->findWithFilters($filters, $limit + 1); + + return $this->processResults($logs, $filters, $totalItems, $limit); + } + + /** + * @return array + */ + private function prepareFilters(string $transactionHash, ?string $afterId, ?string $beforeId): array + { + $filters = ['transactionHash' => $transactionHash]; + if ($afterId !== null && $afterId !== '') { + $filters['afterId'] = $afterId; + } elseif ($beforeId !== null && $beforeId !== '') { + $filters['beforeId'] = $beforeId; + } + + return $filters; + } + + /** + * @param AuditLog[] $logs + * @param array $filters + * + * @return array{ + * logs: AuditLog[], + * totalItems: int, + * limit: int, + * hasNextPage: bool, + * hasPrevPage: bool, + * firstId: string|null, + * lastId: string|null + * } + */ + private function processResults(array $logs, array $filters, int $totalItems, int $limit): array + { + $pagination = $this->calculatePaginationStatus($logs, $filters, $limit); + $logs = $pagination['logs']; + + return [ + 'logs' => $logs, + 'totalItems' => $totalItems, + 'limit' => $limit, + 'hasNextPage' => $pagination['hasNextPage'], + 'hasPrevPage' => $pagination['hasPrevPage'], + 'firstId' => $this->getLogIdReference(reset($logs)), + 'lastId' => $this->getLogIdReference(end($logs)), + ]; + } + + /** + * @param AuditLog[] $logs + * @param array $filters + * + * @return array{logs: AuditLog[], hasNextPage: bool, hasPrevPage: bool} + */ + private function calculatePaginationStatus(array $logs, array $filters, int $limit): array + { + $hasNextPage = false; + $hasPrevPage = false; + + if (isset($filters['beforeId'])) { + $hasNextPage = true; + if ($limit < count($logs)) { + $hasPrevPage = true; + array_shift($logs); + } + } else { + if (isset($filters['afterId'])) { + $hasPrevPage = true; + } + if ($limit < count($logs)) { + $hasNextPage = true; + array_pop($logs); + } + } + + return [ + 'logs' => $logs, + 'hasNextPage' => $hasNextPage, + 'hasPrevPage' => $hasPrevPage, + ]; + } + + private function getLogIdReference(AuditLog|false $log): ?string + { + return $log !== false && $log->id !== null ? (string) $log->id : null; + } +} diff --git a/src/Twig/AuditTrailTwigExtension.php b/src/Twig/AuditTrailTwigExtension.php new file mode 100644 index 0000000..06ce73d --- /dev/null +++ b/src/Twig/AuditTrailTwigExtension.php @@ -0,0 +1,96 @@ + + */ + public function getFunctions(): array + { + return [ + new TwigFunction('audit_action_badge_class', $this->getActionBadgeClass(...)), + new TwigFunction('audit_action_icon', $this->getActionIcon(...)), + new TwigFunction('audit_format_json', $this->formatJson(...)), + ]; + } + + /** + * @return list + */ + public function getFilters(): array + { + return [ + new TwigFilter('audit_short_class', $this->shortenClass(...)), + new TwigFilter('unique', 'array_unique'), + ]; + } + + public function getActionBadgeClass(string $action): string + { + return match ($action) { + AuditLogInterface::ACTION_CREATE => 'success', + AuditLogInterface::ACTION_UPDATE => 'warning', + AuditLogInterface::ACTION_DELETE, AuditLogInterface::ACTION_SOFT_DELETE => 'danger', + AuditLogInterface::ACTION_RESTORE => 'info', + AuditLogInterface::ACTION_REVERT => 'primary', + AuditLogInterface::ACTION_ACCESS => 'secondary', + default => 'light', + }; + } + + public function getActionIcon(string $action): string + { + return match ($action) { + AuditLogInterface::ACTION_CREATE => 'fa-plus-circle', + AuditLogInterface::ACTION_UPDATE => 'fa-pencil-alt', + AuditLogInterface::ACTION_DELETE => 'fa-trash-alt', + AuditLogInterface::ACTION_SOFT_DELETE => 'fa-eye-slash', + AuditLogInterface::ACTION_RESTORE => 'fa-undo-alt', + AuditLogInterface::ACTION_REVERT => 'fa-history', + AuditLogInterface::ACTION_ACCESS => 'fa-eye', + default => 'fa-question-circle', + }; + } + + public function formatJson(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_array($value)) { + $json = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return $json !== false ? $json : ''; + } + + return is_scalar($value) ? (string) $value : ''; + } + + public function shortenClass(string $className): string + { + $lastBackslash = mb_strrpos($className, '\\'); + + return $lastBackslash === false ? $className : mb_substr($className, $lastBackslash + 1); + } +} diff --git a/tests/Unit/Query/AuditQueryTest.php b/tests/Unit/Query/AuditQueryTest.php index 5c45908..700efbd 100644 --- a/tests/Unit/Query/AuditQueryTest.php +++ b/tests/Unit/Query/AuditQueryTest.php @@ -165,11 +165,11 @@ public function testCountWithChangedFields(): void public function testGetFirstResult(): void { - $uuid1 = Uuid::v4()->toString(); + $uuid1 = Uuid::v7()->toString(); $log1 = new AuditLog('Class', '1', 'create'); $this->setLogId($log1, $uuid1); $log2 = new AuditLog('Class', '2', 'create'); - $this->setLogId($log2, Uuid::v4()->toString()); + $this->setLogId($log2, Uuid::v7()->toString()); // Should call findWithFilters with limit 1 $this->repository->expects($this->once()) @@ -196,10 +196,10 @@ public function testExists(): void public function testGetNextCursor(): void { - $uuid1 = Uuid::v4()->toString(); + $uuid1 = Uuid::v7()->toString(); $log1 = new AuditLog('Class', '1', 'create'); $this->setLogId($log1, $uuid1); - $uuid2 = Uuid::v4()->toString(); + $uuid2 = Uuid::v7()->toString(); $log2 = new AuditLog('Class', '2', 'create'); $this->setLogId($log2, $uuid2); diff --git a/tests/Unit/Query/AuditReaderTest.php b/tests/Unit/Query/AuditReaderTest.php index 60c4d4c..fcc4fa0 100644 --- a/tests/Unit/Query/AuditReaderTest.php +++ b/tests/Unit/Query/AuditReaderTest.php @@ -5,6 +5,7 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Query; use Exception; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; @@ -12,6 +13,7 @@ use Rcsofttech\AuditTrailBundle\Query\AuditReader; use stdClass; +#[AllowMockObjectsWithoutExpectations] class AuditReaderTest extends TestCase { public function testCreateQueryReturnsAuditQuery(): void diff --git a/tests/Unit/Repository/AuditLogRepositoryTest.php b/tests/Unit/Repository/AuditLogRepositoryTest.php index 781b1d8..309acc9 100644 --- a/tests/Unit/Repository/AuditLogRepositoryTest.php +++ b/tests/Unit/Repository/AuditLogRepositoryTest.php @@ -126,7 +126,7 @@ public function testFindWithFiltersAll(): void 'transactionHash' => 'tx1', 'from' => new DateTimeImmutable(), 'to' => new DateTimeImmutable(), - 'afterId' => Uuid::v4()->toString(), + 'afterId' => Uuid::v7()->toString(), ]; // Verify filter application @@ -139,7 +139,7 @@ public function testFindWithFiltersPaginationBackwards(): void { $this->setupQueryBuilderDefaults(false); // Don't mock getResult yet - $filters = ['beforeId' => Uuid::v4()->toString()]; + $filters = ['beforeId' => Uuid::v7()->toString()]; $this->qb->expects($this->once())->method('andWhere')->with('a.id > :beforeId'); $this->qb->expects($this->once())->method('orderBy')->with('a.id', 'ASC'); @@ -166,13 +166,13 @@ public function testFindWithFiltersPaginationBackwardsReversed(): void { $this->setupQueryBuilderDefaults(false); - $uuid = Uuid::v4()->toString(); + $uuid = Uuid::v7()->toString(); $filters = ['beforeId' => $uuid]; $log1 = new AuditLog('Class', '1', 'create'); - $this->setLogId($log1, Uuid::v4()->toString()); + $this->setLogId($log1, Uuid::v7()->toString()); $log2 = new AuditLog('Class', '2', 'create'); - $this->setLogId($log2, Uuid::v4()->toString()); + $this->setLogId($log2, Uuid::v7()->toString()); // Results from DB will be ASC: [11, 12] $this->query->method('getResult')->willReturn([$log1, $log2]); @@ -286,6 +286,30 @@ public function testCountOlderThan(): void self::assertEquals(10, $this->repository->countOlderThan(new DateTimeImmutable())); } + public function testIsReverted(): void + { + $this->setupQueryBuilderDefaults(false); + + $log = new AuditLog('Class', '1', 'update'); + $this->setLogId($log, Uuid::v7()->toString()); + + $this->query->method('getSingleScalarResult')->willReturn(1); + + self::assertTrue($this->repository->isReverted($log)); + } + + public function testIsRevertedFalse(): void + { + $this->setupQueryBuilderDefaults(false); + + $log = new AuditLog('Class', '1', 'update'); + $this->setLogId($log, Uuid::v7()->toString()); + + $this->query->method('getSingleScalarResult')->willReturn(0); + + self::assertFalse($this->repository->isReverted($log)); + } + private function setLogId(AuditLog $log, string $id): void { $reflection = new ReflectionClass($log); diff --git a/tests/Unit/Service/AuditExporterTest.php b/tests/Unit/Service/AuditExporterTest.php index b16e248..983a554 100644 --- a/tests/Unit/Service/AuditExporterTest.php +++ b/tests/Unit/Service/AuditExporterTest.php @@ -42,7 +42,7 @@ public function testSanitizeCsvValue(): void $reflection = new ReflectionClass($log); $property = $reflection->getProperty('id'); $property->setAccessible(true); - $property->setValue($log, Uuid::v4()); + $property->setValue($log, Uuid::v7()); $audits = [$log]; diff --git a/tests/Unit/Service/AuditRevertIntegrityTest.php b/tests/Unit/Service/AuditRevertIntegrityTest.php index 61121e3..25184af 100644 --- a/tests/Unit/Service/AuditRevertIntegrityTest.php +++ b/tests/Unit/Service/AuditRevertIntegrityTest.php @@ -12,6 +12,7 @@ use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; @@ -54,7 +55,8 @@ protected function setUp(): void $this->integrityService, $this->createMock(AuditDispatcherInterface::class), $serializer, - $this->createMock(ScheduledAuditManagerInterface::class) + $this->createMock(ScheduledAuditManagerInterface::class), + $this->createMock(AuditLogRepositoryInterface::class) ); } diff --git a/tests/Unit/Service/AuditReverterTest.php b/tests/Unit/Service/AuditReverterTest.php index 8c8b306..2bdd144 100644 --- a/tests/Unit/Service/AuditReverterTest.php +++ b/tests/Unit/Service/AuditReverterTest.php @@ -13,6 +13,7 @@ use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; @@ -46,6 +47,8 @@ class AuditReverterTest extends AbstractAuditTestCase private AuditDispatcherInterface&MockObject $dispatcher; + private AuditLogRepositoryInterface&MockObject $repository; + private ScheduledAuditManagerInterface&MockObject $auditManager; private AuditReverter $reverter; @@ -59,6 +62,7 @@ protected function setUp(): void $this->softDeleteHandler = $this->createMock(SoftDeleteHandlerInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); $this->dispatcher = $this->createMock(AuditDispatcherInterface::class); + $this->repository = $this->createMock(AuditLogRepositoryInterface::class); $this->auditManager = $this->createMock(ScheduledAuditManagerInterface::class); $this->em->method('getFilters')->willReturn($this->filterCollection); @@ -75,7 +79,8 @@ protected function setUp(): void $this->integrityService, $this->dispatcher, $serializer, - $this->auditManager + $this->auditManager, + $this->repository ); } @@ -327,7 +332,7 @@ public function testRevertSoftDeleteSuccess(): void public function testRevertWithCustomContext(): void { - $id = Uuid::v4(); + $id = Uuid::v7(); $log = new AuditLog( entityClass: RevertTestUser::class, entityId: '1', @@ -364,6 +369,35 @@ public function testRevertWithCustomContext(): void $this->reverter->revert($log, false, false, ['custom_key' => 'custom_val']); } + public function testRevertAccessAction(): void + { + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_ACCESS); + + $entity = new RevertTestUser(); + $this->filterCollection->method('getEnabledFilters')->willReturn([]); + $this->em->method('find')->willReturn($entity); + + $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); + + // ACTION_ACCESS should return empty changes and NOT throw exception + $changes = $this->reverter->revert($log); + self::assertEquals([], $changes); + } + + public function testRevertAlreadyReverted(): void + { + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_UPDATE, oldValues: ['name' => 'Old']); + + $this->filterCollection->method('getEnabledFilters')->willReturn([]); + $this->em->method('find')->willReturn(new RevertTestUser()); + $this->repository->method('isReverted')->with($log)->willReturn(true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('already been reverted'); + + $this->reverter->revert($log); + } + private function setLogId(AuditLog $log, Uuid $id): void { $reflection = new ReflectionClass($log); diff --git a/tests/Unit/Service/ChangeProcessorTest.php b/tests/Unit/Service/ChangeProcessorTest.php index eb043f5..bfaa726 100644 --- a/tests/Unit/Service/ChangeProcessorTest.php +++ b/tests/Unit/Service/ChangeProcessorTest.php @@ -103,7 +103,7 @@ public function testDetermineUpdateAction(): void $this->processor->determineUpdateAction(['deletedAt' => [new DateTime(), null]]) ); self::assertEquals( - AuditLogInterface::ACTION_UPDATE, + AuditLogInterface::ACTION_SOFT_DELETE, $this->processor->determineUpdateAction(['deletedAt' => [null, new DateTime()]]) ); } diff --git a/tests/Unit/Service/RevertPreviewFormatterTest.php b/tests/Unit/Service/RevertPreviewFormatterTest.php new file mode 100644 index 0000000..5f8798c --- /dev/null +++ b/tests/Unit/Service/RevertPreviewFormatterTest.php @@ -0,0 +1,95 @@ +formatter = new RevertPreviewFormatter(); + } + + public function testFormatNull(): void + { + self::assertNull($this->formatter->format(null)); + } + + public function testFormatDateTime(): void + { + $date = new DateTimeImmutable('2024-03-12 15:30:00'); + self::assertSame('2024-03-12 15:30:00', $this->formatter->format($date)); + } + + public function testFormatStringableObject(): void + { + $object = new class implements Stringable { + public function __toString(): string + { + return 'string_representation'; + } + }; + + self::assertSame('string_representation', $this->formatter->format($object)); + } + + public function testFormatObjectWithId(): void + { + $object = new class { + public function getId(): int + { + return 42; + } + }; + + $result = $this->formatter->format($object); + self::assertStringContainsString('#42', $result); + } + + public function testFormatPlainObject(): void + { + $object = new stdClass(); + self::assertSame('stdClass', $this->formatter->format($object)); + } + + public function testFormatRecursiveArray(): void + { + $data = [ + 'name' => 'Test', + 'date' => new DateTimeImmutable('2024-03-12 15:30:00'), + 'nested' => [ + 'val' => null, + 'obj' => new stdClass(), + ], + ]; + + $expected = [ + 'name' => 'Test', + 'date' => '2024-03-12 15:30:00', + 'nested' => [ + 'val' => null, + 'obj' => 'stdClass', + ], + ]; + + self::assertSame($expected, $this->formatter->format($data)); + } + + public function testFormatScalars(): void + { + self::assertSame(123, $this->formatter->format(123)); + self::assertSame('string', $this->formatter->format('string')); + self::assertTrue($this->formatter->format(true)); + } +} diff --git a/tests/Unit/Service/TransactionDrilldownServiceTest.php b/tests/Unit/Service/TransactionDrilldownServiceTest.php new file mode 100644 index 0000000..783d794 --- /dev/null +++ b/tests/Unit/Service/TransactionDrilldownServiceTest.php @@ -0,0 +1,142 @@ +repository = $this->createMock(AuditLogRepositoryInterface::class); + $this->service = new TransactionDrilldownService($this->repository); + } + + public function testGetDrilldownPageFirstPage(): void + { + $hash = 'hash123'; + $limit = 5; + + $log1 = $this->createAuditLog(); + $log2 = $this->createAuditLog(); + $logs = [$log1, $log2]; + + $this->repository->expects($this->once()) + ->method('count') + ->with(['transactionHash' => $hash]) + ->willReturn(10); + + $this->repository->expects($this->once()) + ->method('findWithFilters') + ->with(['transactionHash' => $hash], $limit + 1) + ->willReturn($logs); + + $result = $this->service->getDrilldownPage($hash, null, null, $limit); + + self::assertCount(2, $result['logs']); + self::assertSame(10, $result['totalItems']); + self::assertFalse($result['hasNextPage']); + self::assertFalse($result['hasPrevPage']); + self::assertSame((string) $log1->id, $result['firstId']); + self::assertSame((string) $log2->id, $result['lastId']); + } + + public function testGetDrilldownPageWithNextPage(): void + { + $hash = 'hash123'; + $limit = 2; + + $log1 = $this->createAuditLog(); + $log2 = $this->createAuditLog(); + $log3 = $this->createAuditLog(); // Extra record + $logs = [$log1, $log2, $log3]; + + $this->repository->method('count')->willReturn(10); + $this->repository->expects($this->once()) + ->method('findWithFilters') + ->with(['transactionHash' => $hash], $limit + 1) + ->willReturn($logs); + + $result = $this->service->getDrilldownPage($hash, null, null, $limit); + + self::assertCount(2, $result['logs']); + self::assertTrue($result['hasNextPage']); + self::assertFalse($result['hasPrevPage']); + self::assertSame((string) $log1->id, $result['firstId']); + self::assertSame((string) $log2->id, $result['lastId']); + } + + public function testGetDrilldownPageWithPrevPage(): void + { + $hash = 'hash123'; + $limit = 2; + $afterId = 'id0'; + + $log1 = $this->createAuditLog(); + $log2 = $this->createAuditLog(); + $logs = [$log1, $log2]; + + $this->repository->method('count')->willReturn(10); + $this->repository->expects($this->once()) + ->method('findWithFilters') + ->with(['transactionHash' => $hash, 'afterId' => $afterId], $limit + 1) + ->willReturn($logs); + + $result = $this->service->getDrilldownPage($hash, $afterId, null, $limit); + + self::assertTrue($result['hasPrevPage']); + self::assertFalse($result['hasNextPage']); + } + + public function testGetDrilldownPagePaginatingBackwards(): void + { + $hash = 'hash123'; + $limit = 2; + $beforeId = (string) Uuid::v7(); + + $log3 = $this->createAuditLog(); // Extra record (newer) + $log4 = $this->createAuditLog(); + $log5 = $this->createAuditLog(); + $logs = [$log3, $log4, $log5]; + + $this->repository->method('count')->willReturn(10); + $this->repository->expects($this->once()) + ->method('findWithFilters') + ->with(['transactionHash' => $hash, 'beforeId' => $beforeId], $limit + 1) + ->willReturn($logs); + + $result = $this->service->getDrilldownPage($hash, null, $beforeId, $limit); + + self::assertCount(2, $result['logs']); + self::assertTrue($result['hasPrevPage']); // Sliced off log3 + self::assertTrue($result['hasNextPage']); + self::assertSame((string) $log4->id, $result['firstId']); + self::assertSame((string) $log5->id, $result['lastId']); + } + + private function createAuditLog(): AuditLog + { + $log = new AuditLog('Class', '1', 'create'); + + $reflection = new ReflectionClass($log); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($log, Uuid::v7()); + + return $log; + } +}