From 6bbdc858bd579ae5b3a2b9423014de8ee4a234ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 23 Jan 2026 17:33:48 +0100 Subject: [PATCH] Add audit system core with event handling and subject conversion - Implemented `AuditEvent` class to represent audit events. - Added `AuditEvent2Trail` for converting events to audit trails. - Introduced `Subject` and `SubjectConverterManager` for subject conversion. - Created contracts like `SubjectConverterInterface` and `AuditEvent2TrailInterface`. - Developed `AuditEventSubscriber` to persist audit events using `AuditTrailPersister`. - Included test classes for core audit services and components. --- .../ChillMainBundle/Audit/AuditEvent.php | 27 +++++ .../Audit/AuditEvent2Trail.php | 51 +++++++++ .../Audit/AuditEvent2TrailInterface.php | 19 ++++ .../Audit/AuditEventSubscriber.php | 34 ++++++ .../Audit/AuditTrailPersister.php | 19 ++++ .../Exception/ConvertSubjectException.php | 23 ++++ src/Bundle/ChillMainBundle/Audit/Subject.php | 28 +++++ .../Audit/SubjectConverterInterface.php | 24 +++++ .../Audit/SubjectConverterManager.php | 46 ++++++++ .../SubjectConverterManagerInterface.php | 20 ++++ .../Tests/Audit/AuditEvent2TrailTest.php | 100 ++++++++++++++++++ .../Audit/SubjectConverterManagerTest.php | 94 ++++++++++++++++ 12 files changed, 485 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Audit/AuditEvent.php create mode 100644 src/Bundle/ChillMainBundle/Audit/AuditEvent2Trail.php create mode 100644 src/Bundle/ChillMainBundle/Audit/AuditEvent2TrailInterface.php create mode 100644 src/Bundle/ChillMainBundle/Audit/AuditEventSubscriber.php create mode 100644 src/Bundle/ChillMainBundle/Audit/AuditTrailPersister.php create mode 100644 src/Bundle/ChillMainBundle/Audit/Exception/ConvertSubjectException.php create mode 100644 src/Bundle/ChillMainBundle/Audit/Subject.php create mode 100644 src/Bundle/ChillMainBundle/Audit/SubjectConverterInterface.php create mode 100644 src/Bundle/ChillMainBundle/Audit/SubjectConverterManager.php create mode 100644 src/Bundle/ChillMainBundle/Audit/SubjectConverterManagerInterface.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Audit/AuditEvent2TrailTest.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Audit/SubjectConverterManagerTest.php diff --git a/src/Bundle/ChillMainBundle/Audit/AuditEvent.php b/src/Bundle/ChillMainBundle/Audit/AuditEvent.php new file mode 100644 index 000000000..e8ed86329 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/AuditEvent.php @@ -0,0 +1,27 @@ + + */ + public array $subjects = [], + public string|TranslatableInterface $description = '', + public array $metadata = [], + ) {} +} diff --git a/src/Bundle/ChillMainBundle/Audit/AuditEvent2Trail.php b/src/Bundle/ChillMainBundle/Audit/AuditEvent2Trail.php new file mode 100644 index 000000000..2b7380631 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/AuditEvent2Trail.php @@ -0,0 +1,51 @@ +description instanceof TranslatableInterface ? + $event->description->trans($this->translator) + : $event->description; + + $targets = array_map(fn (mixed $subject) => $this->subjectConverterManager->convertEntityToSubjects($subject), $event->subjects); + + $user = $this->security->getUser(); + + return new AuditTrail( + Uuid::uuid7(), + $event->action, + $this->clock->now(), + $user instanceof User ? $user : null, + $description, + $targets, + $event->metadata, + ); + } +} diff --git a/src/Bundle/ChillMainBundle/Audit/AuditEvent2TrailInterface.php b/src/Bundle/ChillMainBundle/Audit/AuditEvent2TrailInterface.php new file mode 100644 index 000000000..62eefc5ed --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/AuditEvent2TrailInterface.php @@ -0,0 +1,19 @@ + 'onAuditEvent', + ]; + } + + public function onAuditEvent(AuditEvent $event): void + { + $this->auditTrailPersister->persistAuditTrail($this->auditEvent2Trail->convertToTrail($event)); + } +} diff --git a/src/Bundle/ChillMainBundle/Audit/AuditTrailPersister.php b/src/Bundle/ChillMainBundle/Audit/AuditTrailPersister.php new file mode 100644 index 000000000..67277369f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/AuditTrailPersister.php @@ -0,0 +1,19 @@ + + */ + public array $identifiers, + ) {} + + public function asArray(): array + { + return [...$this->identifiers, 't' => $this->type]; + } +} diff --git a/src/Bundle/ChillMainBundle/Audit/SubjectConverterInterface.php b/src/Bundle/ChillMainBundle/Audit/SubjectConverterInterface.php new file mode 100644 index 000000000..35e766fcb --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/SubjectConverterInterface.php @@ -0,0 +1,24 @@ + + */ + public function convert(mixed $subject): Subject|array; + + public function supportsConvert(mixed $subject): bool; + + public static function getDefaultPriority(): int; +} diff --git a/src/Bundle/ChillMainBundle/Audit/SubjectConverterManager.php b/src/Bundle/ChillMainBundle/Audit/SubjectConverterManager.php new file mode 100644 index 000000000..d43196889 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/SubjectConverterManager.php @@ -0,0 +1,46 @@ + + */ + private iterable $converters, + ) {} + + /** + * @return list + * + * @throws ConvertSubjectException + */ + public function convertEntityToSubjects(mixed $subject): array + { + foreach ($this->converters as $converter) { + if ($converter->supportsConvert($subject)) { + $subjects = $converter->convert($subject); + + if ($subjects instanceof Subject) { + return [$subjects->asArray()]; + } + + return array_map(static fn (Subject $subject) => $subject->asArray(), $subjects); + } + } + + throw new ConvertSubjectException($subject); + } +} diff --git a/src/Bundle/ChillMainBundle/Audit/SubjectConverterManagerInterface.php b/src/Bundle/ChillMainBundle/Audit/SubjectConverterManagerInterface.php new file mode 100644 index 000000000..c955648f3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/SubjectConverterManagerInterface.php @@ -0,0 +1,20 @@ + + */ + public function convertEntityToSubjects(mixed $subject): array; +} diff --git a/src/Bundle/ChillMainBundle/Tests/Audit/AuditEvent2TrailTest.php b/src/Bundle/ChillMainBundle/Tests/Audit/AuditEvent2TrailTest.php new file mode 100644 index 000000000..8d4e7f27a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Audit/AuditEvent2TrailTest.php @@ -0,0 +1,100 @@ +prophesize(TranslatorInterface::class); + $subjectConverterManager = $this->prophesize(SubjectConverterManagerInterface::class); + $clock = new MockClock(new \DateTimeImmutable('2024-01-01 12:00:00')); + $security = $this->prophesize(Security::class); + + $description = $this->prophesize(TranslatableInterface::class); + $description->trans($translator->reveal())->willReturn('translated description'); + + $event = new AuditEvent( + action: 'test_action', + subjects: [$subject = new \stdClass()], + description: $description->reveal(), + metadata: ['foo' => 'bar'] + ); + + $subjectConverterManager->convertEntityToSubjects($subject) + ->willReturn([['type' => 'stdClass', 'id' => '1']]); + + $security->getUser()->willReturn(null); + + $service = new AuditEvent2Trail( + $translator->reveal(), + $subjectConverterManager->reveal(), + $clock, + $security->reveal() + ); + + $trail = $service->convertToTrail($event); + + $this->assertInstanceOf(AuditTrail::class, $trail); + $this->assertSame('test_action', $trail->getAction()); + $this->assertSame('translated description', $trail->getDescription()); + $this->assertSame([[['type' => 'stdClass', 'id' => '1']]], $trail->getTargets()); + $this->assertSame(['foo' => 'bar'], $trail->getMetadata()); + $this->assertEquals($clock->now(), $trail->getOccurredAt()); + } + + public function testConvertToTrailWithStringDescription(): void + { + $translator = $this->prophesize(TranslatorInterface::class); + $subjectConverterManager = $this->prophesize(SubjectConverterManagerInterface::class); + $clock = new MockClock(new \DateTimeImmutable('2024-01-01 12:00:00')); + $security = $this->prophesize(Security::class); + + $event = new AuditEvent( + action: 'test_action', + subjects: [], + description: 'string description', + metadata: [] + ); + + $security->getUser()->willReturn(null); + + $service = new AuditEvent2Trail( + $translator->reveal(), + $subjectConverterManager->reveal(), + $clock, + $security->reveal() + ); + + $trail = $service->convertToTrail($event); + + $this->assertSame('string description', $trail->getDescription()); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Audit/SubjectConverterManagerTest.php b/src/Bundle/ChillMainBundle/Tests/Audit/SubjectConverterManagerTest.php new file mode 100644 index 000000000..fa32383de --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Audit/SubjectConverterManagerTest.php @@ -0,0 +1,94 @@ +expectException(ConvertSubjectException::class); + $manager->convertEntityToSubjects(new \stdClass()); + } + + public function testReturnsSingleSubjectWhenConverterReturnsSubject(): void + { + $subject = new \stdClass(); + $auditSubject = new Subject('type', ['id' => '123']); + + $converter = $this->prophesize(SubjectConverterInterface::class); + $converter->supportsConvert($subject)->willReturn(true); + $converter->convert($subject)->willReturn($auditSubject); + + $manager = new SubjectConverterManager([$converter->reveal()]); + + $result = $manager->convertEntityToSubjects($subject); + + $this->assertCount(1, $result); + $this->assertSame(['id' => '123', 't' => 'type'], $result[0]); + } + + public function testReturnsListOfSubjectsWhenConverterReturnsArray(): void + { + $subject = new \stdClass(); + $auditSubject1 = new Subject('type1', ['id' => '123']); + $auditSubject2 = new Subject('type2', ['id' => '456']); + + $converter = $this->prophesize(SubjectConverterInterface::class); + $converter->supportsConvert($subject)->willReturn(true); + $converter->convert($subject)->willReturn([$auditSubject1, $auditSubject2]); + + $manager = new SubjectConverterManager([$converter->reveal()]); + + $result = $manager->convertEntityToSubjects($subject); + + $this->assertCount(2, $result); + $this->assertSame(['id' => '123', 't' => 'type1'], $result[0]); + $this->assertSame(['id' => '456', 't' => 'type2'], $result[1]); + } + + public function testSkipsConverterThatDoesNotSupportSubject(): void + { + $subject = new \stdClass(); + $auditSubject = new Subject('type', ['id' => '123']); + + $converter1 = $this->prophesize(SubjectConverterInterface::class); + $converter1->supportsConvert($subject)->willReturn(false); + $converter1->convert($subject)->shouldNotBeCalled(); + + $converter2 = $this->prophesize(SubjectConverterInterface::class); + $converter2->supportsConvert($subject)->willReturn(true); + $converter2->convert($subject)->willReturn($auditSubject); + + $manager = new SubjectConverterManager([$converter1->reveal(), $converter2->reveal()]); + + $result = $manager->convertEntityToSubjects($subject); + + $this->assertCount(1, $result); + $this->assertSame(['id' => '123', 't' => 'type'], $result[0]); + } +}