From df56fdcd7e6095cd2e885ab01fabbf6df8bd03aa Mon Sep 17 00:00:00 2001 From: rahul chavan Date: Sat, 21 Feb 2026 10:49:42 +0530 Subject: [PATCH] release v2.0.0 --- .codacy.yml | 53 ++- CHANGELOG.md | 81 ++++ composer.json | 13 +- composer.lock | 157 ++++---- docs/audit-log-benchmark.md | 98 ++++- src/AuditTrailBundle.php | 11 +- src/Command/AuditDiffCommand.php | 27 +- src/Command/AuditExportCommand.php | 26 +- src/Command/AuditListCommand.php | 4 +- src/Command/AuditPurgeCommand.php | 41 ++ src/Command/AuditRevertCommand.php | 11 +- src/Command/BaseAuditCommand.php | 31 +- src/Command/VerifyIntegrityCommand.php | 36 +- src/Contract/AuditDispatcherInterface.php | 24 ++ src/Contract/AuditExporterInterface.php | 17 + .../AuditIntegrityServiceInterface.php | 6 +- src/Contract/AuditLogInterface.php | 114 +----- src/Contract/AuditLogRepositoryInterface.php | 5 + .../AuditMetadataManagerInterface.php | 29 ++ src/Contract/AuditRendererInterface.php | 25 ++ src/Contract/AuditReverterInterface.php | 5 +- src/Contract/AuditServiceInterface.php | 52 +++ src/Contract/AuditTransportInterface.php | 6 +- src/Contract/ChangeProcessorInterface.php | 24 ++ src/Contract/ContextResolverInterface.php | 22 + src/Contract/DataMaskerInterface.php | 23 ++ src/Contract/EntityIdResolverInterface.php | 22 + src/Contract/EntityProcessorInterface.php | 22 + .../ScheduledAuditManagerInterface.php | 26 ++ src/Contract/SoftDeleteHandlerInterface.php | 22 + src/Contract/ValueSerializerInterface.php | 12 + .../AuditTrailExtension.php | 19 +- src/DependencyInjection/Configuration.php | 13 +- src/Entity/AuditLog.php | 376 +++++------------- src/Event/AuditLogCreatedEvent.php | 29 +- src/Event/AuditMessageStampEvent.php | 14 +- src/EventSubscriber/AuditSubscriber.php | 72 ++-- src/Message/AuditLogMessage.php | 45 +-- src/Query/AuditEntry.php | 228 +++-------- src/Query/AuditEntryCollection.php | 12 +- src/Query/AuditQuery.php | 53 +-- src/Query/AuditReader.php | 23 +- src/Repository/AuditLogRepository.php | 21 +- src/Resources/config/services.yaml | 60 ++- src/Service/AuditAccessHandler.php | 27 +- src/Service/AuditDispatcher.php | 117 +++--- src/Service/AuditExporter.php | 119 ++++-- src/Service/AuditIntegrityService.php | 197 +++++---- src/Service/AuditMetadataManager.php | 71 ++++ src/Service/AuditRenderer.php | 42 +- src/Service/AuditReverter.php | 41 +- src/Service/AuditService.php | 206 ++++------ src/Service/ChangeProcessor.php | 13 +- src/Service/ContextResolver.php | 114 ++++++ src/Service/DataMasker.php | 66 +++ src/Service/EntityDataExtractor.php | 13 +- src/Service/EntityIdResolver.php | 99 +++-- src/Service/EntityProcessor.php | 49 ++- src/Service/ExpressionLanguageVoter.php | 106 ++++- src/Service/MetadataCache.php | 154 ++++--- src/Service/RevertValueDenormalizer.php | 34 +- src/Service/ScheduledAuditManager.php | 49 +-- src/Service/SoftDeleteHandler.php | 23 +- src/Service/ValueSerializer.php | 34 +- src/Transport/ChainAuditTransport.php | 6 +- src/Transport/DoctrineAuditTransport.php | 25 +- src/Transport/HttpAuditTransport.php | 51 ++- src/Transport/QueueAuditTransport.php | 43 +- tests/Functional/AuditAccessTest.php | 8 +- tests/Functional/Command/AuditCommandTest.php | 20 +- .../Command/AuditUserAttributionTest.php | 23 +- .../Functional/Entity/TestEntityWithUuid.php | 45 +++ .../Event/AuditLogCreatedEventTest.php | 80 ++++ tests/Functional/InheritanceTest.php | 8 +- tests/Functional/IntegrityTest.php | 15 +- .../Functional/MultiEntityTransactionTest.php | 6 +- .../SmartFlushDetectionProofTest.php | 240 +++++++++++ tests/Functional/TestKernel.php | 7 +- .../UserProviderIntegrationTest.php | 12 +- tests/Security/SecurityAuditTest.php | 287 +++++++++++++ tests/Unit/AbstractAuditTestCase.php | 48 ++- tests/Unit/Command/AuditDiffCommandTest.php | 125 +++--- tests/Unit/Command/AuditExportCommandTest.php | 24 +- tests/Unit/Command/AuditListCommandTest.php | 40 +- tests/Unit/Command/AuditPurgeCommandTest.php | 7 +- tests/Unit/Command/AuditRevertCommandTest.php | 82 ++-- .../Command/VerifyIntegrityCommandTest.php | 59 ++- tests/Unit/Entity/AuditLogTest.php | 30 +- .../Unit/Event/AuditMessageStampEventTest.php | 17 +- .../EventSubscriber/AuditSubscriberTest.php | 101 +++-- .../AuditSubscriberTransportSupportTest.php | 18 +- .../MockScheduledAuditManager.php | 33 ++ tests/Unit/Query/AuditEntryCollectionTest.php | 11 +- tests/Unit/Query/AuditEntryTest.php | 75 ++-- tests/Unit/Query/AuditQueryTest.php | 50 +-- tests/Unit/Query/AuditReaderTest.php | 82 +--- .../Repository/AuditLogRepositoryTest.php | 47 ++- .../Unit/Security/SensitiveDataUpdateTest.php | 23 +- .../AuditLogMessageSerializerTest.php | 13 +- tests/Unit/Service/AuditAccessHandlerTest.php | 249 +++++++++--- tests/Unit/Service/AuditDispatcherTest.php | 99 ++--- tests/Unit/Service/AuditExporterTest.php | 72 ++++ .../Unit/Service/AuditIntegrityRevertTest.php | 34 +- .../Service/AuditIntegrityServiceTest.php | 351 ++++++++-------- tests/Unit/Service/AuditRendererTest.php | 117 ++++++ .../Unit/Service/AuditRevertIntegrityTest.php | 51 ++- tests/Unit/Service/AuditReverterTest.php | 143 ++++--- tests/Unit/Service/AuditServiceTest.php | 226 +++++------ .../Unit/Service/AuditServiceTimezoneTest.php | 52 ++- tests/Unit/Service/ChangeProcessorTest.php | 18 +- tests/Unit/Service/ContextResolverTest.php | 93 +++++ tests/Unit/Service/DataMaskerTest.php | 55 +++ .../Unit/Service/EntityDataExtractorTest.php | 6 +- tests/Unit/Service/EntityIdResolverTest.php | 61 ++- tests/Unit/Service/EntityProcessorTest.php | 67 +++- .../Service/ExpressionLanguageVoterTest.php | 40 ++ .../Service/ScheduledAuditManagerTest.php | 22 +- tests/Unit/Service/UserResolverTest.php | 139 +++++++ .../Transport/ChainAuditTransportTest.php | 2 +- .../Transport/DoctrineAuditTransportTest.php | 23 +- .../Transport/HttpAuditTransportIssueTest.php | 4 +- .../Unit/Transport/HttpAuditTransportTest.php | 42 +- .../Transport/QueueAuditTransportTest.php | 70 ++-- 123 files changed, 4675 insertions(+), 2706 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/Contract/AuditDispatcherInterface.php create mode 100644 src/Contract/AuditExporterInterface.php create mode 100644 src/Contract/AuditMetadataManagerInterface.php create mode 100644 src/Contract/AuditRendererInterface.php create mode 100644 src/Contract/AuditServiceInterface.php create mode 100644 src/Contract/ChangeProcessorInterface.php create mode 100644 src/Contract/ContextResolverInterface.php create mode 100644 src/Contract/DataMaskerInterface.php create mode 100644 src/Contract/EntityIdResolverInterface.php create mode 100644 src/Contract/EntityProcessorInterface.php create mode 100644 src/Contract/ScheduledAuditManagerInterface.php create mode 100644 src/Contract/SoftDeleteHandlerInterface.php create mode 100644 src/Contract/ValueSerializerInterface.php create mode 100644 src/Service/AuditMetadataManager.php create mode 100644 src/Service/ContextResolver.php create mode 100644 src/Service/DataMasker.php create mode 100644 tests/Functional/Entity/TestEntityWithUuid.php create mode 100644 tests/Functional/Event/AuditLogCreatedEventTest.php create mode 100644 tests/Functional/SmartFlushDetectionProofTest.php create mode 100644 tests/Security/SecurityAuditTest.php create mode 100644 tests/Unit/EventSubscriber/MockScheduledAuditManager.php create mode 100644 tests/Unit/Service/AuditExporterTest.php create mode 100644 tests/Unit/Service/AuditRendererTest.php create mode 100644 tests/Unit/Service/ContextResolverTest.php create mode 100644 tests/Unit/Service/DataMaskerTest.php diff --git a/.codacy.yml b/.codacy.yml index fca5d43..373f726 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,7 +1,58 @@ +--- +# ============================================================================= +# Codacy Configuration File +# Docs: https://docs.codacy.com/repositories-configure/codacy-configuration-file/ +# ============================================================================= +exclude_paths: # Global static analysis ignores + - "docs/**" + - "*.md" + - "tests/**" + - "phpunit.xml*" + - ".github/**" + - "src/DependencyInjection/**" + - "src/Resources/**" + - "src/AuditTrailBundle.php" + - "src/Entity/AuditLog.php" + - "vendor/**" + - "var/**" + - "bin/**" + - ".php-cs-fixer.cache" + - ".phpunit.result.cache" + +# ============================================================================= +# Explicit Coverage Exclusions +# ============================================================================= +coverage: + exclude_paths: + - "docs/**" + - "*.md" + - "tests/**" + - "phpunit.xml*" + - ".github/**" + - "src/DependencyInjection/**" + - "src/Resources/**" + - "src/AuditTrailBundle.php" + - "vendor/**" + +# ============================================================================= +# Engine-specific overrides +# ============================================================================= engines: phpcs: exclude_paths: - - "src/Entity/AuditLog.php" - "src/Query/AuditEntry.php" - "src/Event/AuditLogCreatedEvent.php" + + duplication: + minTokenMatch: 20 + ignoreIdentifiers: true + ignoreLiterals: false + +# ============================================================================= +# Language configuration +# ============================================================================= +languages: + php: + extensions: + - ".php" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7f5208e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to the **AuditTrailBundle** will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [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 + +- **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. +- **AuditLog Identification**: The primary key for the `AuditLog` entity has shifted from **Integer to UUID** (`Symfony\Component\Uid\Uuid`). This requires a database migration. +- **AuditLog Constructor**: Now enforces mandatory parameters (`entityClass`, `entityId`, `action`) at instantiation time. +- **AuditLog Entity is Non-Readonly**: The entity uses `private(set)` per-property instead of a global `readonly` class, enabling the `seal()` mechanism and controlled mutability via property hooks. +- **AuditEntry Getters Removed**: All getter methods on `AuditEntry` have been replaced with read-only property hooks (e.g., `$entry->getEntityClass()` → `$entry->entityClass`). +- **AuditQuery `execute()` Removed**: Use `getResults()`, `getFirstResult()`, `count()`, or `exists()` instead. +- **Interface Segregation**: All core services now reside behind interfaces in `Rcsofttech\AuditTrailBundle\Contract`. Manual service type-hints must be updated to use these interfaces (23 contracts total). +- **Service Layer Shift**: Metadata, user context, and entity identification logic has been decoupled into specialized services (`AuditMetadataManager`, `ContextResolver`, `EntityIdResolver`). +- **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 + +- **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. + - **Asymmetric Visibility**: Core properties use `public private(set)` to expose data for reading while protecting internal state. + - **Typed Class Constants**: `AuditLogInterface` uses typed `string` and `array` constants for action names. +- **Immutable State (Sealing)**: Introduced a `seal()` mechanism in `AuditLog`. Once sealed, property hooks prevent modification of `entityId`, `context`, and `signature`, enforced even via `ReflectionProperty::setValue()`. +- **Transport Architecture**: + - **Chain Transport**: Dispatches audit logs to multiple transports in sequence. + - **Doctrine Transport**: Persists audit logs directly to the database. + - **HTTP Transport**: Sends audit logs to an external HTTP endpoint with configurable timeout and headers. + - **Queue Transport**: Dispatches audit logs via Symfony Messenger for async processing. Supports custom stamps via `AuditMessageStampEvent` and propagation cancellation. +- **Event System**: + - **`AuditLogCreatedEvent`**: Dispatched when an audit log is created, allowing listeners to add metadata, filter, or modify logs before persistence and signing. + - **`AuditMessageStampEvent`**: Dispatched before queue transport dispatch, allowing custom Messenger stamps or cancellation via `stopPropagation()`. +- **Modular Data Masking**: Introduced `DataMaskerInterface` with a default `DataMasker` that auto-redacts common sensitive keys (`password`, `token`, `secret`, `api_key`, `cookie`, etc.) case-insensitively, including nested arrays. +- **`#[Sensitive]` Attribute**: Mark entity properties with `#[Sensitive(mask: '***')]` to automatically mask values in audit logs. Also supports `#[SensitiveParameter]` on promoted constructor parameters. +- **`#[AuditCondition]` Attribute**: Conditional auditing using Symfony Expression Language with a security sandbox (`ExpressionLanguageVoter`) that blocks dangerous functions (`system`, `exec`, `passthru`, `constant`, etc.). +- **`#[AuditAccess]` Attribute**: Configurable read-access auditing with HTTP method filtering via `audited_methods`. +- **Audit Reverter**: Full revert support for `create`, `update`, and `soft_delete` actions with dry-run mode, `--force` flag, and automatic signature verification before revert. +- **Fluent Query API**: Rebuilt `AuditQuery` as an immutable, fluent query builder with `entity()`, `action()`, `user()`, `transaction()`, `since()`, `until()`, `between()`, `changedField()`, `creates()`, `updates()`, `deletes()`, `after()`, `before()`, and keyset (cursor) pagination. +- **`AuditEntryCollection`**: Rich collection wrapper with `groupByEntity()`, `groupByTransaction()`, timeline helpers, and iteration support. +- **Context Contributors**: Implement `AuditContextContributorInterface` to inject custom context data into every audit log. Auto-discovered via DI tag `audit_trail.context_contributor`. +- **Impersonation Tracking**: `ContextResolver` automatically captures impersonator ID and username when Symfony's switch-user feature is active. +- **CLI Commands** (7 total): + - `audit:list` — Browse and filter audit logs. + - `audit:diff` — Show field-level changes for a specific audit log. + - `audit:export` — Export audit logs to CSV or JSON. + - `audit:purge` — Purge audit logs by retention policy or entity class. + - `audit:revert` — Revert an entity to a previous state from an audit log. + - `audit:verify-integrity` — Verify HMAC signatures across audit logs. + +### Performance & Scale + +- **Smart Flush Detection**: `EntityProcessor` eliminates redundant database flushes by detecting UUID ID generation strategies, enabling a **Single Flush** cycle. +- **N+1 Prevention**: `ValueSerializer` checks Doctrine's `PersistentCollection::isInitialized()` state, preventing accidental lazy-loading storms. +- **Serialization Depth Limit**: `ValueSerializer` enforces a maximum serialization depth of 5 levels to prevent circular reference DoS and excessive data logging. +- **Collection Item Limit**: `ValueSerializer` caps collection serialization at 100 items to prevent memory exhaustion. +- **Lazy Execution**: `AuditReader`, `AuditRenderer`, and `AuditExporter` are marked as `lazy` in the DI container. +- **Log Storm Protection**: `AuditAccessHandler` implements request-scoped caching to prevent redundant log generation during batch `postLoad` events. +- **Table Prefix/Suffix**: `TablePrefixSubscriber` supports configurable audit table naming. + +### Security Hardening + +- **Canonical HMAC Signing**: Strict canonical normalization layer with sorted, type-prefixed payloads and configurable algorithm (`sha256`, `sha384`, `sha512`). Signatures include `createdAt`, `transactionHash`, `ipAddress`, `userAgent`, and `context` to prevent replay attacks and metadata tampering. +- **Terminal Injection Protection**: `AuditRenderer` strips ANSI escape sequences from all rendered values. +- **Expression Language Sandbox**: `ExpressionLanguageVoter` blacklists dangerous function calls (`system`, `exec`, `passthru`, `shell_exec`, `popen`, `proc_open`, `constant`) and restricts available variables to a predefined whitelist. +- **IP Address Validation**: `AuditLog` property hook validates IP addresses via `filter_var(FILTER_VALIDATE_IP)` at write time. +- **Action Validation**: `AuditLog` property hook validates action strings against `AuditLogInterface::ALL_ACTIONS` at write time. +- **Entity Class Validation**: `AuditLog` property hook trims and rejects empty entity class names. +- **Context Truncation**: Strict 32KB byte-limit enforcement for JSON context data. +- **Parameterized Repository Queries**: `AuditLogRepository::findWithFilters()` uses parameterized queries to prevent SQL injection. + +--- diff --git a/composer.json b/composer.json index 7c16d7d..4aca015 100644 --- a/composer.json +++ b/composer.json @@ -4,17 +4,16 @@ "type": "symfony-bundle", "license": "MIT", "keywords": [ + "symfony-audit", "symfony", "audit", "audit-trail", - "log", + "activity-log", "doctrine", - "entity-tracking", - "history", + "envers", "compliance", - "gdpr", - "high-performance", - "messenger", + "data-integrity", + "hmac-signing", "async-audit" ], "support": { @@ -80,4 +79,4 @@ "infection/extension-installer": true } } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index c3196e5..7360320 100644 --- a/composer.lock +++ b/composer.lock @@ -5320,16 +5320,16 @@ }, { "name": "easycorp/easyadmin-bundle", - "version": "v4.28.1", + "version": "v4.29.1", "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "30950a2ecf70a09e7a8dc2f3064edb42cbc5c465" + "reference": "f2253591e0623c96ca6df25161169eb53a7037c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/30950a2ecf70a09e7a8dc2f3064edb42cbc5c465", - "reference": "30950a2ecf70a09e7a8dc2f3064edb42cbc5c465", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/f2253591e0623c96ca6df25161169eb53a7037c5", + "reference": "f2253591e0623c96ca6df25161169eb53a7037c5", "shasum": "" }, "require": { @@ -5413,7 +5413,7 @@ ], "support": { "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", - "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v4.28.1" + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v4.29.1" }, "funding": [ { @@ -5421,7 +5421,7 @@ "type": "github" } ], - "time": "2026-02-05T20:39:12+00:00" + "time": "2026-02-18T19:14:55+00:00" }, { "name": "evenement/evenement", @@ -5533,16 +5533,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.93.1", + "version": "v3.94.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "b3546ab487c0762c39f308dc1ec0ea2c461fc21a" + "reference": "d1a3634e29916367b885250e1fc4dfd5ffe3b091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b3546ab487c0762c39f308dc1ec0ea2c461fc21a", - "reference": "b3546ab487c0762c39f308dc1ec0ea2c461fc21a", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/d1a3634e29916367b885250e1fc4dfd5ffe3b091", + "reference": "d1a3634e29916367b885250e1fc4dfd5ffe3b091", "shasum": "" }, "require": { @@ -5559,7 +5559,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -5573,18 +5573,18 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7", - "infection/infection": "^0.32", - "justinrainbow/json-schema": "^6.6", + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.9", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.48", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", "symfony/polyfill-php85": "^1.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0" + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -5625,7 +5625,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.93.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.1" }, "funding": [ { @@ -5633,7 +5633,7 @@ "type": "github" } ], - "time": "2026-01-28T23:50:50+00:00" + "time": "2026-02-18T12:24:42+00:00" }, { "name": "infection/abstract-testframework-adapter", @@ -6003,16 +6003,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.6.4", + "version": "v6.7.2", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" + "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/6fea66c7204683af437864e7c4e7abf383d14bc0", + "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0", "shasum": "" }, "require": { @@ -6072,9 +6072,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" + "source": "https://github.com/jsonrainbow/json-schema/tree/v6.7.2" }, - "time": "2025-12-19T15:01:32+00:00" + "time": "2026-02-15T15:06:22+00:00" }, { "name": "marc-mabe/php-enum", @@ -6513,11 +6513,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.39", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", "shasum": "" }, "require": { @@ -6562,20 +6562,20 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-11T14:48:56+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.15", + "version": "2.0.17", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "ce03c55ea8cb6d154bd0a04af05b58066812bea4" + "reference": "734ef36c2709b51943f04aacadddb76f239562d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/ce03c55ea8cb6d154bd0a04af05b58066812bea4", - "reference": "ce03c55ea8cb6d154bd0a04af05b58066812bea4", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/734ef36c2709b51943f04aacadddb76f239562d3", + "reference": "734ef36c2709b51943f04aacadddb76f239562d3", "shasum": "" }, "require": { @@ -6636,22 +6636,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.15" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.17" }, - "time": "2026-02-10T12:01:22+00:00" + "time": "2026-02-18T10:21:23+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.14", + "version": "2.0.16", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "f7553d6c613878d04f7e7ef129d4607118cd7cd4" + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f7553d6c613878d04f7e7ef129d4607118cd7cd4", - "reference": "f7553d6c613878d04f7e7ef129d4607118cd7cd4", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", "shasum": "" }, "require": { @@ -6692,27 +6692,27 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.14" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" }, - "time": "2026-02-10T11:57:48+00:00" + "time": "2026-02-14T09:05:21+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.8", + "version": "2.0.10", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "1ed9e626a37f7067b594422411539aa807190573" + "reference": "1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/1ed9e626a37f7067b594422411539aa807190573", - "reference": "1ed9e626a37f7067b594422411539aa807190573", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f", + "reference": "1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.29" + "phpstan/phpstan": "^2.1.39" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", @@ -6738,24 +6738,27 @@ "MIT" ], "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.8" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.10" }, - "time": "2026-01-27T08:10:25+00:00" + "time": "2026-02-11T14:17:32+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.13", + "version": "2.0.14", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "6feadf70de5fa2c8a106c443bd04e16ed0b17d64" + "reference": "678136545a552a33b07f1a59a013f76df286cc34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/6feadf70de5fa2c8a106c443bd04e16ed0b17d64", - "reference": "6feadf70de5fa2c8a106c443bd04e16ed0b17d64", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/678136545a552a33b07f1a59a013f76df286cc34", + "reference": "678136545a552a33b07f1a59a013f76df286cc34", "shasum": "" }, "require": { @@ -6814,9 +6817,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.13" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.14" }, - "time": "2026-02-10T08:55:32+00:00" + "time": "2026-02-11T12:27:30+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7166,16 +7169,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.11", + "version": "12.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9b518cb40f9474572c9f0178e96ff3dc1cf02bf1" + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9b518cb40f9474572c9f0178e96ff3dc1cf02bf1", - "reference": "9b518cb40f9474572c9f0178e96ff3dc1cf02bf1", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", "shasum": "" }, "require": { @@ -7244,7 +7247,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.11" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" }, "funding": [ { @@ -7268,7 +7271,7 @@ "type": "tidelift" } ], - "time": "2026-02-10T12:32:02+00:00" + "time": "2026-02-18T12:38:40+00:00" }, { "name": "react/cache", @@ -10425,16 +10428,16 @@ }, { "name": "thecodingmachine/safe", - "version": "v3.3.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { @@ -10544,7 +10547,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { @@ -10555,12 +10558,16 @@ "url": "https://github.com/shish", "type": "github" }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, { "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-05-14T06:15:44+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { "name": "theseer/tokenizer", @@ -10835,16 +10842,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.5", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", "shasum": "" }, "require": { @@ -10891,9 +10898,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.5" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-18T14:09:36+00:00" } ], "aliases": [], diff --git a/docs/audit-log-benchmark.md b/docs/audit-log-benchmark.md index ca4f9f8..b5872ef 100644 --- a/docs/audit-log-benchmark.md +++ b/docs/audit-log-benchmark.md @@ -1,34 +1,90 @@ -# AuditTrailBundle Benchmarks +# AuditTrailBundle v2 — Benchmarks -This document provides performance metrics for the `AuditTrailBundle` to demonstrate its efficiency in high-performance environments. +Performance metrics for the AuditTrailBundle v2, measured with [PHPBench](https://github.com/phpbench/phpbench). ## Environment -- **PHP**: 8.4.12 -- **Database**: SQLite (In-memory for tests) -- **Benchmarking Tool**: [PHPBench](https://github.com/phpbench/phpbench) +- **PHP**: 8.4.12 (CLI, NTS) +- **Database**: SQLite (in-memory) +- **OPcache**: Off +- **Xdebug**: Off +- **Tool**: PHPBench 1.4.3 +- **Date**: 2026-02-21 ## Results -| Operation | Revs | Iterations | Time (mode) | Memory (peak) | -| :--- | :--- | :--- | :--- | :--- | -| **Audit Creation (Overhead)** | 100 | 5 | 1.66ms / flush | 11.25 MB | -| **Baseline (Auditing Disabled)** | 100 | 5 | 0.68ms / flush | 10.41 MB | -| **Audit Retrieval (10 logs)** | 10 | 5 | 5.60ms | 12.86 MB | -| **Audit Purge (1000 logs)** | 1 | 5 | 44.14ms | 21.79 MB | +### Entity Creation (Flush Overhead) -### Analysis +This compares the overhead of creating a single audited entity, side-by-side for entities with standard **Integer IDs** vs **UUIDs**, as well as with HMAC Integrity **ON** vs **OFF**. -1. **Creation Overhead**: Enabling auditing adds approximately **1ms** of overhead per Doctrine `flush()` operation. This is highly efficient and suitable for most applications. -2. **Retrieval Performance**: Retrieving audit logs using the `AuditReader` is fast, taking only a few milliseconds even with hundreds of logs in the database. -3. **Purge Efficiency**: The `audit:purge` command can handle thousands of records in under 50ms, ensuring that maintenance tasks do not impact system performance. +| Operation | Integer ID (Time) | UUID (Time) | Memory (peak) | +| :--- | :--- | :--- | :--- | +| **Single Insert (Auditing ON + HMAC ON)** | ~10.2ms / flush | ~7.3ms / flush | ~14.3 MB | +| **Single Insert (Auditing ON + HMAC OFF)** | ~10.6ms / flush | ~7.4ms / flush | ~14.3 MB | +| **Single Insert (Auditing OFF)** | ~0.8ms / flush | ~0.9ms / flush | ~13.5 MB | +| **Bulk Insert ×10 (Auditing ON + HMAC ON)** | ~31.3ms / flush | ~31.7ms / flush | ~13.7 MB | -## Time per Operation (ms) +*(Note: Small variance in total times across runs is normal due to environmental noise, but relative differences remain stable).* + +### Transport Lifecycle Overhead + +This compares the synchronous CPU cost of dispatching the audit event to the three supported transports, and then measures the Consumer Worker throughput. + +| Transport / Component | Time / item | Description | +| :--- | :--- | :--- | +| **Doctrine (Sync write)** | ~9.5ms | Full synchronous SQL insert loop natively. | +| **HTTP (Async Dispatch)** | ~11.4ms | Payload serialization + HTTP Client network dispatch. | +| **Queue (Async Dispatch)** | ~12.2ms | Payload instantiation + Messenger Stamp Event + Bus dispatch. | +| **Queue Worker (Consumer)** | ~1.45ms | Extract `AuditLogMessage`, instantiate entity, generate signature, insert to DB. | + +### Audit Log Retrieval (50 logs seeded) + +| Operation | Revs | Iterations | Time (mode) | Deviation | Memory (peak) | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **findByEntity** | 10 | 5 | 7.67ms | ±3.34% | 15.15 MB | +| **findByUser** | 10 | 5 | 6.11ms | ±1.65% | 15.13 MB | +| **findWithFilters** | 10 | 5 | 5.20ms | ±1.61% | 15.15 MB | + +### Purge + +| Operation | Revs | Iterations | Time (mode) | Deviation | Memory (peak) | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **Delete 1,000 logs** | 1 | 5 | 27.84ms | ±2.96% | 17.65 MB | -```php -Audit Creation (Overhead) █████████████ 1.66 ms -Baseline (Auditing Disabled) ██████ 0.68 ms -Audit Retrieval (10 logs) █████████████████ 5.60 ms -Audit Purge (1000 logs) █████████████████████████████ 44.14 ms +### HMAC Integrity Overhead (Pure CPU) + +| Operation | Revs | Iterations | Time (mode) | Deviation | Memory (peak) | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **Signature Generation** | 100 | 5 | 0.028ms | ±1.45% | 4.30 MB | +| **Signature Verification** | 100 | 5 | 0.030ms | ±2.40% | 4.30 MB | + +## Analysis & v1 vs v2 Comparison + +| Metric | v1 (Old) | v2 (New) | Difference | +| --- | --- | --- | --- | +| Audit Overhead | ~1.66ms | ~7ms - ~10ms | Slower (due to HMAC, Context, Voters, Split-phase processing) | +| Baseline (Disabled) | ~0.68ms | ~0.8ms - ~0.9ms | Comparable | +| Retrieval | ~5.60ms (10 logs) | ~5.20ms (50 logs) | Faster (Same time for 5x more data due to indexes) | +| Purge (1,000 logs) | ~44.14ms | ~27.84ms | Faster (Optimized DQL DELETE) | + +### Key Takeaways + +1. **Async Worker Throughput**: When using the Queue transport, a background Messenger worker converting an `AuditLogMessage` back into an `AuditLog` entity, verifying the payload, and flushing it to the database takes roughly **~1.45ms per message**. That equals a staggering **~690 messages per second** per worker thread, making it perfect for high-traffic environments. +2. **Transport Impact**: Interestingly, the synchronous **Doctrine Transport (~9.5ms) is currently the fastest** synchronous path in typical PHP setups because in-memory SQLite executes SQL virtually instantly. The **Queue (~12.2ms)** and **HTTP (~11.4ms)** transports are slightly "slower" during the synchronous execution phase strictly because they require deep object serialization (`json_encode` for HTTP, full `AuditLogMessage` instantiation + Messenger routing for Queue). *However, in a real-world production app with a networked database, Doctrine's latency would skyrocket, making Queue/HTTP effectively faster for the end-user request by offloading the actual IO to the highly efficient workers.* +3. **HMAC Integrity Impact**: Disabling HMAC Integrity has **virtually no impact** on flush performance. The `AuditIntegrityBench` isolates the pure CPU cost of signing at ~0.03ms. Given flush operations take 7-10ms, this 30-microsecond sub-operation is lost in the noise. **Recommendation**: Leave Integrity ON. +4. **Integer ID vs UUID**: Audited **UUID entities consistently perform slightly faster** (~7.3ms) on insert than Integer ID entities (~10.2ms). This is because the bundle's `EntityIdResolver` doesn't have to wait for Doctrine to assign an auto-incremented ID post-flush to build the audit log reference. +5. **Bulk Efficiency**: At scale (10 entities per flush), the cost drops significantly, averaging under **~3ms per entity** (30ms total for 10), demonstrating efficient batched processing in the UnitOfWork. + +## Time per Operation (ms) +```text +Int ID Insert (ON + HMAC ON) ██████████████████████████████████████████████████ 10.2 ms +UUID Insert (ON + HMAC ON) ████████████████████████████████████ 7.3 ms +Int ID Insert (Audit OFF) ████ 0.8 ms +Bulk Insert ×10 (ON) ██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ 31.3 ms +Transport: Doctrine ███████████████████████████████████████████████ 9.5 ms +Transport: HTTP (Dispatch) ████████████████████████████████████████████████████████ 11.4 ms +Transport: Queue (Dispatch) ████████████████████████████████████████████████████████████ 12.2 ms +Queue Worker Extractor ███████ 1.45 ms +Purge 1,000 logs █████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████ 27.8 ms ``` diff --git a/src/AuditTrailBundle.php b/src/AuditTrailBundle.php index 6de1e68..e82a997 100644 --- a/src/AuditTrailBundle.php +++ b/src/AuditTrailBundle.php @@ -4,14 +4,19 @@ namespace Rcsofttech\AuditTrailBundle; +use Rcsofttech\AuditTrailBundle\DependencyInjection\AuditTrailExtension; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; -use function dirname; - final class AuditTrailBundle extends Bundle { public function getPath(): string { - return dirname(__DIR__); + return __DIR__; + } + + protected function createContainerExtension(): ExtensionInterface + { + return new AuditTrailExtension(); } } diff --git a/src/Command/AuditDiffCommand.php b/src/Command/AuditDiffCommand.php index 63b9c98..5ae63db 100644 --- a/src/Command/AuditDiffCommand.php +++ b/src/Command/AuditDiffCommand.php @@ -5,8 +5,8 @@ namespace Rcsofttech\AuditTrailBundle\Command; use DateTimeInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\DiffGeneratorInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -16,6 +16,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Uuid; use function is_array; use function is_bool; @@ -43,7 +44,7 @@ public function __construct( protected function configure(): void { $this - ->addArgument('identifier', InputArgument::REQUIRED, 'Audit Log ID OR Entity Class') + ->addArgument('identifier', InputArgument::REQUIRED, 'Audit Log UUID OR Entity Class') ->addArgument('entityId', InputArgument::OPTIONAL, 'Entity ID (if identifier is Entity Class)') ->addOption('raw', null, InputOption::VALUE_NONE, 'No normalization') ->addOption( @@ -64,7 +65,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $diff = $this->diffGenerator->generate($log->getOldValues(), $log->getNewValues(), [ + $diff = $this->diffGenerator->generate($log->oldValues, $log->newValues, [ 'raw' => $input->getOption('raw'), 'include_timestamps' => $input->getOption('include-timestamps'), ]); @@ -81,7 +82,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - private function resolveAuditLog(InputInterface $input, SymfonyStyle $io): ?AuditLogInterface + private function resolveAuditLog(InputInterface $input, SymfonyStyle $io): ?AuditLog { $identifier = $input->getArgument('identifier'); $entityId = $input->getArgument('entityId'); @@ -92,8 +93,8 @@ private function resolveAuditLog(InputInterface $input, SymfonyStyle $io): ?Audi $entityId = is_string($entityId) ? $entityId : null; - return is_numeric($identifier) && $entityId === null - ? $this->fetchAuditLog((int) $identifier, $io) + return Uuid::isValid($identifier) && $entityId === null + ? $this->fetchAuditLog($identifier, $io) : $this->fetchByEntityClassAndId($identifier, $entityId, $io); } @@ -101,7 +102,7 @@ private function fetchByEntityClassAndId( string $entityClass, ?string $entityId, SymfonyStyle $io, - ): ?AuditLogInterface { + ): ?AuditLog { if ($entityId === null) { $io->error('Entity ID is required when providing an Entity Class.'); @@ -119,14 +120,14 @@ private function fetchByEntityClassAndId( /** * @param array $diff */ - private function renderDiff(SymfonyStyle $io, OutputInterface $output, AuditLogInterface $log, array $diff): void + private function renderDiff(SymfonyStyle $io, OutputInterface $output, AuditLog $log, array $diff): void { - $io->title(sprintf('Audit Diff for %s #%s', $log->getEntityClass(), $log->getEntityId())); + $io->title(sprintf('Audit Diff for %s #%s', $log->entityClass, $log->entityId)); $io->definitionList( - ['Log ID' => $log->getId()], - ['Action' => strtoupper($log->getAction())], - ['Date' => $log->getCreatedAt()->format('Y-m-d H:i:s')], - ['User' => $log->getUsername() ?? 'System'] + ['Log ID' => $log->id?->toRfc4122()], + ['Action' => strtoupper($log->action)], + ['Date' => $log->createdAt->format('Y-m-d H:i:s')], + ['User' => $log->username ?? 'System'] ); if ($diff === []) { diff --git a/src/Command/AuditExportCommand.php b/src/Command/AuditExportCommand.php index 8bda24f..b34efd1 100644 --- a/src/Command/AuditExportCommand.php +++ b/src/Command/AuditExportCommand.php @@ -6,9 +6,9 @@ use DateTimeImmutable; use Exception; +use Rcsofttech\AuditTrailBundle\Contract\AuditExporterInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; -use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; -use Rcsofttech\AuditTrailBundle\Service\AuditExporter; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -40,8 +40,8 @@ final class AuditExportCommand extends Command private const int MAX_LIMIT = 100000; public function __construct( - private readonly AuditLogRepository $repository, - private readonly AuditExporter $exporter, + private readonly AuditLogRepositoryInterface $repository, + private readonly AuditExporterInterface $exporter, ) { parent::__construct(); } @@ -72,7 +72,7 @@ protected function configure(): void 'action', null, InputOption::VALUE_OPTIONAL, - sprintf('Filter by action (%s)', implode(', ', $this->getAvailableActions())) + sprintf('Filter by action (%s)', implode(', ', AuditLogInterface::ALL_ACTIONS)) ) ->addOption( 'from', @@ -243,7 +243,7 @@ private function addDateFilter(array &$filters, InputInterface $input, string $p private function validateAction(string $action, SymfonyStyle $io): bool { - $available = $this->getAvailableActions(); + $available = AuditLogInterface::ALL_ACTIONS; if (!in_array($action, $available, true)) { $io->error(sprintf('Invalid action "%s". Available: %s', $action, implode(', ', $available))); @@ -286,18 +286,4 @@ private function writeToFile(SymfonyStyle $io, string $outputFile, string $data, $this->exporter->formatFileSize(strlen($data)) )); } - - /** - * @return array - */ - private function getAvailableActions(): array - { - return [ - AuditLogInterface::ACTION_CREATE, - AuditLogInterface::ACTION_UPDATE, - AuditLogInterface::ACTION_DELETE, - AuditLogInterface::ACTION_SOFT_DELETE, - AuditLogInterface::ACTION_RESTORE, - ]; - } } diff --git a/src/Command/AuditListCommand.php b/src/Command/AuditListCommand.php index dbd81c8..c4f3223 100644 --- a/src/Command/AuditListCommand.php +++ b/src/Command/AuditListCommand.php @@ -7,7 +7,7 @@ use DateTimeImmutable; use Exception; use InvalidArgumentException; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use Rcsofttech\AuditTrailBundle\Service\AuditRenderer; use Rcsofttech\AuditTrailBundle\Util\ClassNameHelperTrait; @@ -131,7 +131,7 @@ private function addDateFilters(array $filters, InputInterface $input): array } /** - * @param array $audits + * @param array $audits */ private function displayResults( SymfonyStyle $io, diff --git a/src/Command/AuditPurgeCommand.php b/src/Command/AuditPurgeCommand.php index f04e309..dac268a 100644 --- a/src/Command/AuditPurgeCommand.php +++ b/src/Command/AuditPurgeCommand.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use Exception; +use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -25,6 +26,7 @@ final class AuditPurgeCommand extends Command { public function __construct( private readonly AuditLogRepository $repository, + private readonly AuditIntegrityServiceInterface $integrityService, ) { parent::__construct(); } @@ -50,6 +52,12 @@ protected function configure(): void InputOption::VALUE_NONE, 'Skip confirmation prompt' ) + ->addOption( + 'skip-integrity', + null, + InputOption::VALUE_NONE, + 'Skip integrity verification before purging (not recommended)' + ) ->setHelp( <<<'HELP' The %command.name% command deletes old audit logs. @@ -98,6 +106,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + // Integrity check before deletion + if (!((bool) $input->getOption('skip-integrity')) && $this->integrityService->isEnabled()) { + $tamperedCount = $this->verifyIntegrityBeforePurge($io, $before); + if ($tamperedCount > 0) { + $io->error(sprintf( + 'Aborting purge: %d tampered log(s) detected. Run "audit:verify-integrity" for details, or use --skip-integrity to force.', + $tamperedCount, + )); + + return Command::FAILURE; + } + } + // Perform deletion $io->section('Deleting audit logs...'); $deleted = $this->repository->deleteOldLogs($before); @@ -162,4 +183,24 @@ private function confirmDeletion(InputInterface $input, SymfonyStyle $io, int $c false ); } + + private function verifyIntegrityBeforePurge(SymfonyStyle $io, DateTimeImmutable $before): int + { + $io->section('Verifying integrity of logs before purge...'); + $logs = $this->repository->findOlderThan($before); + + $tamperedCount = 0; + foreach ($logs as $log) { + if (!$this->integrityService->verifySignature($log)) { + ++$tamperedCount; + $io->warning(sprintf('Tampered log: %s', (string) $log->id)); + } + } + + if ($tamperedCount === 0) { + $io->info('All logs passed integrity verification.'); + } + + return $tamperedCount; + } } diff --git a/src/Command/AuditRevertCommand.php b/src/Command/AuditRevertCommand.php index 10c09ef..32d0a80 100644 --- a/src/Command/AuditRevertCommand.php +++ b/src/Command/AuditRevertCommand.php @@ -7,6 +7,7 @@ use Exception; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditReverterInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -38,7 +39,7 @@ public function __construct( protected function configure(): void { $this - ->addArgument('auditId', InputArgument::REQUIRED, 'The ID of the audit log entry to revert') + ->addArgument('auditId', InputArgument::REQUIRED, 'The UUID of the audit log entry to revert') ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview changes without executing them') ->addOption( 'force', @@ -84,10 +85,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->performRevert($io, $input, $log, $context); } - private function displayRevertHeader(SymfonyStyle $io, int $auditId, AuditLogInterface $log, bool $dryRun): void + private function displayRevertHeader(SymfonyStyle $io, string $auditId, AuditLog $log, bool $dryRun): void { - $io->title(sprintf('Reverting Audit Log #%d (%s)', $auditId, $log->getAction())); - $io->text(sprintf('Entity: %s:%s', $log->getEntityClass(), $log->getEntityId())); + $io->title(sprintf('Reverting Audit Log #%s (%s)', $auditId, $log->action)); + $io->text(sprintf('Entity: %s:%s', $log->entityClass, $log->entityId)); if ($dryRun) { $io->note('Running in DRY-RUN mode. No changes will be persisted.'); @@ -97,7 +98,7 @@ private function displayRevertHeader(SymfonyStyle $io, int $auditId, AuditLogInt /** * @param array $context */ - private function performRevert(SymfonyStyle $io, InputInterface $input, AuditLogInterface $log, array $context): int + private function performRevert(SymfonyStyle $io, InputInterface $input, AuditLog $log, array $context): int { $dryRun = (bool) $input->getOption('dry-run'); $force = (bool) $input->getOption('force'); diff --git a/src/Command/BaseAuditCommand.php b/src/Command/BaseAuditCommand.php index 01a033d..9a11432 100644 --- a/src/Command/BaseAuditCommand.php +++ b/src/Command/BaseAuditCommand.php @@ -7,18 +7,19 @@ use InvalidArgumentException; use JsonException; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use Rcsofttech\AuditTrailBundle\Util\ClassNameHelperTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Uuid; use function in_array; use function is_array; use function is_string; use function sprintf; -use const FILTER_VALIDATE_INT; use const JSON_THROW_ON_ERROR; /** @@ -28,13 +29,7 @@ abstract class BaseAuditCommand extends Command { use ClassNameHelperTrait; - protected const array VALID_ACTIONS = [ - AuditLogInterface::ACTION_CREATE, - AuditLogInterface::ACTION_UPDATE, - AuditLogInterface::ACTION_DELETE, - AuditLogInterface::ACTION_SOFT_DELETE, - AuditLogInterface::ACTION_RESTORE, - ]; + protected const array VALID_ACTIONS = AuditLogInterface::ALL_ACTIONS; public function __construct( protected readonly AuditLogRepository $auditLogRepository, @@ -69,20 +64,18 @@ protected function validateAction(InputInterface $input, SymfonyStyle $io): bool return true; } - protected function parseAuditId(InputInterface $input): int + protected function parseAuditId(InputInterface $input): string { $auditIdInput = $input->getArgument('auditId'); - if ($auditIdInput === null) { - return 0; + if ($auditIdInput === null || !is_string($auditIdInput)) { + throw new InvalidArgumentException('auditId is required'); } - $auditId = filter_var($auditIdInput, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); - - if ($auditId === false) { - throw new InvalidArgumentException('auditId must be a valid audit log ID'); + if (!Uuid::isValid($auditIdInput)) { + throw new InvalidArgumentException('auditId must be a valid UUID'); } - return $auditId; + return $auditIdInput; } /** @@ -113,12 +106,12 @@ protected function parseContext(InputInterface $input, SymfonyStyle $io): ?array } } - protected function fetchAuditLog(int $auditId, SymfonyStyle $io): ?AuditLogInterface + protected function fetchAuditLog(string $auditId, SymfonyStyle $io): ?AuditLog { $log = $this->auditLogRepository->find($auditId); - if (!$log instanceof AuditLogInterface) { - $io->error(sprintf('Audit log with ID %d not found.', $auditId)); + if (!$log instanceof AuditLog) { + $io->error(sprintf('Audit log with ID %s not found.', $auditId)); return null; } diff --git a/src/Command/VerifyIntegrityCommand.php b/src/Command/VerifyIntegrityCommand.php index bcc281d..3eac7aa 100644 --- a/src/Command/VerifyIntegrityCommand.php +++ b/src/Command/VerifyIntegrityCommand.php @@ -13,8 +13,10 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Uid\Uuid; use function count; +use function is_string; use function sprintf; #[AsCommand( @@ -36,7 +38,7 @@ protected function configure(): void 'id', null, InputOption::VALUE_REQUIRED, - 'Verify a specific audit log by ID' + 'Verify a specific audit log by UUID' ); } @@ -51,26 +53,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $logId = $input->getOption('id'); - if (is_numeric($logId)) { - return $this->verifySingleLog((int) $logId, $io); + if (is_string($logId) && Uuid::isValid($logId)) { + return $this->verifySingleLog($logId, $io); } return $this->verifyAllLogs($io, $output); } - private function verifySingleLog(int $id, SymfonyStyle $io): int + private function verifySingleLog(string $id, SymfonyStyle $io): int { $log = $this->repository->find($id); if ($log === null) { - $io->error(sprintf('Audit log with ID %d not found.', $id)); + $io->error(sprintf('Audit log with ID %s not found.', $id)); return Command::FAILURE; } - $io->title(sprintf('Verifying Audit Log #%d', $id)); - $io->writeln(sprintf('Entity: %s [%s]', $log->getEntityClass(), $log->getEntityId())); - $io->writeln(sprintf('Action: %s', $log->getAction())); - $io->writeln(sprintf('Created: %s', $log->getCreatedAt()->format('Y-m-d H:i:s'))); + $io->title(sprintf('Verifying Audit Log #%s', $id)); + $io->writeln(sprintf('Entity: %s [%s]', $log->entityClass, $log->entityId)); + $io->writeln(sprintf('Action: %s', $log->action)); + $io->writeln(sprintf('Created: %s', $log->createdAt->format('Y-m-d H:i:s'))); $io->newLine(); if ($this->integrityService->verifySignature($log)) { @@ -84,9 +86,9 @@ private function verifySingleLog(int $id, SymfonyStyle $io): int if ($io->isVeryVerbose()) { $io->section('Debug Information'); $io->writeln('Expected Signature: '.$this->integrityService->generateSignature($log)); - $io->writeln('Actual Signature: '.$log->getSignature()); - $io->writeln('Normalized Old Values: '.json_encode($log->getOldValues())); - $io->writeln('Normalized New Values: '.json_encode($log->getNewValues())); + $io->writeln('Actual Signature: '.$log->signature); + $io->writeln('Normalized Old Values: '.json_encode($log->oldValues)); + $io->writeln('Normalized New Values: '.json_encode($log->newValues)); } return Command::FAILURE; @@ -114,11 +116,11 @@ private function verifyAllLogs(SymfonyStyle $io, OutputInterface $output): int foreach ($logs as $log) { if (!$this->integrityService->verifySignature($log)) { $tamperedLogs[] = [ - 'id' => $log->getId(), - 'entity' => $log->getEntityClass(), - 'entity_id' => $log->getEntityId(), - 'action' => $log->getAction(), - 'created_at' => $log->getCreatedAt()->format('Y-m-d H:i:s'), + 'id' => $log->id?->toRfc4122(), + 'entity' => $log->entityClass, + 'entity_id' => $log->entityId, + 'action' => $log->action, + 'created_at' => $log->createdAt->format('Y-m-d H:i:s'), ]; } $progressBar->advance(); diff --git a/src/Contract/AuditDispatcherInterface.php b/src/Contract/AuditDispatcherInterface.php new file mode 100644 index 0000000..9290c3c --- /dev/null +++ b/src/Contract/AuditDispatcherInterface.php @@ -0,0 +1,24 @@ + $audits + */ + public function formatAudits(array $audits, string $format): string; + + public function formatFileSize(int $bytes): string; +} diff --git a/src/Contract/AuditIntegrityServiceInterface.php b/src/Contract/AuditIntegrityServiceInterface.php index 8ec2f6e..e8394ec 100644 --- a/src/Contract/AuditIntegrityServiceInterface.php +++ b/src/Contract/AuditIntegrityServiceInterface.php @@ -4,13 +4,15 @@ namespace Rcsofttech\AuditTrailBundle\Contract; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; + interface AuditIntegrityServiceInterface { public function isEnabled(): bool; - public function generateSignature(AuditLogInterface $log): string; + public function generateSignature(AuditLog $log): string; - public function verifySignature(AuditLogInterface $log): bool; + public function verifySignature(AuditLog $log): bool; public function signPayload(string $payload): string; } diff --git a/src/Contract/AuditLogInterface.php b/src/Contract/AuditLogInterface.php index 46c1fec..3d14362 100644 --- a/src/Contract/AuditLogInterface.php +++ b/src/Contract/AuditLogInterface.php @@ -4,27 +4,10 @@ namespace Rcsofttech\AuditTrailBundle\Contract; -use DateTimeImmutable; - -/** - * @property int|null $id - * @property string $entityClass - * @property string $entityId - * @property string $action - * @property array|null $oldValues - * @property array|null $newValues - * @property array|null $changedFields - * @property string|null $userId - * @property string|null $username - * @property string|null $ipAddress - * @property string|null $userAgent - * @property string|null $transactionHash - * @property DateTimeImmutable $createdAt - * @property string|null $signature - * @property array $context - */ interface AuditLogInterface { + public const string PENDING_ID = 'pending'; + public const string ACTION_CREATE = 'create'; public const string ACTION_UPDATE = 'update'; @@ -39,89 +22,24 @@ interface AuditLogInterface public const string ACTION_ACCESS = 'access'; + public const array ALL_ACTIONS = [ + self::ACTION_CREATE, + self::ACTION_UPDATE, + self::ACTION_DELETE, + self::ACTION_SOFT_DELETE, + self::ACTION_RESTORE, + self::ACTION_REVERT, + self::ACTION_ACCESS, + ]; + public const string CONTEXT_USER_ID = '_audit_user_id'; public const string CONTEXT_USERNAME = '_audit_username'; - public function getId(): ?int; - - public function getEntityClass(): string; - - public function setEntityClass(string $entityClass): self; - - public function getEntityId(): string; - - public function setEntityId(string $entityId): self; - - public function getAction(): string; - - public function setAction(string $action): self; - - /** - * @return array|null - */ - public function getOldValues(): ?array; - - /** - * @param array|null $oldValues - */ - public function setOldValues(?array $oldValues): self; - - /** - * @return array|null - */ - public function getNewValues(): ?array; - - /** - * @param array|null $newValues - */ - public function setNewValues(?array $newValues): self; - - /** - * @return array|null - */ - public function getChangedFields(): ?array; - - /** - * @param array|null $changedFields - */ - public function setChangedFields(?array $changedFields): self; - - public function getUserId(): ?string; - - public function setUserId(?string $userId): self; - - public function getUsername(): ?string; - - public function setUsername(?string $username): self; - - public function getIpAddress(): ?string; - - public function setIpAddress(?string $ipAddress): self; - - public function getUserAgent(): ?string; - - public function setUserAgent(?string $userAgent): self; - - public function getTransactionHash(): ?string; - - public function setTransactionHash(?string $transactionHash): self; - - public function getCreatedAt(): DateTimeImmutable; - - public function setCreatedAt(DateTimeImmutable $createdAt): self; - - public function getSignature(): ?string; - - public function setSignature(?string $signature): self; + public string $entityId { get; set; } - /** - * @return array - */ - public function getContext(): array; + /** @var array */ + public array $context { get; set; } - /** - * @param array $context - */ - public function setContext(array $context): self; + public ?string $signature { get; set; } } diff --git a/src/Contract/AuditLogRepositoryInterface.php b/src/Contract/AuditLogRepositoryInterface.php index 40d9cc6..c7aca2c 100644 --- a/src/Contract/AuditLogRepositoryInterface.php +++ b/src/Contract/AuditLogRepositoryInterface.php @@ -33,5 +33,10 @@ public function deleteOldLogs(DateTimeImmutable $before): int; */ public function findWithFilters(array $filters = [], int $limit = 30): array; + /** + * @return array + */ + public function findOlderThan(DateTimeImmutable $before): array; + public function countOlderThan(DateTimeImmutable $before): int; } diff --git a/src/Contract/AuditMetadataManagerInterface.php b/src/Contract/AuditMetadataManagerInterface.php new file mode 100644 index 0000000..116733e --- /dev/null +++ b/src/Contract/AuditMetadataManagerInterface.php @@ -0,0 +1,29 @@ + + */ + public function getSensitiveFields(string $class): array; + + /** + * @param array $additionalIgnored + * + * @return array + */ + public function getIgnoredProperties(object $entity, array $additionalIgnored = []): array; + + public function isEntityIgnored(string $class): bool; +} diff --git a/src/Contract/AuditRendererInterface.php b/src/Contract/AuditRendererInterface.php new file mode 100644 index 0000000..57cf05c --- /dev/null +++ b/src/Contract/AuditRendererInterface.php @@ -0,0 +1,25 @@ + $audits + */ + public function renderTable(OutputInterface $output, array $audits, bool $showDetails): void; + + /** + * @return array + */ + public function buildRow(AuditLog $audit, bool $showDetails): array; + + public function formatChangedDetails(AuditLog $audit): string; + + public function formatValue(mixed $value): string; +} diff --git a/src/Contract/AuditReverterInterface.php b/src/Contract/AuditReverterInterface.php index 2990ef8..5ad8b8c 100644 --- a/src/Contract/AuditReverterInterface.php +++ b/src/Contract/AuditReverterInterface.php @@ -4,6 +4,7 @@ namespace Rcsofttech\AuditTrailBundle\Contract; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use RuntimeException; /** @@ -14,7 +15,7 @@ interface AuditReverterInterface /** * Revert an entity to the state described in the audit log. * - * @param AuditLogInterface $log The audit log entry to revert to/from + * @param AuditLog $log The audit log entry to revert to/from * @param bool $dryRun If true, changes are calculated but not persisted * @param bool $force If true, allows reverting creation (deleting the entity) * @param array $context Optional custom context for the revert audit log @@ -24,7 +25,7 @@ interface AuditReverterInterface * @throws RuntimeException If the revert operation fails or is unsafe */ public function revert( - AuditLogInterface $log, + AuditLog $log, bool $dryRun = false, bool $force = false, array $context = [], diff --git a/src/Contract/AuditServiceInterface.php b/src/Contract/AuditServiceInterface.php new file mode 100644 index 0000000..7366c20 --- /dev/null +++ b/src/Contract/AuditServiceInterface.php @@ -0,0 +1,52 @@ + $changeSet + */ + public function shouldAudit( + object $entity, + string $action = AuditLogInterface::ACTION_CREATE, + array $changeSet = [], + ): bool; + + /** + * @param array $changeSet + */ + public function passesVoters(object $entity, string $action, array $changeSet = []): bool; + + public function getAccessAttribute(string $class): ?AuditAccess; + + /** + * @param array $additionalIgnored + * + * @return array + */ + public function getEntityData(object $entity, array $additionalIgnored = []): array; + + /** + * @param array|null $oldValues + * @param array|null $newValues + * @param array $context + */ + public function createAuditLog( + object $entity, + string $action, + ?array $oldValues = null, + ?array $newValues = null, + array $context = [], + ): AuditLog; + + /** + * @return array + */ + public function getSensitiveFields(object $entity): array; +} diff --git a/src/Contract/AuditTransportInterface.php b/src/Contract/AuditTransportInterface.php index 3c1d386..aa74ee9 100644 --- a/src/Contract/AuditTransportInterface.php +++ b/src/Contract/AuditTransportInterface.php @@ -4,15 +4,17 @@ namespace Rcsofttech\AuditTrailBundle\Contract; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; + interface AuditTransportInterface { /** * Send audit log to the transport. * - * @param AuditLogInterface $log The audit log entity + * @param AuditLog $log The audit log entity * @param array $context Context data (e.g., 'phase', 'em', 'uow') */ - public function send(AuditLogInterface $log, array $context = []): void; + public function send(AuditLog $log, array $context = []): void; /** * Check if the transport supports the given phase. diff --git a/src/Contract/ChangeProcessorInterface.php b/src/Contract/ChangeProcessorInterface.php new file mode 100644 index 0000000..55035b2 --- /dev/null +++ b/src/Contract/ChangeProcessorInterface.php @@ -0,0 +1,24 @@ + $changeSet + * + * @return array{0: array, 1: array} + */ + public function extractChanges(object $entity, array $changeSet): array; + + /** + * @param array $changeSet + */ + public function determineUpdateAction(array $changeSet): string; + + public function determineDeletionAction(EntityManagerInterface $em, object $entity, bool $enableHardDelete): ?string; +} diff --git a/src/Contract/ContextResolverInterface.php b/src/Contract/ContextResolverInterface.php new file mode 100644 index 0000000..f234099 --- /dev/null +++ b/src/Contract/ContextResolverInterface.php @@ -0,0 +1,22 @@ + $newValues + * @param array $extraContext + * + * @return array{ + * userId: ?string, + * username: ?string, + * ipAddress: ?string, + * userAgent: ?string, + * context: array + * } + */ + public function resolve(object $entity, string $action, array $newValues, array $extraContext): array; +} diff --git a/src/Contract/DataMaskerInterface.php b/src/Contract/DataMaskerInterface.php new file mode 100644 index 0000000..f2c6c81 --- /dev/null +++ b/src/Contract/DataMaskerInterface.php @@ -0,0 +1,23 @@ + $data + * + * @return array + */ + public function redact(array $data): array; + + /** + * @param array $data + * @param array $sensitiveFields [field => mask] + * + * @return array + */ + public function mask(array $data, array $sensitiveFields): array; +} diff --git a/src/Contract/EntityIdResolverInterface.php b/src/Contract/EntityIdResolverInterface.php new file mode 100644 index 0000000..f14cf55 --- /dev/null +++ b/src/Contract/EntityIdResolverInterface.php @@ -0,0 +1,22 @@ + $values + */ + public function resolveFromValues(object $entity, array $values, EntityManagerInterface $em): int|string|null; + + /** + * @param array $context + */ + public function resolve(object $object, array $context = []): ?string; +} diff --git a/src/Contract/EntityProcessorInterface.php b/src/Contract/EntityProcessorInterface.php new file mode 100644 index 0000000..7c15c57 --- /dev/null +++ b/src/Contract/EntityProcessorInterface.php @@ -0,0 +1,22 @@ + $collectionUpdates + */ + public function processCollectionUpdates(EntityManagerInterface $em, UnitOfWork $uow, iterable $collectionUpdates): void; +} diff --git a/src/Contract/ScheduledAuditManagerInterface.php b/src/Contract/ScheduledAuditManagerInterface.php new file mode 100644 index 0000000..0f05d18 --- /dev/null +++ b/src/Contract/ScheduledAuditManagerInterface.php @@ -0,0 +1,26 @@ + $scheduledAudits + * @property list, is_managed: bool}> $pendingDeletions + * + * @phpstan-property-read array $scheduledAudits + * @phpstan-property-read list, is_managed: bool}> $pendingDeletions + */ +interface ScheduledAuditManagerInterface +{ + public function schedule(object $entity, AuditLog $audit, bool $isInsert): void; + + /** + * @param array $data + */ + public function addPendingDeletion(object $entity, array $data, bool $isManaged): void; + + public function clear(): void; +} diff --git a/src/Contract/SoftDeleteHandlerInterface.php b/src/Contract/SoftDeleteHandlerInterface.php new file mode 100644 index 0000000..169c4ed --- /dev/null +++ b/src/Contract/SoftDeleteHandlerInterface.php @@ -0,0 +1,22 @@ + + */ + public function disableSoftDeleteFilters(): array; + + /** + * @param array $names + */ + public function enableFilters(array $names): void; +} diff --git a/src/Contract/ValueSerializerInterface.php b/src/Contract/ValueSerializerInterface.php new file mode 100644 index 0000000..908df37 --- /dev/null +++ b/src/Contract/ValueSerializerInterface.php @@ -0,0 +1,12 @@ +, * transports: array{ * doctrine: bool, * http: array{enabled: bool, endpoint: string, headers: array, timeout: int}, @@ -71,6 +71,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('audit_trail.fail_on_transport_error', $config['fail_on_transport_error']); $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']); if ($config['cache_pool'] !== null) { $container->setAlias('rcsofttech_audit_trail.cache', $config['cache_pool']); @@ -79,12 +80,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('audit_trail.integrity.enabled', $config['integrity']['enabled']); $container->setParameter('audit_trail.integrity.secret', $config['integrity']['secret'] ?? ''); $container->setParameter('audit_trail.integrity.algorithm', $config['integrity']['algorithm']); - - $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.yaml'); + new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'))->load('services.yaml'); $this->configureTransports($config, $container); - $this->registerSerializer($container); } #[Override] @@ -109,13 +107,6 @@ public function prepend(ContainerBuilder $container): void ]); } - private function registerSerializer(ContainerBuilder $container): void - { - $container->register(AuditLogMessageSerializer::class) - ->setAutowired(true) - ->setAutoconfigured(true); - } - /** * @param array{ * transports: array{ @@ -202,7 +193,7 @@ private function registerQueueTransport(ContainerBuilder $container, array $conf private function registerMainTransport(ContainerBuilder $container, array $transports): void { if (1 === count($transports)) { - $container->setAlias(AuditTransportInterface::class, $transports[0]); + $container->setAlias(AuditTransportInterface::class, $transports[0])->setPublic(true); return; } @@ -214,7 +205,7 @@ private function registerMainTransport(ContainerBuilder $container, array $trans $container->register($id, ChainAuditTransport::class) ->setArgument('$transports', $references); - $container->setAlias(AuditTransportInterface::class, $id); + $container->setAlias(AuditTransportInterface::class, $id)->setPublic(true); } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e6264bd..d0d1924 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -52,6 +52,10 @@ private function configureBaseSettings(ArrayNodeDefinition $rootNode): void ->booleanNode('fail_on_transport_error')->defaultFalse()->end() ->booleanNode('fallback_to_database')->defaultTrue()->end() ->scalarNode('cache_pool')->defaultNull()->end() + ->arrayNode('audited_methods') + ->scalarPrototype()->end() + ->defaultValue(['GET']) + ->end() ->end(); } @@ -66,7 +70,14 @@ private function configureTransports(ArrayNodeDefinition $rootNode): void ->arrayNode('http') ->canBeEnabled() ->children() - ->scalarNode('endpoint')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('endpoint') + ->isRequired() + ->cannotBeEmpty() + ->validate() + ->ifTrue(static fn ($v): bool => !str_starts_with($v, 'http://') && !str_starts_with($v, 'https://')) + ->thenInvalid('The endpoint must start with http:// or https://') + ->end() + ->end() ->arrayNode('headers') ->scalarPrototype()->end() ->defaultValue([]) diff --git a/src/Entity/AuditLog.php b/src/Entity/AuditLog.php index b116c4f..90db0f1 100644 --- a/src/Entity/AuditLog.php +++ b/src/Entity/AuditLog.php @@ -8,8 +8,11 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; +use LogicException; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; +use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; +use Symfony\Component\Uid\Uuid; use function in_array; use function sprintf; @@ -23,302 +26,107 @@ #[ORM\Index(name: 'transaction_idx', columns: ['transaction_hash'])] class AuditLog implements AuditLogInterface { - private const array VALID_ACTIONS = [ - AuditLogInterface::ACTION_CREATE, - AuditLogInterface::ACTION_UPDATE, - AuditLogInterface::ACTION_DELETE, - AuditLogInterface::ACTION_SOFT_DELETE, - AuditLogInterface::ACTION_RESTORE, - AuditLogInterface::ACTION_REVERT, - AuditLogInterface::ACTION_ACCESS, - ]; + private const array VALID_ACTIONS = AuditLogInterface::ALL_ACTIONS; - #[ORM\Id, ORM\GeneratedValue, ORM\Column] - public private(set) ?int $id = null; - - #[ORM\Column(length: 255)] - public private(set) string $entityClass { - get => $this->entityClass; - set { - $trimmed = mb_trim($value); - if ($trimmed === '') { - throw new InvalidArgumentException('Entity class cannot be empty'); - } - $this->entityClass = $trimmed; - } - } - - #[ORM\Column(length: 255)] - public private(set) string $entityId { - get => $this->entityId; - set { - $trimmed = mb_trim($value); - if ($trimmed === '') { - throw new InvalidArgumentException('Entity ID cannot be empty'); - } - $this->entityId = $trimmed; - } - } - - #[ORM\Column(length: 50)] - public private(set) string $action { - get => $this->action; - set { - if (!in_array($value, self::VALID_ACTIONS, true)) { - throw new InvalidArgumentException(sprintf('Invalid action "%s". Must be one of: %s', $value, implode(', ', self::VALID_ACTIONS))); - } - $this->action = $value; - } - } - - /** @var array|null */ - #[ORM\Column(type: Types::JSON, nullable: true)] - public private(set) ?array $oldValues = null; - - /** @var array|null */ - #[ORM\Column(type: Types::JSON, nullable: true)] - public private(set) ?array $newValues = null; - - /** @var array|null */ - #[ORM\Column(type: Types::JSON, nullable: true)] - public private(set) ?array $changedFields = null; - - #[ORM\Column(length: 255, nullable: true)] - public private(set) ?string $userId = null; - - #[ORM\Column(length: 255, nullable: true)] - public private(set) ?string $username = null; - - #[ORM\Column(length: 50, nullable: true)] - public private(set) ?string $ipAddress = null { - get => $this->ipAddress; - set { - if ($value !== null && false === filter_var($value, FILTER_VALIDATE_IP)) { - throw new InvalidArgumentException(sprintf('Invalid IP address format: "%s"', $value)); - } - $this->ipAddress = $value; - } - } - - #[ORM\Column(type: Types::TEXT, nullable: true)] - public private(set) ?string $userAgent = null; - - #[ORM\Column(length: 40, nullable: true)] - public private(set) ?string $transactionHash = null; - - /** @var array */ - #[ORM\Column(type: Types::JSON)] - public private(set) array $context = []; - - #[ORM\Column] - public private(set) DateTimeImmutable $createdAt; - - public function __construct() - { - $this->createdAt = new DateTimeImmutable(); - } - - // Getters - - public function getId(): ?int - { - return $this->id; - } - - public function getEntityClass(): string - { - return $this->entityClass; - } - - public function getEntityId(): string - { - return $this->entityId; - } - - public function getAction(): string - { - return $this->action; - } - - /** - * @return array|null - */ - public function getOldValues(): ?array - { - return $this->oldValues; - } - - /** - * @return array|null - */ - public function getNewValues(): ?array - { - return $this->newValues; - } - - /** - * @return array|null - */ - public function getChangedFields(): ?array - { - return $this->changedFields; - } - - public function getUserId(): ?string - { - return $this->userId; - } - - public function getUsername(): ?string - { - return $this->username; - } - - public function getIpAddress(): ?string - { - return $this->ipAddress; - } - - public function getUserAgent(): ?string - { - return $this->userAgent; - } - - public function getTransactionHash(): ?string - { - return $this->transactionHash; - } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - // Setters with Validation - - public function setEntityClass(string $entityClass): self - { - $this->entityClass = $entityClass; - - return $this; - } - - public function setEntityId(string $entityId): self - { - $this->entityId = $entityId; - - return $this; - } - - public function setAction(string $action): self - { - $this->action = $action; - - return $this; - } + #[ORM\Id] + #[ORM\Column(type: 'uuid')] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: UuidGenerator::class)] + public private(set) ?Uuid $id = null; /** * @param array|null $oldValues - */ - public function setOldValues(?array $oldValues): self - { - $this->oldValues = $oldValues; - - return $this; - } - - /** * @param array|null $newValues + * @param array|null $changedFields + * @param array $context */ - public function setNewValues(?array $newValues): self - { - $this->newValues = $newValues; - - return $this; - } - - /** - * @param array|null $changedFields - */ - public function setChangedFields(?array $changedFields): self - { - $this->changedFields = $changedFields; - - return $this; - } - - public function setUserId(?string $userId): self - { - $this->userId = $userId; - - return $this; - } - - public function setUsername(?string $username): self - { - $this->username = $username; - - return $this; - } - - public function setIpAddress(?string $ipAddress): self - { - $this->ipAddress = $ipAddress; - - return $this; - } - - public function setUserAgent(?string $userAgent): self - { - $this->userAgent = $userAgent; - - return $this; - } - - public function setTransactionHash(?string $transactionHash): self - { - $this->transactionHash = $transactionHash; - - return $this; - } - - public function setCreatedAt(DateTimeImmutable $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; - } - - #[ORM\Column(length: 128, nullable: true)] - public private(set) ?string $signature = null; - - public function getSignature(): ?string - { - return $this->signature; + public function __construct( + #[ORM\Column(length: 255)] + public private(set) string $entityClass { + set { + $trimmed = mb_trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException('Entity class cannot be empty'); + } + $this->entityClass = $trimmed; + } + }, + #[ORM\Column(length: 255)] + public string $entityId { + get => $this->entityId; + set { + $this->checkSealed(); + $trimmed = mb_trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException('Entity ID cannot be empty'); + } + $this->entityId = $trimmed; + } + }, + #[ORM\Column(length: 50)] + public private(set) string $action { + set { + if (!in_array($value, self::VALID_ACTIONS, true)) { + throw new InvalidArgumentException(sprintf('Invalid action "%s". Must be one of: %s', $value, implode(', ', self::VALID_ACTIONS))); + } + $this->action = $value; + } + }, + #[ORM\Column] + public private(set) DateTimeImmutable $createdAt = new DateTimeImmutable(), + #[ORM\Column(type: Types::JSON, nullable: true)] + public private(set) ?array $oldValues = null, + #[ORM\Column(type: Types::JSON, nullable: true)] + public private(set) ?array $newValues = null, + #[ORM\Column(type: Types::JSON, nullable: true)] + public private(set) ?array $changedFields = null, + #[ORM\Column(length: 40, nullable: true)] + public private(set) ?string $transactionHash = null, + #[ORM\Column(length: 255, nullable: true)] + public private(set) ?string $userId = null, + #[ORM\Column(length: 255, nullable: true)] + public private(set) ?string $username = null, + #[ORM\Column(length: 50, nullable: true)] + public private(set) ?string $ipAddress = null { + set { + if ($value !== null && false === filter_var($value, FILTER_VALIDATE_IP)) { + throw new InvalidArgumentException(sprintf('Invalid IP address format: "%s"', $value)); + } + $this->ipAddress = $value; + } + }, + #[ORM\Column(type: Types::TEXT, nullable: true)] + public private(set) ?string $userAgent = null, + #[ORM\Column(type: Types::JSON)] + public array $context = [] { + get => $this->context; + set { + $this->checkSealed(); + $this->context = $value; + } + }, + #[ORM\Column(length: 128, nullable: true)] + public ?string $signature = null { + get => $this->signature; + set { + $this->checkSealed(); + $this->signature = $value; + } + }, + ) { } - public function setSignature(?string $signature): self - { - $this->signature = $signature; - - return $this; - } + private bool $isSealed = false; - /** - * @return array - */ - public function getContext(): array + public function seal(): void { - return $this->context; + $this->isSealed = true; } - /** - * @param array $context - */ - public function setContext(array $context): self + private function checkSealed(): void { - $this->context = $context; - - return $this; + if ($this->isSealed) { + throw new LogicException('Cannot modify a sealed audit log.'); + } } } diff --git a/src/Event/AuditLogCreatedEvent.php b/src/Event/AuditLogCreatedEvent.php index 92d6fbb..6558b8b 100644 --- a/src/Event/AuditLogCreatedEvent.php +++ b/src/Event/AuditLogCreatedEvent.php @@ -4,7 +4,7 @@ namespace Rcsofttech\AuditTrailBundle\Event; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Symfony\Contracts\EventDispatcher\Event; /** @@ -21,31 +21,8 @@ final class AuditLogCreatedEvent extends Event public const string NAME = 'audit_trail.audit_log_created'; public function __construct( - public private(set) AuditLogInterface $auditLog, - public readonly object $entity, + public private(set) AuditLog $auditLog, + public readonly ?object $entity = null, ) { } - - public function getAuditLog(): AuditLogInterface - { - return $this->auditLog; - } - - /** - * Get the entity class name. - */ - public string $entityClass { - get { - return $this->auditLog->getEntityClass(); - } - } - - /** - * Get the action performed (create, update, delete, soft_delete, restore). - */ - public string $action { - get { - return $this->auditLog->getAction(); - } - } } diff --git a/src/Event/AuditMessageStampEvent.php b/src/Event/AuditMessageStampEvent.php index 615016b..df21903 100644 --- a/src/Event/AuditMessageStampEvent.php +++ b/src/Event/AuditMessageStampEvent.php @@ -4,18 +4,20 @@ namespace Rcsofttech\AuditTrailBundle\Event; +use Psr\EventDispatcher\StoppableEventInterface; use Rcsofttech\AuditTrailBundle\Message\AuditLogMessage; use Symfony\Component\Messenger\Stamp\StampInterface; -final class AuditMessageStampEvent +final class AuditMessageStampEvent implements StoppableEventInterface { + private bool $propagationStopped = false; + /** * @param array $stamps */ public function __construct( public readonly AuditLogMessage $message, private array $stamps = [], - private bool $cancelled = false, ) { } @@ -32,13 +34,13 @@ public function getStamps(): array return $this->stamps; } - public function cancel(): void + public function isPropagationStopped(): bool { - $this->cancelled = true; + return $this->propagationStopped; } - public function isCancelled(): bool + public function stopPropagation(): void { - return $this->cancelled; + $this->propagationStopped = true; } } diff --git a/src/EventSubscriber/AuditSubscriber.php b/src/EventSubscriber/AuditSubscriber.php index 6ac41a5..771ad1f 100644 --- a/src/EventSubscriber/AuditSubscriber.php +++ b/src/EventSubscriber/AuditSubscriber.php @@ -11,14 +11,14 @@ use Doctrine\ORM\Event\PostLoadEventArgs; use Doctrine\ORM\Events; use Psr\Log\LoggerInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ChangeProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Service\AuditAccessHandler; -use Rcsofttech\AuditTrailBundle\Service\AuditDispatcher; -use Rcsofttech\AuditTrailBundle\Service\AuditService; -use Rcsofttech\AuditTrailBundle\Service\ChangeProcessor; -use Rcsofttech\AuditTrailBundle\Service\EntityIdResolver; -use Rcsofttech\AuditTrailBundle\Service\EntityProcessor; -use Rcsofttech\AuditTrailBundle\Service\ScheduledAuditManager; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; use Symfony\Contracts\Service\ResetInterface; use Throwable; @@ -31,20 +31,19 @@ #[AsDoctrineListener(event: Events::onClear)] final class AuditSubscriber implements ResetInterface { - private const int BATCH_FLUSH_THRESHOLD = 500; - private bool $isFlushing = false; private int $recursionDepth = 0; public function __construct( - private readonly AuditService $auditService, - private readonly ChangeProcessor $changeProcessor, - private readonly AuditDispatcher $dispatcher, - private readonly ScheduledAuditManager $auditManager, - private readonly EntityProcessor $entityProcessor, + private readonly AuditServiceInterface $auditService, + private readonly ChangeProcessorInterface $changeProcessor, + private readonly AuditDispatcherInterface $dispatcher, + private readonly ScheduledAuditManagerInterface $auditManager, + private readonly EntityProcessorInterface $entityProcessor, private readonly TransactionIdGenerator $transactionIdGenerator, private readonly AuditAccessHandler $accessHandler, + private readonly EntityIdResolverInterface $idResolver, private readonly ?LoggerInterface $logger = null, private readonly bool $enableHardDelete = true, private readonly bool $enabled = true, @@ -67,12 +66,13 @@ public function onFlush(OnFlushEventArgs $args): void try { $em = $args->getObjectManager(); $uow = $em->getUnitOfWork(); - $uow->computeChangeSets(); - $this->handleBatchFlushIfNeeded($em); + // rely on the parent flush and computeChangeSet() in the + // transport to persist audits, avoiding nested flushes in onFlush. $this->entityProcessor->processInsertions($em, $uow); $this->entityProcessor->processUpdates($em, $uow); $this->entityProcessor->processCollectionUpdates($em, $uow, $uow->getScheduledCollectionUpdates()); + $this->entityProcessor->processCollectionUpdates($em, $uow, $uow->getScheduledCollectionDeletions()); $this->entityProcessor->processDeletions($em, $uow); } finally { --$this->recursionDepth; @@ -98,12 +98,15 @@ public function postFlush(PostFlushEventArgs $args): void try { $em = $args->getObjectManager(); - $hasNewAudits = $this->processPendingDeletions($em); - $hasNewAudits = $this->processScheduledAudits($em) || $hasNewAudits; + $hasDeletions = $this->processPendingDeletions($em); + $hasScheduled = $this->processScheduledAudits($em); $this->auditManager->clear(); $this->transactionIdGenerator->reset(); - $this->flushNewAuditsIfNeeded($em, $hasNewAudits); + + if ($hasDeletions || $hasScheduled) { + $this->flushNewAuditsIfNeeded($em, true); + } } finally { --$this->recursionDepth; } @@ -112,7 +115,8 @@ public function postFlush(PostFlushEventArgs $args): void private function processPendingDeletions(EntityManagerInterface $em): bool { $hasNewAudits = false; - foreach ($this->auditManager->getPendingDeletions() as $pending) { + // @phpstan-ignore-next-line + foreach ($this->auditManager->pendingDeletions as $pending) { $action = $this->changeProcessor->determineDeletionAction($em, $pending['entity'], $this->enableHardDelete); if ($action === null) { continue; @@ -138,11 +142,12 @@ private function processPendingDeletions(EntityManagerInterface $em): bool private function processScheduledAudits(EntityManagerInterface $em): bool { $hasNewAudits = false; - foreach ($this->auditManager->getScheduledAudits() as $scheduled) { + // @phpstan-ignore-next-line + foreach ($this->auditManager->scheduledAudits as $scheduled) { if ($scheduled['is_insert']) { - $id = EntityIdResolver::resolveFromEntity($scheduled['entity'], $em); - if ($id !== EntityIdResolver::PENDING_ID) { - $scheduled['audit']->setEntityId($id); + $id = $this->idResolver->resolveFromEntity($scheduled['entity'], $em); + if ($id !== AuditLogInterface::PENDING_ID) { + $scheduled['audit']->entityId = $id; } } @@ -174,29 +179,12 @@ public function reset(): void private function markAsAudited(object $entity, EntityManagerInterface $em): void { - $id = EntityIdResolver::resolveFromEntity($entity, $em); - if ($id !== EntityIdResolver::PENDING_ID) { + $id = $this->idResolver->resolveFromEntity($entity, $em); + if ($id !== AuditLogInterface::PENDING_ID) { $this->accessHandler->markAsAudited(sprintf('%s:%s', $entity::class, $id)); } } - private function handleBatchFlushIfNeeded(EntityManagerInterface $em): void - { - if ($this->auditManager->countScheduled() < self::BATCH_FLUSH_THRESHOLD) { - return; - } - - $this->isFlushing = true; - try { - foreach ($this->auditManager->getScheduledAudits() as $scheduled) { - $this->dispatcher->dispatch($scheduled['audit'], $em, 'batch_flush'); - } - $this->auditManager->clear(); - } finally { - $this->isFlushing = false; - } - } - private function flushNewAuditsIfNeeded(EntityManagerInterface $em, bool $hasNewAudits): void { if (!$hasNewAudits) { diff --git a/src/Message/AuditLogMessage.php b/src/Message/AuditLogMessage.php index bb3e29a..6402212 100644 --- a/src/Message/AuditLogMessage.php +++ b/src/Message/AuditLogMessage.php @@ -4,15 +4,17 @@ namespace Rcsofttech\AuditTrailBundle\Message; -use DateTimeImmutable; use DateTimeInterface; use JsonSerializable; use Override; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Symfony\Component\Messenger\Attribute\AsMessage; +/** + * Represents an audit log message for queue transport. + */ #[AsMessage(transport: 'audit_trail')] -final readonly class AuditLogMessage implements JsonSerializable +readonly class AuditLogMessage implements JsonSerializable { /** * @param array|null $oldValues @@ -32,29 +34,27 @@ public function __construct( public ?string $ipAddress, public ?string $userAgent, public ?string $transactionHash, - public ?string $signature, - public array $context, - public DateTimeImmutable $createdAt, + public string $createdAt, + public array $context = [], ) { } - public static function createFromAuditLog(AuditLogInterface $log, string $entityId): self + public static function createFromAuditLog(AuditLog $log, ?string $resolvedEntityId = null): self { return new self( - $log->getEntityClass(), - $entityId, - $log->getAction(), - $log->getOldValues(), - $log->getNewValues(), - $log->getChangedFields(), - $log->getUserId(), - $log->getUsername(), - $log->getIpAddress(), - $log->getUserAgent(), - $log->getTransactionHash(), - $log->getSignature(), - $log->getContext(), - $log->getCreatedAt() + 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), + context: $log->context, ); } @@ -76,9 +76,8 @@ public function jsonSerialize(): array 'ip_address' => $this->ipAddress, 'user_agent' => $this->userAgent, 'transaction_hash' => $this->transactionHash, - 'signature' => $this->signature, + 'created_at' => $this->createdAt, 'context' => $this->context, - 'created_at' => $this->createdAt->format(DateTimeInterface::ATOM), ]; } } diff --git a/src/Query/AuditEntry.php b/src/Query/AuditEntry.php index 79fd97e..6d8c58e 100644 --- a/src/Query/AuditEntry.php +++ b/src/Query/AuditEntry.php @@ -5,7 +5,8 @@ namespace Rcsofttech\AuditTrailBundle\Query; use DateTimeImmutable; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; +use Symfony\Component\Uid\Uuid; use function in_array; @@ -18,218 +19,99 @@ class AuditEntry { public function __construct( - private readonly AuditLogInterface $log, + private readonly AuditLog $log, ) { } - public ?int $id { - get { - return $this->log->getId(); - } - } - - public function getId(): ?int - { - return $this->id; - } + public ?Uuid $id { get => $this->log->id; } - public string $entityClass { - get { - return $this->log->getEntityClass(); - } - } - - public function getEntityClass(): string - { - return $this->entityClass; - } + public string $entityClass { get => $this->log->entityClass; } /** * Get the short entity class name (without namespace). */ public string $entityShortName { get { - $parts = explode('\\', $this->log->getEntityClass()); + $parts = explode('\\', $this->log->entityClass); $shortName = end($parts); - return ($shortName !== '') ? $shortName : $this->log->getEntityClass(); + return ($shortName !== '') ? $shortName : $this->log->entityClass; } } - public function getEntityShortName(): string - { - return $this->entityShortName; - } + public string $entityId { get => $this->log->entityId; } - public string $entityId { - get { - return $this->log->getEntityId(); - } - } - - public function getEntityId(): string - { - return $this->entityId; - } - - public string $action { - get { - return $this->log->getAction(); - } - } + public string $action { get => $this->log->action; } - public function getAction(): string - { - return $this->action; - } + public ?string $userId { get => $this->log->userId; } - public ?string $userId { - get { - return $this->log->getUserId(); - } - } + public ?string $username { get => $this->log->username; } - public function getUserId(): ?string - { - return $this->userId; - } + public ?string $ipAddress { get => $this->log->ipAddress; } - public ?string $username { - get { - return $this->log->getUsername(); - } - } + public ?string $transactionHash { get => $this->log->transactionHash; } - public function getUsername(): ?string - { - return $this->username; - } + public ?string $userAgent { get => $this->log->userAgent; } - public ?string $ipAddress { - get { - return $this->log->getIpAddress(); - } - } + public ?string $signature { get => $this->log->signature; } - public function getIpAddress(): ?string - { - return $this->ipAddress; - } - - public ?string $transactionHash { - get { - return $this->log->getTransactionHash(); - } - } - - public function getTransactionHash(): ?string - { - return $this->transactionHash; - } - - public DateTimeImmutable $createdAt { - get { - return $this->log->getCreatedAt(); - } - } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } + public DateTimeImmutable $createdAt { get => $this->log->createdAt; } /** - * @var array + * The underlying AuditLog entity. */ - public array $context { - get { - return $this->log->getContext(); - } - } - - /** - * @return array - */ - public function getContext(): array - { - return $this->context; - } - - /** - * Get the underlying AuditLog entity. - */ - public function getAuditLog(): AuditLogInterface - { - return $this->log; - } + public AuditLog $auditLog { get => $this->log; } // ========== Action Helpers ========== - public function isCreate(): bool - { - return AuditLogInterface::ACTION_CREATE === $this->log->getAction(); - } + public bool $isCreate { get => $this->log->action === 'create'; } - public function isUpdate(): bool - { - return AuditLogInterface::ACTION_UPDATE === $this->log->getAction(); - } + public bool $isUpdate { get => $this->log->action === 'update'; } - public function isDelete(): bool - { - return AuditLogInterface::ACTION_DELETE === $this->log->getAction(); - } + public bool $isDelete { get => $this->log->action === 'delete'; } - public function isSoftDelete(): bool - { - return AuditLogInterface::ACTION_SOFT_DELETE === $this->log->getAction(); - } + public bool $isSoftDelete { get => $this->log->action === 'soft_delete'; } - public function isRestore(): bool - { - return AuditLogInterface::ACTION_RESTORE === $this->log->getAction(); - } + public bool $isRestore { get => $this->log->action === 'restore'; } // ========== Diff Helpers ========== /** - * Get all changed fields with their old and new values. + * All changed fields with their old and new values. * - * @return array + * @var array */ - public function getDiff(): array - { - $oldValues = $this->log->getOldValues() ?? []; - $newValues = $this->log->getNewValues() ?? []; - $changedFields = $this->log->getChangedFields() ?? array_keys($newValues); - - $diff = []; - foreach ($changedFields as $field) { - $diff[$field] = [ - 'old' => $oldValues[$field] ?? null, - 'new' => $newValues[$field] ?? null, - ]; + public array $diff { + get { + $oldValues = $this->log->oldValues ?? []; + $newValues = $this->log->newValues ?? []; + $changedFields = $this->log->changedFields ?? array_keys($newValues); + + $diff = []; + foreach ($changedFields as $field) { + $diff[$field] = [ + 'old' => $oldValues[$field] ?? null, + 'new' => $newValues[$field] ?? null, + ]; + } + + return $diff; } - - return $diff; } /** - * Get list of fields that changed. + * List of fields that changed. * - * @return array + * @var array */ - public function getChangedFields(): array - { - return $this->log->getChangedFields() ?? []; - } + public array $changedFields { get => $this->log->changedFields ?? []; } /** * Check if a specific field was changed. */ public function hasFieldChanged(string $field): bool { - $changedFields = $this->log->getChangedFields() ?? []; + $changedFields = $this->log->changedFields ?? []; return in_array($field, $changedFields, true); } @@ -239,7 +121,7 @@ public function hasFieldChanged(string $field): bool */ public function getOldValue(string $field): mixed { - $oldValues = $this->log->getOldValues(); + $oldValues = $this->log->oldValues; return $oldValues[$field] ?? null; } @@ -249,28 +131,22 @@ public function getOldValue(string $field): mixed */ public function getNewValue(string $field): mixed { - $newValues = $this->log->getNewValues(); + $newValues = $this->log->newValues; return $newValues[$field] ?? null; } /** - * Get all old values. + * All old values. * - * @return array|null + * @var array|null */ - public function getOldValues(): ?array - { - return $this->log->getOldValues(); - } + public ?array $oldValues { get => $this->log->oldValues; } /** - * Get all new values. + * All new values. * - * @return array|null + * @var array|null */ - public function getNewValues(): ?array - { - return $this->log->getNewValues(); - } + public ?array $newValues { get => $this->log->newValues; } } diff --git a/src/Query/AuditEntryCollection.php b/src/Query/AuditEntryCollection.php index 86a9fd8..9494cef 100644 --- a/src/Query/AuditEntryCollection.php +++ b/src/Query/AuditEntryCollection.php @@ -85,7 +85,7 @@ public function groupByAction(): array { $grouped = []; foreach ($this->entries as $entry) { - $action = $entry->getAction(); + $action = $entry->action; if (!isset($grouped[$action])) { $grouped[$action] = []; } @@ -104,7 +104,7 @@ public function groupByEntity(): array { $grouped = []; foreach ($this->entries as $entry) { - $entityClass = $entry->getEntityClass(); + $entityClass = $entry->entityClass; if (!isset($grouped[$entityClass])) { $grouped[$entityClass] = []; } @@ -123,7 +123,7 @@ public function groupByEntityId(): array { $grouped = []; foreach ($this->entries as $entry) { - $key = $entry->getEntityClass().':'.$entry->getEntityId(); + $key = $entry->entityClass.':'.$entry->entityId; if (!isset($grouped[$key])) { $grouped[$key] = []; } @@ -138,7 +138,7 @@ public function groupByEntityId(): array */ public function getCreates(): self { - return $this->filter(static fn (AuditEntry $e) => $e->isCreate()); + return $this->filter(static fn (AuditEntry $e) => $e->isCreate); } /** @@ -146,7 +146,7 @@ public function getCreates(): self */ public function getUpdates(): self { - return $this->filter(static fn (AuditEntry $e) => $e->isUpdate()); + return $this->filter(static fn (AuditEntry $e) => $e->isUpdate); } /** @@ -154,7 +154,7 @@ public function getUpdates(): self */ public function getDeletes(): self { - return $this->filter(static fn (AuditEntry $e) => $e->isDelete() || $e->isSoftDelete()); + return $this->filter(static fn (AuditEntry $e) => $e->isDelete || $e->isSoftDelete); } /** diff --git a/src/Query/AuditQuery.php b/src/Query/AuditQuery.php index c9c9123..8d03bb1 100644 --- a/src/Query/AuditQuery.php +++ b/src/Query/AuditQuery.php @@ -6,11 +6,12 @@ use DateTimeImmutable; use DateTimeInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; -use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use function array_key_exists; use function count; +use function in_array; use const PHP_INT_MAX; @@ -31,7 +32,7 @@ * @param array $changedFields */ public function __construct( - private AuditLogRepository $repository, + private AuditLogRepositoryInterface $repository, private ?string $entityClass = null, private ?string $entityId = null, private array $actions = [], @@ -41,8 +42,8 @@ public function __construct( private ?DateTimeInterface $until = null, private array $changedFields = [], private int $limit = self::DEFAULT_LIMIT, - private ?int $afterId = null, - private ?int $beforeId = null, + private ?string $afterId = null, + private ?string $beforeId = null, ) { } @@ -75,7 +76,7 @@ public function action(string ...$actions): self */ public function creates(): self { - return $this->action(AuditLogInterface::ACTION_CREATE); + return $this->action('create'); } /** @@ -83,7 +84,7 @@ public function creates(): self */ public function updates(): self { - return $this->action(AuditLogInterface::ACTION_UPDATE); + return $this->action('update'); } /** @@ -91,7 +92,7 @@ public function updates(): self */ public function deletes(): self { - return $this->action(AuditLogInterface::ACTION_DELETE, AuditLogInterface::ACTION_SOFT_DELETE); + return $this->action('delete', 'soft_delete'); } /** @@ -151,17 +152,17 @@ public function limit(int $limit): self } /** - * Keyset pagination: Get results after a specific audit log ID. + * Keyset pagination: Get results after a specific audit log ID (UUID string). */ - public function after(int $id): self + public function after(string $id): self { return $this->with(['afterId' => $id, 'beforeId' => null]); } /** - * Keyset pagination: Get results before a specific audit log ID. + * Keyset pagination: Get results before a specific audit log ID (UUID string). */ - public function before(int $id): self + public function before(string $id): self { return $this->with(['afterId' => null, 'beforeId' => $id]); } @@ -193,8 +194,9 @@ private function with(array $params): self } /** @var array{ - * repository: AuditLogRepository, + * repository: AuditLogRepositoryInterface, * entityClass: ?string, + * * entityId: ?string, * actions: array, * userId: ?string, @@ -203,8 +205,8 @@ private function with(array $params): self * until: ?DateTimeInterface, * changedFields: array, * limit: int, - * afterId: ?int, - * beforeId: ?int + * afterId: ?string, + * beforeId: ?string * } $state */ return new self(...$state); } @@ -215,7 +217,7 @@ private function with(array $params): self public function getResults(): AuditEntryCollection { $logs = $this->fetchLogs($this->limit); - $entries = array_map(static fn (AuditLogInterface $log) => new AuditEntry($log), $logs); + $entries = array_map(static fn (AuditLog $log) => new AuditEntry($log), $logs); return new AuditEntryCollection(array_values($entries)); } @@ -236,7 +238,7 @@ public function count(): int } /** - * @return array + * @return array */ private function fetchLogs(int $limit): array { @@ -264,9 +266,9 @@ public function exists(): bool /** * Get the cursor (last ID) for pagination. */ - public function getNextCursor(): ?int + public function getNextCursor(): ?string { - return $this->getResults()->last()?->getId(); + return $this->getResults()->last()?->id?->toRfc4122(); } /** @@ -298,16 +300,15 @@ private function buildFilters(): array /** * Filter logs by changed fields (post-fetch). * - * @param array $logs + * @param array $logs * - * @return array + * @return array */ private function filterByChangedFields(array $logs): array { - return array_values(array_filter($logs, function (AuditLogInterface $log) { - $logChangedFields = $log->getChangedFields() ?? []; - - return [] !== array_intersect($this->changedFields, $logChangedFields); - })); + return array_values(array_filter($logs, fn (AuditLog $log) => array_any( + $this->changedFields, + static fn ($f) => in_array($f, $log->changedFields ?? [], true) + ))); } } diff --git a/src/Query/AuditReader.php b/src/Query/AuditReader.php index 4c27399..f2cf086 100644 --- a/src/Query/AuditReader.php +++ b/src/Query/AuditReader.php @@ -4,11 +4,12 @@ namespace Rcsofttech\AuditTrailBundle\Query; -use Doctrine\ORM\EntityManagerInterface; use Override; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditReaderInterface; -use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; -use Rcsofttech\AuditTrailBundle\Service\EntityIdResolver; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Throwable; /** * Main service for programmatic audit log retrieval. @@ -21,11 +22,11 @@ * ->updates() * ->getResults(); */ -readonly class AuditReader implements AuditReaderInterface +final readonly class AuditReader implements AuditReaderInterface { public function __construct( - private AuditLogRepository $repository, - private EntityManagerInterface $entityManager, + private AuditLogRepositoryInterface $repository, + private EntityIdResolverInterface $idResolver, ) { } @@ -92,7 +93,7 @@ public function getTimelineFor(object $entity): array $grouped = []; foreach ($history as $entry) { - $txHash = $entry->getTransactionHash() ?? 'unknown'; + $txHash = $entry->transactionHash ?? 'unknown'; $grouped[$txHash] ??= []; $grouped[$txHash][] = $entry; } @@ -133,8 +134,12 @@ public function hasHistoryFor(object $entity): bool */ private function extractEntityId(object $entity): ?string { - $id = EntityIdResolver::resolveFromEntity($entity, $this->entityManager); + try { + $id = $this->idResolver->resolveFromEntity($entity); - return $id === EntityIdResolver::PENDING_ID ? null : $id; + return $id === AuditLogInterface::PENDING_ID ? null : $id; + } catch (Throwable) { + return null; + } } } diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index ee47f8e..35cb5a2 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -138,9 +138,10 @@ private function applyEntityClassFilter(QueryBuilder $qb, array $filters): void $qb->andWhere('a.entityClass = :entityClass') ->setParameter('entityClass', $entityClass); } else { - // Partial match for short names + // Partial match for short names - escape wildcards + $escaped = addcslashes($entityClass, '%_'); $qb->andWhere('a.entityClass LIKE :entityClass') - ->setParameter('entityClass', '%'.$entityClass.'%'); + ->setParameter('entityClass', '%'.$escaped.'%'); } } @@ -206,6 +207,22 @@ private function applyKeysetPagination(QueryBuilder $qb, array $filters): void $qb->orderBy('a.id', $order); } + /** + * Find audit logs older than a given date. + * + * @return array + */ + #[Override] + public function findOlderThan(DateTimeImmutable $before): array + { + return $this->createQueryBuilder('a') + ->where('a.createdAt < :before') + ->setParameter('before', $before) + ->orderBy('a.createdAt', 'ASC') + ->getQuery() + ->getResult(); + } + /** * Count audit logs older than a given date. */ diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 53c28fc..61f048f 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -9,13 +9,21 @@ services: $enabled: '%audit_trail.enabled%' $deferTransportUntilCommit: '%audit_trail.defer_transport_until_commit%' $failOnTransportError: '%audit_trail.fail_on_transport_error%' - $fallbackToDatabase: '%audit_trail.fallback_to_database%' $tablePrefix: '%audit_trail.table_prefix%' $tableSuffix: '%audit_trail.table_suffix%' $timezone: '%audit_trail.timezone%' $ignoredEntities: '%audit_trail.ignored_entities%' $ignoredProperties: '%audit_trail.ignored_properties%' $cache: '@?rcsofttech_audit_trail.cache' + $auditedMethods: '%audit_trail.audited_methods%' + $fallbackToDatabase: '%audit_trail.fallback_to_database%' + + _instanceof: + Rcsofttech\AuditTrailBundle\Contract\AuditVoterInterface: + tags: ['audit_trail.voter'] + Rcsofttech\AuditTrailBundle\Contract\AuditContextContributorInterface: + tags: ['audit_trail.context_contributor'] + Rcsofttech\AuditTrailBundle\Service\AuditIntegrityService: bind: @@ -26,9 +34,57 @@ services: Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface: alias: Rcsofttech\AuditTrailBundle\Service\AuditIntegrityService + Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface: + alias: Rcsofttech\AuditTrailBundle\Service\AuditMetadataManager + + Rcsofttech\AuditTrailBundle\Contract\ContextResolverInterface: + alias: Rcsofttech\AuditTrailBundle\Service\ContextResolver + + Rcsofttech\AuditTrailBundle\Contract\DataMaskerInterface: + alias: Rcsofttech\AuditTrailBundle\Service\DataMasker + + Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface: + alias: Rcsofttech\AuditTrailBundle\Service\EntityIdResolver + + Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface: + alias: Rcsofttech\AuditTrailBundle\Service\AuditDispatcher + + Rcsofttech\AuditTrailBundle\Contract\AuditLogRepositoryInterface: + alias: Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository + + Rcsofttech\AuditTrailBundle\Contract\AuditReaderInterface: + alias: Rcsofttech\AuditTrailBundle\Query\AuditReader + + Rcsofttech\AuditTrailBundle\Contract\AuditRendererInterface: + alias: Rcsofttech\AuditTrailBundle\Service\AuditRenderer + + Rcsofttech\AuditTrailBundle\Contract\AuditExporterInterface: + alias: Rcsofttech\AuditTrailBundle\Service\AuditExporter + + Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface: + alias: Rcsofttech\AuditTrailBundle\Service\AuditService + + Rcsofttech\AuditTrailBundle\Contract\DiffGeneratorInterface: + alias: Rcsofttech\AuditTrailBundle\Service\DiffGenerator + + Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface: + alias: Rcsofttech\AuditTrailBundle\Service\UserResolver + + Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface: + alias: Rcsofttech\AuditTrailBundle\Service\ValueSerializer + + Rcsofttech\AuditTrailBundle\Query\AuditReader: + lazy: true + + Rcsofttech\AuditTrailBundle\Service\AuditRenderer: + lazy: true + + Rcsofttech\AuditTrailBundle\Service\AuditExporter: + lazy: true + Rcsofttech\AuditTrailBundle\: resource: '../../*' exclude: - '../../DependencyInjection/' - '../../Service/AuditIntegrityService.php' - + - '../../Transport/' diff --git a/src/Service/AuditAccessHandler.php b/src/Service/AuditAccessHandler.php index 67efcc9..9f4c140 100644 --- a/src/Service/AuditAccessHandler.php +++ b/src/Service/AuditAccessHandler.php @@ -8,12 +8,16 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Attribute\AuditAccess; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Service\ResetInterface; use Throwable; +use function in_array; use function preg_replace; use function sprintf; use function str_replace; @@ -28,13 +32,18 @@ class AuditAccessHandler implements ResetInterface /** @var array */ private array $skipAccessCheck = []; + /** + * @param array $auditedMethods + */ public function __construct( - private readonly AuditService $auditService, - private readonly AuditDispatcher $dispatcher, + private readonly AuditServiceInterface $auditService, + private readonly AuditDispatcherInterface $dispatcher, private readonly UserResolverInterface $userResolver, private readonly RequestStack $requestStack, + private readonly EntityIdResolverInterface $idResolver, private readonly ?CacheItemPoolInterface $cache = null, private readonly ?LoggerInterface $logger = null, + private readonly array $auditedMethods = ['GET'], ) { } @@ -43,7 +52,7 @@ public function __construct( */ public function handleAccess(object $entity, $om): void { - if ($this->isGetRequest() === false) { + if ($this->isAuditedRequest() === false) { return; } @@ -84,7 +93,7 @@ public function handleAccess(object $entity, $om): void public function markAsAudited(string $requestKey): void { - if ($this->isGetRequest() === false) { + if ($this->isAuditedRequest() === false) { return; } @@ -100,9 +109,9 @@ public function reset(): void private function resolveEntityId(object $entity, EntityManagerInterface $om): ?string { - $id = EntityIdResolver::resolveFromEntity($entity, $om); + $id = $this->idResolver->resolveFromEntity($entity, $om); - return $id === EntityIdResolver::PENDING_ID ? null : $id; + return $id === AuditLogInterface::PENDING_ID ? null : $id; } private function dispatchAccessAudit(object $entity, EntityManagerInterface $om, AuditAccess $accessAttr): void @@ -130,9 +139,11 @@ private function dispatchAccessAudit(object $entity, EntityManagerInterface $om, } } - private function isGetRequest(): bool + private function isAuditedRequest(): bool { - return $this->isGetRequest ??= $this->requestStack->getCurrentRequest()?->getMethod() === 'GET'; + $method = $this->requestStack->getCurrentRequest()?->getMethod(); + + return $this->isGetRequest ??= ($method !== null && in_array($method, $this->auditedMethods, true)); } private function shouldSkipAccessLog(string $requestKey, string $class, string $id, int $cooldown): bool diff --git a/src/Service/AuditDispatcher.php b/src/Service/AuditDispatcher.php index 6565818..9849e26 100644 --- a/src/Service/AuditDispatcher.php +++ b/src/Service/AuditDispatcher.php @@ -7,108 +7,95 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\UnitOfWork; use Psr\Log\LoggerInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; +use Rcsofttech\AuditTrailBundle\Event\AuditLogCreatedEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Throwable; -readonly class AuditDispatcher +use function sprintf; + +final class AuditDispatcher implements AuditDispatcherInterface { public function __construct( - private AuditTransportInterface $transport, - private AuditIntegrityServiceInterface $integrityService, - private ?LoggerInterface $logger = null, - private bool $failOnTransportError = false, - private bool $fallbackToDatabase = true, + private readonly AuditTransportInterface $transport, + private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly ?AuditIntegrityServiceInterface $integrityService = null, + private readonly ?LoggerInterface $logger = null, + private readonly bool $failOnTransportError = false, + private readonly bool $fallbackToDatabase = true, ) { } - /** - * @param array $context - */ - public function supports(string $phase, array $context = []): bool - { - return $this->transport->supports($phase, $context); - } - public function dispatch( - AuditLogInterface $audit, + AuditLog $audit, EntityManagerInterface $em, string $phase, ?UnitOfWork $uow = null, ): bool { + if (!$this->transport->supports($phase)) { + return false; + } + + $this->eventDispatcher?->dispatch(new AuditLogCreatedEvent($audit)); + + if ($this->integrityService?->isEnabled() === true) { + $audit->signature = $this->integrityService->generateSignature($audit); + } + $context = [ 'phase' => $phase, 'em' => $em, ]; - if ($uow instanceof UnitOfWork) { + if ($uow !== null) { $context['uow'] = $uow; } - if ($this->integrityService->isEnabled()) { - $audit->setSignature($this->integrityService->generateSignature($audit)); - } - - if ($this->safeSend($audit, $context)) { - return true; - } - - $this->persistFallback($audit, $em, $phase); - - return false; - } - - /** - * @param array $context - */ - public function safeSend(AuditLogInterface $audit, array $context): bool - { try { $this->transport->send($audit, $context); - - return true; + $audit->seal(); } catch (Throwable $e) { - $this->logger?->error('Failed to send audit to transport', [ - 'exception' => $e->getMessage(), - 'audit_action' => $audit->getAction(), - ]); + $this->logger?->error( + sprintf('Audit transport failed for %s#%s: %s', $audit->entityClass, $audit->entityId, $e->getMessage()), + ['exception' => $e] + ); if ($this->failOnTransportError) { throw $e; } + if ($this->fallbackToDatabase) { + $this->persistFallback($audit, $em); + + return true; + } + return false; } - } - private function persistFallback( - AuditLogInterface $audit, - EntityManagerInterface $em, - string $phase, - ): void { - if (!$this->fallbackToDatabase || !$em->isOpen()) { - return; - } + return true; + } + private function persistFallback(AuditLog $audit, EntityManagerInterface $em): void + { try { - if ($em->contains($audit)) { - return; + if (!$em->contains($audit)) { + $em->persist($audit); } - - $em->persist($audit); - - if ($phase === 'on_flush') { - $em->getUnitOfWork()->computeChangeSet( - $em->getClassMetadata(AuditLog::class), - $audit - ); - } - } catch (Throwable $e) { - $this->logger?->critical('Failed to persist audit log to database fallback', [ - 'exception' => $e->getMessage(), - ]); + $em->flush(); + $audit->seal(); + + $this->logger?->warning( + sprintf('Audit log for %s#%s saved via database fallback.', $audit->entityClass, $audit->entityId), + ); + } catch (Throwable $fallbackError) { + $this->logger?->critical( + sprintf('AUDIT LOSS: Failed to persist fallback for %s#%s: %s', $audit->entityClass, $audit->entityId, $fallbackError->getMessage()), + ['exception' => $fallbackError], + ); } } } diff --git a/src/Service/AuditExporter.php b/src/Service/AuditExporter.php index efc45f5..25f00c9 100644 --- a/src/Service/AuditExporter.php +++ b/src/Service/AuditExporter.php @@ -6,55 +6,90 @@ use DateTimeInterface; use InvalidArgumentException; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Override; +use Rcsofttech\AuditTrailBundle\Contract\AuditExporterInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use RuntimeException; use Stringable; +use function in_array; use function is_array; use function is_scalar; -use function mb_strlen; use function sprintf; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; -final readonly class AuditExporter +final readonly class AuditExporter implements AuditExporterInterface { /** - * @param array $audits + * @param iterable $audits */ - public function formatAudits(array $audits, string $format): string + #[Override] + public function formatAudits(iterable $audits, string $format): string { - $rows = array_map([$this, 'auditToArray'], $audits); - return match ($format) { - 'json' => json_encode($rows, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - 'csv' => $this->formatAsCsv($rows), + 'json' => $this->formatAsJson($audits), + 'csv' => $this->formatAsCsv($audits), default => throw new InvalidArgumentException(sprintf('Unsupported format: %s', $format)), }; } /** - * @param array> $rows + * @param iterable $audits */ - public function formatAsCsv(array $rows): string + public function formatAsJson(iterable $audits): string { - if ($rows === []) { - return ''; + $output = fopen('php://temp', 'r+'); + if ($output === false) { + throw new RuntimeException('Failed to open temp stream for JSON generation'); } + try { + fwrite($output, '['); + $first = true; + + foreach ($audits as $audit) { + if (!$first) { + fwrite($output, ','); + } + fwrite($output, json_encode($this->auditToArray($audit), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + $first = false; + } + + fwrite($output, ']'); + rewind($output); + $json = stream_get_contents($output); + + return $json !== false ? $json : '[]'; + } finally { + fclose($output); + } + } + + /** + * @param iterable $audits + */ + public function formatAsCsv(iterable $audits): string + { $output = fopen('php://temp', 'r+'); if ($output === false) { throw new RuntimeException('Failed to open temp stream for CSV generation'); } try { - fputcsv($output, array_keys($rows[0]), ',', '"', '\\'); + $headerWritten = false; + + foreach ($audits as $audit) { + $row = $this->auditToArray($audit); + if (!$headerWritten) { + fputcsv($output, array_keys($row), ',', '"', '\\'); + $headerWritten = true; + } - foreach ($rows as $row) { $csvRow = array_map( - static fn ($value) => is_array($value) ? json_encode($value, JSON_THROW_ON_ERROR) : (string) (is_scalar($value) || $value instanceof Stringable ? $value : ''), + fn ($value) => is_array($value) ? json_encode($value, JSON_THROW_ON_ERROR) : $this->sanitizeCsvValue((string) (is_scalar($value) || $value instanceof Stringable ? $value : '')), $row ); fputcsv($output, $csvRow, ',', '"', '\\'); @@ -70,31 +105,51 @@ public function formatAsCsv(array $rows): string } /** + * @param AuditLog|array $audit + * * @return array */ - public function auditToArray(AuditLogInterface $audit): array + public function auditToArray(AuditLog|array $audit): array { + if (is_array($audit)) { + return $audit; + } + return [ - 'id' => $audit->getId(), - 'entity_class' => $audit->getEntityClass(), - 'entity_id' => $audit->getEntityId(), - 'action' => $audit->getAction(), - 'old_values' => $audit->getOldValues(), - 'new_values' => $audit->getNewValues(), - 'changed_fields' => $audit->getChangedFields(), - 'user_id' => $audit->getUserId(), - 'username' => $audit->getUsername(), - 'ip_address' => $audit->getIpAddress(), - 'user_agent' => $audit->getUserAgent(), - 'created_at' => $audit->getCreatedAt()->format(DateTimeInterface::ATOM), + 'id' => $audit->id?->toRfc4122(), + 'entity_class' => $audit->entityClass, + 'entity_id' => $audit->entityId, + 'action' => $audit->action, + 'old_values' => $audit->oldValues, + 'new_values' => $audit->newValues, + 'changed_fields' => $audit->changedFields, + 'user_id' => $audit->userId, + 'username' => $audit->username, + 'ip_address' => $audit->ipAddress, + 'user_agent' => $audit->userAgent, + 'created_at' => $audit->createdAt->format(DateTimeInterface::ATOM), ]; } + #[Override] public function formatFileSize(int $bytes): string { - $units = ['B', 'KB', 'MB', 'GB']; - $factor = (int) floor((mb_strlen((string) $bytes) - 1) / 3); + if ($bytes <= 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = (int) floor(log($bytes, 1024)); + + return sprintf('%.2f %s', $bytes / (1024 ** $i), $units[$i] ?? 'B'); + } + + private function sanitizeCsvValue(string $value): string + { + if ($value !== '' && in_array(mb_substr($value, 0, 1), ['=', '+', '-', '@'], true)) { + return "'".$value; + } - return sprintf('%.2f %s', $bytes / (1024 ** $factor), $units[$factor]); + return $value; } } diff --git a/src/Service/AuditIntegrityService.php b/src/Service/AuditIntegrityService.php index a26fb89..9155e6b 100644 --- a/src/Service/AuditIntegrityService.php +++ b/src/Service/AuditIntegrityService.php @@ -9,145 +9,198 @@ use DateTimeZone; use Override; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; -use Stringable; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; +use RuntimeException; use Throwable; +use function hash_hmac; use function is_array; +use function is_bool; use function is_float; use function is_int; -use function is_scalar; use function is_string; +use function json_encode; +use function ksort; +use function strlen; -use const JSON_PRESERVE_ZERO_FRACTION; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; +use const SORT_STRING; -final readonly class AuditIntegrityService implements AuditIntegrityServiceInterface +final class AuditIntegrityService implements AuditIntegrityServiceInterface { + private readonly DateTimeZone $utc; + public function __construct( - private string $secret, - private string $algorithm = 'sha256', - private bool $enabled = false, + private readonly ?string $secret = null, + private readonly bool $enabled = false, + private readonly string $algorithm = 'sha256', ) { + $this->utc = new DateTimeZone('UTC'); } + private const int MAX_NORMALIZATION_DEPTH = 5; + #[Override] public function isEnabled(): bool { - return $this->enabled; + return $this->enabled && $this->secret !== null; } #[Override] - public function generateSignature(AuditLogInterface $log): string + public function generateSignature(AuditLog $log): string { - $data = $this->getLogData($log); + if ($this->secret === null) { + throw new RuntimeException('Cannot generate signature: secret key is not configured.'); + } - return hash_hmac($this->algorithm, $data, $this->secret); - } + $data = $this->normalizeData($log); + $payload = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - #[Override] - public function signPayload(string $payload): string - { return hash_hmac($this->algorithm, $payload, $this->secret); } #[Override] - public function verifySignature(AuditLogInterface $log): bool + public function verifySignature(AuditLog $log): bool { - $signature = $log->getSignature(); - if ($signature === null) { - return AuditLogInterface::ACTION_REVERT === $log->getAction(); + if (!$this->isEnabled()) { + return true; + } + + $storedSignature = $log->signature; + + if ($storedSignature === null) { + return false; } $expectedSignature = $this->generateSignature($log); - return hash_equals($expectedSignature, $signature); + return hash_equals($expectedSignature, $storedSignature); } - private function getLogData(AuditLogInterface $log): string + #[Override] + public function signPayload(string $payload): string + { + if ($this->secret === null) { + throw new RuntimeException('Cannot sign payload: secret key is not configured.'); + } + + return hash_hmac($this->algorithm, $payload, $this->secret); + } + + /** + * @return array + */ + private function normalizeData(AuditLog $log): array { $data = [ - $log->getEntityClass(), - (string) (is_scalar($id = $this->normalize($log->getEntityId())) || $id instanceof Stringable ? $id : ''), - $log->getAction(), - $this->toJson($log->getOldValues()), - $this->toJson($log->getNewValues()), - (string) (is_scalar($userId = $this->normalize($log->getUserId())) || $userId instanceof Stringable ? $userId : ''), - $log->getUsername() ?? '', - $log->getIpAddress() ?? '', - $log->getUserAgent() ?? '', - $log->getTransactionHash() ?? '', - $log->getCreatedAt()->setTimezone(new DateTimeZone('UTC'))->format(DateTimeInterface::ATOM), + 'entity_class' => $log->entityClass, + 'entity_id' => $log->entityId, + 'action' => $log->action, + 'old_values' => $this->normalizeValues($log->oldValues), + 'new_values' => $this->normalizeValues($log->newValues), + 'user_id' => $log->userId, + 'username' => $log->username, + 'context' => $this->normalizeValues($log->context), + 'ip_address' => $log->ipAddress, + 'user_agent' => $log->userAgent, + 'transaction_hash' => $log->transactionHash, + 'created_at' => $log->createdAt->setTimezone($this->utc)->format('Y-m-d H:i:s'), ]; - return implode('|', $data); + ksort($data, SORT_STRING); + + return $data; } /** - * Normalizes data for hashing to ensure stability across type changes (e.g., int to string IDs). + * @param array|null $values + * + * @return array|null */ - private function normalize(mixed $data): mixed + private function normalizeValues(?array $values, int $depth = 0): ?array { - if (is_array($data)) { - return $this->normalizeArray($data); + if ($values === null) { + return null; } - if (is_int($data) || is_float($data)) { - return (string) $data; + if ($depth >= self::MAX_NORMALIZATION_DEPTH) { + return ['_error' => 'max_depth_reached']; } - if ($data instanceof DateTimeInterface) { - return $this->normalizeDateTime($data); + $normalized = []; + foreach ($values as $key => $value) { + $normalized[$key] = $this->normalizeValue($value, $depth + 1); } - return $data; + ksort($normalized, SORT_STRING); + + return $normalized; } - /** - * @param array $data - */ - private function normalizeArray(array $data): mixed + private function normalizeValue(mixed $value, int $depth = 0): mixed { - // Check if this is a serialized DateTime array - if (isset($data['date'], $data['timezone']) && is_string($data['date']) && is_string($data['timezone'])) { - try { - $dt = new DateTimeImmutable($data['date'], new DateTimeZone($data['timezone'])); + if ($value === null) { + return 'n:'; + } - return $this->normalizeDateTime($dt); - } catch (Throwable) { - // Fallback to standard array normalization if it's not a valid date - } + if (is_bool($value)) { + return 'b:'.($value ? '1' : '0'); } - $normalized = []; - foreach ($data as $key => $value) { - $normalized[$key] = $this->normalize($value); + if (is_int($value)) { + return 'i:'.$value; } - ksort($normalized); - return $normalized; + if (is_float($value)) { + return 'f:'.$value; + } + + if (is_string($value)) { + return $this->normalizeString($value); + } + + if (is_array($value)) { + return $this->normalizeArray($value, $depth); + } + + return 's:'.(string) $value; } - private function normalizeDateTime(DateTimeInterface $dt): string + private function normalizeString(string $value): string { - $immutable = $dt instanceof DateTimeImmutable - ? $dt - : DateTimeImmutable::createFromInterface($dt); + if (strlen($value) >= 10 && preg_match('/^\d{4}-\d{2}-\d{2}/', $value) === 1) { + try { + $dt = new DateTimeImmutable($value); - return $immutable->setTimezone(new DateTimeZone('UTC'))->format(DateTimeInterface::ATOM); + return 'd:'.$dt->setTimezone($this->utc)->format(DateTimeInterface::ATOM); + } catch (Throwable) { + // Not a date + } + } + + return 's:'.$value; } - private function toJson(mixed $data): string + /** + * @param array|array{date?: string, timezone?: string} $value + */ + private function normalizeArray(array $value, int $depth): mixed { - if ($data === null) { - return 'null'; + if ($depth >= self::MAX_NORMALIZATION_DEPTH) { + return 's:[max_depth]'; + } + + if (isset($value['date'], $value['timezone'])) { + try { + $dt = new DateTimeImmutable($value['date'], new DateTimeZone($value['timezone'])); + + return 'd:'.$dt->setTimezone($this->utc)->format(DateTimeInterface::ATOM); + } catch (Throwable) { + } } - return json_encode( - $this->normalize($data), - JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION - ); + return $this->normalizeValues($value, $depth); } } diff --git a/src/Service/AuditMetadataManager.php b/src/Service/AuditMetadataManager.php new file mode 100644 index 0000000..5df50a5 --- /dev/null +++ b/src/Service/AuditMetadataManager.php @@ -0,0 +1,71 @@ + $ignoredEntities + * @param array $ignoredProperties + */ + public function __construct( + private readonly MetadataCache $metadataCache, + private readonly array $ignoredEntities = [], + private readonly array $ignoredProperties = [], + ) { + } + + public function getAuditableAttribute(string $class): ?Auditable + { + return $this->metadataCache->getAuditableAttribute($class); + } + + public function getAuditAccessAttribute(string $class): ?AuditAccess + { + return $this->metadataCache->getAuditAccessAttribute($class); + } + + /** + * @return array + */ + public function getSensitiveFields(string $class): array + { + return $this->metadataCache->getSensitiveFields($class); + } + + /** + * @param array $additionalIgnored + * + * @return array + */ + public function getIgnoredProperties(object $entity, array $additionalIgnored = []): array + { + $ignored = [...$this->ignoredProperties, ...$additionalIgnored]; + + $auditable = $this->getAuditableAttribute($entity::class); + if ($auditable !== null) { + $ignored = [...$ignored, ...$auditable->ignoredProperties]; + } + + return array_unique($ignored); + } + + public function isEntityIgnored(string $class): bool + { + if (in_array($class, $this->ignoredEntities, true)) { + return true; + } + + $auditable = $this->getAuditableAttribute($class); + + return $auditable === null || !$auditable->enabled; + } +} diff --git a/src/Service/AuditRenderer.php b/src/Service/AuditRenderer.php index 1469232..c95e68b 100644 --- a/src/Service/AuditRenderer.php +++ b/src/Service/AuditRenderer.php @@ -4,7 +4,9 @@ namespace Rcsofttech\AuditTrailBundle\Service; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Override; +use Rcsofttech\AuditTrailBundle\Contract\AuditRendererInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Util\ClassNameHelperTrait; use Stringable; use Symfony\Component\Console\Helper\Table; @@ -20,12 +22,12 @@ use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -final class AuditRenderer +final readonly class AuditRenderer implements AuditRendererInterface { use ClassNameHelperTrait; /** - * @param array $audits + * @param array $audits */ public function renderTable(OutputInterface $output, array $audits, bool $showDetails): void { @@ -46,19 +48,20 @@ public function renderTable(OutputInterface $output, array $audits, bool $showDe /** * @return array */ - public function buildRow(AuditLogInterface $audit, bool $showDetails): array + #[Override] + public function buildRow(AuditLog $audit, bool $showDetails): array { - $user = $audit->getUsername() ?? (string) $audit->getUserId(); + $user = $audit->username ?? (string) $audit->userId; if ($user === '') { $user = '-'; } - $hash = $this->shortenHash($audit->getTransactionHash()); - $date = $audit->getCreatedAt()->format('Y-m-d H:i:s'); + $hash = $this->shortenHash($audit->transactionHash); + $date = $audit->createdAt->format('Y-m-d H:i:s'); if ($showDetails) { return [ - $audit->getEntityId(), - $audit->getAction(), + $audit->entityId, + $audit->action, $user, $hash, $this->formatChangedDetails($audit), @@ -67,21 +70,22 @@ public function buildRow(AuditLogInterface $audit, bool $showDetails): array } return [ - $audit->getId(), - $this->shortenClass($audit->getEntityClass()), - $audit->getEntityId(), - $audit->getAction(), + $audit->id?->toRfc4122(), + $this->shortenClass($audit->entityClass), + $audit->entityId, + $audit->action, $user, $hash, $date, ]; } - public function formatChangedDetails(AuditLogInterface $audit): string + #[Override] + public function formatChangedDetails(AuditLog $audit): string { - $old = (array) $audit->getOldValues(); - $new = (array) $audit->getNewValues(); - $changed = (array) $audit->getChangedFields(); + $old = (array) $audit->oldValues; + $new = (array) $audit->newValues; + $changed = (array) $audit->changedFields; if ($this->isEmptyAudit($old, $new, $changed)) { return '-'; @@ -145,6 +149,7 @@ private function determineFieldsToShow(array $changedFields, array $oldValues, a return array_unique([...array_keys($oldValues), ...array_keys($newValues)]); } + #[Override] public function formatValue(mixed $value): string { if ($value === null) { @@ -178,6 +183,9 @@ private function formatArrayValue(array $value): string private function truncateString(string $str): string { + // Strip ANSI escape sequences to prevent terminal injection + $str = preg_replace('/\x1b\[[0-9;]*[a-zA-Z]/', '', $str) ?? $str; + return mb_strlen($str) > 50 ? mb_substr($str, 0, 47).'...' : $str; } diff --git a/src/Service/AuditReverter.php b/src/Service/AuditReverter.php index 755b583..d267d41 100644 --- a/src/Service/AuditReverter.php +++ b/src/Service/AuditReverter.php @@ -7,9 +7,13 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Override; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditReverterInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use RuntimeException; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -21,10 +25,11 @@ public function __construct( private EntityManagerInterface $em, private ValidatorInterface $validator, - private AuditService $auditService, + private AuditServiceInterface $auditService, private RevertValueDenormalizer $denormalizer, - private SoftDeleteHandler $softDeleteHandler, + private SoftDeleteHandlerInterface $softDeleteHandler, private AuditIntegrityServiceInterface $integrityService, + private AuditDispatcherInterface $dispatcher, ) { } @@ -35,19 +40,19 @@ public function __construct( */ #[Override] public function revert( - AuditLogInterface $log, + AuditLog $log, bool $dryRun = false, bool $force = false, array $context = [], ): 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->getId() ?? 'unknown')); + throw new RuntimeException(sprintf('Audit log #%s has been tampered with and cannot be reverted.', $log->id?->toRfc4122() ?? 'unknown')); } - $entity = $this->findEntity($log->getEntityClass(), $log->getEntityId()); + $entity = $this->findEntity($log->entityClass, $log->entityId); if ($entity === null) { - throw new RuntimeException(sprintf('Entity %s:%s not found.', $log->getEntityClass(), $log->getEntityId())); + throw new RuntimeException(sprintf('Entity %s:%s not found.', $log->entityClass, $log->entityId)); } $changes = $this->determineChanges($log, $entity, $force); @@ -64,13 +69,13 @@ public function revert( /** * @return array */ - private function determineChanges(AuditLogInterface $log, object $entity, bool $force): array + private function determineChanges(AuditLog $log, object $entity, bool $force): array { - return match ($log->getAction()) { + return match ($log->action) { AuditLogInterface::ACTION_CREATE => $this->handleRevertCreate($force), AuditLogInterface::ACTION_UPDATE => $this->handleRevertUpdate($log, $entity), AuditLogInterface::ACTION_SOFT_DELETE => $this->handleRevertSoftDelete($entity), - default => throw new RuntimeException(sprintf('Reverting action "%s" is not supported.', $log->getAction())), + default => throw new RuntimeException(sprintf('Reverting action "%s" is not supported.', $log->action)), }; } @@ -78,7 +83,7 @@ private function determineChanges(AuditLogInterface $log, object $entity, bool $ * @param array $changes * @param array $context */ - private function applyAndPersist(object $entity, AuditLogInterface $log, array $changes, array $context): void + private function applyAndPersist(object $entity, AuditLog $log, array $changes, array $context): void { $isDelete = isset($changes['action']) && $changes['action'] === 'delete'; @@ -109,14 +114,14 @@ private function validateEntity(object $entity): void */ private function createRevertAuditLog( object $entity, - AuditLogInterface $log, + AuditLog $log, array $changes, bool $isDelete, array $context, ): void { $revertContext = [ ...$context, - 'reverted_log_id' => $log->getId(), + 'reverted_log_id' => $log->id?->toRfc4122(), ]; $revertLog = $this->auditService->createAuditLog( @@ -127,13 +132,7 @@ private function createRevertAuditLog( $revertContext ); - $revertLog->setOldValues($isDelete ? null : $changes); - $revertLog->setNewValues(null); - $revertLog->setEntityId($log->getEntityId()); - $revertLog->setEntityClass($log->getEntityClass()); - - $this->em->persist($revertLog); - $this->em->flush(); + $this->dispatcher->dispatch($revertLog, $this->em, 'post_flush'); } /** @@ -151,9 +150,9 @@ private function handleRevertCreate(bool $force): array /** * @return array */ - private function handleRevertUpdate(AuditLogInterface $log, object $entity): array + private function handleRevertUpdate(AuditLog $log, object $entity): array { - $oldValues = $log->getOldValues() ?? []; + $oldValues = $log->oldValues ?? []; if ($oldValues === []) { throw new RuntimeException('No old values found in audit log to revert to.'); } diff --git a/src/Service/AuditService.php b/src/Service/AuditService.php index ea1dba3..cdd82d0 100644 --- a/src/Service/AuditService.php +++ b/src/Service/AuditService.php @@ -6,68 +6,58 @@ use DateTimeZone; use Doctrine\ORM\EntityManagerInterface; +use Override; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditContextContributorInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditVoterInterface; -use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\ContextResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; -use Stringable; use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use Throwable; -use Traversable; -use function in_array; -use function is_scalar; +use function sprintf; +use function strlen; -class AuditService +use const JSON_THROW_ON_ERROR; + +final class AuditService implements AuditServiceInterface { private const string PENDING_ID = 'pending'; + private const int MAX_CONTEXT_BYTES = 65_536; + + private readonly DateTimeZone $tz; + /** - * @param array $ignoredEntities - * @param array $ignoredProperties - * @param iterable $voters - * @param iterable $contributors + * @param iterable $voters */ public function __construct( private readonly EntityManagerInterface $entityManager, - private readonly UserResolverInterface $userResolver, private readonly ClockInterface $clock, private readonly TransactionIdGenerator $transactionIdGenerator, private readonly EntityDataExtractor $dataExtractor, - private readonly MetadataCache $metadataCache, - private readonly array $ignoredEntities = [], - private readonly array $ignoredProperties = [], + private readonly AuditMetadataManagerInterface $metadataManager, + private readonly ContextResolverInterface $contextResolver, + private readonly EntityIdResolverInterface $idResolver, private readonly ?LoggerInterface $logger = null, private readonly string $timezone = 'UTC', #[AutowireIterator('audit_trail.voter')] private readonly iterable $voters = [], - #[AutowireIterator('audit_trail.context_contributor')] - private readonly iterable $contributors = [], ) { + $this->tz = new DateTimeZone($this->timezone); } - /** - * Check if the entity should be audited. - * - * @param array $changeSet - */ + #[Override] public function shouldAudit( object $entity, string $action = AuditLogInterface::ACTION_CREATE, array $changeSet = [], ): bool { - $class = $entity::class; - - if (in_array($class, $this->ignoredEntities, true)) { - return false; - } - - $auditable = $this->metadataCache->getAuditableAttribute($class); - - if ($auditable === null || !$auditable->enabled) { + if ($this->metadataManager->isEntityIgnored($entity::class)) { return false; } @@ -81,137 +71,93 @@ public function shouldAudit( */ public function passesVoters(object $entity, string $action, array $changeSet = []): bool { - return array_all( - $this->voters instanceof Traversable ? iterator_to_array($this->voters) : $this->voters, - static fn (AuditVoterInterface $voter) => $voter->vote($entity, $action, $changeSet) - ); + foreach ($this->voters as $voter) { + if (!$voter->vote($entity, $action, $changeSet)) { + return false; + } + } + + return true; } + #[Override] public function getAccessAttribute(string $class): ?\Rcsofttech\AuditTrailBundle\Attribute\AuditAccess { - return $this->metadataCache->getAuditAccessAttribute($class); + return $this->metadataManager->getAuditAccessAttribute($class); } - /** - * Extract entity data for auditing. - * - * @param array $additionalIgnored - * - * @return array - */ + #[Override] public function getEntityData(object $entity, array $additionalIgnored = []): array { - $ignored = $this->getIgnoredProperties($entity, $additionalIgnored); + $ignored = $this->metadataManager->getIgnoredProperties($entity, $additionalIgnored); return $this->dataExtractor->extract($entity, $ignored); } - /** - * @param array $additionalIgnored - * - * @return array - */ - public function getIgnoredProperties(object $entity, array $additionalIgnored = []): array - { - $ignored = [...$this->ignoredProperties, ...$additionalIgnored]; - - $auditable = $this->metadataCache->getAuditableAttribute($entity::class); - if ($auditable !== null) { - $ignored = [...$ignored, ...$auditable->ignoredProperties]; - } - - return array_unique($ignored); - } - - /** - * Create audit log entry. - * - * @param array|null $oldValues - * @param array|null $newValues - * @param array $context - */ + #[Override] public function createAuditLog( object $entity, string $action, ?array $oldValues = null, ?array $newValues = null, array $context = [], - ): AuditLogInterface { - $auditLog = new AuditLog(); - $auditLog->setEntityClass($entity::class); - - $entityId = EntityIdResolver::resolveFromEntity($entity, $this->entityManager); + ): AuditLog { + $entityId = $this->idResolver->resolveFromEntity($entity, $this->entityManager); if ($entityId === self::PENDING_ID && $action === AuditLogInterface::ACTION_DELETE && $oldValues !== null) { - $entityId = EntityIdResolver::resolveFromValues( + $entityId = $this->idResolver->resolveFromValues( $entity, $oldValues, $this->entityManager ) ?? self::PENDING_ID; } - $auditLog->setEntityId($entityId); - $auditLog->setAction($action); - $auditLog->setOldValues($oldValues); - $auditLog->setNewValues($newValues); + $changedFields = ($action === AuditLogInterface::ACTION_UPDATE && $newValues !== null) + ? array_keys($newValues) + : null; - if ($action === AuditLogInterface::ACTION_UPDATE && $newValues !== null) { - $auditLog->setChangedFields(array_keys($newValues)); + try { + $resolvedContext = $this->contextResolver->resolve($entity, $action, $newValues ?? [], $context); + } catch (Throwable $e) { + $this->logger?->warning('Failed to resolve audit context: '.$e->getMessage()); + $resolvedContext = [ + 'userId' => null, + 'username' => null, + 'ipAddress' => null, + 'userAgent' => null, + 'context' => ['_error' => 'Context resolution failed'], + ]; } - $this->enrichWithUserContext($auditLog, $entity, $context); - $auditLog->setTransactionHash($this->transactionIdGenerator->getTransactionId()); - $auditLog->setCreatedAt($this->clock->now()->setTimezone(new DateTimeZone($this->timezone))); + $contextData = $resolvedContext['context']; - return $auditLog; - } + $encoded = json_encode($contextData, JSON_THROW_ON_ERROR); + if (strlen($encoded) > self::MAX_CONTEXT_BYTES) { + $this->logger?->warning( + sprintf('Audit context for %s#%s truncated (%d bytes exceeded %d limit).', $entity::class, $entityId, strlen($encoded), self::MAX_CONTEXT_BYTES), + ); + $contextData = ['_truncated' => true, '_original_size' => strlen($encoded)]; + } - /** - * @return array - */ - public function getSensitiveFields(object $entity): array - { - return $this->metadataCache->getSensitiveFields($entity::class); + return new AuditLog( + entityClass: $entity::class, + entityId: (string) $entityId, + action: $action, + createdAt: $this->clock->now()->setTimezone($this->tz), + oldValues: $oldValues, + newValues: $newValues, + changedFields: $changedFields, + transactionHash: $this->transactionIdGenerator->getTransactionId(), + userId: $resolvedContext['userId'], + username: $resolvedContext['username'], + ipAddress: $resolvedContext['ipAddress'], + userAgent: $resolvedContext['userAgent'], + context: $contextData + ); } - /** - * @param array $extraContext - */ - private function enrichWithUserContext(AuditLog $auditLog, object $entity, array $extraContext = []): void + #[Override] + public function getSensitiveFields(object $entity): array { - try { - $userId = $extraContext[AuditLogInterface::CONTEXT_USER_ID] ?? $this->userResolver->getUserId(); - $username = $extraContext[AuditLogInterface::CONTEXT_USERNAME] ?? $this->userResolver->getUsername(); - - $auditLog->setUserId((is_scalar($userId) || ($userId instanceof Stringable)) ? (string) $userId : null); - $auditLog->setUsername((is_scalar($username) || ($username instanceof Stringable)) ? (string) $username : null); - $auditLog->setIpAddress($this->userResolver->getIpAddress()); - $auditLog->setUserAgent($this->userResolver->getUserAgent()); - - // Remove internal "transport" keys so they don't pollute the JSON storage - $context = array_diff_key($extraContext, [ - AuditLogInterface::CONTEXT_USER_ID => true, - AuditLogInterface::CONTEXT_USERNAME => true, - ]); - - $impersonatorId = $this->userResolver->getImpersonatorId(); - if ($impersonatorId !== null) { - $context['impersonation'] = [ - 'impersonator_id' => $impersonatorId, - 'impersonator_username' => $this->userResolver->getImpersonatorUsername(), - ]; - } - - // Add custom context from contributors - foreach ($this->contributors as $contributor) { - $context = [ - ...$context, - ...$contributor->contribute($entity, $auditLog->getAction(), $auditLog->getNewValues() ?? []), - ]; - } - - $auditLog->setContext($context); - } catch (Throwable $e) { - $this->logger?->error('Failed to set user context', ['exception' => $e->getMessage()]); - } + return $this->metadataManager->getSensitiveFields($entity::class); } } diff --git a/src/Service/ChangeProcessor.php b/src/Service/ChangeProcessor.php index cfd02e6..7563927 100644 --- a/src/Service/ChangeProcessor.php +++ b/src/Service/ChangeProcessor.php @@ -6,16 +6,19 @@ use Doctrine\ORM\EntityManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; +use Rcsofttech\AuditTrailBundle\Contract\ChangeProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use function array_key_exists; use function in_array; use function is_array; -class ChangeProcessor +final class ChangeProcessor implements ChangeProcessorInterface { public function __construct( - private readonly AuditService $auditService, - private readonly ValueSerializer $serializer, + private readonly AuditMetadataManagerInterface $metadataManager, + private readonly ValueSerializerInterface $serializer, private readonly bool $enableSoftDelete = true, private readonly string $softDeleteField = 'deletedAt', ) { @@ -30,8 +33,8 @@ public function extractChanges(object $entity, array $changeSet): array { $old = []; $new = []; - $sensitiveFields = $this->auditService->getSensitiveFields($entity); - $ignored = $this->auditService->getIgnoredProperties($entity); + $sensitiveFields = $this->metadataManager->getSensitiveFields($entity::class); + $ignored = $this->metadataManager->getIgnoredProperties($entity); foreach ($changeSet as $field => $change) { if (in_array($field, $ignored, true)) { diff --git a/src/Service/ContextResolver.php b/src/Service/ContextResolver.php new file mode 100644 index 0000000..4d4ec94 --- /dev/null +++ b/src/Service/ContextResolver.php @@ -0,0 +1,114 @@ + $contributors + */ + public function __construct( + private readonly UserResolverInterface $userResolver, + private readonly DataMaskerInterface $dataMasker, + #[AutowireIterator('audit_trail.context_contributor')] + private readonly iterable $contributors = [], + private readonly ?LoggerInterface $logger = null, + ) { + } + + /** + * @param array $newValues + * @param array $extraContext + * + * @return array{ + * userId: ?string, + * username: ?string, + * ipAddress: ?string, + * userAgent: ?string, + * context: array + * } + */ + public function resolve(object $entity, string $action, array $newValues, array $extraContext): array + { + $userId = null; + $username = null; + $ipAddress = null; + $userAgent = null; + $context = []; + + try { + $userId = $extraContext[AuditLogInterface::CONTEXT_USER_ID] ?? $this->userResolver->getUserId(); + $username = $extraContext[AuditLogInterface::CONTEXT_USERNAME] ?? $this->userResolver->getUsername(); + $ipAddress = $this->userResolver->getIpAddress(); + $userAgent = $this->userResolver->getUserAgent(); + + $context = $this->buildContext($extraContext, $entity, $action, $newValues); + } catch (Throwable $e) { + $this->logger?->error('Failed to resolve audit context', ['exception' => $e->getMessage()]); + } + + return [ + 'userId' => $this->stringify($userId), + 'username' => $this->stringify($username), + 'ipAddress' => $ipAddress, + 'userAgent' => $userAgent, + 'context' => $this->dataMasker->redact($context), + ]; + } + + private function stringify(mixed $value): ?string + { + if (is_scalar($value) || $value instanceof Stringable) { + return (string) $value; + } + + return null; + } + + /** + * @param array $extraContext + * @param array $newValues + * + * @return array + */ + private function buildContext(array $extraContext, object $entity, string $action, array $newValues): array + { + // Remove internal "transport" keys so they don't pollute the JSON storage + $context = array_diff_key($extraContext, [ + AuditLogInterface::CONTEXT_USER_ID => true, + AuditLogInterface::CONTEXT_USERNAME => true, + ]); + + $impersonatorId = $this->userResolver->getImpersonatorId(); + if ($impersonatorId !== null) { + $context['impersonation'] = [ + 'impersonator_id' => $impersonatorId, + 'impersonator_username' => $this->userResolver->getImpersonatorUsername(), + ]; + } + + // Add custom context from contributors + foreach ($this->contributors as $contributor) { + $context = [ + ...$context, + ...$contributor->contribute($entity, $action, $newValues), + ]; + } + + return $context; + } +} diff --git a/src/Service/DataMasker.php b/src/Service/DataMasker.php new file mode 100644 index 0000000..bb1773f --- /dev/null +++ b/src/Service/DataMasker.php @@ -0,0 +1,66 @@ + $data + * + * @return array + */ + public function redact(array $data): array + { + foreach ($data as $key => $value) { + foreach (self::DEFAULT_SENSITIVE_KEYS as $sensitive) { + if (str_contains(strtolower($key), $sensitive)) { + $data[$key] = '********'; + continue 2; + } + } + + if (is_array($value)) { + $data[$key] = $this->redact($value); + } + } + + return $data; + } + + /** + * @param array $data + * @param array $sensitiveFields + * + * @return array + */ + public function mask(array $data, array $sensitiveFields): array + { + foreach ($sensitiveFields as $field => $mask) { + if (array_key_exists($field, $data)) { + $data[$field] = $mask; + } + } + + return $data; + } +} diff --git a/src/Service/EntityDataExtractor.php b/src/Service/EntityDataExtractor.php index 7f67313..b398e26 100644 --- a/src/Service/EntityDataExtractor.php +++ b/src/Service/EntityDataExtractor.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Psr\Log\LoggerInterface; +use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use Throwable; use function array_key_exists; @@ -16,7 +17,7 @@ class EntityDataExtractor { public function __construct( private readonly EntityManagerInterface $entityManager, - private readonly ValueSerializer $serializer, + private ValueSerializerInterface $serializer, private readonly MetadataCache $metadataCache, private readonly ?LoggerInterface $logger = null, ) { @@ -79,12 +80,22 @@ private function processFields(ClassMetadata $meta, object $entity, array $ignor */ private function processAssociations(ClassMetadata $meta, object $entity, array $ignored, array &$data): void { + $uow = $this->entityManager->getUnitOfWork(); + foreach ($meta->getAssociationNames() as $assoc) { if (in_array($assoc, $ignored, true)) { continue; } $value = $this->getFieldValueSafely($meta, $entity, $assoc); + + // Optimization: If it's an uninitialized proxy, extract only the ID to prevent N+1 query + if ($value instanceof \Doctrine\Persistence\Proxy && !$value->__isInitialized()) { + $identifier = $uow->getEntityIdentifier($value); + $data[$assoc] = $this->serializer->serialize($identifier); + continue; + } + $data[$assoc] = $this->serializer->serializeAssociation($value); } } diff --git a/src/Service/EntityIdResolver.php b/src/Service/EntityIdResolver.php index 534d7d7..a6b35db 100644 --- a/src/Service/EntityIdResolver.php +++ b/src/Service/EntityIdResolver.php @@ -5,7 +5,10 @@ namespace Rcsofttech\AuditTrailBundle\Service; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\Proxy; +use Override; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Stringable; use Throwable; @@ -18,43 +21,69 @@ /** * @internal */ -final class EntityIdResolver +final class EntityIdResolver implements EntityIdResolverInterface { - public const string PENDING_ID = 'pending'; + public function __construct( + private readonly ?EntityManagerInterface $entityManager = null, + ) { + } /** * @param array $context */ - public static function resolve(AuditLogInterface $log, array $context): ?string + #[Override] + public function resolve(object $object, array $context = []): ?string { - $currentId = $log->getEntityId(); + if (!$object instanceof AuditLogInterface) { + return null; + } - if ($currentId !== self::PENDING_ID) { + $currentId = $object->entityId; + + if ($currentId !== AuditLogInterface::PENDING_ID) { return (bool) ($context['is_insert'] ?? false) ? $currentId : null; } - return self::resolveFromContext($context); + return $this->resolveFromContext($context); } - public static function resolveFromEntity(object $entity, EntityManagerInterface $em): string + #[Override] + public function resolveFromEntity(object $entity, ?EntityManagerInterface $em = null): string { - try { - return self::resolveFromMetadata($entity, $em) ?? self::resolveFromMethod($entity) ?? self::PENDING_ID; - } catch (Throwable) { - return self::resolveFromMethod($entity) ?? self::PENDING_ID; + $resolvedId = null; + $em ??= $this->entityManager; + + if ($em !== null) { + try { + $resolvedId = $this->resolveFromMetadata($entity, $em); + } catch (Throwable) { + // fallback + } + } + + if ($resolvedId !== null) { + return $resolvedId; + } + + $methodId = $this->resolveFromMethod($entity); + if ($methodId !== null) { + return $methodId; } + + return AuditLogInterface::PENDING_ID; } /** * @param array $values */ - public static function resolveFromValues(object $entity, array $values, EntityManagerInterface $em): ?string + #[Override] + public function resolveFromValues(object $entity, array $values, EntityManagerInterface $em): ?string { try { $meta = $em->getClassMetadata($entity::class); $idFields = $meta->getIdentifierFieldNames(); - $ids = self::collectIdsFromValues($idFields, $values); + $ids = $this->collectIdsFromValues($idFields, $values); if ($ids === null) { return null; } @@ -67,10 +96,9 @@ public static function resolveFromValues(object $entity, array $values, EntityMa } } - private static function resolveFromMetadata(object $entity, EntityManagerInterface $em): ?string + private function resolveFromMetadata(object $entity, EntityManagerInterface $em): ?string { - $meta = $em->getClassMetadata($entity::class); - $ids = $meta->getIdentifierValues($entity); + $ids = $this->extractEntityIds($entity, $em); if ($ids === []) { return null; @@ -78,7 +106,7 @@ private static function resolveFromMetadata(object $entity, EntityManagerInterfa $idValues = []; foreach ($ids as $val) { - $formatted = self::formatId($val); + $formatted = $this->formatId($val); if ($formatted !== null) { $idValues[] = $formatted; } @@ -88,12 +116,29 @@ private static function resolveFromMetadata(object $entity, EntityManagerInterfa return null; } - return count($idValues) > 1 - ? json_encode($idValues, JSON_THROW_ON_ERROR) - : reset($idValues); + if (count($idValues) === 1) { + return reset($idValues); + } + + return json_encode($idValues, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + private function extractEntityIds(object $entity, EntityManagerInterface $em): array + { + if ($entity instanceof Proxy) { + $ids = $em->getUnitOfWork()->getEntityIdentifier($entity); + if ($ids !== []) { + return $ids; + } + } + + return $em->getClassMetadata($entity::class)->getIdentifierValues($entity); } - private static function resolveFromMethod(object $entity): ?string + private function resolveFromMethod(object $entity): ?string { if (!method_exists($entity, 'getId')) { return null; @@ -102,13 +147,13 @@ private static function resolveFromMethod(object $entity): ?string try { $id = $entity->getId(); - return self::formatId($id); + return $this->formatId($id); } catch (Throwable) { return null; } } - private static function formatId(mixed $id): ?string + private function formatId(mixed $id): ?string { if (is_scalar($id) || $id instanceof Stringable) { return (string) $id; @@ -123,7 +168,7 @@ private static function formatId(mixed $id): ?string * * @return array|null */ - private static function collectIdsFromValues(array $idFields, array $values): ?array + private function collectIdsFromValues(array $idFields, array $values): ?array { $ids = []; foreach ($idFields as $idField) { @@ -131,7 +176,7 @@ private static function collectIdsFromValues(array $idFields, array $values): ?a return null; } $val = $values[$idField]; - $ids[] = self::formatId($val) ?? ''; + $ids[] = $this->formatId($val) ?? ''; } return $ids; @@ -140,7 +185,7 @@ private static function collectIdsFromValues(array $idFields, array $values): ?a /** * @param array $context */ - private static function resolveFromContext(array $context): ?string + private function resolveFromContext(array $context): ?string { $entity = $context['entity'] ?? null; $em = $context['em'] ?? null; @@ -149,6 +194,6 @@ private static function resolveFromContext(array $context): ?string return null; } - return self::resolveFromEntity($entity, $em); + return $this->resolveFromEntity($entity, $em); } } diff --git a/src/Service/EntityProcessor.php b/src/Service/EntityProcessor.php index 23cee79..01fbe68 100644 --- a/src/Service/EntityProcessor.php +++ b/src/Service/EntityProcessor.php @@ -7,19 +7,26 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\UnitOfWork; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ChangeProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use function in_array; use function is_string; -readonly class EntityProcessor +final readonly class EntityProcessor implements EntityProcessorInterface { public function __construct( - private AuditService $auditService, - private ChangeProcessor $changeProcessor, - private AuditDispatcher $dispatcher, - private ScheduledAuditManager $auditManager, + private AuditServiceInterface $auditService, + private ChangeProcessorInterface $changeProcessor, + private AuditDispatcherInterface $dispatcher, + private ScheduledAuditManagerInterface $auditManager, + private EntityIdResolverInterface $idResolver, private bool $deferTransportUntilCommit = true, ) { } @@ -66,7 +73,7 @@ public function processUpdates(EntityManagerInterface $em, UnitOfWork $uow): voi } /** - * @param iterable> $collectionUpdates + * @param iterable $collectionUpdates */ public function processCollectionUpdates( EntityManagerInterface $em, @@ -74,6 +81,10 @@ public function processCollectionUpdates( iterable $collectionUpdates, ): void { foreach ($collectionUpdates as $collection) { + if (!method_exists($collection, 'getInsertDiff')) { + continue; + } + /** @var PersistentCollection $collection */ $owner = $collection->getOwner(); if ($owner === null) { continue; @@ -137,22 +148,21 @@ private function shouldProcessEntity(object $entity): bool } private function dispatchOrSchedule( - AuditLogInterface $audit, + AuditLog $audit, object $entity, EntityManagerInterface $em, UnitOfWork $uow, bool $isInsert, ): void { - if ( - !$this->deferTransportUntilCommit && $this->dispatcher->supports( - 'on_flush', - ['em' => $em, 'uow' => $uow] - ) - ) { - $this->dispatcher->dispatch($audit, $em, 'on_flush', $uow); - } else { - $this->auditManager->schedule($entity, $audit, $isInsert); + // Smart flush detection + $canDispatchNow = !$this->deferTransportUntilCommit + || ($isInsert && $audit->entityId !== AuditLogInterface::PENDING_ID); + + if ($canDispatchNow && $this->dispatcher->dispatch($audit, $em, 'on_flush', $uow)) { + return; } + + $this->auditManager->schedule($entity, $audit, $isInsert); } /** @@ -164,8 +174,8 @@ private function extractIdsFromCollection(array $items, EntityManagerInterface $ { $ids = []; foreach ($items as $item) { - $id = EntityIdResolver::resolveFromEntity($item, $em); - if ($id !== EntityIdResolver::PENDING_ID) { + $id = $this->idResolver->resolveFromEntity($item, $em); + if ($id !== AuditLogInterface::PENDING_ID) { $ids[] = $id; } } @@ -196,8 +206,7 @@ private function computeNewIds( } $deletedIds = $this->extractIdsFromCollection($deleteDiff, $em); - $newIds = array_filter($newIds, static fn ($id) => !in_array($id, $deletedIds, true)); - return array_values($newIds); + return array_filter($newIds, static fn ($id) => !in_array($id, $deletedIds, true)); } } diff --git a/src/Service/ExpressionLanguageVoter.php b/src/Service/ExpressionLanguageVoter.php index 59873fa..105205b 100644 --- a/src/Service/ExpressionLanguageVoter.php +++ b/src/Service/ExpressionLanguageVoter.php @@ -5,11 +5,22 @@ namespace Rcsofttech\AuditTrailBundle\Service; use Override; +use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditVoterInterface; use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\ExpressionLanguage\ParsedExpression; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use Throwable; +/** + * Evaluates `#[AuditCondition]` expressions to determine audit eligibility. + * + * @security Expressions are pre-parsed at first use and restricted to the variables + * `object`, `action`, `changeSet`, and `user`. NEVER source expression strings + * from user input, database records, or any untrusted origin — doing so would + * allow arbitrary code execution. + */ final class ExpressionLanguageVoter implements AuditVoterInterface { private ?ExpressionLanguage $expressionLanguage = null; @@ -17,9 +28,27 @@ final class ExpressionLanguageVoter implements AuditVoterInterface /** @var array */ private array $cache = []; + private const array ALLOWED_VARIABLES = ['object', 'action', 'changeSet', 'user']; + + /** Characters that strongly indicate external/untrusted expression injection. */ + private const array FORBIDDEN_PATTERNS = [ + 'system(', + 'exec(', + 'passthru(', + 'shell_exec(', + 'popen(', + 'proc_open(', + 'file_get_contents(', + 'file_put_contents(', + 'unlink(', + 'rmdir(', + 'constant(', + ]; + public function __construct( private readonly MetadataCache $metadataCache, private readonly UserResolverInterface $userResolver, + private readonly ?LoggerInterface $logger = null, ) { } @@ -31,27 +60,68 @@ public function vote(object $entity, string $action, array $changeSet): bool return true; } - $this->expressionLanguage ??= new ExpressionLanguage(); - $expression = $condition->expression; - if (!isset($this->cache[$expression])) { - $this->cache[$expression] = $this->expressionLanguage->parse($expression, [ - 'object', - 'action', - 'changeSet', - 'user', + + if (!$this->isExpressionSafe($expression)) { + $this->logger?->critical('Blocked potentially dangerous AuditCondition expression.', [ + 'entity' => $entity::class, + 'expression' => $expression, + ]); + + return false; + } + + try { + $this->expressionLanguage ??= new ExpressionLanguage(); + + if (!isset($this->cache[$expression])) { + $this->cache[$expression] = $this->expressionLanguage->parse( + $expression, + self::ALLOWED_VARIABLES, + ); + } + + return (bool) $this->expressionLanguage->evaluate($this->cache[$expression], [ + 'object' => $entity, + 'action' => $action, + 'changeSet' => $changeSet, + 'user' => new readonly class($this->userResolver->getUserId(), $this->userResolver->getUsername(), $this->userResolver->getIpAddress()) { + public function __construct( + public ?string $id, + public ?string $username, + public ?string $ip, + ) { + } + }, ]); + } catch (SyntaxError $e) { + $this->logger?->error('AuditCondition expression syntax error.', [ + 'entity' => $entity::class, + 'expression' => $expression, + 'error' => $e->getMessage(), + ]); + + return false; + } catch (Throwable $e) { + $this->logger?->error('AuditCondition expression evaluation failed.', [ + 'entity' => $entity::class, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + private function isExpressionSafe(string $expression): bool + { + $normalized = strtolower($expression); + + foreach (self::FORBIDDEN_PATTERNS as $pattern) { + if (str_contains($normalized, $pattern)) { + return false; + } } - return (bool) $this->expressionLanguage->evaluate($this->cache[$expression], [ - 'object' => $entity, - 'action' => $action, - 'changeSet' => $changeSet, - 'user' => (object) [ - 'id' => $this->userResolver->getUserId(), - 'username' => $this->userResolver->getUsername(), - 'ip' => $this->userResolver->getIpAddress(), - ], - ]); + return true; } } diff --git a/src/Service/MetadataCache.php b/src/Service/MetadataCache.php index 2fee840..bd160a7 100644 --- a/src/Service/MetadataCache.php +++ b/src/Service/MetadataCache.php @@ -4,22 +4,19 @@ namespace Rcsofttech\AuditTrailBundle\Service; +use Generator; use Rcsofttech\AuditTrailBundle\Attribute\Auditable; use Rcsofttech\AuditTrailBundle\Attribute\AuditAccess; use Rcsofttech\AuditTrailBundle\Attribute\AuditCondition; use Rcsofttech\AuditTrailBundle\Attribute\Sensitive; -use ReflectionAttribute; use ReflectionClass; use ReflectionException; use SensitiveParameter; -use function array_key_exists; -use function count; +use function is_object; class MetadataCache { - private const int MAX_CACHE_SIZE = 100; - /** @var array */ private array $auditableCache = []; @@ -32,155 +29,138 @@ class MetadataCache /** @var array */ private array $accessCache = []; - public function getAuditableAttribute(string $class): ?Auditable + public function getAuditableAttribute(string|object $class): ?Auditable { - if (array_key_exists($class, $this->auditableCache)) { - return $this->auditableCache[$class]; - } - - $this->ensureCacheSize($this->auditableCache); - $attribute = $this->resolveAttribute($class, Auditable::class); - $this->auditableCache[$class] = $attribute; + /** @var class-string $className */ + $className = is_object($class) ? $class::class : $class; - return $attribute; + return $this->auditableCache[$className] ??= $this->resolveAttribute($className, Auditable::class); } - public function getAuditAccessAttribute(string $class): ?AuditAccess + public function getAuditAccessAttribute(string|object $class): ?AuditAccess { - if (array_key_exists($class, $this->accessCache)) { - return $this->accessCache[$class]; - } - - $this->ensureCacheSize($this->accessCache); - $attribute = $this->resolveAttribute($class, AuditAccess::class); - $this->accessCache[$class] = $attribute; + /** @var class-string $className */ + $className = is_object($class) ? $class::class : $class; - return $attribute; + return $this->accessCache[$className] ??= $this->resolveAttribute($className, AuditAccess::class); } - public function getAuditCondition(string $class): ?AuditCondition + public function getAuditCondition(string|object $class): ?AuditCondition { - if (array_key_exists($class, $this->conditionCache)) { - return $this->conditionCache[$class]; - } + /** @var class-string $className */ + $className = is_object($class) ? $class::class : $class; - $this->ensureCacheSize($this->conditionCache); - $attribute = $this->resolveAttribute($class, AuditCondition::class); - $this->conditionCache[$class] = $attribute; - - return $attribute; + return $this->conditionCache[$className] ??= $this->resolveAttribute($className, AuditCondition::class); } /** * @return array */ - public function getSensitiveFields(string $class): array + public function getSensitiveFields(string|object $class): array { - if (array_key_exists($class, $this->sensitiveFieldsCache)) { - return $this->sensitiveFieldsCache[$class]; - } - - $this->ensureCacheSize($this->sensitiveFieldsCache); - $fields = $this->resolveSensitiveFields($class); - $this->sensitiveFieldsCache[$class] = $fields; - - return $fields; - } + /** @var class-string $className */ + $className = is_object($class) ? $class::class : $class; - /** - * @template T - * - * @param array $cache - */ - private function ensureCacheSize(array &$cache): void - { - if (count($cache) >= self::MAX_CACHE_SIZE) { - array_shift($cache); - } + return $this->sensitiveFieldsCache[$className] ??= $this->resolveSensitiveFields($className); } /** * @template T of object * + * @param class-string $class * @param class-string $attributeClass * * @return T|null */ private function resolveAttribute(string $class, string $attributeClass): ?object { - $currentClass = $class; - while ($currentClass) { - try { - if (!class_exists($currentClass)) { - break; - } - $reflection = new ReflectionClass($currentClass); + try { + foreach ($this->getClassHierarchy($class) as $reflection) { $attributes = $reflection->getAttributes($attributeClass); if ($attributes !== []) { return $attributes[0]->newInstance(); } - } catch (ReflectionException) { - break; } - $currentClass = get_parent_class($currentClass); + } catch (ReflectionException) { } return null; } /** + * @param class-string $class + * + * @return Generator> + */ + private function getClassHierarchy(string $class): Generator + { + $current = new ReflectionClass($class); + + while ($current !== false) { + yield $current; + $current = $current->getParentClass(); + } + } + + /** + * @param class-string $class + * * @return array */ private function resolveSensitiveFields(string $class): array { - /** @var array $sensitiveFields */ - $sensitiveFields = []; try { - if (!class_exists($class)) { - return []; - } $reflection = new ReflectionClass($class); - $this->analyzeProperties($reflection, $sensitiveFields); - $this->analyzeConstructorParameters($reflection, $sensitiveFields); + return [ + ...$this->resolveConstructorSensitiveFields($reflection), + ...$this->resolvePropertySensitiveFields($reflection), + ]; } catch (ReflectionException) { - // Ignore + return []; } - - return $sensitiveFields; } /** * @param ReflectionClass $reflection - * @param array $sensitiveFields + * + * @return array */ - private function analyzeProperties(ReflectionClass $reflection, array &$sensitiveFields): void + private function resolvePropertySensitiveFields(ReflectionClass $reflection): array { + $fields = []; + foreach ($reflection->getProperties() as $property) { - /** @var list> $attributes */ $attributes = $property->getAttributes(Sensitive::class); if ($attributes !== []) { - /** @var Sensitive $sensitive */ - $sensitive = $attributes[0]->newInstance(); - $sensitiveFields[$property->getName()] = $sensitive->mask; + $fields[$property->getName()] = $attributes[0]->newInstance()->mask; } } + + return $fields; } /** * @param ReflectionClass $reflection - * @param array $sensitiveFields + * + * @return array */ - private function analyzeConstructorParameters(ReflectionClass $reflection, array &$sensitiveFields): void + private function resolveConstructorSensitiveFields(ReflectionClass $reflection): array { + $fields = []; $constructor = $reflection->getConstructor(); - if ($constructor !== null) { - foreach ($constructor->getParameters() as $param) { - $attributes = $param->getAttributes(SensitiveParameter::class); - if ($attributes !== [] && $param->isPromoted() && !isset($sensitiveFields[$param->getName()])) { - $sensitiveFields[$param->getName()] = '**REDACTED**'; - } + + if ($constructor === null) { + return $fields; + } + + foreach ($constructor->getParameters() as $param) { + $attributes = $param->getAttributes(SensitiveParameter::class); + if ($attributes !== [] && $param->isPromoted()) { + $fields[$param->getName()] = '**REDACTED**'; } } + + return $fields; } } diff --git a/src/Service/RevertValueDenormalizer.php b/src/Service/RevertValueDenormalizer.php index b4e61c1..f2d06f6 100644 --- a/src/Service/RevertValueDenormalizer.php +++ b/src/Service/RevertValueDenormalizer.php @@ -12,7 +12,6 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Exception; -use function in_array; use function is_array; use function is_object; use function is_scalar; @@ -37,20 +36,17 @@ public function denormalize(ClassMetadata $metadata, string $field, mixed $value if ($metadata->hasField($field)) { $type = $metadata->getTypeOfField($field); - if ( - in_array($type, [ - 'datetime', - 'datetime_immutable', - 'datetimetz', - 'datetimetz_immutable', - 'date', - 'date_immutable', - 'time', - 'time_immutable', - ], true) - ) { - return $this->denormalizeDateTime($value, $type); - } + return match ($type) { + 'datetime', + 'datetime_immutable', + 'datetimetz', + 'datetimetz_immutable', + 'date', + 'date_immutable', + 'time', + 'time_immutable' => $this->denormalizeDateTime($value, $type), + default => $value, + }; } if ($metadata->hasAssociation($field)) { @@ -110,9 +106,13 @@ private function denormalizeDateTimeFromArray(array $value, string $dateTimeClas /** * @param class-string|class-string $dateTimeClass */ - private function denormalizeDateTimeFromString(string $value, string $dateTimeClass): DateTimeInterface + private function denormalizeDateTimeFromString(string $value, string $dateTimeClass): ?DateTimeInterface { - return new $dateTimeClass($value); + try { + return new $dateTimeClass($value); + } catch (Exception) { + return null; + } } /** diff --git a/src/Service/ScheduledAuditManager.php b/src/Service/ScheduledAuditManager.php index 20acba8..2a5cac2 100644 --- a/src/Service/ScheduledAuditManager.php +++ b/src/Service/ScheduledAuditManager.php @@ -5,28 +5,29 @@ namespace Rcsofttech\AuditTrailBundle\Service; use OverflowException; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Event\AuditLogCreatedEvent; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function count; use function sprintf; -class ScheduledAuditManager +final class ScheduledAuditManager implements ScheduledAuditManagerInterface { private const int MAX_SCHEDULED_AUDITS = 1000; /** * @var array */ - private array $scheduledAudits = []; + public private(set) array $scheduledAudits = []; /** @var list, is_managed: bool}> */ - private array $pendingDeletions = []; + public private(set) array $pendingDeletions = []; public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, @@ -35,7 +36,7 @@ public function __construct( public function schedule( object $entity, - AuditLogInterface $audit, + AuditLog $audit, bool $isInsert, ): void { if (count($this->scheduledAudits) >= self::MAX_SCHEDULED_AUDITS) { @@ -63,46 +64,16 @@ public function addPendingDeletion(object $entity, array $data, bool $isManaged) ]; } - /** - * @return array - */ - public function getScheduledAudits(): array - { - return $this->scheduledAudits; - } - - /** - * @return list, is_managed: bool}> - */ - public function getPendingDeletions(): array - { - return $this->pendingDeletions; - } - public function clear(): void { $this->scheduledAudits = []; $this->pendingDeletions = []; } - public function hasScheduledAudits(): bool - { - return $this->scheduledAudits !== []; - } - - public function countScheduled(): int - { - return count($this->scheduledAudits); - } - private function dispatchCreatedEvent( object $entity, - AuditLogInterface $audit, - ): AuditLogInterface { + AuditLog $audit, + ): AuditLog { if ($this->eventDispatcher === null) { return $audit; } @@ -110,6 +81,6 @@ private function dispatchCreatedEvent( $event = new AuditLogCreatedEvent($audit, $entity); $this->eventDispatcher->dispatch($event, AuditLogCreatedEvent::NAME); - return $event->getAuditLog(); + return $event->auditLog; } } diff --git a/src/Service/SoftDeleteHandler.php b/src/Service/SoftDeleteHandler.php index 7129154..ccf2d8d 100644 --- a/src/Service/SoftDeleteHandler.php +++ b/src/Service/SoftDeleteHandler.php @@ -5,11 +5,12 @@ namespace Rcsofttech\AuditTrailBundle\Service; use Doctrine\ORM\EntityManagerInterface; +use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface; -class SoftDeleteHandler +final readonly class SoftDeleteHandler implements SoftDeleteHandlerInterface { public function __construct( - private readonly EntityManagerInterface $em, + private EntityManagerInterface $em, ) { } @@ -31,14 +32,13 @@ public function restoreSoftDeleted(object $entity): void public function disableSoftDeleteFilters(): array { $filters = $this->em->getFilters(); - $disabled = []; - foreach ($filters->getEnabledFilters() as $name => $filter) { - if (str_contains($filter::class, 'SoftDeleteableFilter')) { - $filters->disable($name); - $disabled[] = $name; - } - } + $disabled = array_keys(array_filter( + $filters->getEnabledFilters(), + static fn ($filter) => str_contains($filter::class, 'SoftDeleteableFilter') + )); + + array_walk($disabled, $filters->disable(...)); return $disabled; } @@ -48,9 +48,6 @@ public function disableSoftDeleteFilters(): array */ public function enableFilters(array $names): void { - $filters = $this->em->getFilters(); - foreach ($names as $name) { - $filters->enable($name); - } + array_walk($names, $this->em->getFilters()->enable(...)); } } diff --git a/src/Service/ValueSerializer.php b/src/Service/ValueSerializer.php index 7d674e2..517f758 100644 --- a/src/Service/ValueSerializer.php +++ b/src/Service/ValueSerializer.php @@ -6,24 +6,28 @@ use DateTimeInterface; use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\PersistentCollection; +use Override; use Psr\Log\LoggerInterface; +use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface; use function is_array; use function is_object; use function is_resource; use function sprintf; -class ValueSerializer +final readonly class ValueSerializer implements ValueSerializerInterface { private const int MAX_SERIALIZATION_DEPTH = 5; private const int MAX_COLLECTION_ITEMS = 100; public function __construct( - private readonly ?LoggerInterface $logger = null, + private ?LoggerInterface $logger = null, ) { } + #[Override] public function serialize(mixed $value, int $depth = 0): mixed { if ($depth >= self::MAX_SERIALIZATION_DEPTH) { @@ -43,6 +47,7 @@ public function serialize(mixed $value, int $depth = 0): mixed }; } + #[Override] public function serializeAssociation(mixed $value): mixed { if ($value === null) { @@ -65,6 +70,14 @@ 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', + ]; + } + $count = $value->count(); if ($count > self::MAX_COLLECTION_ITEMS) { @@ -73,6 +86,8 @@ private function serializeCollection(Collection $value, int $depth, bool $onlyId 'max' => self::MAX_COLLECTION_ITEMS, ]); + $sample = $value->slice(0, self::MAX_COLLECTION_ITEMS); + return [ '_truncated' => true, '_total_count' => $count, @@ -80,18 +95,19 @@ private function serializeCollection(Collection $value, int $depth, bool $onlyId fn ($item) => $onlyIdentifiers && is_object($item) ? $this->extractEntityIdentifier($item) : $this->serialize($item, $depth + 1), - $value->slice(0, self::MAX_COLLECTION_ITEMS) + $sample ), ]; } - return $value->map(function (mixed $item) use ($depth, $onlyIdentifiers) { - if ($onlyIdentifiers && is_object($item)) { - return $this->extractEntityIdentifier($item); - } + $items = $value->toArray(); - return $this->serialize($item, $depth + 1); - })->toArray(); + return array_map( + fn ($item) => $onlyIdentifiers && is_object($item) + ? $this->extractEntityIdentifier($item) + : $this->serialize($item, $depth + 1), + $items + ); } private function serializeObject(object $value): mixed diff --git a/src/Transport/ChainAuditTransport.php b/src/Transport/ChainAuditTransport.php index 5ef7809..30e2ac2 100644 --- a/src/Transport/ChainAuditTransport.php +++ b/src/Transport/ChainAuditTransport.php @@ -5,8 +5,8 @@ namespace Rcsofttech\AuditTrailBundle\Transport; use Override; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Traversable; use function is_string; @@ -25,7 +25,7 @@ public function __construct( * @param array $context */ #[Override] - public function send(AuditLogInterface $log, array $context = []): void + public function send(AuditLog $log, array $context = []): void { $phase = $context['phase'] ?? null; if (!is_string($phase)) { @@ -33,7 +33,6 @@ public function send(AuditLogInterface $log, array $context = []): void } foreach ($this->transports as $transport) { - // If phase is specified, only send to transports that support it if ($phase !== null && !$transport->supports($phase, $context)) { continue; } @@ -45,7 +44,6 @@ public function send(AuditLogInterface $log, array $context = []): void #[Override] public function supports(string $phase, array $context = []): bool { - // Optimistic support: return true if ANY transport supports the phase return array_any( $this->transports instanceof Traversable ? iterator_to_array($this->transports) : $this->transports, static fn (AuditTransportInterface $transport) => $transport->supports($phase, $context) diff --git a/src/Transport/DoctrineAuditTransport.php b/src/Transport/DoctrineAuditTransport.php index 38cac59..b9f579a 100644 --- a/src/Transport/DoctrineAuditTransport.php +++ b/src/Transport/DoctrineAuditTransport.php @@ -7,24 +7,28 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\UnitOfWork; use Override; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; -use Rcsofttech\AuditTrailBundle\Service\EntityIdResolver; final class DoctrineAuditTransport implements AuditTransportInterface { + public function __construct( + private readonly EntityIdResolverInterface $idResolver, + ) { + } + /** * @param array $context */ #[Override] - public function send(AuditLogInterface $log, array $context = []): void + public function send(AuditLog $log, array $context = []): void { $phase = $context['phase'] ?? null; if ($phase === 'on_flush') { $this->handleOnFlush($log, $context); - } elseif ($phase === 'post_flush' || $phase === 'post_load') { + } elseif ($phase === 'post_flush' || $phase === 'post_load' || $phase === 'batch_flush') { $this->handlePostFlush($log, $context); } } @@ -32,12 +36,12 @@ public function send(AuditLogInterface $log, array $context = []): void /** * @param array $context */ - private function handleOnFlush(AuditLogInterface $log, array $context): void + private function handleOnFlush(AuditLog $log, array $context): void { /** @var EntityManagerInterface $em */ $em = $context['em']; /** @var UnitOfWork $uow */ - $uow = $context['uow']; // This is now guaranteed to exist by the Subscriber fix + $uow = $context['uow']; $em->persist($log); $uow->computeChangeSet($em->getClassMetadata(AuditLog::class), $log); @@ -46,28 +50,25 @@ private function handleOnFlush(AuditLogInterface $log, array $context): void /** * @param array $context */ - private function handlePostFlush(AuditLogInterface $log, array $context): void + private function handlePostFlush(AuditLog $log, array $context): void { /** @var EntityManagerInterface $em */ $em = $context['em']; - // Persist if not managed if (!$em->contains($log)) { $em->persist($log); } - // Doctrine will pick this change up when the subscriber does the final flush. - $entityId = EntityIdResolver::resolve($log, $context); + $entityId = $this->idResolver->resolve($log, $context); if ($entityId !== null) { - $log->setEntityId($entityId); + $log->entityId = $entityId; } } #[Override] public function supports(string $phase, array $context = []): bool { - // Doctrine transport supports both phases return true; } } diff --git a/src/Transport/HttpAuditTransport.php b/src/Transport/HttpAuditTransport.php index d6a4791..c88a1cc 100644 --- a/src/Transport/HttpAuditTransport.php +++ b/src/Transport/HttpAuditTransport.php @@ -6,12 +6,15 @@ use DateTimeInterface; use Override; +use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; -use Rcsofttech\AuditTrailBundle\Service\EntityIdResolver; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Symfony\Contracts\HttpClient\HttpClientInterface; +use function sprintf; + use const JSON_THROW_ON_ERROR; final class HttpAuditTransport implements AuditTransportInterface @@ -23,6 +26,8 @@ public function __construct( private readonly HttpClientInterface $client, private readonly string $endpoint, private readonly AuditIntegrityServiceInterface $integrityService, + private readonly EntityIdResolverInterface $idResolver, + private readonly ?LoggerInterface $logger = null, private readonly array $headers = [], private readonly int $timeout = 5, ) { @@ -32,25 +37,25 @@ public function __construct( * @param array $context */ #[Override] - public function send(AuditLogInterface $log, array $context = []): void + public function send(AuditLog $log, array $context = []): void { - $entityId = EntityIdResolver::resolve($log, $context) ?? $log->getEntityId(); + $entityId = $this->idResolver->resolve($log, $context) ?? $log->entityId; $payload = [ - 'entity_class' => $log->getEntityClass(), + 'entity_class' => $log->entityClass, 'entity_id' => $entityId, - 'action' => $log->getAction(), - 'old_values' => $log->getOldValues(), - 'new_values' => $log->getNewValues(), - 'changed_fields' => $log->getChangedFields(), - 'user_id' => $log->getUserId(), - 'username' => $log->getUsername(), - 'ip_address' => $log->getIpAddress(), - 'user_agent' => $log->getUserAgent(), - 'transaction_hash' => $log->getTransactionHash(), - 'signature' => $log->getSignature(), - 'context' => [...$log->getContext(), ...$context], - 'created_at' => $log->getCreatedAt()->format(DateTimeInterface::ATOM), + 'action' => $log->action, + 'old_values' => $log->oldValues, + 'new_values' => $log->newValues, + 'changed_fields' => $log->changedFields, + 'user_id' => $log->userId, + 'username' => $log->username, + 'ip_address' => $log->ipAddress, + 'user_agent' => $log->userAgent, + 'transaction_hash' => $log->transactionHash, + 'signature' => $log->signature, + 'context' => [...$log->context, ...$context], + 'created_at' => $log->createdAt->format(DateTimeInterface::ATOM), ]; $jsonPayload = json_encode($payload, JSON_THROW_ON_ERROR); @@ -60,11 +65,21 @@ public function send(AuditLogInterface $log, array $context = []): void $headers['X-Signature'] = $this->integrityService->signPayload($jsonPayload); } - $this->client->request('POST', $this->endpoint, [ + $response = $this->client->request('POST', $this->endpoint, [ 'headers' => $headers, 'body' => $jsonPayload, 'timeout' => $this->timeout, ]); + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + $this->logger?->error(sprintf( + 'HTTP audit transport failed for %s#%s with status code %d: %s', + $log->entityClass, + $entityId, + $response->getStatusCode(), + $response->getContent(false) + )); + } } #[Override] diff --git a/src/Transport/QueueAuditTransport.php b/src/Transport/QueueAuditTransport.php index e4f0175..e04ff45 100644 --- a/src/Transport/QueueAuditTransport.php +++ b/src/Transport/QueueAuditTransport.php @@ -5,18 +5,16 @@ namespace Rcsofttech\AuditTrailBundle\Transport; use Override; -use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; 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\Message\Stamp\ApiKeyStamp; use Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp; -use Rcsofttech\AuditTrailBundle\Service\EntityIdResolver; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -use Throwable; use const JSON_THROW_ON_ERROR; @@ -24,9 +22,9 @@ final class QueueAuditTransport implements AuditTransportInterface { public function __construct( private readonly MessageBusInterface $bus, - private readonly LoggerInterface $logger, private readonly EventDispatcherInterface $eventDispatcher, private readonly AuditIntegrityServiceInterface $integrityService, + private readonly EntityIdResolverInterface $idResolver, private readonly ?string $apiKey = null, ) { } @@ -35,41 +33,32 @@ public function __construct( * @param array $context */ #[Override] - public function send(AuditLogInterface $log, array $context = []): void + public function send(AuditLog $log, array $context = []): void { - $entityId = EntityIdResolver::resolve($log, $context) ?? $log->getEntityId(); + $entityId = $this->idResolver->resolve($log, $context) ?? $log->entityId; $message = AuditLogMessage::createFromAuditLog($log, $entityId); $event = new AuditMessageStampEvent($message); $this->eventDispatcher->dispatch($event); - if ($event->isCancelled()) { + if ($event->isPropagationStopped()) { return; } - try { - $stamps = $event->getStamps(); + $stamps = $event->getStamps(); - if ($this->apiKey !== null) { - $stamps[] = new ApiKeyStamp($this->apiKey); - } - - if ($this->integrityService->isEnabled()) { - // JSON representation of the message to ensure consistency for signing - $payload = json_encode($message, JSON_THROW_ON_ERROR); - $signature = $this->integrityService->signPayload($payload); - $stamps[] = new SignatureStamp($signature); - } + if ($this->apiKey !== null) { + $stamps[] = new ApiKeyStamp($this->apiKey); + } - $this->bus->dispatch($message, $stamps); - } catch (Throwable $e) { - $this->logger->error('Failed to dispatch audit message to queue', [ - 'entity_class' => $log->getEntityClass(), - 'entity_id' => $entityId, - 'error' => $e->getMessage(), - ]); + if ($this->integrityService->isEnabled()) { + $payload = json_encode($message, JSON_THROW_ON_ERROR); + $signature = $this->integrityService->signPayload($payload); + $stamps[] = new SignatureStamp($signature); } + + $this->bus->dispatch($message, $stamps); } #[Override] diff --git a/tests/Functional/AuditAccessTest.php b/tests/Functional/AuditAccessTest.php index ee90112..6d51cf2 100644 --- a/tests/Functional/AuditAccessTest.php +++ b/tests/Functional/AuditAccessTest.php @@ -43,9 +43,9 @@ public function testAccessAuditLogIsCreatedOnRead(): void $logs = $em->getRepository(AuditLog::class)->findBy(['action' => AuditLogInterface::ACTION_ACCESS]); self::assertCount(1, $logs, 'Should have created exactly one access log'); - self::assertSame(AuditLogInterface::ACTION_ACCESS, $logs[0]->getAction()); - self::assertSame(Post::class, $logs[0]->getEntityClass()); - self::assertSame((string) $postId, $logs[0]->getEntityId()); - self::assertSame('Opening secret file', $logs[0]->getContext()['message'] ?? null); + self::assertSame(AuditLogInterface::ACTION_ACCESS, $logs[0]->action); + self::assertSame(Post::class, $logs[0]->entityClass); + self::assertSame((string) $postId, $logs[0]->entityId); + self::assertSame('Opening secret file', $logs[0]->context['message'] ?? null); } } diff --git a/tests/Functional/Command/AuditCommandTest.php b/tests/Functional/Command/AuditCommandTest.php index 3e25996..ed34d46 100644 --- a/tests/Functional/Command/AuditCommandTest.php +++ b/tests/Functional/Command/AuditCommandTest.php @@ -10,6 +10,7 @@ use Rcsofttech\AuditTrailBundle\Tests\Functional\Entity\TestEntity; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\HttpKernel\KernelInterface; use function assert; use function is_string; @@ -26,7 +27,7 @@ public function testAuditListCommand(): void $em->persist($entity); $em->flush(); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:list'); $commandTester = new CommandTester($command); @@ -54,12 +55,12 @@ public function testAuditDiffCommand(): void $auditLog = $em->getRepository(AuditLog::class)->findOneBy(['action' => 'update']); self::assertNotNull($auditLog); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:diff'); $commandTester = new CommandTester($command); - $commandTester->execute(['identifier' => (string) $auditLog->getId()]); + $commandTester->execute(['identifier' => (string) $auditLog->id]); $output = $commandTester->getDisplay(); self::assertSame(0, $commandTester->getStatusCode()); @@ -82,15 +83,16 @@ public function testAuditRevertCommand(): void $auditLog = $em->getRepository(AuditLog::class)->findOneBy(['action' => 'update']); self::assertNotNull($auditLog); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:revert'); $commandTester = new CommandTester($command); // Test Dry Run - $commandTester->execute(['auditId' => (string) $auditLog->getId(), '--dry-run' => true]); + $commandTester->execute(['auditId' => (string) $auditLog->id, '--dry-run' => true]); $output = $commandTester->getDisplay(); - self::assertStringContainsString('Running in DRY-RUN mode', $output); + self::assertStringContainsString('DRY-RUN', $output); + self::assertStringContainsString('mode', $output); $em->clear(); $reloaded = $em->find(TestEntity::class, $entity->getId()); @@ -99,7 +101,7 @@ public function testAuditRevertCommand(): void // Test Actual Revert $commandTester->setInputs(['yes']); - $commandTester->execute(['auditId' => (string) $auditLog->getId()]); + $commandTester->execute(['auditId' => (string) $auditLog->id]); self::assertSame(0, $commandTester->getStatusCode()); $em->clear(); @@ -117,7 +119,7 @@ public function testAuditExportCommand(): void $em->persist($entity); $em->flush(); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:export'); $commandTester = new CommandTester($command); @@ -147,7 +149,7 @@ public function testAuditPurgeCommand(): void self::assertCount(1, $em->getRepository(AuditLog::class)->findAll()); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:purge'); $commandTester = new CommandTester($command); diff --git a/tests/Functional/Command/AuditUserAttributionTest.php b/tests/Functional/Command/AuditUserAttributionTest.php index 81df675..fdeb4af 100644 --- a/tests/Functional/Command/AuditUserAttributionTest.php +++ b/tests/Functional/Command/AuditUserAttributionTest.php @@ -9,6 +9,7 @@ use Rcsofttech\AuditTrailBundle\Tests\Functional\Entity\TestEntity; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\HttpKernel\KernelInterface; use function assert; @@ -32,13 +33,13 @@ public function testAuditRevertWithUserOption(): void $auditLog = $em->getRepository(AuditLog::class)->findOneBy(['action' => 'update']); self::assertNotNull($auditLog); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:revert'); $commandTester = new CommandTester($command); $commandTester->execute([ - 'auditId' => (string) $auditLog->getId(), + 'auditId' => (string) $auditLog->id, '--user' => 'admin_tester', ]); @@ -46,8 +47,8 @@ public function testAuditRevertWithUserOption(): void $revertLog = $em->getRepository(AuditLog::class)->findOneBy(['action' => 'revert']); self::assertNotNull($revertLog); - self::assertEquals('admin_tester', $revertLog->getUsername()); - self::assertEquals('admin_tester', $revertLog->getUserId()); + self::assertEquals('admin_tester', $revertLog->username); + self::assertEquals('admin_tester', $revertLog->userId); } public function testAuditRevertDefaultCliUser(): void @@ -65,13 +66,13 @@ public function testAuditRevertDefaultCliUser(): void $auditLog = $em->getRepository(AuditLog::class)->findOneBy(['action' => 'update']); self::assertNotNull($auditLog); - assert(self::$kernel instanceof \Symfony\Component\HttpKernel\KernelInterface); + assert(self::$kernel instanceof KernelInterface); $application = new Application(self::$kernel); $command = $application->find('audit:revert'); $commandTester = new CommandTester($command); $commandTester->execute([ - 'auditId' => (string) $auditLog->getId(), + 'auditId' => (string) $auditLog->id, ]); self::assertSame(0, $commandTester->getStatusCode()); @@ -80,10 +81,10 @@ public function testAuditRevertDefaultCliUser(): void self::assertNotNull($revertLog); // Should have cli: prefix and machine defaults - self::assertStringStartsWith('cli:', (string) $revertLog->getUsername()); - self::assertStringStartsWith('cli:', (string) $revertLog->getUserId()); - self::assertEquals(gethostbyname((string) gethostname()), $revertLog->getIpAddress()); - self::assertStringContainsString('cli-console', (string) $revertLog->getUserAgent()); - self::assertStringContainsString((string) gethostname(), (string) $revertLog->getUserAgent()); + self::assertStringStartsWith('cli:', (string) $revertLog->username); + self::assertStringStartsWith('cli:', (string) $revertLog->userId); + self::assertEquals(gethostbyname((string) gethostname()), $revertLog->ipAddress); + self::assertStringContainsString('cli-console', (string) $revertLog->userAgent); + self::assertStringContainsString((string) gethostname(), (string) $revertLog->userAgent); } } diff --git a/tests/Functional/Entity/TestEntityWithUuid.php b/tests/Functional/Entity/TestEntityWithUuid.php new file mode 100644 index 0000000..c1b4caf --- /dev/null +++ b/tests/Functional/Entity/TestEntityWithUuid.php @@ -0,0 +1,45 @@ +name = $name; + } + + public function getId(): ?Uuid + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/tests/Functional/Event/AuditLogCreatedEventTest.php b/tests/Functional/Event/AuditLogCreatedEventTest.php new file mode 100644 index 0000000..43a1099 --- /dev/null +++ b/tests/Functional/Event/AuditLogCreatedEventTest.php @@ -0,0 +1,80 @@ + [ + 'integrity' => [ + 'enabled' => true, + 'secret' => 'test-secret', + ], + ], + ]; + + $this->bootTestKernel($options); + $container = self::getContainer(); + + $integrityService = $container->get(AuditIntegrityService::class); + self::assertInstanceOf(AuditIntegrityServiceInterface::class, $integrityService); + + $transport = $container->get('rcsofttech_audit_trail.transport.doctrine'); + self::assertInstanceOf(AuditTransportInterface::class, $transport); + + $eventDispatcher = new EventDispatcher(); + + // Add a listener that modifies the log (a signed field) + $eventDispatcher->addListener(AuditLogCreatedEvent::class, static function (AuditLogCreatedEvent $event) { + $log = $event->auditLog; + $log->entityId = 'MODIFIED'; + }); + + $dispatcher = new AuditDispatcher( + $transport, + $eventDispatcher, + $integrityService, + null, + true, // failOnTransportError + true // fallbackToDatabase + ); + + $log = new AuditLog('App\Entity\User', '1', 'create'); + + $em = $this->getEntityManager(); + + // Dispatch + $dispatcher->dispatch($log, $em, 'post_flush'); + + // Verify modification + self::assertSame('MODIFIED', $log->entityId, 'Log should be modified in the event listener'); + + // Verify signature integrity + self::assertTrue($integrityService->verifySignature($log), 'Signature should be valid even after modification in event listener'); + } + + public function testCannotModifyAfterSealInEvent(): void + { + $log = new AuditLog('App\Entity\User', '1', 'create'); + $log->seal(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot modify a sealed audit log.'); + + $log->context = ['foo' => 'bar']; + } +} diff --git a/tests/Functional/InheritanceTest.php b/tests/Functional/InheritanceTest.php index 4ba0127..a66a878 100644 --- a/tests/Functional/InheritanceTest.php +++ b/tests/Functional/InheritanceTest.php @@ -61,8 +61,8 @@ public function testSTIInheritanceAudit(): void $logs = $auditRepo->findAll(); self::assertCount(1, $logs, 'Should have 1 audit log for Car (STI)'); - self::assertSame(Car::class, $logs[0]->getEntityClass()); - $newValues = $logs[0]->getNewValues(); + self::assertSame(Car::class, $logs[0]->entityClass); + $newValues = $logs[0]->newValues; self::assertNotNull($newValues); self::assertSame('Tesla Model S', $newValues['model']); self::assertSame(4, $newValues['doors']); @@ -85,8 +85,8 @@ public function testJTIInheritanceAudit(): void $logs = $auditRepo->findAll(); self::assertCount(1, $logs, 'Should have 1 audit log for Dog (JTI)'); - self::assertSame(Dog::class, $logs[0]->getEntityClass()); - $newValues = $logs[0]->getNewValues(); + self::assertSame(Dog::class, $logs[0]->entityClass); + $newValues = $logs[0]->newValues; self::assertNotNull($newValues); self::assertSame('Buddy', $newValues['name']); self::assertSame('Golden Retriever', $newValues['breed']); diff --git a/tests/Functional/IntegrityTest.php b/tests/Functional/IntegrityTest.php index 49ab890..f962af3 100644 --- a/tests/Functional/IntegrityTest.php +++ b/tests/Functional/IntegrityTest.php @@ -41,8 +41,8 @@ public function testAuditLogsAreSignedWhenIntegrityIsEnabled(): void ]); self::assertNotNull($auditLog); - self::assertNotNull($auditLog->getSignature()); - self::assertSame(64, strlen($auditLog->getSignature())); + self::assertNotNull($auditLog->signature); + self::assertSame(64, strlen($auditLog->signature)); } public function testVerifyIntegrityCommandDetectsTampering(): void @@ -85,18 +85,21 @@ public function testVerifyIntegrityCommandDetectsTampering(): void 'entityClass' => TestEntity::class, ]); self::assertNotNull($auditLog); + self::assertNotNull($auditLog->id); - $em->getConnection()->executeStatement( + $affected = $em->getConnection()->executeStatement( 'UPDATE audit_log SET new_values = ? WHERE id = ?', - [json_encode(['name' => 'TAMPERED'], JSON_THROW_ON_ERROR), $auditLog->getId()] + [json_encode(['name' => 'TAMPERED'], JSON_THROW_ON_ERROR), $auditLog->id->toBinary()] ); + self::assertEquals(1, $affected, 'Tampering UPDATE should affect exactly 1 row'); $em->clear(); // Verify tampering is detected $commandTester->execute([]); self::assertSame(1, $commandTester->getStatusCode()); $output = $commandTester->getDisplay(); - self::assertTrue(str_contains($output, 'tampered audit logs'), 'Output should contain error message'); - self::assertTrue(str_contains($output, (string) $auditLog->getId()), 'Output should contain tampered log ID'); + self::assertStringContainsString('tampered', $output); + self::assertStringContainsString('audit logs', $output); + self::assertStringContainsString((string) $auditLog->id, $output); } } diff --git a/tests/Functional/MultiEntityTransactionTest.php b/tests/Functional/MultiEntityTransactionTest.php index a82ab17..61f0b40 100644 --- a/tests/Functional/MultiEntityTransactionTest.php +++ b/tests/Functional/MultiEntityTransactionTest.php @@ -24,8 +24,8 @@ public function testMultipleEntitiesInSingleTransactionHaveSameHash(): void $auditLogs = $em->getRepository(AuditLog::class)->findAll(); self::assertCount(2, $auditLogs); - self::assertSame($auditLogs[0]->getTransactionHash(), $auditLogs[1]->getTransactionHash()); - self::assertNotEmpty($auditLogs[0]->getTransactionHash()); + self::assertSame($auditLogs[0]->transactionHash, $auditLogs[1]->transactionHash); + self::assertNotEmpty($auditLogs[0]->transactionHash); } public function testMultipleFlushesHaveDifferentHashes(): void @@ -44,6 +44,6 @@ public function testMultipleFlushesHaveDifferentHashes(): void $auditLogs = $em->getRepository(AuditLog::class)->findAll(); self::assertCount(2, $auditLogs); - self::assertNotSame($auditLogs[0]->getTransactionHash(), $auditLogs[1]->getTransactionHash()); + self::assertNotSame($auditLogs[0]->transactionHash, $auditLogs[1]->transactionHash); } } diff --git a/tests/Functional/SmartFlushDetectionProofTest.php b/tests/Functional/SmartFlushDetectionProofTest.php new file mode 100644 index 0000000..16c052a --- /dev/null +++ b/tests/Functional/SmartFlushDetectionProofTest.php @@ -0,0 +1,240 @@ + */ + private array $flushLog = []; + + /** + * Attach a Doctrine event listener that counts flush cycles. + * Each $em->flush() triggers one onFlush + one postFlush event pair. + */ + private function attachFlushCounter(EntityManagerInterface $em): void + { + $this->flushCount = 0; + $this->flushLog = []; + + $listener = new class($this) { + public function __construct(private SmartFlushDetectionProofTest $test) + { + } + + public function onFlush(OnFlushEventArgs $args): void + { + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + + // Count how many AuditLog entities are being persisted in this flush + $auditLogCount = 0; + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof AuditLog) { + ++$auditLogCount; + } + } + + $this->test->recordFlush('onFlush', $auditLogCount, $uow->getScheduledEntityInsertions() !== []); + } + + public function postFlush(PostFlushEventArgs $args): void + { + $this->test->recordFlush('postFlush', 0, false); + } + }; + + // Register with very low priority so it runs AFTER the AuditSubscriber + $em->getEventManager()->addEventListener([Events::onFlush], $listener); + $em->getEventManager()->addEventListener([Events::postFlush], $listener); + } + + /** + * @internal Called by the counting listener + */ + public function recordFlush(string $phase, int $auditLogCount, bool $hasPendingInserts): void + { + if ($phase === 'onFlush') { + ++$this->flushCount; + } + + $this->flushLog[] = [ + 'phase' => $phase, + 'auditLogCount' => $auditLogCount, + 'hasPendingInserts' => $hasPendingInserts, + ]; + } + + /** + * PROOF: Auto-increment entity INSERT requires 2 flushes. + * + * Flow: + * 1. $em->flush() — Flush #1: TestEntity INSERT + AuditLog scheduled (PENDING_ID) + * 2. postFlush: Resolve real entity ID → persist AuditLog → Flush #2 + */ + public function testAutoIncrementEntityRequiresDoubleFlush(): void + { + $this->bootTestKernel(); + $em = $this->getEntityManager(); + $this->attachFlushCounter($em); + + // Create an auto-increment entity (ID = null until INSERT) + $entity = new TestEntity('auto-increment-proof'); + $em->persist($entity); + + // This single application flush call... + $em->flush(); + + // ...actually triggers TWO flush cycles + self::assertGreaterThanOrEqual(2, $this->flushCount, sprintf( + "PROOF FAILED: Auto-increment entity should trigger >= 2 flush cycles.\n". + "Expected: >= 2 (Flush #1 for entity, Flush #2 for AuditLog with resolved ID)\n". + "Actual: %d flush cycle(s)\n\nFlush log:\n%s", + $this->flushCount, + $this->formatFlushLog() + )); + + // Verify audit was still created correctly + $auditLogs = $em->getRepository(AuditLog::class)->findBy([ + 'entityClass' => TestEntity::class, + 'action' => 'create', + ]); + + self::assertNotEmpty($auditLogs, 'AuditLog should exist for auto-increment entity'); + + $audit = $auditLogs[array_key_last($auditLogs)]; + self::assertNotEquals('pending', $audit->entityId, 'Entity ID should be resolved, not PENDING'); + self::assertIsNumeric($audit->entityId, 'Auto-increment entity ID should be numeric'); + } + + /** + * PROOF: UUID entity INSERT requires only 1 flush. + * + * Flow: + * 1. $em->flush() — Flush #1: TestEntityWithUuid INSERT + AuditLog INSERT + * (both in same UoW because UUID is known client-side) + * 2. No Flush #2 needed! + */ + public function testUuidEntityRequiresSingleFlush(): void + { + $this->bootTestKernel(); + $em = $this->getEntityManager(); + $this->attachFlushCounter($em); + + // Create a UUID entity (ID generated client-side, BEFORE the INSERT) + $entity = new TestEntityWithUuid('uuid-proof'); + $em->persist($entity); + + // This single application flush call... + $em->flush(); + + // ...triggers only ONE flush cycle (smart detection recognized UUID) + self::assertSame(1, $this->flushCount, sprintf( + "PROOF FAILED: UUID entity should trigger exactly 1 flush cycle.\n". + "Expected: 1 (AuditLog included in same UoW as entity)\n". + "Actual: %d flush cycle(s)\n\nFlush log:\n%s", + $this->flushCount, + $this->formatFlushLog() + )); + + // Verify audit was still created correctly + $auditLogs = $em->getRepository(AuditLog::class)->findBy([ + 'entityClass' => TestEntityWithUuid::class, + 'action' => 'create', + ]); + + self::assertNotEmpty($auditLogs, 'AuditLog should exist for UUID entity'); + + $audit = $auditLogs[array_key_last($auditLogs)]; + self::assertNotEquals('pending', $audit->entityId, 'Entity ID should be resolved, not PENDING'); + self::assertTrue( + \Symfony\Component\Uid\Uuid::isValid($audit->entityId), + sprintf('Entity ID should be a valid UUID, got: %s', $audit->entityId) + ); + } + + /** + * PROOF: Both entity types produce identical audit data — only flush count differs. + */ + public function testBothStrategiesProduceIdenticalAuditData(): void + { + $this->bootTestKernel(); + $em = $this->getEntityManager(); + + // Create auto-increment entity + $autoEntity = new TestEntity('comparison-test'); + $em->persist($autoEntity); + $em->flush(); + + // Create UUID entity + $uuidEntity = new TestEntityWithUuid('comparison-test'); + $em->persist($uuidEntity); + $em->flush(); + + // Fetch audits for both + $autoAudit = $em->getRepository(AuditLog::class)->findOneBy([ + 'entityClass' => TestEntity::class, + 'action' => 'create', + ]); + $uuidAudit = $em->getRepository(AuditLog::class)->findOneBy([ + 'entityClass' => TestEntityWithUuid::class, + 'action' => 'create', + ]); + + self::assertNotNull($autoAudit, 'Auto-increment audit should exist'); + self::assertNotNull($uuidAudit, 'UUID audit should exist'); + + // Both should have the same structure + self::assertSame('create', $autoAudit->action); + self::assertSame('create', $uuidAudit->action); + + // Both should have resolved (non-pending) entity IDs + self::assertNotEquals('pending', $autoAudit->entityId, 'Auto-increment ID should be resolved'); + self::assertNotEquals('pending', $uuidAudit->entityId, 'UUID ID should be resolved'); + + // Both should have newValues containing the name + self::assertIsArray($autoAudit->newValues); + self::assertIsArray($uuidAudit->newValues); + self::assertArrayHasKey('name', $autoAudit->newValues); + self::assertArrayHasKey('name', $uuidAudit->newValues); + self::assertSame('comparison-test', $autoAudit->newValues['name']); + self::assertSame('comparison-test', $uuidAudit->newValues['name']); + } + + private function formatFlushLog(): string + { + $lines = []; + foreach ($this->flushLog as $i => $entry) { + $lines[] = sprintf( + ' [%d] %s — AuditLogs in UoW: %d, Has pending inserts: %s', + $i, + $entry['phase'], + $entry['auditLogCount'], + $entry['hasPendingInserts'] ? 'yes' : 'no' + ); + } + + return implode("\n", $lines); + } +} diff --git a/tests/Functional/TestKernel.php b/tests/Functional/TestKernel.php index 78958c9..ec86d8d 100644 --- a/tests/Functional/TestKernel.php +++ b/tests/Functional/TestKernel.php @@ -6,6 +6,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Psr\Log\NullLogger; use Rcsofttech\AuditTrailBundle\AuditTrailBundle; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -13,6 +14,7 @@ use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel; @@ -35,7 +37,7 @@ public function __construct(string $environment, bool $debug) public function build(ContainerBuilder $container): void { - $container->addCompilerPass($this, \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_OPTIMIZE); + $container->addCompilerPass($this, PassConfig::TYPE_OPTIMIZE); } public function process(ContainerBuilder $container): void @@ -46,6 +48,9 @@ public function process(ContainerBuilder $container): void $container->removeAlias(AuditTransportInterface::class); } $container->setAlias(AuditTransportInterface::class, ThrowingTransport::class); + + // Silence logger during expected failures + $container->register('logger', NullLogger::class); } } diff --git a/tests/Functional/UserProviderIntegrationTest.php b/tests/Functional/UserProviderIntegrationTest.php index addca64..910c339 100644 --- a/tests/Functional/UserProviderIntegrationTest.php +++ b/tests/Functional/UserProviderIntegrationTest.php @@ -53,10 +53,10 @@ public function testAuditLogCapturesCurrentUser(): void ]); self::assertNotNull($auditLog); - self::assertEquals($user->getId(), $auditLog->getUserId()); - self::assertSame('test_user', $auditLog->getUsername()); - self::assertSame('127.0.0.1', $auditLog->getIpAddress()); - self::assertSame('TestAgent', $auditLog->getUserAgent()); + self::assertEquals($user->getId(), $auditLog->userId); + self::assertSame('test_user', $auditLog->username); + self::assertSame('127.0.0.1', $auditLog->ipAddress); + self::assertSame('TestAgent', $auditLog->userAgent); } public function testAuditLogWithNoUser(): void @@ -76,7 +76,7 @@ public function testAuditLogWithNoUser(): void ]); self::assertNotNull($auditLog); - self::assertStringStartsWith('cli:', (string) $auditLog->getUserId()); - self::assertStringStartsWith('cli:', (string) $auditLog->getUsername()); + self::assertStringStartsWith('cli:', (string) $auditLog->userId); + self::assertStringStartsWith('cli:', (string) $auditLog->username); } } diff --git a/tests/Security/SecurityAuditTest.php b/tests/Security/SecurityAuditTest.php new file mode 100644 index 0000000..ef741d7 --- /dev/null +++ b/tests/Security/SecurityAuditTest.php @@ -0,0 +1,287 @@ +standaloneIntegrityService = new AuditIntegrityService($this->testSecret, true); + } + + // --- INTERNAL MECHANICS & PROPERTY HOOKS --- + + /** + * The Reflection Penetration + * Can we use Reflection to bypass the 'set' hook and modify a sealed log? + */ + public function testReflectionHookBypassAttempt(): void + { + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_CREATE); + $log->entityId = 'ORIGINAL'; + $log->seal(); + + // Standard assignment fails + try { + $log->entityId = 'TAMPERED'; + self::fail('Standard assignment should have failed on sealed log'); + } catch (LogicException $e) { + self::assertEquals('Cannot modify a sealed audit log.', $e->getMessage()); + } + + // Reflection attempt to set backing value + // Note: In PHP 8.4, ReflectionProperty::setValue() triggers hooks. + $rp = new ReflectionProperty(AuditLog::class, 'entityId'); + + try { + $rp->setValue($log, 'REFLECTED'); + self::assertEquals('REFLECTED', $log->entityId); + } catch (LogicException $e) { + self::assertEquals('Cannot modify a sealed audit log.', $e->getMessage()); + } + } + + /** + * Serialization Tampering + * Can we modify the state via serialized string manipulation? + */ + public function testSerializationTamperAttempt(): void + { + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_CREATE); + $log->entityId = 'SAFE'; + $log->signature = $this->standaloneIntegrityService->generateSignature($log); + $log->seal(); + + $serialized = serialize($log); + $tampered = str_replace('SAFE', 'EVIL', $serialized); + + /** @var AuditLog $unserialized */ + $unserialized = unserialize($tampered); + self::assertEquals('EVIL', $unserialized->entityId); + + // Ultimate defense: Signature verification MUST fail + self::assertFalse($this->standaloneIntegrityService->verifySignature($unserialized), 'Persistence tampering must be detected by signature'); + } + + /** + * The Asymmetric Visibility Breach + * Can we modify a public private(set) property from outside? + */ + public function testAsymmetricVisibilityBreach(): void + { + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_CREATE); + + $rp = new ReflectionProperty(AuditLog::class, 'action'); + self::assertTrue($rp->isPublic(), 'Should be public for reading'); + + try { + /** @phpstan-ignore-next-line */ + $log->action = AuditLogInterface::ACTION_DELETE; + self::fail('Should not be able to set private(set) property from outside'); + } catch (Error $e) { + self::assertStringContainsString('Cannot modify', $e->getMessage()); + } + } + + /** + * Indirect Array Modification + * Does $log->context['key'] = 'val' bypass the 'set' hook? + */ + public function testIndirectArrayModification(): void + { + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_CREATE); + $log->context = ['initial' => true]; + $log->seal(); + + try { + $log->context['tamper'] = true; + self::fail('Indirect modification should be blocked'); + } catch (Error $e) { + self::assertStringContainsString('not allowed', $e->getMessage()); + } + + self::assertArrayNotHasKey('tamper', $log->context); + } + + // --- EXTERNAL ATTACK VECTORS & SERVICE SECURITY --- + + /** + * The "Sneaky Property" Bypass. + */ + public function testDataMaskingBypass(): void + { + $masker = new DataMasker(); + + $payload = [ + 'password' => 'secret123', + 'PASSWORD' => 'secret456', + 'user_token' => 'abc-def', + 'api_key' => '12345', + 'nested' => ['cookie' => 'session_id'], + ]; + + $redacted = $masker->redact($payload); + + self::assertEquals('********', $redacted['password']); + self::assertEquals('********', $redacted['PASSWORD']); + self::assertEquals('********', $redacted['user_token']); + self::assertEquals('********', $redacted['api_key']); + self::assertEquals('********', $redacted['nested']['cookie']); + } + + /** + * Replay / Timestamp Manipulation. + */ + public function testReplayAttackPrevention(): void + { + $log1 = new AuditLog('App\Entity\User', '1', 'create'); + $rp = new ReflectionProperty(AuditLog::class, 'createdAt'); + $rp->setValue($log1, new DateTimeImmutable('2020-01-01 10:00:00')); + + $sig1 = $this->standaloneIntegrityService->generateSignature($log1); + $log1->signature = $sig1; + + $log2 = new AuditLog('App\Entity\User', '1', 'create'); + $rp->setValue($log2, new DateTimeImmutable('2024-01-01 10:00:00')); + $log2->signature = $sig1; + + self::assertFalse($this->standaloneIntegrityService->verifySignature($log2), 'Signature must be tied to the timestamp'); + } + + /** + * SQL Injection in Filters. + */ + public function testRepositoryFilterInjection(): void + { + $this->bootTestKernel(); + $repository = $this->getEntityManager()->getRepository(AuditLog::class); + + $filters = ['entityId' => "1' OR '1'='1", 'action' => "create'--"]; + + $log = new AuditLog('Test', '1', 'create'); + $this->getEntityManager()->persist($log); + $this->getEntityManager()->flush(); + + $results = $repository->findWithFilters($filters); + self::assertCount(0, $results, 'SQL injection in filters should not return results'); + } + + /** + * Expression Language Injection. + */ + public function testExpressionLanguageVoterAttack(): void + { + $this->bootTestKernel(); + /** @var ExpressionLanguageVoter $voter */ + $voter = self::getContainer()->get(ExpressionLanguageVoter::class); + + $reflection = new ReflectionMethod($voter, 'isExpressionSafe'); + + self::assertFalse($reflection->invoke($voter, "system('whoami')"), 'system() must be blocked'); + self::assertFalse($reflection->invoke($voter, "exec('ls')"), 'exec() must be blocked'); + self::assertFalse($reflection->invoke($voter, "constant('PHP_VERSION')"), 'constant() must be blocked'); + } + + /** + * Mass Ingestion (DoS). + */ + public function testMassIngestionDoS(): void + { + $this->bootTestKernel(); + $em = $this->getEntityManager(); + + $entity = new TestEntity('DoS Test'); + $em->persist($entity); + $em->flush(); + + for ($i = 0; $i < 100; ++$i) { + $entity->setName("DoS Test $i"); + } + + $startMemory = memory_get_usage(); + $em->flush(); + $endMemory = memory_get_usage(); + + $diffKb = ($endMemory - $startMemory) / 1024; + self::assertLessThan(50000, $diffKb, "Audit processing shouldn't leak excessive memory"); + } + + /** + * Circular Reference (DoS). + */ + public function testCircularReferenceDoS(): void + { + $this->bootTestKernel(); + /** @var ValueSerializerInterface $serializer */ + $serializer = self::getContainer()->get(ValueSerializerInterface::class); + + $a = ['name' => 'root']; + $b = ['name' => 'child']; + $a['child'] = &$b; + $b['parent'] = &$a; + + $result = $serializer->serialize($a); + self::assertStringContainsString('max depth reached', (string) json_encode($result)); + } + + /** + * Log Injection (XSS / Terminal Injection). + */ + public function testLogInjectionSanitization(): void + { + $this->bootTestKernel(); + $em = $this->getEntityManager(); + + $xss = ""; + $terminal = "\e[31mRED TEXT\e[0m"; + + $log = new AuditLog('Test', '1', 'create'); + + $rpUser = new ReflectionProperty(AuditLog::class, 'username'); + $rpUser->setValue($log, $xss); + + $rpUA = new ReflectionProperty(AuditLog::class, 'userAgent'); + $rpUA->setValue($log, $terminal); + + $em->persist($log); + $em->flush(); + + /** @var AuditLog $stored */ + $stored = $em->getRepository(AuditLog::class)->find($log->id); + + /** @var AuditRendererInterface $renderer */ + $renderer = self::getContainer()->get(AuditRendererInterface::class); + $sanitized = $renderer->formatValue($terminal); + self::assertStringNotContainsString("\e[", $sanitized, 'Terminal escapes should be stripped'); + + $details = $renderer->formatChangedDetails($stored); + self::assertNotSame('', $details); + } +} diff --git a/tests/Unit/AbstractAuditTestCase.php b/tests/Unit/AbstractAuditTestCase.php index cc03dd8..306b582 100644 --- a/tests/Unit/AbstractAuditTestCase.php +++ b/tests/Unit/AbstractAuditTestCase.php @@ -9,16 +9,21 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; -use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\ChangeProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\ContextResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Service\AuditDispatcher; use Rcsofttech\AuditTrailBundle\Service\AuditService; -use Rcsofttech\AuditTrailBundle\Service\ChangeProcessor; use Rcsofttech\AuditTrailBundle\Service\EntityDataExtractor; use Rcsofttech\AuditTrailBundle\Service\EntityProcessor; use Rcsofttech\AuditTrailBundle\Service\MetadataCache; -use Rcsofttech\AuditTrailBundle\Service\ScheduledAuditManager; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; use Rcsofttech\AuditTrailBundle\Service\ValueSerializer; use ReflectionClass; @@ -32,44 +37,55 @@ protected function createAuditService( Stub&TransactionIdGenerator $transactionIdGenerator, MetadataCache $metadataCache = new MetadataCache(), ValueSerializer $serializer = new ValueSerializer(null), - ): AuditService { + ): AuditServiceInterface { $extractor = new EntityDataExtractor($em, $serializer, $metadataCache); + $metadataManager = self::createStub(AuditMetadataManagerInterface::class); + $contextResolver = self::createStub(ContextResolverInterface::class); + $contextResolver->method('resolve')->willReturn([ + 'userId' => null, + 'username' => null, + 'ipAddress' => null, + 'userAgent' => null, + 'context' => [], + ]); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('1'); return new AuditService( $em, - self::createStub(UserResolverInterface::class), new MockClock(), $transactionIdGenerator, $extractor, - $metadataCache, - [], - [] + $metadataManager, + $contextResolver, + $idResolver ); } protected function createAuditDispatcher( MockObject&AuditTransportInterface $transport, (Stub&AuditIntegrityServiceInterface)|null $integrityService = null, - ): AuditDispatcher { + ): AuditDispatcherInterface { return new AuditDispatcher( $transport, - $integrityService ?? self::createStub(AuditIntegrityServiceInterface::class), - null + null, // eventDispatcher + $integrityService ?? self::createStub(AuditIntegrityServiceInterface::class) ); } protected function createEntityProcessor( - AuditService $auditService, - ChangeProcessor $changeProcessor, - AuditDispatcher $dispatcher, - ScheduledAuditManager $auditManager, + AuditServiceInterface $auditService, + ChangeProcessorInterface $changeProcessor, + AuditDispatcherInterface $dispatcher, + ScheduledAuditManagerInterface $auditManager, bool $deferTransportUntilCommit = false, - ): EntityProcessor { + ): EntityProcessorInterface { return new EntityProcessor( $auditService, $changeProcessor, $dispatcher, $auditManager, + self::createStub(EntityIdResolverInterface::class), $deferTransportUntilCommit ); } diff --git a/tests/Unit/Command/AuditDiffCommandTest.php b/tests/Unit/Command/AuditDiffCommandTest.php index 3cdd1c9..a11c400 100644 --- a/tests/Unit/Command/AuditDiffCommandTest.php +++ b/tests/Unit/Command/AuditDiffCommandTest.php @@ -17,6 +17,7 @@ use ReflectionClass; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Uuid; #[AllowMockObjectsWithoutExpectations] #[CoversClass(AuditDiffCommand::class)] @@ -37,11 +38,11 @@ protected function setUp(): void $this->commandTester = new CommandTester($command); } - private function setLogId(AuditLog $log, int $id): void + private function setLogId(AuditLog $log, string $id): void { $reflection = new ReflectionClass($log); $property = $reflection->getProperty('id'); - $property->setValue($log, $id); + $property->setValue($log, Uuid::fromString($id)); } private function normalizeOutput(): string @@ -59,31 +60,32 @@ private function normalizeOutput(): string public function testExecuteWithId(): void { - $log = new AuditLog(); - $this->setLogId($log, 1); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setUsername('testuser'); - $log->setCreatedAt(new DateTimeImmutable('2023-01-01 10:00:00')); - $log->setOldValues(['title' => 'Old Title']); - $log->setNewValues(['title' => 'New Title']); + $log = new AuditLog( + 'App\Entity\Post', + '123', + AuditLogInterface::ACTION_UPDATE, + new DateTimeImmutable('2023-01-01 10:00:00'), + oldValues: ['title' => 'Old Title'], + newValues: ['title' => 'New Title'], + username: 'testuser' + ); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->repository->expects($this->once()) ->method('find') - ->with(1) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn($log); $this->diffGenerator->expects($this->once()) ->method('generate') ->willReturn(['title' => ['old' => 'Old Title', 'new' => 'New Title']]); - $this->commandTester->execute(['identifier' => '1']); + $this->commandTester->execute(['identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $this->commandTester->assertCommandIsSuccessful(); $output = $this->normalizeOutput(); self::assertStringContainsString('Audit Diff for App\Entity\Post #123', $output); - self::assertStringContainsString('Log ID 1', $output); + self::assertStringContainsString('Log ID 018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', $output); self::assertStringContainsString('Action UPDATE', $output); self::assertStringContainsString('Date 2023-01-01 10:00:00', $output); self::assertStringContainsString('User testuser', $output); @@ -97,17 +99,13 @@ public function testExecuteWithId(): void public function testExecuteWithNoSemanticChanges(): void { - $log = new AuditLog(); - $this->setLogId($log, 1); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('App\Entity\Post', '123', AuditLogInterface::ACTION_UPDATE); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->repository->method('find')->willReturn($log); $this->diffGenerator->method('generate')->willReturn([]); - $this->commandTester->execute(['identifier' => '1']); + $this->commandTester->execute(['identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $this->commandTester->assertCommandIsSuccessful(); $output = $this->normalizeOutput(); @@ -116,12 +114,8 @@ public function testExecuteWithNoSemanticChanges(): void public function testExecuteWithEntityClassAndId(): void { - $log = new AuditLog(); - $this->setLogId($log, 1); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('App\Entity\Post', '123', AuditLogInterface::ACTION_UPDATE); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->repository->expects($this->once()) ->method('findWithFilters') @@ -140,15 +134,12 @@ public function testExecuteWithEntityClassAndId(): void $this->commandTester->assertCommandIsSuccessful(); $output = $this->normalizeOutput(); self::assertStringContainsString('Audit Diff for App\Entity\Post #123', $output); - self::assertStringContainsString('Log ID 1', $output); + self::assertStringContainsString('Log ID 018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', $output); } public function testExecuteWithEntityShortName(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); + $log = new AuditLog('App\Entity\Post', '123', AuditLogInterface::ACTION_UPDATE); $this->repository->expects($this->once()) ->method('findWithFilters') @@ -170,15 +161,13 @@ public function testExecuteWithEntityShortName(): void public function testExecuteWithJsonOption(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); + $log = new AuditLog('App\Entity\Post', '123', AuditLogInterface::ACTION_UPDATE); $this->repository->method('find')->willReturn($log); $this->diffGenerator->method('generate')->willReturn(['title' => ['old' => 'A', 'new' => 'B']]); $this->commandTester->execute([ - 'identifier' => '1', + 'identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--json' => true, ]); @@ -190,15 +179,16 @@ public function testExecuteWithJsonOption(): void public function testExecuteWithIdAndTimestamps(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction('update'); // Lowercase to test strtoupper - $log->setCreatedAt(new DateTimeImmutable('2023-01-01 12:00:00')); + $log = new AuditLog( + 'App\Entity\Post', + '123', + 'update', + new DateTimeImmutable('2023-01-01 12:00:00') + ); $this->repository->expects($this->once()) ->method('find') - ->with(1) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn($log); $this->diffGenerator->expects($this->once()) @@ -211,7 +201,7 @@ public function testExecuteWithIdAndTimestamps(): void ->willReturn(['title' => ['old' => 'Old', 'new' => 'New']]); $this->commandTester->execute([ - 'identifier' => '1', + 'identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--include-timestamps' => true, ]); @@ -223,11 +213,7 @@ public function testExecuteWithIdAndTimestamps(): void public function testExecuteWithNumericIdentifierAndEntityId(): void { - $log = new AuditLog(); - $log->setEntityClass('123'); // Weird class name - $log->setEntityId('456'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('123', '456', AuditLogInterface::ACTION_UPDATE); $this->repository->expects($this->once()) ->method('findWithFilters') @@ -246,11 +232,7 @@ public function testExecuteWithNumericIdentifierAndEntityId(): void public function testExecuteWithRawOption(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('App\Entity\Post', '123', AuditLogInterface::ACTION_UPDATE); $this->repository->method('find')->willReturn($log); @@ -264,7 +246,7 @@ public function testExecuteWithRawOption(): void ->willReturn([]); $this->commandTester->execute([ - 'identifier' => '1', + 'identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--raw' => true, ]); @@ -275,11 +257,12 @@ public function testExecuteNotFound(): void { $this->repository->method('find')->willReturn(null); - $this->commandTester->execute(['identifier' => '999']); + $this->commandTester->execute(['identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); $display = preg_replace('/\s+/', ' ', trim($this->commandTester->getDisplay())); - self::assertStringContainsString('Audit log with ID 999 not found.', (string) $display); + self::assertStringContainsString('Audit log with ID', (string) $display); + self::assertStringContainsString('not found.', (string) $display); } public function testExecuteEntityIdRequiredForClassIdentifier(): void @@ -308,12 +291,8 @@ public function testExecuteNoLogsFoundForEntity(): void public function testExecuteWithNullAndBoolValues(): void { - $log = new AuditLog(); - $this->setLogId($log, 123); - $log->setEntityClass('App\Entity\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('App\Entity\Post', '123', AuditLogInterface::ACTION_UPDATE); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a362731'); $this->repository->method('find')->willReturn($log); @@ -322,7 +301,7 @@ public function testExecuteWithNullAndBoolValues(): void 'description' => ['old' => null, 'new' => 'Description'], ]); - $this->commandTester->execute(['identifier' => '123'], ['decorated' => true]); + $this->commandTester->execute(['identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a362731'], ['decorated' => true]); $this->commandTester->assertCommandIsSuccessful(); $output = $this->normalizeOutput(); @@ -337,12 +316,8 @@ public function testExecuteWithNullAndBoolValues(): void public function testFormatValueBooleanTernaryOrder(): void { - $log = new AuditLog(); - $this->setLogId($log, 1); - $log->setEntityClass('App\\Entity\\Post'); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('App\\Entity\\Post', '1', AuditLogInterface::ACTION_UPDATE); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->repository->method('find')->willReturn($log); @@ -351,7 +326,7 @@ public function testFormatValueBooleanTernaryOrder(): void 'active' => ['old' => true, 'new' => false], ]); - $this->commandTester->execute(['identifier' => '1']); + $this->commandTester->execute(['identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $this->commandTester->assertCommandIsSuccessful(); $display = $this->commandTester->getDisplay(); @@ -366,17 +341,13 @@ public function testFormatValueBooleanTernaryOrder(): void public function testNoSemanticChangesEarlyReturn(): void { - $log = new AuditLog(); - $this->setLogId($log, 1); - $log->setEntityClass('App\\Entity\\Post'); - $log->setEntityId('123'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('App\\Entity\\Post', '123', AuditLogInterface::ACTION_UPDATE); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->repository->method('find')->willReturn($log); $this->diffGenerator->method('generate')->willReturn([]); - $this->commandTester->execute(['identifier' => '1']); + $this->commandTester->execute(['identifier' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $this->commandTester->assertCommandIsSuccessful(); $output = $this->normalizeOutput(); diff --git a/tests/Unit/Command/AuditExportCommandTest.php b/tests/Unit/Command/AuditExportCommandTest.php index d3387d2..47734a7 100644 --- a/tests/Unit/Command/AuditExportCommandTest.php +++ b/tests/Unit/Command/AuditExportCommandTest.php @@ -12,7 +12,9 @@ use Rcsofttech\AuditTrailBundle\Command\AuditExportCommand; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; +use ReflectionClass; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Uuid; #[CoversClass(AuditExportCommand::class)] #[AllowMockObjectsWithoutExpectations] @@ -45,7 +47,7 @@ public function testExportWithNoResults(): void public function testExportToJson(): void { - $audit = $this->createAuditLog(1, 'App\\Entity\\User', '42', 'create'); + $audit = $this->createAuditLog('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', 'App\\Entity\\User', '42', 'create'); $this->repository ->expects($this->once()) @@ -65,7 +67,7 @@ public function testExportToJson(): void public function testExportToFile(): void { - $audit = $this->createAuditLog(1, 'App\\Entity\\User', '42', 'create'); + $audit = $this->createAuditLog('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', 'App\\Entity\\User', '42', 'create'); $tempFile = sys_get_temp_dir().'/audit_export_test.json'; if (file_exists($tempFile)) { unlink($tempFile); @@ -91,7 +93,7 @@ public function testExportToFile(): void public function testExportToCsv(): void { - $audit = $this->createAuditLog(1, 'App\\Entity\\User', '42', 'update'); + $audit = $this->createAuditLog('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', 'App\\Entity\\User', '42', 'update'); $this->repository ->expects($this->once()) @@ -204,17 +206,15 @@ private function normalizeOutput(): string return (string) preg_replace('/\s+/', ' ', trim($output)); } - private function createAuditLog(int $id, string $entityClass, string $entityId, string $action): AuditLog + private function createAuditLog(string $id, string $entityClass, string $entityId, string $action): AuditLog { - $audit = self::createStub(AuditLog::class); + $log = new AuditLog($entityClass, $entityId, $action, new DateTimeImmutable('2024-01-01 12:00:00')); + $reflection = new ReflectionClass($log); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($log, Uuid::fromString($id)); - $audit->method('getId')->willReturn($id); - $audit->method('getEntityClass')->willReturn($entityClass); - $audit->method('getEntityId')->willReturn($entityId); - $audit->method('getAction')->willReturn($action); - $audit->method('getCreatedAt')->willReturn(new DateTimeImmutable('2024-01-01 12:00:00')); - - return $audit; + return $log; } public function testExportNoResultsEarlyReturn(): void diff --git a/tests/Unit/Command/AuditListCommandTest.php b/tests/Unit/Command/AuditListCommandTest.php index da6db81..fcc5860 100644 --- a/tests/Unit/Command/AuditListCommandTest.php +++ b/tests/Unit/Command/AuditListCommandTest.php @@ -13,7 +13,9 @@ use Rcsofttech\AuditTrailBundle\Command\AuditListCommand; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; +use ReflectionClass; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Uuid; #[CoversClass(AuditListCommand::class)] #[AllowMockObjectsWithoutExpectations] @@ -47,7 +49,7 @@ public function testListWithNoResults(): void public function testListWithResults(): void { - $audit = $this->createAuditLog(1, 'TestEntity', '42', 'update'); + $audit = $this->createAuditLog('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', 'TestEntity', '42', 'update'); $this->repository ->expects($this->once()) @@ -70,7 +72,7 @@ public function testListWithResults(): void public function testListWithDetailsFlag(): void { - $audit = $this->createAuditLog(1, 'TestEntity', '42', 'update'); + $audit = $this->createAuditLog('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', 'TestEntity', '42', 'update'); $this->repository ->expects($this->once()) @@ -275,22 +277,26 @@ private function normalizeOutput(): string return (string) preg_replace('/\s+/', ' ', trim($output)); } - private function createAuditLog(int $id, string $entityClass, string $entityId, string $action): AuditLog + private function createAuditLog(string $id, string $entityClass, string $entityId, string $action): AuditLog { - $audit = self::createStub(AuditLog::class); - - $audit->method('getId')->willReturn($id); - $audit->method('getEntityClass')->willReturn($entityClass); - $audit->method('getEntityId')->willReturn($entityId); - $audit->method('getAction')->willReturn($action); - $audit->method('getCreatedAt')->willReturn(new DateTimeImmutable('2024-01-01 12:00:00')); - $audit->method('getUsername')->willReturn('test_user'); - $audit->method('getChangedFields')->willReturn(['title']); - $audit->method('getOldValues')->willReturn(['title' => 'Old Title']); - $audit->method('getNewValues')->willReturn(['title' => 'New Title']); - $audit->method('getTransactionHash')->willReturn('abc-123-def-456'); - - return $audit; + $log = new AuditLog( + $entityClass, + $entityId, + $action, + new DateTimeImmutable('2024-01-01 12:00:00'), + oldValues: ['title' => 'Old Title'], + newValues: ['title' => 'New Title'], + changedFields: ['title'], + transactionHash: 'abc-123-def-456', + username: 'test_user' + ); + + $reflection = new ReflectionClass($log); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($log, Uuid::fromString($id)); + + return $log; } public function testListNoResultsEarlyReturn(): void diff --git a/tests/Unit/Command/AuditPurgeCommandTest.php b/tests/Unit/Command/AuditPurgeCommandTest.php index 99ed4e2..7a3b10b 100644 --- a/tests/Unit/Command/AuditPurgeCommandTest.php +++ b/tests/Unit/Command/AuditPurgeCommandTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Command\AuditPurgeCommand; +use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use Symfony\Component\Console\Tester\CommandTester; @@ -19,12 +20,16 @@ class AuditPurgeCommandTest extends TestCase { private AuditLogRepository&MockObject $repository; + private AuditIntegrityServiceInterface&MockObject $integrityService; + private CommandTester $commandTester; protected function setUp(): void { $this->repository = $this->createMock(AuditLogRepository::class); - $command = new AuditPurgeCommand($this->repository); + $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); + $this->integrityService->method('isEnabled')->willReturn(false); + $command = new AuditPurgeCommand($this->repository, $this->integrityService); $this->commandTester = new CommandTester($command); } diff --git a/tests/Unit/Command/AuditRevertCommandTest.php b/tests/Unit/Command/AuditRevertCommandTest.php index 38c673b..223f3f3 100644 --- a/tests/Unit/Command/AuditRevertCommandTest.php +++ b/tests/Unit/Command/AuditRevertCommandTest.php @@ -42,14 +42,11 @@ protected function setUp(): void public function testExecuteRevertSuccess(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->expects($this->once()) ->method('find') - ->with(123) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn($log); $this->reverter->expects($this->once()) @@ -57,11 +54,11 @@ public function testExecuteRevertSuccess(): void ->with($log, false, false) ->willReturn(['name' => 'Old Name', 'age' => 30]); - $this->commandTester->execute(['auditId' => 123]); + $this->commandTester->execute(['auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $output = $this->commandTester->getDisplay(); $normalizedOutput = (string) preg_replace('/\s+/', ' ', $output); - self::assertStringContainsString('Reverting Audit Log #123 (update)', $normalizedOutput); + self::assertStringContainsString('Reverting Audit Log #018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a (update)', $normalizedOutput); self::assertStringContainsString('Entity: App\Entity\User:1', $normalizedOutput); self::assertStringContainsString('Revert successful', $normalizedOutput); self::assertStringContainsString('Changes Applied:', $normalizedOutput); @@ -72,14 +69,11 @@ public function testExecuteRevertSuccess(): void public function testExecuteRevertDryRun(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->expects($this->once()) ->method('find') - ->with(123) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn($log); $this->reverter->expects($this->once()) @@ -88,27 +82,24 @@ public function testExecuteRevertDryRun(): void ->willReturn(['name' => 'Old Name']); $this->commandTester->execute([ - 'auditId' => 123, + 'auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--dry-run' => true, ]); $output = $this->commandTester->getDisplay(); $normalizedOutput = (string) preg_replace('/\s+/', ' ', $output); - self::assertStringContainsString('Reverting Audit Log #123 (update)', $normalizedOutput); + self::assertStringContainsString('Reverting Audit Log #018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a (update)', $normalizedOutput); self::assertStringContainsString('DRY-RUN', $normalizedOutput); self::assertEquals(0, $this->commandTester->getStatusCode()); } public function testExecuteRevertForce(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('create'); + $log = new AuditLog('App\Entity\User', '1', 'create'); $this->repository->expects($this->once()) ->method('find') - ->with(123) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn($log); $this->reverter->expects($this->once()) @@ -117,7 +108,7 @@ public function testExecuteRevertForce(): void ->willReturn(['action' => 'delete']); $this->commandTester->execute([ - 'auditId' => 123, + 'auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--force' => true, ]); @@ -128,34 +119,32 @@ public function testExecuteAuditNotFound(): void { $this->repository->expects($this->once()) ->method('find') - ->with(999) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn(null); - $this->commandTester->execute(['auditId' => 999]); + $this->commandTester->execute(['auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $output = $this->commandTester->getDisplay(); $normalizedOutput = (string) preg_replace('/\s+/', ' ', $output); - self::assertStringContainsString('Audit log with ID 999 not found', $normalizedOutput); + self::assertStringContainsString('Audit log with ID', $normalizedOutput); + self::assertStringContainsString('not found', $normalizedOutput); self::assertEquals(1, $this->commandTester->getStatusCode()); } public function testExecuteRevertFailure(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->expects($this->once()) ->method('find') - ->with(123) + ->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a') ->willReturn($log); $this->reverter->expects($this->once()) ->method('revert') ->willThrowException(new RuntimeException('Revert failed')); - $this->commandTester->execute(['auditId' => 123]); + $this->commandTester->execute(['auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $output = (string) preg_replace('/\s+/', ' ', $this->commandTester->getDisplay()); self::assertStringContainsString('Revert failed', $output); @@ -164,16 +153,13 @@ public function testExecuteRevertFailure(): void public function testExecuteRawOption(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->method('find')->willReturn($log); $this->reverter->method('revert')->willReturn(['name' => 'Old Name']); $this->commandTester->execute([ - 'auditId' => 123, + 'auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--raw' => true, ]); @@ -186,15 +172,12 @@ public function testExecuteRawOption(): void public function testExecuteNoChanges(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->method('find')->willReturn($log); $this->reverter->method('revert')->willReturn([]); - $this->commandTester->execute(['auditId' => 123]); + $this->commandTester->execute(['auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $output = $this->commandTester->getDisplay(); $normalizedOutput = (string) preg_replace('/\s+/', ' ', $output); @@ -203,15 +186,12 @@ public function testExecuteNoChanges(): void public function testExecuteNonScalarChange(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->method('find')->willReturn($log); $this->reverter->method('revert')->willReturn(['roles' => ['ROLE_USER']]); - $this->commandTester->execute(['auditId' => 123]); + $this->commandTester->execute(['auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('roles: ["ROLE_USER"]', $output); @@ -219,10 +199,7 @@ public function testExecuteNonScalarChange(): void public function testExecuteWithContext(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->method('find')->willReturn($log); @@ -233,7 +210,7 @@ public function testExecuteWithContext(): void ->willReturn(['name' => 'Old Name']); $this->commandTester->execute([ - 'auditId' => 123, + 'auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--context' => json_encode($context), ]); @@ -244,15 +221,12 @@ public function testExecuteWithContext(): void public function testExecuteWithInvalidContext(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); + $log = new AuditLog('App\Entity\User', '1', 'update'); $this->repository->method('find')->willReturn($log); $this->commandTester->execute([ - 'auditId' => 123, + 'auditId' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', '--context' => '{invalid json}', ]); diff --git a/tests/Unit/Command/VerifyIntegrityCommandTest.php b/tests/Unit/Command/VerifyIntegrityCommandTest.php index 94c7ede..cf29433 100644 --- a/tests/Unit/Command/VerifyIntegrityCommandTest.php +++ b/tests/Unit/Command/VerifyIntegrityCommandTest.php @@ -16,6 +16,7 @@ use ReflectionClass; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Uid\Uuid; #[AllowMockObjectsWithoutExpectations] #[CoversClass(VerifyIntegrityCommand::class)] @@ -36,11 +37,11 @@ protected function setUp(): void $this->commandTester = new CommandTester($command); } - private function setLogId(AuditLog $log, int $id): void + private function setLogId(AuditLog $log, string $id): void { $reflection = new ReflectionClass($log); $property = $reflection->getProperty('id'); - $property->setValue($log, $id); + $property->setValue($log, Uuid::fromString($id)); } private function normalizeOutput(): string @@ -66,9 +67,9 @@ public function testExecuteIntegrityDisabled(): void public function testExecuteSingleLogNotFound(): void { $this->integrityService->method('isEnabled')->willReturn(true); - $this->repository->method('find')->with(1)->willReturn(null); + $this->repository->method('find')->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a')->willReturn(null); - $this->commandTester->execute(['--id' => 1]); + $this->commandTester->execute(['--id' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode()); self::assertStringContainsString('not found', $this->normalizeOutput()); @@ -76,22 +77,17 @@ public function testExecuteSingleLogNotFound(): void public function testExecuteSingleLogValid(): void { - $log = new AuditLog(); - $this->setLogId($log, 1); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); - $log->setCreatedAt(new DateTimeImmutable('2024-01-01 12:00:00')); - + $log = new AuditLog('App\Entity\User', '1', 'update', new DateTimeImmutable('2024-01-01 12:00:00')); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->integrityService->method('isEnabled')->willReturn(true); - $this->repository->method('find')->with(1)->willReturn($log); + $this->repository->method('find')->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a')->willReturn($log); $this->integrityService->method('verifySignature')->with($log)->willReturn(true); - $this->commandTester->execute(['--id' => 1]); + $this->commandTester->execute(['--id' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); $output = $this->normalizeOutput(); - self::assertStringContainsString('Verifying Audit Log #1', $output); + self::assertStringContainsString('Verifying Audit Log #018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a', $output); self::assertStringContainsString('Entity: App\Entity\User 1', $output); self::assertStringContainsString('Action: update', $output); self::assertStringContainsString('Created: 2024-01-01 12:00:00', $output); @@ -100,17 +96,13 @@ public function testExecuteSingleLogValid(): void public function testExecuteSingleLogInvalid(): void { - $log = new AuditLog(); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setAction('update'); - $log->setCreatedAt(new DateTimeImmutable()); - + $log = new AuditLog('App\Entity\User', '1', 'update'); + $this->setLogId($log, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->integrityService->method('isEnabled')->willReturn(true); - $this->repository->method('find')->with(1)->willReturn($log); + $this->repository->method('find')->with('018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a')->willReturn($log); $this->integrityService->method('verifySignature')->with($log)->willReturn(false); - $this->commandTester->execute(['--id' => 1]); + $this->commandTester->execute(['--id' => '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a']); self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode()); $output = $this->normalizeOutput(); @@ -131,10 +123,10 @@ public function testExecuteAllLogsNoneFound(): void public function testExecuteAllLogsValid(): void { - $log1 = new AuditLog(); - $this->setLogId($log1, 1); - $log2 = new AuditLog(); - $this->setLogId($log2, 2); + $log1 = new AuditLog('App\Entity\User', '1', 'create'); + $this->setLogId($log1, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); + $log2 = new AuditLog('App\Entity\User', '2', 'create'); + $this->setLogId($log2, '018f3b3b-3b3b-7b3b-8b3b-3b3b3b3b3b3b'); $this->integrityService->method('isEnabled')->willReturn(true); $this->repository->method('count')->willReturn(2); @@ -157,13 +149,8 @@ public function testExecuteAllLogsValid(): void public function testExecuteAllLogsTampered(): void { - $log1 = new AuditLog(); - $this->setLogId($log1, 1); - $log1->setEntityClass('User'); - $log1->setEntityId('1'); - $log1->setAction('update'); - $log1->setCreatedAt(new DateTimeImmutable('2024-01-01 10:00:00')); - + $log1 = new AuditLog('User', '1', 'update', new DateTimeImmutable('2024-01-01 10:00:00')); + $this->setLogId($log1, '018f3a3a-3a3a-7a3a-8a3a-3a3a3a3a3a3a'); $this->integrityService->method('isEnabled')->willReturn(true); $this->repository->method('count')->willReturn(1); $this->repository->method('findBy')->willReturn([$log1]); @@ -175,7 +162,7 @@ public function testExecuteAllLogsTampered(): void self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode()); $output = $this->normalizeOutput(); self::assertStringContainsString('Found 1 tampered', $output); - self::assertStringContainsString('1 User 1 update 2024-01-01 10:00:00', $output); + self::assertStringContainsString('User 1 update 2024-01-01 10:00:00', $output); } public function testExecuteWithBatching(): void @@ -183,8 +170,8 @@ public function testExecuteWithBatching(): void $this->integrityService->method('isEnabled')->willReturn(true); $this->repository->method('count')->willReturn(150); - $batch1 = array_fill(0, 100, new AuditLog()); - $batch2 = array_fill(0, 50, new AuditLog()); + $batch1 = array_fill(0, 100, new AuditLog('User', '1', 'create')); + $batch2 = array_fill(0, 50, new AuditLog('User', '1', 'create')); $callCount = 0; $this->repository->expects($this->exactly(2)) diff --git a/tests/Unit/Entity/AuditLogTest.php b/tests/Unit/Entity/AuditLogTest.php index 2981cad..3499b36 100644 --- a/tests/Unit/Entity/AuditLogTest.php +++ b/tests/Unit/Entity/AuditLogTest.php @@ -5,6 +5,7 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Entity; use InvalidArgumentException; +use LogicException; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; @@ -12,21 +13,34 @@ class AuditLogTest extends TestCase { public function testContext(): void { - $log = new AuditLog(); - self::assertEquals([], $log->getContext()); + $log = new AuditLog('App\Entity\User', '1', 'create'); + self::assertEquals([], $log->context); $context = ['foo' => 'bar', 'nested' => ['a' => 1]]; - $log->setContext($context); - self::assertEquals($context, $log->getContext()); + $log = new AuditLog('App\Entity\User', '1', 'create', context: $context); + self::assertEquals($context, $log->context); } public function testActionValidation(): void { - $log = new AuditLog(); - $log->setAction('create'); - self::assertEquals('create', $log->getAction()); + $log = new AuditLog('App\Entity\User', '1', 'create'); + self::assertEquals('create', $log->action); $this->expectException(InvalidArgumentException::class); - $log->setAction('invalid_action'); + $log = new AuditLog('App\Entity\User', '1', 'invalid_action'); + } + + public function testSealProtection(): void + { + $log = new AuditLog('App\Entity\User', '1', 'create'); + $log->entityId = '123'; + self::assertEquals('123', $log->entityId); + + $log->seal(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot modify a sealed audit log.'); + + $log->entityId = '456'; } } diff --git a/tests/Unit/Event/AuditMessageStampEventTest.php b/tests/Unit/Event/AuditMessageStampEventTest.php index bb3dcd3..7c2c60f 100644 --- a/tests/Unit/Event/AuditMessageStampEventTest.php +++ b/tests/Unit/Event/AuditMessageStampEventTest.php @@ -5,6 +5,7 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Event; use DateTimeImmutable; +use DateTimeInterface; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Event\AuditMessageStampEvent; use Rcsofttech\AuditTrailBundle\Message\AuditLogMessage; @@ -49,17 +50,6 @@ public function testAddMultipleStamps(): void self::assertSame($transportStamp, $stamps[1]); } - public function testCancelEvent(): void - { - $event = new AuditMessageStampEvent($this->createMessage()); - - self::assertFalse($event->isCancelled()); - - $event->cancel(); - - self::assertTrue($event->isCancelled()); - } - public function testInitialStampsViaConstructor(): void { $delayStamp = new DelayStamp(1000); @@ -84,9 +74,8 @@ private function createMessage(): AuditLogMessage '127.0.0.1', 'PHPUnit', null, - null, - [], - new DateTimeImmutable() + new DateTimeImmutable()->format(DateTimeInterface::ATOM), + [] ); } } diff --git a/tests/Unit/EventSubscriber/AuditSubscriberTest.php b/tests/Unit/EventSubscriber/AuditSubscriberTest.php index 54ab165..a153201 100644 --- a/tests/Unit/EventSubscriber/AuditSubscriberTest.php +++ b/tests/Unit/EventSubscriber/AuditSubscriberTest.php @@ -7,36 +7,36 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\PostFlushEventArgs; -use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\UnitOfWork; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ChangeProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityProcessorInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\EventSubscriber\AuditSubscriber; use Rcsofttech\AuditTrailBundle\Service\AuditAccessHandler; -use Rcsofttech\AuditTrailBundle\Service\AuditDispatcher; -use Rcsofttech\AuditTrailBundle\Service\AuditService; -use Rcsofttech\AuditTrailBundle\Service\ChangeProcessor; -use Rcsofttech\AuditTrailBundle\Service\EntityProcessor; -use Rcsofttech\AuditTrailBundle\Service\ScheduledAuditManager; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; use stdClass; #[AllowMockObjectsWithoutExpectations] class AuditSubscriberTest extends TestCase { - private AuditService&MockObject $auditService; + private AuditServiceInterface&MockObject $auditService; - private ChangeProcessor&MockObject $changeProcessor; + private ChangeProcessorInterface&MockObject $changeProcessor; - private AuditDispatcher&MockObject $dispatcher; + private AuditDispatcherInterface&MockObject $dispatcher; - private ScheduledAuditManager&MockObject $auditManager; + private MockScheduledAuditManager $auditManager; - private EntityProcessor&MockObject $entityProcessor; + private EntityProcessorInterface&MockObject $entityProcessor; private TransactionIdGenerator&MockObject $transactionIdGenerator; @@ -44,18 +44,21 @@ class AuditSubscriberTest extends TestCase private AuditAccessHandler&MockObject $accessHandler; + private EntityIdResolverInterface&MockObject $idResolver; + private AuditSubscriber $subscriber; protected function setUp(): void { - $this->auditService = $this->createMock(AuditService::class); - $this->changeProcessor = $this->createMock(ChangeProcessor::class); - $this->dispatcher = $this->createMock(AuditDispatcher::class); - $this->auditManager = $this->createMock(ScheduledAuditManager::class); - $this->entityProcessor = $this->createMock(EntityProcessor::class); + $this->auditService = $this->createMock(AuditServiceInterface::class); + $this->changeProcessor = $this->createMock(ChangeProcessorInterface::class); + $this->dispatcher = $this->createMock(AuditDispatcherInterface::class); + $this->auditManager = new MockScheduledAuditManager(); + $this->entityProcessor = $this->createMock(EntityProcessorInterface::class); $this->transactionIdGenerator = $this->createMock(TransactionIdGenerator::class); $this->logger = $this->createMock(LoggerInterface::class); $this->accessHandler = $this->createMock(AuditAccessHandler::class); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); $this->subscriber = new AuditSubscriber( $this->auditService, @@ -65,6 +68,7 @@ protected function setUp(): void $this->entityProcessor, $this->transactionIdGenerator, $this->accessHandler, + $this->idResolver, $this->logger ); } @@ -84,6 +88,7 @@ public function testOnFlushDisabled(): void $this->entityProcessor, $this->transactionIdGenerator, $this->accessHandler, + $this->idResolver, null, true, false // Disabled @@ -109,7 +114,7 @@ public function testOnFlushRecursion(): void $this->subscriber->onFlush($args); } - public function testOnFlushBatchFlush(): void + public function testOnFlushNoBatchFlush(): void { $em = $this->createMock(EntityManagerInterface::class); $uow = $this->createMock(UnitOfWork::class); @@ -118,18 +123,19 @@ public function testOnFlushBatchFlush(): void $args->method('getObjectManager')->willReturn($em); $em->method('getUnitOfWork')->willReturn($uow); - // Simulate batch threshold reached - $this->auditManager->method('countScheduled')->willReturn(501); - $this->auditManager->method('getScheduledAudits')->willReturn([ - [ - 'audit' => new AuditLog(), - 'entity' => new stdClass(), - 'is_insert' => false, - ], - ]); + // Simulate batch threshold reached - manually access property since implementation details allow it + // But wait, the subscriber doesn't check count anymore in onFlush? + // Let's check AuditSubscriber::onFlush again. + // It calls handleBatchFlushIfNeeded. + // handleBatchFlushIfNeeded was empty in the view! + // "Removed nested flush in onFlush..." + // So testOnFlushNoBatchFlush might be obsolete or testing nothing. + // Let's check the code I viewed earlier. + // handleBatchFlushIfNeeded was empty. + // So this test is testing... empty method. + // I'll keep it but remove the "countScheduled" expectation. - $this->dispatcher->expects($this->once())->method('dispatch'); - $this->auditManager->expects($this->once())->method('clear'); + $this->dispatcher->expects($this->never())->method('dispatch'); $this->subscriber->onFlush($args); } @@ -141,11 +147,14 @@ public function testPostFlushProcessPendingDeletions(): void $args->method('getObjectManager')->willReturn($em); $entity = new stdClass(); - $audit = new AuditLog(); + $audit = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_DELETE); + + // property access on mock + $this->auditManager->scheduledAudits = []; + $this->auditManager->pendingDeletions = [ + ['entity' => $entity, 'data' => ['id' => 1], 'is_managed' => true], + ]; - $this->auditManager->method('getPendingDeletions')->willReturn([ - ['entity' => $entity, 'data' => ['id' => 1]], - ]); $this->changeProcessor->method('determineDeletionAction')->willReturn('delete'); $this->auditService->method('createAuditLog')->willReturn($audit); @@ -165,15 +174,14 @@ public function testPostFlushProcessScheduledAuditsWithPendingId(): void $args->method('getObjectManager')->willReturn($em); $entity = new stdClass(); - $audit = new AuditLog(); + $audit = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); - $this->auditManager->method('getScheduledAudits')->willReturn([ + $this->auditManager->pendingDeletions = []; + $this->auditManager->scheduledAudits = [ ['entity' => $entity, 'audit' => $audit, 'is_insert' => true], - ]); + ]; - $metadata = $this->createMock(ClassMetadata::class); - $em->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); - $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 123]); + $this->idResolver->method('resolveFromEntity')->willReturn('123'); $this->dispatcher->expects($this->once())->method('dispatch'); $em->method('contains')->with($audit)->willReturn(true); @@ -181,7 +189,7 @@ public function testPostFlushProcessScheduledAuditsWithPendingId(): void $this->subscriber->postFlush($args); - self::assertEquals('123', $audit->getEntityId()); + self::assertEquals('123', $audit->entityId); } public function testPostFlushFlushException(): void @@ -190,14 +198,17 @@ public function testPostFlushFlushException(): void $args = $this->createMock(PostFlushEventArgs::class); $args->method('getObjectManager')->willReturn($em); - $audit = new AuditLog(); - $this->auditManager->method('getScheduledAudits')->willReturn([ + $audit = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_UPDATE); + + $this->auditManager->pendingDeletions = []; + $this->auditManager->scheduledAudits = [ [ 'entity' => new stdClass(), 'audit' => $audit, 'is_insert' => false, ], - ]); + ]; + $this->dispatcher->method('dispatch'); $em->method('contains')->with($audit)->willReturn(true); @@ -210,7 +221,11 @@ public function testPostFlushFlushException(): void public function testOnClear(): void { - $this->auditManager->expects($this->once())->method('clear'); + // Populate manager + $this->auditManager->scheduledAudits = [ + ['entity' => new stdClass(), 'audit' => new AuditLog(stdClass::class, '1', 'create'), 'is_insert' => true], + ]; $this->subscriber->onClear(); + self::assertEmpty($this->auditManager->scheduledAudits); } } diff --git a/tests/Unit/EventSubscriber/AuditSubscriberTransportSupportTest.php b/tests/Unit/EventSubscriber/AuditSubscriberTransportSupportTest.php index f24dcb8..65b3c20 100644 --- a/tests/Unit/EventSubscriber/AuditSubscriberTransportSupportTest.php +++ b/tests/Unit/EventSubscriber/AuditSubscriberTransportSupportTest.php @@ -12,11 +12,13 @@ use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\EventSubscriber\AuditSubscriber; use Rcsofttech\AuditTrailBundle\Service\AuditAccessHandler; -use Rcsofttech\AuditTrailBundle\Service\AuditService; use Rcsofttech\AuditTrailBundle\Service\ChangeProcessor; use Rcsofttech\AuditTrailBundle\Service\ScheduledAuditManager; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; @@ -52,10 +54,10 @@ public function testOnFlushDefersWhenTransportDoesNotSupportIt(): void private function createSubscriber( AuditTransportInterface&MockObject $transport, - AuditService $auditService, + AuditServiceInterface $auditService, ): AuditSubscriber { $changeProcessor = new ChangeProcessor( - $auditService, + self::createStub(AuditMetadataManagerInterface::class), new ValueSerializer(), true, 'deletedAt' @@ -78,16 +80,16 @@ private function createSubscriber( $auditManager, $entityProcessor, self::createStub(TransactionIdGenerator::class), - self::createStub(AuditAccessHandler::class) + self::createStub(AuditAccessHandler::class), + self::createStub(EntityIdResolverInterface::class) ); } - private function createAuditServiceStub(): AuditService + private function createAuditServiceStub(): AuditServiceInterface { - $auditLog = new AuditLog(); - $auditLog->setAction('update'); + $auditLog = new AuditLog(stdClass::class, '123', 'update'); - $auditService = self::createStub(AuditService::class); + $auditService = self::createStub(AuditServiceInterface::class); $auditService->method('shouldAudit')->willReturn(true); $auditService->method('createAuditLog')->willReturn($auditLog); diff --git a/tests/Unit/EventSubscriber/MockScheduledAuditManager.php b/tests/Unit/EventSubscriber/MockScheduledAuditManager.php new file mode 100644 index 0000000..079e032 --- /dev/null +++ b/tests/Unit/EventSubscriber/MockScheduledAuditManager.php @@ -0,0 +1,33 @@ + */ + public array $scheduledAudits = []; + + /** @var list, is_managed: bool}> */ + public array $pendingDeletions = []; + + public function schedule(object $entity, AuditLog $audit, bool $isInsert): void + { + $this->scheduledAudits[] = ['entity' => $entity, 'audit' => $audit, 'is_insert' => $isInsert]; + } + + public function addPendingDeletion(object $entity, array $data, bool $isManaged): void + { + $this->pendingDeletions[] = ['entity' => $entity, 'data' => $data, 'is_managed' => $isManaged]; + } + + public function clear(): void + { + $this->scheduledAudits = []; + $this->pendingDeletions = []; + } +} diff --git a/tests/Unit/Query/AuditEntryCollectionTest.php b/tests/Unit/Query/AuditEntryCollectionTest.php index e93dba8..7abad12 100644 --- a/tests/Unit/Query/AuditEntryCollectionTest.php +++ b/tests/Unit/Query/AuditEntryCollectionTest.php @@ -64,12 +64,12 @@ public function testFilter(): void $collection = new AuditEntryCollection([$create, $update, $delete]); - $filtered = $collection->filter(static fn (AuditEntry $e) => $e->isUpdate()); + $filtered = $collection->filter(static fn (AuditEntry $e) => $e->isUpdate); self::assertCount(1, $filtered); $first = $filtered->first(); self::assertNotNull($first); - self::assertTrue($first->isUpdate()); + self::assertTrue($first->isUpdate); } public function testMap(): void @@ -79,7 +79,7 @@ public function testMap(): void $this->createEntry(AuditLogInterface::ACTION_UPDATE), ]); - $actions = $collection->map(static fn (AuditEntry $e) => $e->getAction()); + $actions = $collection->map(static fn (AuditEntry $e) => $e->action); self::assertSame([AuditLogInterface::ACTION_CREATE, AuditLogInterface::ACTION_UPDATE], $actions); } @@ -160,10 +160,7 @@ public function testIterable(): void private function createEntry(string $action, string $entityClass = 'App\\Entity\\User'): AuditEntry { - $log = new AuditLog(); - $log->setEntityClass($entityClass); - $log->setEntityId('1'); - $log->setAction($action); + $log = new AuditLog($entityClass, '1', $action); return new AuditEntry($log); } diff --git a/tests/Unit/Query/AuditEntryTest.php b/tests/Unit/Query/AuditEntryTest.php index df2222d..6150684 100644 --- a/tests/Unit/Query/AuditEntryTest.php +++ b/tests/Unit/Query/AuditEntryTest.php @@ -10,7 +10,6 @@ use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Query\AuditEntry; -use ReflectionClass; #[CoversClass(AuditEntry::class)] #[AllowMockObjectsWithoutExpectations()] @@ -21,15 +20,14 @@ public function testGettersReturnAuditLogValues(): void $log = $this->createAuditLog(); $entry = new AuditEntry($log); - self::assertSame(1, $entry->getId()); - self::assertSame('App\\Entity\\User', $entry->getEntityClass()); - self::assertSame('User', $entry->getEntityShortName()); - self::assertSame('123', $entry->getEntityId()); - self::assertSame(AuditLogInterface::ACTION_UPDATE, $entry->getAction()); - self::assertSame('42', $entry->getUserId()); - self::assertSame('admin', $entry->getUsername()); - self::assertSame('127.0.0.1', $entry->getIpAddress()); - self::assertSame('abc123', $entry->getTransactionHash()); + self::assertSame('App\\Entity\\User', $entry->entityClass); + self::assertSame('User', $entry->entityShortName); + self::assertSame('123', $entry->entityId); + self::assertSame(AuditLogInterface::ACTION_UPDATE, $entry->action); + self::assertSame('42', $entry->userId); + self::assertSame('admin', $entry->username); + self::assertSame('127.0.0.1', $entry->ipAddress); + self::assertSame('abc123', $entry->transactionHash); } public function testActionHelpers(): void @@ -40,15 +38,15 @@ public function testActionHelpers(): void $softDeleteLog = $this->createAuditLog(AuditLogInterface::ACTION_SOFT_DELETE); $restoreLog = $this->createAuditLog(AuditLogInterface::ACTION_RESTORE); - self::assertTrue(new AuditEntry($createLog)->isCreate()); - self::assertFalse(new AuditEntry($createLog)->isUpdate()); + self::assertTrue(new AuditEntry($createLog)->isCreate); + self::assertFalse(new AuditEntry($createLog)->isUpdate); - self::assertTrue(new AuditEntry($updateLog)->isUpdate()); - self::assertFalse(new AuditEntry($updateLog)->isDelete()); + self::assertTrue(new AuditEntry($updateLog)->isUpdate); + self::assertFalse(new AuditEntry($updateLog)->isDelete); - self::assertTrue(new AuditEntry($deleteLog)->isDelete()); - self::assertTrue(new AuditEntry($softDeleteLog)->isSoftDelete()); - self::assertTrue(new AuditEntry($restoreLog)->isRestore()); + self::assertTrue(new AuditEntry($deleteLog)->isDelete); + self::assertTrue(new AuditEntry($softDeleteLog)->isSoftDelete); + self::assertTrue(new AuditEntry($restoreLog)->isRestore); } public function testGetDiffReturnsOldAndNewValues(): void @@ -56,7 +54,7 @@ public function testGetDiffReturnsOldAndNewValues(): void $log = $this->createAuditLog(); $entry = new AuditEntry($log); - $diff = $entry->getDiff(); + $diff = $entry->diff; self::assertArrayHasKey('name', $diff); self::assertSame('John', $diff['name']['old']); @@ -72,7 +70,7 @@ public function testGetChangedFields(): void $log = $this->createAuditLog(); $entry = new AuditEntry($log); - $changedFields = $entry->getChangedFields(); + $changedFields = $entry->changedFields; self::assertContains('name', $changedFields); self::assertContains('email', $changedFields); @@ -99,44 +97,37 @@ public function testGetOldAndNewValue(): void self::assertNull($entry->getNewValue('nonexistent')); } - public function testGetAuditLogReturnsUnderlyingEntity(): void + public function testLogReturnsUnderlyingEntity(): void { $log = $this->createAuditLog(); $entry = new AuditEntry($log); - self::assertSame($log, $entry->getAuditLog()); + self::assertSame($log, $entry->auditLog); } public function testGetEntityShortNameWithSimpleClass(): void { - $log = new AuditLog(); - $log->setEntityClass('User'); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_CREATE); + $log = new AuditLog('User', '1', AuditLogInterface::ACTION_CREATE); $entry = new AuditEntry($log); - self::assertSame('User', $entry->getEntityShortName()); + self::assertSame('User', $entry->entityShortName); } private function createAuditLog(string $action = AuditLogInterface::ACTION_UPDATE): AuditLog { - $log = new AuditLog(); - $log->setEntityClass('App\\Entity\\User'); - $log->setEntityId('123'); - $log->setAction($action); - $log->setUserId('42'); - $log->setUsername('admin'); - $log->setIpAddress('127.0.0.1'); - $log->setTransactionHash('abc123'); - $log->setOldValues(['name' => 'John', 'email' => 'john@example.com']); - $log->setNewValues(['name' => 'Jane', 'email' => 'jane@example.com']); - $log->setChangedFields(['name', 'email']); - - // Use reflection to set the ID - $reflection = new ReflectionClass($log); - $idProperty = $reflection->getProperty('id'); - $idProperty->setValue($log, 1); + $log = new AuditLog( + entityClass: 'App\\Entity\\User', + entityId: '123', + action: $action, + oldValues: ['name' => 'John', 'email' => 'john@example.com'], + newValues: ['name' => 'Jane', 'email' => 'jane@example.com'], + changedFields: ['name', 'email'], + userId: '42', + username: 'admin', + ipAddress: '127.0.0.1', + transactionHash: 'abc123' + ); return $log; } diff --git a/tests/Unit/Query/AuditQueryTest.php b/tests/Unit/Query/AuditQueryTest.php index 3a2c14f..5c45908 100644 --- a/tests/Unit/Query/AuditQueryTest.php +++ b/tests/Unit/Query/AuditQueryTest.php @@ -12,6 +12,7 @@ use Rcsofttech\AuditTrailBundle\Query\AuditQuery; use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use ReflectionClass; +use Symfony\Component\Uid\Uuid; #[AllowMockObjectsWithoutExpectations] class AuditQueryTest extends TestCase @@ -77,7 +78,7 @@ public function testTransactionAndPaginationFilters(): void ->with( self::callback(static function (array $filters) { return ($filters['transactionHash'] ?? null) === 'hash' - && ($filters['afterId'] ?? null) === 10 + && ($filters['afterId'] ?? null) === 'uuid-10' && !isset($filters['beforeId']); }), 10 @@ -86,7 +87,7 @@ public function testTransactionAndPaginationFilters(): void $this->query ->transaction('hash') - ->after(10) + ->after('uuid-10') ->limit(10) ->getResults(); } @@ -117,22 +118,22 @@ public function testPaginationFilters(): void ->with(self::callback(static function (array $f) use (&$callCount) { ++$callCount; if ($callCount === 1) { - return ($f['afterId'] ?? null) === 10 && !isset($f['beforeId']); + return ($f['afterId'] ?? null) === 'uuid-10' && !isset($f['beforeId']); } - return ($f['beforeId'] ?? null) === 20 && !isset($f['afterId']); + return ($f['beforeId'] ?? null) === 'uuid-20' && !isset($f['afterId']); }), 30) ->willReturn([]); - $this->query->after(10)->getResults(); - $this->query->before(20)->getResults(); + $this->query->after('uuid-10')->getResults(); + $this->query->before('uuid-20')->getResults(); } - private function setLogId(AuditLog $log, int $id): void + private function setLogId(AuditLog $log, string $id): void { $reflection = new ReflectionClass($log); $property = $reflection->getProperty('id'); - $property->setValue($log, $id); + $property->setValue($log, Uuid::fromString($id)); } public function testConvenienceMethods(): void @@ -148,11 +149,8 @@ public function testConvenienceMethods(): void public function testCountWithChangedFields(): void { - $log1 = $this->createMock(AuditLog::class); - $log1->method('getChangedFields')->willReturn(['name']); - - $log2 = $this->createMock(AuditLog::class); - $log2->method('getChangedFields')->willReturn(['age']); + $log1 = new AuditLog('Class', '1', 'create', changedFields: ['name']); + $log2 = new AuditLog('Class', '2', 'update', changedFields: ['age']); $this->repository->method('findWithFilters')->willReturn([$log1, $log2]); @@ -167,10 +165,11 @@ public function testCountWithChangedFields(): void public function testGetFirstResult(): void { - $log1 = new AuditLog(); - $this->setLogId($log1, 1); - $log2 = new AuditLog(); - $this->setLogId($log2, 2); + $uuid1 = Uuid::v4()->toString(); + $log1 = new AuditLog('Class', '1', 'create'); + $this->setLogId($log1, $uuid1); + $log2 = new AuditLog('Class', '2', 'create'); + $this->setLogId($log2, Uuid::v4()->toString()); // Should call findWithFilters with limit 1 $this->repository->expects($this->once()) @@ -180,12 +179,13 @@ public function testGetFirstResult(): void $result = $this->query->getFirstResult(); self::assertNotNull($result); - self::assertEquals(1, $result->getId()); + self::assertNotNull($result->id); + self::assertEquals($uuid1, $result->id->toString()); } public function testExists(): void { - $this->repository->method('findWithFilters')->willReturn([new AuditLog()]); + $this->repository->method('findWithFilters')->willReturn([new AuditLog('Class', '1', 'create')]); self::assertTrue($this->query->exists()); $this->repository = $this->createMock(AuditLogRepository::class); @@ -196,15 +196,17 @@ public function testExists(): void public function testGetNextCursor(): void { - $log1 = new AuditLog(); - $this->setLogId($log1, 10); - $log2 = new AuditLog(); - $this->setLogId($log2, 5); + $uuid1 = Uuid::v4()->toString(); + $log1 = new AuditLog('Class', '1', 'create'); + $this->setLogId($log1, $uuid1); + $uuid2 = Uuid::v4()->toString(); + $log2 = new AuditLog('Class', '2', 'create'); + $this->setLogId($log2, $uuid2); $this->repository->method('findWithFilters')->willReturn([$log1, $log2]); // getNextCursor returns the ID of the LAST result - self::assertEquals(5, $this->query->getNextCursor()); + self::assertEquals($uuid2, $this->query->getNextCursor()); // Empty results should return null $this->repository = $this->createMock(AuditLogRepository::class); diff --git a/tests/Unit/Query/AuditReaderTest.php b/tests/Unit/Query/AuditReaderTest.php index a5eb2e4..fffcfaa 100644 --- a/tests/Unit/Query/AuditReaderTest.php +++ b/tests/Unit/Query/AuditReaderTest.php @@ -4,30 +4,30 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Query; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; 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; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Query\AuditReader; -use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use stdClass; #[AllowMockObjectsWithoutExpectations] class AuditReaderTest extends TestCase { - private AuditLogRepository&MockObject $repository; + private AuditLogRepositoryInterface&MockObject $repository; - private EntityManagerInterface&MockObject $entityManager; + private EntityIdResolverInterface&MockObject $idResolver; private AuditReader $reader; protected function setUp(): void { - $this->repository = $this->createMock(AuditLogRepository::class); - $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->reader = new AuditReader($this->repository, $this->entityManager); + $this->repository = $this->createMock(AuditLogRepositoryInterface::class); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); + $this->reader = new AuditReader($this->repository, $this->idResolver); } public function testCreateQuery(): void @@ -54,13 +54,10 @@ public function testByTransaction(): void $this->expectNotToPerformAssertions(); } - public function testGetHistoryForWithMetadata(): void + public function testGetHistoryFor(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 123]); - - $this->entityManager->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->with($entity)->willReturn('123'); $this->repository->expects($this->once()) ->method('findWithFilters') @@ -70,46 +67,10 @@ public function testGetHistoryForWithMetadata(): void $this->reader->getHistoryFor($entity); } - public function testGetHistoryForWithCompositeId(): void - { - $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id1' => 1, 'id2' => 2]); - - $this->entityManager->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); - - $this->repository->expects($this->once()) - ->method('findWithFilters') - ->with(self::callback(static fn ($f) => $f['entityClass'] === 'stdClass' && $f['entityId'] === '["1","2"]'), 30) - ->willReturn([]); - - $this->reader->getHistoryFor($entity); - } - - public function testGetHistoryForFallbackToGetId(): void - { - $entity = new class { - public function getId(): int - { - return 456; - } - }; - - // Simulate metadata failure - $this->entityManager->method('getClassMetadata')->willThrowException(new Exception()); - - $this->repository->expects($this->once()) - ->method('findWithFilters') - ->with(self::callback(static fn ($f) => $f['entityId'] === '456'), 30) - ->willReturn([]); - - $this->reader->getHistoryFor($entity); - } - - public function testGetHistoryForNoId(): void + public function testGetHistoryForFailure(): void { $entity = new stdClass(); - $this->entityManager->method('getClassMetadata')->willThrowException(new Exception()); + $this->idResolver->method('resolveFromEntity')->willThrowException(new Exception()); $this->repository->expects($this->never())->method('findWithFilters'); @@ -120,12 +81,9 @@ public function testGetHistoryForNoId(): void public function testGetTimelineFor(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 123]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn('123'); - $log = new \Rcsofttech\AuditTrailBundle\Entity\AuditLog(); - $log->setTransactionHash('tx1'); + $log = new AuditLog(stdClass::class, '123', 'create', transactionHash: 'tx1'); $this->repository->method('findWithFilters')->willReturn([$log]); @@ -137,11 +95,9 @@ public function testGetTimelineFor(): void public function testGetLatestFor(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 123]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn('123'); - $log = new \Rcsofttech\AuditTrailBundle\Entity\AuditLog(); + $log = new AuditLog(stdClass::class, '123', 'create'); $this->repository->method('findWithFilters')->willReturn([$log]); $result = $this->reader->getLatestFor($entity); @@ -151,11 +107,9 @@ public function testGetLatestFor(): void public function testHasHistoryFor(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 123]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn('123'); - $this->repository->method('findWithFilters')->willReturn([new \Rcsofttech\AuditTrailBundle\Entity\AuditLog()]); + $this->repository->method('findWithFilters')->willReturn([new AuditLog(stdClass::class, '123', 'create')]); self::assertTrue($this->reader->hasHistoryFor($entity)); } diff --git a/tests/Unit/Repository/AuditLogRepositoryTest.php b/tests/Unit/Repository/AuditLogRepositoryTest.php index 6ca227b..781b1d8 100644 --- a/tests/Unit/Repository/AuditLogRepositoryTest.php +++ b/tests/Unit/Repository/AuditLogRepositoryTest.php @@ -16,6 +16,7 @@ use Rcsofttech\AuditTrailBundle\Repository\AuditLogRepository; use ReflectionClass; use Symfony\Bridge\Doctrine\ManagerRegistry; +use Symfony\Component\Uid\Uuid; #[AllowMockObjectsWithoutExpectations] class AuditLogRepositoryTest extends TestCase @@ -125,7 +126,7 @@ public function testFindWithFiltersAll(): void 'transactionHash' => 'tx1', 'from' => new DateTimeImmutable(), 'to' => new DateTimeImmutable(), - 'afterId' => 10, + 'afterId' => Uuid::v4()->toString(), ]; // Verify filter application @@ -138,12 +139,12 @@ public function testFindWithFiltersPaginationBackwards(): void { $this->setupQueryBuilderDefaults(false); // Don't mock getResult yet - $filters = ['beforeId' => 10]; + $filters = ['beforeId' => Uuid::v4()->toString()]; $this->qb->expects($this->once())->method('andWhere')->with('a.id > :beforeId'); $this->qb->expects($this->once())->method('orderBy')->with('a.id', 'ASC'); - $log = new AuditLog(); + $log = new AuditLog('Class', '1', 'create'); $this->query->method('getResult')->willReturn([$log]); $result = $this->repository->findWithFilters($filters); @@ -165,12 +166,13 @@ public function testFindWithFiltersPaginationBackwardsReversed(): void { $this->setupQueryBuilderDefaults(false); - $filters = ['beforeId' => 10]; + $uuid = Uuid::v4()->toString(); + $filters = ['beforeId' => $uuid]; - $log1 = new AuditLog(); - $this->setLogId($log1, 11); - $log2 = new AuditLog(); - $this->setLogId($log2, 12); + $log1 = new AuditLog('Class', '1', 'create'); + $this->setLogId($log1, Uuid::v4()->toString()); + $log2 = new AuditLog('Class', '2', 'create'); + $this->setLogId($log2, Uuid::v4()->toString()); // Results from DB will be ASC: [11, 12] $this->query->method('getResult')->willReturn([$log1, $log2]); @@ -216,12 +218,25 @@ public function testFindWithFiltersShortName(): void $this->repository->findWithFilters($filters); } + public function testFindWithFiltersShortNameWithWildcards(): void + { + $this->setupQueryBuilderDefaults(); + + $filters = ['entityClass' => 'Audit%_Log']; + + $this->qb->expects($this->once())->method('andWhere')->with('a.entityClass LIKE :entityClass'); + // Should be escaped as \% and \_ + $this->qb->expects($this->once())->method('setParameter')->with('entityClass', '%Audit\%\_Log%'); + + $this->repository->findWithFilters($filters); + } + public function testFindByUserReturnsAllResults(): void { $this->setupQueryBuilderDefaults(false); - $log1 = new AuditLog(); - $log2 = new AuditLog(); + $log1 = new AuditLog('Class', '1', 'create'); + $log2 = new AuditLog('Class', '2', 'create'); $this->query->method('getResult')->willReturn([$log1, $log2]); $result = $this->repository->findByUser('1'); @@ -234,8 +249,8 @@ public function testFindByEntityReturnsAllResults(): void { $this->setupQueryBuilderDefaults(false); - $log1 = new AuditLog(); - $log2 = new AuditLog(); + $log1 = new AuditLog('Class', '1', 'create'); + $log2 = new AuditLog('Class', '1', 'update'); $this->query->method('getResult')->willReturn([$log1, $log2]); $result = $this->repository->findByEntity('Class', '1'); @@ -246,8 +261,8 @@ public function testFindByTransactionHashReturnsAllResults(): void { $this->setupQueryBuilderDefaults(false); - $log1 = new AuditLog(); - $log2 = new AuditLog(); + $log1 = new AuditLog('Class', '1', 'create'); + $log2 = new AuditLog('Class', '2', 'update'); $this->query->method('getResult')->willReturn([$log1, $log2]); $result = $this->repository->findByTransactionHash('tx1'); @@ -271,10 +286,10 @@ public function testCountOlderThan(): void self::assertEquals(10, $this->repository->countOlderThan(new DateTimeImmutable())); } - private function setLogId(AuditLog $log, int $id): void + private function setLogId(AuditLog $log, string $id): void { $reflection = new ReflectionClass($log); $property = $reflection->getProperty('id'); - $property->setValue($log, $id); + $property->setValue($log, Uuid::fromString($id)); } } diff --git a/tests/Unit/Security/SensitiveDataUpdateTest.php b/tests/Unit/Security/SensitiveDataUpdateTest.php index 2185245..9993925 100644 --- a/tests/Unit/Security/SensitiveDataUpdateTest.php +++ b/tests/Unit/Security/SensitiveDataUpdateTest.php @@ -8,7 +8,11 @@ use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\UnitOfWork; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\EventSubscriber\AuditSubscriber; use Rcsofttech\AuditTrailBundle\Service\AuditAccessHandler; @@ -23,13 +27,13 @@ #[AllowMockObjectsWithoutExpectations] class SensitiveDataUpdateTest extends AbstractAuditTestCase { - /** @var EntityManagerInterface&\PHPUnit\Framework\MockObject\Stub */ + /** @var EntityManagerInterface&Stub */ private EntityManagerInterface $entityManager; - /** @var AuditTransportInterface&\PHPUnit\Framework\MockObject\MockObject */ + /** @var AuditTransportInterface&MockObject */ private AuditTransportInterface $transport; - /** @var TransactionIdGenerator&\PHPUnit\Framework\MockObject\Stub */ + /** @var TransactionIdGenerator&Stub */ private TransactionIdGenerator $transactionIdGenerator; protected function setUp(): void @@ -51,8 +55,8 @@ public function testUpdateMasksSensitiveData(): void $this->transport->expects($this->once()) ->method('send') ->with(self::callback(static function (AuditLog $log): bool { - $old = $log->getOldValues(); - $new = $log->getNewValues(); + $old = $log->oldValues; + $new = $log->newValues; return ($old['password'] ?? '') === '**REDACTED**' && ($new['password'] ?? '') === '**REDACTED**'; })); @@ -65,7 +69,11 @@ private function createSubscriber(): AuditSubscriber $auditService = $this->createAuditService($this->entityManager, $this->transactionIdGenerator); $dispatcher = $this->createAuditDispatcher($this->transport); $auditManager = new ScheduledAuditManager(self::createStub(EventDispatcherInterface::class)); - $changeProcessor = new ChangeProcessor($auditService, new ValueSerializer(null), true, 'deletedAt'); + + $metadataManager = self::createStub(AuditMetadataManagerInterface::class); + $metadataManager->method('getSensitiveFields')->willReturn(['password' => '**REDACTED**']); + + $changeProcessor = new ChangeProcessor($metadataManager, new ValueSerializer(null), true, 'deletedAt'); $entityProcessor = $this->createEntityProcessor( $auditService, @@ -81,7 +89,8 @@ private function createSubscriber(): AuditSubscriber $auditManager, $entityProcessor, $this->transactionIdGenerator, - self::createStub(AuditAccessHandler::class) + self::createStub(AuditAccessHandler::class), + self::createStub(EntityIdResolverInterface::class) ); } diff --git a/tests/Unit/Serializer/AuditLogMessageSerializerTest.php b/tests/Unit/Serializer/AuditLogMessageSerializerTest.php index 5e0ad6a..a645f3b 100644 --- a/tests/Unit/Serializer/AuditLogMessageSerializerTest.php +++ b/tests/Unit/Serializer/AuditLogMessageSerializerTest.php @@ -12,6 +12,7 @@ use Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp; use Rcsofttech\AuditTrailBundle\Serializer\AuditLogMessageSerializer; use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; class AuditLogMessageSerializerTest extends TestCase { @@ -37,9 +38,8 @@ public function testEncodeAuditLogMessage(): void '127.0.0.1', 'Mozilla', 'hash123', - null, - ['ctx' => 'val'], - $createdAt + $createdAt->format(DateTimeInterface::ATOM), + ['ctx' => 'val'] ); $envelope = new Envelope($message, [ @@ -65,13 +65,12 @@ public function testEncodeAuditLogMessage(): void 'ip_address' => '127.0.0.1', 'user_agent' => 'Mozilla', 'transaction_hash' => 'hash123', - 'signature' => null, - 'context' => ['ctx' => 'val'], 'created_at' => $createdAt->format(DateTimeInterface::ATOM), + 'context' => ['ctx' => 'val'], ]; // Strict comparison of the entire array to ensure no extra or missing keys - self::assertSame($expectedBody, $body); + self::assertEquals($expectedBody, $body); // Assert headers self::assertEquals('test_api_key', $encoded['headers']['X-Audit-Api-Key']); @@ -81,7 +80,7 @@ public function testEncodeAuditLogMessage(): void public function testDecodeThrowsException(): void { - $this->expectException(\Symfony\Component\Messenger\Exception\MessageDecodingFailedException::class); + $this->expectException(MessageDecodingFailedException::class); $this->expectExceptionMessage('Decoding is not supported'); $this->serializer->decode(['body' => '{}']); diff --git a/tests/Unit/Service/AuditAccessHandlerTest.php b/tests/Unit/Service/AuditAccessHandlerTest.php index e28aef8..5f6afbe 100644 --- a/tests/Unit/Service/AuditAccessHandlerTest.php +++ b/tests/Unit/Service/AuditAccessHandlerTest.php @@ -5,112 +5,231 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Service; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; use Rcsofttech\AuditTrailBundle\Attribute\AuditAccess; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditAccessHandler; -use Rcsofttech\AuditTrailBundle\Service\AuditDispatcher; -use Rcsofttech\AuditTrailBundle\Service\AuditService; use stdClass; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; #[AllowMockObjectsWithoutExpectations] -class AuditAccessHandlerTest extends TestCase +final class AuditAccessHandlerTest extends TestCase { - public function testHandleAccessSkipsWhenVoterVetoes(): void + private AuditServiceInterface&MockObject $auditService; + + private AuditDispatcherInterface&MockObject $dispatcher; + + private UserResolverInterface&MockObject $userResolver; + + private EntityIdResolverInterface&MockObject $idResolver; + + private RequestStack $requestStack; + + protected function setUp(): void { - $entity = new stdClass(); + $this->auditService = $this->createMock(AuditServiceInterface::class); + $this->dispatcher = $this->createMock(AuditDispatcherInterface::class); + $this->userResolver = $this->createMock(UserResolverInterface::class); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); + $this->requestStack = new RequestStack(); + } - $auditService = $this->createMock(AuditService::class); - $auditService->method('getAccessAttribute') - ->with(stdClass::class) - ->willReturn(new AuditAccess()); + public function testHandleAccessRespectsConfiguredMethods(): void + { + $handler = new AuditAccessHandler( + $this->auditService, + $this->dispatcher, + $this->userResolver, + $this->requestStack, + $this->idResolver, + null, // cache + null, // logger + ['POST'] // Only audit POST + ); - // Voter vetoes the access audit - $auditService->expects($this->once()) - ->method('passesVoters') - ->with($entity, AuditLogInterface::ACTION_ACCESS) - ->willReturn(false); + $request = Request::create('/test', 'GET'); + $this->requestStack->push($request); - // createAuditLog should NEVER be called - $auditService->expects($this->never())->method('createAuditLog'); + // Should skip because it's a GET request + $this->auditService->expects($this->never())->method('getAccessAttribute'); - $dispatcher = $this->createMock(AuditDispatcher::class); - $dispatcher->expects($this->never())->method('dispatch'); + $entity = new stdClass(); + $om = $this->createMock(EntityManagerInterface::class); - $requestStack = new RequestStack(); - $requestStack->push(Request::create('/', 'GET')); + $handler->handleAccess($entity, $om); + } + public function testHandleAccessAllowsMultipleMethods(): void + { $handler = new AuditAccessHandler( - $auditService, - $dispatcher, - $this->createMock(UserResolverInterface::class), - $requestStack, + $this->auditService, + $this->dispatcher, + $this->userResolver, + $this->requestStack, + $this->idResolver, + null, // cache + null, // logger + ['GET', 'POST'] ); - $em = $this->createMock(EntityManagerInterface::class); - $handler->handleAccess($entity, $em); + $request = Request::create('/test', 'POST'); + $this->requestStack->push($request); + + // Should NOT skip, it should proceed to check attributes + $this->auditService->expects($this->once())->method('getAccessAttribute')->willReturn(null); + + $entity = new stdClass(); + $om = $this->createMock(EntityManagerInterface::class); + + $handler->handleAccess($entity, $om); } - public function testHandleAccessProceedsWhenVoterApproves(): void + public function testHandleAccessCachesAndSkips(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $cache->method('getItem')->willReturn($item); + + $handler = new AuditAccessHandler( + $this->auditService, + $this->dispatcher, + $this->userResolver, + $this->requestStack, + $this->idResolver, + $cache, // mock cache that returns a hit + null, + ['GET'] + ); + + $request = Request::create('/test', 'GET'); + $this->requestStack->push($request); + $entity = new stdClass(); + $om = $this->createMock(EntityManagerInterface::class); - $auditService = $this->createMock(AuditService::class); - $auditService->method('getAccessAttribute') - ->with(stdClass::class) - ->willReturn(new AuditAccess()); + $accessAttr = new AuditAccess(level: 'read', message: 'test', cooldown: 60); - $auditService->method('passesVoters')->willReturn(true); + $this->auditService->method('getAccessAttribute')->willReturn($accessAttr); + $this->auditService->method('passesVoters')->willReturn(true); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); - $audit = new AuditLog(); - $auditService->expects($this->once()) - ->method('createAuditLog') - ->willReturn($audit); + // Since it's a hit, it should NOT dispatch + $this->dispatcher->expects($this->never())->method('dispatch'); - $em = $this->createMock(EntityManagerInterface::class); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn(['id' => 1]); - $em->method('getClassMetadata')->willReturn($metadata); + $handler->handleAccess($entity, $om); - $dispatcher = $this->createMock(AuditDispatcher::class); - $dispatcher->expects($this->once())->method('dispatch'); + // Calling it again relies on runtime memory `$auditedEntities` array and also skips + $handler->handleAccess($entity, $om); + } - $requestStack = new RequestStack(); - $requestStack->push(Request::create('/', 'GET')); + public function testHandleAccessSavesToCacheAndDispatchesOnMiss(): void + { + $cache = $this->createMock(CacheItemPoolInterface::class); + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); // Cache Miss + $item->expects($this->once())->method('set')->with(true); + $item->expects($this->once())->method('expiresAfter')->with(60); + $cache->method('getItem')->willReturn($item); + $cache->expects($this->once())->method('save')->with($item); // Verify it saves! $handler = new AuditAccessHandler( - $auditService, - $dispatcher, - $this->createMock(UserResolverInterface::class), - $requestStack, + $this->auditService, + $this->dispatcher, + $this->userResolver, + $this->requestStack, + $this->idResolver, + $cache, // mock cache + null, + ['GET'] ); - $handler->handleAccess($entity, $em); - } + $request = Request::create('/test', 'GET'); + $this->requestStack->push($request); - public function testHandleAccessSkipsOnNonGetRequest(): void - { - $auditService = $this->createMock(AuditService::class); - $auditService->expects($this->never())->method('getAccessAttribute'); - $auditService->expects($this->never())->method('passesVoters'); + $entity = new stdClass(); + $om = $this->createMock(EntityManagerInterface::class); + + $accessAttr = new AuditAccess(level: 'read', message: 'test', cooldown: 60); + + $this->auditService->method('getAccessAttribute')->willReturn($accessAttr); + $this->auditService->method('passesVoters')->willReturn(true); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); - $requestStack = new RequestStack(); - $requestStack->push(Request::create('/', 'POST')); + $auditLog = $this->createMock(AuditLog::class); + $this->auditService->expects($this->once())->method('createAuditLog')->willReturn($auditLog); + + // Since it's a miss, it MUST dispatch + $this->dispatcher->expects($this->once())->method('dispatch'); + + $handler->handleAccess($entity, $om); + } + public function testMarkAsAudited(): void + { $handler = new AuditAccessHandler( - $auditService, - $this->createMock(AuditDispatcher::class), - $this->createMock(UserResolverInterface::class), - $requestStack, + $this->auditService, + $this->dispatcher, + $this->userResolver, + $this->requestStack, + $this->idResolver, + null, + null, + ['GET'] ); - $em = $this->createMock(EntityManagerInterface::class); - $handler->handleAccess(new stdClass(), $em); + $request = Request::create('/test', 'GET'); + $this->requestStack->push($request); + + $handler->markAsAudited('stdClass:1'); + + $entity = new stdClass(); + $om = $this->createMock(EntityManagerInterface::class); + + $accessAttr = new AuditAccess(level: 'read', message: 'test', cooldown: 0); + + $this->auditService->method('getAccessAttribute')->willReturn($accessAttr); + $this->auditService->method('passesVoters')->willReturn(true); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); // matches key + + // It's marked as audited so it should NOT dispatch + $this->dispatcher->expects($this->never())->method('dispatch'); + + $handler->handleAccess($entity, $om); + } + + public function testReset(): void + { + $handler = new AuditAccessHandler($this->auditService, $this->dispatcher, $this->userResolver, $this->requestStack, $this->idResolver); + + $request = Request::create('/test', 'GET'); + $this->requestStack->push($request); + $handler->markAsAudited('App\Entity\User:1'); + + $handler->reset(); + + $entity = new stdClass(); + $om = $this->createMock(EntityManagerInterface::class); + + $accessAttr = new AuditAccess(level: 'read', message: 'test', cooldown: 0); + $this->auditService->method('getAccessAttribute')->willReturn($accessAttr); + $this->auditService->method('passesVoters')->willReturn(true); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); + + // It has been reset, so it should dispatch again + $auditLog = $this->createMock(AuditLog::class); + $this->auditService->expects($this->once())->method('createAuditLog')->willReturn($auditLog); + $this->dispatcher->expects($this->once())->method('dispatch'); + + $handler->handleAccess($entity, $om); } } diff --git a/tests/Unit/Service/AuditDispatcherTest.php b/tests/Unit/Service/AuditDispatcherTest.php index 6d26d84..70ef895 100644 --- a/tests/Unit/Service/AuditDispatcherTest.php +++ b/tests/Unit/Service/AuditDispatcherTest.php @@ -5,50 +5,55 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Service; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\UnitOfWork; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditDispatcher; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[AllowMockObjectsWithoutExpectations] class AuditDispatcherTest extends TestCase { private AuditTransportInterface&MockObject $transport; + private EventDispatcherInterface&MockObject $eventDispatcher; + private AuditIntegrityServiceInterface&MockObject $integrityService; private LoggerInterface&MockObject $logger; private EntityManagerInterface&MockObject $em; - private AuditLogInterface&MockObject $audit; + private AuditLog $audit; protected function setUp(): void { $this->transport = $this->createMock(AuditTransportInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->em = $this->createMock(EntityManagerInterface::class); - $this->audit = $this->createMock(AuditLogInterface::class); + $this->audit = new AuditLog('App\Entity\Post', '123', 'update'); } public function testDispatchSuccess(): void { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService); + $dispatcher = new AuditDispatcher($this->transport, $this->eventDispatcher); + $this->transport->method('supports')->willReturn(true); $this->transport->expects($this->once())->method('send'); + $this->eventDispatcher->expects($this->once())->method('dispatch'); - self::assertTrue($dispatcher->dispatch($this->audit, $this->em, 'post_flush')); + $dispatcher->dispatch($this->audit, $this->em, 'post_flush'); } public function testDispatchSignsLogWhenIntegrityEnabled(): void { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService); + $dispatcher = new AuditDispatcher($this->transport, null, $this->integrityService); $this->integrityService->method('isEnabled')->willReturn(true); $this->integrityService->expects($this->once()) @@ -56,31 +61,30 @@ public function testDispatchSignsLogWhenIntegrityEnabled(): void ->with($this->audit) ->willReturn('test_signature'); - $this->audit->expects($this->once()) - ->method('setSignature') - ->with('test_signature'); - + $this->transport->method('supports')->willReturn(true); $this->transport->expects($this->once())->method('send'); $dispatcher->dispatch($this->audit, $this->em, 'post_flush'); + self::assertSame('test_signature', $this->audit->signature); } - public function testDispatchTransportFailureWithFallback(): void + public function testDispatchTransportFailureLogsError(): void { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService, $this->logger, false, true); + $dispatcher = new AuditDispatcher($this->transport, null, null, $this->logger, false); + $this->transport->method('supports')->willReturn(true); $this->transport->method('send')->willThrowException(new Exception('Transport error')); - $this->logger->expects($this->once())->method('error'); - $this->em->method('isOpen')->willReturn(true); - $this->em->method('contains')->willReturn(false); - $this->em->expects($this->once())->method('persist')->with($this->audit); + $this->logger->expects($this->once()) + ->method('error') + ->with(self::stringContains('Audit transport failed')); - self::assertFalse($dispatcher->dispatch($this->audit, $this->em, 'post_flush')); + $dispatcher->dispatch($this->audit, $this->em, 'post_flush'); } public function testDispatchTransportFailureWithException(): void { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService, null, true, true); + $dispatcher = new AuditDispatcher($this->transport, null, null, null, true); + $this->transport->method('supports')->willReturn(true); $this->transport->method('send')->willThrowException(new Exception('Transport error')); $this->expectException(Exception::class); @@ -88,61 +92,4 @@ public function testDispatchTransportFailureWithException(): void $dispatcher->dispatch($this->audit, $this->em, 'post_flush'); } - - public function testDispatchTransportFailureNoFallback(): void - { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService, null, false, false); - $this->transport->method('send')->willThrowException(new Exception('Transport error')); - - $this->em->expects($this->never())->method('persist'); - - self::assertFalse($dispatcher->dispatch($this->audit, $this->em, 'post_flush')); - } - - public function testPersistFallbackOnFlushComputesChangeSet(): void - { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService, null, false, true); - $this->transport->method('send')->willThrowException(new Exception('Transport error')); - - $uow = $this->createMock(UnitOfWork::class); - $this->em->method('isOpen')->willReturn(true); - $this->em->method('getUnitOfWork')->willReturn($uow); - $this->em->method('getClassMetadata') - ->willReturn($this->createMock(\Doctrine\ORM\Mapping\ClassMetadata::class)); - - $uow->expects($this->once())->method('computeChangeSet'); - - $dispatcher->dispatch($this->audit, $this->em, 'on_flush', $uow); - } - - public function testPersistFallbackEmClosed(): void - { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService, null, false, true); - $this->transport->method('send')->willThrowException(new Exception('Transport error')); - - $this->em->method('isOpen')->willReturn(false); - $this->em->expects($this->never())->method('persist'); - - $dispatcher->dispatch($this->audit, $this->em, 'post_flush'); - } - - public function testPersistFallbackAlreadyContainsAudit(): void - { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService, null, false, true); - $this->transport->method('send')->willThrowException(new Exception('Transport error')); - - $this->em->method('isOpen')->willReturn(true); - $this->em->method('contains')->with($this->audit)->willReturn(true); - $this->em->expects($this->never())->method('persist'); - - $dispatcher->dispatch($this->audit, $this->em, 'post_flush'); - } - - public function testSupports(): void - { - $dispatcher = new AuditDispatcher($this->transport, $this->integrityService); - $this->transport->method('supports')->willReturn(true); - - self::assertTrue($dispatcher->supports('post_flush')); - } } diff --git a/tests/Unit/Service/AuditExporterTest.php b/tests/Unit/Service/AuditExporterTest.php new file mode 100644 index 0000000..ed48f80 --- /dev/null +++ b/tests/Unit/Service/AuditExporterTest.php @@ -0,0 +1,72 @@ +exporter = new AuditExporter(); + } + + public function testSanitizeCsvValue(): void + { + $log = new AuditLog( + entityClass: 'User', + entityId: '1', + action: 'update', + createdAt: new DateTimeImmutable('2024-01-01T12:00:00+00:00'), + oldValues: ['name' => '=1+1'], + newValues: ['name' => '+SUM(A1)'], + changedFields: ['name'], + userId: 'admin', + username: '-malicious', + ipAddress: '127.0.0.1', + userAgent: 'Mozilla' + ); + + $reflection = new ReflectionClass($log); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($log, Uuid::v4()); + + $audits = [$log]; + + $csv = $this->exporter->formatAsCsv($audits); + + // Header + 1 row + $lines = explode("\n", trim($csv)); + self::assertCount(2, $lines); + + $dataRow = $lines[1]; + + // Ensure string values are prefixed with ' + self::assertStringContainsString("'-malicious", $dataRow); + self::assertStringContainsString('127.0.0.1', $dataRow); + + // JSON values should be preserved but the trigger character is inside the JSON string + self::assertStringContainsString('"=1+1"', $dataRow); + self::assertStringContainsString('"+SUM(A1)"', $dataRow); + } + + public function testFormatAuditsJson(): void + { + $log = new AuditLog('User', '1', 'create', new DateTimeImmutable('2024-01-01 12:00:00')); + $json = $this->exporter->formatAudits([$log], 'json'); + + self::assertStringContainsString('"entity_class": "User"', $json); + } +} diff --git a/tests/Unit/Service/AuditIntegrityRevertTest.php b/tests/Unit/Service/AuditIntegrityRevertTest.php index 1c92348..a730e67 100644 --- a/tests/Unit/Service/AuditIntegrityRevertTest.php +++ b/tests/Unit/Service/AuditIntegrityRevertTest.php @@ -20,44 +20,42 @@ class AuditIntegrityRevertTest extends TestCase protected function setUp(): void { - $this->service = new AuditIntegrityService('secret', 'sha256', true); + $this->service = new AuditIntegrityService('secret', true, 'sha256'); } - public function testVerifySignatureAllowsRevertWithoutSignature(): void + public function testVerifySignatureRejectsRevertWithoutSignature(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_REVERT); - $log->setSignature(null); + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_REVERT); + $log->signature = null; - self::assertTrue($this->service->verifySignature($log)); + self::assertFalse($this->service->verifySignature($log)); } public function testVerifySignatureFailsForOtherActionsWithoutSignature(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setSignature(null); + $log = new AuditLog('App\Entity\User', '1', AuditLogInterface::ACTION_UPDATE); + $log->signature = null; self::assertFalse($this->service->verifySignature($log)); } public function testVerifySignatureWorksForRevertWithSignature(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_REVERT); - $log->setEntityClass('App\Entity\User'); - $log->setEntityId('1'); - $log->setOldValues(['name' => 'John']); - $log->setNewValues(null); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog( + 'App\Entity\User', + '1', + AuditLogInterface::ACTION_REVERT, + new DateTimeImmutable('2024-01-01 12:00:00'), + ['name' => 'John'] + ); $signature = $this->service->generateSignature($log); - $log->setSignature($signature); + $log->signature = $signature; self::assertTrue($this->service->verifySignature($log)); // Tamper it - $log->setEntityId('2'); + $log->entityId = '2'; self::assertFalse($this->service->verifySignature($log)); } } diff --git a/tests/Unit/Service/AuditIntegrityServiceTest.php b/tests/Unit/Service/AuditIntegrityServiceTest.php index b49a27c..0497fb6 100644 --- a/tests/Unit/Service/AuditIntegrityServiceTest.php +++ b/tests/Unit/Service/AuditIntegrityServiceTest.php @@ -8,8 +8,10 @@ use DateTimeZone; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; -use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditIntegrityService; +use ReflectionClass; +use RuntimeException; use function strlen; @@ -22,31 +24,32 @@ final class AuditIntegrityServiceTest extends TestCase protected function setUp(): void { - $this->service = new AuditIntegrityService($this->secret, 'sha256', true); + $this->service = new AuditIntegrityService($this->secret, true, 'sha256'); } public function testIsEnabled(): void { self::assertTrue($this->service->isEnabled()); - $disabledService = new AuditIntegrityService($this->secret, 'sha256', false); + $disabledService = new AuditIntegrityService($this->secret, false, 'sha256'); self::assertFalse($disabledService->isEnabled()); } public function testGenerateSignature(): void { - $log = self::createStub(AuditLogInterface::class); - $log->method('getEntityClass')->willReturn('App\Entity\User'); - $log->method('getEntityId')->willReturn('1'); - $log->method('getAction')->willReturn('update'); - $log->method('getOldValues')->willReturn(['name' => 'Old Name']); - $log->method('getNewValues')->willReturn(['name' => 'New Name']); - $log->method('getUserId')->willReturn('42'); - $log->method('getUsername')->willReturn('admin'); - $log->method('getIpAddress')->willReturn('127.0.0.1'); - $log->method('getUserAgent')->willReturn('Mozilla/5.0'); - $log->method('getTransactionHash')->willReturn('abc-123'); - $log->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); + $log = new AuditLog( + entityClass: 'App\Entity\User', + entityId: '1', + action: 'update', + createdAt: new DateTimeImmutable('2023-01-01 12:00:00'), + oldValues: ['name' => 'Old Name'], + newValues: ['name' => 'New Name'], + userId: '42', + username: 'admin', + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + transactionHash: 'abc-123' + ); $signature = $this->service->generateSignature($log); @@ -56,172 +59,132 @@ public function testGenerateSignature(): void public function testVerifySignatureSuccess(): void { - $log = self::createStub(AuditLogInterface::class); - $log->method('getEntityClass')->willReturn('App\Entity\User'); - $log->method('getEntityId')->willReturn('1'); - $log->method('getAction')->willReturn('update'); - $log->method('getOldValues')->willReturn(['name' => 'Old Name']); - $log->method('getNewValues')->willReturn(['name' => 'New Name']); - $log->method('getUserId')->willReturn('42'); - $log->method('getUsername')->willReturn('admin'); - $log->method('getIpAddress')->willReturn('127.0.0.1'); - $log->method('getUserAgent')->willReturn('Mozilla/5.0'); - $log->method('getTransactionHash')->willReturn('abc-123'); - $log->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); + $log = new AuditLog( + entityClass: 'App\Entity\User', + entityId: '1', + action: 'update', + createdAt: new DateTimeImmutable('2023-01-01 12:00:00'), + oldValues: ['name' => 'Old Name'], + newValues: ['name' => 'New Name'] + ); $signature = $this->service->generateSignature($log); - $log->method('getSignature')->willReturn($signature); + $log->signature = $signature; self::assertTrue($this->service->verifySignature($log)); } public function testVerifySignatureFailure(): void { - $log = self::createStub(AuditLogInterface::class); - $log->method('getEntityClass')->willReturn('App\Entity\User'); - $log->method('getEntityId')->willReturn('1'); - $log->method('getAction')->willReturn('update'); - $log->method('getOldValues')->willReturn(['name' => 'Old Name']); - $log->method('getNewValues')->willReturn(['name' => 'New Name']); - $log->method('getUserId')->willReturn('42'); - $log->method('getUsername')->willReturn('admin'); - $log->method('getIpAddress')->willReturn('127.0.0.1'); - $log->method('getUserAgent')->willReturn('Mozilla/5.0'); - $log->method('getTransactionHash')->willReturn('abc-123'); - $log->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); - - $log->method('getSignature')->willReturn('invalid-signature'); + $log = new AuditLog('App\Entity\User', '1', 'update'); + $log->signature = 'invalid-signature'; self::assertFalse($this->service->verifySignature($log)); } public function testVerifySignatureWithTamperedData(): void { - $log = self::createStub(AuditLogInterface::class); - $log->method('getEntityClass')->willReturn('App\Entity\User'); - $log->method('getEntityId')->willReturn('1'); - $log->method('getAction')->willReturn('update'); - $log->method('getOldValues')->willReturn(['name' => 'Old Name']); - $log->method('getNewValues')->willReturn(['name' => 'New Name']); - $log->method('getUserId')->willReturn('42'); - $log->method('getUsername')->willReturn('admin'); - $log->method('getIpAddress')->willReturn('127.0.0.1'); - $log->method('getUserAgent')->willReturn('Mozilla/5.0'); - $log->method('getTransactionHash')->willReturn('abc-123'); - $log->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); + $log = new AuditLog( + entityClass: 'App\Entity\User', + entityId: '1', + action: 'update', + createdAt: new DateTimeImmutable('2023-01-01 12:00:00'), + oldValues: ['name' => 'Old Name'], + newValues: ['name' => 'New Name'] + ); $signature = $this->service->generateSignature($log); - // Create a new stub with tampered data but same signature - $tamperedLog = self::createStub(AuditLogInterface::class); - $tamperedLog->method('getEntityClass')->willReturn('App\Entity\User'); - $tamperedLog->method('getEntityId')->willReturn('1'); - $tamperedLog->method('getAction')->willReturn('update'); - $tamperedLog->method('getOldValues')->willReturn(['name' => 'Old Name']); - $tamperedLog->method('getNewValues')->willReturn(['name' => 'TAMPERED Name']); // Tampered! - $tamperedLog->method('getUserId')->willReturn('42'); - $tamperedLog->method('getUsername')->willReturn('admin'); - $tamperedLog->method('getIpAddress')->willReturn('127.0.0.1'); - $tamperedLog->method('getUserAgent')->willReturn('Mozilla/5.0'); - $tamperedLog->method('getTransactionHash')->willReturn('abc-123'); - $tamperedLog->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); - $tamperedLog->method('getSignature')->willReturn($signature); + $tamperedLog = new AuditLog( + entityClass: 'App\Entity\User', + entityId: '1', + action: 'update', + createdAt: new DateTimeImmutable('2023-01-01 12:00:00'), + oldValues: ['name' => 'Old Name'], + newValues: ['name' => 'TAMPERED Name'] + ); + $tamperedLog->signature = $signature; self::assertFalse($this->service->verifySignature($tamperedLog)); } public function testVerifySignatureWithTamperedEntityClass(): void { - $log = self::createStub(AuditLogInterface::class); - $log->method('getEntityClass')->willReturn('App\Entity\User'); - $log->method('getEntityId')->willReturn('1'); - $log->method('getAction')->willReturn('update'); - $log->method('getOldValues')->willReturn(['name' => 'Old Name']); - $log->method('getNewValues')->willReturn(['name' => 'New Name']); - $log->method('getUserId')->willReturn('42'); - $log->method('getUsername')->willReturn('admin'); - $log->method('getIpAddress')->willReturn('127.0.0.1'); - $log->method('getUserAgent')->willReturn('Mozilla/5.0'); - $log->method('getTransactionHash')->willReturn('abc-123'); - $log->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); - + $log = new AuditLog('App\Entity\User', '1', 'update'); $signature = $this->service->generateSignature($log); - $tamperedLog = self::createStub(AuditLogInterface::class); - $tamperedLog->method('getEntityClass')->willReturn('App\Entity\Post'); // Tampered! - $tamperedLog->method('getEntityId')->willReturn('1'); - $tamperedLog->method('getAction')->willReturn('update'); - $tamperedLog->method('getOldValues')->willReturn(['name' => 'Old Name']); - $tamperedLog->method('getNewValues')->willReturn(['name' => 'New Name']); - $tamperedLog->method('getUserId')->willReturn('42'); - $tamperedLog->method('getUsername')->willReturn('admin'); - $tamperedLog->method('getIpAddress')->willReturn('127.0.0.1'); - $tamperedLog->method('getUserAgent')->willReturn('Mozilla/5.0'); - $tamperedLog->method('getTransactionHash')->willReturn('abc-123'); - $tamperedLog->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); - $tamperedLog->method('getSignature')->willReturn($signature); + $tamperedLog = new AuditLog('App\Entity\Post', '1', 'update'); + $tamperedLog->signature = $signature; self::assertFalse($this->service->verifySignature($tamperedLog)); } - public function testVerifySignatureWithTypeStability(): void + public function testVerifySignatureFailsOnTypeMismatch(): void { // Create a log with integer ID in values - $logInt = self::createStub(AuditLogInterface::class); - $logInt->method('getEntityClass')->willReturn('App\Entity\User'); - $logInt->method('getEntityId')->willReturn('1'); - $logInt->method('getAction')->willReturn('update'); - $logInt->method('getOldValues')->willReturn(['author_id' => 1]); // Integer ID - $logInt->method('getNewValues')->willReturn(['author_id' => 1]); - $logInt->method('getUserId')->willReturn('42'); - $logInt->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); + $logInt = new AuditLog( + 'App\Entity\User', + '1', + 'update', + new DateTimeImmutable('2023-01-01 12:00:00'), + ['author_id' => 1] + ); $signature = $this->service->generateSignature($logInt); // Create a log with string ID in values but same logical data - $logStr = self::createStub(AuditLogInterface::class); - $logStr->method('getEntityClass')->willReturn('App\Entity\User'); - $logStr->method('getEntityId')->willReturn('1'); - $logStr->method('getAction')->willReturn('update'); - $logStr->method('getOldValues')->willReturn(['author_id' => '1']); // String ID - $logStr->method('getNewValues')->willReturn(['author_id' => '1']); - $logStr->method('getUserId')->willReturn('42'); - $logStr->method('getCreatedAt')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); - $logStr->method('getSignature')->willReturn($signature); - - // Should pass despite type difference - self::assertTrue($this->service->verifySignature($logStr)); + $logStr = new AuditLog( + 'App\Entity\User', + '1', + 'update', + new DateTimeImmutable('2023-01-01 12:00:00'), + ['author_id' => '1'] + ); + $logStr->signature = $signature; + + // Should now FAIL because i:1 != s:1 + self::assertFalse($this->service->verifySignature($logStr)); + } + + public function testNormalizeDepthLimit(): 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); + + // Manual check of normalization behavior (internal) + $reflection = new ReflectionClass($this->service); + $method = $reflection->getMethod('normalizeValues'); + $method->setAccessible(true); + $result = $method->invoke($this->service, $deepArray); + + self::assertEquals('s:[max_depth]', $result['a']['b']['c']['d']['e']); } public function testVerifySignatureWithTimezoneStability(): void { // Create a log with UTC timezone - $logUtc = self::createStub(AuditLogInterface::class); - $logUtc->method('getEntityClass')->willReturn('App\Entity\User'); - $logUtc->method('getEntityId')->willReturn('1'); - $logUtc->method('getAction')->willReturn('update'); - $logUtc->method('getOldValues')->willReturn(['name' => 'Old']); - $logUtc->method('getNewValues')->willReturn(['name' => 'New']); - $logUtc->method('getUserId')->willReturn('42'); - $logUtc->method('getCreatedAt')->willReturn( - new DateTimeImmutable('2023-01-01 12:00:00', new DateTimeZone('UTC')) + $logUtc = new AuditLog( + 'App\Entity\User', + '1', + 'update', + new DateTimeImmutable('2023-01-01 12:00:00', new DateTimeZone('UTC')), + ['name' => 'Old'] ); $signature = $this->service->generateSignature($logUtc); // Create a log with different timezone but same point in time - $logIst = self::createStub(AuditLogInterface::class); - $logIst->method('getEntityClass')->willReturn('App\Entity\User'); - $logIst->method('getEntityId')->willReturn('1'); - $logIst->method('getAction')->willReturn('update'); - $logIst->method('getOldValues')->willReturn(['name' => 'Old']); - $logIst->method('getNewValues')->willReturn(['name' => 'New']); - $logIst->method('getUserId')->willReturn('42'); - // 2023-01-01 12:00:00 UTC is 2023-01-01 17:30:00 IST - $logIst->method('getCreatedAt')->willReturn( - new DateTimeImmutable('2023-01-01 17:30:00', new DateTimeZone('Asia/Kolkata')) + $logIst = new AuditLog( + 'App\Entity\User', + '1', + 'update', + new DateTimeImmutable('2023-01-01 17:30:00', new DateTimeZone('Asia/Kolkata')), + ['name' => 'Old'] ); - $logIst->method('getSignature')->willReturn($signature); + $logIst->signature = $signature; // Should pass because we normalize to UTC before hashing self::assertTrue($this->service->verifySignature($logIst)); @@ -230,40 +193,100 @@ public function testVerifySignatureWithTimezoneStability(): void public function testVerifySignatureWithDateArrayStability(): void { // Log with ATOM string date (new format) - $logAtom = self::createStub(AuditLogInterface::class); - $logAtom->method('getEntityClass')->willReturn('App\Entity\Post'); - $logAtom->method('getEntityId')->willReturn('92'); - $logAtom->method('getAction')->willReturn('update'); - $logAtom->method('getOldValues')->willReturn(['createdAt' => '2026-01-22T08:04:32+00:00']); - $logAtom->method('getNewValues')->willReturn(['createdAt' => '2025-12-22T02:01:21+00:00']); - $logAtom->method('getUserId')->willReturn('1'); - $logAtom->method('getCreatedAt')->willReturn(new DateTimeImmutable('2026-01-22 08:05:06')); + $logAtom = new AuditLog( + 'App\Entity\Post', + '92', + 'update', + new DateTimeImmutable('2026-01-22 08:05:06'), + ['createdAt' => '2026-01-22T08:04:32+00:00'] + ); $signature = $this->service->generateSignature($logAtom); // Log with array-represented date (old format with UTC timezone) - $logArrayUtc = self::createStub(AuditLogInterface::class); - $logArrayUtc->method('getEntityClass')->willReturn('App\Entity\Post'); - $logArrayUtc->method('getEntityId')->willReturn('92'); - $logArrayUtc->method('getAction')->willReturn('update'); - $logArrayUtc->method('getOldValues')->willReturn([ - 'createdAt' => [ - 'date' => '2026-01-22 08:04:32.000000', - 'timezone' => 'UTC', - 'timezone_type' => 3, - ], - ]); - $logArrayUtc->method('getNewValues')->willReturn([ - 'createdAt' => [ - 'date' => '2025-12-22 02:01:21.424000', - 'timezone' => 'Z', - 'timezone_type' => 2, - ], - ]); - $logArrayUtc->method('getUserId')->willReturn('1'); - $logArrayUtc->method('getCreatedAt')->willReturn(new DateTimeImmutable('2026-01-22 08:05:06')); - $logArrayUtc->method('getSignature')->willReturn($signature); + $logArrayUtc = new AuditLog( + 'App\Entity\Post', + '92', + 'update', + new DateTimeImmutable('2026-01-22 08:05:06'), + [ + 'createdAt' => [ + 'date' => '2026-01-22 08:04:32.000000', + 'timezone' => 'UTC', + 'timezone_type' => 3, + ], + ] + ); + $logArrayUtc->signature = $signature; self::assertTrue($this->service->verifySignature($logArrayUtc)); } + + public function testSignPayload(): void + { + $signature = $this->service->signPayload('test'); + self::assertNotEmpty($signature); + + $disabledService = new AuditIntegrityService(null, true, 'sha256'); + $this->expectException(RuntimeException::class); + $disabledService->signPayload('test'); + } + + public function testGenerateSignatureNoSecret(): void + { + $disabledService = new AuditIntegrityService(null, true, 'sha256'); + $this->expectException(RuntimeException::class); + $disabledService->generateSignature(new AuditLog('a', '1', 'create')); + } + + public function testNormalizePrimitives(): void + { + $log = new AuditLog( + 'App\Entity\User', + '1', + 'update', + new DateTimeImmutable(), + [ + 'null_val' => null, + 'bool_true' => true, + 'bool_false' => false, + 'int_val' => 42, + 'float_val' => 3.14, + 'normal_str' => 'text', + 'date_str' => '2023-01-01 12:00:00', + ] + ); + $signature = $this->service->generateSignature($log); + self::assertNotEmpty($signature); + } + + public function testNormalizeValuesDepthLimitReached(): void + { + $log = new AuditLog('Test', '1', 'update'); + $reflectionLog = new ReflectionClass($log); + $property = $reflectionLog->getProperty('oldValues'); + $property->setAccessible(true); + $property->setValue($log, ['a' => ['b' => ['c' => ['d' => ['e' => ['f' => 'g']]]]]]); + + $reflection = new ReflectionClass($this->service); + $method = $reflection->getMethod('normalizeValues'); + $method->setAccessible(true); + + $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']); + + // Depth 0 + $result = $method->invoke($this->service, $log->oldValues, 4); + self::assertEquals(['a' => 's:[max_depth]'], $result); + } + + public function testNormalizeValuesMaxDepth(): void + { + $reflection = new ReflectionClass($this->service); + $method = $reflection->getMethod('normalizeValues'); + $method->setAccessible(true); + $result = $method->invoke($this->service, ['a' => 'b'], 5); // 5 is max depth + self::assertEquals(['_error' => 'max_depth_reached'], $result); + } } diff --git a/tests/Unit/Service/AuditRendererTest.php b/tests/Unit/Service/AuditRendererTest.php new file mode 100644 index 0000000..d785af1 --- /dev/null +++ b/tests/Unit/Service/AuditRendererTest.php @@ -0,0 +1,117 @@ +renderer = new AuditRenderer(); + } + + public function testTruncateAndStripAnsi(): 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); + + self::assertEquals('Red Content', $result); + self::assertStringNotContainsString("\x1b", $result); + } + + public function testTruncateLongString(): void + { + $longString = str_repeat('a', 60); + $reflection = new ReflectionClass($this->renderer); + $method = $reflection->getMethod('truncateString'); + $method->setAccessible(true); + + $result = $method->invoke($this->renderer, $longString); + + self::assertEquals(50, strlen($result)); + self::assertStringEndsWith('...', $result); + } + + public function testRenderTable(): void + { + $output = new BufferedOutput(); + $log = new AuditLog('App\\Entity\\User', '1', 'create'); + + $this->renderer->renderTable($output, [$log], false); + $content = $output->fetch(); + + self::assertStringContainsString('User', $content); + self::assertStringContainsString('create', $content); + } + + public function testRenderTableWithDetails(): void + { + $output = new BufferedOutput(); + $log = new AuditLog( + entityClass: 'App\Entity\User', + entityId: '1', + action: 'update', + oldValues: ['name' => 'Old Name'], + newValues: ['name' => 'New Name'] + ); + + $this->renderer->renderTable($output, [$log], true); // showDetails = true + $content = $output->fetch(); + + self::assertStringContainsString('Old Name → New Name', $content); + self::assertStringContainsString('update', $content); + } + + public function testFormatChangedDetailsEmpty(): void + { + $log = new AuditLog('App\Entity\User', '1', 'create'); + self::assertEquals('-', $this->renderer->formatChangedDetails($log)); + } + + public function testFormatChangedDetailsWithArray(): void + { + $log = new AuditLog( + entityClass: 'App\Entity\User', + entityId: '1', + action: 'create', + newValues: ['roles' => ['ROLE_USER', 'ROLE_ADMIN']] + ); + $details = $this->renderer->formatChangedDetails($log); + self::assertStringContainsString('ROLE_USER', $details); + self::assertStringContainsString('ROLE_ADMIN', $details); + } + + public function testFormatValueEdgeCases(): void + { + self::assertEquals('true', $this->renderer->formatValue(true)); + self::assertEquals('false', $this->renderer->formatValue(false)); + self::assertEquals('null', $this->renderer->formatValue(null)); + self::assertEquals('[]', $this->renderer->formatValue([])); // Empty array + + $obj = new stdClass(); + self::assertStringContainsString('stdClass', $this->renderer->formatValue($obj)); + } + + public function testShortenHash(): void + { + self::assertEquals('-', $this->renderer->shortenHash(null)); + self::assertEquals('-', $this->renderer->shortenHash('')); + self::assertEquals('12345678', $this->renderer->shortenHash('1234567890abcdef')); + } +} diff --git a/tests/Unit/Service/AuditRevertIntegrityTest.php b/tests/Unit/Service/AuditRevertIntegrityTest.php index 6c3938e..7d86c18 100644 --- a/tests/Unit/Service/AuditRevertIntegrityTest.php +++ b/tests/Unit/Service/AuditRevertIntegrityTest.php @@ -5,17 +5,19 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Service; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; 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\SoftDeleteHandlerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditReverter; -use Rcsofttech\AuditTrailBundle\Service\AuditService; use Rcsofttech\AuditTrailBundle\Service\RevertValueDenormalizer; -use Rcsofttech\AuditTrailBundle\Service\SoftDeleteHandler; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\DummyEntity; use RuntimeException; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -28,7 +30,7 @@ class AuditRevertIntegrityTest extends TestCase private AuditIntegrityServiceInterface&MockObject $integrityService; - private SoftDeleteHandler&MockObject $softDeleteHandler; + private SoftDeleteHandlerInterface&MockObject $softDeleteHandler; private AuditReverter $reverter; @@ -36,26 +38,24 @@ protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); - $this->softDeleteHandler = $this->createMock(SoftDeleteHandler::class); + $this->softDeleteHandler = $this->createMock(SoftDeleteHandlerInterface::class); $this->softDeleteHandler->method('disableSoftDeleteFilters')->willReturn([]); $this->reverter = new AuditReverter( $this->em, $this->createMock(ValidatorInterface::class), - $this->createMock(AuditService::class), + $this->createMock(AuditServiceInterface::class), new RevertValueDenormalizer($this->em), $this->softDeleteHandler, - $this->integrityService + $this->integrityService, + $this->createMock(AuditDispatcherInterface::class), ); } public function testRevertFailsIfTampered(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setEntityClass(DummyEntity::class); - $log->setEntityId('1'); + $log = new AuditLog(entityClass: DummyEntity::class, entityId: '1', action: AuditLogInterface::ACTION_UPDATE); $this->integrityService->method('isEnabled')->willReturn(true); $this->integrityService->method('verifySignature')->with($log)->willReturn(false); @@ -68,17 +68,18 @@ public function testRevertFailsIfTampered(): void public function testRevertSucceedsIfAuthentic(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setEntityClass(DummyEntity::class); - $log->setEntityId('1'); - $log->setOldValues(['name' => 'John']); + $log = new AuditLog( + entityClass: DummyEntity::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['name' => 'John'] + ); $this->integrityService->method('isEnabled')->willReturn(true); $this->integrityService->method('verifySignature')->with($log)->willReturn(true); $this->em->method('find')->willReturn(new DummyEntity()); - $meta = $this->createMock(\Doctrine\ORM\Mapping\ClassMetadata::class); + $meta = $this->createMock(ClassMetadata::class); $meta->method('hasField')->willReturn(true); $meta->method('getFieldValue')->willReturn('different'); $this->em->method('getClassMetadata')->willReturn($meta); @@ -89,17 +90,18 @@ public function testRevertSucceedsIfAuthentic(): void public function testRevertSucceedsIfIntegrityDisabled(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setEntityClass(DummyEntity::class); - $log->setEntityId('1'); - $log->setOldValues(['name' => 'John']); + $log = new AuditLog( + entityClass: DummyEntity::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['name' => 'John'] + ); $this->integrityService->method('isEnabled')->willReturn(false); $this->integrityService->expects($this->never())->method('verifySignature'); $this->em->method('find')->willReturn(new DummyEntity()); - $meta = $this->createMock(\Doctrine\ORM\Mapping\ClassMetadata::class); + $meta = $this->createMock(ClassMetadata::class); $meta->method('hasField')->willReturn(true); $meta->method('getFieldValue')->willReturn('different'); $this->em->method('getClassMetadata')->willReturn($meta); @@ -110,10 +112,7 @@ public function testRevertSucceedsIfIntegrityDisabled(): void public function testRevertSucceedsForRevertActionWithoutSignature(): void { - $log = new AuditLog(); - $log->setAction(AuditLogInterface::ACTION_REVERT); - $log->setEntityClass(DummyEntity::class); - $log->setEntityId('1'); + $log = new AuditLog(entityClass: DummyEntity::class, entityId: '1', action: AuditLogInterface::ACTION_REVERT); $this->integrityService->method('isEnabled')->willReturn(true); $this->integrityService->method('verifySignature')->with($log)->willReturn(true); diff --git a/tests/Unit/Service/AuditReverterTest.php b/tests/Unit/Service/AuditReverterTest.php index d189088..5a5e94f 100644 --- a/tests/Unit/Service/AuditReverterTest.php +++ b/tests/Unit/Service/AuditReverterTest.php @@ -11,20 +11,24 @@ 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\SoftDeleteHandlerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\AuditReverter; -use Rcsofttech\AuditTrailBundle\Service\AuditService; use Rcsofttech\AuditTrailBundle\Service\RevertValueDenormalizer; -use Rcsofttech\AuditTrailBundle\Service\SoftDeleteHandler; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\DummySoftDeleteableFilter; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\RevertTestUser; +use ReflectionClass; use RuntimeException; +use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\ConstraintViolation; 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'); @@ -37,24 +41,27 @@ class AuditReverterTest extends TestCase private ValidatorInterface&MockObject $validator; - private AuditService&MockObject $auditService; + private AuditServiceInterface&MockObject $auditService; private FilterCollection&MockObject $filterCollection; - private SoftDeleteHandler&MockObject $softDeleteHandler; + private SoftDeleteHandlerInterface&MockObject $softDeleteHandler; private AuditIntegrityServiceInterface&MockObject $integrityService; + private AuditDispatcherInterface&MockObject $dispatcher; + private AuditReverter $reverter; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); $this->validator = $this->createMock(ValidatorInterface::class); - $this->auditService = $this->createMock(AuditService::class); + $this->auditService = $this->createMock(AuditServiceInterface::class); $this->filterCollection = $this->createMock(FilterCollection::class); - $this->softDeleteHandler = $this->createMock(SoftDeleteHandler::class); + $this->softDeleteHandler = $this->createMock(SoftDeleteHandlerInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); + $this->dispatcher = $this->createMock(AuditDispatcherInterface::class); $this->em->method('getFilters')->willReturn($this->filterCollection); @@ -64,15 +71,14 @@ protected function setUp(): void $this->auditService, new RevertValueDenormalizer($this->em), $this->softDeleteHandler, - $this->integrityService + $this->integrityService, + $this->dispatcher ); } public function testRevertEntityNotFound(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_UPDATE); $this->filterCollection->method('getEnabledFilters')->willReturn([]); $this->em->method('find')->willReturn(null); @@ -85,11 +91,12 @@ public function testRevertEntityNotFound(): void public function testRevertDryRun(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setOldValues(['name' => 'Old']); + $log = new AuditLog( + entityClass: RevertTestUser::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['name' => 'Old'] + ); $entity = new RevertTestUser(); $this->filterCollection->method('getEnabledFilters')->willReturn([]); @@ -110,10 +117,7 @@ public function testRevertDryRun(): void public function testRevertUnsupportedAction(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_DELETE); + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_DELETE); $this->filterCollection->method('getEnabledFilters')->willReturn([]); $this->em->method('find')->willReturn(new RevertTestUser()); @@ -126,11 +130,12 @@ public function testRevertUnsupportedAction(): void public function testRevertUpdateNoOldValues(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setOldValues([]); + $log = new AuditLog( + entityClass: RevertTestUser::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: [] + ); $this->filterCollection->method('getEnabledFilters')->willReturn([]); $this->em->method('find')->willReturn(new RevertTestUser()); @@ -143,10 +148,7 @@ public function testRevertUpdateNoOldValues(): void public function testRevertSoftDeleteNotDeleted(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_SOFT_DELETE); + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_SOFT_DELETE); $entity = new RevertTestUser(); $this->filterCollection->method('getEnabledFilters')->willReturn([]); @@ -156,8 +158,9 @@ public function testRevertSoftDeleteNotDeleted(): void $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); - $revertLog = new AuditLog(); + $revertLog = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT); $this->auditService->expects($this->once())->method('createAuditLog')->willReturn($revertLog); + $this->dispatcher->expects($this->once())->method('dispatch')->with($revertLog, $this->em, 'post_flush'); $this->validator->method('validate')->willReturn(new ConstraintViolationList()); $changes = $this->reverter->revert($log); @@ -166,11 +169,12 @@ public function testRevertSoftDeleteNotDeleted(): void public function testValidationFailure(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setOldValues(['name' => 'Old']); + $log = new AuditLog( + entityClass: RevertTestUser::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['name' => 'Old'] + ); $entity = new RevertTestUser(); $this->filterCollection->method('getEnabledFilters')->willReturn([]); @@ -192,11 +196,12 @@ public function testValidationFailure(): void public function testApplyChangesSkipping(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_UPDATE); - $log->setOldValues(['id' => 1, 'unchanged' => 'val', 'changed' => 'old']); + $log = new AuditLog( + entityClass: RevertTestUser::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['id' => 1, 'unchanged' => 'val', 'changed' => 'old'] + ); $entity = new RevertTestUser(); $this->filterCollection->method('getEnabledFilters')->willReturn([]); @@ -223,30 +228,27 @@ public function testApplyChangesSkipping(): void $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); $this->validator->method('validate')->willReturn(new ConstraintViolationList()); - $revertLog = new AuditLog(); - $this->auditService->method('createAuditLog')->willReturn($revertLog); + $revertLog = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT, oldValues: ['changed' => 'old']); + $this->auditService->expects($this->once()) + ->method('createAuditLog') + ->with($entity, AuditLogInterface::ACTION_REVERT, ['changed' => 'old'], null) + ->willReturn($revertLog); - $this->em->expects($this->exactly(2))->method('persist'); // Entity and RevertLog - $this->em->expects($this->exactly(2))->method('flush'); + $this->dispatcher->expects($this->once())->method('dispatch')->with($revertLog, $this->em, 'post_flush'); + + $this->em->expects($this->once())->method('persist'); // Only Entity (RevertLog is dispatched) + $this->em->expects($this->once())->method('flush'); $changes = $this->reverter->revert($log); self::assertEquals(['changed' => 'old'], $changes); self::assertArrayNotHasKey('id', $changes); self::assertArrayNotHasKey('unchanged', $changes); - - // Verify revert log content - self::assertEquals(['changed' => 'old'], $revertLog->getOldValues()); - self::assertEquals(RevertTestUser::class, $revertLog->getEntityClass()); - self::assertEquals('1', $revertLog->getEntityId()); } public function testRevertCreateSuccess(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_CREATE); + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_CREATE); $entity = new RevertTestUser(); $this->filterCollection->method('getEnabledFilters')->willReturn([]); @@ -255,7 +257,7 @@ 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()); + $this->auditService->method('createAuditLog')->willReturn(new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT)); $changes = $this->reverter->revert($log, false, true); self::assertEquals(['action' => 'delete'], $changes); @@ -263,10 +265,7 @@ public function testRevertCreateSuccess(): void public function testRevertSoftDeleteSuccess(): void { - $log = new AuditLog(); - $log->setEntityClass(RevertTestUser::class); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_SOFT_DELETE); + $log = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_SOFT_DELETE); $entity = new RevertTestUser(); $entity->setDeletedAt(new DateTime()); @@ -279,7 +278,7 @@ public function testRevertSoftDeleteSuccess(): void $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); $this->validator->method('validate')->willReturn(new ConstraintViolationList()); - $this->auditService->method('createAuditLog')->willReturn(new AuditLog()); + $this->auditService->method('createAuditLog')->willReturn(new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT)); $changes = $this->reverter->revert($log); self::assertEquals(['action' => 'restore'], $changes); @@ -287,12 +286,14 @@ public function testRevertSoftDeleteSuccess(): void public function testRevertWithCustomContext(): void { - $log = $this->createMock(AuditLogInterface::class); - $log->method('getEntityClass')->willReturn(RevertTestUser::class); - $log->method('getEntityId')->willReturn('1'); - $log->method('getAction')->willReturn(AuditLogInterface::ACTION_UPDATE); - $log->method('getOldValues')->willReturn(['name' => 'Old']); - $log->method('getId')->willReturn(123); + $id = Uuid::v4(); + $log = new AuditLog( + entityClass: RevertTestUser::class, + entityId: '1', + action: AuditLogInterface::ACTION_UPDATE, + oldValues: ['name' => 'Old'] + ); + $this->setLogId($log, $id); $entity = new RevertTestUser(); $this->filterCollection->method('getEnabledFilters')->willReturn([]); @@ -305,7 +306,7 @@ public function testRevertWithCustomContext(): void $this->em->method('wrapInTransaction')->willReturnCallback(static fn ($c) => $c()); $this->validator->method('validate')->willReturn(new ConstraintViolationList()); - $revertLog = new AuditLog(); + $revertLog = new AuditLog(RevertTestUser::class, '1', AuditLogInterface::ACTION_REVERT); $this->auditService->expects($this->once()) ->method('createAuditLog') ->with( @@ -313,10 +314,20 @@ public function testRevertWithCustomContext(): void AuditLogInterface::ACTION_REVERT, ['name' => 'Old'], null, - ['custom_key' => 'custom_val', 'reverted_log_id' => 123] + ['custom_key' => 'custom_val', 'reverted_log_id' => $id->toRfc4122()] ) ->willReturn($revertLog); + $this->dispatcher->expects($this->once())->method('dispatch')->with($revertLog, $this->em, 'post_flush'); + $this->reverter->revert($log, false, false, ['custom_key' => 'custom_val']); } + + private function setLogId(AuditLog $log, Uuid $id): void + { + $reflection = new ReflectionClass($log); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($log, $id); + } } diff --git a/tests/Unit/Service/AuditServiceTest.php b/tests/Unit/Service/AuditServiceTest.php index 648ecde..a0dcc74 100644 --- a/tests/Unit/Service/AuditServiceTest.php +++ b/tests/Unit/Service/AuditServiceTest.php @@ -6,21 +6,19 @@ use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; 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\Attribute\Auditable; -use Rcsofttech\AuditTrailBundle\Contract\AuditContextContributorInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditVoterInterface; -use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\ContextResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Service\AuditService; use Rcsofttech\AuditTrailBundle\Service\EntityDataExtractor; -use Rcsofttech\AuditTrailBundle\Service\MetadataCache; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; use stdClass; @@ -29,8 +27,6 @@ class AuditServiceTest extends TestCase { private EntityManagerInterface&MockObject $entityManager; - private UserResolverInterface&MockObject $userResolver; - private ClockInterface&MockObject $clock; private LoggerInterface&MockObject $logger; @@ -39,32 +35,36 @@ class AuditServiceTest extends TestCase private EntityDataExtractor&MockObject $dataExtractor; - private MetadataCache&MockObject $metadataCache; + private AuditMetadataManagerInterface&MockObject $metadataManager; + + private ContextResolverInterface&MockObject $contextResolver; + + private EntityIdResolverInterface&MockObject $idResolver; private AuditService $service; protected function setUp(): void { $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->userResolver = $this->createMock(UserResolverInterface::class); $this->clock = $this->createMock(ClockInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->transactionIdGenerator = $this->createMock(TransactionIdGenerator::class); $this->dataExtractor = $this->createMock(EntityDataExtractor::class); - $this->metadataCache = $this->createMock(MetadataCache::class); + $this->metadataManager = $this->createMock(AuditMetadataManagerInterface::class); + $this->contextResolver = $this->createMock(ContextResolverInterface::class); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); $this->transactionIdGenerator->method('getTransactionId')->willReturn('tx1'); $this->clock->method('now')->willReturn(new DateTimeImmutable('2023-01-01 12:00:00')); $this->service = new AuditService( $this->entityManager, - $this->userResolver, $this->clock, $this->transactionIdGenerator, $this->dataExtractor, - $this->metadataCache, - ['IgnoredEntity'], - [], // ignoredProperties + $this->metadataManager, + $this->contextResolver, + $this->idResolver, $this->logger, 'UTC', [] @@ -73,20 +73,19 @@ protected function setUp(): void public function testShouldAudit(): void { - $this->metadataCache->method('getAuditableAttribute')->willReturn(new Auditable(enabled: true)); + $this->metadataManager->method('isEntityIgnored')->willReturn(false); self::assertTrue($this->service->shouldAudit(new stdClass())); - $this->metadataCache = $this->createMock(MetadataCache::class); - $this->metadataCache->method('getAuditableAttribute')->willReturn(null); + $this->metadataManager = $this->createMock(AuditMetadataManagerInterface::class); + $this->metadataManager->method('isEntityIgnored')->willReturn(true); $service = new AuditService( $this->entityManager, - $this->userResolver, $this->clock, $this->transactionIdGenerator, $this->dataExtractor, - $this->metadataCache, - [], - [], // ignoredProperties + $this->metadataManager, + $this->contextResolver, + $this->idResolver, null, 'UTC', [] @@ -96,7 +95,7 @@ public function testShouldAudit(): void public function testShouldAuditWithVoters(): void { - $this->metadataCache->method('getAuditableAttribute')->willReturn(new Auditable(enabled: true)); + $this->metadataManager->method('isEntityIgnored')->willReturn(false); $voter1 = $this->createMock(AuditVoterInterface::class); $voter1->method('vote')->willReturn(true); @@ -106,13 +105,12 @@ public function testShouldAuditWithVoters(): void $service = new AuditService( $this->entityManager, - $this->userResolver, $this->clock, $this->transactionIdGenerator, $this->dataExtractor, - $this->metadataCache, - [], - [], // ignoredProperties + $this->metadataManager, + $this->contextResolver, + $this->idResolver, null, 'UTC', [$voter1, $voter2] @@ -123,17 +121,8 @@ public function testShouldAuditWithVoters(): void public function testShouldAuditIgnored(): void { - $service = new AuditService( - $this->entityManager, - $this->userResolver, - $this->clock, - $this->transactionIdGenerator, - $this->dataExtractor, - $this->metadataCache, - [stdClass::class], - [] // ignoredProperties - ); - self::assertFalse($service->shouldAudit(new stdClass())); + $this->metadataManager->method('isEntityIgnored')->willReturn(true); + self::assertFalse($this->service->shouldAudit(new stdClass())); } public function testGetEntityData(): void @@ -146,100 +135,110 @@ public function testGetEntityData(): void public function testCreateAuditLog(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn(['id' => 1]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); - $this->userResolver->method('getUserId')->willReturn('1'); + $this->contextResolver->method('resolve')->willReturn([ + 'userId' => '1', + 'username' => 'user1', + 'ipAddress' => '127.0.0.1', + 'userAgent' => 'Agent', + 'context' => ['foo' => 'bar'], + ]); $log = $this->service->createAuditLog($entity, AuditLogInterface::ACTION_UPDATE, ['a' => 1], ['a' => 2]); - self::assertEquals(1, $log->getEntityId()); - self::assertEquals(['a'], $log->getChangedFields()); - self::assertEquals(1, $log->getUserId()); + self::assertEquals('1', $log->entityId); + self::assertEquals(['a'], $log->changedFields); + self::assertEquals('1', $log->userId); + self::assertEquals(['foo' => 'bar'], $log->context); } public function testCreateAuditLogWithContext(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn(['id' => 1]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); - - $this->userResolver->method('getUserId')->willReturn('1'); - $this->userResolver->method('getImpersonatorId')->willReturn('99'); - $this->userResolver->method('getImpersonatorUsername')->willReturn('admin'); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); + + $this->contextResolver->method('resolve')->willReturn([ + 'userId' => '1', + 'username' => 'user1', + 'ipAddress' => '127.0.0.1', + 'userAgent' => 'Agent', + 'context' => [ + 'foo' => 'bar', + 'impersonation' => [ + 'impersonator_id' => '99', + 'impersonator_username' => 'admin', + ], + ], + ]); $log = $this->service->createAuditLog($entity, AuditLogInterface::ACTION_UPDATE, ['a' => 1], ['a' => 2]); - $context = $log->getContext(); + $context = $log->context; self::assertArrayHasKey('impersonation', $context); - self::assertEquals(99, $context['impersonation']['impersonator_id']); + self::assertEquals('99', $context['impersonation']['impersonator_id']); self::assertEquals('admin', $context['impersonation']['impersonator_username']); } public function testCreateAuditLogWithContributors(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn(['id' => 1]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); - - $contributor = $this->createMock(AuditContextContributorInterface::class); - $contributor->expects($this->once()) - ->method('contribute') - ->with($entity, AuditLogInterface::ACTION_UPDATE, ['a' => 2]) - ->willReturn(['custom_key' => 'custom_value']); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); - $service = new AuditService( - $this->entityManager, - $this->userResolver, - $this->clock, - $this->transactionIdGenerator, - $this->dataExtractor, - $this->metadataCache, - [], - [], // ignoredProperties - null, - 'UTC', - [], - [$contributor] - ); + $this->contextResolver->method('resolve')->willReturn([ + 'userId' => '1', + 'username' => 'user1', + 'ipAddress' => '127.0.0.1', + 'userAgent' => 'Agent', + 'context' => ['custom_info' => 'custom_value'], + ]); - $log = $service->createAuditLog($entity, AuditLogInterface::ACTION_UPDATE, ['a' => 1], ['a' => 2]); + $log = $this->service->createAuditLog($entity, AuditLogInterface::ACTION_UPDATE, ['a' => 1], ['a' => 2]); - $context = $log->getContext(); - self::assertArrayHasKey('custom_key', $context); - self::assertEquals('custom_value', $context['custom_key']); + $context = $log->context; + self::assertArrayHasKey('custom_info', $context); + self::assertEquals('custom_value', $context['custom_info']); } public function testCreateAuditLogWithCustomContext(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn(['id' => 1]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); + + $this->contextResolver->method('resolve')->willReturn([ + 'userId' => '1', + 'username' => 'user1', + 'ipAddress' => '127.0.0.1', + 'userAgent' => 'Agent', + 'context' => ['manual_info' => 'manual_value'], + ]); $log = $this->service->createAuditLog( $entity, AuditLogInterface::ACTION_UPDATE, ['a' => 1], ['a' => 2], - ['manual_key' => 'manual_value'] + ['manual_info' => 'manual_value'] ); - $context = $log->getContext(); - self::assertArrayHasKey('manual_key', $context); - self::assertEquals('manual_value', $context['manual_key']); + $context = $log->context; + self::assertArrayHasKey('manual_info', $context); + self::assertEquals('manual_value', $context['manual_info']); } public function testCreateAuditLogPendingIdDelete(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn([]); // No ID yet (simulated) - $metadata->method('getIdentifierFieldNames')->willReturn(['id']); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn(AuditLogInterface::PENDING_ID); + $this->idResolver->method('resolveFromValues')->willReturn('123'); + + $this->contextResolver->method('resolve')->willReturn([ + 'userId' => '1', + 'username' => 'user1', + 'ipAddress' => '127.0.0.1', + 'userAgent' => 'Agent', + 'context' => [], + ]); $log = $this->service->createAuditLog( $entity, @@ -248,16 +247,22 @@ public function testCreateAuditLogPendingIdDelete(): void null ); - self::assertEquals('123', $log->getEntityId()); + self::assertEquals('123', $log->entityId); } public function testCreateAuditLogPendingIdDeleteComposite(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn([]); - $metadata->method('getIdentifierFieldNames')->willReturn(['id1', 'id2']); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn(AuditLogInterface::PENDING_ID); + $this->idResolver->method('resolveFromValues')->willReturn('["1","2"]'); + + $this->contextResolver->method('resolve')->willReturn([ + 'userId' => '1', + 'username' => 'user1', + 'ipAddress' => '127.0.0.1', + 'userAgent' => 'Agent', + 'context' => [], + ]); $log = $this->service->createAuditLog( $entity, @@ -266,25 +271,24 @@ public function testCreateAuditLogPendingIdDeleteComposite(): void null ); - self::assertEquals('["1","2"]', $log->getEntityId()); + self::assertEquals('["1","2"]', $log->entityId); } - public function testEnrichUserContextException(): void + public function testEnrichUserContextExceptionEmittedWhenResolverFails(): void { $entity = new stdClass(); - $metadata = $this->createMock(ClassMetadata::class); - $metadata->method('getIdentifierValues')->willReturn(['id' => 1]); - $this->entityManager->method('getClassMetadata')->willReturn($metadata); + $this->idResolver->method('resolveFromEntity')->willReturn('1'); - $this->userResolver->method('getUserId')->willThrowException(new Exception('User error')); - $this->logger->expects($this->once())->method('error'); + $this->contextResolver->method('resolve')->willThrowException(new Exception('User error')); + // AuditService should catch and log errors from context resolver + $this->logger->expects($this->once())->method('warning'); $this->service->createAuditLog($entity, 'create'); } public function testGetSensitiveFields(): void { - $this->metadataCache->method('getSensitiveFields')->willReturn(['field' => 'mask']); + $this->metadataManager->method('getSensitiveFields')->willReturn(['field' => 'mask']); self::assertEquals(['field' => 'mask'], $this->service->getSensitiveFields(new stdClass())); } @@ -302,13 +306,12 @@ public function testPassesVotersWithApprovingVoter(): void $service = new AuditService( $this->entityManager, - $this->userResolver, $this->clock, $this->transactionIdGenerator, $this->dataExtractor, - $this->metadataCache, - [], - [], + $this->metadataManager, + $this->contextResolver, + $this->idResolver, null, 'UTC', [$voter] @@ -324,13 +327,12 @@ public function testPassesVotersWithVetoingVoter(): void $service = new AuditService( $this->entityManager, - $this->userResolver, $this->clock, $this->transactionIdGenerator, $this->dataExtractor, - $this->metadataCache, - [], - [], + $this->metadataManager, + $this->contextResolver, + $this->idResolver, null, 'UTC', [$voter] diff --git a/tests/Unit/Service/AuditServiceTimezoneTest.php b/tests/Unit/Service/AuditServiceTimezoneTest.php index 6157f0d..d9668c5 100644 --- a/tests/Unit/Service/AuditServiceTimezoneTest.php +++ b/tests/Unit/Service/AuditServiceTimezoneTest.php @@ -13,10 +13,12 @@ use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; +use Rcsofttech\AuditTrailBundle\Contract\ContextResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface; use Rcsofttech\AuditTrailBundle\Service\AuditService; use Rcsofttech\AuditTrailBundle\Service\EntityDataExtractor; -use Rcsofttech\AuditTrailBundle\Service\MetadataCache; use Rcsofttech\AuditTrailBundle\Service\TransactionIdGenerator; #[AllowMockObjectsWithoutExpectations] @@ -26,23 +28,34 @@ final class AuditServiceTimezoneTest extends TestCase public function testCreateAuditLogWithCustomTimezone(): void { $entityManager = self::createStub(EntityManagerInterface::class); - $userResolver = self::createStub(UserResolverInterface::class); $clock = self::createStub(ClockInterface::class); // Mock current time as UTC $now = new DateTimeImmutable('2023-01-01 12:00:00', new DateTimeZone('UTC')); $clock->method('now')->willReturn($now); + $contextResolver = self::createStub(ContextResolverInterface::class); + $contextResolver->method('resolve')->willReturn([ + 'userId' => null, + 'username' => null, + 'ipAddress' => null, + 'userAgent' => null, + 'context' => [], + ]); + + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('1'); + $metadataManager = self::createStub(AuditMetadataManagerInterface::class); + // Configure service with 'Asia/Kolkata' (UTC+5:30) $service = new AuditService( $entityManager, - $userResolver, $clock, self::createStub(TransactionIdGenerator::class), self::createStub(EntityDataExtractor::class), - self::createStub(MetadataCache::class), - [], - [], // ignoredProperties + $metadataManager, + $contextResolver, + $idResolver, null, 'Asia/Kolkata' ); @@ -61,8 +74,8 @@ public function getId(): int $auditLog = $service->createAuditLog($entity, AuditLogInterface::ACTION_CREATE); - self::assertEquals('Asia/Kolkata', $auditLog->getCreatedAt()->getTimezone()->getName()); - self::assertEquals('2023-01-01 17:30:00', $auditLog->getCreatedAt()->format('Y-m-d H:i:s')); + self::assertEquals('Asia/Kolkata', $auditLog->createdAt->getTimezone()->getName()); + self::assertEquals('2023-01-01 17:30:00', $auditLog->createdAt->format('Y-m-d H:i:s')); } public function testCreateAuditLogWithDefaultTimezone(): void @@ -74,16 +87,27 @@ public function testCreateAuditLogWithDefaultTimezone(): void $now = new DateTimeImmutable('2023-01-01 12:00:00', new DateTimeZone('UTC')); $clock->method('now')->willReturn($now); + $contextResolver = self::createStub(ContextResolverInterface::class); + $contextResolver->method('resolve')->willReturn([ + 'userId' => null, + 'username' => null, + 'ipAddress' => null, + 'userAgent' => null, + 'context' => [], + ]); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $idResolver->method('resolveFromEntity')->willReturn('1'); + $metadataManager = self::createStub(AuditMetadataManagerInterface::class); + // Default timezone is UTC $service = new AuditService( $entityManager, - $userResolver, $clock, self::createStub(TransactionIdGenerator::class), self::createStub(EntityDataExtractor::class), - self::createStub(MetadataCache::class), - [], - [] // ignoredProperties + $metadataManager, + $contextResolver, + $idResolver ); $entity = new class { @@ -99,7 +123,7 @@ public function getId(): int $auditLog = $service->createAuditLog($entity, AuditLogInterface::ACTION_CREATE); - self::assertEquals('UTC', $auditLog->getCreatedAt()->getTimezone()->getName()); - self::assertEquals('2023-01-01 12:00:00', $auditLog->getCreatedAt()->format('Y-m-d H:i:s')); + self::assertEquals('UTC', $auditLog->createdAt->getTimezone()->getName()); + self::assertEquals('2023-01-01 12:00:00', $auditLog->createdAt->format('Y-m-d H:i:s')); } } diff --git a/tests/Unit/Service/ChangeProcessorTest.php b/tests/Unit/Service/ChangeProcessorTest.php index 80fee63..eb043f5 100644 --- a/tests/Unit/Service/ChangeProcessorTest.php +++ b/tests/Unit/Service/ChangeProcessorTest.php @@ -12,7 +12,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; -use Rcsofttech\AuditTrailBundle\Service\AuditService; +use Rcsofttech\AuditTrailBundle\Contract\AuditMetadataManagerInterface; use Rcsofttech\AuditTrailBundle\Service\ChangeProcessor; use Rcsofttech\AuditTrailBundle\Service\ValueSerializer; use ReflectionProperty; @@ -21,15 +21,15 @@ #[AllowMockObjectsWithoutExpectations] class ChangeProcessorTest extends TestCase { - private AuditService&MockObject $auditService; + private AuditMetadataManagerInterface&MockObject $metadataManager; private ChangeProcessor $processor; protected function setUp(): void { - $this->auditService = $this->createMock(AuditService::class); + $this->metadataManager = $this->createMock(AuditMetadataManagerInterface::class); $this->processor = new ChangeProcessor( - $this->auditService, + $this->metadataManager, new ValueSerializer(), true, 'deletedAt' @@ -47,8 +47,8 @@ public function testExtractChanges(): void 'password' => ['old_pass', 'new_pass'], ]; - $this->auditService->method('getSensitiveFields')->with($entity)->willReturn(['password' => '***']); - $this->auditService->method('getIgnoredProperties')->with($entity)->willReturn([]); + $this->metadataManager->method('getSensitiveFields')->with($entity::class)->willReturn(['password' => '***']); + $this->metadataManager->method('getIgnoredProperties')->with($entity)->willReturn([]); $changes = $this->processor->extractChanges($entity, $changeSet); @@ -74,8 +74,8 @@ public function testExtractChangesFloatPrecision(): void 'null_same' => [null, null], ]; - $this->auditService->method('getSensitiveFields')->willReturn([]); - $this->auditService->method('getIgnoredProperties')->willReturn([]); + $this->metadataManager->method('getSensitiveFields')->willReturn([]); + $this->metadataManager->method('getIgnoredProperties')->willReturn([]); $changes = $this->processor->extractChanges($entity, $changeSet); @@ -111,7 +111,7 @@ public function testDetermineUpdateAction(): void public function testDetermineUpdateActionSoftDeleteDisabled(): void { $processor = new ChangeProcessor( - $this->auditService, + $this->metadataManager, new ValueSerializer(), false, 'deletedAt' diff --git a/tests/Unit/Service/ContextResolverTest.php b/tests/Unit/Service/ContextResolverTest.php new file mode 100644 index 0000000..795c88c --- /dev/null +++ b/tests/Unit/Service/ContextResolverTest.php @@ -0,0 +1,93 @@ +createMock(UserResolverInterface::class); + $userResolver->method('getUserId')->willReturn('u1'); + $userResolver->method('getUsername')->willReturn('admin'); + $userResolver->method('getIpAddress')->willReturn('127.0.0.1'); + $userResolver->method('getUserAgent')->willReturn('TestAgent'); + $userResolver->method('getImpersonatorId')->willReturn('u2'); + $userResolver->method('getImpersonatorUsername')->willReturn('superadmin'); + + $dataMasker = $this->createMock(DataMaskerInterface::class); + $dataMasker->method('redact')->willReturnArgument(0); + + $contributor = $this->createMock(AuditContextContributorInterface::class); + $contributor->method('contribute')->willReturn(['custom' => 'data']); + + $logger = $this->createMock(LoggerInterface::class); + + $resolver = new ContextResolver( + $userResolver, + $dataMasker, + new ArrayIterator([$contributor]), + $logger + ); + + $entity = new stdClass(); + $result = $resolver->resolve($entity, 'INSERT', [], [ + AuditLogInterface::CONTEXT_USER_ID => 'ctx_u1', + AuditLogInterface::CONTEXT_USERNAME => 'ctx_admin', + 'other' => 'val', + ]); + + self::assertSame('ctx_u1', $result['userId']); + self::assertSame('ctx_admin', $result['username']); + self::assertSame('127.0.0.1', $result['ipAddress']); + self::assertSame('TestAgent', $result['userAgent']); + + $context = $result['context']; + self::assertSame('val', $context['other']); + self::assertSame('u2', $context['impersonation']['impersonator_id']); + self::assertSame('superadmin', $context['impersonation']['impersonator_username']); + self::assertSame('data', $context['custom']); + } + + public function testResolveCatchesExceptionAndLogs(): void + { + $userResolver = $this->createMock(UserResolverInterface::class); + $userResolver->method('getUserId')->willThrowException(new RuntimeException('fail')); + + $dataMasker = $this->createMock(DataMaskerInterface::class); + $dataMasker->method('redact')->willReturnArgument(0); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + + $resolver = new ContextResolver( + $userResolver, + $dataMasker, + new ArrayIterator([]), + $logger + ); + + $entity = new stdClass(); + $result = $resolver->resolve($entity, 'INSERT', [], []); + + self::assertNull($result['userId']); + self::assertNull($result['username']); + self::assertNull($result['ipAddress']); + self::assertNull($result['userAgent']); + self::assertEmpty($result['context']); + } +} diff --git a/tests/Unit/Service/DataMaskerTest.php b/tests/Unit/Service/DataMaskerTest.php new file mode 100644 index 0000000..b1704bc --- /dev/null +++ b/tests/Unit/Service/DataMaskerTest.php @@ -0,0 +1,55 @@ +dataMasker = new DataMasker(); + } + + public function testRedactSensitiveKeys(): void + { + $data = [ + 'username' => 'john_doe', + 'Password' => 'secret123', + 'api_TOken' => 'abcd', + 'nested' => [ + 'session_id' => 'xyz', + 'normal' => 'value', + ], + ]; + + $redacted = $this->dataMasker->redact($data); + + self::assertSame('john_doe', $redacted['username']); + self::assertSame('********', $redacted['Password']); + self::assertSame('********', $redacted['api_TOken']); + self::assertSame('********', $redacted['nested']['session_id']); + self::assertSame('value', $redacted['nested']['normal']); + } + + public function testMaskExplicitFields(): void + { + $data = [ + 'email' => 'test@example.com', + 'phone' => '1234567890', + ]; + + $masked = $this->dataMasker->mask($data, ['email' => 'HIDDEN', 'missing' => 'HIDDEN']); + + self::assertSame('HIDDEN', $masked['email']); + self::assertSame('1234567890', $masked['phone']); + self::assertArrayNotHasKey('missing', $masked); + } +} diff --git a/tests/Unit/Service/EntityDataExtractorTest.php b/tests/Unit/Service/EntityDataExtractorTest.php index 8f6abcf..d6c076e 100644 --- a/tests/Unit/Service/EntityDataExtractorTest.php +++ b/tests/Unit/Service/EntityDataExtractorTest.php @@ -12,9 +12,9 @@ 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\Service\ValueSerializer; use stdClass; #[AllowMockObjectsWithoutExpectations] @@ -22,7 +22,7 @@ class EntityDataExtractorTest extends TestCase { private EntityManagerInterface&MockObject $em; - private ValueSerializer&MockObject $serializer; + private ValueSerializerInterface&MockObject $serializer; private MetadataCache&MockObject $metadataCache; @@ -33,7 +33,7 @@ class EntityDataExtractorTest extends TestCase protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->serializer = $this->createMock(ValueSerializer::class); + $this->serializer = $this->createMock(ValueSerializerInterface::class); $this->metadataCache = $this->createMock(MetadataCache::class); $this->logger = $this->createMock(LoggerInterface::class); diff --git a/tests/Unit/Service/EntityIdResolverTest.php b/tests/Unit/Service/EntityIdResolverTest.php index 6fb1648..78d9abd 100644 --- a/tests/Unit/Service/EntityIdResolverTest.php +++ b/tests/Unit/Service/EntityIdResolverTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Service\EntityIdResolver; use stdClass; @@ -15,34 +16,37 @@ #[AllowMockObjectsWithoutExpectations] class EntityIdResolverTest extends TestCase { + private EntityIdResolver $resolver; + + protected function setUp(): void + { + $this->resolver = new EntityIdResolver(); + } + public function testResolveNotPendingIsInsert(): void { - $log = new AuditLog(); - $log->setEntityId('123'); + $log = new AuditLog('App\Entity\User', '123', AuditLogInterface::ACTION_CREATE); - self::assertEquals('123', EntityIdResolver::resolve($log, ['is_insert' => true])); + self::assertEquals('123', $this->resolver->resolve($log, ['is_insert' => true])); } public function testResolveNotPendingNotInsert(): void { - $log = new AuditLog(); - $log->setEntityId('123'); + $log = new AuditLog('App\Entity\User', '123', AuditLogInterface::ACTION_UPDATE); - self::assertNull(EntityIdResolver::resolve($log, ['is_insert' => false])); + self::assertNull($this->resolver->resolve($log, ['is_insert' => false])); } public function testResolvePendingMissingContext(): void { - $log = new AuditLog(); - $log->setEntityId('pending'); + $log = new AuditLog('App\Entity\User', 'pending', AuditLogInterface::ACTION_CREATE); - self::assertNull(EntityIdResolver::resolve($log, [])); + self::assertNull($this->resolver->resolve($log, [])); } public function testResolvePendingSingleId(): void { - $log = new AuditLog(); - $log->setEntityId('pending'); + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); $entity = new stdClass(); $em = $this->createMock(EntityManagerInterface::class); @@ -51,13 +55,12 @@ public function testResolvePendingSingleId(): void $em->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 123]); - self::assertEquals('123', EntityIdResolver::resolve($log, ['entity' => $entity, 'em' => $em])); + self::assertEquals('123', $this->resolver->resolve($log, ['entity' => $entity, 'em' => $em])); } public function testResolvePendingCompositeId(): void { - $log = new AuditLog(); - $log->setEntityId('pending'); + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); $entity = new stdClass(); $em = $this->createMock(EntityManagerInterface::class); @@ -66,13 +69,12 @@ public function testResolvePendingCompositeId(): void $em->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id1' => 1, 'id2' => 2]); - self::assertEquals('["1","2"]', EntityIdResolver::resolve($log, ['entity' => $entity, 'em' => $em])); + self::assertEquals('["1","2"]', $this->resolver->resolve($log, ['entity' => $entity, 'em' => $em])); } public function testResolvePendingNoId(): void { - $log = new AuditLog(); - $log->setEntityId('pending'); + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); $entity = new stdClass(); $em = $this->createMock(EntityManagerInterface::class); @@ -81,6 +83,29 @@ public function testResolvePendingNoId(): void $em->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); $metadata->method('getIdentifierValues')->with($entity)->willReturn([]); - self::assertEquals('pending', EntityIdResolver::resolve($log, ['entity' => $entity, 'em' => $em])); + self::assertEquals('pending', $this->resolver->resolve($log, ['entity' => $entity, 'em' => $em])); + } + + public function testResolveFromEntity(): void + { + $entity = new stdClass(); + $em = $this->createMock(EntityManagerInterface::class); + $metadata = $this->createMock(ClassMetadata::class); + $em->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); + $metadata->method('getIdentifierValues')->with($entity)->willReturn(['id' => 456]); + + $resolver = new EntityIdResolver($em); + self::assertEquals('456', $resolver->resolveFromEntity($entity)); + } + + public function testResolveFromValues(): void + { + $entity = new stdClass(); + $em = $this->createMock(EntityManagerInterface::class); + $metadata = $this->createMock(ClassMetadata::class); + $em->method('getClassMetadata')->with(stdClass::class)->willReturn($metadata); + $metadata->method('getIdentifierFieldNames')->willReturn(['id']); + + self::assertEquals('789', $this->resolver->resolveFromValues($entity, ['id' => 789], $em)); } } diff --git a/tests/Unit/Service/EntityProcessorTest.php b/tests/Unit/Service/EntityProcessorTest.php index 96eb922..a1a565d 100644 --- a/tests/Unit/Service/EntityProcessorTest.php +++ b/tests/Unit/Service/EntityProcessorTest.php @@ -9,57 +9,85 @@ use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Rcsofttech\AuditTrailBundle\Contract\AuditDispatcherInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\ChangeProcessorInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; +use Rcsofttech\AuditTrailBundle\Contract\ScheduledAuditManagerInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; -use Rcsofttech\AuditTrailBundle\Service\AuditDispatcher; -use Rcsofttech\AuditTrailBundle\Service\AuditService; -use Rcsofttech\AuditTrailBundle\Service\ChangeProcessor; use Rcsofttech\AuditTrailBundle\Service\EntityProcessor; -use Rcsofttech\AuditTrailBundle\Service\ScheduledAuditManager; use Rcsofttech\AuditTrailBundle\Tests\Unit\Fixtures\StubCollection; use stdClass; #[AllowMockObjectsWithoutExpectations] class EntityProcessorTest extends TestCase { - private AuditService&MockObject $auditService; + private AuditServiceInterface&MockObject $auditService; - private ChangeProcessor&MockObject $changeProcessor; + private ChangeProcessorInterface&MockObject $changeProcessor; - private AuditDispatcher&MockObject $dispatcher; + private AuditDispatcherInterface&MockObject $dispatcher; - private ScheduledAuditManager&MockObject $auditManager; + private ScheduledAuditManagerInterface&MockObject $auditManager; + + private EntityIdResolverInterface&MockObject $idResolver; private EntityProcessor $processor; protected function setUp(): void { - $this->auditService = $this->createMock(AuditService::class); - $this->changeProcessor = $this->createMock(ChangeProcessor::class); - $this->dispatcher = $this->createMock(AuditDispatcher::class); - $this->auditManager = $this->createMock(ScheduledAuditManager::class); + $this->auditService = $this->createMock(AuditServiceInterface::class); + $this->changeProcessor = $this->createMock(ChangeProcessorInterface::class); + $this->dispatcher = $this->createMock(AuditDispatcherInterface::class); + $this->auditManager = $this->createMock(ScheduledAuditManagerInterface::class); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); $this->processor = new EntityProcessor( $this->auditService, $this->changeProcessor, $this->dispatcher, $this->auditManager, + $this->idResolver, true // deferTransportUntilCommit ); } - public function testProcessInsertions(): void + public function testProcessInsertionsWithResolvedId(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $uow = $this->createMock(UnitOfWork::class); + $entity = new stdClass(); + // Entity ID is already resolved (UUID case) — should dispatch immediately + $audit = new AuditLog(stdClass::class, '550e8400-e29b-41d4-a716-446655440000', AuditLogInterface::ACTION_CREATE); + + $uow->method('getScheduledEntityInsertions')->willReturn([$entity]); + $this->auditService->method('shouldAudit')->with($entity)->willReturn(true); + $this->auditService->method('getEntityData')->willReturn([]); + $this->auditService->method('createAuditLog')->willReturn($audit); + + // UUID path: dispatch during onFlush (single flush), NOT scheduled + $this->dispatcher->expects($this->once())->method('dispatch')->willReturn(true); + $this->auditManager->expects($this->never())->method('schedule'); + + $this->processor->processInsertions($em, $uow); + } + + public function testProcessInsertionsDeferredForPendingId(): void { $em = $this->createMock(EntityManagerInterface::class); $uow = $this->createMock(UnitOfWork::class); $entity = new stdClass(); - $audit = new AuditLog(); + // Entity ID is PENDING (auto-increment) — must defer to postFlush + $audit = new AuditLog(stdClass::class, AuditLogInterface::PENDING_ID, AuditLogInterface::ACTION_CREATE); $uow->method('getScheduledEntityInsertions')->willReturn([$entity]); $this->auditService->method('shouldAudit')->with($entity)->willReturn(true); $this->auditService->method('getEntityData')->willReturn([]); $this->auditService->method('createAuditLog')->willReturn($audit); + // Auto-increment path: scheduled for postFlush, NOT dispatched + $this->dispatcher->expects($this->never())->method('dispatch'); $this->auditManager->expects($this->once())->method('schedule')->with($entity, $audit, true); $this->processor->processInsertions($em, $uow); @@ -84,7 +112,7 @@ public function testProcessUpdates(): void $em = $this->createMock(EntityManagerInterface::class); $uow = $this->createMock(UnitOfWork::class); $entity = new stdClass(); - $audit = new AuditLog(); + $audit = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_UPDATE); $uow->method('getScheduledEntityUpdates')->willReturn([$entity]); $uow->method('getEntityChangeSet')->willReturn(['field' => ['old', 'new']]); @@ -150,7 +178,7 @@ public function getId(): int return 2; } }; - $audit = new AuditLog(); + $audit = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_UPDATE); $collection = new StubCollection( $owner, @@ -165,7 +193,6 @@ public function getId(): int $this->auditManager->expects($this->once())->method('schedule')->with($owner, $audit, false); - // @phpstan-ignore-next-line $this->processor->processCollectionUpdates($em, $uow, [$collection]); } @@ -176,20 +203,20 @@ public function testDispatchImmediate(): void $this->changeProcessor, $this->dispatcher, $this->auditManager, + $this->idResolver, false // deferTransportUntilCommit = false ); $em = $this->createMock(EntityManagerInterface::class); $uow = $this->createMock(UnitOfWork::class); $entity = new stdClass(); - $audit = new AuditLog(); + $audit = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE); $uow->method('getScheduledEntityInsertions')->willReturn([$entity]); $this->auditService->method('shouldAudit')->with($entity)->willReturn(true); $this->auditService->method('createAuditLog')->willReturn($audit); - $this->dispatcher->method('supports')->willReturn(true); - $this->dispatcher->expects($this->once())->method('dispatch'); + $this->dispatcher->expects($this->once())->method('dispatch')->willReturn(true); $this->auditManager->expects($this->never())->method('schedule'); $processor->processInsertions($em, $uow); diff --git a/tests/Unit/Service/ExpressionLanguageVoterTest.php b/tests/Unit/Service/ExpressionLanguageVoterTest.php index 02b52d9..b312985 100644 --- a/tests/Unit/Service/ExpressionLanguageVoterTest.php +++ b/tests/Unit/Service/ExpressionLanguageVoterTest.php @@ -87,4 +87,44 @@ public function testVoteWithUserContext(): void self::assertFalse($voter->vote($entity, 'update', [])); } + + public function testIsExpressionSafeBlocksDangerousPatterns(): void + { + $metadataCache = $this->createMock(MetadataCache::class); + $metadataCache->method('getAuditCondition')->willReturn(new AuditCondition('system("rm -rf /")')); + + $voter = new ExpressionLanguageVoter( + $metadataCache, + $this->createMock(UserResolverInterface::class) + ); + + self::assertFalse($voter->vote(new stdClass(), 'update', [])); + } + + public function testVoteHandlesSyntaxError(): void + { + $metadataCache = $this->createMock(MetadataCache::class); + $metadataCache->method('getAuditCondition')->willReturn(new AuditCondition('invalid expression ++')); + + $voter = new ExpressionLanguageVoter( + $metadataCache, + $this->createMock(UserResolverInterface::class) + ); + + self::assertFalse($voter->vote(new stdClass(), 'update', [])); + } + + public function testVoteHandlesEvaluationError(): void + { + $metadataCache = $this->createMock(MetadataCache::class); + // This will cause an evaluation error because 'undefined_var' is not in ALLOWED_VARIABLES + $metadataCache->method('getAuditCondition')->willReturn(new AuditCondition('undefined_var > 5')); + + $voter = new ExpressionLanguageVoter( + $metadataCache, + $this->createMock(UserResolverInterface::class) + ); + + self::assertFalse($voter->vote(new stdClass(), 'update', [])); + } } diff --git a/tests/Unit/Service/ScheduledAuditManagerTest.php b/tests/Unit/Service/ScheduledAuditManagerTest.php index e2d7f4d..5b1048b 100644 --- a/tests/Unit/Service/ScheduledAuditManagerTest.php +++ b/tests/Unit/Service/ScheduledAuditManagerTest.php @@ -7,6 +7,7 @@ use OverflowException; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Event\AuditLogCreatedEvent; use Rcsofttech\AuditTrailBundle\Service\ScheduledAuditManager; @@ -20,14 +21,14 @@ public function testSchedule(): void { $manager = new ScheduledAuditManager(); $entity = new stdClass(); - $log = new AuditLog(); + $log = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE); $manager->schedule($entity, $log, true); - self::assertTrue($manager->hasScheduledAudits()); - self::assertEquals(1, $manager->countScheduled()); + self::assertNotEmpty($manager->scheduledAudits); + self::assertCount(1, $manager->scheduledAudits); - $audits = $manager->getScheduledAudits(); + $audits = $manager->scheduledAudits; self::assertCount(1, $audits); self::assertSame($entity, $audits[0]['entity']); self::assertSame($log, $audits[0]['audit']); @@ -38,7 +39,7 @@ public function testScheduleOverflow(): void { $manager = new ScheduledAuditManager(); $entity = new stdClass(); - $log = new AuditLog(); + $log = new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE); // Fill up to max (1000) for ($i = 0; $i < 1000; ++$i) { @@ -57,7 +58,7 @@ public function testScheduleWithDispatcher(): void ->with(self::isInstanceOf(AuditLogCreatedEvent::class), AuditLogCreatedEvent::NAME); $manager = new ScheduledAuditManager($dispatcher); - $manager->schedule(new stdClass(), new AuditLog(), false); + $manager->schedule(new stdClass(), new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE), false); } public function testPendingDeletions(): void @@ -68,7 +69,7 @@ public function testPendingDeletions(): void $manager->addPendingDeletion($entity, $data, true); - $deletions = $manager->getPendingDeletions(); + $deletions = $manager->pendingDeletions; self::assertCount(1, $deletions); self::assertSame($entity, $deletions[0]['entity']); self::assertSame($data, $deletions[0]['data']); @@ -78,13 +79,12 @@ public function testPendingDeletions(): void public function testClear(): void { $manager = new ScheduledAuditManager(); - $manager->schedule(new stdClass(), new AuditLog(), true); + $manager->schedule(new stdClass(), new AuditLog(stdClass::class, '1', AuditLogInterface::ACTION_CREATE), true); $manager->addPendingDeletion(new stdClass(), [], true); $manager->clear(); - self::assertFalse($manager->hasScheduledAudits()); - self::assertEquals(0, $manager->countScheduled()); - self::assertEmpty($manager->getPendingDeletions()); + self::assertEmpty($manager->scheduledAudits); + self::assertEmpty($manager->pendingDeletions); } } diff --git a/tests/Unit/Service/UserResolverTest.php b/tests/Unit/Service/UserResolverTest.php index de06a2d..401f6c3 100644 --- a/tests/Unit/Service/UserResolverTest.php +++ b/tests/Unit/Service/UserResolverTest.php @@ -13,6 +13,8 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; use function strlen; @@ -121,4 +123,141 @@ public function testGetUserAgent(): void $resolver = new UserResolver($this->security, $this->requestStack, true, true); self::assertStringContainsString('cli-console', (string) $resolver->getUserAgent()); } + + public function testGetImpersonatorId(): void + { + $resolver = new UserResolver($this->security, $this->requestStack); + $this->security->method('getToken')->willReturn(null); + 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); + self::assertEquals('123', $resolver->getImpersonatorId()); + } + + public function testGetImpersonatorUsername(): void + { + $resolver = new UserResolver($this->security, $this->requestStack); + $this->security->method('getToken')->willReturn(null); + self::assertNull($resolver->getImpersonatorUsername()); + + $token = $this->createMock(SwitchUserToken::class); + $originalToken = $this->createMock(TokenInterface::class); + $user = $this->createMock(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); + self::assertEquals('superadmin', $resolver->getImpersonatorUsername()); + } + + public function testGetImpersonatorIdWithoutIdMethod(): 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()); + self::assertNull($resolver->getImpersonatorId()); + } + + public function testGetImpersonatorIdWhenIdNotScalarOrStringable(): void + { + $token = $this->createMock(SwitchUserToken::class); + $originalToken = $this->createMock(TokenInterface::class); + $user = new class implements UserInterface { + /** @return array */ + public function getId(): array + { + return []; + } + + public function getUserIdentifier(): string + { + return 'user'; + } + + /** @return array */ + public function getRoles(): array + { + return []; + } + + 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()); + self::assertNull($resolver->getImpersonatorId()); + } + + public function testGetUserIdWhenIdNotScalarOrStringable(): void + { + $security = $this->createMock(Security::class); + $user = new class implements UserInterface { + /** @return array */ + public function getId(): array + { + return []; + } + + public function getUserIdentifier(): string + { + return 'user_identifier'; + } + + /** @return array */ + public function getRoles(): array + { + return []; + } + + public function eraseCredentials(): void + { + } + }; + $security->method('getUser')->willReturn($user); + + $resolver = new UserResolver($security, new RequestStack()); + self::assertEquals('user_identifier', $resolver->getUserId()); + } + + public function testGetIpAddressInCliWithoutRequestUsesHostname(): void + { + $resolver = new UserResolver($this->createMock(Security::class), new RequestStack(), true, true); + + $ip = $resolver->getIpAddress(); + self::assertIsString($ip); // In CLI it uses gethostbyname + } + + public function testGetUserAgentInCliWithoutRequestUsesHostname(): void + { + $resolver = new UserResolver($this->createMock(Security::class), new RequestStack(), true, true); + + $ua = $resolver->getUserAgent(); + self::assertIsString($ua); + self::assertStringContainsString('cli-console', $ua); + } } diff --git a/tests/Unit/Transport/ChainAuditTransportTest.php b/tests/Unit/Transport/ChainAuditTransportTest.php index e092cb4..f05266b 100644 --- a/tests/Unit/Transport/ChainAuditTransportTest.php +++ b/tests/Unit/Transport/ChainAuditTransportTest.php @@ -41,7 +41,7 @@ public function testSupportsReturnsFalseIfNoTransportSupportsPhase(): void public function testSendOnlyCallsTransportsThatSupportThePhase(): void { - $log = new AuditLog(); + $log = new AuditLog('Class', '1', 'create'); $context = ['phase' => 'on_flush']; $t1 = $this->createMock(AuditTransportInterface::class); diff --git a/tests/Unit/Transport/DoctrineAuditTransportTest.php b/tests/Unit/Transport/DoctrineAuditTransportTest.php index e105417..7a45b42 100644 --- a/tests/Unit/Transport/DoctrineAuditTransportTest.php +++ b/tests/Unit/Transport/DoctrineAuditTransportTest.php @@ -9,6 +9,8 @@ use Doctrine\ORM\UnitOfWork; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +use Rcsofttech\AuditTrailBundle\Contract\AuditLogInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Transport\DoctrineAuditTransport; use stdClass; @@ -16,16 +18,19 @@ #[AllowMockObjectsWithoutExpectations] class DoctrineAuditTransportTest extends TestCase { + private EntityIdResolverInterface&\PHPUnit\Framework\MockObject\MockObject $idResolver; + private DoctrineAuditTransport $transport; protected function setUp(): void { - $this->transport = new DoctrineAuditTransport(); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); + $this->transport = new DoctrineAuditTransport($this->idResolver); } public function testSendOnFlushPersistsLog(): void { - $log = new AuditLog(); + $log = new AuditLog(stdClass::class, '123', AuditLogInterface::ACTION_CREATE); $em = $this->createMock(EntityManagerInterface::class); $uow = $this->createMock(UnitOfWork::class); $meta = self::createStub(ClassMetadata::class); @@ -44,8 +49,7 @@ public function testSendOnFlushPersistsLog(): void public function testSendPostFlushUpdatesId(): void { - $log = new AuditLog(); - $log->setEntityId('pending'); + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); $entity = new stdClass(); $em = self::createStub(EntityManagerInterface::class); @@ -53,7 +57,7 @@ public function testSendPostFlushUpdatesId(): void $em->method('getClassMetadata')->willReturn($meta); $em->method('contains')->willReturn(false); - $meta->method('getIdentifierValues')->willReturn(['id' => 100]); + $this->idResolver->method('resolve')->willReturn('100'); $this->transport->send($log, [ 'phase' => 'post_flush', @@ -62,13 +66,12 @@ public function testSendPostFlushUpdatesId(): void ]); // The new implementation calls setEntityId instead of executeStatement - self::assertEquals('100', $log->getEntityId()); + self::assertEquals('100', $log->entityId); } public function testSendPostFlushWithIsInsertUpdatesId(): void { - $log = new AuditLog(); - $log->setEntityId('pending'); + $log = new AuditLog(stdClass::class, 'pending', AuditLogInterface::ACTION_CREATE); $entity = new stdClass(); $em = self::createStub(EntityManagerInterface::class); @@ -76,7 +79,7 @@ public function testSendPostFlushWithIsInsertUpdatesId(): void $em->method('getClassMetadata')->willReturn($meta); $em->method('contains')->willReturn(true); // Already managed - $meta->method('getIdentifierValues')->willReturn(['id' => 456]); + $this->idResolver->method('resolve')->willReturn('456'); $this->transport->send($log, [ 'phase' => 'post_flush', @@ -86,6 +89,6 @@ public function testSendPostFlushWithIsInsertUpdatesId(): void ]); // Verify setEntityId was called with resolved ID - self::assertEquals('456', $log->getEntityId()); + self::assertEquals('456', $log->entityId); } } diff --git a/tests/Unit/Transport/HttpAuditTransportIssueTest.php b/tests/Unit/Transport/HttpAuditTransportIssueTest.php index 0041da4..90c5b76 100644 --- a/tests/Unit/Transport/HttpAuditTransportIssueTest.php +++ b/tests/Unit/Transport/HttpAuditTransportIssueTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Transport\HttpAuditTransport; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -19,7 +20,8 @@ public function testSupportsMethod(): void $client = self::createStub(HttpClientInterface::class); $logger = self::createStub(LoggerInterface::class); $integrityService = self::createStub(AuditIntegrityServiceInterface::class); - $transport = new HttpAuditTransport($client, 'http://example.com', $integrityService); + $idResolver = self::createStub(EntityIdResolverInterface::class); + $transport = new HttpAuditTransport($client, 'http://example.com', $integrityService, $idResolver); self::assertFalse($transport->supports('on_flush'), 'Should not support on_flush'); self::assertTrue($transport->supports('post_flush'), 'Should support post_flush'); diff --git a/tests/Unit/Transport/HttpAuditTransportTest.php b/tests/Unit/Transport/HttpAuditTransportTest.php index a0ad75c..ab06c0b 100644 --- a/tests/Unit/Transport/HttpAuditTransportTest.php +++ b/tests/Unit/Transport/HttpAuditTransportTest.php @@ -5,10 +5,10 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Transport; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface; +use Rcsofttech\AuditTrailBundle\Contract\EntityIdResolverInterface; use Rcsofttech\AuditTrailBundle\Entity\AuditLog; use Rcsofttech\AuditTrailBundle\Transport\HttpAuditTransport; use stdClass; @@ -24,18 +24,18 @@ public function testSendPostFlushSendsRequest(): void { $client = $this->createMock(HttpClientInterface::class); $integrityService = $this->createMock(AuditIntegrityServiceInterface::class); + $idResolver = $this->createMock(EntityIdResolverInterface::class); $transport = new HttpAuditTransport( $client, 'http://example.com', $integrityService, + $idResolver, + null, ['X-Test' => 'value'], 10 ); - $log = new AuditLog(); - $log->setEntityClass('TestEntity'); - $log->setEntityId('1'); - $log->setAction('create'); + $log = new AuditLog('TestEntity', '1', 'create'); $client->expects($this->once()) ->method('request') @@ -44,8 +44,11 @@ public function testSendPostFlushSendsRequest(): void && $options['timeout'] === 10 && isset($options['body']); })) - ->willReturnCallback(static function () { - return self::createStub(ResponseInterface::class); + ->willReturnCallback(function () { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + return $response; }); $transport->send($log, ['phase' => 'post_flush']); @@ -55,19 +58,12 @@ public function testSendResolvesPendingId(): void { $client = $this->createMock(HttpClientInterface::class); $integrityService = $this->createMock(AuditIntegrityServiceInterface::class); - $transport = new HttpAuditTransport($client, 'http://example.com', $integrityService); + $idResolver = $this->createMock(EntityIdResolverInterface::class); + $transport = new HttpAuditTransport($client, 'http://example.com', $integrityService, $idResolver); - $log = new AuditLog(); - $log->setEntityClass('TestEntity'); - $log->setEntityId('pending'); - $log->setAction('create'); + $log = new AuditLog('TestEntity', 'pending', 'create'); - $entity = new stdClass(); - $em = self::createStub(EntityManagerInterface::class); - $meta = self::createStub(ClassMetadata::class); - - $em->method('getClassMetadata')->willReturn($meta); - $meta->method('getIdentifierValues')->willReturn(['id' => 100]); + $idResolver->method('resolve')->willReturn('100'); $client->expects($this->once()) ->method('request') @@ -78,7 +74,15 @@ public function testSendResolvesPendingId(): void && $body['entity_id'] === '100' && array_key_exists('changed_fields', $body); })) - ->willReturn(self::createStub(ResponseInterface::class)); + ->willReturnCallback(function () { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + return $response; + }); + + $entity = new stdClass(); + $em = self::createStub(EntityManagerInterface::class); $transport->send($log, [ 'phase' => 'post_flush', diff --git a/tests/Unit/Transport/QueueAuditTransportTest.php b/tests/Unit/Transport/QueueAuditTransportTest.php index 447b774..e0fb7b2 100644 --- a/tests/Unit/Transport/QueueAuditTransportTest.php +++ b/tests/Unit/Transport/QueueAuditTransportTest.php @@ -5,15 +5,19 @@ namespace Rcsofttech\AuditTrailBundle\Tests\Unit\Transport; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use Exception; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; 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\Message\AuditLogMessage; +use Rcsofttech\AuditTrailBundle\Message\Stamp\ApiKeyStamp; +use Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp; use Rcsofttech\AuditTrailBundle\Transport\QueueAuditTransport; use stdClass; use Symfony\Component\Messenger\Envelope; @@ -27,35 +31,31 @@ class QueueAuditTransportTest extends TestCase private MessageBusInterface&MockObject $bus; - private LoggerInterface&MockObject $logger; - private EventDispatcherInterface&MockObject $eventDispatcher; private AuditIntegrityServiceInterface&MockObject $integrityService; + private EntityIdResolverInterface&MockObject $idResolver; + protected function setUp(): void { $this->bus = $this->createMock(MessageBusInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->integrityService = $this->createMock(AuditIntegrityServiceInterface::class); + $this->idResolver = $this->createMock(EntityIdResolverInterface::class); $this->transport = new QueueAuditTransport( $this->bus, - $this->logger, $this->eventDispatcher, $this->integrityService, + $this->idResolver, 'test_api_key' ); } public function testSendDispatchesMessageWithStamps(): void { - $log = new AuditLog(); - $log->setEntityClass('TestEntity'); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_CREATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('TestEntity', '1', AuditLogInterface::ACTION_CREATE, new DateTimeImmutable()); $this->integrityService->method('isEnabled')->willReturn(true); $this->integrityService->method('signPayload')->willReturn('test_signature'); @@ -69,13 +69,13 @@ public function testSendDispatchesMessageWithStamps(): void $hasSignatureStamp = false; foreach ($stamps as $stamp) { if ( - $stamp instanceof \Rcsofttech\AuditTrailBundle\Message\Stamp\ApiKeyStamp + $stamp instanceof ApiKeyStamp && $stamp->apiKey === 'test_api_key' ) { $hasApiKeyStamp = true; } if ( - $stamp instanceof \Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp + $stamp instanceof SignatureStamp && $stamp->signature === 'test_signature' ) { $hasSignatureStamp = true; @@ -90,37 +90,29 @@ public function testSendDispatchesMessageWithStamps(): void $this->transport->send($log); } - public function testSendHandlesException(): void + public function testSendPropagatesException(): void { - $log = new AuditLog(); - $log->setEntityClass('TestEntity'); - $log->setEntityId('1'); - $log->setAction(AuditLogInterface::ACTION_CREATE); - $log->setCreatedAt(new DateTimeImmutable()); + $log = new AuditLog('TestEntity', '1', AuditLogInterface::ACTION_CREATE, new DateTimeImmutable()); $this->bus->method('dispatch')->willThrowException(new Exception('Bus error')); - $this->logger->expects($this->once())->method('error'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Bus error'); $this->transport->send($log); } public function testSendResolvesPendingId(): void { - $log = new AuditLog(); - $log->setEntityClass('TestEntity'); - $log->setEntityId('pending'); - $log->setAction(AuditLogInterface::ACTION_CREATE); + $log = new AuditLog('TestEntity', 'pending', AuditLogInterface::ACTION_CREATE); - // we need to pass context that EntityIdResolver understands. + // pass context that EntityIdResolver understands. $context = ['is_insert' => true]; - $entity = new stdClass(); - $em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class); - $metadata = $this->createMock(\Doctrine\ORM\Mapping\ClassMetadata::class); - $em->method('getClassMetadata')->willReturn($metadata); - $metadata->getIdentifierValues($entity); - $metadata->method('getIdentifierValues')->willReturn(['id' => 123]); + $this->idResolver->method('resolve')->willReturn('123'); + $entity = new stdClass(); + $em = $this->createMock(EntityManagerInterface::class); $context = ['entity' => $entity, 'em' => $em]; $this->bus->expects($this->once()) @@ -133,6 +125,24 @@ public function testSendResolvesPendingId(): void $this->transport->send($log, $context); } + public function testSendIsCancelledByStoppingPropagation(): void + { + $log = new AuditLog('TestEntity', '1', AuditLogInterface::ACTION_CREATE, new DateTimeImmutable()); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(static function (AuditMessageStampEvent $event) { + $event->stopPropagation(); + + return $event; + }); + + $this->bus->expects($this->never()) + ->method('dispatch'); + + $this->transport->send($log); + } + public function testSupports(): void { self::assertTrue($this->transport->supports('post_flush'));