mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2026-02-14 12:25:30 +00:00
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.
This commit is contained in:
27
src/Bundle/ChillMainBundle/Audit/AuditEvent.php
Normal file
27
src/Bundle/ChillMainBundle/Audit/AuditEvent.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
readonly class AuditEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $action,
|
||||
/**
|
||||
* @var list<object>
|
||||
*/
|
||||
public array $subjects = [],
|
||||
public string|TranslatableInterface $description = '',
|
||||
public array $metadata = [],
|
||||
) {}
|
||||
}
|
||||
51
src/Bundle/ChillMainBundle/Audit/AuditEvent2Trail.php
Normal file
51
src/Bundle/ChillMainBundle/Audit/AuditEvent2Trail.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
use Chill\MainBundle\Entity\AuditTrail;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final readonly class AuditEvent2Trail implements AuditEvent2TrailInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
private SubjectConverterManagerInterface $subjectConverterManager,
|
||||
private ClockInterface $clock,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function convertToTrail(AuditEvent $event): AuditTrail
|
||||
{
|
||||
$description = $event->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
use Chill\MainBundle\Entity\AuditTrail;
|
||||
|
||||
interface AuditEvent2TrailInterface
|
||||
{
|
||||
public function convertToTrail(AuditEvent $event): AuditTrail;
|
||||
}
|
||||
34
src/Bundle/ChillMainBundle/Audit/AuditEventSubscriber.php
Normal file
34
src/Bundle/ChillMainBundle/Audit/AuditEventSubscriber.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
final readonly class AuditEventSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private AuditTrailPersister $auditTrailPersister,
|
||||
private AuditEvent2TrailInterface $auditEvent2Trail,
|
||||
) {}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
AuditEvent::class => 'onAuditEvent',
|
||||
];
|
||||
}
|
||||
|
||||
public function onAuditEvent(AuditEvent $event): void
|
||||
{
|
||||
$this->auditTrailPersister->persistAuditTrail($this->auditEvent2Trail->convertToTrail($event));
|
||||
}
|
||||
}
|
||||
19
src/Bundle/ChillMainBundle/Audit/AuditTrailPersister.php
Normal file
19
src/Bundle/ChillMainBundle/Audit/AuditTrailPersister.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
use Chill\MainBundle\Entity\AuditTrail;
|
||||
|
||||
class AuditTrailPersister
|
||||
{
|
||||
public function persistAuditTrail(AuditTrail $auditTrail): void {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit\Exception;
|
||||
|
||||
class ConvertSubjectException extends \LogicException
|
||||
{
|
||||
public function __construct(mixed $subject, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct(
|
||||
sprintf('Could not convert object of type %s to an acceptable audit representation', is_object($subject) ? get_class($subject) : gettype($subject)),
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/Bundle/ChillMainBundle/Audit/Subject.php
Normal file
28
src/Bundle/ChillMainBundle/Audit/Subject.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
class Subject
|
||||
{
|
||||
public function __construct(
|
||||
public string $type,
|
||||
/**
|
||||
* @var array<string, string|number|\Stringable>
|
||||
*/
|
||||
public array $identifiers,
|
||||
) {}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
return [...$this->identifiers, 't' => $this->type];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
interface SubjectConverterInterface
|
||||
{
|
||||
/**
|
||||
* @return Subject|list<Subject>
|
||||
*/
|
||||
public function convert(mixed $subject): Subject|array;
|
||||
|
||||
public function supportsConvert(mixed $subject): bool;
|
||||
|
||||
public static function getDefaultPriority(): int;
|
||||
}
|
||||
46
src/Bundle/ChillMainBundle/Audit/SubjectConverterManager.php
Normal file
46
src/Bundle/ChillMainBundle/Audit/SubjectConverterManager.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
use Chill\MainBundle\Audit\Exception\ConvertSubjectException;
|
||||
|
||||
final readonly class SubjectConverterManager implements SubjectConverterManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* @var iterable<SubjectConverterInterface>
|
||||
*/
|
||||
private iterable $converters,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<array>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Audit;
|
||||
|
||||
interface SubjectConverterManagerInterface
|
||||
{
|
||||
/**
|
||||
* @return list<array>
|
||||
*/
|
||||
public function convertEntityToSubjects(mixed $subject): array;
|
||||
}
|
||||
100
src/Bundle/ChillMainBundle/Tests/Audit/AuditEvent2TrailTest.php
Normal file
100
src/Bundle/ChillMainBundle/Tests/Audit/AuditEvent2TrailTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Tests\Audit;
|
||||
|
||||
use Chill\MainBundle\Audit\AuditEvent;
|
||||
use Chill\MainBundle\Audit\AuditEvent2Trail;
|
||||
use Chill\MainBundle\Audit\SubjectConverterManagerInterface;
|
||||
use Chill\MainBundle\Entity\AuditTrail;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class AuditEvent2TrailTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testConvertToTrailWithTranslatableDescription(): 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);
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Tests\Audit;
|
||||
|
||||
use Chill\MainBundle\Audit\Exception\ConvertSubjectException;
|
||||
use Chill\MainBundle\Audit\Subject;
|
||||
use Chill\MainBundle\Audit\SubjectConverterInterface;
|
||||
use Chill\MainBundle\Audit\SubjectConverterManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class SubjectConverterManagerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testThrowsExceptionWhenNoConverterSupportsSubject(): void
|
||||
{
|
||||
$manager = new SubjectConverterManager([]);
|
||||
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user