From 975e1c8c849b515eb81f4a56958d6331d371859a Mon Sep 17 00:00:00 2001 From: rahul chavan Date: Sun, 8 Mar 2026 11:49:54 +0530 Subject: [PATCH] BC:replace doctrine transport with async-capable database transport --- .codacy.yml | 2 + CHANGELOG.md | 17 +- docs/configuration.md | 19 +- docs/revert-feature.md | 14 + docs/symfony-audit-transports.md | 82 ++- src/Command/AuditRevertCommand.php | 9 +- src/Contract/AuditReverterInterface.php | 1 + .../ScheduledAuditManagerInterface.php | 6 + .../AuditTrailExtension.php | 59 ++- src/DependencyInjection/Configuration.php | 20 +- src/EventSubscriber/AuditSubscriber.php | 9 +- src/Factory/AuditLogMessageFactory.php | 56 ++ src/Message/PersistAuditLogMessage.php | 62 +++ src/MessageHandler/PersistAuditLogHandler.php | 50 ++ src/Resources/config/services.yaml | 2 + src/Service/AuditReverter.php | 33 +- src/Service/AuditService.php | 24 +- src/Service/EntityDataExtractor.php | 8 +- src/Service/ScheduledAuditManager.php | 19 + src/Service/ValueSerializer.php | 32 +- src/Transport/AsyncDatabaseAuditTransport.php | 43 ++ src/Transport/QueueAuditTransport.php | 9 +- .../AsyncDatabaseAndQueueConflictTest.php | 95 ++++ .../Event/AuditLogCreatedEventTest.php | 2 +- tests/Functional/TestKernel.php | 2 +- tests/Functional/TransactionSafetyTest.php | 6 +- .../AuditTrailExtensionTest.php | 12 +- tests/Unit/AbstractAuditTestCase.php | 24 + tests/Unit/Command/AuditRevertCommandTest.php | 22 + .../EventSubscriber/AuditSubscriberTest.php | 29 +- .../MockScheduledAuditManager.php | 17 + .../Factory/AuditLogMessageFactoryTest.php | 101 ++++ .../PersistAuditLogHandlerTest.php | 102 ++++ tests/Unit/Query/AuditReaderTest.php | 85 +-- .../Service/AuditIntegrityServiceTest.php | 58 ++- tests/Unit/Service/AuditRendererTest.php | 15 +- .../Unit/Service/AuditRevertIntegrityTest.php | 2 + tests/Unit/Service/AuditReverterTest.php | 60 ++- tests/Unit/Service/AuditServiceTest.php | 4 +- .../Unit/Service/EntityDataExtractorTest.php | 4 +- tests/Unit/Service/MetadataCacheTest.php | 36 +- tests/Unit/Service/TestEntity.php | 6 +- tests/Unit/Service/UserResolverTest.php | 222 ++++---- tests/Unit/Service/ValueSerializerTest.php | 491 ++++-------------- .../AsyncDatabaseAuditTransportTest.php | 88 ++++ .../Transport/QueueAuditTransportTest.php | 85 ++- tests/bootstrap.php | 7 + 47 files changed, 1431 insertions(+), 720 deletions(-) create mode 100644 src/Factory/AuditLogMessageFactory.php create mode 100644 src/Message/PersistAuditLogMessage.php create mode 100644 src/MessageHandler/PersistAuditLogHandler.php create mode 100644 src/Transport/AsyncDatabaseAuditTransport.php create mode 100644 tests/Functional/AsyncDatabaseAndQueueConflictTest.php create mode 100644 tests/Unit/Factory/AuditLogMessageFactoryTest.php create mode 100644 tests/Unit/MessageHandler/PersistAuditLogHandlerTest.php create mode 100644 tests/Unit/Transport/AsyncDatabaseAuditTransportTest.php diff --git a/.codacy.yml b/.codacy.yml index 373f726..5363c33 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -14,6 +14,8 @@ exclude_paths: # Global static analysis ignores - "src/Resources/**" - "src/AuditTrailBundle.php" - "src/Entity/AuditLog.php" + - "src/Message/AuditLogMessage.php" + - "src/Message/PersistAuditLogMessage.php" - "vendor/**" - "var/**" - "bin/**" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5208e..6fc4cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [2.1.0] + +### 2.1.0 Breaking Changes + +- **Transport Configuration**: The `doctrine` transport config key has been replaced with `database`. The new `database` key strictly requires an options array: `database: { enabled: true, async: false }` rather than a scalar boolean. + +### 2.1.0 New Features + +- **Asynchronous Database Transport**: Audit logs can now be dispatched to the database asynchronously via Symfony Messenger by configuring `database: { enabled: true, async: true }`. This utilizes a dedicated `audit_trail_database` route and a built-in `PersistAuditLogHandler` to insert the records, preventing conflict with the external `queue` transport. +- **Collection Serialization Tuning**: Introduced a tiered strategy for Doctrine collections (`lazy`, `ids_only`, `eager`) with configurable `max_collection_items` (default 100). This allows developers to balance audit detail against N+1 query safety and log bloat. + +--- + ## [2.0.0] This major release represents a complete architectural modernization of the bundle, leveraging **PHP 8.4** features and introducing a **Strict Contract Layer** for better extensibility and performance. -### Breaking Changes +### 2.0.0 Breaking Changes - **PHP 8.4 Required**: The bundle now requires PHP 8.4+ for property hooks, asymmetric visibility, and typed class constants. - **Symfony 7.4+ Required**: Updated to leverage modern Symfony DI attributes and framework features. @@ -25,7 +38,7 @@ This major release represents a complete architectural modernization of the bund - **Transport Interface**: `AuditTransportInterface::send()` now requires a mandatory `$context` array parameter for phase-aware dispatching (`on_flush` / `post_flush`). The `supports(string $phase, array $context)` method was added. - **Configuration Defaults**: `audited_methods` defaults to `['GET']`. `defer_transport_until_commit` defaults to `true`. `fallback_to_database` defaults to `true`. -### New Features +### 2.0.0 New Features - **PHP 8.4 Native Features**: - **Property Hooks**: Used throughout `AuditLog` for real-time validation of IP addresses, action types, and entity class names directly on the entity. `AuditEntry` uses read-only property hooks for all accessors. diff --git a/docs/configuration.md b/docs/configuration.md index 574e7e0..3be2783 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,9 +27,16 @@ audit_trail: enable_soft_delete: true soft_delete_field: 'deletedAt' + # Collection Serialization Performance + # ----------------------------------- + collection_serialization_mode: 'lazy' + max_collection_items: 100 + transports: # Store logs in the local database - doctrine: true + database: + enabled: true + async: false # Set to true to persist via Messenger worker (requires 'audit_trail_database' transport) # Send logs to an external API http: @@ -66,3 +73,13 @@ audit_trail: # If false (default), transport errors are logged but execution continues. fail_on_transport_error: false ``` + +## Collection Serialization Guide + +Choose the mode that best fits your performance and audit requirements: + +| Mode | Database Impact | Audit Detail | Recommended Use Case | +| :--- | :--- | :--- | :--- | +| **`lazy`** (Default) | **None** (Zero queries) | Low (shows placeholder) | High-traffic apps where performance is the #1 priority. | +| **`ids_only`** | **Low** (1 targeted query) | High (shows all item IDs) | **Best for most apps.** Relationship visibility with low memory. | +| **`eager`** | **Medium** (Full hydration) | High (Full entities) | Only if custom code needs to inspect full entity state. | diff --git a/docs/revert-feature.md b/docs/revert-feature.md index dc03fa7..3abfccd 100644 --- a/docs/revert-feature.md +++ b/docs/revert-feature.md @@ -8,6 +8,9 @@ php bin/console audit:revert 123 # Revert with custom context (JSON) php bin/console audit:revert 123 --context='{"reason": "Accidental deletion", "ticket": "T-101"}' + +# Revert without silencing automatic audit logs (Keep technical logs) +php bin/console audit:revert 123 --noisy ``` ## Custom Context on Revert @@ -23,6 +26,17 @@ $auditReverter->revert($log, false, false, [ The bundle also **automatically** adds `reverted_log_id` to the context of the new audit log, linking it back to the original entry. +## Controlling Log Redundancy + +By default, `AuditReverter` **silences** the standard `AuditSubscriber` during a revert. This prevents a duplicate `update` log from being created alongside the explicit `revert` log. + +For scenarios requiring full technical transparency (e.g., strict forensic compliance), you can disable this silencing: + +```php +// Pass 'false' as the 5th parameter to keep standard update logs enabled +$auditReverter->revert($log, false, false, [], false); +``` + ## Why it's "Safe" - **Association Awareness**: Automatically handles entity relations and collections. diff --git a/docs/symfony-audit-transports.md b/docs/symfony-audit-transports.md index 77cbbf3..13bc333 100644 --- a/docs/symfony-audit-transports.md +++ b/docs/symfony-audit-transports.md @@ -2,9 +2,47 @@ AuditTrailBundle supports multiple transports to dispatch audit logs. This allows you to offload audit processing to external services or queues, keeping your main application fast. -## 1. Queue Transport (Symfony Messenger) +> [!NOTE] +> **Messenger Confusion Warning:** The bundle utilizes Symfony Messenger for **two different features**. +> +> 1. `database: { async: true }`: Designed to save logs to your *local database* asynchronously via an internal worker. +> 2. `queue: { enabled: true }`: Designed to publish logs to an *external system* (so other microservices or tools like ELK can consume them). +> +> They can be used simultaneously without conflict because they route to different internal queues. -The Queue transport dispatches audit logs as messages via Symfony Messenger. This is the recommended way to handle audits in high-traffic applications. +## 1. Database Transport (Async) + +By default, the `database` transport persists logs synchronously at the end of the Doctrine transaction. For high-traffic applications, you can offload this database write to a background worker. + +### Configuration + +Enable async mode in `config/packages/audit_trail.yaml`: + +```yaml +audit_trail: + transports: + database: + enabled: true + async: true # Offloads inserts to Messenger +``` + +You must explicitly define a transport named `audit_trail_database` in `config/packages/messenger.yaml`: + +```yaml +framework: + messenger: + transports: + # Internal bundle worker will consume from this transport + audit_trail_database: '%env(MESSENGER_TRANSPORT_DSN)%' +``` + +*(The bundle auto-registers `PersistAuditLogHandler` to consume from this transport and insert the records into the database).* + +--- + +## 2. Queue Transport (External Delivery) + +The `queue` transport acts as a webhook publisher. It dispatches a strictly-typed DTO (`AuditLogMessage`) to the bus. You must write your own external consumer to ingest these messages (e.g., Logstash, another microservice). ### Configuration for Queue transport @@ -24,9 +62,8 @@ You must define a transport named `audit_trail` in `config/packages/messenger.ya framework: messenger: transports: + # Your external service/worker will consume from this transport audit_trail: '%env(MESSENGER_TRANSPORT_DSN)%' - - ``` ### Advanced Usage: Messenger Stamps @@ -145,14 +182,42 @@ The transport sends a `POST` request with a JSON body: --- -## 3. Doctrine Transport (Default) +## 3. Database Transport (Default) + +The Database transport stores logs in your local database. It is enabled by default. -The Doctrine transport stores logs in your local database. It is enabled by default. +### Sync Mode (Default) + +Logs are persisted directly during the Doctrine lifecycle: ```yaml audit_trail: transports: - doctrine: true + database: + enabled: true + async: false +``` + +### Async Mode + +Logs are dispatched via Symfony Messenger and persisted by a built-in handler (`PersistAuditLogHandler`). +This is useful for high-traffic applications where you want to offload DB writes to a worker. + +```yaml +audit_trail: + transports: + database: + enabled: true + async: true +``` + +You must define a transport named `audit_trail_database` in `config/packages/messenger.yaml`: + +```yaml +framework: + messenger: + transports: + audit_trail_database: '%env(MESSENGER_TRANSPORT_DSN)%' ``` --- @@ -164,7 +229,8 @@ You can enable multiple transports simultaneously. The bundle will automatically ```yaml audit_trail: transports: - doctrine: true + database: + enabled: true queue: enabled: true ``` diff --git a/src/Command/AuditRevertCommand.php b/src/Command/AuditRevertCommand.php index 32d0a80..0486200 100644 --- a/src/Command/AuditRevertCommand.php +++ b/src/Command/AuditRevertCommand.php @@ -60,6 +60,12 @@ protected function configure(): void InputOption::VALUE_REQUIRED, 'Custom context for the revert audit log (JSON string)', '{}' + ) + ->addOption( + 'noisy', + null, + InputOption::VALUE_NONE, + 'Do not silence the AuditSubscriber (useful for strict compliance or debugging)' ); } @@ -111,7 +117,8 @@ private function performRevert(SymfonyStyle $io, InputInterface $input, AuditLog } try { - $changes = $this->auditReverter->revert($log, $dryRun, $force, $context); + $silence = !((bool) $input->getOption('noisy')); + $changes = $this->auditReverter->revert($log, $dryRun, $force, $context, $silence); $this->displayRevertResult($io, $changes, $raw); return Command::SUCCESS; diff --git a/src/Contract/AuditReverterInterface.php b/src/Contract/AuditReverterInterface.php index 5ad8b8c..2002e75 100644 --- a/src/Contract/AuditReverterInterface.php +++ b/src/Contract/AuditReverterInterface.php @@ -29,5 +29,6 @@ public function revert( bool $dryRun = false, bool $force = false, array $context = [], + bool $silenceSubscriber = true, ): array; } diff --git a/src/Contract/ScheduledAuditManagerInterface.php b/src/Contract/ScheduledAuditManagerInterface.php index 0f05d18..da9e174 100644 --- a/src/Contract/ScheduledAuditManagerInterface.php +++ b/src/Contract/ScheduledAuditManagerInterface.php @@ -23,4 +23,10 @@ public function schedule(object $entity, AuditLog $audit, bool $isInsert): void; public function addPendingDeletion(object $entity, array $data, bool $isManaged): void; public function clear(): void; + + public function disable(): void; + + public function enable(): void; + + public function isEnabled(): bool; } diff --git a/src/DependencyInjection/AuditTrailExtension.php b/src/DependencyInjection/AuditTrailExtension.php index f713099..3ca1fbb 100644 --- a/src/DependencyInjection/AuditTrailExtension.php +++ b/src/DependencyInjection/AuditTrailExtension.php @@ -7,6 +7,9 @@ use LogicException; use Override; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Factory\AuditLogMessageFactory; +use Rcsofttech\AuditTrailBundle\MessageHandler\PersistAuditLogHandler; +use Rcsofttech\AuditTrailBundle\Transport\AsyncDatabaseAuditTransport; use Rcsofttech\AuditTrailBundle\Transport\ChainAuditTransport; use Rcsofttech\AuditTrailBundle\Transport\DoctrineAuditTransport; use Rcsofttech\AuditTrailBundle\Transport\HttpAuditTransport; @@ -47,8 +50,10 @@ public function load(array $configs, ContainerBuilder $container): void * integrity: array{enabled: bool, secret: ?string, algorithm: string}, * cache_pool: ?string, * audited_methods: array, + * collection_serialization_mode: string, + * max_collection_items: int, * transports: array{ - * doctrine: bool, + * database: array{enabled: bool, async: bool}, * http: array{enabled: bool, endpoint: string, headers: array, timeout: int}, * queue: array{enabled: bool, api_key: ?string, bus: ?string} * } @@ -72,6 +77,8 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('audit_trail.fallback_to_database', $config['fallback_to_database']); $container->setParameter('audit_trail.cache_pool', $config['cache_pool']); $container->setParameter('audit_trail.audited_methods', $config['audited_methods']); + $container->setParameter('audit_trail.collection_serialization_mode', $config['collection_serialization_mode']); + $container->setParameter('audit_trail.max_collection_items', $config['max_collection_items']); if ($config['cache_pool'] !== null) { $container->setAlias('rcsofttech_audit_trail.cache', $config['cache_pool']); @@ -110,7 +117,7 @@ public function prepend(ContainerBuilder $container): void /** * @param array{ * transports: array{ - * doctrine: bool, + * database: array{enabled: bool, async: bool}, * http: array{enabled: bool, endpoint: string, headers: array, timeout: int}, * queue: array{enabled: bool, api_key: ?string, bus: ?string} * } @@ -120,8 +127,8 @@ private function configureTransports(array $config, ContainerBuilder $container) { $transports = []; - if ($config['transports']['doctrine'] === true) { - $transports[] = $this->registerDoctrineTransport($container); + if ($config['transports']['database']['enabled'] === true) { + $transports[] = $this->registerDatabaseTransport($container, $config['transports']['database']); } if ($config['transports']['http']['enabled'] === true) { @@ -135,9 +142,16 @@ private function configureTransports(array $config, ContainerBuilder $container) $this->registerMainTransport($container, $transports); } - private function registerDoctrineTransport(ContainerBuilder $container): string + /** + * @param array{enabled: bool, async: bool} $config + */ + private function registerDatabaseTransport(ContainerBuilder $container, array $config): string { - $id = 'rcsofttech_audit_trail.transport.doctrine'; + if ($config['async']) { + return $this->registerAsyncDatabaseTransport($container); + } + + $id = 'rcsofttech_audit_trail.transport.database'; $container->register($id, DoctrineAuditTransport::class) ->setAutowired(true) ->addTag('audit_trail.transport'); @@ -145,6 +159,37 @@ private function registerDoctrineTransport(ContainerBuilder $container): string return $id; } + private function registerAsyncDatabaseTransport(ContainerBuilder $container): string + { + if (!interface_exists(MessageBusInterface::class)) { + throw new LogicException('To use async database transport, you must install the symfony/messenger package.'); + } + + $this->registerMessageFactory($container); + + $handlerId = 'rcsofttech_audit_trail.handler.persist_audit_log'; + $container->register($handlerId, PersistAuditLogHandler::class) + ->setAutowired(true) + ->addTag('messenger.message_handler'); + + $id = 'rcsofttech_audit_trail.transport.async_database'; + $container->register($id, AsyncDatabaseAuditTransport::class) + ->setAutowired(true) + ->addTag('audit_trail.transport'); + + return $id; + } + + private function registerMessageFactory(ContainerBuilder $container): void + { + if ($container->has(AuditLogMessageFactory::class)) { + return; + } + + $container->register(AuditLogMessageFactory::class, AuditLogMessageFactory::class) + ->setAutowired(true); + } + /** * @param array{enabled: bool, endpoint: string, headers: array, timeout: int} $config */ @@ -174,6 +219,8 @@ private function registerQueueTransport(ContainerBuilder $container, array $conf throw new LogicException('To use the Queue transport, you must install the symfony/messenger package.'); } + $this->registerMessageFactory($container); + $id = 'rcsofttech_audit_trail.transport.queue'; $definition = $container->register($id, QueueAuditTransport::class) ->setAutowired(true) diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index d0d1924..097fc01 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -56,6 +56,16 @@ private function configureBaseSettings(ArrayNodeDefinition $rootNode): void ->scalarPrototype()->end() ->defaultValue(['GET']) ->end() + ->enumNode('collection_serialization_mode') + ->values(['lazy', 'ids_only', 'eager']) + ->defaultValue('lazy') + ->info('Determines how to serialize Doctrine collections: lazy (placeholder), ids_only (query IDs), or eager (initialize).') + ->end() + ->integerNode('max_collection_items') + ->defaultValue(100) + ->min(1) + ->info('Maximum number of items to serialize in a collection.') + ->end() ->end(); } @@ -66,7 +76,15 @@ private function configureTransports(ArrayNodeDefinition $rootNode): void ->arrayNode('transports') ->addDefaultsIfNotSet() ->children() - ->booleanNode('doctrine')->defaultTrue()->end() + ->arrayNode('database') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->booleanNode('async')->defaultFalse() + ->info('When true, audit logs are persisted via a Messenger worker instead of synchronously.') + ->end() + ->end() + ->end() ->arrayNode('http') ->canBeEnabled() ->children() diff --git a/src/EventSubscriber/AuditSubscriber.php b/src/EventSubscriber/AuditSubscriber.php index 771ad1f..efb560b 100644 --- a/src/EventSubscriber/AuditSubscriber.php +++ b/src/EventSubscriber/AuditSubscriber.php @@ -46,18 +46,17 @@ public function __construct( private readonly EntityIdResolverInterface $idResolver, private readonly ?LoggerInterface $logger = null, private readonly bool $enableHardDelete = true, - private readonly bool $enabled = true, ) { } public function isEnabled(): bool { - return $this->enabled; + return $this->auditManager->isEnabled(); } public function onFlush(OnFlushEventArgs $args): void { - if (!$this->enabled || $this->isFlushing || $this->recursionDepth > 0) { + if (!$this->auditManager->isEnabled() || $this->isFlushing || $this->recursionDepth > 0) { return; } @@ -81,7 +80,7 @@ public function onFlush(OnFlushEventArgs $args): void public function postLoad(PostLoadEventArgs $args): void { - if (!$this->enabled) { + if (!$this->auditManager->isEnabled()) { return; } @@ -90,7 +89,7 @@ public function postLoad(PostLoadEventArgs $args): void public function postFlush(PostFlushEventArgs $args): void { - if (!$this->enabled || $this->isFlushing || $this->recursionDepth > 0) { + if (!$this->auditManager->isEnabled() || $this->isFlushing || $this->recursionDepth > 0) { return; } diff --git a/src/Factory/AuditLogMessageFactory.php b/src/Factory/AuditLogMessageFactory.php new file mode 100644 index 0000000..7fa5ab4 --- /dev/null +++ b/src/Factory/AuditLogMessageFactory.php @@ -0,0 +1,56 @@ + $context + */ + public function createQueueMessage(AuditLog $log, array $context = []): AuditLogMessage + { + $entityId = $this->resolveEntityId($log, $context); + + return AuditLogMessage::createFromAuditLog($log, $entityId); + } + + /** + * Create a message for the async Database transport (internal persistence). + * + * @param array $context + */ + public function createPersistMessage(AuditLog $log, array $context = []): PersistAuditLogMessage + { + $entityId = $this->resolveEntityId($log, $context); + + return PersistAuditLogMessage::createFromAuditLog($log, $entityId); + } + + /** + * @param array $context + */ + private function resolveEntityId(AuditLog $log, array $context): string + { + return $this->idResolver->resolve($log, $context) ?? $log->entityId; + } +} diff --git a/src/Message/PersistAuditLogMessage.php b/src/Message/PersistAuditLogMessage.php new file mode 100644 index 0000000..3269fb1 --- /dev/null +++ b/src/Message/PersistAuditLogMessage.php @@ -0,0 +1,62 @@ +|null */ + public ?array $oldValues, + /** @var array|null */ + public ?array $newValues, + /** @var array|null */ + public ?array $changedFields, + public ?string $userId, + public ?string $username, + public ?string $ipAddress, + public ?string $userAgent, + public ?string $transactionHash, + public string $createdAt, + public ?string $signature = null, + /** @var array */ + public array $context = [], + ) { + } + + public static function createFromAuditLog(AuditLog $log, ?string $resolvedEntityId = null): self + { + return new self( + entityClass: $log->entityClass, + entityId: $resolvedEntityId ?? $log->entityId, + action: $log->action, + oldValues: $log->oldValues, + newValues: $log->newValues, + changedFields: $log->changedFields, + userId: $log->userId, + username: $log->username, + ipAddress: $log->ipAddress, + userAgent: $log->userAgent, + transactionHash: $log->transactionHash, + createdAt: $log->createdAt->format(DateTimeInterface::ATOM), + signature: $log->signature, + context: $log->context, + ); + } +} diff --git a/src/MessageHandler/PersistAuditLogHandler.php b/src/MessageHandler/PersistAuditLogHandler.php new file mode 100644 index 0000000..24f40e7 --- /dev/null +++ b/src/MessageHandler/PersistAuditLogHandler.php @@ -0,0 +1,50 @@ +entityClass, + entityId: $message->entityId, + action: $message->action, + createdAt: new DateTimeImmutable($message->createdAt), + oldValues: $message->oldValues, + newValues: $message->newValues, + changedFields: $message->changedFields, + transactionHash: $message->transactionHash, + userId: $message->userId, + username: $message->username, + ipAddress: $message->ipAddress, + userAgent: $message->userAgent, + context: $message->context, + signature: $message->signature, + ); + + $this->em->persist($log); + $this->em->flush(); + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 61f048f..7eda560 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -17,6 +17,8 @@ services: $cache: '@?rcsofttech_audit_trail.cache' $auditedMethods: '%audit_trail.audited_methods%' $fallbackToDatabase: '%audit_trail.fallback_to_database%' + $collectionSerializationMode: '%audit_trail.collection_serialization_mode%' + $maxCollectionItems: '%audit_trail.max_collection_items%' _instanceof: Rcsofttech\AuditTrailBundle\Contract\AuditVoterInterface: diff --git a/src/Service/AuditReverter.php b/src/Service/AuditReverter.php index e77eabe..e590cb3 100644 --- a/src/Service/AuditReverter.php +++ b/src/Service/AuditReverter.php @@ -12,6 +12,7 @@ use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditReverterInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; @@ -32,6 +33,7 @@ public function __construct( private AuditIntegrityServiceInterface $integrityService, private AuditDispatcherInterface $dispatcher, private ValueSerializerInterface $serializer, + private ScheduledAuditManagerInterface $auditManager, ) { } @@ -46,6 +48,7 @@ public function revert( bool $dryRun = false, bool $force = false, array $context = [], + bool $silenceSubscriber = true, ): array { if ($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')); @@ -63,7 +66,7 @@ public function revert( return $changes; } - $this->applyAndPersist($entity, $log, $changes, $context); + $this->applyAndPersist($entity, $log, $changes, $context, $silenceSubscriber); return $changes; } @@ -85,11 +88,16 @@ private function determineChanges(AuditLog $log, object $entity, bool $force): a * @param array $changes * @param array $context */ - private function applyAndPersist(object $entity, AuditLog $log, array $changes, array $context): void - { + private function applyAndPersist( + object $entity, + 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) { + $this->em->wrapInTransaction(function () use ($entity, $isDelete, $log, $changes, $context, $silenceSubscriber) { if ($isDelete) { $this->em->remove($entity); } else { @@ -97,7 +105,18 @@ private function applyAndPersist(object $entity, AuditLog $log, array $changes, $this->em->persist($entity); } - $this->em->flush(); + if ($silenceSubscriber) { + $this->auditManager->disable(); + } + + try { + $this->em->flush(); + } finally { + if ($silenceSubscriber) { + $this->auditManager->enable(); + } + } + $this->createRevertAuditLog($entity, $log, $changes, $isDelete, $context); }); } @@ -139,6 +158,10 @@ private function createRevertAuditLog( $revertContext ); + if ($revertLog->entityId === AuditLogInterface::PENDING_ID) { + $revertLog->entityId = $log->entityId; + } + $this->dispatcher->dispatch($revertLog, $this->em, 'post_flush'); } diff --git a/src/Service/AuditService.php b/src/Service/AuditService.php index cdd82d0..8d025c4 100644 --- a/src/Service/AuditService.php +++ b/src/Service/AuditService.php @@ -24,29 +24,29 @@ use const JSON_THROW_ON_ERROR; -final class AuditService implements AuditServiceInterface +final readonly class AuditService implements AuditServiceInterface { private const string PENDING_ID = 'pending'; private const int MAX_CONTEXT_BYTES = 65_536; - private readonly DateTimeZone $tz; + private DateTimeZone $tz; /** * @param iterable $voters */ public function __construct( - private readonly EntityManagerInterface $entityManager, - private readonly ClockInterface $clock, - private readonly TransactionIdGenerator $transactionIdGenerator, - private readonly EntityDataExtractor $dataExtractor, - private readonly AuditMetadataManagerInterface $metadataManager, - private readonly ContextResolverInterface $contextResolver, - private readonly EntityIdResolverInterface $idResolver, - private readonly ?LoggerInterface $logger = null, - private readonly string $timezone = 'UTC', + private EntityManagerInterface $entityManager, + private ClockInterface $clock, + private TransactionIdGenerator $transactionIdGenerator, + private EntityDataExtractor $dataExtractor, + private AuditMetadataManagerInterface $metadataManager, + private ContextResolverInterface $contextResolver, + private EntityIdResolverInterface $idResolver, + private ?LoggerInterface $logger = null, + private string $timezone = 'UTC', #[AutowireIterator('audit_trail.voter')] - private readonly iterable $voters = [], + private iterable $voters = [], ) { $this->tz = new DateTimeZone($this->timezone); } diff --git a/src/Service/EntityDataExtractor.php b/src/Service/EntityDataExtractor.php index b398e26..332874a 100644 --- a/src/Service/EntityDataExtractor.php +++ b/src/Service/EntityDataExtractor.php @@ -13,13 +13,13 @@ use function array_key_exists; use function in_array; -class EntityDataExtractor +readonly class EntityDataExtractor { public function __construct( - private readonly EntityManagerInterface $entityManager, + private EntityManagerInterface $entityManager, private ValueSerializerInterface $serializer, - private readonly MetadataCache $metadataCache, - private readonly ?LoggerInterface $logger = null, + private MetadataCache $metadataCache, + private ?LoggerInterface $logger = null, ) { } diff --git a/src/Service/ScheduledAuditManager.php b/src/Service/ScheduledAuditManager.php index 2a5cac2..3889db6 100644 --- a/src/Service/ScheduledAuditManager.php +++ b/src/Service/ScheduledAuditManager.php @@ -29,9 +29,28 @@ final class ScheduledAuditManager implements ScheduledAuditManagerInterface /** @var list, is_managed: bool}> */ public private(set) array $pendingDeletions = []; + private bool $enabled; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, + bool $enabled = true, ) { + $this->enabled = $enabled; + } + + public function disable(): void + { + $this->enabled = false; + } + + public function enable(): void + { + $this->enabled = true; + } + + public function isEnabled(): bool + { + return $this->enabled; } public function schedule( diff --git a/src/Service/ValueSerializer.php b/src/Service/ValueSerializer.php index 1653774..7079ee0 100644 --- a/src/Service/ValueSerializer.php +++ b/src/Service/ValueSerializer.php @@ -24,10 +24,10 @@ { private const int MAX_SERIALIZATION_DEPTH = 5; - private const int MAX_COLLECTION_ITEMS = 100; - public function __construct( private ?LoggerInterface $logger = null, + private string $collectionSerializationMode = 'lazy', + private int $maxCollectionItems = 100, ) { } @@ -76,40 +76,46 @@ public function serializeAssociation(mixed $value): mixed */ private function serializeCollection(Collection $value, int $depth, bool $onlyIdentifiers = false): mixed { - // Optimization: Prevent N+1 queries by checking if the collection is initialized. if ($value instanceof PersistentCollection && !$value->isInitialized()) { - return [ - '_state' => 'uninitialized', - '_total_count' => 'unknown', - ]; + if ($this->collectionSerializationMode === 'lazy') { + return [ + '_state' => 'uninitialized', + '_total_count' => 'unknown', + ]; + } + + if ($this->collectionSerializationMode === 'eager') { + $value->initialize(); + } } $count = $value->count(); - if ($count > self::MAX_COLLECTION_ITEMS) { + $forceOnlyIdentifiers = $onlyIdentifiers || $this->collectionSerializationMode === 'ids_only'; + + if ($count > $this->maxCollectionItems) { $this->logger?->warning('Collection exceeds max items for audit', [ 'count' => $count, - 'max' => self::MAX_COLLECTION_ITEMS, + 'max' => $this->maxCollectionItems, ]); - $sample = $value->slice(0, self::MAX_COLLECTION_ITEMS); + $sample = $value->slice(0, $this->maxCollectionItems); return [ '_truncated' => true, '_total_count' => $count, '_sample' => array_map( - fn ($item) => $onlyIdentifiers && is_object($item) + fn ($item) => $forceOnlyIdentifiers && is_object($item) ? $this->extractEntityIdentifier($item) : $this->serialize($item, $depth + 1), $sample ), ]; } - $items = $value->toArray(); return array_map( - fn ($item) => $onlyIdentifiers && is_object($item) + fn ($item) => $forceOnlyIdentifiers && is_object($item) ? $this->extractEntityIdentifier($item) : $this->serialize($item, $depth + 1), $items diff --git a/src/Transport/AsyncDatabaseAuditTransport.php b/src/Transport/AsyncDatabaseAuditTransport.php new file mode 100644 index 0000000..c7d474f --- /dev/null +++ b/src/Transport/AsyncDatabaseAuditTransport.php @@ -0,0 +1,43 @@ + $context + */ + #[Override] + public function send(AuditLog $log, array $context = []): void + { + $message = $this->messageFactory->createPersistMessage($log, $context); + + $this->bus->dispatch($message); + } + + #[Override] + public function supports(string $phase, array $context = []): bool + { + return $phase === 'post_flush' || $phase === 'post_load'; + } +} diff --git a/src/Transport/QueueAuditTransport.php b/src/Transport/QueueAuditTransport.php index e04ff45..24ee299 100644 --- a/src/Transport/QueueAuditTransport.php +++ b/src/Transport/QueueAuditTransport.php @@ -7,10 +7,9 @@ use Override; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; -use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Event\AuditMessageStampEvent; -use Rcsofttech\AuditTrailBundle\Message\AuditLogMessage; +use Rcsofttech\AuditTrailBundle\Factory\AuditLogMessageFactory; use Rcsofttech\AuditTrailBundle\Message\Stamp\ApiKeyStamp; use Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp; use Symfony\Component\Messenger\MessageBusInterface; @@ -24,7 +23,7 @@ public function __construct( private readonly MessageBusInterface $bus, private readonly EventDispatcherInterface $eventDispatcher, private readonly AuditIntegrityServiceInterface $integrityService, - private readonly EntityIdResolverInterface $idResolver, + private readonly AuditLogMessageFactory $messageFactory, private readonly ?string $apiKey = null, ) { } @@ -35,9 +34,7 @@ public function __construct( #[Override] public function send(AuditLog $log, array $context = []): void { - $entityId = $this->idResolver->resolve($log, $context) ?? $log->entityId; - - $message = AuditLogMessage::createFromAuditLog($log, $entityId); + $message = $this->messageFactory->createQueueMessage($log, $context); $event = new AuditMessageStampEvent($message); $this->eventDispatcher->dispatch($event); diff --git a/tests/Functional/AsyncDatabaseAndQueueConflictTest.php b/tests/Functional/AsyncDatabaseAndQueueConflictTest.php new file mode 100644 index 0000000..3b761f6 --- /dev/null +++ b/tests/Functional/AsyncDatabaseAndQueueConflictTest.php @@ -0,0 +1,95 @@ +createMock(EntityManagerInterface::class); + $idResolver = $this->createMock(EntityIdResolverInterface::class); + $idResolver->method('resolve')->willReturn('1'); + + $factory = new AuditLogMessageFactory($idResolver); + $eventDispatcher = new EventDispatcher(); // Simple dispatcher for testing + $integrityService = $this->createMock(AuditIntegrityServiceInterface::class); + $integrityService->method('isEnabled')->willReturn(false); + + // mock the MessageBus to intercept all dispatches globally. + $mockBus = $this->createMock(MessageBusInterface::class); + $dispatchedMessages = []; + + // expect the bus to be called EXACTLY 2 times. + // Once with PersistAuditLogMessage, once with AuditLogMessage. + $mockBus->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(static function ($message) use (&$dispatchedMessages) { + $dispatchedMessages[] = $message; + + return new Envelope($message ?? current($dispatchedMessages)); // Fallback + }); + + // Instantiate both transports identically to how the bundle DI extension wires them + $asyncDbTransport = new AsyncDatabaseAuditTransport($mockBus, $factory); + $queueTransport = new QueueAuditTransport($mockBus, $eventDispatcher, $integrityService, $factory); + + // Combine them into the ChainAuditTransport, just like `AuditTrailExtension` does + $chainTransport = new ChainAuditTransport([$asyncDbTransport, $queueTransport]); + + // Build the dispatcher + $auditDispatcher = new AuditDispatcher($chainTransport); + + // Act: Create an AuditLog and dispatch it through the pipeline + $log = new AuditLog(TestEntity::class, '1', 'create'); + $log->signature = 'mock-signature-hash'; + $log->context = ['test' => true]; + + // use 'post_flush' because AsyncDatabaseTransport specifically listens to post_flush + $auditDispatcher->dispatch($log, $em, 'post_flush'); + + // Verify exactly 2 distinct messages were dispatched + self::assertCount(2, $dispatchedMessages, 'Exactly two messages should be dispatched to the bus.'); + + $hasPersistMessage = false; + $hasQueueMessage = false; + + foreach ($dispatchedMessages as $message) { + if ($message instanceof PersistAuditLogMessage) { + $hasPersistMessage = true; + self::assertSame('create', $message->action, 'Persist message should retain the action.'); + self::assertSame('mock-signature-hash', $message->signature, 'Persist message MUST carry the signature to the database.'); + self::assertSame(['test' => true], $message->context, 'Persist message MUST carry context.'); + } + if ($message instanceof AuditLogMessage) { + $hasQueueMessage = true; + self::assertSame('create', $message->action, 'Queue message should retain the action.'); + // AuditLogMessage does not have a signature property, it relies on stamps (verified in Unit tests) + } + } + + // Each transport fired exactly its intended DTO without interfering or doubling up. + self::assertTrue($hasPersistMessage, 'A PersistAuditLogMessage must be dispatched to save the data in the DB worker.'); + self::assertTrue($hasQueueMessage, 'An AuditLogMessage must be dispatched to send the data to the external Queue worker.'); + } +} diff --git a/tests/Functional/Event/AuditLogCreatedEventTest.php b/tests/Functional/Event/AuditLogCreatedEventTest.php index 3cc1bb5..60253f4 100644 --- a/tests/Functional/Event/AuditLogCreatedEventTest.php +++ b/tests/Functional/Event/AuditLogCreatedEventTest.php @@ -33,7 +33,7 @@ public function testModifyLogInEventPreservesSignature(): void $integrityService = $container->get(AuditIntegrityService::class); self::assertInstanceOf(AuditIntegrityServiceInterface::class, $integrityService); - $transport = $container->get('rcsofttech_audit_trail.transport.doctrine'); + $transport = $container->get('rcsofttech_audit_trail.transport.database'); self::assertInstanceOf(AuditTransportInterface::class, $transport); $eventDispatcher = new EventDispatcher(); diff --git a/tests/Functional/TestKernel.php b/tests/Functional/TestKernel.php index ec86d8d..82898c1 100644 --- a/tests/Functional/TestKernel.php +++ b/tests/Functional/TestKernel.php @@ -143,7 +143,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $auditConfig = array_merge([ 'enabled' => true, 'transports' => [ - 'doctrine' => true, + 'database' => ['enabled' => true], ], 'cache_pool' => 'audit_test.cache', 'defer_transport_until_commit' => true, // Default diff --git a/tests/Functional/TransactionSafetyTest.php b/tests/Functional/TransactionSafetyTest.php index 2d5c011..8bcd40c 100644 --- a/tests/Functional/TransactionSafetyTest.php +++ b/tests/Functional/TransactionSafetyTest.php @@ -42,7 +42,7 @@ public function testAtomicModeRollsBackDataOnTransportFailure(): void 'audit_config' => [ 'defer_transport_until_commit' => false, 'fail_on_transport_error' => true, - 'transports' => ['doctrine' => true], + 'transports' => ['database' => ['enabled' => true]], ], ]; @@ -74,7 +74,7 @@ public function testDeferredModePersistsDataEvenIfTransportFails(): void $options = [ 'audit_config' => [ 'defer_transport_until_commit' => true, - 'transports' => ['doctrine' => true], + 'transports' => ['database' => ['enabled' => true]], ], ]; @@ -97,7 +97,7 @@ public function testDeferredModeWithFailOnErrorThrowsButPersistsData(): void 'audit_config' => [ 'defer_transport_until_commit' => true, 'fail_on_transport_error' => true, - 'transports' => ['doctrine' => true], + 'transports' => ['database' => ['enabled' => true]], ], ]; diff --git a/tests/Integration/DependencyInjection/AuditTrailExtensionTest.php b/tests/Integration/DependencyInjection/AuditTrailExtensionTest.php index cad80e4..0a6ea1b 100644 --- a/tests/Integration/DependencyInjection/AuditTrailExtensionTest.php +++ b/tests/Integration/DependencyInjection/AuditTrailExtensionTest.php @@ -4,6 +4,7 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Integration\DependencyInjection; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; use Rcsofttech\AuditTrailBundle\DependencyInjection\AuditTrailExtension; @@ -11,6 +12,7 @@ use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +#[AllowMockObjectsWithoutExpectations] class AuditTrailExtensionTest extends TestCase { public function testDefaultConfigurationLoadsDoctrineTransport(): void @@ -21,10 +23,10 @@ public function testDefaultConfigurationLoadsDoctrineTransport(): void $config = []; $extension->load($config, $container); - self::assertTrue($container->hasDefinition('rcsofttech_audit_trail.transport.doctrine')); + self::assertTrue($container->hasDefinition('rcsofttech_audit_trail.transport.database')); self::assertTrue($container->hasAlias(AuditTransportInterface::class)); self::assertEquals( - 'rcsofttech_audit_trail.transport.doctrine', + 'rcsofttech_audit_trail.transport.database', (string) $container->getAlias(AuditTransportInterface::class) ); } @@ -41,7 +43,7 @@ public function testHttpTransportConfiguration(): void $config = [ 'audit_trail' => [ 'transports' => [ - 'doctrine' => false, + 'database' => ['enabled' => false], 'http' => [ 'enabled' => true, 'endpoint' => 'http://example.com', @@ -71,7 +73,7 @@ public function testQueueTransportConfiguration(): void $config = [ 'audit_trail' => [ 'transports' => [ - 'doctrine' => false, + 'database' => ['enabled' => false], 'queue' => [ 'enabled' => true, ], @@ -100,7 +102,7 @@ public function testChainTransportConfiguration(): void $config = [ 'audit_trail' => [ 'transports' => [ - 'doctrine' => true, + 'database' => ['enabled' => true], 'http' => [ 'enabled' => true, 'endpoint' => 'http://example.com', diff --git a/tests/Unit/AbstractAuditTestCase.php b/tests/Unit/AbstractAuditTestCase.php index 306b582..9c8a9d5 100644 --- a/tests/Unit/AbstractAuditTestCase.php +++ b/tests/Unit/AbstractAuditTestCase.php @@ -4,8 +4,12 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\PersistentCollection; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -115,4 +119,24 @@ protected function createEntityMetadataStub( return $metadata; } + + /** + * @param ClassMetadata $metadata + * @param Collection|null $inner + * + * @return PersistentCollection + */ + protected function createUninitializedCollection( + EntityManagerInterface $em, + ClassMetadata $metadata, + ?Collection $inner = null + ): PersistentCollection { + /** @var Collection&Selectable $actualInner */ + $actualInner = $inner ?? new ArrayCollection(); + + $coll = new PersistentCollection($em, $metadata, $actualInner); + $coll->setInitialized(false); + + return $coll; + } } diff --git a/tests/Unit/Command/AuditRevertCommandTest.php b/tests/Unit/Command/AuditRevertCommandTest.php index 83bfb9c..684ff2a 100644 --- a/tests/Unit/Command/AuditRevertCommandTest.php +++ b/tests/Unit/Command/AuditRevertCommandTest.php @@ -114,6 +114,28 @@ public function testExecuteRevertForce(): void self::assertEquals(0, $this->commandTester->getStatusCode()); } + public function testExecuteRevertNoisy(): void + { + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_UPDATE); + + $this->repository->expects($this->once()) + ->method('find') + ->willReturn($log); + + // Expectations: silenceSubscriber should be FALSE + $this->reverter->expects($this->once()) + ->method('revert') + ->with($log, false, false, [], false) + ->willReturn(['name' => 'Old Name']); + + $this->commandTester->execute([ + 'auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', + '--noisy' => true, + ]); + + self::assertEquals(0, $this->commandTester->getStatusCode()); + } + public function testExecuteAuditNotFound(): void { $this->repository->expects($this->once()) diff --git a/tests/Unit/EventSubscriber/AuditSubscriberTest.php b/tests/Unit/EventSubscriber/AuditSubscriberTest.php index b30c317..c3b8092 100644 --- a/tests/Unit/EventSubscriber/AuditSubscriberTest.php +++ b/tests/Unit/EventSubscriber/AuditSubscriberTest.php @@ -82,12 +82,12 @@ public function testIsEnabled(): void public function testOnFlushDisabled(): void { - $subscriber = $this->createDisabledSubscriber(); + $this->auditManager->disable(); $args = $this->createMock(OnFlushEventArgs::class); $args->expects($this->never())->method('getObjectManager'); - $subscriber->onFlush($args); + $this->subscriber->onFlush($args); } public function testOnFlushRecursion(): void @@ -216,14 +216,14 @@ public function testOnClear(): void public function testPostLoadDisabled(): void { - $subscriber = $this->createDisabledSubscriber(); + $this->auditManager->disable(); $entity = new stdClass(); $em = $this->createMock(EntityManagerInterface::class); $args = new PostLoadEventArgs($entity, $em); $this->accessHandler->expects($this->never())->method('handleAccess'); - $subscriber->postLoad($args); + $this->subscriber->postLoad($args); } public function testPostLoadEnabled(): void @@ -239,7 +239,7 @@ public function testPostLoadEnabled(): void public function testPostFlushDisabled(): void { - $subscriber = $this->createDisabledSubscriber(); + $this->auditManager->disable(); $args = $this->createMock(PostFlushEventArgs::class); $args->method('getObjectManager')->willReturn($this->createMock(EntityManagerInterface::class)); @@ -247,7 +247,7 @@ public function testPostFlushDisabled(): void $this->auditManager->pendingDeletions = [['entity' => new stdClass(), 'data' => [], 'is_managed' => true]]; $this->auditService->expects($this->never())->method('createAuditLog'); - $subscriber->postFlush($args); + $this->subscriber->postFlush($args); } public function testPostFlushProcessPendingDeletionsSkipped(): void @@ -406,23 +406,6 @@ public function testPostFlushFlushExceptionLogsCorrectContext(): void $subscriber->postFlush($args); } - private function createDisabledSubscriber(): AuditSubscriber - { - return new AuditSubscriber( - $this->auditService, - $this->changeProcessor, - $this->dispatcher, - $this->auditManager, - $this->entityProcessor, - $this->transactionIdGenerator, - $this->accessHandler, - $this->idResolver, - null, - true, - false, - ); - } - private function createSubscriberWithLogger(?LoggerInterface $logger): AuditSubscriber { return new AuditSubscriber( diff --git a/tests/Unit/EventSubscriber/MockScheduledAuditManager.php b/tests/Unit/EventSubscriber/MockScheduledAuditManager.php index 079e032..6fb2216 100644 --- a/tests/Unit/EventSubscriber/MockScheduledAuditManager.php +++ b/tests/Unit/EventSubscriber/MockScheduledAuditManager.php @@ -30,4 +30,21 @@ public function clear(): void $this->scheduledAudits = []; $this->pendingDeletions = []; } + + private bool $enabled = true; + + public function disable(): void + { + $this->enabled = false; + } + + public function enable(): void + { + $this->enabled = true; + } + + public function isEnabled(): bool + { + return $this->enabled; + } } diff --git a/tests/Unit/Factory/AuditLogMessageFactoryTest.php b/tests/Unit/Factory/AuditLogMessageFactoryTest.php new file mode 100644 index 0000000..f72613f --- /dev/null +++ b/tests/Unit/Factory/AuditLogMessageFactoryTest.php @@ -0,0 +1,101 @@ +idResolver = $this->createMock(EntityIdResolverInterface::class); + $this->factory = new AuditLogMessageFactory($this->idResolver); + } + + public function testCreateQueueMessageResolvesEntityId(): void + { + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); + + $this->idResolver->method('resolve')->willReturn('42'); + + $message = $this->factory->createQueueMessage($log); + + self::assertSame('42', $message->entityId); + self::assertSame(stdClass::class, $message->entityClass); + self::assertSame('create', $message->action); + } + + public function testCreatePersistMessageResolvesEntityId(): void + { + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); + + $this->idResolver->method('resolve')->willReturn('42'); + + $message = $this->factory->createPersistMessage($log); + + self::assertSame('42', $message->entityId); + self::assertSame(stdClass::class, $message->entityClass); + self::assertSame('create', $message->action); + } + + public function testCreateQueueMessageFallsBackToLogEntityId(): void + { + $log = new AuditLog(stdClass::class, '100', AuditLogInterface::ACTION_UPDATE); + + $this->idResolver->method('resolve')->willReturn(null); + + $message = $this->factory->createQueueMessage($log); + + self::assertSame('100', $message->entityId); + } + + public function testCreatePersistMessageFallsBackToLogEntityId(): void + { + $log = new AuditLog(stdClass::class, '100', AuditLogInterface::ACTION_UPDATE); + + $this->idResolver->method('resolve')->willReturn(null); + + $message = $this->factory->createPersistMessage($log); + + self::assertSame('100', $message->entityId); + } + + public function testCreatePersistMessageIncludesSignature(): void + { + $log = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE); + $log->signature = 'test-sig'; + + $message = $this->factory->createPersistMessage($log); + + self::assertSame('test-sig', $message->signature); + } + + public function testCreateQueueMessagePassesContext(): void + { + $log = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE); + $context = ['entity' => new stdClass(), 'em' => 'mock']; + + $this->idResolver->expects($this->once()) + ->method('resolve') + ->with($log, $context) + ->willReturn('99'); + + $message = $this->factory->createQueueMessage($log, $context); + + self::assertSame('99', $message->entityId); + } +} diff --git a/tests/Unit/MessageHandler/PersistAuditLogHandlerTest.php b/tests/Unit/MessageHandler/PersistAuditLogHandlerTest.php new file mode 100644 index 0000000..b06997a --- /dev/null +++ b/tests/Unit/MessageHandler/PersistAuditLogHandlerTest.php @@ -0,0 +1,102 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->handler = new PersistAuditLogHandler($this->em); + } + + public function testInvokePersistsAuditLog(): void + { + $message = new PersistAuditLogMessage( + entityClass: 'App\Entity\Product', + entityId: '42', + action: 'create', + oldValues: null, + newValues: ['name' => 'Widget'], + changedFields: ['name'], + userId: 'user-1', + username: 'admin', + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + transactionHash: 'abc123', + createdAt: '2025-01-01T12:00:00+00:00', + signature: 'sig-hash', + context: ['source' => 'test'], + ); + + $this->em->expects($this->once()) + ->method('persist') + ->with(self::callback(static function (AuditLog $log) { + return $log->entityClass === 'App\Entity\Product' + && $log->entityId === '42' + && $log->action === 'create' + && $log->oldValues === null + && $log->newValues === ['name' => 'Widget'] + && $log->changedFields === ['name'] + && $log->userId === 'user-1' + && $log->username === 'admin' + && $log->ipAddress === '127.0.0.1' + && $log->userAgent === 'Mozilla/5.0' + && $log->transactionHash === 'abc123' + && $log->signature === 'sig-hash' + && $log->context === ['source' => 'test']; + })); + + $this->em->expects($this->once())->method('flush'); + + ($this->handler)($message); + } + + public function testInvokeWithMinimalMessage(): void + { + $message = new PersistAuditLogMessage( + entityClass: 'App\Entity\Order', + entityId: '99', + action: 'delete', + oldValues: ['status' => 'active'], + newValues: null, + changedFields: null, + userId: null, + username: null, + ipAddress: null, + userAgent: null, + transactionHash: null, + createdAt: '2025-06-15T08:00:00+00:00', + ); + + $this->em->expects($this->once()) + ->method('persist') + ->with(self::callback(static function (AuditLog $log) { + return $log->entityClass === 'App\Entity\Order' + && $log->entityId === '99' + && $log->action === 'delete' + && $log->userId === null + && $log->signature === null + && $log->context === []; + })); + + $this->em->expects($this->once())->method('flush'); + + ($this->handler)($message); + } +} diff --git a/tests/Unit/Query/AuditReaderTest.php b/tests/Unit/Query/AuditReaderTest.php index fffcfaa..60c4d4c 100644 --- a/tests/Unit/Query/AuditReaderTest.php +++ b/tests/Unit/Query/AuditReaderTest.php @@ -5,8 +5,6 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Query; use Exception; -use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; @@ -14,80 +12,80 @@ use Rcsofttech\AuditTrailBundle\Query\AuditReader; use stdClass; -#[AllowMockObjectsWithoutExpectations] class AuditReaderTest extends TestCase { - private AuditLogRepositoryInterface&MockObject $repository; - - private EntityIdResolverInterface&MockObject $idResolver; - - private AuditReader $reader; - - protected function setUp(): void - { - $this->repository = $this->createMock(AuditLogRepositoryInterface::class); - $this->idResolver = $this->createMock(EntityIdResolverInterface::class); - $this->reader = new AuditReader($this->repository, $this->idResolver); - } - - public function testCreateQuery(): void + public function testCreateQueryReturnsAuditQuery(): void { - $this->reader->createQuery(); + $reader = new AuditReader(self::createStub(AuditLogRepositoryInterface::class), self::createStub(EntityIdResolverInterface::class)); + $reader->createQuery(); $this->expectNotToPerformAssertions(); } - public function testForEntity(): void + public function testForEntityReturnsAuditQuery(): void { - $this->reader->forEntity('App\Entity\User', '123'); + $reader = new AuditReader(self::createStub(AuditLogRepositoryInterface::class), self::createStub(EntityIdResolverInterface::class)); + $reader->forEntity('App\Entity\User', '123'); $this->expectNotToPerformAssertions(); } - public function testByUser(): void + public function testByUserReturnsAuditQuery(): void { - $this->reader->byUser('1'); + $reader = new AuditReader(self::createStub(AuditLogRepositoryInterface::class), self::createStub(EntityIdResolverInterface::class)); + $reader->byUser('1'); $this->expectNotToPerformAssertions(); } - public function testByTransaction(): void + public function testByTransactionReturnsAuditQuery(): void { - $this->reader->byTransaction('hash'); + $reader = new AuditReader(self::createStub(AuditLogRepositoryInterface::class), self::createStub(EntityIdResolverInterface::class)); + $reader->byTransaction('hash'); $this->expectNotToPerformAssertions(); } public function testGetHistoryFor(): void { $entity = new stdClass(); - $this->idResolver->method('resolveFromEntity')->with($entity)->willReturn('123'); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('123'); - $this->repository->expects($this->once()) + $repository = self::createMock(AuditLogRepositoryInterface::class); + $repository->expects($this->once()) ->method('findWithFilters') - ->with(self::callback(static fn ($f) => $f['entityClass'] === 'stdClass' && $f['entityId'] === '123'), 30) + ->with(self::callback(static fn ($f) => $f['entityClass'] === stdClass::class && $f['entityId'] === '123'), 30) ->willReturn([]); - $this->reader->getHistoryFor($entity); + $reader = new AuditReader($repository, $idResolver); + $reader->getHistoryFor($entity); } public function testGetHistoryForFailure(): void { $entity = new stdClass(); - $this->idResolver->method('resolveFromEntity')->willThrowException(new Exception()); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willThrowException(new Exception()); - $this->repository->expects($this->never())->method('findWithFilters'); + $repository = self::createMock(AuditLogRepositoryInterface::class); + $repository->expects($this->never())->method('findWithFilters'); - $collection = $this->reader->getHistoryFor($entity); + $reader = new AuditReader($repository, $idResolver); + $collection = $reader->getHistoryFor($entity); self::assertCount(0, $collection); } public function testGetTimelineFor(): void { $entity = new stdClass(); - $this->idResolver->method('resolveFromEntity')->willReturn('123'); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('123'); $log = new AuditLog(stdClass::class, '123', 'create', transactionHash: 'tx1'); - $this->repository->method('findWithFilters')->willReturn([$log]); + $repository = self::createStub(AuditLogRepositoryInterface::class); + $repository->method('findWithFilters')->willReturn([$log]); + + $reader = new AuditReader($repository, $idResolver); + $timeline = $reader->getTimelineFor($entity); - $timeline = $this->reader->getTimelineFor($entity); self::assertArrayHasKey('tx1', $timeline); self::assertCount(1, $timeline['tx1']); } @@ -95,22 +93,29 @@ public function testGetTimelineFor(): void public function testGetLatestFor(): void { $entity = new stdClass(); - $this->idResolver->method('resolveFromEntity')->willReturn('123'); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('123'); $log = new AuditLog(stdClass::class, '123', 'create'); - $this->repository->method('findWithFilters')->willReturn([$log]); + $repository = self::createStub(AuditLogRepositoryInterface::class); + $repository->method('findWithFilters')->willReturn([$log]); + + $reader = new AuditReader($repository, $idResolver); + $result = $reader->getLatestFor($entity); - $result = $this->reader->getLatestFor($entity); self::assertNotNull($result); } public function testHasHistoryFor(): void { $entity = new stdClass(); - $this->idResolver->method('resolveFromEntity')->willReturn('123'); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('123'); - $this->repository->method('findWithFilters')->willReturn([new AuditLog(stdClass::class, '123', 'create')]); + $repository = self::createStub(AuditLogRepositoryInterface::class); + $repository->method('findWithFilters')->willReturn([new AuditLog(stdClass::class, '123', 'create')]); - self::assertTrue($this->reader->hasHistoryFor($entity)); + $reader = new AuditReader($repository, $idResolver); + self::assertTrue($reader->hasHistoryFor($entity)); } } diff --git a/tests/Unit/Service/AuditIntegrityServiceTest.php b/tests/Unit/Service/AuditIntegrityServiceTest.php index 69b5f67..9708553 100644 --- a/tests/Unit/Service/AuditIntegrityServiceTest.php +++ b/tests/Unit/Service/AuditIntegrityServiceTest.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditIntegrityService; -use ReflectionClass; use RuntimeException; use function strlen; @@ -146,20 +145,20 @@ public function testVerifySignatureFailsOnTypeMismatch(): void self::assertFalse($this->service->verifySignature($logStr)); } - public function testNormalizeDepthLimit(): void + public function testDeepNestedValuesProduceStableSignatures(): void { $deepArray = ['a' => ['b' => ['c' => ['d' => ['e' => ['f' => 'too_deep']]]]]]; $log = new AuditLog('Test', '1', 'create', new DateTimeImmutable(), $deepArray); - $signature = $this->service->generateSignature($log); - self::assertNotEmpty($signature); + $signature1 = $this->service->generateSignature($log); + $signature2 = $this->service->generateSignature($log); - // Manual check of normalization behavior (internal) - $reflection = new ReflectionClass($this->service); - $method = $reflection->getMethod('normalizeValues'); - $result = $method->invoke($this->service, $deepArray); + self::assertNotEmpty($signature1); + self::assertSame($signature1, $signature2, 'Deep nested data should produce deterministic signatures'); - self::assertEquals('s:[max_depth]', $result['a']['b']['c']['d']['e']); + // Verify that the signature is valid + $log->signature = $signature1; + self::assertTrue($this->service->verifySignature($log)); } public function testVerifySignatureWithTimezoneStability(): void @@ -259,30 +258,33 @@ public function testNormalizePrimitives(): void self::assertNotEmpty($signature); } - public function testNormalizeValuesDepthLimitReached(): void + public function testDeeplyNestedValuesDoNotBreakSigning(): void { - $log = new AuditLog('Test', '1', 'update'); - $reflectionLog = new ReflectionClass($log); - $property = $reflectionLog->getProperty('oldValues'); - $property->setValue($log, ['a' => ['b' => ['c' => ['d' => ['e' => ['f' => 'g']]]]]]); - - $reflection = new ReflectionClass($this->service); - $method = $reflection->getMethod('normalizeValues'); + $deepValues = ['a' => ['b' => ['c' => ['d' => ['e' => ['f' => 'g']]]]]]; + $log = new AuditLog( + 'Test', + '1', + 'update', + new DateTimeImmutable(), + $deepValues, + ['x' => 'y'] + ); - $result = $method->invoke($this->service, $log->oldValues); - // Depth logic internal check, maximum depth replaces value with something max_depth_reached - self::assertEquals('s:[max_depth]', $result['a']['b']['c']['d']['e']); + $signature = $this->service->generateSignature($log); + self::assertNotEmpty($signature); - // Depth 0 - $result = $method->invoke($this->service, $log->oldValues, 4); - self::assertEquals(['a' => 's:[max_depth]'], $result); + $log->signature = $signature; + self::assertTrue($this->service->verifySignature($log)); } - public function testNormalizeValuesMaxDepth(): void + public function testShallowValuesProduceDifferentSignatureThanDeep(): void { - $reflection = new ReflectionClass($this->service); - $method = $reflection->getMethod('normalizeValues'); - $result = $method->invoke($this->service, ['a' => 'b'], 5); // 5 is max depth - self::assertEquals(['_error' => 'max_depth_reached'], $result); + $shallow = new AuditLog('Test', '1', 'update', new DateTimeImmutable(), ['a' => 'b']); + $deep = new AuditLog('Test', '1', 'update', new DateTimeImmutable(), ['a' => ['b' => 'c']]); + + $sig1 = $this->service->generateSignature($shallow); + $sig2 = $this->service->generateSignature($deep); + + self::assertNotSame($sig1, $sig2, 'Different nesting depths should produce different signatures'); } } diff --git a/tests/Unit/Service/AuditRendererTest.php b/tests/Unit/Service/AuditRendererTest.php index d785af1..7ef2547 100644 --- a/tests/Unit/Service/AuditRendererTest.php +++ b/tests/Unit/Service/AuditRendererTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditRenderer; -use ReflectionClass; use stdClass; use Symfony\Component\Console\Output\BufferedOutput; @@ -22,27 +21,21 @@ protected function setUp(): void $this->renderer = new AuditRenderer(); } - public function testTruncateAndStripAnsi(): void + public function testFormatValueStripsAnsiSequences(): void { $ansiString = "\x1b[31mRed Content\x1b[0m"; - $reflection = new ReflectionClass($this->renderer); - $method = $reflection->getMethod('truncateString'); - $method->setAccessible(true); - $result = $method->invoke($this->renderer, $ansiString); + $result = $this->renderer->formatValue($ansiString); self::assertEquals('Red Content', $result); self::assertStringNotContainsString("\x1b", $result); } - public function testTruncateLongString(): void + public function testFormatValueTruncatesLongStrings(): void { $longString = str_repeat('a', 60); - $reflection = new ReflectionClass($this->renderer); - $method = $reflection->getMethod('truncateString'); - $method->setAccessible(true); - $result = $method->invoke($this->renderer, $longString); + $result = $this->renderer->formatValue($longString); self::assertEquals(50, strlen($result)); self::assertStringEndsWith('...', $result); diff --git a/tests/Unit/Service/AuditRevertIntegrityTest.php b/tests/Unit/Service/AuditRevertIntegrityTest.php index 21ca607..61121e3 100644 --- a/tests/Unit/Service/AuditRevertIntegrityTest.php +++ b/tests/Unit/Service/AuditRevertIntegrityTest.php @@ -13,6 +13,7 @@ use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; @@ -53,6 +54,7 @@ protected function setUp(): void $this->integrityService, $this->createMock(AuditDispatcherInterface::class), $serializer, + $this->createMock(ScheduledAuditManagerInterface::class) ); } diff --git a/tests/Unit/Service/AuditReverterTest.php b/tests/Unit/Service/AuditReverterTest.php index f45dccd..8c8b306 100644 --- a/tests/Unit/Service/AuditReverterTest.php +++ b/tests/Unit/Service/AuditReverterTest.php @@ -10,17 +10,17 @@ use Doctrine\ORM\Query\FilterCollection; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditReverter; use Rcsofttech\AuditTrailBundle\Service\RevertValueDenormalizer; -use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\DummySoftDeleteableFilter; +use Rcsofttech\AuditTrailBundle\Tests\Unit\AbstractAuditTestCase; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\RevertTestUser; use ReflectionClass; use RuntimeException; @@ -29,14 +29,8 @@ use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; -// Mock class ChangeProcessor implements ChangeProcessorInterface -// Mock class for testing if not present -if (!class_exists('Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter')) { - class_alias(DummySoftDeleteableFilter::class, 'Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter'); -} - -#[AllowMockObjectsWithoutExpectations()] -class AuditReverterTest extends TestCase +#[AllowMockObjectsWithoutExpectations] +class AuditReverterTest extends AbstractAuditTestCase { private EntityManagerInterface&MockObject $em; @@ -52,6 +46,8 @@ class AuditReverterTest extends TestCase private AuditDispatcherInterface&MockObject $dispatcher; + private ScheduledAuditManagerInterface&MockObject $auditManager; + private AuditReverter $reverter; protected function setUp(): void @@ -63,6 +59,7 @@ protected function setUp(): void $this->softDeleteHandler = $this->createMock(SoftDeleteHandlerInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); $this->dispatcher = $this->createMock(AuditDispatcherInterface::class); + $this->auditManager = $this->createMock(ScheduledAuditManagerInterface::class); $this->em->method('getFilters')->willReturn($this->filterCollection); @@ -77,7 +74,8 @@ protected function setUp(): void $this->softDeleteHandler, $this->integrityService, $this->dispatcher, - $serializer + $serializer, + $this->auditManager ); } @@ -244,6 +242,9 @@ public function testApplyChangesSkipping(): void $this->em->expects($this->once())->method('persist'); // Only Entity (RevertLog is dispatched) $this->em->expects($this->once())->method('flush'); + $this->auditManager->expects($this->once())->method('disable'); + $this->auditManager->expects($this->once())->method('enable'); + $changes = $this->reverter->revert($log); self::assertEquals(['changed' => 'old'], $changes); @@ -251,6 +252,36 @@ public function testApplyChangesSkipping(): void self::assertArrayNotHasKey('unchanged', $changes); } + public function testRevertNoisy(): void + { + $log = new AuditLog( + entityClass: RevertTestUser::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['name' => 'Old'] + ); + + $entity = new RevertTestUser(); + $this->filterCollection->method('getEnabledFilters')->willReturn([]); + $this->em->method('find')->willReturn($entity); + + $metadata = $this->createMock(ClassMetadata::class); + $metadata->method('hasField')->willReturn(true); + $this->em->method('getClassMetadata')->willReturn($metadata); + + $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); + $this->validator->method('validate')->willReturn(new ConstraintViolationList()); + + $revertLog = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT, oldValues: ['name' => 'Old']); + $this->auditService->method('createAuditLog')->willReturn($revertLog); + + // Expectations: disable() and enable() should NEVER be called + $this->auditManager->expects($this->never())->method('disable'); + $this->auditManager->expects($this->never())->method('enable'); + + $this->reverter->revert($log, false, false, [], false); // silenceSubscriber = false + } + public function testRevertCreateSuccess(): void { $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_CREATE); @@ -262,7 +293,12 @@ public function testRevertCreateSuccess(): void $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); $this->em->expects($this->once())->method('remove')->with($entity); - $this->auditService->method('createAuditLog')->willReturn(new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT)); + $revertLog = new AuditLog(RevertTestUser::class, 'pending', AuditLogInterface::ACTION_REVERT); + $this->auditService->method('createAuditLog')->willReturn($revertLog); + + $this->dispatcher->expects($this->once())->method('dispatch')->with(self::callback(static function (AuditLog $arg) { + return $arg->action === AuditLogInterface::ACTION_REVERT && $arg->entityId === '1'; + }), $this->em, 'post_flush'); $changes = $this->reverter->revert($log, false, true); self::assertEquals(['action' => 'delete'], $changes); diff --git a/tests/Unit/Service/AuditServiceTest.php b/tests/Unit/Service/AuditServiceTest.php index a0dcc74..2992b96 100644 --- a/tests/Unit/Service/AuditServiceTest.php +++ b/tests/Unit/Service/AuditServiceTest.php @@ -9,7 +9,6 @@ use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; @@ -20,10 +19,11 @@ use Rcsofttech\AuditTrailBundle\Service\AuditService; use Rcsofttech\AuditTrailBundle\Service\EntityDataExtractor; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; +use Rcsofttech\AuditTrailBundle\Tests\Unit\AbstractAuditTestCase; use stdClass; #[AllowMockObjectsWithoutExpectations] -class AuditServiceTest extends TestCase +class AuditServiceTest extends AbstractAuditTestCase { private EntityManagerInterface&MockObject $entityManager; diff --git a/tests/Unit/Service/EntityDataExtractorTest.php b/tests/Unit/Service/EntityDataExtractorTest.php index d6c076e..a09ec49 100644 --- a/tests/Unit/Service/EntityDataExtractorTest.php +++ b/tests/Unit/Service/EntityDataExtractorTest.php @@ -9,16 +9,16 @@ use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Attribute\Auditable; use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use Rcsofttech\AuditTrailBundle\Service\EntityDataExtractor; use Rcsofttech\AuditTrailBundle\Service\MetadataCache; +use Rcsofttech\AuditTrailBundle\Tests\Unit\AbstractAuditTestCase; use stdClass; #[AllowMockObjectsWithoutExpectations] -class EntityDataExtractorTest extends TestCase +class EntityDataExtractorTest extends AbstractAuditTestCase { private EntityManagerInterface&MockObject $em; diff --git a/tests/Unit/Service/MetadataCacheTest.php b/tests/Unit/Service/MetadataCacheTest.php index aed1e49..102f47d 100644 --- a/tests/Unit/Service/MetadataCacheTest.php +++ b/tests/Unit/Service/MetadataCacheTest.php @@ -4,6 +4,8 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Service; +use ArrayObject; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Service\MetadataCache; @@ -11,6 +13,8 @@ use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\NotAuditable; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\ParentAuditable; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\SensitiveEntity; +use RuntimeException; +use stdClass; #[AllowMockObjectsWithoutExpectations] class MetadataCacheTest extends TestCase @@ -67,23 +71,23 @@ public function testGetSensitiveFields(): void self::assertSame($fields, $fields2); } - public function testCacheEviction(): void + public function testCacheReturnsConsistentResultsForManyClasses(): void { - // Reflection to access private cache or just add > 100 items - // Adding 101 items - for ($i = 0; $i < 105; ++$i) { - $class = "Class$i"; - if (!class_exists($class)) { - eval("class $class {}"); - } - $this->cache->getAuditableAttribute($class); + // Verifies the cache handles many lookups without issues + // and always returns null for non-auditable classes + $classes = [ + NotAuditable::class, + stdClass::class, + DateTimeImmutable::class, + RuntimeException::class, + ArrayObject::class, + ]; + + foreach ($classes as $class) { + $result1 = $this->cache->getAuditableAttribute($class); + $result2 = $this->cache->getAuditableAttribute($class); + self::assertSame($result1, $result2, "Cache should return identical result for {$class}"); + self::assertNull($result1, "{$class} should not be auditable"); } - - // The first one should be evicted - // But we can't easily verify eviction without reflection or checking if it re-computes. - // Since resolveAuditableAttribute is fast for empty classes, it's hard to tell. - // But we can check coverage of ensureCacheSize. - - $this->expectNotToPerformAssertions(); } } diff --git a/tests/Unit/Service/TestEntity.php b/tests/Unit/Service/TestEntity.php index 323dd4c..b2b562a 100644 --- a/tests/Unit/Service/TestEntity.php +++ b/tests/Unit/Service/TestEntity.php @@ -9,8 +9,12 @@ #[Auditable(enabled: true)] class TestEntity { + public function __construct(private int $id = 1) + { + } + public function getId(): int { - return 1; + return $this->id; } } diff --git a/tests/Unit/Service/UserResolverTest.php b/tests/Unit/Service/UserResolverTest.php index 401f6c3..2ff1406 100644 --- a/tests/Unit/Service/UserResolverTest.php +++ b/tests/Unit/Service/UserResolverTest.php @@ -4,8 +4,7 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Service; -use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Service\UserResolver; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\StubUserWithId; @@ -19,168 +18,146 @@ use function strlen; -#[AllowMockObjectsWithoutExpectations] class UserResolverTest extends TestCase { - private Security&MockObject $security; - - private RequestStack&MockObject $requestStack; - - protected function setUp(): void + public function testGetUserIdFallsBackToCliWhenNoUser(): void { - $this->security = $this->createMock(Security::class); - $this->requestStack = $this->createMock(RequestStack::class); + $resolver = $this->createResolver(); + self::assertStringStartsWith('cli:', (string) $resolver->getUserId()); } - public function testGetUserId(): void + public function testGetUserIdReturnsIdFromUserWithIdMethod(): void { - $resolver = new UserResolver($this->security, $this->requestStack); - - // No User -> Fallback to CLI in this environment - $this->security->method('getUser')->willReturn(null); - self::assertStringStartsWith('cli:', (string) $resolver->getUserId()); - - // User with ID - $this->security = $this->createMock(Security::class); - $this->security->method('getUser')->willReturn(new StubUserWithId()); - $resolver = new UserResolver($this->security, $this->requestStack); + $resolver = $this->createResolver(user: new StubUserWithId()); self::assertEquals(123, $resolver->getUserId()); + } - // User without ID -> returns identifier - $this->security = $this->createMock(Security::class); - $this->security->method('getUser')->willReturn(new StubUserWithoutId()); - $resolver = new UserResolver($this->security, $this->requestStack); + public function testGetUserIdReturnsIdentifierWhenNoIdMethod(): void + { + $resolver = $this->createResolver(user: new StubUserWithoutId()); self::assertEquals('user', $resolver->getUserId()); } - public function testGetUsername(): void + public function testGetUsernameFallsBackToCliWhenNoUser(): void { - $resolver = new UserResolver($this->security, $this->requestStack); - - $this->security->method('getUser')->willReturn(null); + $resolver = $this->createResolver(); self::assertStringStartsWith('cli:', (string) $resolver->getUsername()); + } - $this->security = $this->createMock(Security::class); - $user = $this->createMock(UserInterface::class); + public function testGetUsernameReturnsUserIdentifier(): void + { + $user = self::createStub(UserInterface::class); $user->method('getUserIdentifier')->willReturn('test_user'); - $this->security->method('getUser')->willReturn($user); - $resolver = new UserResolver($this->security, $this->requestStack); + $resolver = $this->createResolver(user: $user); self::assertEquals('test_user', $resolver->getUsername()); } - public function testGetIpAddress(): void + public function testGetIpAddressReturnsClientIpWhenTrackingEnabled(): void { - // Tracking enabled - $resolver = new UserResolver($this->security, $this->requestStack, true, true); - $request = new Request([], [], [], [], [], ['REMOTE_ADDR' => '127.0.0.1']); - $this->requestStack->method('getCurrentRequest')->willReturn($request); + $resolver = $this->createResolver( + request: $request, + trackIp: true, + trackUserAgent: true + ); self::assertEquals('127.0.0.1', $resolver->getIpAddress()); + } - // Tracking disabled - $resolver = new UserResolver($this->security, $this->requestStack, false, true); + public function testGetIpAddressReturnsNullWhenTrackingDisabled(): void + { + $resolver = $this->createResolver(trackIp: false, trackUserAgent: true); self::assertNull($resolver->getIpAddress()); + } - // No request -> fallback to machine IP in CLI - $this->requestStack = $this->createMock(RequestStack::class); - $this->requestStack->method('getCurrentRequest')->willReturn(null); - $resolver = new UserResolver($this->security, $this->requestStack, true, true); + public function testGetIpAddressFallsBackToHostnameInCli(): void + { + $resolver = $this->createResolver(trackIp: true, trackUserAgent: true); self::assertEquals(gethostbyname((string) gethostname()), $resolver->getIpAddress()); } - public function testGetUserAgent(): void + public function testGetUserAgentReturnsHeaderWhenTrackingEnabled(): void { - // Tracking enabled - $resolver = new UserResolver($this->security, $this->requestStack, true, true); - $request = new Request([], [], [], [], [], ['HTTP_USER_AGENT' => 'Mozilla/5.0']); - $this->requestStack->method('getCurrentRequest')->willReturn($request); + $resolver = $this->createResolver( + request: $request, + trackIp: true, + trackUserAgent: true + ); self::assertEquals('Mozilla/5.0', $resolver->getUserAgent()); + } - // Tracking disabled - $resolver = new UserResolver($this->security, $this->requestStack, true, false); + public function testGetUserAgentReturnsNullWhenTrackingDisabled(): void + { + $resolver = $this->createResolver(trackIp: true, trackUserAgent: false); self::assertNull($resolver->getUserAgent()); + } - // Truncation + public function testGetUserAgentTruncatesLongStrings(): void + { $longUa = str_repeat('a', 600); $request = new Request([], [], [], [], [], ['HTTP_USER_AGENT' => $longUa]); - $this->requestStack = $this->createMock(RequestStack::class); - $this->requestStack->method('getCurrentRequest')->willReturn($request); - $resolver = new UserResolver($this->security, $this->requestStack, true, true); + $resolver = $this->createResolver( + request: $request, + trackIp: true, + trackUserAgent: true + ); $ua = $resolver->getUserAgent(); self::assertNotNull($ua); self::assertEquals(500, strlen($ua)); + } - // Empty UA -> fallback to CLI UA in CLI + public function testGetUserAgentFallsBackToCliInCliEnvironment(): void + { $request = new Request([], [], [], [], [], []); - $this->requestStack = $this->createMock(RequestStack::class); - $this->requestStack->method('getCurrentRequest')->willReturn($request); - $resolver = new UserResolver($this->security, $this->requestStack, true, true); + $resolver = $this->createResolver( + request: $request, + trackIp: true, + trackUserAgent: true + ); + self::assertStringContainsString('cli-console', (string) $resolver->getUserAgent()); } - public function testGetImpersonatorId(): void + public function testGetImpersonatorIdReturnsNullWhenNoToken(): void { - $resolver = new UserResolver($this->security, $this->requestStack); - $this->security->method('getToken')->willReturn(null); + $resolver = $this->createResolver(); self::assertNull($resolver->getImpersonatorId()); + } - $token = $this->createMock(SwitchUserToken::class); - $originalToken = $this->createMock(TokenInterface::class); - $originalToken->method('getUser')->willReturn(new StubUserWithId()); - $token->method('getOriginalToken')->willReturn($originalToken); - - $this->security = $this->createMock(Security::class); - $this->security->method('getToken')->willReturn($token); - - $resolver = new UserResolver($this->security, $this->requestStack); + public function testGetImpersonatorIdReturnsSwitchUserOriginalId(): void + { + $resolver = $this->createResolverWithImpersonation(new StubUserWithId()); self::assertEquals('123', $resolver->getImpersonatorId()); } - public function testGetImpersonatorUsername(): void + public function testGetImpersonatorUsernameReturnsNullWhenNoToken(): void { - $resolver = new UserResolver($this->security, $this->requestStack); - $this->security->method('getToken')->willReturn(null); + $resolver = $this->createResolver(); self::assertNull($resolver->getImpersonatorUsername()); + } - $token = $this->createMock(SwitchUserToken::class); - $originalToken = $this->createMock(TokenInterface::class); - $user = $this->createMock(UserInterface::class); + public function testGetImpersonatorUsernameReturnsSwitchUserOriginalIdentifier(): void + { + $user = self::createStub(UserInterface::class); $user->method('getUserIdentifier')->willReturn('superadmin'); - $originalToken->method('getUser')->willReturn($user); - $token->method('getOriginalToken')->willReturn($originalToken); - - $this->security = $this->createMock(Security::class); - $this->security->method('getToken')->willReturn($token); - $resolver = new UserResolver($this->security, $this->requestStack); + $resolver = $this->createResolverWithImpersonation($user); self::assertEquals('superadmin', $resolver->getImpersonatorUsername()); } - public function testGetImpersonatorIdWithoutIdMethod(): void + public function testGetImpersonatorIdReturnsNullWithoutIdMethod(): void { - $token = $this->createMock(SwitchUserToken::class); - $originalToken = $this->createMock(TokenInterface::class); - $user = new StubUserWithoutId(); - $originalToken->method('getUser')->willReturn($user); - $token->method('getOriginalToken')->willReturn($originalToken); - - $security = $this->createMock(Security::class); - $security->method('getToken')->willReturn($token); - - $resolver = new UserResolver($security, new RequestStack()); + $resolver = $this->createResolverWithImpersonation(new StubUserWithoutId()); self::assertNull($resolver->getImpersonatorId()); } - public function testGetImpersonatorIdWhenIdNotScalarOrStringable(): void + public function testGetImpersonatorIdReturnsNullWhenIdNotScalarOrStringable(): void { - $token = $this->createMock(SwitchUserToken::class); - $originalToken = $this->createMock(TokenInterface::class); $user = new class implements UserInterface { /** @return array */ public function getId(): array @@ -203,19 +180,13 @@ public function eraseCredentials(): void { } }; - $originalToken->method('getUser')->willReturn($user); - $token->method('getOriginalToken')->willReturn($originalToken); - $security = $this->createMock(Security::class); - $security->method('getToken')->willReturn($token); - - $resolver = new UserResolver($security, new RequestStack()); + $resolver = $this->createResolverWithImpersonation($user); self::assertNull($resolver->getImpersonatorId()); } - public function testGetUserIdWhenIdNotScalarOrStringable(): void + public function testGetUserIdReturnsIdentifierWhenIdNotScalarOrStringable(): void { - $security = $this->createMock(Security::class); $user = new class implements UserInterface { /** @return array */ public function getId(): array @@ -238,26 +209,57 @@ public function eraseCredentials(): void { } }; - $security->method('getUser')->willReturn($user); - $resolver = new UserResolver($security, new RequestStack()); + $resolver = $this->createResolver(user: $user); self::assertEquals('user_identifier', $resolver->getUserId()); } public function testGetIpAddressInCliWithoutRequestUsesHostname(): void { - $resolver = new UserResolver($this->createMock(Security::class), new RequestStack(), true, true); + $resolver = $this->createResolver(trackIp: true, trackUserAgent: true); $ip = $resolver->getIpAddress(); - self::assertIsString($ip); // In CLI it uses gethostbyname + self::assertIsString($ip); } public function testGetUserAgentInCliWithoutRequestUsesHostname(): void { - $resolver = new UserResolver($this->createMock(Security::class), new RequestStack(), true, true); + $resolver = $this->createResolver(trackIp: true, trackUserAgent: true); $ua = $resolver->getUserAgent(); self::assertIsString($ua); self::assertStringContainsString('cli-console', $ua); } + + private function createResolver( + ?UserInterface $user = null, + ?Request $request = null, + bool $trackIp = false, + bool $trackUserAgent = false, + ): UserResolver { + $security = self::createStub(Security::class); + $security->method('getUser')->willReturn($user); + + $requestStack = new RequestStack(); + if ($request !== null) { + $requestStack->push($request); + } + + return new UserResolver($security, $requestStack, $trackIp, $trackUserAgent); + } + + private function createResolverWithImpersonation(UserInterface $originalUser): UserResolver + { + $originalToken = self::createStub(TokenInterface::class); + $originalToken->method('getUser')->willReturn($originalUser); + + /** @var SwitchUserToken&Stub $token */ + $token = self::createStub(SwitchUserToken::class); + $token->method('getOriginalToken')->willReturn($originalToken); + + $security = self::createStub(Security::class); + $security->method('getToken')->willReturn($token); + + return new UserResolver($security, new RequestStack()); + } } diff --git a/tests/Unit/Service/ValueSerializerTest.php b/tests/Unit/Service/ValueSerializerTest.php index f034f9c..bee30bf 100644 --- a/tests/Unit/Service/ValueSerializerTest.php +++ b/tests/Unit/Service/ValueSerializerTest.php @@ -5,51 +5,26 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Service; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Selectable; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; -use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\MockObject; use Rcsofttech\AuditTrailBundle\Service\ValueSerializer; +use Rcsofttech\AuditTrailBundle\Tests\Unit\AbstractAuditTestCase; use stdClass; use Stringable; #[AllowMockObjectsWithoutExpectations] -class ValueSerializerTest extends TestCase +class ValueSerializerTest extends AbstractAuditTestCase { - public function testSerializeRespectsMaxDepth(): void - { - $serializer = new ValueSerializer(); + private EntityManagerInterface&MockObject $em; - // Let's create a deeply nested array - $deepArray = [ - 'level1' => [ - 'level2' => [ - 'level3' => [ - 'level4' => [ - 'level5' => [ - 'level6' => 'value', - ], - ], - ], - ], - ], - ]; - - $serialized = $serializer->serialize($deepArray); - - // Expected structure: - self::assertSame( - [ - 'level1' => [ - 'level2' => [ - 'level3' => [ - 'level4' => [ - 'level5' => '[max depth reached]', - ], - ], - ], - ], - ], - $serialized - ); + protected function setUp(): void + { + $this->em = $this->createMock(EntityManagerInterface::class); } public function testSerializeBasicTypes(): void @@ -71,34 +46,25 @@ public function testSerializeDateTime(): void self::assertSame('2023-01-01T12:00:00+00:00', $serializer->serialize($date)); } - public function testSerializeResource(): void + public function testSerializeEnum(): void { $serializer = new ValueSerializer(); - $resource = fopen('php://memory', 'r'); - self::assertNotFalse($resource); - - self::assertStringStartsWith('[resource: stream]', $serializer->serialize($resource)); - - fclose($resource); + self::assertSame('Foo', $serializer->serialize(TestUnitEnum::Foo)); + self::assertSame('bar_value', $serializer->serialize(TestBackedEnum::Bar)); } public function testSerializeObjectWithId(): void { $serializer = new ValueSerializer(); - $object = new class { - public function getId(): int - { - return 1; - } - }; + $entity = new TestEntity(42); - self::assertSame(1, $serializer->serialize($object)); + self::assertSame(42, $serializer->serialize($entity)); } public function testSerializeObjectWithToString(): void { $serializer = new ValueSerializer(); - $object = new class { + $object = new class implements Stringable { public function __toString(): string { return 'string_rep'; @@ -122,391 +88,140 @@ public function testSerializeAssociation(): void self::assertNull($serializer->serializeAssociation(null)); - $object = new class { - public function getId(): int - { - return 1; - } - }; - self::assertSame(1, $serializer->serializeAssociation($object)); + $entity = new TestEntity(42); + self::assertSame(42, $serializer->serializeAssociation($entity)); self::assertNull($serializer->serializeAssociation('not_an_object')); - - // Test collection in association - $collection = new \Doctrine\Common\Collections\ArrayCollection([$object]); - self::assertEquals([1], $serializer->serializeAssociation($collection)); - - // Test object without getId - $objNoId = new stdClass(); - self::assertEquals('stdClass', $serializer->serializeAssociation($objNoId)); - } - - public function testSerializeCollection(): void - { - $serializer = new ValueSerializer(); - $collection = new \Doctrine\Common\Collections\ArrayCollection(['a', 'b']); - - self::assertEquals(['a', 'b'], $serializer->serialize($collection)); - } - - public function testSerializeLargeCollection(): void - { - $logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $logger->expects($this->once())->method('warning'); - - $serializer = new ValueSerializer($logger); - - $items = array_fill(0, 101, 'item'); - $collection = new \Doctrine\Common\Collections\ArrayCollection($items); - - $result = $serializer->serialize($collection); - - self::assertIsArray($result); - self::assertTrue($result['_truncated']); - self::assertEquals(101, $result['_total_count']); - self::assertCount(100, $result['_sample']); - } - - public function testSerializeObjectIdRecursionLimit(): void - { - $serializer = new ValueSerializer(); - - // Create an object whose ID is another object, recurring deep enough to hit the limit - $deepObj = new class { - public object $child; - - public function getId(): object - { - return $this->child; - } - }; - - $obj1 = clone $deepObj; - $obj2 = clone $deepObj; - $obj3 = clone $deepObj; - $obj4 = clone $deepObj; - $obj5 = clone $deepObj; - $obj6 = clone $deepObj; - - $obj1->child = $obj2; - $obj2->child = $obj3; - $obj3->child = $obj4; - $obj4->child = $obj5; - $obj5->child = $obj6; - $obj6->child = new stdClass(); - - $result = $serializer->serialize($obj1); - self::assertSame('[max depth reached]', $result); - } - - public function testExtractEntityIdentifierWithObjectId(): void - { - $serializer = new ValueSerializer(); - - $idObj = new class { - public function __toString(): string - { - return 'id_string'; - } - }; - - $entity = new class($idObj) { - public function __construct(private object $id) - { - } - - public function getId(): object - { - return $this->id; - } - }; - - // This tests line 146 where is_object($id) is true in extractEntityIdentifier - $result = $serializer->serializeAssociation($entity); - self::assertSame('id_string', $result); - } - - public function testSerializeStringableInterface(): void - { - $serializer = new ValueSerializer(); - // PHP 8 Stringable interface - $stringable = new class implements Stringable { - public function __toString(): string - { - return 'stringable_val'; - } - }; - - $result = $serializer->serialize($stringable); - self::assertSame('stringable_val', $result); - } - - public function testSerializeEnum(): void - { - $serializer = new ValueSerializer(); - self::assertSame('Foo', $serializer->serialize(TestUnitEnum::Foo)); - self::assertSame('bar_value', $serializer->serialize(TestBackedEnum::Bar)); - } - - public function testSerializeUninitializedPersistentCollection(): void - { - $serializer = new ValueSerializer(); - $em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class); - $classMetadata = new \Doctrine\ORM\Mapping\ClassMetadata('stdClass'); - - $collection = new \Doctrine\ORM\PersistentCollection($em, $classMetadata, new \Doctrine\Common\Collections\ArrayCollection()); - $collection->setInitialized(false); - - $result = $serializer->serialize($collection); - self::assertEquals([ - '_state' => 'uninitialized', - '_total_count' => 'unknown', - ], $result); - } - - public function testSerializeCollectionIdentifiersOnly(): void - { - $serializer = new ValueSerializer(); - $obj1 = new class { - public function getId(): int - { - return 1; - } - }; - $obj2 = new class { - public function getId(): string - { - return 'two'; - } - }; - // a primitive to test the `is_object` check in the array_map closure - $primitive = 'primitive_value'; - - $collection = new \Doctrine\Common\Collections\ArrayCollection([$obj1, $obj2, $primitive]); - - $result = $serializer->serializeAssociation($collection); - self::assertEquals([1, 'two', 'primitive_value'], $result); - } - - public function testSerializeLargeCollectionIdentifiersOnly(): void - { - $logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $serializer = new ValueSerializer($logger); - - // Mix objects and primitives - $items = []; - for ($i = 0; $i < 101; ++$i) { - $items[] = new class($i) { - public function __construct(private int $id) - { - } - - public function getId(): int - { - return $this->id; - } - }; - } - $items[50] = 'primitive'; // Insert a primitive to cover is_object() = false logic - - $collection = new \Doctrine\Common\Collections\ArrayCollection($items); - - $result = $serializer->serializeAssociation($collection); - - self::assertIsArray($result); - self::assertTrue($result['_truncated']); - self::assertEquals(101, $result['_total_count']); - self::assertCount(100, $result['_sample']); - - self::assertEquals(0, $result['_sample'][0]); - self::assertEquals('primitive', $result['_sample'][50]); - } - - /** - * Test that serialize(null) returns null, not void. - * Kills mutant: MatchArm removal for null => null. - */ - public function testSerializeNullReturnsNull(): void - { - $serializer = new ValueSerializer(); - $result = $serializer->serialize(null); - self::assertNull($result); } - /** - * Test serializeAssociation(null) returns exactly null (not void). - * Kills mutant: ReturnRemoval in serializeAssociation null branch. - */ - public function testSerializeAssociationNullReturnsNull(): void - { - $serializer = new ValueSerializer(); - $result = $serializer->serializeAssociation(null); - self::assertNull($result); - } - - /** - * Test boundary: collection with exactly 100 items should NOT be truncated. - * Kills mutant: > vs >= on MAX_COLLECTION_ITEMS check. - */ - public function testSerializeCollectionBoundaryExactly100(): void - { - $serializer = new ValueSerializer(); - $items = array_fill(0, 100, 'item'); - $collection = new \Doctrine\Common\Collections\ArrayCollection($items); - - $result = $serializer->serialize($collection); - // 100 items should NOT be truncated (> 100, not >= 100) - self::assertIsArray($result); - self::assertArrayNotHasKey('_truncated', $result); - self::assertCount(100, $result); - } - - /** - * Test collection with nested arrays to detect depth increment mutations. - * Kills mutants: depth + 1 → depth + 0, depth + 2, depth - 1. - */ - public function testSerializeCollectionDepthIncrement(): void + public function testSerializeRespectsMaxDepth(): void { $serializer = new ValueSerializer(); - // Create collection with deeply nested arrays - // depth starts at 0 when called from serialize() - // collection at depth 0, items serialized at depth 1, nested arrays at depth 2, etc. - $nestedItem = [ + $deepArray = [ 'level1' => [ 'level2' => [ 'level3' => [ 'level4' => [ - 'level5' => 'deep_value', + 'level5' => [ + 'level6' => 'value', + ], ], ], ], ], ]; - $collection = new \Doctrine\Common\Collections\ArrayCollection([$nestedItem]); - $result = $serializer->serialize($collection); + $serialized = $serializer->serialize($deepArray); - // With correct depth: serialize(collection, 0) → items serialized at depth 1 - // The nested array should reach max depth at level 5 - self::assertIsArray($result); - self::assertIsArray($result[0]); - self::assertIsArray($result[0]['level1']); - self::assertIsArray($result[0]['level1']['level2']); - self::assertIsArray($result[0]['level1']['level2']['level3']); - // At depth 5 (0 + 1 for collection + 4 levels), should hit max depth - self::assertSame('[max depth reached]', $result[0]['level1']['level2']['level3']['level4']); + self::assertSame( + [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => [ + 'level5' => '[max depth reached]', + ], + ], + ], + ], + ], + $serialized + ); } - /** - * Test serializeAssociation with objects uses extractEntityIdentifier (onlyIdentifiers=true). - * vs serialize() which uses full serialization (onlyIdentifiers=false). - * Kills mutants: TrueValue and FalseValue on onlyIdentifiers parameter. - */ - public function testSerializeAssociationUsesIdentifiersNotFullSerialization(): void + #[DataProvider('provideCollectionModes')] + public function testCollectionSerializationModes(string $mode, mixed $expected): void { - $serializer = new ValueSerializer(); + $serializer = new ValueSerializer(null, $mode); + /** @var ClassMetadata&MockObject $metadata */ + $metadata = $this->createMock(ClassMetadata::class); + $metadata->method('getName')->willReturn(TestEntity::class); - // Object with getId returning an object with __toString - $entity = new class { - public function getId(): int - { - return 42; - } - - public function __toString(): string - { - return 'Entity#42'; - } - }; + /** @var ArrayCollection&Selectable $inner */ + $inner = new ArrayCollection([new TestEntity(1), new TestEntity(2)]); + /** @phpstan-ignore-next-line */ + $collection = $this->createUninitializedCollection($this->em, $metadata, $inner); - // serializeAssociation with a single object should use extractEntityIdentifier - $assocResult = $serializer->serializeAssociation($entity); - self::assertSame(42, $assocResult); // extractEntityIdentifier returns getId() + $result = $serializer->serialize($collection); - // serialize with the same object should use serializeObject - $serializeResult = $serializer->serialize($entity); - self::assertSame(42, $serializeResult); // serializeObject also returns getId() + self::assertEquals($expected, $result); + } - // Now test with a collection - serializeAssociation should use onlyIdentifiers=true - $collection = new \Doctrine\Common\Collections\ArrayCollection([$entity]); + /** + * @return iterable + */ + public static function provideCollectionModes(): iterable + { + yield 'lazy mode returns placeholder' => [ + 'lazy', + ['_state' => 'uninitialized', '_total_count' => 'unknown'], + ]; - $assocCollectionResult = $serializer->serializeAssociation($collection); - self::assertSame([42], $assocCollectionResult); + yield 'eager mode returns full values (IDs by default)' => [ + 'eager', + [1, 2], + ]; - // serialize() on collection uses onlyIdentifiers=false — also returns getId via serializeObject - $serializeCollectionResult = $serializer->serialize($collection); - self::assertSame([42], $serializeCollectionResult); + yield 'ids_only mode returns IDs' => [ + 'ids_only', + [1, 2], + ]; } - /** - * Test large collection with onlyIdentifiers=false (via serialize, not serializeAssociation). - * Ensures truncated sample uses serialize(), not extractEntityIdentifier. - * Kills mutants on depth + 1 in truncated branch. - */ - public function testSerializeLargeCollectionWithoutIdentifiersOnly(): void + public function testIdsOnlyModeForcesIdentifiersEvenDirectly(): void { - $logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $serializer = new ValueSerializer($logger); + $serializer = new ValueSerializer(null, 'ids_only'); - // Create 101 items that are nested arrays to test depth - $items = []; - for ($i = 0; $i < 101; ++$i) { - $items[] = ['nested' => ['value' => $i]]; - } - $collection = new \Doctrine\Common\Collections\ArrayCollection($items); + $collection = new ArrayCollection([new TestEntity(1), new TestEntity(2)]); + // Direct serialize() usually tries for detail, but ids_only should lock it to IDs $result = $serializer->serialize($collection); - self::assertIsArray($result); - self::assertTrue($result['_truncated']); - self::assertCount(100, $result['_sample']); - // First item should be fully serialized (not just identifier extraction) - self::assertIsArray($result['_sample'][0]); - self::assertSame(['value' => 0], $result['_sample'][0]['nested']); + self::assertEquals([1, 2], $result); } - /** - * Test that slice(0, ...) matters — first item must be present. - * Kills mutant: slice(0, ...) → slice(1, ...). - */ - public function testLargeCollectionSliceStartsAtZero(): void + public function testMaxItemsRespectsConfig(): void { $logger = $this->createMock(\Psr\Log\LoggerInterface::class); - $serializer = new ValueSerializer($logger); + $logger->expects($this->once())->method('warning'); - $items = []; - for ($i = 0; $i < 101; ++$i) { - $items[] = 'item_'.$i; - } - $collection = new \Doctrine\Common\Collections\ArrayCollection($items); + $serializer = new ValueSerializer($logger, 'eager', 2); + $collection = new ArrayCollection([1, 2, 3, 4, 5]); $result = $serializer->serialize($collection); - self::assertIsArray($result); self::assertTrue($result['_truncated']); - // First item should be item_0, not item_1 - self::assertSame('item_0', $result['_sample'][0]); - // Last item in sample should be item_99 - self::assertSame('item_99', $result['_sample'][99]); + self::assertEquals(5, $result['_total_count']); + self::assertCount(2, $result['_sample']); + self::assertEquals([1, 2], $result['_sample']); } - /** - * Test large collection via serializeAssociation logger null-safe call. - * Kills mutant: logger?-> vs logger->. - */ - public function testLargeCollectionWithoutLoggerDoesNotCrash(): void + public function testSerializeObjectIdRecursionLimit(): void { - // No logger - should not crash on null safe method call - $serializer = new ValueSerializer(null); + $serializer = new ValueSerializer(); + + $current = new class { + public function getId(): object + { + return new stdClass(); + } + }; - $items = array_fill(0, 101, 'item'); - $collection = new \Doctrine\Common\Collections\ArrayCollection($items); + for ($i = 0; $i < 5; ++$i) { + $parent = new class { + public mixed $child; - $result = $serializer->serialize($collection); + public function getId(): mixed + { + return $this->child; + } + }; + $parent->child = $current; + $current = $parent; + } - self::assertIsArray($result); - self::assertTrue($result['_truncated']); + self::assertSame('[max depth reached]', $serializer->serialize($current)); } } diff --git a/tests/Unit/Transport/AsyncDatabaseAuditTransportTest.php b/tests/Unit/Transport/AsyncDatabaseAuditTransportTest.php new file mode 100644 index 0000000..65e6482 --- /dev/null +++ b/tests/Unit/Transport/AsyncDatabaseAuditTransportTest.php @@ -0,0 +1,88 @@ +bus = $this->createMock(MessageBusInterface::class); + $this->messageFactory = $this->createMock(AuditLogMessageFactory::class); + + $this->transport = new AsyncDatabaseAuditTransport( + $this->bus, + $this->messageFactory, + ); + } + + public function testSendDispatchesPersistMessage(): void + { + $log = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE); + + $persistMessage = self::createStub(PersistAuditLogMessage::class); + + $this->messageFactory->expects($this->once()) + ->method('createPersistMessage') + ->with($log, []) + ->willReturn($persistMessage); + + $this->bus->expects($this->once()) + ->method('dispatch') + ->with($persistMessage) + ->willReturn(new Envelope(new stdClass())); + + $this->transport->send($log); + } + + public function testSendPassesContextToFactory(): void + { + $log = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_UPDATE); + $context = ['phase' => 'post_flush', 'em' => 'mock']; + + $persistMessage = self::createStub(PersistAuditLogMessage::class); + + $this->messageFactory->expects($this->once()) + ->method('createPersistMessage') + ->with($log, $context) + ->willReturn($persistMessage); + + $this->bus->expects($this->once()) + ->method('dispatch') + ->willReturn(new Envelope(new stdClass())); + + $this->transport->send($log, $context); + } + + public function testSupportsPostFlushAndPostLoad(): void + { + self::assertTrue($this->transport->supports('post_flush')); + self::assertTrue($this->transport->supports('post_load')); + } + + public function testDoesNotSupportOnFlush(): void + { + self::assertFalse($this->transport->supports('on_flush')); + self::assertFalse($this->transport->supports('pre_flush')); + } +} diff --git a/tests/Unit/Transport/QueueAuditTransportTest.php b/tests/Unit/Transport/QueueAuditTransportTest.php index e0fb7b2..d4d95ae 100644 --- a/tests/Unit/Transport/QueueAuditTransportTest.php +++ b/tests/Unit/Transport/QueueAuditTransportTest.php @@ -5,16 +5,16 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Transport; use DateTimeImmutable; -use Doctrine\ORM\EntityManagerInterface; +use DateTimeInterface; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; -use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Event\AuditMessageStampEvent; +use Rcsofttech\AuditTrailBundle\Factory\AuditLogMessageFactory; use Rcsofttech\AuditTrailBundle\Message\AuditLogMessage; use Rcsofttech\AuditTrailBundle\Message\Stamp\ApiKeyStamp; use Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp; @@ -35,20 +35,20 @@ class QueueAuditTransportTest extends TestCase private AuditIntegrityServiceInterface&MockObject $integrityService; - private EntityIdResolverInterface&MockObject $idResolver; + private AuditLogMessageFactory&MockObject $messageFactory; protected function setUp(): void { $this->bus = $this->createMock(MessageBusInterface::class); $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); - $this->idResolver = $this->createMock(EntityIdResolverInterface::class); + $this->messageFactory = $this->createMock(AuditLogMessageFactory::class); $this->transport = new QueueAuditTransport( $this->bus, $this->eventDispatcher, $this->integrityService, - $this->idResolver, + $this->messageFactory, 'test_api_key' ); } @@ -57,6 +57,23 @@ public function testSendDispatchesMessageWithStamps(): void { $log = new AuditLog('TestEntity', '1', AuditLogInterface::ACTION_CREATE, new DateTimeImmutable()); + $queueMessage = new AuditLogMessage( + entityClass: 'TestEntity', + entityId: '1', + action: 'create', + oldValues: null, + newValues: null, + changedFields: null, + userId: null, + username: null, + ipAddress: null, + userAgent: null, + transactionHash: null, + createdAt: $log->createdAt->format(DateTimeInterface::ATOM), + ); + + $this->messageFactory->method('createQueueMessage')->willReturn($queueMessage); + $this->integrityService->method('isEnabled')->willReturn(true); $this->integrityService->method('signPayload')->willReturn('test_signature'); @@ -94,6 +111,23 @@ public function testSendPropagatesException(): void { $log = new AuditLog('TestEntity', '1', AuditLogInterface::ACTION_CREATE, new DateTimeImmutable()); + $queueMessage = new AuditLogMessage( + entityClass: 'TestEntity', + entityId: '1', + action: 'create', + oldValues: null, + newValues: null, + changedFields: null, + userId: null, + username: null, + ipAddress: null, + userAgent: null, + transactionHash: null, + createdAt: $log->createdAt->format(DateTimeInterface::ATOM), + ); + + $this->messageFactory->method('createQueueMessage')->willReturn($queueMessage); + $this->bus->method('dispatch')->willThrowException(new Exception('Bus error')); $this->expectException(Exception::class); @@ -106,14 +140,22 @@ public function testSendResolvesPendingId(): void { $log = new AuditLog('TestEntity', 'pending', AuditLogInterface::ACTION_CREATE); - // pass context that EntityIdResolver understands. - $context = ['is_insert' => true]; - - $this->idResolver->method('resolve')->willReturn('123'); + $queueMessage = new AuditLogMessage( + entityClass: 'TestEntity', + entityId: '123', + action: 'create', + oldValues: null, + newValues: null, + changedFields: null, + userId: null, + username: null, + ipAddress: null, + userAgent: null, + transactionHash: null, + createdAt: $log->createdAt->format(DateTimeInterface::ATOM), + ); - $entity = new stdClass(); - $em = $this->createMock(EntityManagerInterface::class); - $context = ['entity' => $entity, 'em' => $em]; + $this->messageFactory->method('createQueueMessage')->willReturn($queueMessage); $this->bus->expects($this->once()) ->method('dispatch') @@ -122,13 +164,30 @@ public function testSendResolvesPendingId(): void })) ->willReturn(new Envelope(new stdClass())); - $this->transport->send($log, $context); + $this->transport->send($log); } public function testSendIsCancelledByStoppingPropagation(): void { $log = new AuditLog('TestEntity', '1', AuditLogInterface::ACTION_CREATE, new DateTimeImmutable()); + $queueMessage = new AuditLogMessage( + entityClass: 'TestEntity', + entityId: '1', + action: 'create', + oldValues: null, + newValues: null, + changedFields: null, + userId: null, + username: null, + ipAddress: null, + userAgent: null, + transactionHash: null, + createdAt: $log->createdAt->format(DateTimeInterface::ATOM), + ); + + $this->messageFactory->method('createQueueMessage')->willReturn($queueMessage); + $this->eventDispatcher->expects($this->once()) ->method('dispatch') ->willReturnCallback(static function (AuditMessageStampEvent $event) { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8b9ceaf..874804b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,6 +14,13 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; use Rcsofttech\AuditTrailBundle\Tests\Functional\TestKernel; +use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\DummySoftDeleteableFilter; + +// Register dummy Gedmo filter if the real package is not installed. +// This alias is used by AuditReverterTest and related soft-delete tests. +if (!class_exists('Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter')) { + class_alias(DummySoftDeleteableFilter::class, 'Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter'); +} // This ensures that even after kernel shutdown, the underlying database connection // (especially for in-memory SQLite) persists in DAMA's static registry.