From cae2f6bdffdd256222daf1ab8f907d8a9719968d Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 15:39:54 +0200 Subject: [PATCH 01/28] test: characterize 1.0.0 legacy behavior Lock the historical public API, Symfony configuration, Doctrine mapping, persistence pipeline, listener behavior, and known legacy semantics before introducing opt-in transactional auditing. --- tests/Contract/PublicApiContractTest.php | 236 +++ tests/Contract/public-api-1.0.0.json | 1328 +++++++++++++++++ tests/Fixtures/App/DoctrineTestFactory.php | 26 + tests/Fixtures/App/TestKernel.php | 71 + tests/Fixtures/Entity/AuditableEntity.php | 72 + tests/Fixtures/Entity/NonAuditableEntity.php | 40 + .../Service/CollectingAuditEntryWriter.php | 19 + .../AuditableDoctrineListenerTest.php | 44 +- tests/Integration/ContainerTest.php | 69 + .../DoctrineBundleIntegrationTest.php | 115 ++ tests/Integration/DoctrineMappingTest.php | 91 ++ tests/Integration/ListenerBehaviorTest.php | 188 +++ tests/Unit/ChangeDetectorTest.php | 123 +- tests/Unit/ConfigurationTest.php | 42 + tests/Unit/HistorizerTest.php | 175 +++ tests/Unit/PersistencePipelineTest.php | 106 ++ 16 files changed, 2717 insertions(+), 28 deletions(-) create mode 100644 tests/Contract/PublicApiContractTest.php create mode 100644 tests/Contract/public-api-1.0.0.json create mode 100644 tests/Fixtures/App/DoctrineTestFactory.php create mode 100644 tests/Fixtures/App/TestKernel.php create mode 100644 tests/Fixtures/Entity/AuditableEntity.php create mode 100644 tests/Fixtures/Entity/NonAuditableEntity.php create mode 100644 tests/Fixtures/Service/CollectingAuditEntryWriter.php create mode 100644 tests/Integration/ContainerTest.php create mode 100644 tests/Integration/DoctrineBundleIntegrationTest.php create mode 100644 tests/Integration/DoctrineMappingTest.php create mode 100644 tests/Integration/ListenerBehaviorTest.php create mode 100644 tests/Unit/ConfigurationTest.php create mode 100644 tests/Unit/HistorizerTest.php create mode 100644 tests/Unit/PersistencePipelineTest.php diff --git a/tests/Contract/PublicApiContractTest.php b/tests/Contract/PublicApiContractTest.php new file mode 100644 index 0000000..e22241a --- /dev/null +++ b/tests/Contract/PublicApiContractTest.php @@ -0,0 +1,236 @@ + self::contractFixture(), + ]; + $current = $historical; + $current['Future\\TransactionalType'] = self::contractFixture(); + $current['Legacy\\PublicType']['public_methods']['futureMethod'] = [ + 'static' => false, + 'return' => 'void', + 'parameters' => [], + ]; + $current['Legacy\\PublicType']['public_properties']['futureProperty'] = [ + 'type' => 'string', + 'readonly' => true, + 'static' => false, + ]; + + self::assertHistoricalContractIsPreserved($historical, $current); + } + + /** @param array $current */ + #[DataProvider('brokenHistoricalContracts')] + public function testMissingOrModifiedHistoricalContractIsRejected(array $current): void + { + $historical = ['Legacy\\PublicType' => self::contractFixture()]; + + $this->expectException(\PHPUnit\Framework\ExpectationFailedException::class); + self::assertHistoricalContractIsPreserved($historical, $current); + } + + /** @return iterable}> */ + public static function brokenHistoricalContracts(): iterable + { + yield 'missing historical type' => [[]]; + + $modified = self::contractFixture(); + $modified['final'] = false; + yield 'modified historical type' => [['Legacy\\PublicType' => $modified]]; + } + + /** @return array */ + private static function publicApi(): array + { + $classes = []; + foreach (self::sourceClasses() as $class) { + $reflection = new \ReflectionClass($class); + $methods = []; + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== $class) { + continue; + } + $parameters = []; + foreach ($method->getParameters() as $parameter) { + $parameters[] = [ + 'name' => $parameter->getName(), + 'type' => self::type($parameter->getType()), + 'by_reference' => $parameter->isPassedByReference(), + 'variadic' => $parameter->isVariadic(), + 'has_default' => $parameter->isDefaultValueAvailable(), + 'default' => $parameter->isDefaultValueAvailable() ? self::value($parameter->getDefaultValue()) : null, + ]; + } + $methods[$method->getName()] = [ + 'static' => $method->isStatic(), + 'return' => self::type($method->getReturnType()), + 'parameters' => $parameters, + ]; + } + ksort($methods); + + $properties = []; + foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->getDeclaringClass()->getName() !== $class) { + continue; + } + $properties[$property->getName()] = [ + 'type' => self::type($property->getType()), + 'readonly' => $property->isReadOnly(), + 'static' => $property->isStatic(), + ]; + } + ksort($properties); + + $enumCases = []; + if ($reflection->isEnum()) { + foreach ($reflection->getReflectionConstants() as $case) { + if ($case->isEnumCase()) { + $enumCases[$case->getName()] = self::value($case->getValue()); + } + } + } + $classes[$class] = [ + 'kind' => $reflection->isInterface() ? 'interface' : ($reflection->isEnum() ? 'enum' : 'class'), + 'final' => $reflection->isFinal(), + 'readonly' => $reflection->isReadOnly(), + 'attribute_targets' => self::attributeTargets($reflection), + 'enum_cases' => $enumCases, + 'public_properties' => $properties, + 'public_methods' => $methods, + ]; + } + ksort($classes); + + return $classes; + } + + /** @param array $historical + * @param array $current + */ + private static function assertHistoricalContractIsPreserved(array $historical, array $current): void + { + foreach ($historical as $class => $expectedContract) { + self::assertArrayHasKey($class, $current, \sprintf('Historical public type %s no longer exists.', $class)); + self::assertIsArray($expectedContract); + self::assertIsArray($current[$class]); + $actualContract = $current[$class]; + + foreach (['kind', 'final', 'readonly', 'attribute_targets', 'enum_cases'] as $key) { + self::assertSame($expectedContract[$key], $actualContract[$key], \sprintf('%s changed for %s.', $key, $class)); + } + foreach (['public_methods', 'public_properties'] as $membersKey) { + self::assertIsArray($expectedContract[$membersKey]); + self::assertIsArray($actualContract[$membersKey]); + foreach ($expectedContract[$membersKey] as $member => $expectedMember) { + self::assertArrayHasKey($member, $actualContract[$membersKey], \sprintf('%s::%s no longer has its historical public visibility.', $class, $member)); + self::assertSame($expectedMember, $actualContract[$membersKey][$member], \sprintf('Public contract of %s::%s changed.', $class, $member)); + } + } + } + } + + /** @return array */ + private static function contractFixture(): array + { + return [ + 'kind' => 'class', + 'final' => true, + 'readonly' => false, + 'attribute_targets' => null, + 'enum_cases' => [], + 'public_properties' => [], + 'public_methods' => [], + ]; + } + + /** @return list */ + private static function sourceClasses(): array + { + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(\dirname(__DIR__, 2).'/src')) as $file) { + if ($file instanceof \SplFileInfo && 'php' === $file->getExtension()) { + require_once $file->getPathname(); + } + } + + $sourceDirectory = realpath(\dirname(__DIR__, 2).'/src'); + self::assertIsString($sourceDirectory); + $symbols = array_merge(get_declared_classes(), get_declared_interfaces(), get_declared_traits()); + $symbols = array_values(array_filter( + $symbols, + static function (string $symbol) use ($sourceDirectory): bool { + if (!str_starts_with($symbol, 'Zhortein\\AuditableBundle\\')) { + return false; + } + $filename = (new \ReflectionClass($symbol))->getFileName(); + + return \is_string($filename) && str_starts_with(realpath($filename) ?: '', $sourceDirectory.'/'); + }, + )); + sort($symbols); + + /* @var list $symbols */ + return $symbols; + } + + private static function type(?\ReflectionType $type): ?string + { + if (null === $type) { + return null; + } + if ($type instanceof \ReflectionNamedType) { + return ($type->allowsNull() && 'mixed' !== $type->getName() ? '?' : '').$type->getName(); + } + if ($type instanceof \ReflectionUnionType) { + return implode('|', array_map(self::type(...), $type->getTypes())); + } + if (!$type instanceof \ReflectionIntersectionType) { + throw new \LogicException('Unsupported reflection type.'); + } + + return implode('&', array_map(self::type(...), $type->getTypes())); + } + + private static function value(mixed $value): mixed + { + if ($value instanceof \BackedEnum) { + return $value->value; + } + if ($value instanceof \UnitEnum) { + return $value->name; + } + + return $value; + } + + /** @param \ReflectionClass $reflection */ + private static function attributeTargets(\ReflectionClass $reflection): ?int + { + $attributes = $reflection->getAttributes(\Attribute::class); + + return [] === $attributes ? null : $attributes[0]->newInstance()->flags; + } +} diff --git a/tests/Contract/public-api-1.0.0.json b/tests/Contract/public-api-1.0.0.json new file mode 100644 index 0000000..20a374f --- /dev/null +++ b/tests/Contract/public-api-1.0.0.json @@ -0,0 +1,1328 @@ +{ + "Zhortein\\AuditableBundle\\Attribute\\AuditField": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": { + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Attribute\\AuditIgnore": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Attribute\\Auditable": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 1, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": false, + "static": false + }, + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\Configuration": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getConfigTreeBuilder": { + "static": false, + "return": "Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\ZhorteinAuditableExtension": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "load": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "configs", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Doctrine\\AuditableDoctrineListener": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "historizer", + "type": "Zhortein\\AuditableBundle\\Service\\Historizer", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "metadataProvider", + "type": "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "changeDetector", + "type": "Zhortein\\AuditableBundle\\Service\\ChangeDetector", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackInsert", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackUpdate", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackDelete", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "globalIgnoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + }, + "onFlush": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\OnFlushEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "postPersist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PostPersistEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "preRemove": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PreRemoveEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Entity\\AuditEntry": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [] + }, + "getAction": { + "static": false, + "return": "string", + "parameters": [] + }, + "getActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getContext": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getData": { + "static": false, + "return": "array", + "parameters": [] + }, + "getDescription": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityClass": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getId": { + "static": false, + "return": "?int", + "parameters": [] + }, + "getImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getLevel": { + "static": false, + "return": "string", + "parameters": [] + }, + "getOccurredAt": { + "static": false, + "return": "DateTimeImmutable", + "parameters": [] + }, + "getTitle": { + "static": false, + "return": "string", + "parameters": [] + }, + "isAuto": { + "static": false, + "return": "bool", + "parameters": [] + }, + "setAction": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setActorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setContext": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setData": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setDescription": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityClass": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setImpersonatorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setIsAuto": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setLevel": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setOccurredAt": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "occurredAt", + "type": "DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setTitle": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditAction": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "CREATE": "create", + "UPDATE": "update", + "DELETE": "delete", + "LOG": "log" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditLevel": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "DEBUG": "debug", + "INFO": "info", + "WARNING": "warning", + "ERROR": "error", + "CRITICAL": "critical" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\MessageHandler\\PersistAuditEntryMessageHandler": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "__invoke": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityClass": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityId": { + "type": "?string", + "readonly": true, + "static": false + }, + "impersonatorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "string", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "occurredAt", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "fieldLabels": { + "type": "array", + "readonly": true, + "static": false + }, + "ignoredFields": { + "type": "array", + "readonly": true, + "static": false + }, + "label": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "fieldLabels", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "ignoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getFor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Repository\\AuditEntryRepository": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "registry", + "type": "Doctrine\\Persistence\\ManagerRegistry", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AsyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "bus", + "type": "Symfony\\Component\\Messenger\\MessageBusInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "entityManager", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "persist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ChangeDetector": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "maxStringLength", + "type": "int", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": 180 + } + ] + }, + "getChanges": { + "static": false, + "return": "array", + "parameters": [ + { + "name": "em", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\Historizer": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "writer", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "actorResolver", + "type": "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + } + ] + }, + "historize": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "action", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditAction|string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entity", + "type": "?object", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "level", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditLevel|string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": "info" + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SecurityActorResolver": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "security", + "type": "Symfony\\Bundle\\SecurityBundle\\Security", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\ZhorteinAuditableBundle": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "build": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + } +} diff --git a/tests/Fixtures/App/DoctrineTestFactory.php b/tests/Fixtures/App/DoctrineTestFactory.php new file mode 100644 index 0000000..5aee962 --- /dev/null +++ b/tests/Fixtures/App/DoctrineTestFactory.php @@ -0,0 +1,26 @@ + 'pdo_sqlite', 'memory' => true]); + $configuration = ORMSetup::createAttributeMetadataConfiguration([ + \dirname(__DIR__, 3).'/src/Entity', + \dirname(__DIR__).'/Entity', + ], true); + + return new EntityManager($connection, $configuration, $eventManager); + } +} diff --git a/tests/Fixtures/App/TestKernel.php b/tests/Fixtures/App/TestKernel.php new file mode 100644 index 0000000..aafe636 --- /dev/null +++ b/tests/Fixtures/App/TestKernel.php @@ -0,0 +1,71 @@ + $bundleConfig */ + public function __construct( + private readonly array $bundleConfig = [], + ) { + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new SecurityBundle(); + yield new DoctrineBundle(); + yield new ZhorteinAuditableBundle(); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function (ContainerBuilder $container): void { + $container->loadFromExtension('framework', [ + 'secret' => 'auditable-characterization-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => 'sqlite:///:memory:'], + 'orm' => [ + 'auto_generate_proxy_classes' => true, + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ], + ]); + $container->loadFromExtension('zhortein_auditable', $this->bundleConfig); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-tests/'.getmypid().'-'.sha1(serialize($this->bundleConfig)); + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/Entity/AuditableEntity.php b/tests/Fixtures/Entity/AuditableEntity.php new file mode 100644 index 0000000..add8aa4 --- /dev/null +++ b/tests/Fixtures/Entity/AuditableEntity.php @@ -0,0 +1,72 @@ +name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setSecret(string $secret): void + { + $this->secret = $secret; + } + + public function getSecret(): string + { + return $this->secret; + } + + public function setGloballyIgnored(string $value): void + { + $this->globallyIgnored = $value; + } + + public function getGloballyIgnored(): string + { + return $this->globallyIgnored; + } +} diff --git a/tests/Fixtures/Entity/NonAuditableEntity.php b/tests/Fixtures/Entity/NonAuditableEntity.php new file mode 100644 index 0000000..44f536a --- /dev/null +++ b/tests/Fixtures/Entity/NonAuditableEntity.php @@ -0,0 +1,40 @@ +name = $name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Fixtures/Service/CollectingAuditEntryWriter.php b/tests/Fixtures/Service/CollectingAuditEntryWriter.php new file mode 100644 index 0000000..27b87e5 --- /dev/null +++ b/tests/Fixtures/Service/CollectingAuditEntryWriter.php @@ -0,0 +1,19 @@ + */ + public array $messages = []; + + public function write(PersistAuditEntryMessage $message): void + { + $this->messages[] = $message; + } +} diff --git a/tests/Integration/AuditableDoctrineListenerTest.php b/tests/Integration/AuditableDoctrineListenerTest.php index 6cecae2..b2a1ab3 100644 --- a/tests/Integration/AuditableDoctrineListenerTest.php +++ b/tests/Integration/AuditableDoctrineListenerTest.php @@ -12,11 +12,6 @@ final class AuditableDoctrineListenerTest extends TestCase { - public function testListenerIsInstantiable(): void - { - $this->markTestSkipped('Requires Doctrine integration setup'); - } - public function testAuditableEntityMetadata(): void { $metadataProvider = new AuditableMetadataProvider(); @@ -41,6 +36,33 @@ public function testNonAuditableEntityMetadata(): void self::assertNull($metadata); } + + public function testDefaultLabelEmptyFieldLabelAndCache(): void + { + $metadataProvider = new AuditableMetadataProvider(); + $entity = new DefaultMetadataEntity(); + + $first = $metadataProvider->getFor($entity); + $second = $metadataProvider->getFor($entity); + + self::assertNotNull($first); + self::assertSame('DefaultMetadataEntity', $first->label); + self::assertNull($first->context); + self::assertSame([], $first->fieldLabels); + self::assertSame([], $first->ignoredFields); + self::assertSame($first, $second); + } + + public function testNonAuditableResultIsCached(): void + { + $provider = new AuditableMetadataProvider(); + $entity = new NonAuditableTestEntity(); + + self::assertNull($provider->getFor($entity)); + self::assertNull($provider->getFor($entity)); + $cache = new \ReflectionProperty($provider, 'cache'); + self::assertArrayHasKey(NonAuditableTestEntity::class, $cache->getValue($provider)); + } } #[Auditable(label: 'AuditableTest', context: 'test-context')] @@ -87,3 +109,15 @@ public function setName(string $name): void $this->name = $name; } } + +#[Auditable] +final class DefaultMetadataEntity +{ + #[AuditField(label: ' ')] + private string $emptyLabel = ''; + + public function getEmptyLabel(): string + { + return $this->emptyLabel; + } +} diff --git a/tests/Integration/ContainerTest.php b/tests/Integration/ContainerTest.php new file mode 100644 index 0000000..6772bc7 --- /dev/null +++ b/tests/Integration/ContainerTest.php @@ -0,0 +1,69 @@ +registerBundles()); + + self::assertContainsOnlyInstancesOf(FrameworkBundle::class, [$bundles[0]]); + self::assertContainsOnlyInstancesOf(DoctrineBundle::class, [$bundles[2]]); + self::assertContainsOnlyInstancesOf(ZhorteinAuditableBundle::class, [$bundles[3]]); + } + + /** @param array $config */ + #[DataProvider('writerAliases')] + public function testLegacyDefinitionsAliasesAndParameters(array $config, string $writer): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([$config], $container); + + self::assertTrue($container->hasDefinition(Historizer::class)); + self::assertTrue($container->hasDefinition(AuditableDoctrineListener::class)); + self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); + // Symfony 7.4's PHP configurator public() has no boolean argument: the historical + // public(false) call therefore makes this alias public instead of private. + self::assertTrue($container->getAlias(ActorResolverInterface::class)->isPublic()); + self::assertSame($writer, (string) $container->getAlias(AuditEntryWriterInterface::class)); + self::assertFalse($container->getAlias(AuditEntryWriterInterface::class)->isPublic()); + self::assertSame([], $container->getDefinition(AsyncAuditEntryWriter::class)->getArguments()); + self::assertFalse($container->getDefinition(Historizer::class)->isPublic()); + self::assertFalse($container->getDefinition(AuditableDoctrineListener::class)->isPublic()); + + self::assertTrue($container->getParameter('zhortein_auditable.enabled')); + self::assertSame($config['async']['enabled'] ?? true, $container->getParameter('zhortein_auditable.async.enabled')); + self::assertSame('async', $container->getParameter('zhortein_auditable.async.transport')); + self::assertTrue($container->getParameter('zhortein_auditable.listener.track_insert')); + self::assertTrue($container->getParameter('zhortein_auditable.listener.track_update')); + self::assertTrue($container->getParameter('zhortein_auditable.listener.track_delete')); + self::assertSame(180, $container->getParameter('zhortein_auditable.fields.max_string_length')); + self::assertSame([], $container->getParameter('zhortein_auditable.fields.global_ignored')); + } + + /** @return iterable, class-string}> */ + public static function writerAliases(): iterable + { + yield 'async by default' => [[], AsyncAuditEntryWriter::class]; + yield 'sync when disabled' => [['async' => ['enabled' => false]], SyncAuditEntryWriter::class]; + } +} diff --git a/tests/Integration/DoctrineBundleIntegrationTest.php b/tests/Integration/DoctrineBundleIntegrationTest.php new file mode 100644 index 0000000..62bb648 --- /dev/null +++ b/tests/Integration/DoctrineBundleIntegrationTest.php @@ -0,0 +1,115 @@ +kernel = new TestKernel(); + $this->kernel->boot(); + + $testContainer = $this->kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $this->testContainer = $testContainer; + $registry = $testContainer->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + $manager = $registry->getManagerForClass(AuditEntry::class); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + $this->entityManager = $manager; + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + $cacheDir = $this->kernel->getCacheDir(); + $this->kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + public function testBundleMappingSchemaAndDoctrineListenerUseDoctrineBundleEntityManager(): void + { + $metadata = $this->entityManager->getClassMetadata(AuditEntry::class); + self::assertSame(AuditEntry::class, $metadata->getName()); + self::assertSame('audit_entry', $metadata->getTableName()); + self::assertSame([ + 'id', + 'occurred_at', + 'action', + 'level', + 'title', + 'description', + 'context', + 'entity_class', + 'entity_id', + 'actor_id', + 'impersonator_id', + 'is_auto', + 'data', + ], array_values(array_map(static fn ($mapping): string => $mapping->columnName, $metadata->fieldMappings))); + + $indexes = $metadata->table['indexes'] ?? null; + self::assertIsArray($indexes); + self::assertSame(['columns' => ['occurred_at']], $indexes['idx_audit_entry_occurred_at']); + self::assertSame(['columns' => ['entity_class', 'entity_id']], $indexes['idx_audit_entry_entity']); + + $allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + (new SchemaTool($this->entityManager))->createSchema($allMetadata); + self::assertTrue($this->entityManager->getConnection()->createSchemaManager()->tablesExist(['audit_entry'])); + + // test.service_container can retrieve this private alias; that does not alter + // the visibility asserted directly on the pre-compilation ContainerBuilder. + self::assertInstanceOf(AsyncAuditEntryWriter::class, $this->testContainer->get(AuditEntryWriterInterface::class)); + + foreach ([Events::onFlush, Events::postPersist, Events::preRemove] as $event) { + $listeners = $this->entityManager->getEventManager()->getListeners($event); + $listenerFound = false; + foreach ($listeners as $listener) { + if ($listener instanceof AuditableDoctrineListener) { + $listenerFound = true; + break; + } + } + self::assertTrue($listenerFound, \sprintf('The bundle listener is not registered for %s.', $event)); + } + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/DoctrineMappingTest.php b/tests/Integration/DoctrineMappingTest.php new file mode 100644 index 0000000..c0afcaa --- /dev/null +++ b/tests/Integration/DoctrineMappingTest.php @@ -0,0 +1,91 @@ +entityManager = DoctrineTestFactory::createEntityManager(); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + } + + public function testLegacyAuditEntryMappingIsExact(): void + { + $metadata = $this->entityManager->getClassMetadata(AuditEntry::class); + + self::assertSame(AuditEntry::class, $metadata->getName()); + self::assertSame('audit_entry', $metadata->getTableName()); + self::assertSame(['id'], $metadata->getIdentifierFieldNames()); + self::assertTrue($metadata->isIdGeneratorIdentity()); + self::assertSame([], $metadata->associationMappings); + $indexes = $metadata->table['indexes'] ?? null; + self::assertIsArray($indexes); + self::assertSame([ + 'idx_audit_entry_occurred_at' => ['columns' => ['occurred_at']], + 'idx_audit_entry_entity' => ['columns' => ['entity_class', 'entity_id']], + ], $indexes); + + $actual = []; + foreach ($metadata->fieldMappings as $field => $mapping) { + $actual[$mapping->columnName] = [ + 'field' => $field, + 'type' => $mapping->type, + 'length' => $mapping->length, + 'nullable' => $mapping->nullable, + 'id' => $mapping->id ?? false, + 'options' => $mapping->options ?? [], + ]; + } + + self::assertSame(self::expectedColumns(), $actual); + } + + public function testGeneratedSchemaContainsOnlyLegacyAndFixtureTables(): void + { + $tool = new SchemaTool($this->entityManager); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $tool->dropSchema($metadata); + $tool->createSchema($metadata); + + $tables = $this->entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], $tables); + self::assertCount(3, $tables); + self::assertSame([], $this->entityManager->getClassMetadata(AuditEntry::class)->associationMappings); + } + + /** @return array}> */ + private static function expectedColumns(): array + { + return [ + 'id' => ['field' => 'id', 'type' => 'integer', 'length' => null, 'nullable' => false, 'id' => true, 'options' => []], + 'occurred_at' => ['field' => 'occurredAt', 'type' => 'datetime_immutable', 'length' => null, 'nullable' => false, 'id' => false, 'options' => []], + 'action' => ['field' => 'action', 'type' => 'string', 'length' => 50, 'nullable' => false, 'id' => false, 'options' => []], + 'level' => ['field' => 'level', 'type' => 'string', 'length' => 20, 'nullable' => false, 'id' => false, 'options' => []], + 'title' => ['field' => 'title', 'type' => 'string', 'length' => 255, 'nullable' => false, 'id' => false, 'options' => []], + 'description' => ['field' => 'description', 'type' => 'text', 'length' => null, 'nullable' => true, 'id' => false, 'options' => []], + 'context' => ['field' => 'context', 'type' => 'string', 'length' => 255, 'nullable' => true, 'id' => false, 'options' => []], + 'entity_class' => ['field' => 'entityClass', 'type' => 'string', 'length' => 255, 'nullable' => true, 'id' => false, 'options' => []], + 'entity_id' => ['field' => 'entityId', 'type' => 'string', 'length' => 64, 'nullable' => true, 'id' => false, 'options' => []], + 'actor_id' => ['field' => 'actorId', 'type' => 'string', 'length' => 64, 'nullable' => true, 'id' => false, 'options' => []], + 'impersonator_id' => ['field' => 'impersonatorId', 'type' => 'string', 'length' => 64, 'nullable' => true, 'id' => false, 'options' => []], + 'is_auto' => ['field' => 'isAuto', 'type' => 'boolean', 'length' => null, 'nullable' => false, 'id' => false, 'options' => ['default' => false]], + 'data' => ['field' => 'data', 'type' => 'json', 'length' => null, 'nullable' => true, 'id' => false, 'options' => []], + ]; + } +} diff --git a/tests/Integration/ListenerBehaviorTest.php b/tests/Integration/ListenerBehaviorTest.php new file mode 100644 index 0000000..6b257a3 --- /dev/null +++ b/tests/Integration/ListenerBehaviorTest.php @@ -0,0 +1,188 @@ +entityManager = DoctrineTestFactory::createEntityManager(); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + (new SchemaTool($this->entityManager))->dropSchema($metadata); + (new SchemaTool($this->entityManager))->createSchema($metadata); + $this->writer = new CollectingAuditEntryWriter(); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + } + + public function testCreateUpdateDeleteAndCurrentMessages(): void + { + $this->registerListener(); + $entity = new AuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + + $create = $this->writer->messages[0]; + self::assertSame(AuditAction::CREATE->value, $create->action); + self::assertSame(AuditLevel::INFO->value, $create->level); + self::assertSame('Create [Characterized entity]', $create->title); + self::assertSame('Entity created: '.AuditableEntity::class, $create->description); + self::assertSame('fixture-context', $create->context); + self::assertFalse($create->isAuto); + + $entity->setName('after'); + $entity->setSecret('changed-secret'); + $entity->setGloballyIgnored('changed-global'); + $this->entityManager->flush(); + + $update = $this->writer->messages[1]; + self::assertSame(AuditAction::UPDATE->value, $update->action); + self::assertSame(AuditLevel::INFO->value, $update->level); + self::assertSame('Update [Characterized entity] - 1 field(s) changed', $update->title); + self::assertSame('Public name : before → after', $update->description); + self::assertSame(['Public name' => 'Public name : before → after'], $update->data); + self::assertSame('fixture-context', $update->context); + self::assertFalse($update->isAuto); + + $this->entityManager->remove($entity); + $this->entityManager->flush(); + + $delete = $this->writer->messages[2]; + self::assertSame(AuditAction::DELETE->value, $delete->action); + self::assertSame('Delete [Characterized entity]', $delete->title); + self::assertSame('Entity removed: '.AuditableEntity::class, $delete->description); + self::assertFalse($delete->isAuto); + self::assertCount(3, $this->writer->messages); + } + + public function testNonAuditableEntityProducesNothing(): void + { + $this->registerListener(); + $entity = new NonAuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $entity->setName('after'); + $this->entityManager->flush(); + $this->entityManager->remove($entity); + $this->entityManager->flush(); + + self::assertSame([], $this->writer->messages); + } + + public function testUpdateWithOnlyIgnoredChangesProducesNothing(): void + { + $this->registerListener(); + $entity = new AuditableEntity('same'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->writer->messages = []; + $entity->setSecret('changed-secret'); + $entity->setGloballyIgnored('changed-global'); + $this->entityManager->flush(); + + self::assertSame([], $this->writer->messages); + } + + public function testTrackInsertCanBeDisabled(): void + { + $this->registerListener(trackInsert: false); + $this->entityManager->persist(new AuditableEntity('name')); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + public function testTrackUpdateCanBeDisabled(): void + { + $this->registerListener(trackUpdate: false); + $entity = new AuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->writer->messages = []; + $entity->setName('after'); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + public function testTrackDeleteCanBeDisabled(): void + { + $this->registerListener(trackDelete: false); + $entity = new AuditableEntity('name'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->writer->messages = []; + $this->entityManager->remove($entity); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + public function testGlobalDisableProducesNothing(): void + { + $this->registerListener(enabled: false); + $entity = new AuditableEntity('before'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $entity->setName('after'); + $this->entityManager->flush(); + $this->entityManager->remove($entity); + $this->entityManager->flush(); + self::assertSame([], $this->writer->messages); + } + + private function registerListener( + bool $enabled = true, + bool $trackInsert = true, + bool $trackUpdate = true, + bool $trackDelete = true, + ): void { + $resolver = new class implements ActorResolverInterface { + public function resolveActorId(): ?string + { + return 'fixture-actor'; + } + + public function resolveImpersonatorId(): ?string + { + return null; + } + }; + $listener = new AuditableDoctrineListener( + new Historizer($this->writer, $resolver, new NullLogger()), + new AuditableMetadataProvider(), + new ChangeDetector(), + $enabled, + $trackInsert, + $trackUpdate, + $trackDelete, + ['globallyIgnored'], + ); + $this->entityManager->getEventManager()->addEventListener( + [Events::onFlush, Events::postPersist, Events::preRemove], + $listener, + ); + } +} diff --git a/tests/Unit/ChangeDetectorTest.php b/tests/Unit/ChangeDetectorTest.php index 48f563f..d57d735 100644 --- a/tests/Unit/ChangeDetectorTest.php +++ b/tests/Unit/ChangeDetectorTest.php @@ -4,56 +4,133 @@ namespace Zhortein\AuditableBundle\Tests\Unit; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Zhortein\AuditableBundle\Service\ChangeDetector; final class ChangeDetectorTest extends TestCase { - private ChangeDetector $detector; + #[DataProvider('scalarAndObjectValues')] + public function testCurrentlyProducedRepresentations(mixed $value, string $expected): void + { + self::assertSame($expected, self::stringify(new ChangeDetector(), $value)); + } + + /** @return iterable */ + public static function scalarAndObjectValues(): iterable + { + yield 'null' => [null, '∅']; + yield 'true' => [true, 'true']; + yield 'false' => [false, 'false']; + yield 'date time' => [new \DateTimeImmutable('2025-01-02 03:04:05+00:00'), '2025-01-02 03:04:05']; + yield 'backed enum' => [DetectorEnum::VALUE, 'VALUE (stored)']; + yield 'integer' => [42, '42']; + yield 'float' => [12.5, '12.5']; + yield 'string' => ['text', 'text']; + yield 'serializable array' => [['url' => '/a/b', 'accent' => 'été'], '{"url":"/a/b","accent":"été"}']; + yield 'stringable object' => [new DetectorStringable(), 'string value']; + yield 'getName object' => [new DetectorNamed(), '[DetectorNamed#name value]']; + yield 'getTitle object' => [new DetectorTitled(), '[DetectorTitled#title value]']; + yield 'getId object' => [new DetectorIdentified(), '[DetectorIdentified#123]']; + yield 'unknown object' => [new DetectorUnknown(), '[object DetectorUnknown]']; + } + + public function testNonSerializableArray(): void + { + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + self::assertSame('[array serialization error]', self::stringify(new ChangeDetector(), [$resource])); + fclose($resource); + } + + public function testEmptyCollection(): void + { + self::assertSame('[Collection 0 items: ]', self::stringify(new ChangeDetector(), new ArrayCollection())); + } + + public function testCollectionSamplesKnownRepresentationsAndStopsAtThree(): void + { + $collection = new ArrayCollection([ + new DetectorStringable(), + new DetectorNamed(), + new DetectorTitled(), + new DetectorIdentified(), + ]); + + self::assertSame( + '[Collection 4 items: string value, name value, title value, …]', + self::stringify(new ChangeDetector(), $collection), + ); + } - protected function setUp(): void + public function testCollectionUsesIdAndUnknownClassName(): void { - $this->detector = new ChangeDetector(maxStringLength: 180); + self::assertSame( + '[Collection 2 items: DetectorIdentified#123, DetectorUnknown]', + self::stringify(new ChangeDetector(), new ArrayCollection([new DetectorIdentified(), new DetectorUnknown()])), + ); } - public function testDetectorCanBeInstantiated(): void + public function testTruncationKeepsConfiguredLengthAndAddsEllipsis(): void { - self::assertInstanceOf(ChangeDetector::class, $this->detector); + self::assertSame('1234…', self::stringify(new ChangeDetector(5), '123456789')); + self::assertSame(5, mb_strlen(self::stringify(new ChangeDetector(5), '123456789'))); } - public function testDetectorWithCustomMaxStringLength(): void + public function testZeroAndNegativeLengthsDisableTruncation(): void { - $detector = new ChangeDetector(maxStringLength: 50); - self::assertInstanceOf(ChangeDetector::class, $detector); + self::assertSame('123456789', self::stringify(new ChangeDetector(0), '123456789')); + self::assertSame('123456789', self::stringify(new ChangeDetector(-1), '123456789')); } - public function testDetectorWithZeroMaxStringLength(): void + private static function stringify(ChangeDetector $detector, mixed $value): string { - $detector = new ChangeDetector(maxStringLength: 0); - self::assertInstanceOf(ChangeDetector::class, $detector); + $method = new \ReflectionMethod($detector, 'stringify'); + $result = $method->invoke($detector, $value); + self::assertIsString($result); + + return $result; } +} + +enum DetectorEnum: string +{ + case VALUE = 'stored'; +} - public function testDetectorWithNegativeMaxStringLength(): void +final class DetectorStringable +{ + public function __toString(): string { - $detector = new ChangeDetector(maxStringLength: -1); - self::assertInstanceOf(ChangeDetector::class, $detector); + return 'string value'; } +} - public function testStringifyMethodExists(): void +final class DetectorNamed +{ + public function getName(): string { - $reflectionClass = new \ReflectionClass($this->detector); - self::assertTrue($reflectionClass->hasMethod('stringify')); + return 'name value'; } +} - public function testTruncateMethodExists(): void +final class DetectorTitled +{ + public function getTitle(): string { - $reflectionClass = new \ReflectionClass($this->detector); - self::assertTrue($reflectionClass->hasMethod('truncate')); + return 'title value'; } +} - public function testDetectorIsReadonly(): void +final class DetectorIdentified +{ + public function getId(): int { - $reflectionClass = new \ReflectionClass($this->detector); - self::assertTrue($reflectionClass->isReadonly()); + return 123; } } + +final class DetectorUnknown +{ +} diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php new file mode 100644 index 0000000..738e0c4 --- /dev/null +++ b/tests/Unit/ConfigurationTest.php @@ -0,0 +1,42 @@ + true, + 'async' => ['enabled' => true, 'transport' => 'async'], + 'listener' => ['track_insert' => true, 'track_update' => true, 'track_delete' => true], + 'fields' => ['max_string_length' => 180, 'global_ignored' => []], + ], $this->process([])); + } + + public function testExplicitHistoricalConfiguration(): void + { + $config = [ + 'enabled' => false, + 'async' => ['enabled' => false, 'transport' => 'audit_transport'], + 'listener' => ['track_insert' => false, 'track_update' => false, 'track_delete' => false], + 'fields' => ['max_string_length' => 30, 'global_ignored' => ['updatedAt', 'version']], + ]; + + self::assertSame($config, $this->process($config)); + } + + /** @param array $config + * @return array + */ + private function process(array $config): array + { + return (new Processor())->processConfiguration(new Configuration(), [$config]); + } +} diff --git a/tests/Unit/HistorizerTest.php b/tests/Unit/HistorizerTest.php new file mode 100644 index 0000000..eecda85 --- /dev/null +++ b/tests/Unit/HistorizerTest.php @@ -0,0 +1,175 @@ +createMock(AuditEntryWriterInterface::class); + $resolver = $this->createMock(ActorResolverInterface::class); + $writer->expects(self::never())->method('write'); + $resolver->expects(self::never())->method(self::anything()); + + (new Historizer($writer, $resolver, $this->createMock(LoggerInterface::class), false))->historize('log', 'title'); + } + + #[DataProvider('messageCases')] + public function testMessageValuesAreCharacterized( + AuditAction|string $action, + AuditLevel|string $level, + ?object $entity, + ?string $expectedId, + ): void { + $message = null; + $writer = $this->createMock(AuditEntryWriterInterface::class); + $writer->expects(self::once())->method('write')->with(self::callback( + static function (PersistAuditEntryMessage $actual) use (&$message): bool { + $message = $actual; + + return true; + } + )); + $resolver = $this->createConfiguredMock(ActorResolverInterface::class, [ + 'resolveActorId' => 'actor-1', + 'resolveImpersonatorId' => 'admin-2', + ]); + $before = new \DateTimeImmutable(); + + (new Historizer($writer, $resolver, $this->createMock(LoggerInterface::class)))->historize( + $action, + 'exact title', + 'exact description', + $entity, + 'exact-context', + $level, + data: ['raw' => ['nested' => true]], + ); + $after = new \DateTimeImmutable(); + + self::assertInstanceOf(PersistAuditEntryMessage::class, $message); + self::assertSame($action instanceof AuditAction ? $action->value : $action, $message->action); + self::assertSame($level instanceof AuditLevel ? $level->value : $level, $message->level); + self::assertSame('exact title', $message->title); + self::assertSame('exact description', $message->description); + self::assertSame('exact-context', $message->context); + self::assertSame(null === $entity ? null : $entity::class, $message->entityClass); + self::assertSame($expectedId, $message->entityId); + self::assertSame('actor-1', $message->actorId); + self::assertSame('admin-2', $message->impersonatorId); + self::assertFalse($message->isAuto); + self::assertSame(['raw' => ['nested' => true]], $message->data); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/', $message->occurredAt); + $occurredAt = new \DateTimeImmutable($message->occurredAt); + self::assertGreaterThanOrEqual($before->modify('-1 second'), $occurredAt); + self::assertLessThanOrEqual($after->modify('+1 second'), $occurredAt); + } + + /** @return iterable */ + public static function messageCases(): iterable + { + yield 'enum values and integer getId' => [AuditAction::CREATE, AuditLevel::WARNING, new EntityWithId(42), '42']; + yield 'strings and string getId' => ['custom-action', 'custom-level', new EntityWithId('uuid'), 'uuid']; + yield 'null getId' => ['log', 'info', new EntityWithId(null), null]; + yield 'no getId' => ['log', 'info', new EntityWithoutId(), null]; + yield 'id method is ignored' => ['log', 'info', new EntityWithIdMethod(), null]; + yield 'Stringable getId' => ['log', 'info', new EntityWithId(new StringableId()), 'stringable-id']; + yield 'no entity' => ['log', 'info', null, null]; + } + + #[DataProvider('failOpenCases')] + public function testFailOpenBehavior(string $failure): void + { + $writer = $this->createMock(AuditEntryWriterInterface::class); + $resolver = $this->createMock(ActorResolverInterface::class); + $entity = null; + + if ('resolver' === $failure) { + $writer->expects(self::never())->method('write'); + $resolver->expects(self::once())->method('resolveActorId')->willThrowException(new \RuntimeException('resolver failed')); + $resolver->expects(self::never())->method('resolveImpersonatorId'); + } else { + $resolver->method('resolveActorId')->willReturn('actor'); + $resolver->method('resolveImpersonatorId')->willReturn(null); + } + if ('identifier' === $failure) { + $writer->expects(self::never())->method('write'); + $resolver->expects(self::never())->method('resolveActorId'); + $resolver->expects(self::never())->method('resolveImpersonatorId'); + $entity = new ThrowingIdEntity(); + } + if ('writer' === $failure) { + $writer->expects(self::once())->method('write')->willThrowException(new \RuntimeException('writer failed')); + $resolver->expects(self::once())->method('resolveActorId'); + $resolver->expects(self::once())->method('resolveImpersonatorId'); + } + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error')->with( + self::stringContains('failed'), + self::callback(static fn (array $context): bool => $context['exception'] instanceof \RuntimeException), + ); + + (new Historizer($writer, $resolver, $logger))->historize('log', 'title', entity: $entity); + } + + /** @return iterable */ + public static function failOpenCases(): iterable + { + yield 'resolver exception' => ['resolver']; + yield 'identifier exception' => ['identifier']; + yield 'writer exception' => ['writer']; + } +} + +final class EntityWithId +{ + public function __construct(private readonly mixed $id) + { + } + + public function getId(): mixed + { + return $this->id; + } +} + +final class EntityWithoutId +{ +} + +final class EntityWithIdMethod +{ + public function id(): int + { + return 7; + } +} + +final class StringableId implements \Stringable +{ + public function __toString(): string + { + return 'stringable-id'; + } +} + +final class ThrowingIdEntity +{ + public function getId(): never + { + throw new \RuntimeException('identifier failed'); + } +} diff --git a/tests/Unit/PersistencePipelineTest.php b/tests/Unit/PersistencePipelineTest.php new file mode 100644 index 0000000..af2c693 --- /dev/null +++ b/tests/Unit/PersistencePipelineTest.php @@ -0,0 +1,106 @@ +createMock(MessageBusInterface::class); + $bus->expects(self::once())->method('dispatch')->with( + self::identicalTo($message), + self::identicalTo([]), + )->willReturn(new Envelope($message)); + + (new AsyncAuditEntryWriter($bus))->write($message); + } + + public function testSyncWriterPersistsMessageExactlyOnce(): void + { + $message = self::message(); + $entityManager = $this->entityManagerExpectingPersistThenFlush($message); + + (new SyncAuditEntryWriter(new AuditEntryPersister($entityManager)))->write($message); + } + + public function testPersisterCopiesEveryFieldAndPersistsBeforeFlush(): void + { + $message = self::message(); + (new AuditEntryPersister($this->entityManagerExpectingPersistThenFlush($message)))->persist($message); + } + + public function testHandlerIsInvocableAndDelegatesOnce(): void + { + $message = self::message(); + $handler = new PersistAuditEntryMessageHandler( + new AuditEntryPersister($this->entityManagerExpectingPersistThenFlush($message)), + ); + + self::assertIsCallable($handler); + $handler($message); + } + + private function entityManagerExpectingPersistThenFlush(PersistAuditEntryMessage $message): EntityManagerInterface + { + $sequence = 0; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist')->with(self::callback( + static function (AuditEntry $entry) use ($message, &$sequence): bool { + self::assertSame(0, $sequence++); + self::assertSame($message->occurredAt, $entry->getOccurredAt()->format(\DateTimeInterface::RFC3339_EXTENDED)); + self::assertSame($message->action, $entry->getAction()); + self::assertSame($message->level, $entry->getLevel()); + self::assertSame($message->title, $entry->getTitle()); + self::assertSame($message->description, $entry->getDescription()); + self::assertSame($message->context, $entry->getContext()); + self::assertSame($message->entityClass, $entry->getEntityClass()); + self::assertSame($message->entityId, $entry->getEntityId()); + self::assertSame($message->actorId, $entry->getActorId()); + self::assertSame($message->impersonatorId, $entry->getImpersonatorId()); + self::assertSame($message->isAuto, $entry->isAuto()); + self::assertSame($message->data, $entry->getData()); + + return true; + } + )); + $entityManager->expects(self::once())->method('flush')->with()->willReturnCallback( + static function () use (&$sequence): void { + self::assertSame(1, $sequence++); + } + ); + + return $entityManager; + } + + private static function message(): PersistAuditEntryMessage + { + return new PersistAuditEntryMessage( + '2025-12-21T10:11:12.123+00:00', + 'update', + 'warning', + 'Title', + 'Description', + 'context', + 'Example\\Entity', + '42', + 'actor', + 'impersonator', + true, + ['field' => ['old' => 'before', 'new' => 'after']], + ); + } +} From af9ca64e0f270994c7f5f40b156e77817ed2d203 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 16:58:04 +0200 Subject: [PATCH 02/28] test: support PHP 8.5 and Symfony 8 fixtures Canonicalize equivalent reflection types across PHP versions and adapt Doctrine test fixtures for DoctrineBundle 2/3 and PHP 8.4 native lazy objects without changing the 1.0.0 API snapshot. --- tests/Contract/PublicApiContractTest.php | 78 ++++++++++++++++++++-- tests/Fixtures/App/DoctrineTestFactory.php | 4 ++ tests/Fixtures/App/TestKernel.php | 29 +++++--- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/tests/Contract/PublicApiContractTest.php b/tests/Contract/PublicApiContractTest.php index e22241a..1113208 100644 --- a/tests/Contract/PublicApiContractTest.php +++ b/tests/Contract/PublicApiContractTest.php @@ -40,6 +40,17 @@ public function testAdditionalPublicTypesAndMembersAreAllowed(): void ]; self::assertHistoricalContractIsPreserved($historical, $current); + $reflection = new \ReflectionClass(CanonicalTypeChild::class); + + self::assertSame('self', self::type($reflection->getMethod('selfType')->getReturnType(), $reflection)); + self::assertSame('parent', self::type($reflection->getMethod('parentType')->getReturnType(), $reflection)); + self::assertSame('static', self::type($reflection->getMethod('staticType')->getReturnType(), $reflection)); + self::assertSame('?self', self::type($reflection->getMethod('nullableSelfType')->getReturnType(), $reflection)); + self::assertSame('self|string', self::type($reflection->getMethod('unionType')->getReturnType(), $reflection)); + self::assertSame( + CanonicalTypeLeft::class.'&'.CanonicalTypeRight::class, + self::type($reflection->getMethod('intersectionType')->getReturnType(), $reflection), + ); } /** @param array $current */ @@ -77,7 +88,7 @@ private static function publicApi(): array foreach ($method->getParameters() as $parameter) { $parameters[] = [ 'name' => $parameter->getName(), - 'type' => self::type($parameter->getType()), + 'type' => self::type($parameter->getType(), $reflection), 'by_reference' => $parameter->isPassedByReference(), 'variadic' => $parameter->isVariadic(), 'has_default' => $parameter->isDefaultValueAvailable(), @@ -86,7 +97,7 @@ private static function publicApi(): array } $methods[$method->getName()] = [ 'static' => $method->isStatic(), - 'return' => self::type($method->getReturnType()), + 'return' => self::type($method->getReturnType(), $reflection), 'parameters' => $parameters, ]; } @@ -98,7 +109,7 @@ private static function publicApi(): array continue; } $properties[$property->getName()] = [ - 'type' => self::type($property->getType()), + 'type' => self::type($property->getType(), $reflection), 'readonly' => $property->isReadOnly(), 'static' => $property->isStatic(), ]; @@ -196,22 +207,30 @@ static function (string $symbol) use ($sourceDirectory): bool { return $symbols; } - private static function type(?\ReflectionType $type): ?string + /** @param \ReflectionClass $declaringClass */ + private static function type(?\ReflectionType $type, \ReflectionClass $declaringClass): ?string { if (null === $type) { return null; } if ($type instanceof \ReflectionNamedType) { - return ($type->allowsNull() && 'mixed' !== $type->getName() ? '?' : '').$type->getName(); + $name = $type->getName(); + if ($name === $declaringClass->getName()) { + $name = 'self'; + } elseif (false !== $declaringClass->getParentClass() && $name === $declaringClass->getParentClass()->getName()) { + $name = 'parent'; + } + + return ($type->allowsNull() && 'mixed' !== $name ? '?' : '').$name; } if ($type instanceof \ReflectionUnionType) { - return implode('|', array_map(self::type(...), $type->getTypes())); + return implode('|', array_map(static fn (\ReflectionType $member): ?string => self::type($member, $declaringClass), $type->getTypes())); } if (!$type instanceof \ReflectionIntersectionType) { throw new \LogicException('Unsupported reflection type.'); } - return implode('&', array_map(self::type(...), $type->getTypes())); + return implode('&', array_map(static fn (\ReflectionType $member): ?string => self::type($member, $declaringClass), $type->getTypes())); } private static function value(mixed $value): mixed @@ -234,3 +253,48 @@ private static function attributeTargets(\ReflectionClass $reflection): ?int return [] === $attributes ? null : $attributes[0]->newInstance()->flags; } } + +interface CanonicalTypeLeft +{ +} + +interface CanonicalTypeRight +{ +} + +class CanonicalTypeParent +{ +} + +final class CanonicalTypeChild extends CanonicalTypeParent +{ + public function selfType(): self + { + throw new \LogicException(); + } + + public function parentType(): parent + { + throw new \LogicException(); + } + + public function staticType(): static + { + throw new \LogicException(); + } + + public function nullableSelfType(): ?self + { + throw new \LogicException(); + } + + public function unionType(): self|string + { + throw new \LogicException(); + } + + public function intersectionType(): CanonicalTypeLeft&CanonicalTypeRight + { + throw new \LogicException(); + } +} diff --git a/tests/Fixtures/App/DoctrineTestFactory.php b/tests/Fixtures/App/DoctrineTestFactory.php index 5aee962..38adaa5 100644 --- a/tests/Fixtures/App/DoctrineTestFactory.php +++ b/tests/Fixtures/App/DoctrineTestFactory.php @@ -21,6 +21,10 @@ public static function createEntityManager(): EntityManagerInterface \dirname(__DIR__).'/Entity', ], true); + if (\PHP_VERSION_ID >= 80400 && method_exists($configuration, 'enableNativeLazyObjects')) { + $configuration->enableNativeLazyObjects(true); + } + return new EntityManager($connection, $configuration, $eventManager); } } diff --git a/tests/Fixtures/App/TestKernel.php b/tests/Fixtures/App/TestKernel.php index aafe636..148e514 100644 --- a/tests/Fixtures/App/TestKernel.php +++ b/tests/Fixtures/App/TestKernel.php @@ -4,6 +4,8 @@ namespace Zhortein\AuditableBundle\Tests\Fixtures\App; +use Composer\InstalledVersions; +use Composer\Semver\VersionParser; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; @@ -32,6 +34,21 @@ public function registerBundles(): iterable public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container): void { + $ormConfig = [ + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ]; + + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + $container->loadFromExtension('framework', [ 'secret' => 'auditable-characterization-tests', 'test' => true, @@ -43,17 +60,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void ]); $container->loadFromExtension('doctrine', [ 'dbal' => ['url' => 'sqlite:///:memory:'], - 'orm' => [ - 'auto_generate_proxy_classes' => true, - 'mappings' => [ - 'TestFixtures' => [ - 'type' => 'attribute', - 'dir' => \dirname(__DIR__).'/Entity', - 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', - 'is_bundle' => false, - ], - ], - ], + 'orm' => $ormConfig, ]); $container->loadFromExtension('zhortein_auditable', $this->bundleConfig); }); From dd52559d2037e5452e533de810efef71634d2cb4 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 16:58:33 +0200 Subject: [PATCH 03/28] ci: execute supported compatibility matrix Exercise PHP 8.3 through 8.5, Symfony 7.4 and 8.x, DoctrineBundle 2/3 and Doctrine ORM 3 across lowest and latest dependencies, with separate static analysis, coding style, platform checks, and security audit gates. --- .github/ci/assert-versions.php | 67 ++++++++++++ .github/workflows/ci.yml | 187 ++++++++++++++++++++++++++------- 2 files changed, 218 insertions(+), 36 deletions(-) create mode 100644 .github/ci/assert-versions.php diff --git a/.github/ci/assert-versions.php b/.github/ci/assert-versions.php new file mode 100644 index 0000000..eed3043 --- /dev/null +++ b/.github/ci/assert-versions.php @@ -0,0 +1,67 @@ + \n"); + + exit(2); +} + +[, $expectedPhpMinor, $expectedSymfonyConstraint, $expectedDoctrineBundleMajor, $expectedDoctrineOrmMajor, $expectedDoctrineDbalMajor, $expectedDoctrinePersistenceMajor] = $argv; +$expectedSymfonyMinor = preg_replace('/\.\*$/', '', $expectedSymfonyConstraint); + +if (null === $expectedSymfonyMinor) { + fwrite(STDERR, "Invalid Symfony version constraint.\n"); + + exit(2); +} + +$packages = [ + 'symfony/framework-bundle', + 'doctrine/doctrine-bundle', + 'doctrine/orm', + 'doctrine/dbal', + 'doctrine/persistence', + 'phpunit/phpunit', + 'phpstan/phpstan', + 'friendsofphp/php-cs-fixer', +]; + +printf("PHP: %s\n", PHP_VERSION); + +foreach ($packages as $package) { + printf("%s: %s\n", $package, InstalledVersions::getPrettyVersion($package) ?? 'unknown'); +} + +$assertVersion = static function (string $package, string $expectedPrefix): void { + $version = InstalledVersions::getVersion($package); + + if (null === $version || !str_starts_with(ltrim($version, 'v'), $expectedPrefix.'.')) { + fwrite(STDERR, sprintf( + "Expected %s %s.x, resolved %s.\n", + $package, + $expectedPrefix, + InstalledVersions::getPrettyVersion($package) ?? 'unknown', + )); + + exit(1); + } +}; + +$actualPhpMinor = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; +if ($actualPhpMinor !== $expectedPhpMinor) { + fwrite(STDERR, sprintf("Expected PHP %s.x, running %s.\n", $expectedPhpMinor, PHP_VERSION)); + + exit(1); +} + +$assertVersion('symfony/framework-bundle', $expectedSymfonyMinor); +$assertVersion('doctrine/doctrine-bundle', $expectedDoctrineBundleMajor); +$assertVersion('doctrine/orm', $expectedDoctrineOrmMajor); +$assertVersion('doctrine/dbal', $expectedDoctrineDbalMajor); +$assertVersion('doctrine/persistence', $expectedDoctrinePersistenceMajor); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 983022b..c88114e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,61 +4,176 @@ on: push: pull_request: +permissions: + contents: read + +# DoctrineBundle 2 excludes Symfony 8. ORM 2 is outside the next release baseline. jobs: - tests: + compatibility: + name: Compatibility / ${{ matrix.name }} runs-on: ubuntu-latest + timeout-minutes: 20 strategy: fail-fast: false matrix: include: - - php: "8.3" + - name: PHP 8.3 / Symfony 7.4 / DBundle 2.19 / ORM 3 / lowest + php: "8.3" symfony: "7.4.*" - composer_flags: "" - deprecations: "max[direct]=0" - - - php: "8.4" + doctrine_bundle: "2.19" + doctrine_bundle_major: "2" + doctrine_orm: "3" + doctrine_dbal: "3" + doctrine_persistence: "3" + dependency_flags: "--prefer-lowest --prefer-stable" + - name: PHP 8.3 / Symfony 7.4 / DBundle 2 / ORM 3 / latest + php: "8.3" symfony: "7.4.*" - composer_flags: "" - deprecations: "max[direct]=0" - - # Symfony 8 requires PHP 8.4+ - - php: "8.4" + doctrine_bundle: "2" + doctrine_bundle_major: "2" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + - name: PHP 8.4 / Symfony 7.4 / DBundle 3 / ORM 3 / latest + php: "8.4" + symfony: "7.4.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + - name: PHP 8.4 / Symfony 8.0 / DBundle 3 / ORM 3 / latest + php: "8.4" symfony: "8.0.*" - composer_flags: "" - deprecations: "max[direct]=0" - + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + - name: PHP 8.5 / Symfony 7.4 / DBundle 3 / ORM 3 / latest + php: "8.5" + symfony: "7.4.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + - name: PHP 8.5 / Symfony 8.x latest / DBundle 3 / ORM 3 / latest + php: "8.5" + symfony: "8.*" + symfony_resolved: "8.1.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 with: php-version: "${{ matrix.php }}" + extensions: pdo_sqlite, mbstring, dom, xml, zip tools: composer:v2 coverage: none - - - name: Validate composer.json - run: composer validate --strict - - - name: Install Symfony Flex (for SYMFONY_REQUIRE) + - name: Install Symfony Flex run: | composer global config --no-plugins allow-plugins.symfony/flex true - composer global require --no-progress --no-scripts --no-plugins symfony/flex - - - name: Install dependencies + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies env: SYMFONY_REQUIRE: "${{ matrix.symfony }}" - run: | - composer update --prefer-dist --no-progress ${{ matrix.composer_flags }} - + run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" ${{ matrix.dependency_flags }} + - run: composer validate --strict + - name: Assert resolved versions + run: php .github/ci/assert-versions.php "${{ matrix.php }}" "${{ matrix.symfony_resolved || matrix.symfony }}" "${{ matrix.doctrine_bundle_major }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" + - run: composer check-platform-reqs - name: PHPUnit env: - SYMFONY_DEPRECATIONS_HELPER: "${{ matrix.deprecations }}" - run: vendor/bin/phpunit + SYMFONY_DEPRECATIONS_HELPER: "max[direct]=0" + run: composer test - - name: PHPStan - run: vendor/bin/phpstan analyse -c phpstan.neon.dist --no-progress + static-analysis: + name: Static analysis / ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: Symfony 7.4 / ORM 3 latest + symfony: "7.4.*" + doctrine_bundle: "3" + doctrine_orm: "3" + - name: Symfony 8.x / ORM 3 latest + symfony: "8.*" + doctrine_bundle: "3" + doctrine_orm: "3" + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: pdo_sqlite, mbstring, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "${{ matrix.symfony }}" + run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" + - run: composer validate --strict + - run: composer phpstan -- --memory-limit=512M - - name: CS Fixer (dry-run) - run: vendor/bin/php-cs-fixer fix --dry-run --diff + coding-style: + name: Coding style + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: pdo_sqlite, mbstring, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "7.4.*" + run: composer update --prefer-dist --no-interaction --no-progress --with doctrine/doctrine-bundle:^3.0 --with doctrine/orm:^3.0 + - run: composer validate --strict + - run: composer cs:check + + security-audit: + name: Security audit + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + extensions: pdo_sqlite, mbstring, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "8.*" + run: composer update --prefer-dist --no-interaction --no-progress --with doctrine/doctrine-bundle:^3.0 --with doctrine/orm:^3.0 + - run: composer validate --strict + - run: composer audit --locked --no-interaction From 591be406dae7fd8491040c7021d615fe1025c791 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 17:42:12 +0200 Subject: [PATCH 04/28] build!: require Doctrine ORM 3 and mbstring polyfill Raise the DoctrineBundle minimum to 2.19, drop Doctrine ORM 2 support, declare symfony/polyfill-mbstring as a direct runtime dependency, and prepare the main branch for the 2.x series. BREAKING CHANGE: Doctrine ORM 2 is no longer supported. DoctrineBundle 2.19 or 3.x and Doctrine ORM 3.x are now required. --- CHANGELOG.md | 15 +++++++++++++-- composer.json | 14 +++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622b459..45e5e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,5 +2,16 @@ ## [Unreleased] -## 0.1.0 - TBD -- Initial repository setup (CI, QA tooling, bundle skeleton). +### Changed + +- Doctrine ORM 3 is now the minimum supported ORM version. +- DoctrineBundle 2.19 or 3.x is now required. +- The next release targets the 2.x series because the supported platform baseline has changed. + +### Added + +- Direct runtime dependency on symfony/polyfill-mbstring, allowing the bundle to run without the native mbstring extension. + +## [1.0.0] - 2025-12-24 + +- Initial stable release. diff --git a/composer.json b/composer.json index ffb8da1..5a09ccf 100644 --- a/composer.json +++ b/composer.json @@ -24,14 +24,15 @@ "require": { "php": ">=8.3", "psr/log": "^3.0", - "doctrine/doctrine-bundle": "^2.5 || ^3.0", - "doctrine/orm": "^2.12 || ^3.0", + "doctrine/doctrine-bundle": "^2.19 || ^3.0", + "doctrine/orm": "^3.0", "symfony/config": "^7.4 || ^8.0", "symfony/dependency-injection": "^7.4 || ^8.0", - "symfony/http-kernel": "^7.4 || ^8.0", - "symfony/options-resolver": "^7.4 || ^8.0", "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", "symfony/messenger": "^7.4 || ^8.0", + "symfony/options-resolver": "^7.4 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", "symfony/security-bundle": "^7.4 || ^8.0" }, "require-dev": { @@ -40,6 +41,9 @@ "phpstan/phpstan": "^1.11", "friendsofphp/php-cs-fixer": "^3.60" }, + "suggest": { + "ext-mbstring": "Recommended for the best multibyte string performance." + }, "autoload": { "psr-4": { "Zhortein\\AuditableBundle\\": "src/" @@ -57,7 +61,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-main": "0.1-dev" + "dev-main": "2.0-dev" } }, "scripts": { From 112d199c9bbdde1fd319abaa144bd6cc0d1378b8 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 17:42:34 +0200 Subject: [PATCH 05/28] test: verify native and polyfilled mbstring behavior Exercise UTF-8 truncation with and without the native mbstring extension, assert the direct polyfill dependency and platform state, and keep the supported compatibility matrix green across PHP 8.3 through 8.5. --- .github/ci/assert-versions.php | 52 +++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 31 +++++++++++++++--- tests/Unit/ChangeDetectorTest.php | 10 ++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/.github/ci/assert-versions.php b/.github/ci/assert-versions.php index eed3043..fd5a738 100644 --- a/.github/ci/assert-versions.php +++ b/.github/ci/assert-versions.php @@ -27,6 +27,7 @@ 'doctrine/orm', 'doctrine/dbal', 'doctrine/persistence', + 'symfony/polyfill-mbstring', 'phpunit/phpunit', 'phpstan/phpstan', 'friendsofphp/php-cs-fixer', @@ -38,6 +39,22 @@ printf("%s: %s\n", $package, InstalledVersions::getPrettyVersion($package) ?? 'unknown'); } +if (!InstalledVersions::isInstalled('symfony/polyfill-mbstring')) { + fwrite(STDERR, "symfony/polyfill-mbstring is not installed.\n"); + + exit(1); +} + +$nativeMbstring = extension_loaded('mbstring'); +$iconv = extension_loaded('iconv'); +$mbStrlen = function_exists('mb_strlen'); +$mbSubstr = function_exists('mb_substr'); + +printf("Native mbstring: %s\n", $nativeMbstring ? 'loaded' : 'not loaded'); +printf("Native iconv: %s\n", $iconv ? 'loaded' : 'not loaded'); +printf("mb_strlen: %s\n", $mbStrlen ? 'available' : 'unavailable'); +printf("mb_substr: %s\n", $mbSubstr ? 'available' : 'unavailable'); + $assertVersion = static function (string $package, string $expectedPrefix): void { $version = InstalledVersions::getVersion($package); @@ -65,3 +82,38 @@ $assertVersion('doctrine/orm', $expectedDoctrineOrmMajor); $assertVersion('doctrine/dbal', $expectedDoctrineDbalMajor); $assertVersion('doctrine/persistence', $expectedDoctrinePersistenceMajor); + +$rootComposer = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/composer.json'), true, flags: JSON_THROW_ON_ERROR); +$rootRequire = is_array($rootComposer) && isset($rootComposer['require']) && is_array($rootComposer['require']) ? $rootComposer['require'] : []; +if (!array_key_exists('symfony/polyfill-mbstring', $rootRequire)) { + fwrite(STDERR, "symfony/polyfill-mbstring must be a direct runtime dependency.\n"); + + exit(1); +} + +$expectedNativeMbstring = getenv('EXPECT_NATIVE_MBSTRING'); +if (!in_array($expectedNativeMbstring, ['true', 'false'], true)) { + fwrite(STDERR, "EXPECT_NATIVE_MBSTRING must be true or false.\n"); + + exit(2); +} + +if (('true' === $expectedNativeMbstring) !== $nativeMbstring) { + fwrite(STDERR, sprintf("Expected native mbstring %s, but it is %s.\n", $expectedNativeMbstring, $nativeMbstring ? 'loaded' : 'not loaded')); + + exit(1); +} + +if (!$mbStrlen || !$mbSubstr) { + fwrite(STDERR, "Multibyte string functions are unavailable.\n"); + + exit(1); +} + +if ('false' === $expectedNativeMbstring) { + if (!$iconv || 3 !== mb_strlen('Été') || 'É' !== mb_substr('Été', 0, 1)) { + fwrite(STDERR, "The mbstring polyfill path is not functional.\n"); + + exit(1); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c88114e..a51b23a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: doctrine_dbal: "3" doctrine_persistence: "3" dependency_flags: "--prefer-lowest --prefer-stable" + native_mbstring: false + extensions: "pdo_sqlite, :mbstring, iconv, dom, xml, zip" - name: PHP 8.3 / Symfony 7.4 / DBundle 2 / ORM 3 / latest php: "8.3" symfony: "7.4.*" @@ -35,6 +37,8 @@ jobs: doctrine_dbal: "4" doctrine_persistence: "4" dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" - name: PHP 8.4 / Symfony 7.4 / DBundle 3 / ORM 3 / latest php: "8.4" symfony: "7.4.*" @@ -44,6 +48,8 @@ jobs: doctrine_dbal: "4" doctrine_persistence: "4" dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" - name: PHP 8.4 / Symfony 8.0 / DBundle 3 / ORM 3 / latest php: "8.4" symfony: "8.0.*" @@ -53,6 +59,8 @@ jobs: doctrine_dbal: "4" doctrine_persistence: "4" dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" - name: PHP 8.5 / Symfony 7.4 / DBundle 3 / ORM 3 / latest php: "8.5" symfony: "7.4.*" @@ -62,6 +70,8 @@ jobs: doctrine_dbal: "4" doctrine_persistence: "4" dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" - name: PHP 8.5 / Symfony 8.x latest / DBundle 3 / ORM 3 / latest php: "8.5" symfony: "8.*" @@ -72,12 +82,14 @@ jobs: doctrine_dbal: "4" doctrine_persistence: "4" dependency_flags: "" + native_mbstring: true + extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: "${{ matrix.php }}" - extensions: pdo_sqlite, mbstring, dom, xml, zip + extensions: "${{ matrix.extensions }}" tools: composer:v2 coverage: none - name: Install Symfony Flex @@ -90,12 +102,21 @@ jobs: run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" ${{ matrix.dependency_flags }} - run: composer validate --strict - name: Assert resolved versions + env: + EXPECT_NATIVE_MBSTRING: "${{ matrix.native_mbstring }}" run: php .github/ci/assert-versions.php "${{ matrix.php }}" "${{ matrix.symfony_resolved || matrix.symfony }}" "${{ matrix.doctrine_bundle_major }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" - run: composer check-platform-reqs - name: PHPUnit env: SYMFONY_DEPRECATIONS_HELPER: "max[direct]=0" - run: composer test + run: | + if [[ "${{ matrix.native_mbstring }}" == "false" ]]; then + # PHPUnit 11's launcher requires native mbstring. Calling its + # application directly runs the same 67 tests with the polyfill. + php -r 'require "vendor/autoload.php"; exit((new PHPUnit\TextUI\Application())->run(["phpunit"]));' + else + composer test + fi static-analysis: name: Static analysis / ${{ matrix.name }} @@ -118,7 +139,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: "8.4" - extensions: pdo_sqlite, mbstring, dom, xml, zip + extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip tools: composer:v2 coverage: none - name: Install Symfony Flex @@ -141,7 +162,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: "8.4" - extensions: pdo_sqlite, mbstring, dom, xml, zip + extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip tools: composer:v2 coverage: none - name: Install Symfony Flex @@ -164,7 +185,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: "8.5" - extensions: pdo_sqlite, mbstring, dom, xml, zip + extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip tools: composer:v2 coverage: none - name: Install Symfony Flex diff --git a/tests/Unit/ChangeDetectorTest.php b/tests/Unit/ChangeDetectorTest.php index d57d735..72c83db 100644 --- a/tests/Unit/ChangeDetectorTest.php +++ b/tests/Unit/ChangeDetectorTest.php @@ -78,6 +78,16 @@ public function testTruncationKeepsConfiguredLengthAndAddsEllipsis(): void self::assertSame(5, mb_strlen(self::stringify(new ChangeDetector(5), '123456789'))); } + public function testUtf8TruncationCountsCharactersAndPreservesMultibyteCharacters(): void + { + $result = self::stringify(new ChangeDetector(6), 'Éléphant à Tokyo'); + + self::assertSame('Éléph…', $result); + self::assertSame(6, mb_strlen($result)); + self::assertGreaterThan(6, \strlen($result)); + self::assertSame(1, preg_match('//u', $result)); + } + public function testZeroAndNegativeLengthsDisableTruncation(): void { self::assertSame('123456789', self::stringify(new ChangeDetector(0), '123456789')); From 98c096e8c7b9231000f19f121c295ce681640c2b Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 17:42:56 +0200 Subject: [PATCH 06/28] docs: document the 2.0 platform baseline Document PHP 8.3+, Symfony 7.4 and 8.x, Doctrine ORM 3, DoctrineBundle 2.19/3.x, and the native-or-polyfilled mbstring runtime policy. --- CONTRIBUTING.md | 4 +++- README.md | 7 +++++-- docs/index.md | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2bdfff..383aa81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,9 @@ Thanks for contributing! ## Development -- PHP >= 8.2 +- PHP >= 8.3 +- Symfony 7.4 or 8.x +- Doctrine ORM 3 - Composer ## Commands diff --git a/README.md b/README.md index 4540470..f9fd4cb 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,13 @@ A lightweight Symfony bundle to automatically **audit and historize Doctrine ORM ## Requirements - PHP 8.3+ -- Symfony 7.4+ (Symfony 8.x supported) -- Doctrine ORM + DoctrineBundle +- Symfony 7.4 or 8.x +- Doctrine ORM 3.x +- DoctrineBundle 2.19 or 3.x - Symfony Messenger (optional but recommended for async) +The bundle includes `symfony/polyfill-mbstring`, so the native `mbstring` extension is not required. Installing the native extension remains recommended for the best multibyte string performance. + ## Installation ```bash diff --git a/docs/index.md b/docs/index.md index a74dcfc..3c1460a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,15 @@ A lightweight Symfony bundle for automatic audit trail tracking on Doctrine ORM entities. +## Requirements + +- PHP 8.3 or later +- Symfony 7.4 or 8.x +- Doctrine ORM 3.x +- DoctrineBundle 2.19 or 3.x + +The bundle includes `symfony/polyfill-mbstring`, so the native `mbstring` extension is optional. The native extension remains recommended for the best multibyte string performance. + ## Overview This bundle provides a declarative, attribute-based system to automatically track changes to your Doctrine entities. When you mark an entity with `#[Auditable]`, the bundle's Doctrine listener automatically captures: From 26a2028a69fc578e400f169817c2126728a9799b Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 18:33:59 +0200 Subject: [PATCH 07/28] feat: add transactional audit contracts and readonly models Define strict fail-closed extension contracts and readonly subject, actor, event, and record models while keeping the legacy runtime, Doctrine mapping, service aliases, and database schema unchanged. --- CHANGELOG.md | 1 + config/services.php | 2 + .../Contract/AuditActorResolverInterface.php | 18 ++ .../Contract/AuditEntryFactoryInterface.php | 20 ++ .../Contract/AuditRecorderInterface.php | 18 ++ .../Contract/AuditStorageInterface.php | 19 ++ .../Contract/IdentifierExtractorInterface.php | 18 ++ src/Transactional/Model/AuditActor.php | 28 +++ src/Transactional/Model/AuditEvent.php | 55 ++++++ src/Transactional/Model/AuditRecord.php | 35 ++++ src/Transactional/Model/AuditSubject.php | 27 +++ tests/Contract/TransactionalContractsTest.php | 176 ++++++++++++++++++ .../TransactionalContainerIsolationTest.php | 125 +++++++++++++ .../Transactional/Model/AuditActorTest.php | 55 ++++++ .../Transactional/Model/AuditEventTest.php | 93 +++++++++ .../Transactional/Model/AuditRecordTest.php | 67 +++++++ .../Transactional/Model/AuditSubjectTest.php | 54 ++++++ 17 files changed, 811 insertions(+) create mode 100644 src/Transactional/Contract/AuditActorResolverInterface.php create mode 100644 src/Transactional/Contract/AuditEntryFactoryInterface.php create mode 100644 src/Transactional/Contract/AuditRecorderInterface.php create mode 100644 src/Transactional/Contract/AuditStorageInterface.php create mode 100644 src/Transactional/Contract/IdentifierExtractorInterface.php create mode 100644 src/Transactional/Model/AuditActor.php create mode 100644 src/Transactional/Model/AuditEvent.php create mode 100644 src/Transactional/Model/AuditRecord.php create mode 100644 src/Transactional/Model/AuditSubject.php create mode 100644 tests/Contract/TransactionalContractsTest.php create mode 100644 tests/Integration/TransactionalContainerIsolationTest.php create mode 100644 tests/Unit/Transactional/Model/AuditActorTest.php create mode 100644 tests/Unit/Transactional/Model/AuditEventTest.php create mode 100644 tests/Unit/Transactional/Model/AuditRecordTest.php create mode 100644 tests/Unit/Transactional/Model/AuditSubjectTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e5e84..0b2c058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added - Direct runtime dependency on symfony/polyfill-mbstring, allowing the bundle to run without the native mbstring extension. +- Add immutable transactional audit contracts and data-transfer objects without changing the legacy runtime behavior. ## [1.0.0] - 2025-12-24 diff --git a/config/services.php b/config/services.php index d560c21..bb118ef 100644 --- a/config/services.php +++ b/config/services.php @@ -32,6 +32,8 @@ __DIR__ . '/../src/Attribute/', __DIR__ . '/../src/Enum/', __DIR__ . '/../src/Message/', + __DIR__ . '/../src/Transactional/Contract/', + __DIR__ . '/../src/Transactional/Model/', ]); $services->set(AuditableMetadataProvider::class); diff --git a/src/Transactional/Contract/AuditActorResolverInterface.php b/src/Transactional/Contract/AuditActorResolverInterface.php new file mode 100644 index 0000000..e2205d7 --- /dev/null +++ b/src/Transactional/Contract/AuditActorResolverInterface.php @@ -0,0 +1,18 @@ + $metadata + */ + public function __construct( + public string $type, + public ?string $identifier = null, + public ?string $impersonatorIdentifier = null, + public array $metadata = [], + ) { + if ('' === trim($type)) { + throw new \InvalidArgumentException('The audit actor type must not be empty.'); + } + if (null !== $identifier && '' === trim($identifier)) { + throw new \InvalidArgumentException('The audit actor identifier must not be empty when provided.'); + } + if (null !== $impersonatorIdentifier && '' === trim($impersonatorIdentifier)) { + throw new \InvalidArgumentException('The audit actor impersonator identifier must not be empty when provided.'); + } + } +} diff --git a/src/Transactional/Model/AuditEvent.php b/src/Transactional/Model/AuditEvent.php new file mode 100644 index 0000000..0d91e3c --- /dev/null +++ b/src/Transactional/Model/AuditEvent.php @@ -0,0 +1,55 @@ + $data + */ + public function __construct( + AuditAction|string $action, + public string $title, + public ?string $description = null, + public ?string $context = null, + AuditLevel|string $level = AuditLevel::INFO, + public ?object $entity = null, + public ?AuditSubject $subject = null, + public ?AuditActor $actor = null, + public bool $isAuto = false, + public array $data = [], + public ?\DateTimeImmutable $occurredAt = null, + ) { + $this->action = $action instanceof AuditAction ? $action->value : $action; + $this->level = $level instanceof AuditLevel ? $level->value : $level; + + if ('' === trim($this->action)) { + throw new \InvalidArgumentException('The audit event action must not be empty.'); + } + if ('' === trim($this->level)) { + throw new \InvalidArgumentException('The audit event level must not be empty.'); + } + if ('' === trim($title)) { + throw new \InvalidArgumentException('The audit event title must not be empty.'); + } + if (null !== $entity && null !== $subject) { + throw new \InvalidArgumentException('An audit event cannot contain both an entity and a resolved subject.'); + } + } +} diff --git a/src/Transactional/Model/AuditRecord.php b/src/Transactional/Model/AuditRecord.php new file mode 100644 index 0000000..9f8fdf4 --- /dev/null +++ b/src/Transactional/Model/AuditRecord.php @@ -0,0 +1,35 @@ + $data + */ + public function __construct( + public \DateTimeImmutable $occurredAt, + public string $action, + public string $level, + public string $title, + public ?string $description = null, + public ?string $context = null, + public ?AuditSubject $subject = null, + public ?AuditActor $actor = null, + public bool $isAuto = false, + public array $data = [], + ) { + if ('' === trim($action)) { + throw new \InvalidArgumentException('The audit record action must not be empty.'); + } + if ('' === trim($level)) { + throw new \InvalidArgumentException('The audit record level must not be empty.'); + } + if ('' === trim($title)) { + throw new \InvalidArgumentException('The audit record title must not be empty.'); + } + } +} diff --git a/src/Transactional/Model/AuditSubject.php b/src/Transactional/Model/AuditSubject.php new file mode 100644 index 0000000..aa90b96 --- /dev/null +++ b/src/Transactional/Model/AuditSubject.php @@ -0,0 +1,27 @@ +> */ + private const CONSTRUCTORS = [ + AuditSubject::class => [ + ['name' => 'type', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'identifier', 'type' => 'string', 'optional' => false, 'default' => null], + ], + AuditActor::class => [ + ['name' => 'type', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'identifier', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'impersonatorIdentifier', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'metadata', 'type' => 'array', 'optional' => true, 'default' => []], + ], + AuditEvent::class => [ + ['name' => 'action', 'type' => 'Zhortein\\AuditableBundle\\Enum\\AuditAction|string', 'optional' => false, 'default' => null], + ['name' => 'title', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'description', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'context', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'level', 'type' => 'Zhortein\\AuditableBundle\\Enum\\AuditLevel|string', 'optional' => true, 'default' => 'info'], + ['name' => 'entity', 'type' => '?object', 'optional' => true, 'default' => null], + ['name' => 'subject', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject', 'optional' => true, 'default' => null], + ['name' => 'actor', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor', 'optional' => true, 'default' => null], + ['name' => 'isAuto', 'type' => 'bool', 'optional' => true, 'default' => false], + ['name' => 'data', 'type' => 'array', 'optional' => true, 'default' => []], + ['name' => 'occurredAt', 'type' => '?DateTimeImmutable', 'optional' => true, 'default' => null], + ], + AuditRecord::class => [ + ['name' => 'occurredAt', 'type' => 'DateTimeImmutable', 'optional' => false, 'default' => null], + ['name' => 'action', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'level', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'title', 'type' => 'string', 'optional' => false, 'default' => null], + ['name' => 'description', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'context', 'type' => '?string', 'optional' => true, 'default' => null], + ['name' => 'subject', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject', 'optional' => true, 'default' => null], + ['name' => 'actor', 'type' => '?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor', 'optional' => true, 'default' => null], + ['name' => 'isAuto', 'type' => 'bool', 'optional' => true, 'default' => false], + ['name' => 'data', 'type' => 'array', 'optional' => true, 'default' => []], + ], + ]; + + /** @var array> */ + private const INTERFACES = [ + AuditRecorderInterface::class => ['record' => ['parameter' => 'event', 'type' => AuditEvent::class, 'return' => 'void']], + IdentifierExtractorInterface::class => ['extract' => ['parameter' => 'entity', 'type' => 'object', 'return' => AuditSubject::class]], + AuditActorResolverInterface::class => ['resolveActor' => ['parameter' => '', 'type' => '', 'return' => '?'.AuditActor::class]], + AuditEntryFactoryInterface::class => ['create' => ['parameter' => 'record', 'type' => AuditRecord::class, 'return' => 'object']], + AuditStorageInterface::class => ['persist' => ['parameter' => 'entry', 'type' => 'object', 'return' => 'void']], + ]; + + public function testExactModelContracts(): void + { + foreach (self::CONSTRUCTORS as $class => $expectedParameters) { + $reflection = new \ReflectionClass($class); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Model', $reflection->getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + self::assertFalse($constructor->isStatic()); + self::assertSame($expectedParameters, self::parameters($constructor)); + foreach ($constructor->getParameters() as $parameter) { + self::assertFalse($parameter->isPassedByReference()); + self::assertFalse($parameter->isVariadic()); + } + + $declaredMethods = array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class, + ); + self::assertSame(['__construct'], array_values(array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $declaredMethods))); + + $expectedProperties = array_column($expectedParameters, 'type', 'name'); + if (AuditEvent::class === $class) { + $expectedProperties['action'] = 'string'; + $expectedProperties['level'] = 'string'; + } + $actualProperties = []; + foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->getDeclaringClass()->getName() === $class) { + $actualProperties[$property->getName()] = self::type($property->getType()); + } + } + ksort($expectedProperties); + ksort($actualProperties); + self::assertSame($expectedProperties, $actualProperties); + } + } + + public function testExactInterfaceContracts(): void + { + foreach (self::INTERFACES as $interface => $expectedMethods) { + $reflection = new \ReflectionClass($interface); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Contract', $reflection->getNamespaceName()); + self::assertTrue($reflection->isInterface()); + self::assertFalse($reflection->isFinal()); + $actualMethods = []; + foreach ($reflection->getMethods() as $method) { + self::assertTrue($method->isPublic()); + self::assertFalse($method->isStatic()); + $parameters = $method->getParameters(); + self::assertCount('' === $expectedMethods[$method->getName()]['parameter'] ? 0 : 1, $parameters); + foreach ($parameters as $parameter) { + self::assertFalse($parameter->isPassedByReference()); + self::assertFalse($parameter->isVariadic()); + } + $actualMethods[$method->getName()] = [ + 'parameter' => isset($parameters[0]) ? $parameters[0]->getName() : '', + 'type' => isset($parameters[0]) ? self::type($parameters[0]->getType()) : '', + 'return' => (string) self::type($method->getReturnType()), + ]; + } + self::assertSame($expectedMethods, $actualMethods); + } + self::assertSame(['record'], get_class_methods(AuditRecorderInterface::class)); + self::assertSame(['create'], get_class_methods(AuditEntryFactoryInterface::class)); + self::assertSame(['persist'], get_class_methods(AuditStorageInterface::class)); + foreach (['flush', 'commit', 'rollback', 'transaction'] as $forbidden) { + self::assertFalse(method_exists(AuditStorageInterface::class, $forbidden)); + } + } + + /** @return list */ + private static function parameters(\ReflectionMethod $method): array + { + return array_map(static fn (\ReflectionParameter $parameter): array => [ + 'name' => $parameter->getName(), + 'type' => (string) self::type($parameter->getType()), + 'optional' => $parameter->isOptional(), + 'default' => $parameter->isDefaultValueAvailable() ? self::defaultValue($parameter) : null, + ], $method->getParameters()); + } + + private static function defaultValue(\ReflectionParameter $parameter): mixed + { + $value = $parameter->getDefaultValue(); + + return $value instanceof \BackedEnum ? $value->value : $value; + } + + private static function type(?\ReflectionType $type): ?string + { + if (null === $type) { + return null; + } + if ($type instanceof \ReflectionNamedType) { + return ($type->allowsNull() && 'mixed' !== $type->getName() ? '?' : '').$type->getName(); + } + if ($type instanceof \ReflectionUnionType) { + return implode('|', array_map(self::type(...), $type->getTypes())); + } + if ($type instanceof \ReflectionIntersectionType) { + return implode('&', array_map(self::type(...), $type->getTypes())); + } + + throw new \LogicException('Unsupported reflection type.'); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php new file mode 100644 index 0000000..2eaca54 --- /dev/null +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -0,0 +1,125 @@ +getCacheDir()); + $kernel->boot(); + try { + $container = $kernel->getContainer(); + self::assertInstanceOf(Container::class, $container); + $testContainer = $container->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + + foreach ([AuditSubject::class, AuditActor::class, AuditEvent::class, AuditRecord::class] as $model) { + self::assertTrue(class_exists($model)); + self::assertFalse($container->has($model)); + } + foreach ([AuditRecorderInterface::class, IdentifierExtractorInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + self::assertTrue(interface_exists($contract)); + self::assertFalse($container->has($contract)); + } + + self::assertInstanceOf(AsyncAuditEntryWriter::class, $testContainer->get(AuditEntryWriterInterface::class)); + self::assertInstanceOf(SecurityActorResolver::class, $testContainer->get(ActorResolverInterface::class)); + + $transactionalParameters = array_filter( + array_keys($container->getParameterBag()->all()), + static fn (int|string $name): bool => str_contains((string) $name, 'transactional'), + ); + self::assertSame([], $transactionalParameters); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $classes = array_map(static fn ($classMetadata): string => $classMetadata->getName(), $metadata); + sort($classes); + self::assertSame([ + AuditEntry::class, + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\AuditableEntity', + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\NonAuditableEntity', + ], $classes); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], self::sortedTableNames($metadata)); + (new SchemaTool($entityManager))->createSchema($metadata); + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], $tables); + } finally { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + } + + public function testNoTransactionalAliasReplacesLegacyAliases(): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([], $container); + + self::assertSame(AsyncAuditEntryWriter::class, (string) $container->getAlias(AuditEntryWriterInterface::class)); + self::assertFalse($container->getAlias(AuditEntryWriterInterface::class)->isPublic()); + self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); + self::assertTrue($container->getAlias(ActorResolverInterface::class)->isPublic()); + foreach ([AuditRecorderInterface::class, IdentifierExtractorInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + self::assertFalse($container->hasAlias($contract)); + } + } + + /** @param list> $metadata + * @return list + */ + private static function sortedTableNames(array $metadata): array + { + $tables = array_map(static fn (ClassMetadata $classMetadata): string => $classMetadata->getTableName(), $metadata); + sort($tables); + + return $tables; + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Unit/Transactional/Model/AuditActorTest.php b/tests/Unit/Transactional/Model/AuditActorTest.php new file mode 100644 index 0000000..3f40ddb --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditActorTest.php @@ -0,0 +1,55 @@ + 7, 'roles' => ['ROLE_USER']]; + $actor = new AuditActor('user', '42', '1', $metadata); + self::assertSame('user', $actor->type); + self::assertSame('42', $actor->identifier); + self::assertSame('1', $actor->impersonatorIdentifier); + self::assertSame($metadata, $actor->metadata); + } + + public function testAllowsSystemActorWithoutIdentifier(): void + { + $actor = new AuditActor('system'); + self::assertNull($actor->identifier); + self::assertNull($actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + } + + #[DataProvider('invalidActors')] + public function testRejectsInvalidActor(string $type, ?string $identifier, ?string $impersonator): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditActor($type, $identifier, $impersonator); + } + + /** @return iterable */ + public static function invalidActors(): iterable + { + yield 'empty type' => ['', null, null]; + yield 'blank type' => [' ', null, null]; + yield 'empty identifier' => ['user', '', null]; + yield 'blank identifier' => ['user', ' ', null]; + yield 'empty impersonator' => ['user', null, '']; + yield 'blank impersonator' => ['user', null, ' ']; + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditActor::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Transactional/Model/AuditEventTest.php b/tests/Unit/Transactional/Model/AuditEventTest.php new file mode 100644 index 0000000..9621384 --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditEventTest.php @@ -0,0 +1,93 @@ + ['before', 'after']]; + $event = new AuditEvent(AuditAction::UPDATE, 'Title', 'Description', 'orders', AuditLevel::WARNING, $entity, null, $actor, true, $data, $time); + self::assertSame('update', $event->action); + self::assertSame('warning', $event->level); + self::assertSame('Title', $event->title); + self::assertSame('Description', $event->description); + self::assertSame('orders', $event->context); + self::assertSame($entity, $event->entity); + self::assertNull($event->subject); + self::assertSame($actor, $event->actor); + self::assertTrue($event->isAuto); + self::assertSame($data, $event->data); + self::assertSame($time, $event->occurredAt); + } + + public function testPreservesCustomStringsAndExplicitSubject(): void + { + $subject = new AuditSubject('order', '42'); + $event = new AuditEvent('custom-action', 'Custom', subject: $subject, level: 'notice'); + self::assertSame('custom-action', $event->action); + self::assertSame('notice', $event->level); + self::assertSame($subject, $event->subject); + self::assertNull($event->entity); + } + + public function testGlobalEventDefaults(): void + { + $event = new AuditEvent('0', '0', level: '0'); + self::assertSame('0', $event->action); + self::assertSame('0', $event->title); + self::assertSame('0', $event->level); + self::assertNull($event->description); + self::assertNull($event->context); + self::assertNull($event->entity); + self::assertNull($event->subject); + self::assertNull($event->actor); + self::assertFalse($event->isAuto); + self::assertSame([], $event->data); + self::assertNull($event->occurredAt); + } + + #[DataProvider('invalidText')] + public function testRejectsEmptyActionLevelOrTitle(string $action, string $title, string $level): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditEvent($action, $title, level: $level); + } + + /** @return iterable */ + public static function invalidText(): iterable + { + yield 'empty action' => ['', 'title', 'info']; + yield 'blank action' => [' ', 'title', 'info']; + yield 'empty level' => ['log', 'title', '']; + yield 'blank level' => ['log', 'title', ' ']; + yield 'empty title' => ['log', '', 'info']; + yield 'blank title' => ['log', ' ', 'info']; + } + + public function testRejectsEntityAndSubjectTogether(): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditEvent('log', 'title', entity: new \stdClass(), subject: new AuditSubject('type', 'id')); + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditEvent::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Transactional/Model/AuditRecordTest.php b/tests/Unit/Transactional/Model/AuditRecordTest.php new file mode 100644 index 0000000..7ecc57e --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditRecordTest.php @@ -0,0 +1,67 @@ + 'value']; + $record = new AuditRecord($time, 'update', 'warning', 'Title', 'Description', 'orders', $subject, $actor, true, $data); + self::assertSame($time, $record->occurredAt); + self::assertSame('update', $record->action); + self::assertSame('warning', $record->level); + self::assertSame('Title', $record->title); + self::assertSame('Description', $record->description); + self::assertSame('orders', $record->context); + self::assertSame($subject, $record->subject); + self::assertSame($actor, $record->actor); + self::assertTrue($record->isAuto); + self::assertSame($data, $record->data); + } + + public function testAllowsRecordWithoutSubjectOrActorAndAcceptsZero(): void + { + $record = new AuditRecord(new \DateTimeImmutable(), '0', '0', '0'); + self::assertNull($record->subject); + self::assertNull($record->actor); + self::assertSame([], $record->data); + } + + #[DataProvider('invalidText')] + public function testRejectsInvalidText(string $action, string $level, string $title): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditRecord(new \DateTimeImmutable(), $action, $level, $title); + } + + /** @return iterable */ + public static function invalidText(): iterable + { + yield 'empty action' => ['', 'info', 'title']; + yield 'blank action' => [' ', 'info', 'title']; + yield 'empty level' => ['log', '', 'title']; + yield 'blank level' => ['log', ' ', 'title']; + yield 'empty title' => ['log', 'info', '']; + yield 'blank title' => ['log', 'info', ' ']; + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditRecord::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertFalse($reflection->hasProperty('entity')); + } +} diff --git a/tests/Unit/Transactional/Model/AuditSubjectTest.php b/tests/Unit/Transactional/Model/AuditSubjectTest.php new file mode 100644 index 0000000..fa36926 --- /dev/null +++ b/tests/Unit/Transactional/Model/AuditSubjectTest.php @@ -0,0 +1,54 @@ +type); + self::assertSame($identifier, $subject->identifier); + } + + /** @return iterable */ + public static function validSubjects(): iterable + { + yield 'nominal' => ['App\\Entity\\Order', '42']; + yield 'uuid' => ['order', '4f0af080-3771-46c4-9ec2-7ff09e1f4dd2']; + yield 'integer string' => ['order', '123']; + yield 'canonical composite JSON' => ['order_item', '{"order":42,"line":3}']; + yield 'zero' => ['0', '0']; + yield 'no normalization' => [' order ', ' identifier ']; + } + + #[DataProvider('invalidSubjects')] + public function testRejectsEmptyParts(string $type, string $identifier): void + { + $this->expectException(\InvalidArgumentException::class); + new AuditSubject($type, $identifier); + } + + /** @return iterable */ + public static function invalidSubjects(): iterable + { + yield 'empty type' => ['', 'id']; + yield 'blank type' => [" \t\n", 'id']; + yield 'empty identifier' => ['type', '']; + yield 'blank identifier' => ['type', " \t\n"]; + } + + public function testIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(AuditSubject::class); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + } +} From c995ce28c9f0871a7d964b24c3b44fac26fc965f Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 2 Aug 2026 19:47:38 +0200 Subject: [PATCH 08/28] feat: add deterministic Doctrine identifier extraction Resolve mapped entity types and scalar, BackedEnum, Stringable, and primitive composite identifiers through Doctrine metadata, with a private default extractor alias and explicit failures for unavailable or unsupported identifiers. The extractor performs no persistence, flush, transaction management, repository lookup, or explicit database query. --- CHANGELOG.md | 1 + config/services.php | 5 + .../IdentifierExtractionException.php | 9 + .../Service/DoctrineIdentifierExtractor.php | 130 ++++++++ ...octrineIdentifierExtractorContractTest.php | 57 ++++ tests/Fixtures/App/DoctrineTestFactory.php | 21 +- .../Entity/CompositeIdentifier.php | 24 ++ .../Entity/GeneratedIdentifier.php | 21 ++ .../Transactional/Entity/IdMethodEntity.php | 26 ++ .../Entity/PrivateStringIdentifier.php | 21 ++ .../Transactional/Entity/ProxyEntity.php | 21 ++ .../Entity/ReverseCompositeIdentifier.php | 24 ++ .../Entity/ThrowingGetterEntity.php | 26 ++ ...rineIdentifierExtractorIntegrationTest.php | 117 ++++++++ .../TransactionalContainerIsolationTest.php | 15 +- .../DoctrineIdentifierExtractorTest.php | 280 ++++++++++++++++++ 16 files changed, 792 insertions(+), 6 deletions(-) create mode 100644 src/Transactional/Exception/IdentifierExtractionException.php create mode 100644 src/Transactional/Service/DoctrineIdentifierExtractor.php create mode 100644 tests/Contract/DoctrineIdentifierExtractorContractTest.php create mode 100644 tests/Fixtures/Transactional/Entity/CompositeIdentifier.php create mode 100644 tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php create mode 100644 tests/Fixtures/Transactional/Entity/IdMethodEntity.php create mode 100644 tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php create mode 100644 tests/Fixtures/Transactional/Entity/ProxyEntity.php create mode 100644 tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php create mode 100644 tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php create mode 100644 tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php create mode 100644 tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2c058..4d8765d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Direct runtime dependency on symfony/polyfill-mbstring, allowing the bundle to run without the native mbstring extension. - Add immutable transactional audit contracts and data-transfer objects without changing the legacy runtime behavior. +- Add a default Doctrine ORM identifier extractor for deterministic scalar, Stringable and primitive composite audit subjects. ## [1.0.0] - 2025-12-24 diff --git a/config/services.php b/config/services.php index bb118ef..3f4915a 100644 --- a/config/services.php +++ b/config/services.php @@ -13,6 +13,8 @@ use Zhortein\AuditableBundle\Service\Historizer; use Zhortein\AuditableBundle\Service\SecurityActorResolver; use Zhortein\AuditableBundle\Service\SyncAuditEntryWriter; +use Zhortein\AuditableBundle\Transactional\Contract\IdentifierExtractorInterface; +use Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor; use function Symfony\Component\DependencyInjection\Loader\Configurator\param; @@ -33,9 +35,12 @@ __DIR__ . '/../src/Enum/', __DIR__ . '/../src/Message/', __DIR__ . '/../src/Transactional/Contract/', + __DIR__ . '/../src/Transactional/Exception/', __DIR__ . '/../src/Transactional/Model/', ]); + $services->alias(IdentifierExtractorInterface::class, DoctrineIdentifierExtractor::class); + $services->set(AuditableMetadataProvider::class); $services->set(ChangeDetector::class) diff --git a/src/Transactional/Exception/IdentifierExtractionException.php b/src/Transactional/Exception/IdentifierExtractionException.php new file mode 100644 index 0000000..ac42df3 --- /dev/null +++ b/src/Transactional/Exception/IdentifierExtractionException.php @@ -0,0 +1,9 @@ +resolveMetadata($entity); + $fields = $metadata->getIdentifierFieldNames(); + if ([] === $fields) { + throw new IdentifierExtractionException(\sprintf('Doctrine metadata for "%s" declares no identifier field.', $metadata->getName())); + } + + $values = $metadata->getIdentifierValues($entity); + $canonical = []; + foreach ($fields as $field) { + if (!\array_key_exists($field, $values) || null === $values[$field]) { + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" is not available for mapped class "%s".', $field, $metadata->getName())); + } + if ($metadata->hasAssociation($field)) { + throw new IdentifierExtractionException(\sprintf('Association identifier field "%s" on mapped class "%s" requires a custom extractor or an explicit AuditSubject.', $field, $metadata->getName())); + } + $canonical[$field] = $this->canonicalize($values[$field], $field, $metadata->getName()); + } + + if (1 === \count($fields)) { + return new AuditSubject($metadata->getName(), $canonical[$fields[0]]['value']); + } + + ksort($canonical, \SORT_STRING); + try { + $identifier = json_encode( + ['format' => 'doctrine-composite-v1', 'fields' => $canonical], + \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES, + ); + } catch (\JsonException $exception) { + throw new IdentifierExtractionException(\sprintf('Composite identifier for mapped class "%s" cannot be encoded.', $metadata->getName()), previous: $exception); + } + + return new AuditSubject($metadata->getName(), $identifier); + } + + /** @return ClassMetadata */ + private function resolveMetadata(object $entity): ClassMetadata + { + $class = $entity::class; + try { + $manager = $this->registry->getManagerForClass($class); + if (null === $manager && $entity instanceof Proxy) { + $parent = get_parent_class($entity); + if (false !== $parent) { + $manager = $this->registry->getManagerForClass($parent); + $class = $parent; + } + } + } catch (\Throwable $exception) { + throw new IdentifierExtractionException(\sprintf('Unable to resolve a persistence manager for class "%s".', $class), previous: $exception); + } + + if (null === $manager) { + throw new IdentifierExtractionException(\sprintf('No persistence manager found for class "%s".', $class)); + } + if (!$manager instanceof EntityManagerInterface) { + throw new IdentifierExtractionException(\sprintf('Persistence manager for class "%s" is not a Doctrine ORM entity manager.', $class)); + } + + try { + return $this->loadMetadata($manager, $class); + } catch (\Throwable $exception) { + throw new IdentifierExtractionException(\sprintf('Unable to load Doctrine ORM metadata for class "%s".', $class), previous: $exception); + } + } + + /** + * @return ClassMetadata + * + * @throws \Throwable + */ + private function loadMetadata(EntityManagerInterface $manager, string $class): ClassMetadata + { + return $manager->getClassMetadata($class); + } + + /** @return array{type: 'integer'|'string', value: string} */ + private function canonicalize(mixed $value, string $field, string $class): array + { + if ($value instanceof \BackedEnum) { + $value = $value->value; + } + if (\is_int($value)) { + return ['type' => 'integer', 'value' => (string) $value]; + } + if ($value instanceof \Stringable) { + try { + $stringifier = \Closure::fromCallable([$value, '__toString']); + $value = $stringifier(); + } catch (\Throwable $exception) { + throw new IdentifierExtractionException(\sprintf('Stringable identifier field "%s" on mapped class "%s" could not be converted.', $field, $class), previous: $exception); + } + } + if (\is_string($value)) { + if (!mb_check_encoding($value, 'UTF-8')) { + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" on mapped class "%s" contains invalid UTF-8.', $field, $class)); + } + if ('' === $value || 1 === preg_match('/^\s+$/u', $value)) { + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" on mapped class "%s" must not be empty or blank.', $field, $class)); + } + + return ['type' => 'string', 'value' => $value]; + } + + throw new IdentifierExtractionException(\sprintf('Identifier field "%s" on mapped class "%s" has unsupported PHP type "%s"; use a custom extractor or an explicit AuditSubject.', $field, $class, get_debug_type($value))); + } +} diff --git a/tests/Contract/DoctrineIdentifierExtractorContractTest.php b/tests/Contract/DoctrineIdentifierExtractorContractTest.php new file mode 100644 index 0000000..2952f1a --- /dev/null +++ b/tests/Contract/DoctrineIdentifierExtractorContractTest.php @@ -0,0 +1,57 @@ +getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertTrue($reflection->implementsInterface(IdentifierExtractorInterface::class)); + + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + $parameters = $constructor->getParameters(); + self::assertCount(1, $parameters); + self::assertSame('registry', $parameters[0]->getName()); + self::assertSame(ManagerRegistry::class, (string) $parameters[0]->getType()); + self::assertTrue($parameters[0]->isPromoted()); + self::assertTrue($reflection->getProperty('registry')->isPrivate()); + + $publicMethods = array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => DoctrineIdentifierExtractor::class === $method->getDeclaringClass()->getName(), + )); + self::assertSame(['__construct', 'extract'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $publicMethods)); + $extract = $reflection->getMethod('extract'); + self::assertSame('object', (string) $extract->getParameters()[0]->getType()); + self::assertSame(AuditSubject::class, (string) $extract->getReturnType()); + } + + public function testExceptionContractIsExact(): void + { + $reflection = new \ReflectionClass(IdentifierExtractionException::class); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Exception', $reflection->getNamespaceName()); + self::assertTrue($reflection->isFinal()); + $parent = $reflection->getParentClass(); + self::assertInstanceOf(\ReflectionClass::class, $parent); + self::assertSame(\RuntimeException::class, $parent->getName()); + self::assertSame([], array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => IdentifierExtractionException::class === $method->getDeclaringClass()->getName(), + ))); + } +} diff --git a/tests/Fixtures/App/DoctrineTestFactory.php b/tests/Fixtures/App/DoctrineTestFactory.php index 38adaa5..61bafc2 100644 --- a/tests/Fixtures/App/DoctrineTestFactory.php +++ b/tests/Fixtures/App/DoctrineTestFactory.php @@ -14,12 +14,25 @@ final class DoctrineTestFactory { public static function createEntityManager(): EntityManagerInterface { - $eventManager = new EventManager(); - $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); - $configuration = ORMSetup::createAttributeMetadataConfiguration([ + return self::createForPaths([ \dirname(__DIR__, 3).'/src/Entity', \dirname(__DIR__).'/Entity', - ], true); + ]); + } + + public static function createTransactionalEntityManager(): EntityManagerInterface + { + return self::createForPaths([ + \dirname(__DIR__).'/Transactional/Entity', + ]); + } + + /** @param list $paths */ + private static function createForPaths(array $paths): EntityManagerInterface + { + $eventManager = new EventManager(); + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $configuration = ORMSetup::createAttributeMetadataConfiguration($paths, true); if (\PHP_VERSION_ID >= 80400 && method_exists($configuration, 'enableNativeLazyObjects')) { $configuration->enableNativeLazyObjects(true); diff --git a/tests/Fixtures/Transactional/Entity/CompositeIdentifier.php b/tests/Fixtures/Transactional/Entity/CompositeIdentifier.php new file mode 100644 index 0000000..c363953 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/CompositeIdentifier.php @@ -0,0 +1,24 @@ +country || 0 === $this->number) { + throw new \InvalidArgumentException('Fixture identifier parts must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php b/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php new file mode 100644 index 0000000..793ef8b --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php @@ -0,0 +1,21 @@ +id; + } +} diff --git a/tests/Fixtures/Transactional/Entity/IdMethodEntity.php b/tests/Fixtures/Transactional/Entity/IdMethodEntity.php new file mode 100644 index 0000000..ba4b41b --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/IdMethodEntity.php @@ -0,0 +1,26 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } + + public function id(): never + { + throw new \LogicException('The id() method must not be called.'); + } +} diff --git a/tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php b/tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php new file mode 100644 index 0000000..053a158 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/PrivateStringIdentifier.php @@ -0,0 +1,21 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/ProxyEntity.php b/tests/Fixtures/Transactional/Entity/ProxyEntity.php new file mode 100644 index 0000000..1435450 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/ProxyEntity.php @@ -0,0 +1,21 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php b/tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php new file mode 100644 index 0000000..b0329e0 --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/ReverseCompositeIdentifier.php @@ -0,0 +1,24 @@ +country || 0 === $this->number) { + throw new \InvalidArgumentException('Fixture identifier parts must not be empty.'); + } + } +} diff --git a/tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php b/tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php new file mode 100644 index 0000000..ff17efb --- /dev/null +++ b/tests/Fixtures/Transactional/Entity/ThrowingGetterEntity.php @@ -0,0 +1,26 @@ +identifier) { + throw new \InvalidArgumentException('Fixture identifier must not be empty.'); + } + } + + public function getId(): never + { + throw new \LogicException('The getId() method must not be called.'); + } +} diff --git a/tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php b/tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php new file mode 100644 index 0000000..cb843e0 --- /dev/null +++ b/tests/Integration/Transactional/DoctrineIdentifierExtractorIntegrationTest.php @@ -0,0 +1,117 @@ +entityManager = DoctrineTestFactory::createTransactionalEntityManager(); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + (new SchemaTool($this->entityManager))->createSchema($metadata); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManagerForClass')->willReturnCallback(function (string $class): ?EntityManagerInterface { + if (ProxyEntity::class === $class || is_subclass_of($class, ProxyEntity::class)) { + return ProxyEntity::class === $class ? $this->entityManager : null; + } + + if (!class_exists($class)) { + return null; + } + + /* @var class-string $class */ + return $this->entityManager->getMetadataFactory()->isTransient($class) ? null : $this->entityManager; + }); + $this->extractor = new DoctrineIdentifierExtractor($registry); + } + + protected function tearDown(): void + { + $this->entityManager->getConnection()->close(); + } + + public function testExtractsPrivateStringIdentifierWithoutGetter(): void + { + $subject = $this->extractor->extract(new PrivateStringIdentifier('private-42')); + self::assertSame(PrivateStringIdentifier::class, $subject->type); + self::assertSame('private-42', $subject->identifier); + } + + public function testNeverCallsIdMethod(): void + { + $subject = $this->extractor->extract(new IdMethodEntity('method-42')); + self::assertSame(IdMethodEntity::class, $subject->type); + self::assertSame('method-42', $subject->identifier); + } + + public function testNeverCallsUnusableGetId(): void + { + $subject = $this->extractor->extract(new ThrowingGetterEntity('getter-42')); + self::assertSame(ThrowingGetterEntity::class, $subject->type); + self::assertSame('getter-42', $subject->identifier); + } + + public function testExtractsNewUnmanagedManualIdentifier(): void + { + $entity = new PrivateStringIdentifier('unmanaged-42'); + self::assertFalse($this->entityManager->contains($entity)); + self::assertSame('unmanaged-42', $this->extractor->extract($entity)->identifier); + self::assertFalse($this->entityManager->contains($entity)); + } + + public function testRejectsNewGeneratedNullIdentifierWithoutManagingEntity(): void + { + $entity = new GeneratedIdentifier(); + self::assertFalse($this->entityManager->contains($entity)); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('not available'); + $this->extractor->extract($entity); + } + + public function testCompositeIdentifierIsExactAndIndependentOfDeclarationOrder(): void + { + $first = $this->extractor->extract(new CompositeIdentifier(42, 'FR')); + $second = $this->extractor->extract(new ReverseCompositeIdentifier('FR', 42)); + $expected = '{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"integer","value":"42"}}}'; + self::assertSame(CompositeIdentifier::class, $first->type); + self::assertSame(ReverseCompositeIdentifier::class, $second->type); + self::assertSame($expected, $first->identifier); + self::assertSame($expected, $second->identifier); + } + + public function testExtractsMappedTypeAndIdentifierFromDoctrineReference(): void + { + $entity = new ProxyEntity('proxy-42'); + $this->entityManager->persist($entity); + $this->entityManager->flush(); + $this->entityManager->clear(); + + $reference = $this->entityManager->getReference(ProxyEntity::class, 'proxy-42'); + self::assertInstanceOf(ProxyEntity::class, $reference); + $subject = $this->extractor->extract($reference); + self::assertSame(ProxyEntity::class, $subject->type); + self::assertSame('proxy-42', $subject->identifier); + self::assertStringNotContainsString('Proxies', $subject->type); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php index 2eaca54..6da3c08 100644 --- a/tests/Integration/TransactionalContainerIsolationTest.php +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -23,10 +23,12 @@ use Zhortein\AuditableBundle\Transactional\Contract\AuditRecorderInterface; use Zhortein\AuditableBundle\Transactional\Contract\AuditStorageInterface; use Zhortein\AuditableBundle\Transactional\Contract\IdentifierExtractorInterface; +use Zhortein\AuditableBundle\Transactional\Exception\IdentifierExtractionException; use Zhortein\AuditableBundle\Transactional\Model\AuditActor; use Zhortein\AuditableBundle\Transactional\Model\AuditEvent; use Zhortein\AuditableBundle\Transactional\Model\AuditRecord; use Zhortein\AuditableBundle\Transactional\Model\AuditSubject; +use Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor; final class TransactionalContainerIsolationTest extends TestCase { @@ -45,10 +47,15 @@ public function testTransactionalVocabularyIsIsolatedFromRuntimeContainerAndDoct self::assertTrue(class_exists($model)); self::assertFalse($container->has($model)); } - foreach ([AuditRecorderInterface::class, IdentifierExtractorInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + foreach ([AuditRecorderInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { self::assertTrue(interface_exists($contract)); self::assertFalse($container->has($contract)); } + self::assertTrue(interface_exists(IdentifierExtractorInterface::class)); + self::assertFalse($container->has(IdentifierExtractorInterface::class)); + self::assertFalse($container->has(DoctrineIdentifierExtractor::class)); + self::assertTrue(class_exists(IdentifierExtractionException::class)); + self::assertFalse($container->has(IdentifierExtractionException::class)); self::assertInstanceOf(AsyncAuditEntryWriter::class, $testContainer->get(AuditEntryWriterInterface::class)); self::assertInstanceOf(SecurityActorResolver::class, $testContainer->get(ActorResolverInterface::class)); @@ -91,7 +98,11 @@ public function testNoTransactionalAliasReplacesLegacyAliases(): void self::assertFalse($container->getAlias(AuditEntryWriterInterface::class)->isPublic()); self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); self::assertTrue($container->getAlias(ActorResolverInterface::class)->isPublic()); - foreach ([AuditRecorderInterface::class, IdentifierExtractorInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + self::assertSame(DoctrineIdentifierExtractor::class, (string) $container->getAlias(IdentifierExtractorInterface::class)); + self::assertFalse($container->getAlias(IdentifierExtractorInterface::class)->isPublic()); + self::assertTrue($container->hasDefinition(DoctrineIdentifierExtractor::class)); + self::assertFalse($container->getDefinition(DoctrineIdentifierExtractor::class)->isPublic()); + foreach ([AuditRecorderInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { self::assertFalse($container->hasAlias($contract)); } } diff --git a/tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php b/tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php new file mode 100644 index 0000000..ddf0221 --- /dev/null +++ b/tests/Unit/Transactional/Service/DoctrineIdentifierExtractorTest.php @@ -0,0 +1,280 @@ +extractor($entity, ['identifier'], ['identifier' => $value], 'Mapped\\Subject'); + + $subject = $extractor->extract($entity); + self::assertSame('Mapped\\Subject', $subject->type); + self::assertSame($expected, $subject->identifier); + } + + /** @return iterable */ + public static function validSimpleIdentifiers(): iterable + { + yield 'positive integer' => [42, '42']; + yield 'zero' => [0, '0']; + yield 'negative integer' => [-1, '-1']; + yield 'string' => ['customer-42', 'customer-42']; + yield 'spaces preserved' => [' customer-42 ', ' customer-42 ']; + yield 'string backed enum' => [StringIdentifier::ACTIVE, 'active']; + yield 'integer backed enum' => [IntegerIdentifier::FORTY_TWO, '42']; + yield 'Stringable UUID' => [new CountingStringable('0195f1dc-47ae-7ad2-b67f-9f5666dbbe37'), '0195f1dc-47ae-7ad2-b67f-9f5666dbbe37']; + } + + #[DataProvider('invalidSimpleIdentifiers')] + public function testRejectsUnsupportedOrInvalidSimpleIdentifier(mixed $value, string $type): void + { + $entity = new \stdClass(); + $extractor = $this->extractor($entity, ['identifier'], ['identifier' => $value]); + + try { + $extractor->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertStringContainsString($type, $exception->getMessage()); + self::assertStringNotContainsString('sensitive-raw-value', $exception->getMessage()); + } + } + + /** @return iterable */ + public static function invalidSimpleIdentifiers(): iterable + { + yield 'empty string' => ['', 'empty or blank']; + yield 'blank string' => [" \t\n", 'empty or blank']; + yield 'invalid UTF-8' => ["\xC3\x28", 'invalid UTF-8']; + yield 'empty Stringable' => [new CountingStringable(''), 'empty or blank']; + yield 'blank Stringable' => [new CountingStringable(' '), 'empty or blank']; + yield 'boolean' => [true, 'bool']; + yield 'float' => [1.5, 'float']; + yield 'array' => [['sensitive-raw-value'], 'array']; + yield 'object' => [new \stdClass(), 'stdClass']; + } + + public function testStringableIsCalledExactlyOnce(): void + { + $entity = new \stdClass(); + $value = new CountingStringable('uuid'); + $subject = $this->extractor($entity, ['id'], ['id' => $value])->extract($entity); + self::assertSame('uuid', $subject->identifier); + self::assertSame(1, $value->calls); + } + + public function testWrapsStringableFailureWithoutLeakingValue(): void + { + $entity = new \stdClass(); + $previous = new \LogicException('conversion failed'); + $extractor = $this->extractor($entity, ['id'], ['id' => new ThrowingStringable($previous)]); + try { + $extractor->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + self::assertStringNotContainsString('sensitive-raw-value', $exception->getMessage()); + } + } + + public function testRejectsResource(): void + { + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + try { + $entity = new \stdClass(); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('resource'); + $this->extractor($entity, ['id'], ['id' => $resource])->extract($entity); + } finally { + fclose($resource); + } + } + + /** + * @param list $fields + * @param array $values + */ + #[DataProvider('unavailableIdentifiers')] + public function testRejectsUnavailableIdentifier(array $fields, array $values, string $message): void + { + $entity = new \stdClass(); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage($message); + $this->extractor($entity, $fields, $values)->extract($entity); + } + + /** @return iterable, array, string}> */ + public static function unavailableIdentifiers(): iterable + { + yield 'no fields' => [[], [], 'declares no identifier field']; + yield 'missing simple value' => [['id'], [], 'not available']; + yield 'null omitted value' => [['id'], ['id' => null], 'not available']; + yield 'missing composite field' => [['country', 'number'], ['country' => 'FR'], '"number" is not available']; + } + + public function testCanonicalizesCompositeIdentifierInLexicalOrderWithTypesAndUnescapedCharacters(): void + { + $entity = new \stdClass(); + $subject = $this->extractor($entity, ['number', 'path', 'country'], [ + 'number' => 42, + 'path' => 'Île/Paris', + 'country' => 'FR', + ])->extract($entity); + + self::assertSame('{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"integer","value":"42"},"path":{"type":"string","value":"Île/Paris"}}}', $subject->identifier); + } + + public function testCompositeIntegerAndStringRepresentationsDiffer(): void + { + $integerEntity = new \stdClass(); + $stringEntity = new \stdClass(); + $integer = $this->extractor($integerEntity, ['country', 'number'], ['country' => 'FR', 'number' => 42])->extract($integerEntity); + $string = $this->extractor($stringEntity, ['country', 'number'], ['country' => 'FR', 'number' => '42'])->extract($stringEntity); + self::assertNotSame($integer->identifier, $string->identifier); + self::assertSame('{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"integer","value":"42"}}}', $integer->identifier); + self::assertSame('{"format":"doctrine-composite-v1","fields":{"country":{"type":"string","value":"FR"},"number":{"type":"string","value":"42"}}}', $string->identifier); + } + + public function testRejectsAssociationIdentifier(): void + { + $entity = new \stdClass(); + $related = new \stdClass(); + $extractor = $this->extractor($entity, ['related'], ['related' => $related], associations: ['related']); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('custom extractor or an explicit AuditSubject'); + $extractor->extract($entity); + } + + public function testRejectsMissingOrNonOrmManager(): void + { + $entity = new \stdClass(); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->with($entity::class)->willReturn(null); + $this->expectException(IdentifierExtractionException::class); + (new DoctrineIdentifierExtractor($registry))->extract($entity); + } + + public function testRejectsNonOrmManager(): void + { + $entity = new \stdClass(); + $manager = $this->createMock(ObjectManager::class); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->willReturn($manager); + $this->expectException(IdentifierExtractionException::class); + $this->expectExceptionMessage('not a Doctrine ORM entity manager'); + (new DoctrineIdentifierExtractor($registry))->extract($entity); + } + + public function testWrapsRegistryFailure(): void + { + $entity = new \stdClass(); + $previous = new \LogicException('registry failure'); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->willThrowException($previous); + try { + (new DoctrineIdentifierExtractor($registry))->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + } + } + + public function testWrapsMetadataFailure(): void + { + $entity = new \stdClass(); + $previous = new \LogicException('metadata failure'); + [$registry, $manager] = $this->manager($entity); + $manager->expects(self::once())->method('getClassMetadata')->willThrowException($previous); + try { + (new DoctrineIdentifierExtractor($registry))->extract($entity); + self::fail('Identifier extraction should have failed.'); + } catch (IdentifierExtractionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + } + } + + /** + * @param list $fields + * @param array $values + * @param list $associations + */ + private function extractor(object $entity, array $fields, array $values, string $mappedClass = 'Mapped\\Entity', array $associations = []): DoctrineIdentifierExtractor + { + [$registry, $manager] = $this->manager($entity); + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::atLeastOnce())->method('getName')->willReturn($mappedClass); + $metadata->expects(self::once())->method('getIdentifierFieldNames')->willReturn($fields); + $metadata->expects([] === $fields ? self::never() : self::once())->method('getIdentifierValues')->with($entity)->willReturn($values); + $metadata->method('hasAssociation')->willReturnCallback(static fn (string $field): bool => \in_array($field, $associations, true)); + $manager->expects(self::once())->method('getClassMetadata')->with($entity::class)->willReturn($metadata); + + return new DoctrineIdentifierExtractor($registry); + } + + /** @return array{ManagerRegistry&MockObject, EntityManagerInterface&MockObject} */ + private function manager(object $entity): array + { + $manager = $this->createMock(EntityManagerInterface::class); + foreach (['persist', 'flush', 'find', 'getRepository', 'getConnection', 'getUnitOfWork'] as $method) { + $manager->expects(self::never())->method($method); + } + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManagerForClass')->with($entity::class)->willReturn($manager); + + return [$registry, $manager]; + } +} + +enum StringIdentifier: string +{ + case ACTIVE = 'active'; +} + +enum IntegerIdentifier: int +{ + case FORTY_TWO = 42; +} + +final class CountingStringable implements \Stringable +{ + public int $calls = 0; + + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + ++$this->calls; + + return $this->value; + } +} + +final readonly class ThrowingStringable implements \Stringable +{ + public function __construct(private \Throwable $exception) + { + } + + public function __toString(): string + { + throw $this->exception; + } +} From 59fecc9e2a90203927b87607396a41f843fcd1f4 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 05:39:55 +0200 Subject: [PATCH 09/28] feat: add Symfony Security actor resolution Resolve authenticated Symfony users and immediate SwitchUserToken impersonation through TokenStorageInterface, with a private default resolver alias and no implicit role, token metadata, HTTP context, or Doctrine access. Invalid users and identifiers fail explicitly with ActorResolutionException while preserving the original exception and excluding raw identifiers from error messages. --- CHANGELOG.md | 1 + config/services.php | 3 + .../Exception/ActorResolutionException.php | 9 + .../Service/SymfonySecurityActorResolver.php | 90 ++++++++ ...mfonySecurityActorResolverContractTest.php | 62 ++++++ .../Transactional/Security/TestUser.php | 40 ++++ ...nySecurityActorResolverIntegrationTest.php | 111 ++++++++++ .../TransactionalContainerIsolationTest.php | 13 +- .../SymfonySecurityActorResolverTest.php | 197 ++++++++++++++++++ 9 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 src/Transactional/Exception/ActorResolutionException.php create mode 100644 src/Transactional/Service/SymfonySecurityActorResolver.php create mode 100644 tests/Contract/SymfonySecurityActorResolverContractTest.php create mode 100644 tests/Fixtures/Transactional/Security/TestUser.php create mode 100644 tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php create mode 100644 tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8765d..d127f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Direct runtime dependency on symfony/polyfill-mbstring, allowing the bundle to run without the native mbstring extension. - Add immutable transactional audit contracts and data-transfer objects without changing the legacy runtime behavior. - Add a default Doctrine ORM identifier extractor for deterministic scalar, Stringable and primitive composite audit subjects. +- Add a default Symfony Security actor resolver for authenticated users and immediate SwitchUserToken impersonation context. ## [1.0.0] - 2025-12-24 diff --git a/config/services.php b/config/services.php index 3f4915a..d08ad52 100644 --- a/config/services.php +++ b/config/services.php @@ -13,8 +13,10 @@ use Zhortein\AuditableBundle\Service\Historizer; use Zhortein\AuditableBundle\Service\SecurityActorResolver; use Zhortein\AuditableBundle\Service\SyncAuditEntryWriter; +use Zhortein\AuditableBundle\Transactional\Contract\AuditActorResolverInterface; use Zhortein\AuditableBundle\Transactional\Contract\IdentifierExtractorInterface; use Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor; +use Zhortein\AuditableBundle\Transactional\Service\SymfonySecurityActorResolver; use function Symfony\Component\DependencyInjection\Loader\Configurator\param; @@ -40,6 +42,7 @@ ]); $services->alias(IdentifierExtractorInterface::class, DoctrineIdentifierExtractor::class); + $services->alias(AuditActorResolverInterface::class, SymfonySecurityActorResolver::class); $services->set(AuditableMetadataProvider::class); diff --git a/src/Transactional/Exception/ActorResolutionException.php b/src/Transactional/Exception/ActorResolutionException.php new file mode 100644 index 0000000..24886e2 --- /dev/null +++ b/src/Transactional/Exception/ActorResolutionException.php @@ -0,0 +1,9 @@ +tokenStorage->getToken(); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The current security token could not be read.', previous: $exception); + } + + if (null === $token) { + return null; + } + + try { + $user = $token->getUser(); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The current user could not be read from the security token.', previous: $exception); + } + + if (!$user instanceof UserInterface) { + if ($token instanceof SwitchUserToken) { + throw new ActorResolutionException('The current user in the impersonation token is inconsistent.'); + } + + return null; + } + + $identifier = $this->resolveIdentifier($user, false); + $impersonatorIdentifier = null; + + if ($token instanceof SwitchUserToken) { + try { + $originalUser = $token->getOriginalToken()->getUser(); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The original user in the impersonation token could not be read.', previous: $exception); + } + if (!$originalUser instanceof UserInterface) { + throw new ActorResolutionException('The original user in the impersonation token is inconsistent.'); + } + $impersonatorIdentifier = $this->resolveIdentifier($originalUser, true); + } + + try { + return new AuditActor( + type: self::ACTOR_TYPE, + identifier: $identifier, + impersonatorIdentifier: $impersonatorIdentifier, + metadata: [], + ); + } catch (\Throwable $exception) { + throw new ActorResolutionException('The resolved security user cannot produce a valid audit actor.', previous: $exception); + } + } + + private function resolveIdentifier(UserInterface $user, bool $impersonator): string + { + try { + $identifier = $user->getUserIdentifier(); + } catch (\Throwable $exception) { + throw new ActorResolutionException($impersonator ? 'The impersonator identifier could not be read.' : 'The current user identifier could not be read.', previous: $exception); + } + + if (!mb_check_encoding($identifier, 'UTF-8') || 1 === preg_match('/^\s*$/u', $identifier)) { + throw new ActorResolutionException($impersonator ? 'The impersonator identifier is invalid.' : 'The current user identifier is invalid.'); + } + + return $identifier; + } +} diff --git a/tests/Contract/SymfonySecurityActorResolverContractTest.php b/tests/Contract/SymfonySecurityActorResolverContractTest.php new file mode 100644 index 0000000..3f6a01b --- /dev/null +++ b/tests/Contract/SymfonySecurityActorResolverContractTest.php @@ -0,0 +1,62 @@ +getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertTrue($reflection->implementsInterface(AuditActorResolverInterface::class)); + + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + $parameters = $constructor->getParameters(); + self::assertCount(1, $parameters); + self::assertSame('tokenStorage', $parameters[0]->getName()); + self::assertSame(TokenStorageInterface::class, (string) $parameters[0]->getType()); + self::assertTrue($parameters[0]->isPromoted()); + $properties = $reflection->getProperties(); + self::assertCount(1, $properties); + self::assertSame('tokenStorage', $properties[0]->getName()); + self::assertTrue($properties[0]->isPrivate()); + + $publicMethods = array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => SymfonySecurityActorResolver::class === $method->getDeclaringClass()->getName(), + )); + self::assertSame(['__construct', 'resolveActor'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $publicMethods)); + $resolve = $reflection->getMethod('resolveActor'); + self::assertCount(0, $resolve->getParameters()); + self::assertSame('?'.AuditActor::class, (string) $resolve->getReturnType()); + } + + public function testExceptionContractIsExact(): void + { + self::assertTrue(class_exists(ActorResolutionException::class)); + $reflection = new \ReflectionClass(ActorResolutionException::class); + self::assertSame('Zhortein\\AuditableBundle\\Transactional\\Exception', $reflection->getNamespaceName()); + self::assertTrue($reflection->isFinal()); + $parent = $reflection->getParentClass(); + self::assertInstanceOf(\ReflectionClass::class, $parent); + self::assertSame(\RuntimeException::class, $parent->getName()); + self::assertSame([], array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => ActorResolutionException::class === $method->getDeclaringClass()->getName(), + ))); + } +} diff --git a/tests/Fixtures/Transactional/Security/TestUser.php b/tests/Fixtures/Transactional/Security/TestUser.php new file mode 100644 index 0000000..438b047 --- /dev/null +++ b/tests/Fixtures/Transactional/Security/TestUser.php @@ -0,0 +1,40 @@ + */ + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void + { + } + + public function getUserIdentifier(): string + { + return $this->identifier; + } + + public function getId(): never + { + throw new \LogicException('getId() must not be called.'); + } + + public function id(): never + { + throw new \LogicException('id() must not be called.'); + } +} diff --git a/tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php b/tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php new file mode 100644 index 0000000..8daef2d --- /dev/null +++ b/tests/Integration/Transactional/SymfonySecurityActorResolverIntegrationTest.php @@ -0,0 +1,111 @@ +kernel = new TestKernel(); + $this->kernel->boot(); + $container = $this->kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $container); + $tokenStorage = $container->get(TokenStorageInterface::class); + self::assertInstanceOf(TokenStorageInterface::class, $tokenStorage); + $this->tokenStorage = $tokenStorage; + $this->resolver = new SymfonySecurityActorResolver($tokenStorage); + } + + protected function tearDown(): void + { + $cacheDir = $this->kernel->getCacheDir(); + $this->kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + public function testNoToken(): void + { + $this->assertActorWithToken(null, static function (?object $actor): void { + self::assertNull($actor); + }); + } + + public function testAuthenticatedUser(): void + { + $token = new UsernamePasswordToken(new TestUser('user-42'), 'test'); + $this->assertActorWithToken($token, static function (?object $actor): void { + self::assertNotNull($actor); + self::assertSame('authenticated_user', $actor->type); + self::assertSame('user-42', $actor->identifier); + self::assertNull($actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + }); + } + + public function testImpersonation(): void + { + $original = new UsernamePasswordToken(new TestUser('original-user'), 'test'); + $token = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $original); + $this->assertActorWithToken($token, static function (?object $actor): void { + self::assertNotNull($actor); + self::assertSame('authenticated_user', $actor->type); + self::assertSame('effective-user', $actor->identifier); + self::assertSame('original-user', $actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + }); + } + + public function testUnusableBusinessGettersAreNeverCalled(): void + { + $token = new UsernamePasswordToken(new TestUser('security-identifier'), 'test'); + $this->assertActorWithToken($token, static function (?object $actor): void { + self::assertNotNull($actor); + self::assertSame('security-identifier', $actor->identifier); + }); + } + + /** @param callable(?AuditActor): void $assertion */ + private function assertActorWithToken(?TokenInterface $token, callable $assertion): void + { + $this->tokenStorage->setToken($token); + try { + $assertion($this->resolver->resolveActor()); + } finally { + $this->tokenStorage->setToken(null); + } + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php index 6da3c08..3b4497a 100644 --- a/tests/Integration/TransactionalContainerIsolationTest.php +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -23,12 +23,14 @@ use Zhortein\AuditableBundle\Transactional\Contract\AuditRecorderInterface; use Zhortein\AuditableBundle\Transactional\Contract\AuditStorageInterface; use Zhortein\AuditableBundle\Transactional\Contract\IdentifierExtractorInterface; +use Zhortein\AuditableBundle\Transactional\Exception\ActorResolutionException; use Zhortein\AuditableBundle\Transactional\Exception\IdentifierExtractionException; use Zhortein\AuditableBundle\Transactional\Model\AuditActor; use Zhortein\AuditableBundle\Transactional\Model\AuditEvent; use Zhortein\AuditableBundle\Transactional\Model\AuditRecord; use Zhortein\AuditableBundle\Transactional\Model\AuditSubject; use Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor; +use Zhortein\AuditableBundle\Transactional\Service\SymfonySecurityActorResolver; final class TransactionalContainerIsolationTest extends TestCase { @@ -47,13 +49,16 @@ public function testTransactionalVocabularyIsIsolatedFromRuntimeContainerAndDoct self::assertTrue(class_exists($model)); self::assertFalse($container->has($model)); } - foreach ([AuditRecorderInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + foreach ([AuditRecorderInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { self::assertTrue(interface_exists($contract)); self::assertFalse($container->has($contract)); } self::assertTrue(interface_exists(IdentifierExtractorInterface::class)); self::assertFalse($container->has(IdentifierExtractorInterface::class)); self::assertFalse($container->has(DoctrineIdentifierExtractor::class)); + self::assertFalse($container->has(SymfonySecurityActorResolver::class)); + self::assertTrue(class_exists(ActorResolutionException::class)); + self::assertFalse($container->has(ActorResolutionException::class)); self::assertTrue(class_exists(IdentifierExtractionException::class)); self::assertFalse($container->has(IdentifierExtractionException::class)); @@ -102,7 +107,11 @@ public function testNoTransactionalAliasReplacesLegacyAliases(): void self::assertFalse($container->getAlias(IdentifierExtractorInterface::class)->isPublic()); self::assertTrue($container->hasDefinition(DoctrineIdentifierExtractor::class)); self::assertFalse($container->getDefinition(DoctrineIdentifierExtractor::class)->isPublic()); - foreach ([AuditRecorderInterface::class, AuditActorResolverInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { + self::assertSame(SymfonySecurityActorResolver::class, (string) $container->getAlias(AuditActorResolverInterface::class)); + self::assertFalse($container->getAlias(AuditActorResolverInterface::class)->isPublic()); + self::assertTrue($container->hasDefinition(SymfonySecurityActorResolver::class)); + self::assertFalse($container->getDefinition(SymfonySecurityActorResolver::class)->isPublic()); + foreach ([AuditRecorderInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { self::assertFalse($container->hasAlias($contract)); } } diff --git a/tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php b/tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php new file mode 100644 index 0000000..0cd5897 --- /dev/null +++ b/tests/Unit/Transactional/Service/SymfonySecurityActorResolverTest.php @@ -0,0 +1,197 @@ +createMock(TokenStorageInterface::class); + $storage->expects(self::once())->method('getToken')->willReturn(null); + self::assertNull((new SymfonySecurityActorResolver($storage))->resolveActor()); + } + + public function testReturnsNullWhenOrdinaryTokenHasNoUser(): void + { + $token = $this->token(null); + self::assertNull($this->resolver($token)->resolveActor()); + } + + #[DataProvider('validIdentifiers')] + public function testResolvesAuthenticatedUserExactly(string $identifier): void + { + $user = $this->user($identifier); + $actor = $this->resolver($this->token($user))->resolveActor(); + self::assertNotNull($actor); + self::assertSame('authenticated_user', $actor->type); + self::assertSame($identifier, $actor->identifier); + self::assertNull($actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + } + + /** @return iterable */ + public static function validIdentifiers(): iterable + { + yield 'zero' => ['0']; + yield 'UTF-8' => ['utilisateur-é']; + yield 'significant spaces' => [' utilisateur ']; + } + + #[DataProvider('invalidIdentifiers')] + public function testRejectsInvalidCurrentIdentifier(string $identifier): void + { + $this->expectException(ActorResolutionException::class); + $this->resolver($this->token($this->user($identifier)))->resolveActor(); + } + + /** @return iterable */ + public static function invalidIdentifiers(): iterable + { + yield 'empty' => ['']; + yield 'blank' => [" \t\n"]; + yield 'invalid UTF-8' => ["\xC3\x28"]; + } + + public function testWrapsTokenStorageFailure(): void + { + $previous = new \LogicException('storage failure'); + $storage = $this->createMock(TokenStorageInterface::class); + $storage->expects(self::once())->method('getToken')->willThrowException($previous); + $this->assertWrappedPrevious(new SymfonySecurityActorResolver($storage), $previous); + } + + public function testWrapsCurrentUserReadFailure(): void + { + $previous = new \LogicException('token failure'); + $token = $this->createMock(TokenInterface::class); + $token->expects(self::once())->method('getUser')->willThrowException($previous); + $this->assertWrappedPrevious($this->resolver($token), $previous); + } + + public function testWrapsIdentifierReadFailureWithoutLeakingIt(): void + { + $previous = new \LogicException('sensitive-raw-value'); + $user = $this->createMock(UserInterface::class); + $user->expects(self::once())->method('getUserIdentifier')->willThrowException($previous); + $user->expects(self::never())->method('getRoles'); + try { + $this->resolver($this->token($user))->resolveActor(); + self::fail('Actor resolution should have failed.'); + } catch (ActorResolutionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + self::assertStringNotContainsString('sensitive-raw-value', $exception->getMessage()); + } + } + + public function testResolvesImmediateSwitchUserContext(): void + { + $current = $this->user('effective-user'); + $original = $this->user('original-user'); + $originalToken = $this->token($original); + $switchToken = new SwitchUserToken($current, 'test', [], $originalToken); + $actor = $this->resolver($switchToken)->resolveActor(); + self::assertNotNull($actor); + self::assertSame('effective-user', $actor->identifier); + self::assertSame('original-user', $actor->impersonatorIdentifier); + self::assertSame([], $actor->metadata); + } + + public function testUsesOnlyImmediateOriginalTokenWhenSwitchTokensAreNested(): void + { + $rootToken = new UsernamePasswordToken(new TestUser('root-user'), 'test'); + $inner = new SwitchUserToken(new TestUser('immediate-user'), 'test', [], $rootToken); + $outer = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $inner); + $actor = $this->resolver($outer)->resolveActor(); + self::assertNotNull($actor); + self::assertSame('immediate-user', $actor->impersonatorIdentifier); + } + + public function testRejectsMissingOriginalUserWithoutPartialResult(): void + { + $switchToken = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $this->token(null)); + $this->expectException(ActorResolutionException::class); + $this->resolver($switchToken)->resolveActor(); + } + + #[DataProvider('invalidIdentifiers')] + public function testRejectsInvalidOriginalIdentifierWithoutLeakingIt(string $identifier): void + { + $switchToken = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $this->token($this->user($identifier))); + try { + $this->resolver($switchToken)->resolveActor(); + self::fail('Actor resolution should have failed.'); + } catch (ActorResolutionException $exception) { + if ('' !== $identifier) { + self::assertStringNotContainsString($identifier, $exception->getMessage()); + } + self::assertSame('The impersonator identifier is invalid.', $exception->getMessage()); + } + } + + public function testWrapsOriginalUserReadFailure(): void + { + $previous = new \LogicException('original token failure'); + $originalToken = $this->createMock(TokenInterface::class); + $originalToken->expects(self::once())->method('getUser')->willThrowException($previous); + $switchToken = new SwitchUserToken(new TestUser('effective-user'), 'test', [], $originalToken); + $this->assertWrappedPrevious($this->resolver($switchToken), $previous); + } + + public function testNeverCallsBusinessIdentifiersOrRoles(): void + { + $actor = $this->resolver(new UsernamePasswordToken(new TestUser('security-identifier'), 'test'))->resolveActor(); + self::assertNotNull($actor); + self::assertSame('security-identifier', $actor->identifier); + } + + /** @return UserInterface&MockObject */ + private function user(string $identifier): UserInterface + { + $user = $this->createMock(UserInterface::class); + $user->expects(self::once())->method('getUserIdentifier')->willReturn($identifier); + $user->expects(self::never())->method('getRoles'); + + return $user; + } + + /** @return TokenInterface&MockObject */ + private function token(?UserInterface $user): TokenInterface + { + $token = $this->createMock(TokenInterface::class); + $token->expects(self::once())->method('getUser')->willReturn($user); + + return $token; + } + + private function resolver(TokenInterface $token): SymfonySecurityActorResolver + { + $storage = $this->createMock(TokenStorageInterface::class); + $storage->expects(self::once())->method('getToken')->willReturn($token); + + return new SymfonySecurityActorResolver($storage); + } + + private function assertWrappedPrevious(SymfonySecurityActorResolver $resolver, \Throwable $previous): void + { + try { + $resolver->resolveActor(); + self::fail('Actor resolution should have failed.'); + } catch (ActorResolutionException $exception) { + self::assertSame($previous, $exception->getPrevious()); + } + } +} From 93d7d357dcf64889ba13f7e9986f73d4730986b0 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 06:21:22 +0200 Subject: [PATCH 10/28] feat: add strict transactional audit recorder Resolve explicit or strategy-provided subjects, actors, and timestamps into an AuditRecord before delegating entry creation and storage, with deterministic ordering and exact exception propagation. The recorder remains manually composable and performs no flush, transaction control, logging, Doctrine access, Messenger dispatch, or implicit Symfony service wiring. --- CHANGELOG.md | 1 + composer.json | 1 + config/services.php | 2 + .../Service/StrictAuditRecorder.php | 56 ++++ .../StrictAuditRecorderContractTest.php | 68 +++++ .../Transactional/Recorder/CallSequence.php | 16 ++ .../Recorder/CapturedAuditEntry.php | 14 + .../Recorder/CapturingAuditEntryFactory.php | 28 ++ .../Recorder/CapturingAuditStorage.php | 24 ++ .../Transactional/Recorder/FrozenClock.php | 23 ++ .../StrictAuditRecorderIntegrationTest.php | 144 ++++++++++ .../TransactionalContainerIsolationTest.php | 8 + .../Service/StrictAuditRecorderTest.php | 269 ++++++++++++++++++ 13 files changed, 654 insertions(+) create mode 100644 src/Transactional/Service/StrictAuditRecorder.php create mode 100644 tests/Contract/StrictAuditRecorderContractTest.php create mode 100644 tests/Fixtures/Transactional/Recorder/CallSequence.php create mode 100644 tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php create mode 100644 tests/Fixtures/Transactional/Recorder/CapturingAuditEntryFactory.php create mode 100644 tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php create mode 100644 tests/Fixtures/Transactional/Recorder/FrozenClock.php create mode 100644 tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php create mode 100644 tests/Unit/Transactional/Service/StrictAuditRecorderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d127f85..784bafd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Add immutable transactional audit contracts and data-transfer objects without changing the legacy runtime behavior. - Add a default Doctrine ORM identifier extractor for deterministic scalar, Stringable and primitive composite audit subjects. - Add a default Symfony Security actor resolver for authenticated users and immediate SwitchUserToken impersonation context. +- Add a manually composable strict transactional recorder that resolves audit subjects, actors and timestamps before delegating to application-supplied entry factories and storage. ## [1.0.0] - 2025-12-24 diff --git a/composer.json b/composer.json index 5a09ccf..f0093bd 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ ], "require": { "php": ">=8.3", + "psr/clock": "^1.0", "psr/log": "^3.0", "doctrine/doctrine-bundle": "^2.19 || ^3.0", "doctrine/orm": "^3.0", diff --git a/config/services.php b/config/services.php index d08ad52..3a3950a 100644 --- a/config/services.php +++ b/config/services.php @@ -39,6 +39,8 @@ __DIR__ . '/../src/Transactional/Contract/', __DIR__ . '/../src/Transactional/Exception/', __DIR__ . '/../src/Transactional/Model/', + // Manually composable until factory, storage and opt-in wiring are introduced. + __DIR__ . '/../src/Transactional/Service/StrictAuditRecorder.php', ]); $services->alias(IdentifierExtractorInterface::class, DoctrineIdentifierExtractor::class); diff --git a/src/Transactional/Service/StrictAuditRecorder.php b/src/Transactional/Service/StrictAuditRecorder.php new file mode 100644 index 0000000..5f3c51f --- /dev/null +++ b/src/Transactional/Service/StrictAuditRecorder.php @@ -0,0 +1,56 @@ + $entryFactory + * @param AuditStorageInterface $storage + */ + public function __construct( + private IdentifierExtractorInterface $identifierExtractor, + private AuditActorResolverInterface $actorResolver, + private ClockInterface $clock, + private AuditEntryFactoryInterface $entryFactory, + private AuditStorageInterface $storage, + ) { + } + + public function record(AuditEvent $event): void + { + $subject = $event->subject ?? (null !== $event->entity ? $this->identifierExtractor->extract($event->entity) : null); + $actor = $event->actor ?? $this->actorResolver->resolveActor(); + $occurredAt = $event->occurredAt ?? $this->clock->now(); + + $record = new AuditRecord( + occurredAt: $occurredAt, + action: $event->action, + level: $event->level, + title: $event->title, + description: $event->description, + context: $event->context, + subject: $subject, + actor: $actor, + isAuto: $event->isAuto, + data: $event->data, + ); + + $entry = $this->entryFactory->create($record); + $this->storage->persist($entry); + } +} diff --git a/tests/Contract/StrictAuditRecorderContractTest.php b/tests/Contract/StrictAuditRecorderContractTest.php new file mode 100644 index 0000000..59bbfd7 --- /dev/null +++ b/tests/Contract/StrictAuditRecorderContractTest.php @@ -0,0 +1,68 @@ +getNamespaceName()); + self::assertTrue($reflection->isFinal()); + self::assertTrue($reflection->isReadOnly()); + self::assertTrue($reflection->implementsInterface(AuditRecorderInterface::class)); + self::assertStringContainsString('@template TEntry of object', (string) $reflection->getDocComment()); + self::assertSame([], $reflection->getConstants(\ReflectionClassConstant::IS_PUBLIC)); + + $constructor = $reflection->getConstructor(); + self::assertNotNull($constructor); + self::assertTrue($constructor->isPublic()); + $parameters = $constructor->getParameters(); + self::assertSame( + ['identifierExtractor', 'actorResolver', 'clock', 'entryFactory', 'storage'], + array_map(static fn (\ReflectionParameter $parameter): string => $parameter->getName(), $parameters), + ); + self::assertSame( + [IdentifierExtractorInterface::class, AuditActorResolverInterface::class, ClockInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class], + array_map(static fn (\ReflectionParameter $parameter): string => (string) $parameter->getType(), $parameters), + ); + foreach ($parameters as $parameter) { + self::assertTrue($parameter->isPromoted()); + self::assertTrue($reflection->getProperty($parameter->getName())->isPrivate()); + } + $constructorDoc = (string) $constructor->getDocComment(); + self::assertMatchesRegularExpression('/@param AuditEntryFactoryInterface\s+\$entryFactory/', $constructorDoc); + self::assertMatchesRegularExpression('/@param AuditStorageInterface\s+\$storage/', $constructorDoc); + + $publicMethods = array_values(array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => StrictAuditRecorder::class === $method->getDeclaringClass()->getName(), + )); + self::assertSame(['__construct', 'record'], array_map(static fn (\ReflectionMethod $method): string => $method->getName(), $publicMethods)); + $record = $reflection->getMethod('record'); + self::assertSame(AuditEvent::class, (string) $record->getParameters()[0]->getType()); + self::assertSame('void', (string) $record->getReturnType()); + } + + public function testPsrClockIsADirectRuntimeDependency(): void + { + $composer = json_decode((string) file_get_contents(\dirname(__DIR__, 2).'/composer.json'), true, flags: \JSON_THROW_ON_ERROR); + self::assertIsArray($composer); + self::assertSame('^1.0', $composer['require']['psr/clock'] ?? null); + self::assertArrayNotHasKey('symfony/clock', $composer['require']); + self::assertArrayNotHasKey('symfony/clock', $composer['require-dev']); + } +} diff --git a/tests/Fixtures/Transactional/Recorder/CallSequence.php b/tests/Fixtures/Transactional/Recorder/CallSequence.php new file mode 100644 index 0000000..04fd518 --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/CallSequence.php @@ -0,0 +1,16 @@ + */ + public array $calls = []; + + public function add(string $call): void + { + $this->calls[] = $call; + } +} diff --git a/tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php b/tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php new file mode 100644 index 0000000..ec00a4f --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/CapturedAuditEntry.php @@ -0,0 +1,14 @@ + */ +final class CapturingAuditEntryFactory implements AuditEntryFactoryInterface +{ + /** @var list */ + public array $entries = []; + + public function __construct(private readonly ?CallSequence $sequence = null) + { + } + + public function create(AuditRecord $record): CapturedAuditEntry + { + $this->sequence?->add('factory'); + $entry = new CapturedAuditEntry($record); + $this->entries[] = $entry; + + return $entry; + } +} diff --git a/tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php b/tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php new file mode 100644 index 0000000..aa9107f --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/CapturingAuditStorage.php @@ -0,0 +1,24 @@ + */ +final class CapturingAuditStorage implements AuditStorageInterface +{ + /** @var list */ + public array $entries = []; + + public function __construct(private readonly ?CallSequence $sequence = null) + { + } + + public function persist(object $entry): void + { + $this->sequence?->add('storage'); + $this->entries[] = $entry; + } +} diff --git a/tests/Fixtures/Transactional/Recorder/FrozenClock.php b/tests/Fixtures/Transactional/Recorder/FrozenClock.php new file mode 100644 index 0000000..f0011e1 --- /dev/null +++ b/tests/Fixtures/Transactional/Recorder/FrozenClock.php @@ -0,0 +1,23 @@ +sequence?->add('clock'); + + return $this->time; + } +} diff --git a/tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php b/tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php new file mode 100644 index 0000000..93270fc --- /dev/null +++ b/tests/Integration/Transactional/StrictAuditRecorderIntegrationTest.php @@ -0,0 +1,144 @@ +kernel = new TestKernel(); + $this->kernel->boot(); + $container = $this->kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $container); + $tokenStorage = $container->get(TokenStorageInterface::class); + self::assertInstanceOf(TokenStorageInterface::class, $tokenStorage); + $this->tokenStorage = $tokenStorage; + } + + protected function tearDown(): void + { + $cacheDir = $this->kernel->getCacheDir(); + $this->kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + public function testAuthenticatedUserWithExplicitSubjectIsCaptured(): void + { + $time = new \DateTimeImmutable('2026-08-03 09:10:11.123456+02:00'); + $subject = new AuditSubject('order', '42'); + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + $recorder = new StrictAuditRecorder( + $this->neverExtractor(), + new SymfonySecurityActorResolver($this->tokenStorage), + new FrozenClock($time), + $factory, + $storage, + ); + $this->tokenStorage->setToken(new UsernamePasswordToken(new TestUser('authenticated-user'), 'test')); + try { + $recorder->record(new AuditEvent('update', 'Updated', subject: $subject, data: ['changed' => true])); + self::assertCount(1, $storage->entries); + self::assertSame($factory->entries[0], $storage->entries[0]); + $record = $storage->entries[0]->record; + self::assertSame($subject, $record->subject); + self::assertSame($time, $record->occurredAt); + self::assertSame(['changed' => true], $record->data); + self::assertNotNull($record->actor); + self::assertSame('authenticated_user', $record->actor->type); + self::assertSame('authenticated-user', $record->actor->identifier); + self::assertSame([], $record->actor->metadata); + } finally { + $this->tokenStorage->setToken(null); + } + } + + public function testExplicitActorBypassesResolverWithoutToken(): void + { + $actor = new AuditActor('system', 'worker', metadata: ['source' => 'command']); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::never())->method('resolveActor'); + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + $this->tokenStorage->setToken(null); + try { + (new StrictAuditRecorder($this->neverExtractor(), $resolver, new FrozenClock(new \DateTimeImmutable()), $factory, $storage)) + ->record(new AuditEvent('run', 'Command', actor: $actor)); + self::assertSame($actor, $storage->entries[0]->record->actor); + self::assertSame(['source' => 'command'], $storage->entries[0]->record->actor->metadata); + } finally { + $this->tokenStorage->setToken(null); + } + } + + public function testAnonymousGlobalEventIsCaptured(): void + { + $time = new \DateTimeImmutable('2026-08-03T00:00:00Z'); + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + $this->tokenStorage->setToken(null); + try { + (new StrictAuditRecorder( + $this->neverExtractor(), + new SymfonySecurityActorResolver($this->tokenStorage), + new FrozenClock($time), + $factory, + $storage, + ))->record(new AuditEvent('maintenance', 'Global', data: ['scope' => 'all'])); + $record = $storage->entries[0]->record; + self::assertNull($record->subject); + self::assertNull($record->actor); + self::assertSame($time, $record->occurredAt); + self::assertSame(['scope' => 'all'], $record->data); + } finally { + $this->tokenStorage->setToken(null); + } + } + + private function neverExtractor(): IdentifierExtractorInterface + { + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->expects(self::never())->method('extract'); + + return $extractor; + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php index 3b4497a..4912a13 100644 --- a/tests/Integration/TransactionalContainerIsolationTest.php +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Tools\SchemaTool; use PHPUnit\Framework\TestCase; +use Psr\Clock\ClockInterface; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -30,6 +31,7 @@ use Zhortein\AuditableBundle\Transactional\Model\AuditRecord; use Zhortein\AuditableBundle\Transactional\Model\AuditSubject; use Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor; +use Zhortein\AuditableBundle\Transactional\Service\StrictAuditRecorder; use Zhortein\AuditableBundle\Transactional\Service\SymfonySecurityActorResolver; final class TransactionalContainerIsolationTest extends TestCase @@ -57,6 +59,8 @@ public function testTransactionalVocabularyIsIsolatedFromRuntimeContainerAndDoct self::assertFalse($container->has(IdentifierExtractorInterface::class)); self::assertFalse($container->has(DoctrineIdentifierExtractor::class)); self::assertFalse($container->has(SymfonySecurityActorResolver::class)); + self::assertTrue(class_exists(StrictAuditRecorder::class)); + self::assertFalse($container->has(StrictAuditRecorder::class)); self::assertTrue(class_exists(ActorResolutionException::class)); self::assertFalse($container->has(ActorResolutionException::class)); self::assertTrue(class_exists(IdentifierExtractionException::class)); @@ -114,6 +118,10 @@ public function testNoTransactionalAliasReplacesLegacyAliases(): void foreach ([AuditRecorderInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { self::assertFalse($container->hasAlias($contract)); } + self::assertTrue($container->hasDefinition(StrictAuditRecorder::class)); + self::assertTrue($container->getDefinition(StrictAuditRecorder::class)->isAbstract()); + self::assertTrue($container->getDefinition(StrictAuditRecorder::class)->hasTag('container.excluded')); + self::assertFalse($container->hasAlias(ClockInterface::class)); } /** @param list> $metadata diff --git a/tests/Unit/Transactional/Service/StrictAuditRecorderTest.php b/tests/Unit/Transactional/Service/StrictAuditRecorderTest.php new file mode 100644 index 0000000..0b2345a --- /dev/null +++ b/tests/Unit/Transactional/Service/StrictAuditRecorderTest.php @@ -0,0 +1,269 @@ +capturingRecorder( + $this->neverExtractor(), + $this->neverActorResolver(), + $this->neverClock(), + ); + + $event = new AuditEvent( + action: 'publish', + title: 'Order published', + description: 'Exact description', + context: 'billing', + level: 'warning', + subject: $subject, + actor: $actor, + isAuto: true, + data: ['nested' => ['value' => 42]], + occurredAt: $occurredAt, + ); + $result = (new \ReflectionMethod($recorder, 'record'))->invoke($recorder, $event); + + self::assertNull($result); + self::assertCount(1, $factory->entries); + self::assertCount(1, $storage->entries); + self::assertSame($factory->entries[0], $storage->entries[0]); + $record = $factory->entries[0]->record; + self::assertSame($occurredAt, $record->occurredAt); + self::assertSame('publish', $record->action); + self::assertSame('warning', $record->level); + self::assertSame('Order published', $record->title); + self::assertSame('Exact description', $record->description); + self::assertSame('billing', $record->context); + self::assertSame($subject, $record->subject); + self::assertSame($actor, $record->actor); + self::assertTrue($record->isAuto); + self::assertSame(['nested' => ['value' => 42]], $record->data); + } + + public function testExtractsEntityExactlyOnceAndUsesReturnedSubject(): void + { + $entity = new \stdClass(); + $subject = new AuditSubject('entity', '7'); + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->expects(self::once())->method('extract')->with(self::identicalTo($entity))->willReturn($subject); + [$recorder, $factory] = $this->capturingRecorder($extractor, $this->neverActorResolver(), $this->neverClock()); + $recorder->record(new AuditEvent('update', 'Updated', entity: $entity, actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + self::assertSame($subject, $factory->entries[0]->record->subject); + } + + public function testGlobalEventHasNoSubjectAndNeverUsesExtractor(): void + { + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock()); + $recorder->record(new AuditEvent('run', 'Global', actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + self::assertNull($factory->entries[0]->record->subject); + } + + public function testResolvesActorExactlyOnceAndPreservesIdentity(): void + { + $actor = new AuditActor('authenticated_user', 'user'); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::once())->method('resolveActor')->willReturn($actor); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $resolver, $this->neverClock()); + $recorder->record(new AuditEvent('read', 'Read', occurredAt: new \DateTimeImmutable())); + self::assertSame($actor, $factory->entries[0]->record->actor); + } + + public function testNullResolvedActorIsValid(): void + { + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::once())->method('resolveActor')->willReturn(null); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $resolver, $this->neverClock()); + $recorder->record(new AuditEvent('read', 'Anonymous', occurredAt: new \DateTimeImmutable())); + self::assertNull($factory->entries[0]->record->actor); + } + + public function testUsesClockExactlyOnceWithoutChangingTimestamp(): void + { + $time = new PreciseDateTimeImmutable('2026-08-03 12:34:56.654321-07:00'); + $clock = $this->createMock(ClockInterface::class); + $clock->expects(self::once())->method('now')->willReturn($time); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $this->neverActorResolver(), $clock); + $recorder->record(new AuditEvent('run', 'Timed', actor: new AuditActor('system'))); + self::assertSame($time, $factory->entries[0]->record->occurredAt); + self::assertSame('-07:00', $factory->entries[0]->record->occurredAt->format('P')); + self::assertSame('654321', $factory->entries[0]->record->occurredAt->format('u')); + } + + public function testExecutionOrderIsExact(): void + { + $sequence = new CallSequence(); + $entity = new \stdClass(); + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->method('extract')->willReturnCallback(static function () use ($sequence): AuditSubject { + $sequence->add('subject'); + + return new AuditSubject('entity', '1'); + }); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->method('resolveActor')->willReturnCallback(static function () use ($sequence): AuditActor { + $sequence->add('actor'); + + return new AuditActor('system'); + }); + $clock = new FrozenClock(new \DateTimeImmutable(), $sequence); + $factory = new CapturingAuditEntryFactory($sequence); + $storage = new CapturingAuditStorage($sequence); + (new StrictAuditRecorder($extractor, $resolver, $clock, $factory, $storage))->record(new AuditEvent('update', 'Ordered', entity: $entity)); + self::assertSame($factory->entries[0], $storage->entries[0]); + self::assertSame(['subject', 'actor', 'clock', 'factory', 'storage'], $sequence->calls); + } + + public function testExtractorFailureStopsAllLaterStepsAndPropagatesSameException(): void + { + $exception = new \LogicException('extractor'); + $extractor = $this->createMock(IdentifierExtractorInterface::class); + $extractor->expects(self::once())->method('extract')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($extractor, $this->neverActorResolver(), $this->neverClock(), $this->neverFactory(), $this->neverStorage()), new AuditEvent('x', 'X', entity: new \stdClass())); + } + + public function testActorFailureStopsLaterStepsAndPropagatesSameException(): void + { + $exception = new \LogicException('actor'); + $resolver = $this->createMock(AuditActorResolverInterface::class); + $resolver->expects(self::once())->method('resolveActor')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $resolver, $this->neverClock(), $this->neverFactory(), $this->neverStorage()), new AuditEvent('x', 'X')); + } + + public function testClockFailureStopsLaterStepsAndPropagatesSameException(): void + { + $exception = new \LogicException('clock'); + $clock = $this->createMock(ClockInterface::class); + $clock->expects(self::once())->method('now')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $this->nullActorResolver(), $clock, $this->neverFactory(), $this->neverStorage()), new AuditEvent('x', 'X')); + } + + public function testFactoryFailureStopsStorageAndPropagatesSameException(): void + { + $exception = new \LogicException('factory'); + $factory = $this->createMock(AuditEntryFactoryInterface::class); + $factory->expects(self::once())->method('create')->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock(), $factory, $this->neverStorage()), new AuditEvent('x', 'X', actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + } + + public function testStorageFailurePropagatesSameException(): void + { + $exception = new \LogicException('storage'); + $entry = new \stdClass(); + $factory = $this->createMock(AuditEntryFactoryInterface::class); + $factory->expects(self::once())->method('create')->willReturn($entry); + $storage = $this->createMock(AuditStorageInterface::class); + $storage->expects(self::once())->method('persist')->with(self::identicalTo($entry))->willThrowException($exception); + $this->assertSameException($exception, new StrictAuditRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock(), $factory, $storage), new AuditEvent('x', 'X', actor: new AuditActor('system'), occurredAt: new \DateTimeImmutable())); + } + + public function testExplicitValuesBypassFailingStrategies(): void + { + $subject = new AuditSubject('explicit', '1'); + $actor = new AuditActor('explicit', '2'); + $time = new \DateTimeImmutable('2026-01-01T00:00:00+02:00'); + [$recorder, $factory] = $this->capturingRecorder($this->neverExtractor(), $this->neverActorResolver(), $this->neverClock()); + $recorder->record(new AuditEvent('x', 'X', subject: $subject, actor: $actor, occurredAt: $time)); + self::assertSame($subject, $factory->entries[0]->record->subject); + self::assertSame($actor, $factory->entries[0]->record->actor); + self::assertSame($time, $factory->entries[0]->record->occurredAt); + } + + /** @return array{StrictAuditRecorder, CapturingAuditEntryFactory, CapturingAuditStorage} */ + private function capturingRecorder(IdentifierExtractorInterface $extractor, AuditActorResolverInterface $resolver, ClockInterface $clock): array + { + $factory = new CapturingAuditEntryFactory(); + $storage = new CapturingAuditStorage(); + + return [new StrictAuditRecorder($extractor, $resolver, $clock, $factory, $storage), $factory, $storage]; + } + + private function neverExtractor(): IdentifierExtractorInterface&MockObject + { + $mock = $this->createMock(IdentifierExtractorInterface::class); + $mock->expects(self::never())->method('extract'); + + return $mock; + } + + private function neverActorResolver(): AuditActorResolverInterface&MockObject + { + $mock = $this->createMock(AuditActorResolverInterface::class); + $mock->expects(self::never())->method('resolveActor'); + + return $mock; + } + + private function nullActorResolver(): AuditActorResolverInterface&MockObject + { + $mock = $this->createMock(AuditActorResolverInterface::class); + $mock->expects(self::once())->method('resolveActor')->willReturn(null); + + return $mock; + } + + private function neverClock(): ClockInterface&MockObject + { + $mock = $this->createMock(ClockInterface::class); + $mock->expects(self::never())->method('now'); + + return $mock; + } + + /** @return AuditEntryFactoryInterface&MockObject */ + private function neverFactory(): AuditEntryFactoryInterface&MockObject + { + $mock = $this->createMock(AuditEntryFactoryInterface::class); + $mock->expects(self::never())->method('create'); + + return $mock; + } + + /** @return AuditStorageInterface&MockObject */ + private function neverStorage(): AuditStorageInterface&MockObject + { + $mock = $this->createMock(AuditStorageInterface::class); + $mock->expects(self::never())->method('persist'); + + return $mock; + } + + /** @param StrictAuditRecorder $recorder */ + private function assertSameException(\Throwable $expected, StrictAuditRecorder $recorder, AuditEvent $event): void + { + try { + $recorder->record($event); + self::fail('Recording should have failed.'); + } catch (\Throwable $actual) { + self::assertSame($expected, $actual); + } + } +} + +final class PreciseDateTimeImmutable extends \DateTimeImmutable +{ +} From 79bdd888c7fb6fe77ee8fa880d3e9edcc3d3f7db Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 07:37:54 +0200 Subject: [PATCH 11/28] feat: add opt-in transactional recorder wiring Register the strict transactional recorder and its private interface alias only when transactional.enabled is explicitly enabled, while requiring the application container to supply the entry factory, storage, and PSR-20 clock. The default-disabled path preserves the legacy container and schema, and no factory, storage, clock, flush, transaction control, or legacy fallback is provided by the bundle. --- CHANGELOG.md | 1 + README.md | 34 +++ .../packages/zhortein_auditable.yaml.example | 6 + docs/index.md | 3 + src/DependencyInjection/Configuration.php | 7 + .../ZhorteinAuditableExtension.php | 14 ++ .../App/TransactionalWiringTestKernel.php | 120 ++++++++++ .../Wiring/TransactionalRecorderConsumer.php | 25 +++ .../Wiring/TransactionalWiringPass.php | 39 ++++ ...sactionalRecorderWiringIntegrationTest.php | 207 ++++++++++++++++++ .../TransactionalContainerIsolationTest.php | 45 +++- tests/Unit/ConfigurationTest.php | 26 +++ 12 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/App/TransactionalWiringTestKernel.php create mode 100644 tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php create mode 100644 tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php create mode 100644 tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 784bafd..dd1de91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Add a default Doctrine ORM identifier extractor for deterministic scalar, Stringable and primitive composite audit subjects. - Add a default Symfony Security actor resolver for authenticated users and immediate SwitchUserToken impersonation context. - Add a manually composable strict transactional recorder that resolves audit subjects, actors and timestamps before delegating to application-supplied entry factories and storage. +- Add opt-in Symfony container wiring for the strict transactional recorder, requiring application-provided entry factory, storage and PSR-20 clock services. ## [1.0.0] - 2025-12-24 diff --git a/README.md b/README.md index f9fd4cb..c95b76b 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,40 @@ The resolver automatically handles: - Null users (not authenticated) → stores `null` - Impersonation → stores both original user and impersonator IDs +### Opt-in transactional recorder wiring + +The strict transactional recorder is disabled by default. Enable its Symfony container wiring explicitly: + +```yaml +# config/packages/zhortein_auditable.yaml +zhortein_auditable: + transactional: + enabled: true +``` + +The application must provide the entry factory, storage and PSR-20 clock through standard Symfony aliases: + +```yaml +# config/services.yaml +services: + App\Audit\AuditEntryFactory: ~ + App\Audit\AuditStorage: ~ + App\Audit\AuditClock: ~ + + Zhortein\AuditableBundle\Transactional\Contract\AuditEntryFactoryInterface: + alias: App\Audit\AuditEntryFactory + + Zhortein\AuditableBundle\Transactional\Contract\AuditStorageInterface: + alias: App\Audit\AuditStorage + + Psr\Clock\ClockInterface: + alias: App\Audit\AuditClock +``` + +The bundle supplies the Doctrine identifier extractor and Symfony Security actor resolver by default. Applications can replace their `IdentifierExtractorInterface` and `AuditActorResolverInterface` aliases using standard Symfony service configuration. + +No entry factory, storage or clock implementation is supplied by default, and activation fails during container compilation if one is missing. The recorder performs no flush or commit and does not manage transactions. With `transactional.enabled: false`, the legacy behavior and its existing services remain unchanged. + ## What gets stored Each `AuditEntry` record in the audit trail contains: diff --git a/config/packages/zhortein_auditable.yaml.example b/config/packages/zhortein_auditable.yaml.example index b6a51d3..13dac0d 100644 --- a/config/packages/zhortein_auditable.yaml.example +++ b/config/packages/zhortein_auditable.yaml.example @@ -11,6 +11,12 @@ zhortein_auditable: # Master enable/disable switch for the entire auditing system enabled: true + # Strict transactional recorder wiring is opt-in and disabled by default. + # Enabling it requires application services or aliases for + # AuditEntryFactoryInterface, AuditStorageInterface and Psr\Clock\ClockInterface. + transactional: + enabled: false + # Async message persistence configuration async: # Set to true to dispatch audit messages to Messenger (recommended) diff --git a/docs/index.md b/docs/index.md index 3c1460a..a69e3f7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,9 +53,12 @@ All audit entries are persisted to a dedicated `audit_entry` table, with optiona - [Installation & Quick Start](../README.md#installation) - [Configuration Reference](../README.md#configuration-reference) +- [Opt-in transactional recorder wiring](../README.md#opt-in-transactional-recorder-wiring) - [Security & PII Handling](../README.md#security--pii) - [GitHub Repository](https://github.com/zhortein/auditable-bundle) +The strict transactional recorder requires explicit activation and application-provided entry factory, storage and PSR-20 clock services. The bundle provides no implicit storage implementation or flush; see [the README wiring section](../README.md#opt-in-transactional-recorder-wiring). + ## Architecture The bundle consists of: diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1158cd1..dfb6c50 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -18,6 +18,13 @@ public function getConfigTreeBuilder(): TreeBuilder ->children() ->booleanNode('enabled')->defaultTrue()->end() + ->arrayNode('transactional') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->end() + ->end() + ->arrayNode('async') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/ZhorteinAuditableExtension.php b/src/DependencyInjection/ZhorteinAuditableExtension.php index 3a2d573..475c127 100644 --- a/src/DependencyInjection/ZhorteinAuditableExtension.php +++ b/src/DependencyInjection/ZhorteinAuditableExtension.php @@ -11,6 +11,8 @@ use Zhortein\AuditableBundle\Service\AsyncAuditEntryWriter; use Zhortein\AuditableBundle\Service\AuditEntryWriterInterface; use Zhortein\AuditableBundle\Service\SyncAuditEntryWriter; +use Zhortein\AuditableBundle\Transactional\Contract\AuditRecorderInterface; +use Zhortein\AuditableBundle\Transactional\Service\StrictAuditRecorder; final class ZhorteinAuditableExtension extends Extension { @@ -33,6 +35,18 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); $loader->load('services.php'); + $container->removeDefinition(StrictAuditRecorder::class); + + if ($config['transactional']['enabled']) { + $container + ->register(StrictAuditRecorder::class, StrictAuditRecorder::class) + ->setAutowired(true) + ->setAutoconfigured(false) + ->setPublic(false); + $container + ->setAlias(AuditRecorderInterface::class, StrictAuditRecorder::class) + ->setPublic(false); + } // Alias Writer (sync vs async) $asyncEnabled = (bool) $config['async']['enabled']; diff --git a/tests/Fixtures/App/TransactionalWiringTestKernel.php b/tests/Fixtures/App/TransactionalWiringTestKernel.php new file mode 100644 index 0000000..f368635 --- /dev/null +++ b/tests/Fixtures/App/TransactionalWiringTestKernel.php @@ -0,0 +1,120 @@ +addCompilerPass(new TransactionalWiringPass( + provideFactory: self::MISSING_FACTORY !== $this->environment, + provideStorage: self::MISSING_STORAGE !== $this->environment, + provideClock: self::MISSING_CLOCK !== $this->environment, + )); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container): void { + $ormConfig = [ + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ]; + + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'transactional-wiring-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => 'sqlite:///:memory:'], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', [ + 'transactional' => ['enabled' => true], + ]); + + $container->register(CallSequence::class)->setPublic(true); + $container + ->register(CapturingAuditEntryFactory::class) + ->setAutowired(true); + $container + ->register(CapturingAuditStorage::class) + ->setAutowired(true) + ->setPublic(true); + $container + ->register(FrozenClock::class) + ->setArguments([new Definition(\DateTimeImmutable::class, [self::NOW])]) + ->setAutowired(true); + $container + ->register(TransactionalRecorderConsumer::class) + ->setAutowired(true) + ->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-transactional-tests/'.getmypid().'-'.$this->environment; + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php b/tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php new file mode 100644 index 0000000..c980da8 --- /dev/null +++ b/tests/Fixtures/Transactional/Wiring/TransactionalRecorderConsumer.php @@ -0,0 +1,25 @@ +recorder->record($event); + } + + public function recorder(): AuditRecorderInterface + { + return $this->recorder; + } +} diff --git a/tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php b/tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php new file mode 100644 index 0000000..384b049 --- /dev/null +++ b/tests/Fixtures/Transactional/Wiring/TransactionalWiringPass.php @@ -0,0 +1,39 @@ +provideFactory) { + $container->setAlias(AuditEntryFactoryInterface::class, CapturingAuditEntryFactory::class); + } + if ($this->provideStorage) { + $container->setAlias(AuditStorageInterface::class, CapturingAuditStorage::class); + } + if ($this->provideClock) { + $container->setAlias(ClockInterface::class, FrozenClock::class); + } else { + $container->removeAlias(ClockInterface::class); + } + } +} diff --git a/tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php b/tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php new file mode 100644 index 0000000..aa69756 --- /dev/null +++ b/tests/Integration/Transactional/TransactionalRecorderWiringIntegrationTest.php @@ -0,0 +1,207 @@ +getCacheDir()); + $kernel->boot(); + + try { + $container = $kernel->getContainer(); + self::assertFalse($container->has(AuditRecorderInterface::class)); + self::assertFalse($container->has(StrictAuditRecorder::class)); + $testContainer = $container->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + self::assertInstanceOf(AsyncAuditEntryWriter::class, $testContainer->get(AuditEntryWriterInterface::class)); + self::assertInstanceOf(SecurityActorResolver::class, $testContainer->get(ActorResolverInterface::class)); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + self::assertSame( + ['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], + self::sortedTableNames($entityManager->getMetadataFactory()->getAllMetadata()), + ); + (new SchemaTool($entityManager))->createSchema($entityManager->getMetadataFactory()->getAllMetadata()); + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], $tables); + } finally { + self::shutdownAndRemove($kernel); + } + } + + public function testEnabledModeAutowiresApplicationStrategiesWithoutMakingRecorderPublic(): void + { + $kernel = new TransactionalWiringTestKernel(TransactionalWiringTestKernel::ENABLED); + self::removeDirectory($kernel->getCacheDir()); + $kernel->boot(); + + try { + $container = $kernel->getContainer(); + self::assertInstanceOf(Container::class, $container); + $consumer = $container->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + self::assertInstanceOf(StrictAuditRecorder::class, $consumer->recorder()); + self::assertFalse($container->has(StrictAuditRecorder::class)); + self::assertFalse($container->has(AuditRecorderInterface::class)); + + $subject = new AuditSubject('order', 'order-42'); + $actor = new AuditActor('user', 'david'); + $consumer->record(new AuditEvent( + action: 'approve', + title: 'Order approved', + description: 'Explicit wiring test', + context: 'orders', + subject: $subject, + actor: $actor, + data: ['order' => 42], + )); + + $storage = $container->get(CapturingAuditStorage::class); + self::assertInstanceOf(CapturingAuditStorage::class, $storage); + self::assertCount(1, $storage->entries); + $entry = $storage->entries[0]; + self::assertSame('2026-08-03T10:15:30+02:00', $entry->record->occurredAt->format(\DateTimeInterface::ATOM)); + self::assertSame($subject, $entry->record->subject); + self::assertSame($actor, $entry->record->actor); + self::assertSame('approve', $entry->record->action); + self::assertSame(['order' => 42], $entry->record->data); + + $testContainer = $container->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $factory = $testContainer->get(CapturingAuditEntryFactory::class); + self::assertInstanceOf(CapturingAuditEntryFactory::class, $factory); + self::assertSame($factory->entries[0], $storage->entries[0]); + $sequence = $container->get(CallSequence::class); + self::assertInstanceOf(CallSequence::class, $sequence); + self::assertSame(['clock', 'factory', 'storage'], $sequence->calls); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + self::assertSame([ + AuditEntry::class, + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\AuditableEntity', + 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity\\NonAuditableEntity', + ], self::sortedClassNames($entityManager->getMetadataFactory()->getAllMetadata())); + self::assertSame( + ['audit_entry', 'test_auditable_entity', 'test_non_auditable_entity'], + self::sortedTableNames($entityManager->getMetadataFactory()->getAllMetadata()), + ); + } finally { + self::shutdownAndRemove($kernel); + } + } + + #[DataProvider('missingDependencyProvider')] + public function testEnabledModeFailsWhenAnApplicationStrategyIsMissing(string $environment, string $interface): void + { + $kernel = new TransactionalWiringTestKernel($environment); + self::removeDirectory($kernel->getCacheDir()); + + try { + $kernel->boot(); + self::fail('The kernel boot unexpectedly succeeded without '.$interface.'.'); + } catch (\Throwable $exception) { + self::assertStringContainsString($interface, self::exceptionMessages($exception)); + } finally { + self::shutdownAndRemove($kernel); + } + } + + /** @return iterable */ + public static function missingDependencyProvider(): iterable + { + yield 'factory' => [TransactionalWiringTestKernel::MISSING_FACTORY, AuditEntryFactoryInterface::class]; + yield 'storage' => [TransactionalWiringTestKernel::MISSING_STORAGE, AuditStorageInterface::class]; + yield 'clock' => [TransactionalWiringTestKernel::MISSING_CLOCK, \Psr\Clock\ClockInterface::class]; + } + + private static function exceptionMessages(\Throwable $exception): string + { + $messages = []; + do { + $messages[] = $exception->getMessage(); + $exception = $exception->getPrevious(); + } while (null !== $exception); + + return implode("\n", $messages); + } + + /** @param list> $metadata + * @return list + */ + private static function sortedClassNames(array $metadata): array + { + $classes = array_map(static fn (ClassMetadata $classMetadata): string => $classMetadata->getName(), $metadata); + sort($classes); + + return $classes; + } + + /** @param list> $metadata + * @return list + */ + private static function sortedTableNames(array $metadata): array + { + $tables = array_map(static fn (ClassMetadata $classMetadata): string => $classMetadata->getTableName(), $metadata); + sort($tables); + + return $tables; + } + + private static function shutdownAndRemove(\Symfony\Component\HttpKernel\KernelInterface $kernel): void + { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php index 4912a13..d5aa0de 100644 --- a/tests/Integration/TransactionalContainerIsolationTest.php +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -118,10 +118,49 @@ public function testNoTransactionalAliasReplacesLegacyAliases(): void foreach ([AuditRecorderInterface::class, AuditEntryFactoryInterface::class, AuditStorageInterface::class] as $contract) { self::assertFalse($container->hasAlias($contract)); } - self::assertTrue($container->hasDefinition(StrictAuditRecorder::class)); - self::assertTrue($container->getDefinition(StrictAuditRecorder::class)->isAbstract()); - self::assertTrue($container->getDefinition(StrictAuditRecorder::class)->hasTag('container.excluded')); + self::assertFalse($container->hasDefinition(StrictAuditRecorder::class)); self::assertFalse($container->hasAlias(ClockInterface::class)); + self::assertNoTransactionalParameters($container); + } + + public function testTransactionalRecorderDefinitionIsConditionalAndPrivateBeforeCompilation(): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([['transactional' => ['enabled' => true]]], $container); + + self::assertTrue($container->hasDefinition(StrictAuditRecorder::class)); + $definition = $container->getDefinition(StrictAuditRecorder::class); + self::assertSame(StrictAuditRecorder::class, $definition->getClass()); + self::assertTrue($definition->isAutowired()); + self::assertFalse($definition->isAutoconfigured()); + self::assertFalse($definition->isPublic()); + self::assertTrue($definition->isShared()); + self::assertSame([], $definition->getArguments()); + self::assertSame([], $definition->getTags()); + + self::assertTrue($container->hasAlias(AuditRecorderInterface::class)); + $alias = $container->getAlias(AuditRecorderInterface::class); + self::assertSame(StrictAuditRecorder::class, (string) $alias); + self::assertFalse($alias->isPublic()); + + foreach ([AuditEntryFactoryInterface::class, AuditStorageInterface::class, ClockInterface::class] as $contract) { + self::assertFalse($container->hasAlias($contract)); + } + self::assertNoTransactionalParameters($container); + + $container->setAlias(IdentifierExtractorInterface::class, 'app.identifier_extractor')->setPublic(false); + $container->setAlias(AuditActorResolverInterface::class, 'app.actor_resolver')->setPublic(false); + self::assertSame('app.identifier_extractor', (string) $container->getAlias(IdentifierExtractorInterface::class)); + self::assertSame('app.actor_resolver', (string) $container->getAlias(AuditActorResolverInterface::class)); + } + + private static function assertNoTransactionalParameters(ContainerBuilder $container): void + { + $parameters = array_filter( + array_keys($container->getParameterBag()->all()), + static fn (int|string $name): bool => str_starts_with((string) $name, 'zhortein_auditable.transactional.'), + ); + self::assertSame([], $parameters); } /** @param list> $metadata diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 738e0c4..027ed74 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -5,6 +5,7 @@ namespace Zhortein\AuditableBundle\Tests\Unit; use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; use Zhortein\AuditableBundle\DependencyInjection\Configuration; @@ -14,6 +15,7 @@ public function testHistoricalDefaults(): void { self::assertSame([ 'enabled' => true, + 'transactional' => ['enabled' => false], 'async' => ['enabled' => true, 'transport' => 'async'], 'listener' => ['track_insert' => true, 'track_update' => true, 'track_delete' => true], 'fields' => ['max_string_length' => 180, 'global_ignored' => []], @@ -24,6 +26,7 @@ public function testExplicitHistoricalConfiguration(): void { $config = [ 'enabled' => false, + 'transactional' => ['enabled' => false], 'async' => ['enabled' => false, 'transport' => 'audit_transport'], 'listener' => ['track_insert' => false, 'track_update' => false, 'track_delete' => false], 'fields' => ['max_string_length' => 30, 'global_ignored' => ['updatedAt', 'version']], @@ -32,6 +35,29 @@ public function testExplicitHistoricalConfiguration(): void self::assertSame($config, $this->process($config)); } + public function testTransactionalModeCanBeExplicitlyEnabled(): void + { + $config = $this->process(['transactional' => ['enabled' => true]]); + + self::assertSame(['enabled' => true], $config['transactional']); + self::assertTrue($config['transactional']['enabled']); + } + + public function testTransactionalModeCanBeExplicitlyDisabled(): void + { + $config = $this->process(['transactional' => ['enabled' => false]]); + + self::assertSame(['enabled' => false], $config['transactional']); + self::assertFalse($config['transactional']['enabled']); + } + + public function testTransactionalModeRejectsNonBooleanValues(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process(['transactional' => ['enabled' => 'yes']]); + } + /** @param array $config * @return array */ From f60c0105f92f463a0cf4885ba58e13d225536bf9 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 08:28:47 +0200 Subject: [PATCH 12/28] test: prove PostgreSQL transactional audit atomicity Add an application-owned UUID v7 business entity, audit entity, entry factory, no-flush Doctrine storage, and dedicated PostgreSQL tests proving shared commit, shared rollback, and fail-closed rollback after a flushed business mutation. The executable example remains isolated to test fixtures and does not add a production entity, mapping, migration, storage, or PostgreSQL runtime requirement to the bundle. --- composer.json | 2 + phpunit.postgresql.xml.dist | 17 ++ phpunit.xml.dist | 1 + .../App/TransactionalPostgreSqlTestKernel.php | 111 ++++++++ .../ApplicationAuditEntryFactory.php | 35 +++ .../PostgreSql/DoctrineAuditStorage.php | 26 ++ .../Entity/ApplicationAuditEntry.php | 132 +++++++++ .../PostgreSql/Entity/BusinessOperation.php | 40 +++ .../PostgreSql/FailingAuditStorage.php | 17 ++ .../PostgreSql/PostgreSqlFrozenClock.php | 24 ++ .../PostgreSql/PostgreSqlWiringPass.php | 28 ++ .../TransactionalAtomicityIntegrationTest.php | 254 ++++++++++++++++++ 12 files changed, 687 insertions(+) create mode 100644 phpunit.postgresql.xml.dist create mode 100644 tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php create mode 100644 tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php create mode 100644 tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php diff --git a/composer.json b/composer.json index f0093bd..794bbe5 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "require-dev": { "phpunit/phpunit": "^11.0", "symfony/phpunit-bridge": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", "phpstan/phpstan": "^1.11", "friendsofphp/php-cs-fixer": "^3.60" }, @@ -67,6 +68,7 @@ }, "scripts": { "test": "vendor/bin/phpunit", + "test:postgresql": "vendor/bin/phpunit -c phpunit.postgresql.xml.dist", "phpstan": "vendor/bin/phpstan analyse --no-progress", "cs:check": "vendor/bin/php-cs-fixer fix --dry-run --diff", "cs:fix": "vendor/bin/php-cs-fixer fix" diff --git a/phpunit.postgresql.xml.dist b/phpunit.postgresql.xml.dist new file mode 100644 index 0000000..883759c --- /dev/null +++ b/phpunit.postgresql.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/Integration/PostgreSql + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b165b93..293974d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,6 +8,7 @@ tests + tests/Integration/PostgreSql diff --git a/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php b/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php new file mode 100644 index 0000000..26976a0 --- /dev/null +++ b/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php @@ -0,0 +1,111 @@ +databaseUrl = $databaseUrl; + + parent::__construct($environment, true); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new SecurityBundle(); + yield new DoctrineBundle(); + yield new ZhorteinAuditableBundle(); + } + + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->addCompilerPass(new PostgreSqlWiringPass(self::FAILING_STORAGE === $this->environment)); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $databaseUrl = $this->databaseUrl; + $loader->load(static function (ContainerBuilder $container) use ($databaseUrl): void { + $ormConfig = [ + 'mappings' => [ + 'TransactionalPostgreSqlFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Transactional/PostgreSql/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Transactional\\PostgreSql\\Entity', + 'is_bundle' => false, + ], + ], + ]; + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'transactional-postgresql-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => $databaseUrl], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', [ + 'transactional' => ['enabled' => true], + ]); + + $container->register(ApplicationAuditEntryFactory::class); + $container->register(DoctrineAuditStorage::class)->setAutowired(true); + $container->register(FailingAuditStorage::class); + $container->register(PostgreSqlFrozenClock::class); + $container + ->register(TransactionalRecorderConsumer::class) + ->setAutowired(true) + ->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-postgresql-tests/'.getmypid().'-'.$this->environment; + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php b/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php new file mode 100644 index 0000000..6a2437e --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php @@ -0,0 +1,35 @@ + */ +final readonly class ApplicationAuditEntryFactory implements AuditEntryFactoryInterface +{ + public function create(AuditRecord $record): ApplicationAuditEntry + { + return new ApplicationAuditEntry( + id: Uuid::v7()->toRfc4122(), + occurredAt: $record->occurredAt, + action: $record->action, + level: $record->level, + title: $record->title, + description: $record->description, + context: $record->context, + subjectType: $record->subject?->type, + subjectIdentifier: $record->subject?->identifier, + actorType: $record->actor?->type, + actorIdentifier: $record->actor?->identifier, + impersonatorIdentifier: $record->actor?->impersonatorIdentifier, + actorMetadata: $record->actor?->metadata ?? [], + isAuto: $record->isAuto, + data: $record->data, + ); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php b/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php new file mode 100644 index 0000000..8c2690a --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php @@ -0,0 +1,26 @@ + */ +final readonly class DoctrineAuditStorage implements AuditStorageInterface +{ + public function __construct(private EntityManagerInterface $entityManager) + { + } + + public function persist(object $entry): void + { + if (!$entry instanceof ApplicationAuditEntry) { + throw new \InvalidArgumentException('DoctrineAuditStorage accepts only ApplicationAuditEntry instances.'); + } + + $this->entityManager->persist($entry); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php b/tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php new file mode 100644 index 0000000..62dd80e --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/Entity/ApplicationAuditEntry.php @@ -0,0 +1,132 @@ + $actorMetadata + * @param array $data + */ + public function __construct( + #[ORM\Id] + #[ORM\Column(type: Types::GUID)] + private string $id, + \DateTimeImmutable $occurredAt, + #[ORM\Column(length: 64)] + private string $action, + #[ORM\Column(length: 32)] + private string $level, + #[ORM\Column(length: 255)] + private string $title, + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description, + #[ORM\Column(length: 255, nullable: true)] + private ?string $context, + #[ORM\Column(length: 255, nullable: true)] + private ?string $subjectType, + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $subjectIdentifier, + #[ORM\Column(length: 128, nullable: true)] + private ?string $actorType, + #[ORM\Column(length: 255, nullable: true)] + private ?string $actorIdentifier, + #[ORM\Column(length: 255, nullable: true)] + private ?string $impersonatorIdentifier, + #[ORM\Column(type: Types::JSON)] + private array $actorMetadata, + #[ORM\Column] + private bool $isAuto, + #[ORM\Column(type: Types::JSON)] + private array $data, + ) { + $this->occurredAt = $occurredAt->format('Y-m-d\\TH:i:s.uP'); + } + + public function getId(): string + { + return $this->id; + } + + public function getOccurredAt(): \DateTimeImmutable + { + return new \DateTimeImmutable($this->occurredAt); + } + + public function getAction(): string + { + return $this->action; + } + + public function getLevel(): string + { + return $this->level; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getContext(): ?string + { + return $this->context; + } + + public function getSubjectType(): ?string + { + return $this->subjectType; + } + + public function getSubjectIdentifier(): ?string + { + return $this->subjectIdentifier; + } + + public function getActorType(): ?string + { + return $this->actorType; + } + + public function getActorIdentifier(): ?string + { + return $this->actorIdentifier; + } + + public function getImpersonatorIdentifier(): ?string + { + return $this->impersonatorIdentifier; + } + + /** @return array */ + public function getActorMetadata(): array + { + return $this->actorMetadata; + } + + public function isAuto(): bool + { + return $this->isAuto; + } + + /** @return array */ + public function getData(): array + { + return $this->data; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php b/tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php new file mode 100644 index 0000000..8329396 --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/Entity/BusinessOperation.php @@ -0,0 +1,40 @@ +id = Uuid::v7()->toRfc4122(); + } + + public function getId(): string + { + return $this->id; + } + + public function getStatus(): string + { + return $this->status; + } + + public function changeStatus(string $status): void + { + $this->status = $status; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php b/tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php new file mode 100644 index 0000000..f3a946a --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/FailingAuditStorage.php @@ -0,0 +1,17 @@ + */ +final readonly class FailingAuditStorage implements AuditStorageInterface +{ + public function persist(object $entry): void + { + throw new \RuntimeException('Intentional transactional audit storage failure.'); + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php new file mode 100644 index 0000000..e18061d --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlFrozenClock.php @@ -0,0 +1,24 @@ +time = new \DateTimeImmutable(self::INSTANT); + } + + public function now(): \DateTimeImmutable + { + return $this->time; + } +} diff --git a/tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php new file mode 100644 index 0000000..ef65610 --- /dev/null +++ b/tests/Fixtures/Transactional/PostgreSql/PostgreSqlWiringPass.php @@ -0,0 +1,28 @@ +setAlias(AuditEntryFactoryInterface::class, ApplicationAuditEntryFactory::class); + $container->setAlias( + AuditStorageInterface::class, + $this->failingStorage ? FailingAuditStorage::class : DoctrineAuditStorage::class, + ); + $container->setAlias(ClockInterface::class, PostgreSqlFrozenClock::class); + } +} diff --git a/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php new file mode 100644 index 0000000..233f001 --- /dev/null +++ b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php @@ -0,0 +1,254 @@ +bootKernel(TransactionalPostgreSqlTestKernel::NORMAL); + $connection = $entityManager->getConnection(); + + try { + $this->assertSchema($connection); + $this->assertStorageHasOnlyOnePersistCall(); + + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + self::assertInstanceOf(UuidV7::class, Uuid::fromString($operation->getId())); + $entityManager->persist($operation); + $operation->changeStatus('completed'); + self::assertSame('completed', $operation->getStatus()); + $consumer->record($this->auditEvent($operation)); + + $this->assertCounts($connection, 0, 0); + $this->assertCounts($observer, 0, 0); + + $entityManager->flush(); + + $this->assertCounts($connection, 1, 1); + $this->assertCounts($observer, 0, 0); + + $connection->commit(); + + $this->assertCounts($observer, 1, 1); + self::assertSame('completed', $observer->fetchOne('SELECT status FROM '.self::BUSINESS_TABLE)); + + $entityManager->clear(); + $entry = $entityManager->getRepository(ApplicationAuditEntry::class)->findOneBy([]); + self::assertInstanceOf(ApplicationAuditEntry::class, $entry); + self::assertInstanceOf(UuidV7::class, Uuid::fromString($entry->getId())); + self::assertSame(PostgreSqlFrozenClock::INSTANT, $entry->getOccurredAt()->format('Y-m-d\\TH:i:s.uP')); + self::assertSame('complete', $entry->getAction()); + self::assertSame('notice', $entry->getLevel()); + self::assertSame('Business operation completed', $entry->getTitle()); + self::assertSame('Atomic PostgreSQL operation', $entry->getDescription()); + self::assertSame('transactional-test', $entry->getContext()); + self::assertSame(BusinessOperation::class, $entry->getSubjectType()); + self::assertSame($operation->getId(), $entry->getSubjectIdentifier()); + self::assertSame('test-user', $entry->getActorType()); + self::assertSame('david', $entry->getActorIdentifier()); + self::assertSame('administrator', $entry->getImpersonatorIdentifier()); + self::assertSame(['tenant' => 'test'], $entry->getActorMetadata()); + self::assertTrue($entry->isAuto()); + self::assertSame(['status' => ['from' => 'pending', 'to' => 'completed']], $entry->getData()); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + public function testBusinessMutationAndAuditRollBackTogether(): void + { + [$kernel, $entityManager, $observer] = $this->bootKernel(TransactionalPostgreSqlTestKernel::NORMAL); + $connection = $entityManager->getConnection(); + + try { + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + $entityManager->persist($operation); + $consumer->record($this->auditEvent($operation)); + $entityManager->flush(); + + $this->assertCounts($connection, 1, 1); + $this->assertCounts($observer, 0, 0); + + $connection->rollBack(); + $entityManager->close(); + + $this->assertCounts($observer, 0, 0); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + public function testStrictAuditFailureKeepsAnAlreadyFlushedBusinessMutationRollbackable(): void + { + [$kernel, $entityManager, $observer] = $this->bootKernel(TransactionalPostgreSqlTestKernel::FAILING_STORAGE); + $connection = $entityManager->getConnection(); + + try { + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + $entityManager->persist($operation); + $entityManager->flush(); + + $this->assertCounts($connection, 1, 0); + $this->assertCounts($observer, 0, 0); + + try { + $consumer->record($this->auditEvent($operation)); + self::fail('The strict recorder unexpectedly absorbed the storage failure.'); + } catch (\RuntimeException $exception) { + self::assertSame('Intentional transactional audit storage failure.', $exception->getMessage()); + if ($connection->isTransactionActive()) { + $connection->rollBack(); + } + $entityManager->close(); + } + + $this->assertCounts($observer, 0, 0); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + + /** @return array{TransactionalPostgreSqlTestKernel, EntityManagerInterface, Connection} */ + private function bootKernel(string $environment): array + { + $kernel = new TransactionalPostgreSqlTestKernel($environment); + self::removeDirectory($kernel->getCacheDir()); + $kernel->boot(); + + $testContainer = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($entityManager); + $schemaTool->dropDatabase(); + $schemaTool->createSchema($metadata); + + $observer = DriverManager::getConnection($entityManager->getConnection()->getParams()); + + return [$kernel, $entityManager, $observer]; + } + + private function assertSchema(Connection $connection): void + { + $tables = $connection->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['audit_entry', self::AUDIT_TABLE, self::BUSINESS_TABLE], $tables); + + foreach ([self::BUSINESS_TABLE, self::AUDIT_TABLE] as $table) { + self::assertSame('uuid', $connection->fetchOne( + 'SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?', + [$table, 'id'], + )); + } + } + + private function assertStorageHasOnlyOnePersistCall(): void + { + $filename = (new \ReflectionClass(DoctrineAuditStorage::class))->getFileName(); + self::assertIsString($filename); + $source = file_get_contents($filename); + self::assertIsString($source); + self::assertSame(1, substr_count($source, '$this->entityManager->persist($entry);')); + foreach (['->flush(', 'beginTransaction(', 'commit(', 'rollBack(', 'wrapInTransaction(', 'transactional(', 'getConnection(', 'createQuery(', 'getRepository('] as $forbidden) { + self::assertStringNotContainsString($forbidden, $source); + } + } + + private function auditEvent(BusinessOperation $operation): AuditEvent + { + return new AuditEvent( + action: 'complete', + title: 'Business operation completed', + description: 'Atomic PostgreSQL operation', + context: 'transactional-test', + level: 'notice', + entity: $operation, + actor: new AuditActor( + type: 'test-user', + identifier: 'david', + impersonatorIdentifier: 'administrator', + metadata: ['tenant' => 'test'], + ), + isAuto: true, + data: ['status' => ['from' => 'pending', 'to' => 'completed']], + ); + } + + private function assertCounts(Connection $connection, int $business, int $audit): void + { + self::assertSame($business, (int) $connection->fetchOne('SELECT COUNT(*) FROM '.self::BUSINESS_TABLE)); + self::assertSame($audit, (int) $connection->fetchOne('SELECT COUNT(*) FROM '.self::AUDIT_TABLE)); + } + + private function closeResources( + TransactionalPostgreSqlTestKernel $kernel, + EntityManagerInterface $entityManager, + Connection $observer, + ): void { + $connection = $entityManager->getConnection(); + if ($connection->isTransactionActive()) { + $connection->rollBack(); + } + if ($entityManager->isOpen()) { + $entityManager->close(); + } + $observer->close(); + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} From 5867b908422b4ef3ea1d729164261210ea0049f6 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 08:29:31 +0200 Subject: [PATCH 13/28] ci: run PostgreSQL transactional guarantee tests Execute the dedicated PostgreSQL 16 atomicity suite on the minimum PHP 8.3 and latest PHP 8.5 supported stacks, covering Doctrine DBAL 3 and 4 without duplicating the general compatibility and QA gates. --- .github/workflows/ci.yml | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a51b23a..4435d8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: doctrine_orm: "3" doctrine_dbal: "3" doctrine_persistence: "3" + prefer_lowest: true dependency_flags: "--prefer-lowest --prefer-stable" native_mbstring: false extensions: "pdo_sqlite, :mbstring, iconv, dom, xml, zip" @@ -36,6 +37,7 @@ jobs: doctrine_orm: "3" doctrine_dbal: "4" doctrine_persistence: "4" + prefer_lowest: false dependency_flags: "" native_mbstring: true extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" @@ -153,6 +155,76 @@ jobs: - run: composer validate --strict - run: composer phpstan -- --memory-limit=512M + transactional-postgresql: + name: Transactional PostgreSQL / ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: PHP 8.3 / Symfony 7.4 / DBundle 2.19 / ORM 3 / lowest + php: "8.3" + symfony: "7.4.*" + doctrine_bundle: "2.19" + doctrine_bundle_major: "2" + doctrine_orm: "3" + doctrine_dbal: "3" + doctrine_persistence: "3" + dependency_flags: "--prefer-lowest --prefer-stable" + - name: PHP 8.5 / Symfony 8.x / DBundle 3 / ORM 3 / latest + php: "8.5" + symfony: "8.*" + symfony_resolved: "8.1.*" + doctrine_bundle: "3" + doctrine_bundle_major: "3" + doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" + dependency_flags: "" + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: auditable_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd "pg_isready -U postgres -d auditable_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + env: + TEST_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5432/auditable_test?serverVersion=16&charset=utf8" + SYMFONY_DEPRECATIONS_HELPER: "max[direct]=0" + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php }}" + extensions: pdo_pgsql, mbstring, dom, xml, zip + tools: composer:v2 + coverage: none + - name: Install Symfony Flex + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex:^2 + - name: Resolve dependencies + env: + SYMFONY_REQUIRE: "${{ matrix.symfony }}" + run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" --with "doctrine/dbal:^${{ matrix.doctrine_dbal }}.0" --with "doctrine/persistence:^${{ matrix.doctrine_persistence }}.0" ${{ matrix.dependency_flags }} + - run: composer validate --strict + - name: Assert resolved versions + env: + EXPECT_NATIVE_MBSTRING: "true" + run: php .github/ci/assert-versions.php "${{ matrix.php }}" "${{ matrix.symfony_resolved || matrix.symfony }}" "${{ matrix.doctrine_bundle_major }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" + - run: composer check-platform-reqs + - run: composer show --direct symfony/uid + - run: composer why symfony/uid + - run: composer test:postgresql + coding-style: name: Coding style runs-on: ubuntu-latest From b375cf69c4c6801d5adb299dd77ae6554305f401 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 08:30:01 +0200 Subject: [PATCH 14/28] docs: document Doctrine transactional audit integration Document application-owned audit entities, no-flush Doctrine storage, shared EntityManager and connection requirements, explicit transaction boundaries, and the executable PostgreSQL commit, rollback, and fail-closed proof. --- CHANGELOG.md | 1 + README.md | 12 ++++++++ docs/index.md | 3 ++ docs/transactional-doctrine.md | 51 ++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 docs/transactional-doctrine.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1de91..74a2056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Add a default Symfony Security actor resolver for authenticated users and immediate SwitchUserToken impersonation context. - Add a manually composable strict transactional recorder that resolves audit subjects, actors and timestamps before delegating to application-supplied entry factories and storage. - Add opt-in Symfony container wiring for the strict transactional recorder, requiring application-provided entry factory, storage and PSR-20 clock services. +- Add a PostgreSQL-backed executable application example proving shared commit, shared rollback and fail-closed behavior with an application-owned UUID v7 audit entity and a Doctrine storage that never flushes. ## [1.0.0] - 2025-12-24 diff --git a/README.md b/README.md index c95b76b..7440a4e 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,18 @@ The bundle supplies the Doctrine identifier extractor and Symfony Security actor No entry factory, storage or clock implementation is supplied by default, and activation fails during container compilation if one is missing. The recorder performs no flush or commit and does not manage transactions. With `transactional.enabled: false`, the legacy behavior and its existing services remain unchanged. +For Doctrine applications, keep the business mutation and audit entry in the same application-owned Unit of Work: + +```php +$entityManager->wrapInTransaction(function (EntityManagerInterface $entityManager) use ($operation, $auditRecorder): void { + $operation->complete(); + $entityManager->persist($operation); + $auditRecorder->record(new AuditEvent(action: 'complete', title: 'Operation completed', entity: $operation)); +}); +``` + +No Doctrine audit storage or audit entity is provided by the bundle; the mapping and persistence strategy remain application choices. See the [transactional Doctrine guide](docs/transactional-doctrine.md) for the full boundary rules and executable PostgreSQL example. + ## What gets stored Each `AuditEntry` record in the audit trail contains: diff --git a/docs/index.md b/docs/index.md index a69e3f7..0d67d0c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,11 +54,14 @@ All audit entries are persisted to a dedicated `audit_entry` table, with optiona - [Installation & Quick Start](../README.md#installation) - [Configuration Reference](../README.md#configuration-reference) - [Opt-in transactional recorder wiring](../README.md#opt-in-transactional-recorder-wiring) +- [Transactional Doctrine and PostgreSQL guide](transactional-doctrine.md) - [Security & PII Handling](../README.md#security--pii) - [GitHub Repository](https://github.com/zhortein/auditable-bundle) The strict transactional recorder requires explicit activation and application-provided entry factory, storage and PSR-20 clock services. The bundle provides no implicit storage implementation or flush; see [the README wiring section](../README.md#opt-in-transactional-recorder-wiring). +The [transactional Doctrine guide](transactional-doctrine.md) documents the application-owned mapping and executable PostgreSQL proofs for shared commit, shared rollback and fail-closed behavior. + ## Architecture The bundle consists of: diff --git a/docs/transactional-doctrine.md b/docs/transactional-doctrine.md new file mode 100644 index 0000000..e2a5629 --- /dev/null +++ b/docs/transactional-doctrine.md @@ -0,0 +1,51 @@ +# Transactional Doctrine integration + +The strict recorder is deliberately independent from an application's persistence model. An application that stores audit entries with Doctrine supplies its own audit entity, `AuditEntryFactoryInterface` implementation, `AuditStorageInterface` implementation and PSR-20 clock. The bundle provides none of these persistence choices. + +## Application-owned storage + +A minimal Doctrine storage attaches the application-owned audit entity to the same Unit of Work as the business mutation: + +```php +use Doctrine\ORM\EntityManagerInterface; +use Zhortein\AuditableBundle\Transactional\Contract\AuditStorageInterface; + +final readonly class DoctrineAuditStorage implements AuditStorageInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + ) { + } + + public function persist(object $entry): void + { + // Optional application-specific type validation belongs here. + $this->entityManager->persist($entry); + } +} +``` + +Calling `persist()` does not commit or even issue the insert. The storage alone does not guarantee durability. Atomicity requires the business mutation and audit storage to use the same entity manager and underlying connection; a factory or storage using another connection cannot provide the same guarantee. + +## Application transaction boundary + +The application owns the transaction boundary. `wrapInTransaction()` is the concise choice when one Doctrine Unit of Work covers the operation: + +```php +$entityManager->wrapInTransaction(function (EntityManagerInterface $entityManager) use ($operation, $auditRecorder): void { + $operation->complete(); + $entityManager->persist($operation); + + $auditRecorder->record(new AuditEvent( + action: 'complete', + title: 'Operation completed', + entity: $operation, + )); +}); +``` + +The recorder does not catch factory or storage errors. Doctrine controls flush and commit at the boundary, so a strict audit failure aborts the business transaction. Applications needing finer control can instead call `beginTransaction()`, perform the mutation and audit, call `flush()` and `commit()`, and call `rollBack()` immediately on failure. + +After a flush or transaction error, abandon the entity manager according to Doctrine's transaction practices rather than attempting to reuse a potentially inconsistent Unit of Work. Never keep a database transaction open during user interaction or across HTTP requests. + +The executable PostgreSQL fixtures in [`tests/Fixtures/Transactional/PostgreSql`](../tests/Fixtures/Transactional/PostgreSql) and [`TransactionalAtomicityIntegrationTest`](../tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php) demonstrate shared commit, shared rollback and fail-closed behavior with application-owned UUID v7 entities. From 3bfbf0fed606e8b103e3e6ba53d1dfcbe1abd3e6 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 09:19:34 +0200 Subject: [PATCH 15/28] feat: allow disabling the legacy Doctrine mapping Add a guarded legacy_mapping.enabled option that retains the AuditEntry mapping by default and conditionally omits it only after legacy auditing has been disabled. The opt-out changes Doctrine metadata registration only: it performs no table deletion, SQL, migration, queue draining, service removal, or automatic storage replacement. --- CHANGELOG.md | 1 + .../packages/zhortein_auditable.yaml.example | 6 + src/DependencyInjection/Configuration.php | 11 ++ .../ZhorteinAuditableExtension.php | 1 + src/ZhorteinAuditableBundle.php | 37 ++++-- ...TransactionalNoLegacyMappingTestKernel.php | 102 +++++++++++++++++ .../App/TransactionalPostgreSqlTestKernel.php | 6 +- .../DoctrineBundleIntegrationTest.php | 1 + .../TransactionalAtomicityIntegrationTest.php | 49 ++++++++ .../LegacyMappingOptOutIntegrationTest.php | 105 ++++++++++++++++++ .../TransactionalContainerIsolationTest.php | 38 +++++++ tests/Unit/ConfigurationTest.php | 36 ++++++ 12 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php create mode 100644 tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a2056..3ad6cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Add a manually composable strict transactional recorder that resolves audit subjects, actors and timestamps before delegating to application-supplied entry factories and storage. - Add opt-in Symfony container wiring for the strict transactional recorder, requiring application-provided entry factory, storage and PSR-20 clock services. - Add a PostgreSQL-backed executable application example proving shared commit, shared rollback and fail-closed behavior with an application-owned UUID v7 audit entity and a Doctrine storage that never flushes. +- Add an explicit legacy Doctrine mapping opt-out for transactional-only applications, disabled by default and guarded against active legacy auditing. ## [1.0.0] - 2025-12-24 diff --git a/config/packages/zhortein_auditable.yaml.example b/config/packages/zhortein_auditable.yaml.example index 13dac0d..2033475 100644 --- a/config/packages/zhortein_auditable.yaml.example +++ b/config/packages/zhortein_auditable.yaml.example @@ -11,6 +11,12 @@ zhortein_auditable: # Master enable/disable switch for the entire auditing system enabled: true + # Keep the bundle's historical AuditEntry Doctrine mapping by default. + # Disabling it requires enabled: false and is intended for applications that + # use only their own transactional audit storage. No table is removed automatically. + legacy_mapping: + enabled: true + # Strict transactional recorder wiring is opt-in and disabled by default. # Enabling it requires application services or aliases for # AuditEntryFactoryInterface, AuditStorageInterface and Psr\Clock\ClockInterface. diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index dfb6c50..2fc3f16 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -15,9 +15,20 @@ public function getConfigTreeBuilder(): TreeBuilder $root = $treeBuilder->getRootNode(); $root + ->validate() + ->ifTrue(static fn (array $config): bool => $config['enabled'] && !$config['legacy_mapping']['enabled']) + ->thenInvalid('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.') + ->end() ->children() ->booleanNode('enabled')->defaultTrue()->end() + ->arrayNode('legacy_mapping') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->end() + ->end() + ->arrayNode('transactional') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/ZhorteinAuditableExtension.php b/src/DependencyInjection/ZhorteinAuditableExtension.php index 475c127..fb4dc79 100644 --- a/src/DependencyInjection/ZhorteinAuditableExtension.php +++ b/src/DependencyInjection/ZhorteinAuditableExtension.php @@ -22,6 +22,7 @@ public function load(array $configs, ContainerBuilder $container): void $config = $this->processConfiguration($configuration, $configs); $container->setParameter('zhortein_auditable.enabled', (bool) $config['enabled']); + $container->setParameter('zhortein_auditable.legacy_mapping.enabled', (bool) $config['legacy_mapping']['enabled']); $container->setParameter('zhortein_auditable.async.enabled', (bool) $config['async']['enabled']); $container->setParameter('zhortein_auditable.async.transport', (string) $config['async']['transport']); diff --git a/src/ZhorteinAuditableBundle.php b/src/ZhorteinAuditableBundle.php index e61d17a..cf2b0fd 100644 --- a/src/ZhorteinAuditableBundle.php +++ b/src/ZhorteinAuditableBundle.php @@ -5,6 +5,7 @@ namespace Zhortein\AuditableBundle; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -25,17 +26,33 @@ public function build(ContainerBuilder $container): void return; } + $mappingPass = DoctrineOrmMappingsPass::createAttributeMappingDriver( + // Namespaces / mapping prefixes + ['Zhortein\\AuditableBundle\\Entity'], + // Directories that contain the entities + [$entityDir], + // Manager parameters: empty = all entity managers + [], + // Enabled parameter: preserve the mapping by default, with an explicit opt-out. + 'zhortein_auditable.legacy_mapping.enabled', + ); + $container->addCompilerPass( - DoctrineOrmMappingsPass::createAttributeMappingDriver( - // Namespaces / mapping prefixes - ['Zhortein\\AuditableBundle\\Entity'], - // Directories that contain the entities - [$entityDir], - // Manager parameters: empty = all entity managers - [], - // Enabled parameter: false = always enabled - false, - ) + new readonly class($mappingPass) implements CompilerPassInterface { + public function __construct( + private DoctrineOrmMappingsPass $mappingPass, + ) { + } + + public function process(ContainerBuilder $container): void + { + if (true !== $container->getParameter('zhortein_auditable.legacy_mapping.enabled')) { + return; + } + + $this->mappingPass->process($container); + } + } ); } } diff --git a/tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php b/tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php new file mode 100644 index 0000000..b08a599 --- /dev/null +++ b/tests/Fixtures/App/TransactionalNoLegacyMappingTestKernel.php @@ -0,0 +1,102 @@ +addCompilerPass(new TransactionalWiringPass(true, true, true)); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container): void { + $ormConfig = [ + 'mappings' => [ + 'TestFixtures' => [ + 'type' => 'attribute', + 'dir' => \dirname(__DIR__).'/Entity', + 'prefix' => 'Zhortein\\AuditableBundle\\Tests\\Fixtures\\Entity', + 'is_bundle' => false, + ], + ], + ]; + if (InstalledVersions::satisfies(new VersionParser(), 'doctrine/doctrine-bundle', '^2.0')) { + $ormConfig['auto_generate_proxy_classes'] = true; + } + + $container->loadFromExtension('framework', [ + 'secret' => 'transactional-no-legacy-mapping-tests', + 'test' => true, + 'messenger' => ['default_bus' => 'messenger.bus.default'], + ]); + $container->loadFromExtension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['test' => ['security' => false]], + ]); + $container->loadFromExtension('doctrine', [ + 'dbal' => ['url' => 'sqlite:///:memory:'], + 'orm' => $ormConfig, + ]); + $container->loadFromExtension('zhortein_auditable', [ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => false], + 'transactional' => ['enabled' => true], + ]); + + $container->register(CapturingAuditEntryFactory::class); + $container->register(CapturingAuditStorage::class); + $container->register(FrozenClock::class) + ->setArguments([new Definition(\DateTimeImmutable::class, [self::NOW])]); + $container->register(TransactionalRecorderConsumer::class) + ->setAutowired(true) + ->setPublic(true); + }); + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/auditable-bundle-no-legacy-mapping-tests/'.getmypid(); + } + + public function getLogDir(): string + { + return $this->getCacheDir().'/log'; + } +} diff --git a/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php b/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php index 26976a0..2e848ff 100644 --- a/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php +++ b/tests/Fixtures/App/TransactionalPostgreSqlTestKernel.php @@ -24,6 +24,7 @@ final class TransactionalPostgreSqlTestKernel extends Kernel { public const NORMAL = 'postgresql_transactional'; public const FAILING_STORAGE = 'postgresql_transactional_failing_storage'; + public const WITHOUT_LEGACY_MAPPING = 'postgresql_transactional_without_legacy_mapping'; private readonly string $databaseUrl; @@ -56,7 +57,8 @@ public function build(ContainerBuilder $container): void public function registerContainerConfiguration(LoaderInterface $loader): void { $databaseUrl = $this->databaseUrl; - $loader->load(static function (ContainerBuilder $container) use ($databaseUrl): void { + $withoutLegacyMapping = self::WITHOUT_LEGACY_MAPPING === $this->environment; + $loader->load(static function (ContainerBuilder $container) use ($databaseUrl, $withoutLegacyMapping): void { $ormConfig = [ 'mappings' => [ 'TransactionalPostgreSqlFixtures' => [ @@ -85,6 +87,8 @@ public function registerContainerConfiguration(LoaderInterface $loader): void 'orm' => $ormConfig, ]); $container->loadFromExtension('zhortein_auditable', [ + 'enabled' => !$withoutLegacyMapping, + 'legacy_mapping' => ['enabled' => !$withoutLegacyMapping], 'transactional' => ['enabled' => true], ]); diff --git a/tests/Integration/DoctrineBundleIntegrationTest.php b/tests/Integration/DoctrineBundleIntegrationTest.php index 62bb648..68f6166 100644 --- a/tests/Integration/DoctrineBundleIntegrationTest.php +++ b/tests/Integration/DoctrineBundleIntegrationTest.php @@ -48,6 +48,7 @@ protected function tearDown(): void public function testBundleMappingSchemaAndDoctrineListenerUseDoctrineBundleEntityManager(): void { + self::assertTrue($this->kernel->getContainer()->getParameter('zhortein_auditable.legacy_mapping.enabled')); $metadata = $this->entityManager->getClassMetadata(AuditEntry::class); self::assertSame(AuditEntry::class, $metadata->getName()); self::assertSame('audit_entry', $metadata->getTableName()); diff --git a/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php index 233f001..51aa782 100644 --- a/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php +++ b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php @@ -8,10 +8,12 @@ use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; +use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\UuidV7; +use Zhortein\AuditableBundle\Entity\AuditEntry; use Zhortein\AuditableBundle\Tests\Fixtures\App\TransactionalPostgreSqlTestKernel; use Zhortein\AuditableBundle\Tests\Fixtures\Transactional\PostgreSql\DoctrineAuditStorage; use Zhortein\AuditableBundle\Tests\Fixtures\Transactional\PostgreSql\Entity\ApplicationAuditEntry; @@ -143,6 +145,44 @@ public function testStrictAuditFailureKeepsAnAlreadyFlushedBusinessMutationRollb } } + public function testTransactionalApplicationCanOwnTheOnlyAuditMapping(): void + { + [$kernel, $entityManager, $observer] = $this->bootKernel(TransactionalPostgreSqlTestKernel::WITHOUT_LEGACY_MAPPING); + $connection = $entityManager->getConnection(); + + try { + $metadataNames = array_map( + static fn ($metadata): string => $metadata->getName(), + $entityManager->getMetadataFactory()->getAllMetadata(), + ); + sort($metadataNames); + self::assertSame([ApplicationAuditEntry::class, BusinessOperation::class], $metadataNames); + self::assertFalse($connection->createSchemaManager()->tablesExist(['audit_entry'])); + self::assertSame([self::AUDIT_TABLE, self::BUSINESS_TABLE], $this->sortedTableNames($connection)); + + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + + $connection->beginTransaction(); + $operation = new BusinessOperation('pending'); + $entityManager->persist($operation); + $operation->changeStatus('completed'); + $consumer->record($this->auditEvent($operation)); + $entityManager->flush(); + $connection->commit(); + + $this->assertCounts($observer, 1, 1); + self::assertFalse($observer->createSchemaManager()->tablesExist(['audit_entry'])); + $testContainer = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $registry = $testContainer->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + self::assertNull($registry->getManagerForClass(AuditEntry::class)); + } finally { + $this->closeResources($kernel, $entityManager, $observer); + } + } + /** @return array{TransactionalPostgreSqlTestKernel, EntityManagerInterface, Connection} */ private function bootKernel(string $environment): array { @@ -217,6 +257,15 @@ private function assertCounts(Connection $connection, int $business, int $audit) self::assertSame($audit, (int) $connection->fetchOne('SELECT COUNT(*) FROM '.self::AUDIT_TABLE)); } + /** @return list */ + private function sortedTableNames(Connection $connection): array + { + $tables = $connection->createSchemaManager()->listTableNames(); + sort($tables); + + return $tables; + } + private function closeResources( TransactionalPostgreSqlTestKernel $kernel, EntityManagerInterface $entityManager, diff --git a/tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php b/tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php new file mode 100644 index 0000000..c785cae --- /dev/null +++ b/tests/Integration/Transactional/LegacyMappingOptOutIntegrationTest.php @@ -0,0 +1,105 @@ +getCacheDir()); + $kernel->boot(); + + try { + $container = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $container); + $registry = $container->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + $manager = $registry->getManagerForClass(AuditEntry::class); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + self::assertSame('audit_entry', $manager->getClassMetadata(AuditEntry::class)->getTableName()); + + (new SchemaTool($manager))->createSchema($manager->getMetadataFactory()->getAllMetadata()); + self::assertTrue($manager->getConnection()->createSchemaManager()->tablesExist(['audit_entry'])); + } finally { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + } + + public function testTransactionalRecorderWorksWithoutLegacyMapping(): void + { + $kernel = new TransactionalNoLegacyMappingTestKernel(); + self::removeDirectory($kernel->getCacheDir()); + $kernel->boot(); + + try { + self::assertTrue(class_exists(AuditEntry::class)); + $testContainer = $kernel->getContainer()->get('test.service_container'); + self::assertInstanceOf(ContainerInterface::class, $testContainer); + $registry = $testContainer->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + self::assertNull($registry->getManagerForClass(AuditEntry::class)); + + $entityManager = $testContainer->get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + $metadataNames = array_map(static fn ($item): string => $item->getName(), $metadata); + sort($metadataNames); + self::assertSame([AuditableEntity::class, NonAuditableEntity::class], $metadataNames); + + (new SchemaTool($entityManager))->createSchema($metadata); + $tables = $entityManager->getConnection()->createSchemaManager()->listTableNames(); + sort($tables); + self::assertSame(['test_auditable_entity', 'test_non_auditable_entity'], $tables); + + $consumer = $kernel->getContainer()->get(TransactionalRecorderConsumer::class); + self::assertInstanceOf(TransactionalRecorderConsumer::class, $consumer); + $consumer->record(new AuditEvent( + action: 'mapping-opt-out', + title: 'Transactional recorder without legacy mapping', + subject: null, + actor: new AuditActor('test-user', 'david'), + )); + } finally { + $cacheDir = $kernel->getCacheDir(); + $kernel->shutdown(); + restore_exception_handler(); + self::removeDirectory($cacheDir); + } + } + + private static function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { + if ($item instanceof \SplFileInfo && $item->isDir()) { + rmdir($item->getPathname()); + } elseif ($item instanceof \SplFileInfo) { + unlink($item->getPathname()); + } + } + rmdir($directory); + } +} diff --git a/tests/Integration/TransactionalContainerIsolationTest.php b/tests/Integration/TransactionalContainerIsolationTest.php index d5aa0de..22017bf 100644 --- a/tests/Integration/TransactionalContainerIsolationTest.php +++ b/tests/Integration/TransactionalContainerIsolationTest.php @@ -9,13 +9,16 @@ use Doctrine\ORM\Tools\SchemaTool; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Zhortein\AuditableBundle\DependencyInjection\ZhorteinAuditableExtension; +use Zhortein\AuditableBundle\Doctrine\AuditableDoctrineListener; use Zhortein\AuditableBundle\Entity\AuditEntry; use Zhortein\AuditableBundle\Service\ActorResolverInterface; use Zhortein\AuditableBundle\Service\AsyncAuditEntryWriter; +use Zhortein\AuditableBundle\Service\AuditEntryPersister; use Zhortein\AuditableBundle\Service\AuditEntryWriterInterface; use Zhortein\AuditableBundle\Service\SecurityActorResolver; use Zhortein\AuditableBundle\Tests\Fixtures\App\TestKernel; @@ -120,6 +123,8 @@ public function testNoTransactionalAliasReplacesLegacyAliases(): void } self::assertFalse($container->hasDefinition(StrictAuditRecorder::class)); self::assertFalse($container->hasAlias(ClockInterface::class)); + self::assertTrue($container->hasParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertTrue($container->getParameter('zhortein_auditable.legacy_mapping.enabled')); self::assertNoTransactionalParameters($container); } @@ -154,6 +159,39 @@ public function testTransactionalRecorderDefinitionIsConditionalAndPrivateBefore self::assertSame('app.actor_resolver', (string) $container->getAlias(AuditActorResolverInterface::class)); } + public function testLegacyMappingOptOutParameterIsFalseBeforeCompilation(): void + { + $container = new ContainerBuilder(); + (new ZhorteinAuditableExtension())->load([[ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => false], + ]], $container); + + self::assertTrue($container->hasParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertFalse($container->getParameter('zhortein_auditable.legacy_mapping.enabled')); + self::assertFalse($container->getParameter('zhortein_auditable.enabled')); + self::assertTrue($container->hasDefinition(AuditEntryPersister::class)); + self::assertTrue($container->hasDefinition(AuditableDoctrineListener::class)); + self::assertSame(AsyncAuditEntryWriter::class, (string) $container->getAlias(AuditEntryWriterInterface::class)); + self::assertSame(SecurityActorResolver::class, (string) $container->getAlias(ActorResolverInterface::class)); + self::assertFalse($container->hasAlias(AuditEntryFactoryInterface::class)); + self::assertFalse($container->hasAlias(AuditStorageInterface::class)); + self::assertFalse($container->hasAlias(ClockInterface::class)); + } + + public function testLegacyMappingCannotBeDisabledForAnActiveLegacyRuntime(): void + { + $container = new ContainerBuilder(); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.'); + + (new ZhorteinAuditableExtension())->load([[ + 'enabled' => true, + 'legacy_mapping' => ['enabled' => false], + ]], $container); + } + private static function assertNoTransactionalParameters(ContainerBuilder $container): void { $parameters = array_filter( diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 027ed74..545d2b9 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -15,6 +15,7 @@ public function testHistoricalDefaults(): void { self::assertSame([ 'enabled' => true, + 'legacy_mapping' => ['enabled' => true], 'transactional' => ['enabled' => false], 'async' => ['enabled' => true, 'transport' => 'async'], 'listener' => ['track_insert' => true, 'track_update' => true, 'track_delete' => true], @@ -26,6 +27,7 @@ public function testExplicitHistoricalConfiguration(): void { $config = [ 'enabled' => false, + 'legacy_mapping' => ['enabled' => true], 'transactional' => ['enabled' => false], 'async' => ['enabled' => false, 'transport' => 'audit_transport'], 'listener' => ['track_insert' => false, 'track_update' => false, 'track_delete' => false], @@ -58,6 +60,40 @@ public function testTransactionalModeRejectsNonBooleanValues(): void $this->process(['transactional' => ['enabled' => 'yes']]); } + public function testLegacyMappingCanBeExplicitlyEnabled(): void + { + $config = $this->process(['legacy_mapping' => ['enabled' => true]]); + + self::assertSame(['enabled' => true], $config['legacy_mapping']); + } + + public function testLegacyMappingCanBeDisabledWithLegacyRuntime(): void + { + $config = $this->process([ + 'enabled' => false, + 'legacy_mapping' => ['enabled' => false], + 'transactional' => ['enabled' => true], + ]); + + self::assertSame(['enabled' => false], $config['legacy_mapping']); + self::assertSame(['enabled' => true], $config['transactional']); + } + + public function testLegacyMappingCannotBeDisabledWhileLegacyRuntimeIsEnabled(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.'); + + $this->process(['legacy_mapping' => ['enabled' => false]]); + } + + public function testLegacyMappingRejectsNonBooleanValues(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process(['legacy_mapping' => ['enabled' => 'no']]); + } + /** @param array $config * @return array */ From 476f2302813ab068d2b1323741f51c5f22b783d9 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 09:19:50 +0200 Subject: [PATCH 16/28] docs: document legacy mapping opt-out Document coexistence and transactional-only configurations, clarify that disabling the mapping never removes an existing table, and require legacy Messenger queues and workers to be drained before opting out. --- README.md | 20 +++++++++++++++++++ docs/index.md | 3 ++- docs/transactional-doctrine.md | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7440a4e..c446f15 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ The bundle's configuration options are documented with comments in `config/packa **Key settings:** - **`enabled`**: Master switch to enable/disable auditing globally (default: `true`) +- **`legacy_mapping.enabled`**: Register the legacy `AuditEntry` Doctrine mapping (default: `true`) - **`async.enabled`**: Use Messenger for async persistence (default: `true`) - **`async.transport`**: Messenger transport name for audit messages (default: `'async'`) - **`listener.track_insert/update/delete`**: Control which operations are tracked (all default to `true`) @@ -190,6 +191,25 @@ $entityManager->wrapInTransaction(function (EntityManagerInterface $entityManage No Doctrine audit storage or audit entity is provided by the bundle; the mapping and persistence strategy remain application choices. See the [transactional Doctrine guide](docs/transactional-doctrine.md) for the full boundary rules and executable PostgreSQL example. +### Transactional-only applications + +Applications that no longer use any legacy audit path can opt out of the bundle's historical `AuditEntry` mapping: + +```yaml +zhortein_auditable: + enabled: false + + legacy_mapping: + enabled: false + + transactional: + enabled: true +``` + +`enabled` controls the legacy runtime, `legacy_mapping.enabled` controls only Doctrine's knowledge of the legacy `AuditEntry`, and `transactional.enabled` controls only the strict recorder. Their defaults are respectively `true`, `true` and `false`, so existing applications without this configuration are unchanged. Disabling the mapping while the legacy runtime remains enabled is rejected. + +The opt-out does not delete an existing `audit_entry` table or provide a migration. Before enabling it in an application that used asynchronous legacy auditing, stop producing legacy messages, drain pending `PersistAuditEntryMessage` messages and ensure no legacy worker still writes them. See [Transactional-only mapping](docs/transactional-doctrine.md#transactional-only-mapping) for transition and migration guidance. + ## What gets stored Each `AuditEntry` record in the audit trail contains: diff --git a/docs/index.md b/docs/index.md index 0d67d0c..2512e17 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,12 +55,13 @@ All audit entries are persisted to a dedicated `audit_entry` table, with optiona - [Configuration Reference](../README.md#configuration-reference) - [Opt-in transactional recorder wiring](../README.md#opt-in-transactional-recorder-wiring) - [Transactional Doctrine and PostgreSQL guide](transactional-doctrine.md) +- [Transactional-only mapping](transactional-doctrine.md#transactional-only-mapping) - [Security & PII Handling](../README.md#security--pii) - [GitHub Repository](https://github.com/zhortein/auditable-bundle) The strict transactional recorder requires explicit activation and application-provided entry factory, storage and PSR-20 clock services. The bundle provides no implicit storage implementation or flush; see [the README wiring section](../README.md#opt-in-transactional-recorder-wiring). -The [transactional Doctrine guide](transactional-doctrine.md) documents the application-owned mapping and executable PostgreSQL proofs for shared commit, shared rollback and fail-closed behavior. +The [transactional Doctrine guide](transactional-doctrine.md) documents the application-owned mapping and executable PostgreSQL proofs for shared commit, shared rollback and fail-closed behavior, including the explicit [transactional-only mapping](transactional-doctrine.md#transactional-only-mapping) opt-out. ## Architecture diff --git a/docs/transactional-doctrine.md b/docs/transactional-doctrine.md index e2a5629..64e9018 100644 --- a/docs/transactional-doctrine.md +++ b/docs/transactional-doctrine.md @@ -49,3 +49,39 @@ The recorder does not catch factory or storage errors. Doctrine controls flush a After a flush or transaction error, abandon the entity manager according to Doctrine's transaction practices rather than attempting to reuse a potentially inconsistent Unit of Work. Never keep a database transaction open during user interaction or across HTTP requests. The executable PostgreSQL fixtures in [`tests/Fixtures/Transactional/PostgreSql`](../tests/Fixtures/Transactional/PostgreSql) and [`TransactionalAtomicityIntegrationTest`](../tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php) demonstrate shared commit, shared rollback and fail-closed behavior with application-owned UUID v7 entities. + +## Transactional-only mapping + +During a transition, an application can keep the historical mapping while enabling the strict recorder: + +```yaml +zhortein_auditable: + legacy_mapping: + enabled: true + transactional: + enabled: true +``` + +The legacy runtime may be enabled or disabled in this coexistence configuration, and Doctrine continues to know the bundle's `AuditEntry` entity. + +Once the application no longer uses any legacy audit path, it can retain only its application-owned transactional mapping: + +```yaml +zhortein_auditable: + enabled: false + legacy_mapping: + enabled: false + transactional: + enabled: true +``` + +The opt-out is rejected unless `enabled` is also `false`. It only prevents registration of the bundle's legacy Doctrine mapping: it does not delete `audit_entry`, alter an existing schema or provide a migration. Historical data remains physically present until an application migration deliberately preserves, archives or changes it. Review any migration generated after removing the mapping before execution; the bundle does not recommend automatic deletion of audit history. + +Before disabling the mapping in an application that used asynchronous legacy auditing: + +1. Stop or disable production of legacy audits. +2. Drain or process every pending `PersistAuditEntryMessage`. +3. Verify that no legacy worker still writes `AuditEntry` records. +4. Only then disable the mapping. + +A queued legacy message still depends on the legacy message handler, persister and `AuditEntry` mapping. The bundle does not drain queues automatically. The executable SQLite and PostgreSQL opt-out tests demonstrate that the strict recorder and application-owned audit schema continue to work without registering the legacy metadata. From 1e9cc36b223446ee29bd5952b209aedb49cfe8d3 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 10:54:03 +0200 Subject: [PATCH 17/28] test: freeze the 2.0 public API Add a deterministic full public API baseline for the 2.0 series while preserving the immutable 1.0 contract, canonicalizing equivalent reflection types across PHP 8.3 through 8.5, and allowing future additive public types. The contract rejects removals and changes to signatures, interfaces, enums, public properties, constructors, finality, and readonly semantics. --- tests/Contract/PublicApi20ContractTest.php | 174 ++ tests/Contract/PublicApiContractTest.php | 40 +- tests/Contract/public-api-2.0.0.json | 2013 ++++++++++++++++++++ 3 files changed, 2214 insertions(+), 13 deletions(-) create mode 100644 tests/Contract/PublicApi20ContractTest.php create mode 100644 tests/Contract/public-api-2.0.0.json diff --git a/tests/Contract/PublicApi20ContractTest.php b/tests/Contract/PublicApi20ContractTest.php new file mode 100644 index 0000000..8a0e287 --- /dev/null +++ b/tests/Contract/PublicApi20ContractTest.php @@ -0,0 +1,174 @@ + $contract) { + self::assertArrayHasKey($type, $versionTwo, \sprintf('The 1.0 public type %s is missing from the 2.0 snapshot.', $type)); + self::assertSame($contract, $versionTwo[$type], \sprintf('The historical contract of %s changed in the 2.0 snapshot.', $type)); + } + } + + public function testFuturePublicTypesRemainAllowed(): void + { + $snapshot = ['Existing\\PublicType' => self::contractFixture()]; + $current = $snapshot; + $current['Future\\PublicType'] = self::contractFixture(); + + self::assertVersionTwoContractIsPreserved($snapshot, $current); + } + + /** + * @param PublicApi $snapshot + * @param PublicApi $current + */ + #[DataProvider('incompatibleVersionTwoContracts')] + public function testVersionTwoContractChangesAreRejected(array $snapshot, array $current): void + { + $this->expectException(\PHPUnit\Framework\ExpectationFailedException::class); + self::assertVersionTwoContractIsPreserved($snapshot, $current); + } + + /** @return iterable */ + public static function incompatibleVersionTwoContracts(): iterable + { + $snapshot = ['Frozen\\PublicType' => self::contractFixture()]; + + yield 'removed type' => [$snapshot, []]; + + $changed = self::contractFixture(); + $changed['final'] = false; + yield 'changed final state' => [$snapshot, ['Frozen\\PublicType' => $changed]]; + + $changed = self::contractFixture(); + $changed['readonly'] = false; + yield 'changed readonly state' => [$snapshot, ['Frozen\\PublicType' => $changed]]; + + $changed = self::contractFixture(); + $changed['enum_cases'] = ['Changed' => 'changed']; + yield 'changed enum cases' => [$snapshot, ['Frozen\\PublicType' => $changed]]; + + $changed = self::contractFixture(); + $changed['public_properties']['value']['type'] = 'int'; + /** @var PublicApi $changedApi */ + $changedApi = ['Frozen\\PublicType' => $changed]; + yield 'changed public property' => [$snapshot, $changedApi]; + + $changed = self::contractFixture(); + $changed['public_methods']['create']['return'] = 'int'; + /** @var PublicApi $changedApi */ + $changedApi = ['Frozen\\PublicType' => $changed]; + yield 'changed public method' => [$snapshot, $changedApi]; + + $changed = self::contractFixture(); + $changed['public_methods']['__construct']['parameters'][0]['name'] = 'renamed'; + /** @var PublicApi $changedApi */ + $changedApi = ['Frozen\\PublicType' => $changed]; + yield 'changed constructor' => [$snapshot, $changedApi]; + + $interface = self::contractFixture(); + $interface['kind'] = 'interface'; + $current = $interface; + $current['public_methods']['additionalMethod'] = [ + 'static' => false, + 'return' => 'void', + 'parameters' => [], + ]; + yield 'added interface method' => [ + ['Frozen\\PublicType' => $interface], + ['Frozen\\PublicType' => $current], + ]; + } + + /** + * @param PublicApi $snapshot + * @param PublicApi $current + */ + private static function assertVersionTwoContractIsPreserved(array $snapshot, array $current): void + { + PublicApiContractTest::assertHistoricalContractIsPreserved($snapshot, $current); + + foreach ($snapshot as $type => $contract) { + if ('interface' === $contract['kind']) { + self::assertSame( + $contract['public_methods'], + $current[$type]['public_methods'], + \sprintf('The 2.0 interface %s gained a method.', $type), + ); + } + } + } + + /** @return PublicApi */ + private static function snapshot(string $file): array + { + self::assertFileExists($file); + $snapshot = json_decode((string) file_get_contents($file), true, flags: \JSON_THROW_ON_ERROR); + self::assertIsArray($snapshot); + /** @var PublicApi $typedSnapshot */ + $typedSnapshot = $snapshot; + + return $typedSnapshot; + } + + /** @return PublicContract */ + private static function contractFixture(): array + { + return [ + 'kind' => 'class', + 'final' => true, + 'readonly' => true, + 'attribute_targets' => null, + 'enum_cases' => ['Stable' => 'stable'], + 'public_properties' => [ + 'value' => [ + 'type' => 'string', + 'readonly' => true, + 'static' => false, + ], + ], + 'public_methods' => [ + '__construct' => [ + 'static' => false, + 'return' => null, + 'parameters' => [[ + 'name' => 'value', + 'type' => 'string', + 'by_reference' => false, + 'variadic' => false, + 'has_default' => false, + 'default' => null, + ]], + ], + 'create' => [ + 'static' => true, + 'return' => 'self', + 'parameters' => [], + ], + ], + ]; + } +} diff --git a/tests/Contract/PublicApiContractTest.php b/tests/Contract/PublicApiContractTest.php index 1113208..e190b7c 100644 --- a/tests/Contract/PublicApiContractTest.php +++ b/tests/Contract/PublicApiContractTest.php @@ -7,6 +7,21 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +/** + * @phpstan-type PublicParameter array{name: string, type: ?string, by_reference: bool, variadic: bool, has_default: bool, default: mixed} + * @phpstan-type PublicMethod array{static: bool, return: ?string, parameters: list} + * @phpstan-type PublicProperty array{type: ?string, readonly: bool, static: bool} + * @phpstan-type PublicContract array{ + * kind: 'class'|'interface'|'enum', + * final: bool, + * readonly: bool, + * attribute_targets: ?int, + * enum_cases: array, + * public_properties: array, + * public_methods: array + * } + * @phpstan-type PublicApi array + */ final class PublicApiContractTest extends TestCase { private const SNAPSHOT = __DIR__.'/public-api-1.0.0.json'; @@ -17,8 +32,10 @@ public function testPublicApiMatchesVersionOneSnapshot(): void self::assertFileExists(self::SNAPSHOT); $expected = json_decode((string) file_get_contents(self::SNAPSHOT), true, flags: \JSON_THROW_ON_ERROR); self::assertIsArray($expected); + /** @var PublicApi $typedExpected */ + $typedExpected = $expected; - self::assertHistoricalContractIsPreserved($expected, $actual); + self::assertHistoricalContractIsPreserved($typedExpected, $actual); } public function testAdditionalPublicTypesAndMembersAreAllowed(): void @@ -53,7 +70,7 @@ public function testAdditionalPublicTypesAndMembersAreAllowed(): void ); } - /** @param array $current */ + /** @param PublicApi $current */ #[DataProvider('brokenHistoricalContracts')] public function testMissingOrModifiedHistoricalContractIsRejected(array $current): void { @@ -63,7 +80,7 @@ public function testMissingOrModifiedHistoricalContractIsRejected(array $current self::assertHistoricalContractIsPreserved($historical, $current); } - /** @return iterable}> */ + /** @return iterable */ public static function brokenHistoricalContracts(): iterable { yield 'missing historical type' => [[]]; @@ -73,8 +90,8 @@ public static function brokenHistoricalContracts(): iterable yield 'modified historical type' => [['Legacy\\PublicType' => $modified]]; } - /** @return array */ - private static function publicApi(): array + /** @return PublicApi */ + public static function publicApi(): array { $classes = []; foreach (self::sourceClasses() as $class) { @@ -139,23 +156,20 @@ private static function publicApi(): array return $classes; } - /** @param array $historical - * @param array $current + /** + * @param PublicApi $historical + * @param PublicApi $current */ - private static function assertHistoricalContractIsPreserved(array $historical, array $current): void + public static function assertHistoricalContractIsPreserved(array $historical, array $current): void { foreach ($historical as $class => $expectedContract) { self::assertArrayHasKey($class, $current, \sprintf('Historical public type %s no longer exists.', $class)); - self::assertIsArray($expectedContract); - self::assertIsArray($current[$class]); $actualContract = $current[$class]; foreach (['kind', 'final', 'readonly', 'attribute_targets', 'enum_cases'] as $key) { self::assertSame($expectedContract[$key], $actualContract[$key], \sprintf('%s changed for %s.', $key, $class)); } foreach (['public_methods', 'public_properties'] as $membersKey) { - self::assertIsArray($expectedContract[$membersKey]); - self::assertIsArray($actualContract[$membersKey]); foreach ($expectedContract[$membersKey] as $member => $expectedMember) { self::assertArrayHasKey($member, $actualContract[$membersKey], \sprintf('%s::%s no longer has its historical public visibility.', $class, $member)); self::assertSame($expectedMember, $actualContract[$membersKey][$member], \sprintf('Public contract of %s::%s changed.', $class, $member)); @@ -164,7 +178,7 @@ private static function assertHistoricalContractIsPreserved(array $historical, a } } - /** @return array */ + /** @return PublicContract */ private static function contractFixture(): array { return [ diff --git a/tests/Contract/public-api-2.0.0.json b/tests/Contract/public-api-2.0.0.json new file mode 100644 index 0000000..d9e55e0 --- /dev/null +++ b/tests/Contract/public-api-2.0.0.json @@ -0,0 +1,2013 @@ +{ + "Zhortein\\AuditableBundle\\Attribute\\AuditField": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": { + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Attribute\\AuditIgnore": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 8, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Attribute\\Auditable": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": 1, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": false, + "static": false + }, + "label": { + "type": "?string", + "readonly": false, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\Configuration": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getConfigTreeBuilder": { + "static": false, + "return": "Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\DependencyInjection\\ZhorteinAuditableExtension": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "load": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "configs", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Doctrine\\AuditableDoctrineListener": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "historizer", + "type": "Zhortein\\AuditableBundle\\Service\\Historizer", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "metadataProvider", + "type": "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "changeDetector", + "type": "Zhortein\\AuditableBundle\\Service\\ChangeDetector", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackInsert", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackUpdate", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "trackDelete", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + }, + { + "name": "globalIgnoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + }, + "onFlush": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\OnFlushEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "postPersist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PostPersistEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "preRemove": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "args", + "type": "Doctrine\\ORM\\Event\\PreRemoveEventArgs", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Entity\\AuditEntry": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [] + }, + "getAction": { + "static": false, + "return": "string", + "parameters": [] + }, + "getActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getContext": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getData": { + "static": false, + "return": "array", + "parameters": [] + }, + "getDescription": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityClass": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getEntityId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getId": { + "static": false, + "return": "?int", + "parameters": [] + }, + "getImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "getLevel": { + "static": false, + "return": "string", + "parameters": [] + }, + "getOccurredAt": { + "static": false, + "return": "DateTimeImmutable", + "parameters": [] + }, + "getTitle": { + "static": false, + "return": "string", + "parameters": [] + }, + "isAuto": { + "static": false, + "return": "bool", + "parameters": [] + }, + "setAction": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setActorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setContext": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setData": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setDescription": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityClass": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setEntityId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setImpersonatorId": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setIsAuto": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setLevel": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setOccurredAt": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "occurredAt", + "type": "DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "setTitle": { + "static": false, + "return": "self", + "parameters": [ + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditAction": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "CREATE": "create", + "UPDATE": "update", + "DELETE": "delete", + "LOG": "log" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Enum\\AuditLevel": { + "kind": "enum", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": { + "DEBUG": "debug", + "INFO": "info", + "WARNING": "warning", + "ERROR": "error", + "CRITICAL": "critical" + }, + "public_properties": { + "name": { + "type": "string", + "readonly": true, + "static": false + }, + "value": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "cases": { + "static": true, + "return": "array", + "parameters": [] + }, + "from": { + "static": true, + "return": "static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "tryFrom": { + "static": true, + "return": "?static", + "parameters": [ + { + "name": "value", + "type": "string|int", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\MessageHandler\\PersistAuditEntryMessageHandler": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "__invoke": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityClass": { + "type": "?string", + "readonly": true, + "static": false + }, + "entityId": { + "type": "?string", + "readonly": true, + "static": false + }, + "impersonatorId": { + "type": "?string", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "string", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "occurredAt", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityClass", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entityId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "impersonatorId", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "fieldLabels": { + "type": "array", + "readonly": true, + "static": false + }, + "ignoredFields": { + "type": "array", + "readonly": true, + "static": false + }, + "label": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "label", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "fieldLabels", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "ignoredFields", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Metadata\\AuditableMetadataProvider": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "getFor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Metadata\\AuditableMetadata", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Repository\\AuditEntryRepository": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "registry", + "type": "Doctrine\\Persistence\\ManagerRegistry", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AsyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "bus", + "type": "Symfony\\Component\\Messenger\\MessageBusInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "entityManager", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "persist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\ChangeDetector": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "maxStringLength", + "type": "int", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": 180 + } + ] + }, + "getChanges": { + "static": false, + "return": "array", + "parameters": [ + { + "name": "em", + "type": "Doctrine\\ORM\\EntityManagerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\Historizer": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "writer", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryWriterInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "actorResolver", + "type": "Zhortein\\AuditableBundle\\Service\\ActorResolverInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "enabled", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": true + } + ] + }, + "historize": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "action", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditAction|string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "entity", + "type": "?object", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "level", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditLevel|string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": "info" + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SecurityActorResolver": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "security", + "type": "Symfony\\Bundle\\SecurityBundle\\Security", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "resolveActorId": { + "static": false, + "return": "?string", + "parameters": [] + }, + "resolveImpersonatorId": { + "static": false, + "return": "?string", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Service\\SyncAuditEntryWriter": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "persister", + "type": "Zhortein\\AuditableBundle\\Service\\AuditEntryPersister", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "write": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "message", + "type": "Zhortein\\AuditableBundle\\Message\\PersistAuditEntryMessage", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditActorResolverInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "resolveActor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditEntryFactoryInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "create": { + "static": false, + "return": "object", + "parameters": [ + { + "name": "record", + "type": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditRecord", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditRecorderInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "record": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "event", + "type": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditEvent", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditStorageInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "persist": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "entry", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Contract\\IdentifierExtractorInterface": { + "kind": "interface", + "final": false, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "extract": { + "static": false, + "return": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Exception\\ActorResolutionException": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Transactional\\Exception\\IdentifierExtractionException": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": [] + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "identifier": { + "type": "?string", + "readonly": true, + "static": false + }, + "impersonatorIdentifier": { + "type": "?string", + "readonly": true, + "static": false + }, + "metadata": { + "type": "array", + "readonly": true, + "static": false + }, + "type": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "type", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "identifier", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "impersonatorIdentifier", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "metadata", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditEvent": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actor": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "entity": { + "type": "?object", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "?DateTimeImmutable", + "readonly": true, + "static": false + }, + "subject": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "action", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditAction|string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "level", + "type": "Zhortein\\AuditableBundle\\Enum\\AuditLevel|string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": "info" + }, + { + "name": "entity", + "type": "?object", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "subject", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actor", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + }, + { + "name": "occurredAt", + "type": "?DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditRecord": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "action": { + "type": "string", + "readonly": true, + "static": false + }, + "actor": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "readonly": true, + "static": false + }, + "context": { + "type": "?string", + "readonly": true, + "static": false + }, + "data": { + "type": "array", + "readonly": true, + "static": false + }, + "description": { + "type": "?string", + "readonly": true, + "static": false + }, + "isAuto": { + "type": "bool", + "readonly": true, + "static": false + }, + "level": { + "type": "string", + "readonly": true, + "static": false + }, + "occurredAt": { + "type": "DateTimeImmutable", + "readonly": true, + "static": false + }, + "subject": { + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "readonly": true, + "static": false + }, + "title": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "occurredAt", + "type": "DateTimeImmutable", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "action", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "level", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "title", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "description", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "context", + "type": "?string", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "subject", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "actor", + "type": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": null + }, + { + "name": "isAuto", + "type": "bool", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": false + }, + { + "name": "data", + "type": "array", + "by_reference": false, + "variadic": false, + "has_default": true, + "default": [] + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": { + "identifier": { + "type": "string", + "readonly": true, + "static": false + }, + "type": { + "type": "string", + "readonly": true, + "static": false + } + }, + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "type", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "identifier", + "type": "string", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Service\\DoctrineIdentifierExtractor": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "registry", + "type": "Doctrine\\Persistence\\ManagerRegistry", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "extract": { + "static": false, + "return": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditSubject", + "parameters": [ + { + "name": "entity", + "type": "object", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Service\\StrictAuditRecorder": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "identifierExtractor", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\IdentifierExtractorInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "actorResolver", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditActorResolverInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "clock", + "type": "Psr\\Clock\\ClockInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "entryFactory", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditEntryFactoryInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + }, + { + "name": "storage", + "type": "Zhortein\\AuditableBundle\\Transactional\\Contract\\AuditStorageInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "record": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "event", + "type": "Zhortein\\AuditableBundle\\Transactional\\Model\\AuditEvent", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + }, + "Zhortein\\AuditableBundle\\Transactional\\Service\\SymfonySecurityActorResolver": { + "kind": "class", + "final": true, + "readonly": true, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "__construct": { + "static": false, + "return": null, + "parameters": [ + { + "name": "tokenStorage", + "type": "Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + }, + "resolveActor": { + "static": false, + "return": "?Zhortein\\AuditableBundle\\Transactional\\Model\\AuditActor", + "parameters": [] + } + } + }, + "Zhortein\\AuditableBundle\\ZhorteinAuditableBundle": { + "kind": "class", + "final": true, + "readonly": false, + "attribute_targets": null, + "enum_cases": [], + "public_properties": [], + "public_methods": { + "build": { + "static": false, + "return": "void", + "parameters": [ + { + "name": "container", + "type": "Symfony\\Component\\DependencyInjection\\ContainerBuilder", + "by_reference": false, + "variadic": false, + "has_default": false, + "default": null + } + ] + } + } + } +} From 7022fac71743c381e447233fc903021a5bb92050 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 10:54:29 +0200 Subject: [PATCH 18/28] chore: upgrade PHPStan to 2 at max level Upgrade static analysis to PHPStan 2 across the supported PHP 8.3 through 8.5 platform and tighten configuration, Doctrine, Reflection, generic and fixture types without changing runtime behavior or public native signatures. No baseline, global ignoreErrors, bleeding edge ruleset, functional cast, configuration change, mapping change, or exception handling was introduced. --- composer.json | 2 +- phpstan.neon.dist | 5 ++- src/DependencyInjection/Configuration.php | 7 +++- .../ZhorteinAuditableExtension.php | 9 +++++ src/Entity/AuditEntry.php | 1 + src/Service/ChangeDetector.php | 37 ++++++++++++++----- src/Service/Historizer.php | 1 + src/Service/SecurityActorResolver.php | 1 + .../StrictAuditRecorderContractTest.php | 10 +++-- tests/Fixtures/App/DoctrineTestFactory.php | 2 +- tests/Fixtures/Entity/AuditableEntity.php | 1 + tests/Fixtures/Entity/NonAuditableEntity.php | 1 + .../Entity/GeneratedIdentifier.php | 1 + .../ApplicationAuditEntryFactory.php | 10 +++-- .../PostgreSql/DoctrineAuditStorage.php | 1 + .../AuditableDoctrineListenerTest.php | 4 +- tests/Integration/ContainerTest.php | 4 +- tests/Integration/ListenerBehaviorTest.php | 1 + .../TransactionalAtomicityIntegrationTest.php | 8 +++- tests/Unit/ConfigurationTest.php | 8 ++-- tests/Unit/PersistencePipelineTest.php | 1 - 21 files changed, 86 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index 794bbe5..3693fc2 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "phpunit/phpunit": "^11.0", "symfony/phpunit-bridge": "^7.4 || ^8.0", "symfony/uid": "^7.4 || ^8.0", - "phpstan/phpstan": "^1.11", + "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.60" }, "suggest": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 59ae674..1c2e504 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,8 @@ parameters: - level: 8 + level: max + phpVersion: + min: 80300 + max: 80599 paths: - src - tests diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2fc3f16..eea3896 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -16,7 +16,12 @@ public function getConfigTreeBuilder(): TreeBuilder $root = $treeBuilder->getRootNode(); $root ->validate() - ->ifTrue(static fn (array $config): bool => $config['enabled'] && !$config['legacy_mapping']['enabled']) + ->ifTrue(static function (array $config): bool { + /** @var array{enabled: bool, legacy_mapping: array{enabled: bool}} $typedConfig */ + $typedConfig = $config; + + return $typedConfig['enabled'] && !$typedConfig['legacy_mapping']['enabled']; + }) ->thenInvalid('The legacy Doctrine mapping cannot be disabled while legacy auditing is enabled. Set "enabled" to false first.') ->end() ->children() diff --git a/src/DependencyInjection/ZhorteinAuditableExtension.php b/src/DependencyInjection/ZhorteinAuditableExtension.php index fb4dc79..4f512dd 100644 --- a/src/DependencyInjection/ZhorteinAuditableExtension.php +++ b/src/DependencyInjection/ZhorteinAuditableExtension.php @@ -19,6 +19,15 @@ final class ZhorteinAuditableExtension extends Extension public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); + /** @var array{ + * enabled: bool, + * legacy_mapping: array{enabled: bool}, + * transactional: array{enabled: bool}, + * async: array{enabled: bool, transport: string}, + * listener: array{track_insert: bool, track_update: bool, track_delete: bool}, + * fields: array{max_string_length: int, global_ignored: list} + * } $config + */ $config = $this->processConfiguration($configuration, $configs); $container->setParameter('zhortein_auditable.enabled', (bool) $config['enabled']); diff --git a/src/Entity/AuditEntry.php b/src/Entity/AuditEntry.php index 425b080..95d4957 100644 --- a/src/Entity/AuditEntry.php +++ b/src/Entity/AuditEntry.php @@ -17,6 +17,7 @@ final class AuditEntry #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: Types::INTEGER)] + /** @phpstan-ignore property.unusedType (assigned by Doctrine) */ private ?int $id = null; #[ORM\Column(name: 'occurred_at', type: Types::DATETIME_IMMUTABLE)] diff --git a/src/Service/ChangeDetector.php b/src/Service/ChangeDetector.php index 0b479db..cac45d4 100644 --- a/src/Service/ChangeDetector.php +++ b/src/Service/ChangeDetector.php @@ -25,6 +25,7 @@ public function getChanges(EntityManagerInterface $em, object $entity): array $changes = $uow->getEntityChangeSet($entity); $formatted = []; + /** @var array $changes */ foreach ($changes as $field => [$old, $new]) { $formatted[$field] = [ 'old' => $this->stringify($old), @@ -58,17 +59,21 @@ private function stringify(mixed $value): string $sample = []; foreach ($value as $item) { + /** @var object $item */ if (method_exists($item, '__toString')) { $sample[] = (string) $item; } elseif (method_exists($item, 'getName')) { - /* @phpstan-ignore-next-line method.undefined */ - $sample[] = (string) $item->getName(); + /** @var scalar|\Stringable|null $name */ + $name = $item->getName(); + $sample[] = (string) $name; } elseif (method_exists($item, 'getTitle')) { - /* @phpstan-ignore-next-line method.undefined */ - $sample[] = (string) $item->getTitle(); + /** @var scalar|\Stringable|null $title */ + $title = $item->getTitle(); + $sample[] = (string) $title; } elseif (method_exists($item, 'getId')) { - /* @phpstan-ignore-next-line method.undefined */ - $sample[] = \sprintf('%s#%s', (new \ReflectionClass($item))->getShortName(), (string) $item->getId()); + /** @var scalar|\Stringable|null $id */ + $id = $item->getId(); + $sample[] = \sprintf('%s#%s', (new \ReflectionClass($item))->getShortName(), (string) $id); } else { $sample[] = (new \ReflectionClass($item))->getShortName(); } @@ -91,13 +96,22 @@ private function stringify(mixed $value): string return $this->truncate((string) $value); } if (method_exists($value, 'getName')) { - return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $value->getName())); + /** @var scalar|\Stringable|null $name */ + $name = $value->getName(); + + return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $name)); } if (method_exists($value, 'getTitle')) { - return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $value->getTitle())); + /** @var scalar|\Stringable|null $title */ + $title = $value->getTitle(); + + return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $title)); } if (method_exists($value, 'getId')) { - return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $value->getId())); + /** @var scalar|\Stringable|null $id */ + $id = $value->getId(); + + return $this->truncate(\sprintf('[%s#%s]', (new \ReflectionClass($value))->getShortName(), (string) $id)); } return $this->truncate(\sprintf('[object %s]', (new \ReflectionClass($value))->getShortName())); @@ -111,7 +125,10 @@ private function stringify(mixed $value): string } } - return $this->truncate((string) $value); + /** @var scalar|\Stringable|null $stringableValue */ + $stringableValue = $value; + + return $this->truncate((string) $stringableValue); } private function truncate(string $value): string diff --git a/src/Service/Historizer.php b/src/Service/Historizer.php index fb77203..5748c52 100644 --- a/src/Service/Historizer.php +++ b/src/Service/Historizer.php @@ -73,6 +73,7 @@ public function historize( private function extractEntityId(object $entity): ?string { if (method_exists($entity, 'getId')) { + /** @var scalar|\Stringable|null $id */ $id = $entity->getId(); return null !== $id ? (string) $id : null; diff --git a/src/Service/SecurityActorResolver.php b/src/Service/SecurityActorResolver.php index c121c1e..9ec9b00 100644 --- a/src/Service/SecurityActorResolver.php +++ b/src/Service/SecurityActorResolver.php @@ -37,6 +37,7 @@ private function extractId(mixed $user): ?string } if (method_exists($user, 'getId')) { + /** @var scalar|\Stringable|null $id */ $id = $user->getId(); return null !== $id ? (string) $id : null; diff --git a/tests/Contract/StrictAuditRecorderContractTest.php b/tests/Contract/StrictAuditRecorderContractTest.php index 59bbfd7..0eb08e6 100644 --- a/tests/Contract/StrictAuditRecorderContractTest.php +++ b/tests/Contract/StrictAuditRecorderContractTest.php @@ -61,8 +61,12 @@ public function testPsrClockIsADirectRuntimeDependency(): void { $composer = json_decode((string) file_get_contents(\dirname(__DIR__, 2).'/composer.json'), true, flags: \JSON_THROW_ON_ERROR); self::assertIsArray($composer); - self::assertSame('^1.0', $composer['require']['psr/clock'] ?? null); - self::assertArrayNotHasKey('symfony/clock', $composer['require']); - self::assertArrayNotHasKey('symfony/clock', $composer['require-dev']); + $require = $composer['require'] ?? null; + $requireDev = $composer['require-dev'] ?? null; + self::assertIsArray($require); + self::assertIsArray($requireDev); + self::assertSame('^1.0', $require['psr/clock'] ?? null); + self::assertArrayNotHasKey('symfony/clock', $require); + self::assertArrayNotHasKey('symfony/clock', $requireDev); } } diff --git a/tests/Fixtures/App/DoctrineTestFactory.php b/tests/Fixtures/App/DoctrineTestFactory.php index 61bafc2..ae614c0 100644 --- a/tests/Fixtures/App/DoctrineTestFactory.php +++ b/tests/Fixtures/App/DoctrineTestFactory.php @@ -34,7 +34,7 @@ private static function createForPaths(array $paths): EntityManagerInterface $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); $configuration = ORMSetup::createAttributeMetadataConfiguration($paths, true); - if (\PHP_VERSION_ID >= 80400 && method_exists($configuration, 'enableNativeLazyObjects')) { + if (\PHP_VERSION_ID >= 80400) { $configuration->enableNativeLazyObjects(true); } diff --git a/tests/Fixtures/Entity/AuditableEntity.php b/tests/Fixtures/Entity/AuditableEntity.php index add8aa4..13e77f8 100644 --- a/tests/Fixtures/Entity/AuditableEntity.php +++ b/tests/Fixtures/Entity/AuditableEntity.php @@ -17,6 +17,7 @@ final class AuditableEntity #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + /** @phpstan-ignore property.unusedType (assigned by Doctrine) */ private ?int $id = null; #[ORM\Column(length: 255)] diff --git a/tests/Fixtures/Entity/NonAuditableEntity.php b/tests/Fixtures/Entity/NonAuditableEntity.php index 44f536a..f121595 100644 --- a/tests/Fixtures/Entity/NonAuditableEntity.php +++ b/tests/Fixtures/Entity/NonAuditableEntity.php @@ -13,6 +13,7 @@ final class NonAuditableEntity #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + /** @phpstan-ignore property.unusedType (assigned by Doctrine) */ private ?int $id = null; #[ORM\Column(length: 255)] diff --git a/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php b/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php index 793ef8b..b7eca99 100644 --- a/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php +++ b/tests/Fixtures/Transactional/Entity/GeneratedIdentifier.php @@ -12,6 +12,7 @@ final class GeneratedIdentifier #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + /** @phpstan-ignore property.unusedType (assigned by Doctrine) */ private ?int $id = null; public function hasGeneratedIdentifier(): bool diff --git a/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php b/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php index 6a2437e..dbd9ee4 100644 --- a/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php +++ b/tests/Fixtures/Transactional/PostgreSql/ApplicationAuditEntryFactory.php @@ -14,6 +14,8 @@ { public function create(AuditRecord $record): ApplicationAuditEntry { + $actor = $record->actor; + return new ApplicationAuditEntry( id: Uuid::v7()->toRfc4122(), occurredAt: $record->occurredAt, @@ -24,10 +26,10 @@ public function create(AuditRecord $record): ApplicationAuditEntry context: $record->context, subjectType: $record->subject?->type, subjectIdentifier: $record->subject?->identifier, - actorType: $record->actor?->type, - actorIdentifier: $record->actor?->identifier, - impersonatorIdentifier: $record->actor?->impersonatorIdentifier, - actorMetadata: $record->actor?->metadata ?? [], + actorType: $actor?->type, + actorIdentifier: $actor?->identifier, + impersonatorIdentifier: $actor?->impersonatorIdentifier, + actorMetadata: null === $actor ? [] : $actor->metadata, isAuto: $record->isAuto, data: $record->data, ); diff --git a/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php b/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php index 8c2690a..86b8689 100644 --- a/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php +++ b/tests/Fixtures/Transactional/PostgreSql/DoctrineAuditStorage.php @@ -17,6 +17,7 @@ public function __construct(private EntityManagerInterface $entityManager) public function persist(object $entry): void { + /* @phpstan-ignore-next-line instanceof.alwaysTrue (runtime generic validation) */ if (!$entry instanceof ApplicationAuditEntry) { throw new \InvalidArgumentException('DoctrineAuditStorage accepts only ApplicationAuditEntry instances.'); } diff --git a/tests/Integration/AuditableDoctrineListenerTest.php b/tests/Integration/AuditableDoctrineListenerTest.php index b2a1ab3..bc036a8 100644 --- a/tests/Integration/AuditableDoctrineListenerTest.php +++ b/tests/Integration/AuditableDoctrineListenerTest.php @@ -61,7 +61,9 @@ public function testNonAuditableResultIsCached(): void self::assertNull($provider->getFor($entity)); self::assertNull($provider->getFor($entity)); $cache = new \ReflectionProperty($provider, 'cache'); - self::assertArrayHasKey(NonAuditableTestEntity::class, $cache->getValue($provider)); + $cacheValue = $cache->getValue($provider); + self::assertIsArray($cacheValue); + self::assertArrayHasKey(NonAuditableTestEntity::class, $cacheValue); } } diff --git a/tests/Integration/ContainerTest.php b/tests/Integration/ContainerTest.php index 6772bc7..2722213 100644 --- a/tests/Integration/ContainerTest.php +++ b/tests/Integration/ContainerTest.php @@ -51,7 +51,9 @@ public function testLegacyDefinitionsAliasesAndParameters(array $config, string self::assertFalse($container->getDefinition(AuditableDoctrineListener::class)->isPublic()); self::assertTrue($container->getParameter('zhortein_auditable.enabled')); - self::assertSame($config['async']['enabled'] ?? true, $container->getParameter('zhortein_auditable.async.enabled')); + $async = $config['async'] ?? []; + self::assertIsArray($async); + self::assertSame($async['enabled'] ?? true, $container->getParameter('zhortein_auditable.async.enabled')); self::assertSame('async', $container->getParameter('zhortein_auditable.async.transport')); self::assertTrue($container->getParameter('zhortein_auditable.listener.track_insert')); self::assertTrue($container->getParameter('zhortein_auditable.listener.track_update')); diff --git a/tests/Integration/ListenerBehaviorTest.php b/tests/Integration/ListenerBehaviorTest.php index 6b257a3..10726d3 100644 --- a/tests/Integration/ListenerBehaviorTest.php +++ b/tests/Integration/ListenerBehaviorTest.php @@ -160,6 +160,7 @@ private function registerListener( bool $trackDelete = true, ): void { $resolver = new class implements ActorResolverInterface { + /** @phpstan-ignore return.unusedType (interface contract is nullable) */ public function resolveActorId(): ?string { return 'fixture-actor'; diff --git a/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php index 51aa782..b774f2b 100644 --- a/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php +++ b/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php @@ -253,8 +253,12 @@ private function auditEvent(BusinessOperation $operation): AuditEvent private function assertCounts(Connection $connection, int $business, int $audit): void { - self::assertSame($business, (int) $connection->fetchOne('SELECT COUNT(*) FROM '.self::BUSINESS_TABLE)); - self::assertSame($audit, (int) $connection->fetchOne('SELECT COUNT(*) FROM '.self::AUDIT_TABLE)); + /** @var int|numeric-string $businessCount */ + $businessCount = $connection->fetchOne('SELECT COUNT(*) FROM '.self::BUSINESS_TABLE); + /** @var int|numeric-string $auditCount */ + $auditCount = $connection->fetchOne('SELECT COUNT(*) FROM '.self::AUDIT_TABLE); + self::assertSame($business, (int) $businessCount); + self::assertSame($audit, (int) $auditCount); } /** @return list */ diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 545d2b9..9b64f5a 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -42,7 +42,6 @@ public function testTransactionalModeCanBeExplicitlyEnabled(): void $config = $this->process(['transactional' => ['enabled' => true]]); self::assertSame(['enabled' => true], $config['transactional']); - self::assertTrue($config['transactional']['enabled']); } public function testTransactionalModeCanBeExplicitlyDisabled(): void @@ -50,7 +49,6 @@ public function testTransactionalModeCanBeExplicitlyDisabled(): void $config = $this->process(['transactional' => ['enabled' => false]]); self::assertSame(['enabled' => false], $config['transactional']); - self::assertFalse($config['transactional']['enabled']); } public function testTransactionalModeRejectsNonBooleanValues(): void @@ -99,6 +97,10 @@ public function testLegacyMappingRejectsNonBooleanValues(): void */ private function process(array $config): array { - return (new Processor())->processConfiguration(new Configuration(), [$config]); + $processed = (new Processor())->processConfiguration(new Configuration(), [$config]); + /** @var array $typedProcessed */ + $typedProcessed = $processed; + + return $typedProcessed; } } diff --git a/tests/Unit/PersistencePipelineTest.php b/tests/Unit/PersistencePipelineTest.php index af2c693..c2c1d5a 100644 --- a/tests/Unit/PersistencePipelineTest.php +++ b/tests/Unit/PersistencePipelineTest.php @@ -50,7 +50,6 @@ public function testHandlerIsInvocableAndDelegatesOnce(): void new AuditEntryPersister($this->entityManagerExpectingPersistThenFlush($message)), ); - self::assertIsCallable($handler); $handler($message); } From e98feac8de5c9a8bc18caaa5121f2feb1011695c Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 10:55:00 +0200 Subject: [PATCH 19/28] ci: verify distributable package contents Require PHPStan 2 in the static-analysis gates and validate the Composer archive as a self-contained runtime package with bundled versioned documentation, relative-link integrity, development-file exclusions, no-dev installation, platform checks, and runtime autoload verification. --- .gitattributes | 11 +- .github/ci/assert-versions.php | 20 +++ .github/ci/check-package-archive.php | 200 +++++++++++++++++++++++++++ .github/workflows/ci.yml | 65 ++++++++- docs/index.md | 2 +- docs/transactional-doctrine.md | 2 +- 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 .github/ci/check-package-archive.php diff --git a/.gitattributes b/.gitattributes index 449e70a..0a044ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,17 @@ * text=auto eol=lf +/.editorconfig export-ignore +/.env export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore /.github export-ignore -/docs export-ignore +/docker export-ignore /tests export-ignore +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore +/Makefile export-ignore +/SECURITY.md export-ignore /phpunit.xml.dist export-ignore +/phpunit.postgresql.xml.dist export-ignore /phpstan.neon.dist export-ignore /.php-cs-fixer.dist.php export-ignore diff --git a/.github/ci/assert-versions.php b/.github/ci/assert-versions.php index fd5a738..4ee805e 100644 --- a/.github/ci/assert-versions.php +++ b/.github/ci/assert-versions.php @@ -39,6 +39,26 @@ printf("%s: %s\n", $package, InstalledVersions::getPrettyVersion($package) ?? 'unknown'); } +$expectedPhpStanMajor = getenv('EXPECT_PHPSTAN_MAJOR'); +if (false !== $expectedPhpStanMajor && '' !== $expectedPhpStanMajor) { + if (!ctype_digit($expectedPhpStanMajor)) { + fwrite(STDERR, "EXPECT_PHPSTAN_MAJOR must be a positive integer.\n"); + + exit(2); + } + + $assertedPhpStanVersion = InstalledVersions::getVersion('phpstan/phpstan'); + if (null === $assertedPhpStanVersion || !str_starts_with(ltrim($assertedPhpStanVersion, 'v'), $expectedPhpStanMajor.'.')) { + fwrite(STDERR, sprintf( + "Expected phpstan/phpstan %s.x, resolved %s.\n", + $expectedPhpStanMajor, + InstalledVersions::getPrettyVersion('phpstan/phpstan') ?? 'unknown', + )); + + exit(1); + } +} + if (!InstalledVersions::isInstalled('symfony/polyfill-mbstring')) { fwrite(STDERR, "symfony/polyfill-mbstring is not installed.\n"); diff --git a/.github/ci/check-package-archive.php b/.github/ci/check-package-archive.php new file mode 100644 index 0000000..90ed9c4 --- /dev/null +++ b/.github/ci/check-package-archive.php @@ -0,0 +1,200 @@ +\n"); + + exit(2); +} + +$archivePath = $argv[1]; +if (!is_file($archivePath)) { + fwrite(\STDERR, sprintf("Archive not found: %s\n", $archivePath)); + + exit(1); +} + +$archive = new ZipArchive(); +if (true !== $archive->open($archivePath)) { + fwrite(\STDERR, sprintf("Unable to open ZIP archive: %s\n", $archivePath)); + + exit(1); +} + +try { + $entries = []; + for ($index = 0; $index < $archive->numFiles; ++$index) { + $name = $archive->getNameIndex($index); + if (false === $name) { + fwrite(\STDERR, sprintf("Unable to read ZIP entry at index %d.\n", $index)); + + exit(1); + } + + $entries[] = ltrim(str_replace('\\', '/', $name), '/'); + } + + $composerEntries = array_values(array_filter( + $entries, + static fn (string $entry): bool => 'composer.json' === $entry || str_ends_with($entry, '/composer.json'), + )); + if (1 !== count($composerEntries)) { + fwrite(\STDERR, sprintf("Expected exactly one composer.json, found %d.\n", count($composerEntries))); + + exit(1); + } + + $composerEntry = $composerEntries[0]; + $rootPrefix = substr($composerEntry, 0, -strlen('composer.json')); + $relativeEntries = []; + foreach ($entries as $entry) { + if (!str_starts_with($entry, $rootPrefix)) { + fwrite(\STDERR, sprintf("ZIP entry is outside the package root: %s\n", $entry)); + + exit(1); + } + + $relativeEntries[] = substr($entry, strlen($rootPrefix)); + } + + $contains = static fn (string $path): bool => in_array($path, $relativeEntries, true); + $containsDirectory = static function (string $directory) use ($relativeEntries): bool { + foreach ($relativeEntries as $entry) { + if (str_starts_with($entry, $directory.'/')) { + return true; + } + } + + return false; + }; + + $requiredFiles = [ + 'composer.json', + 'README.md', + 'CHANGELOG.md', + 'LICENSE', + 'docs/index.md', + 'docs/transactional-doctrine.md', + ]; + foreach ($requiredFiles as $requiredFile) { + if (!$contains($requiredFile)) { + fwrite(\STDERR, sprintf("Required runtime file is missing: %s\n", $requiredFile)); + + exit(1); + } + } + + foreach (['src', 'config'] as $requiredDirectory) { + if (!$containsDirectory($requiredDirectory)) { + fwrite(\STDERR, sprintf("Required runtime directory is missing or empty: %s/\n", $requiredDirectory)); + + exit(1); + } + } + + $excludedFiles = [ + '.editorconfig', + '.env', + '.gitattributes', + '.gitignore', + 'CODE_OF_CONDUCT.md', + 'CONTRIBUTING.md', + 'Makefile', + 'SECURITY.md', + 'phpunit.xml.dist', + 'phpunit.postgresql.xml.dist', + 'phpstan.neon.dist', + '.php-cs-fixer.dist.php', + 'composer.lock', + ]; + foreach ($excludedFiles as $excludedFile) { + if ($contains($excludedFile)) { + fwrite(\STDERR, sprintf("Development file must not be distributed: %s\n", $excludedFile)); + + exit(1); + } + } + + foreach (['.github', 'tests', 'docker', 'vendor'] as $excludedDirectory) { + if ($containsDirectory($excludedDirectory)) { + fwrite(\STDERR, sprintf("Development directory must not be distributed: %s/\n", $excludedDirectory)); + + exit(1); + } + } + + $normalizeRelativePath = static function (string $source, string $target): ?string { + $sourceDirectory = str_contains($source, '/') ? dirname($source) : ''; + $candidate = '' === $sourceDirectory ? $target : $sourceDirectory.'/'.$target; + $segments = []; + + foreach (explode('/', str_replace('\\', '/', $candidate)) as $segment) { + if ('' === $segment || '.' === $segment) { + continue; + } + if ('..' === $segment) { + if ([] === $segments) { + return null; + } + array_pop($segments); + + continue; + } + $segments[] = $segment; + } + + return implode('/', $segments); + }; + + $markdownFiles = ['README.md', 'docs/index.md', 'docs/transactional-doctrine.md']; + $validatedLinks = 0; + foreach ($markdownFiles as $markdownFile) { + $contents = $archive->getFromName($rootPrefix.$markdownFile); + if (false === $contents) { + fwrite(\STDERR, sprintf("Unable to read documentation from archive: %s\n", $markdownFile)); + + exit(1); + } + + preg_match_all('/(?')) { + $target = substr($target, 1, -1); + } + if ( + str_starts_with($target, 'http://') + || str_starts_with($target, 'https://') + || str_starts_with($target, 'mailto:') + || str_starts_with($target, '#') + ) { + continue; + } + + $target = preg_split('/[?#]/', $target, 2)[0]; + $resolvedTarget = $normalizeRelativePath($markdownFile, $target); + if (null === $resolvedTarget) { + fwrite(\STDERR, sprintf("Markdown link escapes package root: %s -> %s\n", $markdownFile, $rawTarget)); + + exit(1); + } + if (!$contains($resolvedTarget)) { + fwrite(\STDERR, sprintf("Broken relative Markdown link: %s -> %s\n", $markdownFile, $rawTarget)); + + exit(1); + } + + ++$validatedLinks; + } + } + + printf( + "Package archive OK: %d entries, root prefix %s, runtime files and documentation present, %d relative Markdown links valid, development files excluded.\n", + count($relativeEntries), + '' === $rootPrefix ? '' : $rootPrefix, + $validatedLinks, + ); +} finally { + $archive->close(); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4435d8c..1dcaee7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,12 +130,18 @@ jobs: include: - name: Symfony 7.4 / ORM 3 latest symfony: "7.4.*" + symfony_resolved: "7.4.*" doctrine_bundle: "3" doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" - name: Symfony 8.x / ORM 3 latest symfony: "8.*" + symfony_resolved: "8.0.*" doctrine_bundle: "3" doctrine_orm: "3" + doctrine_dbal: "4" + doctrine_persistence: "4" steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 @@ -153,7 +159,12 @@ jobs: SYMFONY_REQUIRE: "${{ matrix.symfony }}" run: composer update --prefer-dist --no-interaction --no-progress --with "doctrine/doctrine-bundle:^${{ matrix.doctrine_bundle }}.0" --with "doctrine/orm:^${{ matrix.doctrine_orm }}.0" - run: composer validate --strict - - run: composer phpstan -- --memory-limit=512M + - name: Assert PHPStan and resolved versions + env: + EXPECT_NATIVE_MBSTRING: "true" + EXPECT_PHPSTAN_MAJOR: "2" + run: php .github/ci/assert-versions.php "8.4" "${{ matrix.symfony_resolved }}" "${{ matrix.doctrine_bundle }}" "${{ matrix.doctrine_orm }}" "${{ matrix.doctrine_dbal }}" "${{ matrix.doctrine_persistence }}" + - run: composer phpstan -- --memory-limit=1G transactional-postgresql: name: Transactional PostgreSQL / ${{ matrix.name }} @@ -225,6 +236,58 @@ jobs: - run: composer why symfony/uid - run: composer test:postgresql + package-distribution: + name: Package distribution + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, iconv, dom, xml, zip + tools: composer:v2 + coverage: none + - run: composer validate --strict + - name: Build and inspect Composer archive + run: | + mkdir -p /tmp/auditable-package + composer archive --format=zip --dir=/tmp/auditable-package --file=auditable-bundle + php .github/ci/check-package-archive.php /tmp/auditable-package/auditable-bundle.zip + - name: Install the distributed package without development dependencies + run: | + mkdir -p /tmp/auditable-package/extracted + unzip -q /tmp/auditable-package/auditable-bundle.zip -d /tmp/auditable-package/extracted + package_root=/tmp/auditable-package/extracted + if [[ ! -f "${package_root}/composer.json" ]]; then + package_root="$(find "${package_root}" -mindepth 1 -maxdepth 1 -type d -print -quit)" + fi + cd "${package_root}" + composer install --no-dev --prefer-dist --no-interaction --no-progress + composer validate --strict + composer check-platform-reqs + # The single-quoted program is evaluated by PHP. + # shellcheck disable=SC2016 + php -r ' + require "vendor/autoload.php"; + foreach ([ + Zhortein\AuditableBundle\ZhorteinAuditableBundle::class, + Zhortein\AuditableBundle\Transactional\Model\AuditEvent::class, + Zhortein\AuditableBundle\Transactional\Contract\AuditRecorderInterface::class, + Zhortein\AuditableBundle\Transactional\Service\StrictAuditRecorder::class, + Zhortein\AuditableBundle\Transactional\Service\DoctrineIdentifierExtractor::class, + Zhortein\AuditableBundle\Transactional\Service\SymfonySecurityActorResolver::class, + ] as $type) { + if (!class_exists($type) && !interface_exists($type)) { + fwrite(STDERR, "Unable to autoload ".$type.PHP_EOL); + exit(1); + } + echo "Autoloaded ".$type.PHP_EOL; + } + ' + coding-style: name: Coding style runs-on: ubuntu-latest diff --git a/docs/index.md b/docs/index.md index 2512e17..77692df 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,4 +109,4 @@ $em->flush(); - See [README.md](../README.md) for installation and configuration - Review source code documentation in `/src` for detailed API information -- Check [tests](../tests) for integration examples +- Check the [tests on GitHub](https://github.com/Zhortein/auditable-bundle/tree/main/tests) for integration examples diff --git a/docs/transactional-doctrine.md b/docs/transactional-doctrine.md index 64e9018..81941f2 100644 --- a/docs/transactional-doctrine.md +++ b/docs/transactional-doctrine.md @@ -48,7 +48,7 @@ The recorder does not catch factory or storage errors. Doctrine controls flush a After a flush or transaction error, abandon the entity manager according to Doctrine's transaction practices rather than attempting to reuse a potentially inconsistent Unit of Work. Never keep a database transaction open during user interaction or across HTTP requests. -The executable PostgreSQL fixtures in [`tests/Fixtures/Transactional/PostgreSql`](../tests/Fixtures/Transactional/PostgreSql) and [`TransactionalAtomicityIntegrationTest`](../tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php) demonstrate shared commit, shared rollback and fail-closed behavior with application-owned UUID v7 entities. +The executable PostgreSQL fixtures in [`tests/Fixtures/Transactional/PostgreSql`](https://github.com/Zhortein/auditable-bundle/tree/main/tests/Fixtures/Transactional/PostgreSql) and [`TransactionalAtomicityIntegrationTest`](https://github.com/Zhortein/auditable-bundle/blob/main/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php) demonstrate shared commit, shared rollback and fail-closed behavior with application-owned UUID v7 entities. ## Transactional-only mapping From 2be587d390cc8c89224c7c782af0ca4c5346dfb5 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 11:39:06 +0200 Subject: [PATCH 20/28] docs: add 2.0 migration and operating guides Document the 1.0-to-2.0 upgrade path, preserved legacy semantics, strict transactional Doctrine integration, security and privacy responsibilities, and the supported compatibility and deprecation policy. The guides distinguish fail-open legacy behavior from the opt-in fail-closed recorder and do not mark 2.0 as released. --- UPGRADE-2.0.md | 117 ++++++++++++++++++++++++++++++++ docs/compatibility.md | 39 +++++++++++ docs/index.md | 119 +++------------------------------ docs/legacy-mode.md | 51 ++++++++++++++ docs/security-privacy.md | 51 ++++++++++++++ docs/transactional-doctrine.md | 15 +++++ 6 files changed, 284 insertions(+), 108 deletions(-) create mode 100644 UPGRADE-2.0.md create mode 100644 docs/compatibility.md create mode 100644 docs/legacy-mode.md create mode 100644 docs/security-privacy.md diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..ab11cb0 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,117 @@ +# Upgrade from 1.0.0 to 2.0.0 + +This guide describes how to prepare an application for the future 2.x series. It does not announce a release and it does not replace application-specific migration review. + +## Breaking platform changes + +The principal breaking change is the supported platform: + +- PHP 8.3 or later; +- Symfony 7.4 or 8.x; +- Doctrine ORM 3.x; +- DoctrineBundle 2.19 or 3.x. + +Doctrine ORM 2 is no longer supported. Raising this platform baseline is why these changes belong to a new major series. + +## What remains unchanged by default + +With no new configuration, the historical behavior remains selected: + +- `enabled` remains `true`; +- `legacy_mapping.enabled` remains `true`; +- `transactional.enabled` remains `false`; +- the `audit_entry` table keeps its historical integer identifier, columns and indexes; +- `#[Auditable]`, `#[AuditField]` and `#[AuditIgnore]` keep their contracts; +- the automatic Doctrine listener remains enabled; +- legacy errors remain fail-open: `Historizer` catches and logs them; +- legacy entity identifiers are still read only through `getId()`; +- no SQL change or migration is required merely to preserve this default mode. + +## Checks before updating + +- Confirm PHP, Symfony, Doctrine ORM 3 and DoctrineBundle 2.19 or 3 compatibility. +- Review and preserve the application's `composer.lock` as appropriate for its deployment process. +- Run the complete application test suite. +- Generate and review Doctrine migration diffs; do not execute an unexpected schema change blindly. +- Check Messenger routing, consumers and pending `PersistAuditEntryMessage` messages. +- Review custom implementations of legacy interfaces. +- Search for direct access to concrete bundle services or classes. +- Review the [compatibility policy](docs/compatibility.md) and [security and privacy responsibilities](docs/security-privacy.md). + +## Simple legacy update + +An application can update its Composer constraints and keep its existing configuration. No transactional configuration is required: + +```yaml +zhortein_auditable: + enabled: true +``` + +The historical mapping, listener and selected sync or async writer continue to operate. See [Legacy mode](docs/legacy-mode.md) for its preserved semantics and limitations. + +## Progressive transactional activation + +Enable the strict recorder explicitly: + +```yaml +zhortein_auditable: + transactional: + enabled: true +``` + +The application must provide services or aliases for `AuditEntryFactoryInterface`, `AuditStorageInterface` and `Psr\Clock\ClockInterface`. The bundle supplies default aliases for `IdentifierExtractorInterface` and `AuditActorResolverInterface`; applications may replace those aliases through standard Symfony service configuration. + +The factory creates the application-owned representation. The storage attaches it to the current persistence context without flushing. See the [transactional Doctrine guide](docs/transactional-doctrine.md) for wiring and transaction boundaries. + +## Coexistence + +Legacy storage and the strict recorder can coexist during a progressive migration: + +```yaml +zhortein_auditable: + legacy_mapping: + enabled: true + transactional: + enabled: true +``` + +The historical `AuditEntry` mapping remains known to Doctrine while selected application paths adopt the strict recorder. + +## Transactional-only mode + +After every legacy path has been retired: + +```yaml +zhortein_auditable: + enabled: false + legacy_mapping: + enabled: false + transactional: + enabled: true +``` + +This opt-out does not remove the physical `audit_entry` table and the bundle supplies no migration. Review generated migrations manually and decide whether historical data must be retained, archived or migrated. + +Before disabling the mapping, stop legacy producers, drain pending `PersistAuditEntryMessage` messages and ensure that no legacy worker can write `AuditEntry` records. + +## Transaction boundary and fail-closed behavior + +The strict recorder does not flush, commit, roll back or catch factory and storage exceptions. An application storage must not flush. Doctrine `persist()` does not mean commit. + +Shared atomicity requires the business mutation and application audit entry to use the same `EntityManagerInterface` and underlying connection. The application owns `flush()`, `commit()` and `rollBack()`, and must allow audit exceptions to propagate so the transaction can be rolled back. After a flush or transaction failure, abandon the affected entity manager according to Doctrine practices. Never keep a transaction open across HTTP requests or user interaction. + +## Identifiers + +The default Doctrine extractor accepts integer, string, `BackedEnum` and `Stringable` identifier values. Primitive composite identifiers are encoded deterministically using the stable `doctrine-composite-v1` format. + +Association identifier fields are not supported by the default extractor. Use a custom `IdentifierExtractorInterface` implementation or provide an explicit `AuditSubject`. The same alternatives apply when an application needs a logical subject identifier unrelated to Doctrine metadata. + +## Actors + +The default transactional Symfony Security resolver uses `UserInterface::getUserIdentifier()`. It resolves immediate `SwitchUserToken` impersonation and may return `null` when no user is available. Supply an explicit `AuditActor` for system or technical operations, or replace `AuditActorResolverInterface` when another identifier policy is required. + +## Rollback plan + +An application that retains the legacy mapping can return its own code paths to the historical services without an automatic schema migration. Application-owned audit entities, tables and migrations remain the application's responsibility; the bundle cannot undo them automatically. + +No automatic downgrade of Doctrine ORM is promised. A rollback involving platform dependencies must be planned and tested by the application using its dependency lock and deployment process. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..cfdb1e9 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,39 @@ +# Compatibility and deprecation policy + +## Supported platform + +The future 2.x series targets: + +- PHP 8.3 and later within the tested range; +- Symfony 7.4 and 8.x; +- Doctrine ORM 3.x; +- DoctrineBundle 2.19 and 3.x. + +Doctrine ORM 2 is not supported. This platform break is the reason for the 2.0 major line. + +## Executed compatibility boundaries + +The general CI executes six runtime boundaries covering PHP 8.3, 8.4 and 8.5, Symfony 7.4, 8.0 and 8.1, DoctrineBundle 2.19/2.x/3.x, DBAL 3/4 and the lowest dependency boundary without native mbstring. + +PostgreSQL 16 is used for the executable shared commit, rollback and fail-closed transactional proof. SQLite is used for most container, mapping and general integration tests. Compatibility is not claimed outside executed matrices and declared Composer constraints. + +## Semantic versioning and public API + +The project follows Semantic Versioning. The historical 1.0 API is preserved by `tests/Contract/public-api-1.0.0.json`. The complete 2.0 surface is frozen by `tests/Contract/public-api-2.0.0.json`. + +Additive public types may be introduced during 2.x. Existing 2.0 types, concrete public classes, interface methods, constructors, enum cases, public properties, finality and readonly semantics are protected for the 2.x series. An incompatible removal or signature change requires a future major version. + +The deterministic `doctrine-composite-v1` identifier representation is considered stable during 2.x. + +## Deprecation policy + +Before removing a supported public surface, the project intends to: + +1. document the deprecation; +2. provide and document an alternative where applicable; +3. retain the deprecated path for a reasonable migration period; +4. remove it only in a future major version. + +Concrete classes currently represented in the 2.0 public snapshot are protected even when normally obtained through Symfony services. Consumers should still prefer documented interfaces and dependency injection. + +See [Upgrade 2.0](../UPGRADE-2.0.md), [Legacy mode](legacy-mode.md) and the [Changelog](../CHANGELOG.md). diff --git a/docs/index.md b/docs/index.md index 77692df..af5c458 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,112 +1,15 @@ -# Zhortein Auditable Bundle Documentation +# Zhortein Auditable Bundle documentation -A lightweight Symfony bundle for automatic audit trail tracking on Doctrine ORM entities. +Zhortein Auditable Bundle supports the compatibility-preserved legacy listener and an opt-in strict transactional recorder. Start with the [README overview](../README.md) to choose the appropriate path. -## Requirements +## Guides -- PHP 8.3 or later -- Symfony 7.4 or 8.x -- Doctrine ORM 3.x -- DoctrineBundle 2.19 or 3.x +- [Overview and quick starts](../README.md) +- [Legacy mode](legacy-mode.md) — automatic Doctrine lifecycle auditing and its historical fail-open semantics +- [Transactional Doctrine integration](transactional-doctrine.md) — application-owned persistence and transaction boundaries +- [Upgrade from 1.0 to 2.0](../UPGRADE-2.0.md) — platform changes, migration paths and rollback planning +- [Security and privacy](security-privacy.md) — data minimization, access, retention and guarantees not provided +- [Compatibility and deprecation](compatibility.md) — supported matrices, API freezes and 2.x policy +- [Changelog](../CHANGELOG.md) -The bundle includes `symfony/polyfill-mbstring`, so the native `mbstring` extension is optional. The native extension remains recommended for the best multibyte string performance. - -## Overview - -This bundle provides a declarative, attribute-based system to automatically track changes to your Doctrine entities. When you mark an entity with `#[Auditable]`, the bundle's Doctrine listener automatically captures: - -- **Entity creations** (INSERT) -- **Entity modifications** (UPDATE) with field-level change detection -- **Entity deletions** (DELETE) - -All audit entries are persisted to a dedicated `audit_entry` table, with optional async processing via Symfony Messenger. - -## Core Features - -### ✅ Attribute-based configuration -- `#[Auditable]` on entity classes to enable auditing -- `#[AuditField(label: '...')]` on properties for custom field labels -- `#[AuditIgnore]` on sensitive fields to exclude from audit logs - -### ✅ Change detection -- Automatic diff of old → new values for UPDATE operations -- Smart stringification of complex types (Enums, Collections, Objects, etc.) -- Configurable truncation of long values - -### ✅ Actor tracking -- Integration with Symfony Security to track "who did it" -- Automatic impersonation detection (original user vs impersonator) -- Fallback to null for unauthenticated actions - -### ✅ Flexible persistence -- **Async mode** (recommended): Messages dispatched to Messenger, processed in background -- **Sync mode**: Audit entries written immediately within the request lifecycle -- Easy toggle between modes via configuration - -### ✅ Production-ready -- Full type checking (PHPStan level 8) -- Comprehensive test coverage -- Follows Symfony conventions and best practices -- Stable API suitable for package distribution - -## Quick links - -- [Installation & Quick Start](../README.md#installation) -- [Configuration Reference](../README.md#configuration-reference) -- [Opt-in transactional recorder wiring](../README.md#opt-in-transactional-recorder-wiring) -- [Transactional Doctrine and PostgreSQL guide](transactional-doctrine.md) -- [Transactional-only mapping](transactional-doctrine.md#transactional-only-mapping) -- [Security & PII Handling](../README.md#security--pii) -- [GitHub Repository](https://github.com/zhortein/auditable-bundle) - -The strict transactional recorder requires explicit activation and application-provided entry factory, storage and PSR-20 clock services. The bundle provides no implicit storage implementation or flush; see [the README wiring section](../README.md#opt-in-transactional-recorder-wiring). - -The [transactional Doctrine guide](transactional-doctrine.md) documents the application-owned mapping and executable PostgreSQL proofs for shared commit, shared rollback and fail-closed behavior, including the explicit [transactional-only mapping](transactional-doctrine.md#transactional-only-mapping) opt-out. - -## Architecture - -The bundle consists of: - -- **Attributes**: Configuration metadata for entities and fields -- **Metadata Provider**: Reflection-based extraction of audit configuration -- **Change Detector**: Computes diffs between old and new entity states -- **Historizer**: Main service for recording audit entries (entry point for custom logging) -- **Doctrine Listener**: Intercepts entity lifecycle events to trigger auditing -- **Writers**: Sync/Async implementations for persisting audit data -- **Message Handler**: Processes queued audit messages from Messenger - -## Example usage - -```php -use Zhortein\AuditableBundle\Attribute\Auditable; -use Zhortein\AuditableBundle\Attribute\AuditField; -use Zhortein\AuditableBundle\Attribute\AuditIgnore; - -#[Auditable(label: 'Customer')] -class Customer -{ - #[AuditField(label: 'Email Address')] - private string $email; - - #[AuditField(label: 'Full Name')] - private string $name; - - #[AuditIgnore] - private string $passwordHash; -} - -// In a controller/service: -$customer->setEmail('new@example.com'); -$em->persist($customer); -$em->flush(); - -// Automatically generates an audit entry: -// Title: "Update [Customer] - 2 field(s) changed" -// Description: "Email Address: old@example.com → new@example.com" -``` - -## Further reading - -- See [README.md](../README.md) for installation and configuration -- Review source code documentation in `/src` for detailed API information -- Check the [tests on GitHub](https://github.com/Zhortein/auditable-bundle/tree/main/tests) for integration examples +The transactional PostgreSQL suite proves shared commit, shared rollback and fail-closed rollback. SQLite covers most container and mapping integration tests. Neither database choice imposes an audit entity or storage on consuming applications. diff --git a/docs/legacy-mode.md b/docs/legacy-mode.md new file mode 100644 index 0000000..058b892 --- /dev/null +++ b/docs/legacy-mode.md @@ -0,0 +1,51 @@ +# Legacy mode + +Legacy mode is the behavior preserved from 1.0.0 for backward compatibility. It is enabled by default and is deliberately distinct from the opt-in strict transactional recorder. + +## Attributes and automatic operations + +The supported attributes are: + +- `#[Auditable]` on an entity class; +- `#[AuditField]` on a property to provide a display label; +- `#[AuditIgnore]` on a property to exclude it from automatic change data. + +The Doctrine listener observes create, update and delete operations. Automatic entries use the historical `isAuto=false` value. This behavior is preserved rather than silently reinterpreted. + +## Historical persistence model + +The bundle-owned `AuditEntry` entity maps to `audit_entry` with an auto-generated integer identifier. Its historical indexes cover `occurred_at` and the pair `entity_class, entity_id`. Principal columns include occurrence time, action, level, title, description, context, entity class and identifier, actor and impersonator identifiers, `is_auto`, and JSON data. + +Actor and target identifiers are stored as strings. Legacy entity extraction calls `getId()` only and does not support composite identifiers. The legacy security resolver prefers `getId()` when available and otherwise uses `getUserIdentifier()`. + +## Fail-open behavior and flush + +`Historizer` catches every `Throwable`, logs the error through PSR-3 and does not rethrow it. This is fail-open behavior: an audit failure does not interrupt the business flow. + +`AuditEntryPersister` calls `persist()` and then `flush()`. Consequently, legacy mode provides no guarantee that a business mutation and its audit entry share one application-controlled atomic transaction. Applications requiring fail-closed shared commit and rollback should use the [strict transactional mode](transactional-doctrine.md). + +## Synchronous and asynchronous writers + +With `async.enabled=false`, `SyncAuditEntryWriter` invokes the legacy persister immediately. With `async.enabled=true`, `AsyncAuditEntryWriter` dispatches `PersistAuditEntryMessage` to Messenger and persistence occurs when its handler runs. + +Messenger routing determines the effective transport. The historical `async.transport` configuration key and parameter remain available for backward compatibility, but the writer does not use them to select a transport. Route `PersistAuditEntryMessage` explicitly in Messenger configuration. + +Asynchronous delivery separates business and audit processing and therefore does not provide a shared database transaction. Before disabling the legacy mapping, drain pending messages and stop legacy workers. + +## Configuration + +- `enabled` defaults to `true` and controls the legacy runtime. +- `legacy_mapping.enabled` defaults to `true` and controls Doctrine registration of `AuditEntry`; it cannot be disabled while `enabled=true`. +- `async.enabled` defaults to `true` and selects the legacy writer. +- `async.transport` defaults to `async` but does not replace Messenger routing. +- `listener.track_insert`, `track_update` and `track_delete` default to `true`. +- `fields.max_string_length` defaults to `180`. +- `fields.global_ignored` defaults to an empty list. + +Disabling the mapping never deletes an existing table and no migration is supplied. + +## When to use transactional mode + +Prefer transactional mode when an audit failure must prevent the business commit, when business and audit rows must share commit or rollback, when composite or non-`getId()` subjects are required, or when the application must own its audit entity, factory, storage and retention model. + +See also [Upgrade 2.0](../UPGRADE-2.0.md) and [Security and privacy](security-privacy.md). diff --git a/docs/security-privacy.md b/docs/security-privacy.md new file mode 100644 index 0000000..cbbbdbe --- /dev/null +++ b/docs/security-privacy.md @@ -0,0 +1,51 @@ +# Security and privacy responsibilities + +Audit records can concentrate sensitive information. This bundle supplies recording mechanisms; it does not make an application legally compliant or determine which data it may retain. Establish retention and deletion obligations with the application's legal, privacy or compliance advisers. + +## Sensitive data + +Do not audit passwords, authentication or refresh tokens, API secrets, private keys, temporary codes, complete payment data, or medical and other sensitive data without a documented need and appropriate protection. + +## Data-minimization mechanisms + +- Use `#[AuditIgnore]` for fields excluded from automatic legacy change detection. +- Use `fields.global_ignored` for property names excluded across audited entities. +- Control every value placed in transactional `data` arrays. +- Control actor metadata produced by custom resolvers or explicit `AuditActor` values. +- Use application-owned factories and storage to validate and minimize transactional records. + +These mechanisms depend on application configuration and review; they are not automatic classification of sensitive data. + +## Actor identifiers + +The default transactional resolver uses `getUserIdentifier()`, which may return an email address or another personal identifier. Replace `AuditActorResolverInterface` when an opaque identifier is more appropriate. + +The supplied resolver does not implicitly collect roles, IP addresses, user agents, tenants or organizations. Applications adding such metadata remain responsible for necessity, transparency and retention. + +## Storage, access and retention + +Restrict access to audit tables and administrative views. Define retention, archival and deletion policies. Apply backup controls and encryption appropriate to the application and its infrastructure. An audit trail may be more sensitive than an individual business record because it aggregates actions, identities and historical values. + +Avoid exposing audit payloads or sensitive identifiers in exception messages and logs. Review custom factories, storage implementations and log handlers accordingly. + +## Guarantees not supplied + +The bundle does not automatically provide: + +- encryption at rest; +- cryptographic signatures or hash chaining; +- append-only storage or database immutability; +- tamper evidence; +- archival, purge or anonymization workflows; +- GDPR or sector-specific compliance; +- authorization for an administration interface. + +## Transactional mode + +Fail-closed recording protects the application-controlled transaction boundary; it is not a cryptographic integrity guarantee. Factory and storage implementations remain responsible for validation and minimization. Using another entity manager or connection removes the demonstrated shared-atomicity guarantee. + +## Legacy mode + +Legacy mode is fail-open: `Historizer` logs and absorbs failures. Automatic field change detection can capture unexpected values unless `#[AuditIgnore]` and `fields.global_ignored` are applied carefully. Its persister flushes and does not provide the strict recorder's application-controlled shared transaction. + +For behavioral details, see [Legacy mode](legacy-mode.md) and [Transactional Doctrine integration](transactional-doctrine.md). diff --git a/docs/transactional-doctrine.md b/docs/transactional-doctrine.md index 81941f2..d231f06 100644 --- a/docs/transactional-doctrine.md +++ b/docs/transactional-doctrine.md @@ -2,6 +2,8 @@ The strict recorder is deliberately independent from an application's persistence model. An application that stores audit entries with Doctrine supplies its own audit entity, `AuditEntryFactoryInterface` implementation, `AuditStorageInterface` implementation and PSR-20 clock. The bundle provides none of these persistence choices. +The 2.0 design provides only strict, fail-closed recording: there is no `best_effort` mode. The recorder does not catch exceptions from subject extraction, actor resolution, the factory or storage. Applications that need the compatibility-preserved fail-open behavior should use the [legacy mode](legacy-mode.md) and account for its different transaction semantics. + ## Application-owned storage A minimal Doctrine storage attaches the application-owned audit entity to the same Unit of Work as the business mutation: @@ -50,6 +52,12 @@ After a flush or transaction error, abandon the entity manager according to Doct The executable PostgreSQL fixtures in [`tests/Fixtures/Transactional/PostgreSql`](https://github.com/Zhortein/auditable-bundle/tree/main/tests/Fixtures/Transactional/PostgreSql) and [`TransactionalAtomicityIntegrationTest`](https://github.com/Zhortein/auditable-bundle/blob/main/tests/Integration/PostgreSql/TransactionalAtomicityIntegrationTest.php) demonstrate shared commit, shared rollback and fail-closed behavior with application-owned UUID v7 entities. +## Explicit values and default strategies + +When an `AuditEvent` contains an entity and no explicit subject, the default Doctrine extractor accepts scalar `int` and `string` identifiers, backed enums, stringable identifiers and primitive composite identifiers. Composite identifiers are encoded deterministically with the stable `doctrine-composite-v1` format. Associations that form part of a Doctrine identifier are not supported by the default extractor; use a custom `IdentifierExtractorInterface` or provide an explicit `AuditSubject`. + +An explicit `AuditSubject` bypasses identifier extraction. An explicit `AuditActor` bypasses the Symfony Security resolver. An explicit `occurredAt` bypasses the PSR-20 clock. These values let application code express system actors, external subjects or domain timestamps without changing the default aliases. + ## Transactional-only mapping During a transition, an application can keep the historical mapping while enabling the strict recorder: @@ -85,3 +93,10 @@ Before disabling the mapping in an application that used asynchronous legacy aud 4. Only then disable the mapping. A queued legacy message still depends on the legacy message handler, persister and `AuditEntry` mapping. The bundle does not drain queues automatically. The executable SQLite and PostgreSQL opt-out tests demonstrate that the strict recorder and application-owned audit schema continue to work without registering the legacy metadata. + +## Related guidance + +- [Upgrade from 1.0 to 2.0](../UPGRADE-2.0.md) covers migration sequencing and rollback planning. +- [Legacy mode](legacy-mode.md) documents the compatibility-preserved listener and persistence behavior. +- [Security and privacy](security-privacy.md) describes data-minimization and storage responsibilities. +- [Compatibility and deprecation](compatibility.md) defines the supported platform and stability guarantees. From e9120a7e7f4141a733c78ee87b90642d66d8b58a Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 11:39:36 +0200 Subject: [PATCH 21/28] docs: align project documentation for the 2.x series Rewrite the project overview, configuration comments, and unreleased changelog around the future 2.x platform, default-preserved legacy mode, opt-in strict recorder, migration paths, and explicit security and durability non-guarantees. The documentation does not mark 2.0 as published and preserves the historical 1.0 changelog entry. --- CHANGELOG.md | 34 +-- README.md | 195 ++++++------------ .../packages/zhortein_auditable.yaml.example | 8 +- 3 files changed, 84 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad6cee..cb66fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,22 +2,32 @@ ## [Unreleased] +### Added + +- Add immutable transactional contracts and models for events, records, subjects and actors. +- Add a deterministic Doctrine identifier extractor for scalar, stringable and primitive composite identifiers using the stable `doctrine-composite-v1` format. +- Add a Symfony Security actor resolver supporting authenticated users and immediate impersonation context. +- Add a strict recorder that delegates entity creation and persistence to application-provided factory and storage implementations and uses an application-provided PSR-20 clock. +- Add opt-in Symfony container wiring for the strict recorder. The bundle provides no default transactional factory, storage or clock. +- Add PostgreSQL-backed executable tests proving shared commit, shared rollback, absence of hidden flush and fail-closed rollback with application-owned UUID v7 entities. +- Add a guarded opt-out of the legacy Doctrine mapping for transactional-only applications. The opt-out is disabled by default and never removes a table. +- Add immutable 1.0 and 2.0 public API snapshots. + ### Changed -- Doctrine ORM 3 is now the minimum supported ORM version. -- DoctrineBundle 2.19 or 3.x is now required. -- The next release targets the 2.x series because the supported platform baseline has changed. +- Upgrade static analysis to PHPStan 2 at maximum level without a baseline or global ignored errors. +- Include the versioned documentation in the Composer archive and validate its relative links, no-development installation and runtime autoload. -### Added +### Compatibility + +- Require Doctrine ORM 3.x and DoctrineBundle 2.19 or 3.x for the future 2.x series. +- Support PHP 8.3 through 8.5 and Symfony 7.4, 8.0 and 8.1 on the executed CI boundaries. +- Add a direct runtime dependency on `symfony/polyfill-mbstring`, allowing operation without the native extension. +- Preserve the default legacy runtime, `AuditEntry` mapping, table schema, service aliases and fail-open behavior. Existing applications need no mandatory SQL migration when retaining the default mapping. + +### Documentation -- Direct runtime dependency on symfony/polyfill-mbstring, allowing the bundle to run without the native mbstring extension. -- Add immutable transactional audit contracts and data-transfer objects without changing the legacy runtime behavior. -- Add a default Doctrine ORM identifier extractor for deterministic scalar, Stringable and primitive composite audit subjects. -- Add a default Symfony Security actor resolver for authenticated users and immediate SwitchUserToken impersonation context. -- Add a manually composable strict transactional recorder that resolves audit subjects, actors and timestamps before delegating to application-supplied entry factories and storage. -- Add opt-in Symfony container wiring for the strict transactional recorder, requiring application-provided entry factory, storage and PSR-20 clock services. -- Add a PostgreSQL-backed executable application example proving shared commit, shared rollback and fail-closed behavior with an application-owned UUID v7 audit entity and a Doctrine storage that never flushes. -- Add an explicit legacy Doctrine mapping opt-out for transactional-only applications, disabled by default and guarded against active legacy auditing. +- Add guides for upgrading from 1.0, legacy behavior, transactional Doctrine integration, security and privacy, and the 2.x compatibility and deprecation policy. ## [1.0.0] - 2025-12-24 diff --git a/README.md b/README.md index c446f15..736c3e3 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,20 @@ # Zhortein Auditable Bundle -A lightweight Symfony bundle to automatically **audit and historize Doctrine ORM entity changes** (create/update/delete) with optional **async persistence** via Symfony Messenger. +Zhortein Auditable Bundle provides two audit paths for Symfony applications using Doctrine ORM: -> Designed for Symfony 7.4+ / 8.x, PHP 8.3+. - -## Features - -- ✅ Opt-in auditing with PHP Attributes: - - `#[Audited]` on a Doctrine entity class to enable auditing - - `#[AuditLabel('…')]` on an entity class to override the displayed label - - `#[AuditIgnore]` on an entity property to exclude it from audits (PII/secrets/noise) -- ✅ Captures create / update / delete actions -- ✅ Stores audit records in a `History` entity (Doctrine ORM) -- ✅ Supports async writing using Symfony Messenger (recommended) -- ✅ Extensible: actor resolver, label strategy, change detector, writer… +- the compatibility-preserved legacy listener, which records selected entity changes automatically; +- an opt-in strict recorder that lets the application own its audit model, persistence and transaction boundary. ## Requirements -- PHP 8.3+ +- PHP 8.3 or later - Symfony 7.4 or 8.x - Doctrine ORM 3.x - DoctrineBundle 2.19 or 3.x -- Symfony Messenger (optional but recommended for async) -The bundle includes `symfony/polyfill-mbstring`, so the native `mbstring` extension is not required. Installing the native extension remains recommended for the best multibyte string performance. +Symfony Messenger is a required package dependency and supports the legacy asynchronous writer. The bundle includes `symfony/polyfill-mbstring`; the native `mbstring` extension remains recommended for performance. + +See the [compatibility and deprecation policy](docs/compatibility.md) and the [2.0 upgrade guide](UPGRADE-2.0.md) before upgrading an existing application. ## Installation @@ -31,42 +22,38 @@ The bundle includes `symfony/polyfill-mbstring`, so the native `mbstring` extens composer require zhortein/auditable-bundle ``` -If you **don’t** use Symfony Flex recipes, enable the bundle: +Without Symfony Flex recipes, enable the bundle in `config/bundles.php`: ```php -// config/bundles.php return [ // ... Zhortein\AuditableBundle\ZhorteinAuditableBundle::class => ['all' => true], ]; ``` -### Doctrine mapping & migrations +With no bundle configuration, the legacy runtime and its `AuditEntry` mapping remain enabled and the transactional recorder remains disabled. The bundle does not apply migrations automatically. Review any Doctrine migration generated for your application before running it. -The bundle ships a Doctrine entity (`AuditEntry`). Its mapping is registered automatically by a compiler pass, so you **don't need** to declare a `doctrine.orm.mappings` entry manually. +## Choose an audit path -**Generate and run migrations** after installation: +The [legacy mode](docs/legacy-mode.md) preserves the 1.0 behavior for existing applications. It is convenient for automatic create, update and delete histories, but is fail-open, flushes through its persister and does not guarantee atomicity with the business write. -```bash -php bin/console doctrine:migrations:diff -php bin/console doctrine:migrations:migrate -``` +The [transactional Doctrine integration](docs/transactional-doctrine.md) is opt-in. It is intended for operations where an audit failure must prevent the business commit. The application supplies its own audit entity, factory, storage and clock; the bundle supplies no default persistence model for this path. -This creates the `audit_entry` table with the necessary schema for storing audit trail entries. +Both paths can coexist during a migration. The [2.0 upgrade guide](UPGRADE-2.0.md) covers coexistence, transactional-only applications and rollback planning. -## Quick start +## Legacy quick start -### 1) Mark entities as audited +Mark an entity with the actual legacy attributes: ```php -use Zhortein\AuditableBundle\Attribute\Audited; -use Zhortein\AuditableBundle\Attribute\AuditLabel; +use Zhortein\AuditableBundle\Attribute\Auditable; +use Zhortein\AuditableBundle\Attribute\AuditField; use Zhortein\AuditableBundle\Attribute\AuditIgnore; -#[Audited] -#[AuditLabel('Customer')] -class Customer +#[Auditable(label: 'Customer')] +final class Customer { + #[AuditField(label: 'Email address')] private string $email; #[AuditIgnore] @@ -74,92 +61,37 @@ class Customer } ``` -### 2) (Recommended) Configure async message handling - -By default, the bundle is configured for **async persistence** via Symfony Messenger. - -#### Step A: Define the Messenger transport - -If your project doesn't already have a Messenger `async` transport, add one: - -```yaml -# config/packages/messenger.yaml -framework: - messenger: - transports: - async: '%env(MESSENGER_TRANSPORT_DSN)%' - routing: - 'Zhortein\AuditableBundle\Message\PersistAuditEntryMessage': async -``` - -See `config/packages/messenger.yaml.example` in the bundle for a complete configuration example. - -#### Step B: Configure the bundle - -Create or update the bundle configuration: +The defaults are: ```yaml -# config/packages/zhortein_auditable.yaml zhortein_auditable: enabled: true - async: + legacy_mapping: enabled: true - transport: 'async' -``` - -See `config/packages/zhortein_auditable.yaml.example` in the bundle for all available options. - -#### Synchronous mode (optional) - -If you prefer **synchronous persistence** (audit records written immediately without Messenger): - -```yaml -zhortein_auditable: - enabled: true - async: + transactional: enabled: false + async: + enabled: true + transport: async ``` -> **Note**: Async mode is recommended for production to avoid blocking request handling with database writes. - -## Configuration reference - -The bundle's configuration options are documented with comments in `config/packages/zhortein_auditable.yaml.example`. - -**Key settings:** - -- **`enabled`**: Master switch to enable/disable auditing globally (default: `true`) -- **`legacy_mapping.enabled`**: Register the legacy `AuditEntry` Doctrine mapping (default: `true`) -- **`async.enabled`**: Use Messenger for async persistence (default: `true`) -- **`async.transport`**: Messenger transport name for audit messages (default: `'async'`) -- **`listener.track_insert/update/delete`**: Control which operations are tracked (all default to `true`) -- **`fields.max_string_length`**: Maximum length for serialized field values (default: `180`) -- **`fields.global_ignored`**: List of properties to always exclude from all entities (default: `[]`) - -**Actor resolution:** +When `async.enabled` is `true`, route `Zhortein\AuditableBundle\Message\PersistAuditEntryMessage` through Symfony Messenger. The historical `async.transport` key is preserved for compatibility; Messenger routing determines the effective transport. Set `async.enabled: false` to use the synchronous legacy writer. -By default, the bundle uses Symfony Security to resolve the current user via `SecurityActorResolver`. No additional configuration is needed. +The complete options and historical limitations are documented in [Legacy mode](docs/legacy-mode.md). A commented example is available at `config/packages/zhortein_auditable.yaml.example`. -The resolver automatically handles: -- Regular authenticated users → stores user ID or user identifier -- Null users (not authenticated) → stores `null` -- Impersonation → stores both original user and impersonator IDs +## Transactional quick start -### Opt-in transactional recorder wiring - -The strict transactional recorder is disabled by default. Enable its Symfony container wiring explicitly: +Enable the recorder explicitly: ```yaml -# config/packages/zhortein_auditable.yaml zhortein_auditable: transactional: enabled: true ``` -The application must provide the entry factory, storage and PSR-20 clock through standard Symfony aliases: +Provide application services through standard Symfony aliases: ```yaml -# config/services.yaml services: App\Audit\AuditEntryFactory: ~ App\Audit\AuditStorage: ~ @@ -175,71 +107,60 @@ services: alias: App\Audit\AuditClock ``` -The bundle supplies the Doctrine identifier extractor and Symfony Security actor resolver by default. Applications can replace their `IdentifierExtractorInterface` and `AuditActorResolverInterface` aliases using standard Symfony service configuration. - -No entry factory, storage or clock implementation is supplied by default, and activation fails during container compilation if one is missing. The recorder performs no flush or commit and does not manage transactions. With `transactional.enabled: false`, the legacy behavior and its existing services remain unchanged. +The bundle provides default aliases for `IdentifierExtractorInterface` and `AuditActorResolverInterface`; the application may replace either alias. It intentionally provides no entry factory, storage or clock. -For Doctrine applications, keep the business mutation and audit entry in the same application-owned Unit of Work: +Keep the mutation and audit entry in the same application-owned Unit of Work: ```php $entityManager->wrapInTransaction(function (EntityManagerInterface $entityManager) use ($operation, $auditRecorder): void { $operation->complete(); $entityManager->persist($operation); - $auditRecorder->record(new AuditEvent(action: 'complete', title: 'Operation completed', entity: $operation)); + + $auditRecorder->record(new AuditEvent( + action: 'complete', + title: 'Operation completed', + entity: $operation, + )); }); ``` -No Doctrine audit storage or audit entity is provided by the bundle; the mapping and persistence strategy remain application choices. See the [transactional Doctrine guide](docs/transactional-doctrine.md) for the full boundary rules and executable PostgreSQL example. +The strict recorder performs no flush, commit or rollback and does not catch factory or storage exceptions. Fail-closed behavior therefore requires the audit storage to use the same entity manager and connection and not to flush independently. The application controls the transaction boundary. -### Transactional-only applications +No Doctrine audit entity, storage or migration is imposed by the bundle. The executable PostgreSQL proof and full boundary rules are in the [transactional Doctrine guide](docs/transactional-doctrine.md). -Applications that no longer use any legacy audit path can opt out of the bundle's historical `AuditEntry` mapping: +## Transactional-only applications + +After all legacy producers, pending Messenger messages and legacy workers have been dealt with, an application may omit the legacy mapping: ```yaml zhortein_auditable: enabled: false - legacy_mapping: enabled: false - transactional: enabled: true ``` -`enabled` controls the legacy runtime, `legacy_mapping.enabled` controls only Doctrine's knowledge of the legacy `AuditEntry`, and `transactional.enabled` controls only the strict recorder. Their defaults are respectively `true`, `true` and `false`, so existing applications without this configuration are unchanged. Disabling the mapping while the legacy runtime remains enabled is rejected. - -The opt-out does not delete an existing `audit_entry` table or provide a migration. Before enabling it in an application that used asynchronous legacy auditing, stop producing legacy messages, drain pending `PersistAuditEntryMessage` messages and ensure no legacy worker still writes them. See [Transactional-only mapping](docs/transactional-doctrine.md#transactional-only-mapping) for transition and migration guidance. - -## What gets stored +`enabled` controls the legacy runtime, `legacy_mapping.enabled` controls only Doctrine registration of the legacy `AuditEntry`, and `transactional.enabled` controls the strict recorder. Their defaults are `true`, `true` and `false`. Disabling the mapping while legacy auditing remains enabled is rejected. -Each `AuditEntry` record in the audit trail contains: +This option never drops an existing `audit_entry` table and supplies no migration. See the [upgrade guide](UPGRADE-2.0.md#transactional-only-mode) before opting out. -- **Entity metadata**: Fully qualified class name and entity ID -- **Action**: One of `create`, `update`, `delete`, or `log` -- **Level**: Severity level (`debug`, `info`, `warning`, `error`, `critical`) -- **Title & Description**: Human-readable summary of the change -- **Context**: Optional context tag for grouping related entities -- **Actor**: User ID or identifier of who made the change (null if unauthenticated) -- **Impersonator**: Original user ID if the change was made during impersonation -- **Timestamp**: When the change occurred (as `DateTimeImmutable`) -- **Data**: JSON-encoded field changes (old value → new value), excluding `#[AuditIgnore]` properties +## Security and non-guarantees -**Example audit entry for an update:** -``` -Title: "Update [Customer] - 2 field(s) changed" -Description: - - "Email: john@example.com → john.doe@example.com" - - "Phone: +1234567890 → +1987654321" -Data: { "email": { "old": "john@example.com", "new": "john.doe@example.com" }, ... } -``` +Never audit passwords, tokens, private keys or other secrets. Use `#[AuditIgnore]`, `fields.global_ignored` and application-level factory/storage validation to minimize recorded data. Actor identifiers and audit payloads may be personal data. -## Security / PII +The bundle does not provide encryption at rest, cryptographic signatures, hash chaining, append-only storage, retention, purge, anonymization or legal compliance. Strict transactional recording provides a fail-closed transaction boundary when integrated correctly; it does not provide tamper evidence. See [Security and privacy](docs/security-privacy.md). -This bundle is meant to help you build **auditable applications**—but you are responsible for what you store. +## Documentation -- Use `#[AuditIgnore]` for secrets (password hashes, tokens) and sensitive data that should not be persisted in audit logs. -- Consider encrypting audit payloads or restricting access to the History table depending on your domain constraints. +- [Documentation index](docs/index.md) +- [Legacy mode](docs/legacy-mode.md) +- [Transactional Doctrine integration](docs/transactional-doctrine.md) +- [Upgrade from 1.0 to 2.0](UPGRADE-2.0.md) +- [Security and privacy](docs/security-privacy.md) +- [Compatibility and deprecation](docs/compatibility.md) +- [Changelog](CHANGELOG.md) ## License -MIT (see [LICENSE](LICENSE)]). +MIT. See [LICENSE](LICENSE). diff --git a/config/packages/zhortein_auditable.yaml.example b/config/packages/zhortein_auditable.yaml.example index 2033475..b1630ee 100644 --- a/config/packages/zhortein_auditable.yaml.example +++ b/config/packages/zhortein_auditable.yaml.example @@ -23,14 +23,14 @@ zhortein_auditable: transactional: enabled: false - # Async message persistence configuration + # Legacy asynchronous message persistence configuration async: - # Set to true to dispatch audit messages to Messenger (recommended) + # Set to true to dispatch legacy audit messages to Messenger # Set to false for synchronous persistence (audit records written immediately) enabled: true - # Messenger transport name where PersistAuditEntryMessage will be dispatched - # Must match a transport configured in config/packages/messenger.yaml + # Historical compatibility key. The effective transport for + # PersistAuditEntryMessage is selected by Messenger routing, not this value. transport: 'async' # Doctrine listener configuration - what operations to track From 8f402a5369e4155ece66783117a71360b657fdc6 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 11:39:58 +0200 Subject: [PATCH 22/28] ci: require complete 2.0 documentation in package archive Require the upgrade, legacy, transactional, security, privacy and compatibility guides in the Composer archive and validate every distributed Markdown document for safe, resolvable relative links. --- .github/ci/check-package-archive.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/ci/check-package-archive.php b/.github/ci/check-package-archive.php index 90ed9c4..b4f93a8 100644 --- a/.github/ci/check-package-archive.php +++ b/.github/ci/check-package-archive.php @@ -74,7 +74,11 @@ 'README.md', 'CHANGELOG.md', 'LICENSE', + 'UPGRADE-2.0.md', + 'docs/compatibility.md', 'docs/index.md', + 'docs/legacy-mode.md', + 'docs/security-privacy.md', 'docs/transactional-doctrine.md', ]; foreach ($requiredFiles as $requiredFile) { @@ -147,7 +151,11 @@ return implode('/', $segments); }; - $markdownFiles = ['README.md', 'docs/index.md', 'docs/transactional-doctrine.md']; + $markdownFiles = array_values(array_filter( + $relativeEntries, + static fn (string $entry): bool => str_ends_with(strtolower($entry), '.md'), + )); + sort($markdownFiles); $validatedLinks = 0; foreach ($markdownFiles as $markdownFile) { $contents = $archive->getFromName($rootPrefix.$markdownFile); From 46f44ddc893faf13f9a5f117d1c952479edbba8b Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 15:41:06 +0200 Subject: [PATCH 23/28] fix(ci): align Symfony 8.x version assertions Validate all latest-compatible Symfony 8 matrix entries against the supported major instead of pinning a stale minor, while preserving the explicit Symfony 8.0 compatibility boundary. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dcaee7..23f2c0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: - name: PHP 8.5 / Symfony 8.x latest / DBundle 3 / ORM 3 / latest php: "8.5" symfony: "8.*" - symfony_resolved: "8.1.*" + symfony_resolved: "8.*" doctrine_bundle: "3" doctrine_bundle_major: "3" doctrine_orm: "3" @@ -137,7 +137,7 @@ jobs: doctrine_persistence: "4" - name: Symfony 8.x / ORM 3 latest symfony: "8.*" - symfony_resolved: "8.0.*" + symfony_resolved: "8.*" doctrine_bundle: "3" doctrine_orm: "3" doctrine_dbal: "4" @@ -186,7 +186,7 @@ jobs: - name: PHP 8.5 / Symfony 8.x / DBundle 3 / ORM 3 / latest php: "8.5" symfony: "8.*" - symfony_resolved: "8.1.*" + symfony_resolved: "8.*" doctrine_bundle: "3" doctrine_bundle_major: "3" doctrine_orm: "3" From e56a3b77c805520e11d79c2d8691d5bee2d03a9c Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 15:55:11 +0200 Subject: [PATCH 24/28] ci: avoid duplicate branch workflow runs Run CI once for pull requests targeting develop or main, and on pushes reaching the integration, stable or tag refs, so feature, release and Dependabot branches no longer execute duplicate push and pull-request matrices. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23f2c0a..b84ce0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,15 @@ name: CI on: push: + branches: + - main + - develop + tags: + - '*' pull_request: + branches: + - main + - develop permissions: contents: read From a9cf8e31ec193a5cb4c30bafce010463be1e6ed6 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 16:44:03 +0200 Subject: [PATCH 25/28] chore: align development dependency workflows Alias the develop branch to the future 2.0 development line and prepare monthly Composer and GitHub Actions version updates targeting develop, with compatible minor and patch updates grouped. --- .github/dependabot.yml | 25 +++++++++++++++++++------ composer.json | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7d3c168..4c974e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,24 @@ version: 2 + updates: - - package-ecosystem: "composer" - directory: "/" + - package-ecosystem: composer + directory: / + target-branch: develop schedule: - interval: "weekly" + interval: monthly + groups: + compatible-non-breaking-updates: + update-types: + - minor + - patch - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: github-actions + directory: / + target-branch: develop schedule: - interval: "weekly" + interval: monthly + groups: + compatible-non-breaking-updates: + update-types: + - minor + - patch diff --git a/composer.json b/composer.json index 3693fc2..c49bdce 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-develop": "2.0.x-dev" } }, "scripts": { From 683ec2473e6000c3ffe418e357bc97f7bee81f77 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 3 Aug 2026 16:44:17 +0200 Subject: [PATCH 26/28] docs: document the public release workflow Document feature integration through develop, release branches toward main, explicit tag and publication authorization, security-fix synchronization, and main-to-develop reconciliation after every release. --- .github/ci/check-package-archive.php | 1 + CONTRIBUTING.md | 24 +++++++++++ docs/index.md | 4 ++ docs/release-process.md | 62 ++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 docs/release-process.md diff --git a/.github/ci/check-package-archive.php b/.github/ci/check-package-archive.php index b4f93a8..d78a3b0 100644 --- a/.github/ci/check-package-archive.php +++ b/.github/ci/check-package-archive.php @@ -78,6 +78,7 @@ 'docs/compatibility.md', 'docs/index.md', 'docs/legacy-mode.md', + 'docs/release-process.md', 'docs/security-privacy.md', 'docs/transactional-doctrine.md', ]; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 383aa81..8c90c92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,12 +3,36 @@ Thanks for contributing! ## Development + - PHP >= 8.3 - Symfony 7.4 or 8.x - Doctrine ORM 3 - Composer ## Commands + - Tests: `composer test` - Static analysis: `composer phpstan` - Code style: `composer cs:check` / `composer cs:fix` + +## Branch workflow + +### Ordinary development + +Create `feature/*`, `fix/*` or `chore/*` branches from `develop`, then open a draft pull request targeting `develop`. Ordinary changes must never target `main` directly. + +Keep commits atomic when their history is useful. Squashing is appropriate for maintenance or Dependabot pull requests with little internal structure. A merge commit is appropriate for larger features whose commits have been deliberately organized. + +### Release + +Create `release/x.y.z` from `develop`. Finalize the changelog and release-candidate checks on that branch, then open a `release/x.y.z` pull request targeting `main`. Nothing is published before explicit maintainer approval. After publication, synchronize `main` back into `develop`. + +### Security fix + +Dependabot security updates may target `main`. Apply an urgent security fix to `main` through a pull request, then immediately open a `main` to `develop` synchronization pull request. Also integrate the fix into an active release branch when necessary. + +### Prohibited operations + +- Do not force-push shared branches. +- Do not move or delete a published tag. +- Do not publish implicitly from a pull request. diff --git a/docs/index.md b/docs/index.md index af5c458..5e408a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,4 +12,8 @@ Zhortein Auditable Bundle supports the compatibility-preserved legacy listener a - [Compatibility and deprecation](compatibility.md) — supported matrices, API freezes and 2.x policy - [Changelog](../CHANGELOG.md) +## Maintainer lifecycle + +- [Release process](release-process.md) — branch progression, publication controls and recovery + The transactional PostgreSQL suite proves shared commit, shared rollback and fail-closed rollback. SQLite covers most container and mapping integration tests. Neither database choice imposes an audit entity or storage on consuming applications. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..28104d8 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,62 @@ +# Release process + +This process is for project maintainers. Creating release branches, merging to `main`, tagging, creating GitHub Releases and publishing to Packagist remain manual operations that require explicit maintainer authorization. + +## Branch model + +The release progression is: + +```text +feature/* -> develop +develop -> release/x.y.z +release/x.y.z -> main +tag x.y.z +GitHub Release +Packagist +main -> develop +``` + +Tags use the exact `x.y.z` form without a `v` prefix. + +## Before creating a release branch + +- Confirm that the integration pull request is green. +- Confirm that no unreviewed change is included. +- Confirm that the public API snapshots pass. +- Confirm that the general compatibility matrix passes. +- Confirm that the PostgreSQL checks pass. +- Confirm that PHPStan passes at maximum level. +- Confirm that Composer Audit passes. +- Confirm that the package archive check passes. +- Confirm that migration documentation is complete. + +## Creating release/x.y.z + +Create `release/x.y.z` from `develop`. Do not force-push it. Change the version, changelog and release date only as part of the release work. Explicitly review the Composer branch alias for the branch being prepared; the subsequent synchronization of `main` and `develop` may require a different alias decision for the continuing development cycle. Do not publish anything at this stage. + +## Pull request to main + +Open a pull request with `main` as its base and `release/x.y.z` as its head. A merge commit is recommended to preserve the release topology. All required checks must pass. Do not enable automatic merging, and do not create the tag before the pull request is merged with explicit maintainer approval. + +## Tag and publication + +Only after explicit maintainer authorization: + +1. Create the exact `x.y.z` tag on the validated `main` commit. +2. Never move that tag. +3. Create the GitHub Release for the tag. +4. Verify the release on Packagist. + +Never rebuild or replace a release using the same version number. + +## Synchronize main back to develop + +Open a pull request from `main` to `develop` and use a merge commit. Resolve every divergence explicitly. Do not cherry-pick a partial collection of release commits. Verify that `develop` contains the expected tag and release state. + +## Dependabot + +GitHub reads `.github/dependabot.yml` from the default branch, `main`. Version updates target `develop` through `target-branch`. Security updates target the default branch, `main`. After applying a security fix on `main`, immediately synchronize `main` back to `develop`. + +## Rollback and recovery + +Never delete or rewrite a published tag. Revert a release with a new commit or prepare a new corrective version. A ruleset may be temporarily disabled only during documented administrator recovery. Never force-push to rewrite a public release. From 41ba990b80aa9237e597edf72e5b14abd9777cbb Mon Sep 17 00:00:00 2001 From: David RENARD Date: Tue, 4 Aug 2026 07:44:43 +0200 Subject: [PATCH 27/28] ci: pin GitHub Actions to reviewed commits Adopt the reviewed checkout v7.0.1 Node 24 release and pin every checkout and setup-php invocation to its verified full commit SHA while retaining version comments for Dependabot updates. --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b84ce0c..681b8cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,8 +95,8 @@ jobs: native_mbstring: true extensions: "pdo_sqlite, mbstring, iconv, dom, xml, zip" steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "${{ matrix.php }}" extensions: "${{ matrix.extensions }}" @@ -151,8 +151,8 @@ jobs: doctrine_dbal: "4" doctrine_persistence: "4" steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "8.4" extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip @@ -219,8 +219,8 @@ jobs: TEST_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5432/auditable_test?serverVersion=16&charset=utf8" SYMFONY_DEPRECATIONS_HELPER: "max[direct]=0" steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "${{ matrix.php }}" extensions: pdo_pgsql, mbstring, dom, xml, zip @@ -251,8 +251,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "8.3" extensions: mbstring, iconv, dom, xml, zip @@ -301,8 +301,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "8.4" extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip @@ -324,8 +324,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: "8.5" extensions: pdo_sqlite, mbstring, iconv, dom, xml, zip From 8e304364fd5ec7abc8e16ed4b00bb5cf652c3854 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Tue, 4 Aug 2026 08:48:36 +0200 Subject: [PATCH 28/28] chore: prepare the 2.0.0 release Finalize the 2.0.0 changelog date and release-facing documentation without changing runtime code, public APIs, dependency constraints or the validated compatibility matrix. --- CHANGELOG.md | 4 +++- UPGRADE-2.0.md | 2 +- docs/compatibility.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb66fbf..06cd54b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [2.0.0] - 2026-08-04 + ### Added - Add immutable transactional contracts and models for events, records, subjects and actors. @@ -20,7 +22,7 @@ ### Compatibility -- Require Doctrine ORM 3.x and DoctrineBundle 2.19 or 3.x for the future 2.x series. +- Require Doctrine ORM 3.x and DoctrineBundle 2.19 or 3.x for the 2.x series. - Support PHP 8.3 through 8.5 and Symfony 7.4, 8.0 and 8.1 on the executed CI boundaries. - Add a direct runtime dependency on `symfony/polyfill-mbstring`, allowing operation without the native extension. - Preserve the default legacy runtime, `AuditEntry` mapping, table schema, service aliases and fail-open behavior. Existing applications need no mandatory SQL migration when retaining the default mapping. diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index ab11cb0..014d6c5 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -1,6 +1,6 @@ # Upgrade from 1.0.0 to 2.0.0 -This guide describes how to prepare an application for the future 2.x series. It does not announce a release and it does not replace application-specific migration review. +This guide describes how to prepare an application for the 2.x series. It does not replace application-specific migration review. ## Breaking platform changes diff --git a/docs/compatibility.md b/docs/compatibility.md index cfdb1e9..84407ca 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,7 +2,7 @@ ## Supported platform -The future 2.x series targets: +The 2.x series targets: - PHP 8.3 and later within the tested range; - Symfony 7.4 and 8.x;