diff --git a/app/Audit/AbstractAuditLogFormatter.php b/app/Audit/AbstractAuditLogFormatter.php index 94f614e84..14c1cc146 100644 --- a/app/Audit/AbstractAuditLogFormatter.php +++ b/app/Audit/AbstractAuditLogFormatter.php @@ -3,6 +3,8 @@ namespace App\Audit; use App\Audit\Utils\DateFormatter; +use Doctrine\ORM\PersistentCollection; +use Illuminate\Support\Facades\Log; /** * Copyright 2025 OpenStack Foundation @@ -32,6 +34,90 @@ final public function setContext(AuditContext $ctx): void $this->ctx = $ctx; } + + protected function processCollection( + mixed $owner, + mixed $col, + mixed $uow, + bool $isDeletion = false + ): ?array + { + if (!is_object($col) || !method_exists($col, 'getMapping') || !method_exists($col, 'getInsertDiff')) { + return null; + } + + $mapping = $col->getMapping(); + + $addedEntities = $col->getInsertDiff(); + $removedEntities = $col->getDeleteDiff(); + + $addedIds = $this->extractCollectionEntityIds($addedEntities); + $removedIds = $this->extractCollectionEntityIds($removedEntities); + + if (empty($removedIds) && !empty($addedIds)) { + $this->recoverCollectionRemovalIds($uow, $owner, $mapping, $removedIds); + } + + if (empty($addedIds) && empty($removedIds)) { + return null; + } + + return [ + 'field' => $mapping['fieldName'] ?? 'unknown', + 'target_entity' => $mapping['targetEntity'] ?? null, + 'is_deletion' => $isDeletion, + 'added_ids' => $addedIds, + 'removed_ids' => $removedIds, + 'join_table' => $mapping['joinTable']['name'] ?? null, + ]; + } + + /** + * Recover removed IDs from original entity data + */ + protected function recoverCollectionRemovalIds($uow, $owner, $mapping, &$removedIds): void + { + try { + $originalData = $uow->getOriginalEntityData($owner); + $fieldName = $mapping['fieldName'] ?? null; + + if ($fieldName && isset($originalData[$fieldName])) { + $originalCollection = $originalData[$fieldName]; + if ($originalCollection instanceof PersistentCollection || is_array($originalCollection)) { + $originalEntities = is_array($originalCollection) + ? $originalCollection + : $originalCollection->toArray(); + $removedIds = $this->extractCollectionEntityIds($originalEntities); + } + } + } catch (\Exception $e) { + Log::warning('Failed to recover removed IDs from original entity data', [ + 'error' => $e->getMessage() + ]); + } + } + + /** + * Extract IDs from entity objects in collection + */ + protected function extractCollectionEntityIds(array $entities): array + { + $ids = []; + foreach ($entities as $entity) { + if (method_exists($entity, 'getId')) { + $id = $entity->getId(); + if ($id !== null) { + $ids[] = $id; + } + } + } + + $uniqueIds = array_unique($ids); + sort($uniqueIds); + + return array_values($uniqueIds); + } + protected function getUserInfo(): string { if (app()->runningInConsole()) { diff --git a/app/Audit/AuditEventListener.php b/app/Audit/AuditEventListener.php index b6cf8baf2..f70e48b7a 100644 --- a/app/Audit/AuditEventListener.php +++ b/app/Audit/AuditEventListener.php @@ -14,6 +14,8 @@ use App\Audit\Interfaces\IAuditStrategy; use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\PersistentCollection; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; @@ -53,10 +55,14 @@ public function onFlush(OnFlushEventArgs $eventArgs): void foreach ($uow->getScheduledEntityDeletions() as $entity) { $strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx); } - + foreach ($uow->getScheduledCollectionDeletions() as $col) { + $this->auditCollection($col, $strategy, $ctx, $uow, true); + } foreach ($uow->getScheduledCollectionUpdates() as $col) { - $strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx); + $this->auditCollection($col, $strategy, $ctx, $uow, false); } + + } catch (\Exception $e) { Log::error('Audit event listener failed', [ 'error' => $e->getMessage(), @@ -127,4 +133,41 @@ private function buildAuditContext(): AuditContext rawRoute: $rawRoute ); } + + + /** + * Audit collection changes + * Only determines if it's ManyToMany and emits appropriate event + */ + private function auditCollection($subject, IAuditStrategy $strategy, AuditContext $ctx, $uow, bool $isDeletion = false): void + { + if (!$subject instanceof PersistentCollection) { + return; + } + + $mapping = $subject->getMapping(); + $isManyToMany = ($mapping['type'] ?? null) === ClassMetadata::MANY_TO_MANY; + + if ($isManyToMany && empty($mapping['isOwningSide'])) { + return; + } + + $owner = $subject->getOwner(); + if ($owner === null) { + return; + } + + + $payload = [ + 'collection' => $subject, + 'uow' => $uow, + 'is_deletion' => $isDeletion, + ]; + + $eventType = $isManyToMany + ? ($isDeletion ? IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE : IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE) + : IAuditStrategy::EVENT_COLLECTION_UPDATE; + + $strategy->audit($owner, $payload, $eventType, $ctx); + } } diff --git a/app/Audit/AuditLogFormatterFactory.php b/app/Audit/AuditLogFormatterFactory.php index a66d2032b..42923e0c6 100644 --- a/app/Audit/AuditLogFormatterFactory.php +++ b/app/Audit/AuditLogFormatterFactory.php @@ -107,6 +107,14 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo $formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter); break; + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + $formatter = $this->getFormatterByContext($subject, $event_type, $ctx); + if(is_null($formatter)) { + $child_entity_formatter = ChildEntityFormatterFactory::build($subject); + $formatter = $child_entity_formatter; + } + break; case IAuditStrategy::EVENT_ENTITY_CREATION: $formatter = $this->getFormatterByContext($subject, $event_type, $ctx); if(is_null($formatter)) { diff --git a/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php index 1e092a621..28a733ae4 100644 --- a/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php @@ -42,6 +42,12 @@ public function format($subject, array $change_set): ?string case IAuditStrategy::EVENT_ENTITY_DELETION: return sprintf("Attendee (%s) '%s' deleted by user %s", $id, $name, $this->getUserInfo()); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + return $this->handleManyToManyCollection($subject, $change_set, $id, $name, false); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + return $this->handleManyToManyCollection($subject, $change_set, $id, $name, true); } } catch (\Exception $ex) { Log::warning("SummitAttendeeAuditLogFormatter error: " . $ex->getMessage()); @@ -49,4 +55,72 @@ public function format($subject, array $change_set): ?string return null; } + + private function handleManyToManyCollection(SummitAttendee $subject, array $change_set, $id, $name, bool $isDeletion): ?string + { + if (!isset($change_set['collection']) || !isset($change_set['uow'])) { + return null; + } + + $col = $change_set['collection']; + $uow = $change_set['uow']; + + $collectionData = $this->processCollection($subject, $col, $uow, $isDeletion); + if (!$collectionData) { + return null; + } + + return $isDeletion + ? $this->formatManyToManyDelete($subject, $collectionData, $id, $name) + : $this->formatManyToManyUpdate($subject, $collectionData, $id, $name); + } + + private function formatManyToManyUpdate(SummitAttendee $subject, array $collectionData, $id, $name): ?string + { + try { + $field = $collectionData['field'] ?? 'unknown'; + $targetEntity = $collectionData['target_entity'] ?? 'unknown'; + $added_ids = $collectionData['added_ids'] ?? []; + + $ownerId = $subject->getId(); + + $description = sprintf( + "Attendee (%s), Field: %s, Target: %s, Added IDs: %s, by user %s", + $ownerId, + $field, + class_basename($targetEntity), + json_encode($added_ids), + $this->getUserInfo() + ); + + return $description; + + } catch (\Exception $ex) { + Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyUpdate error: " . $ex->getMessage()); + return sprintf("Attendee (%s) '%s' association updated by user %s", $id, $name, $this->getUserInfo()); + } + } + + private function formatManyToManyDelete(SummitAttendee $subject, array $collectionData, $id, $name): ?string + { + try { + $field = $collectionData['field'] ?? 'unknown'; + $targetEntity = $collectionData['target_entity'] ?? 'unknown'; + $removed_ids = $collectionData['removed_ids'] ?? []; + + $description = sprintf( + "Attendee Delete: Field: %s, Target: %s, Cleared IDs: %s, by user %s", + $field, + class_basename($targetEntity), + json_encode($removed_ids), + $this->getUserInfo() + ); + + return $description; + + } catch (\Exception $ex) { + Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyDelete error: " . $ex->getMessage()); + return sprintf("Attendee (%s) '%s' association deleted by user %s", $id, $name, $this->getUserInfo()); + } + } } diff --git a/app/Audit/Interfaces/IAuditStrategy.php b/app/Audit/Interfaces/IAuditStrategy.php index 4ed0a1a9c..39057302b 100644 --- a/app/Audit/Interfaces/IAuditStrategy.php +++ b/app/Audit/Interfaces/IAuditStrategy.php @@ -33,6 +33,8 @@ public function audit($subject, array $change_set, string $event_type, AuditCont public const EVENT_ENTITY_CREATION = 'event_entity_creation'; public const EVENT_ENTITY_DELETION = 'event_entity_deletion'; public const EVENT_ENTITY_UPDATE = 'event_entity_update'; + public const EVENT_COLLECTION_MANYTOMANY_UPDATE = 'event_collection_manytomany_update'; + public const EVENT_COLLECTION_MANYTOMANY_DELETE = 'event_collection_manytomany_delete'; public const ACTION_CREATE = 'create'; public const ACTION_UPDATE = 'update'; diff --git a/tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php b/tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php new file mode 100644 index 000000000..3aad1a807 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php @@ -0,0 +1,287 @@ +mockSubject = $this->createMockSubject(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function createMockSubject(): mixed + { + $mock = Mockery::mock('models\summit\SummitAttendee'); + $mock->shouldReceive('getId')->andReturn(self::ATTENDEE_ID); + $mock->shouldReceive('getFirstName')->andReturn(self::ATTENDEE_FIRST_NAME); + $mock->shouldReceive('getSurname')->andReturn(self::ATTENDEE_LAST_NAME); + return $mock; + } + + /** + * Test many-to-many association update with added IDs + */ + public function testManyToManyAssociationUpdateWithAddedIds(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $changeSet = $this->buildCollectionChangeSet( + added: [100, 101, 102], + removed: [], + isDeletion: false + ); + + $result = $formatter->format($this->mockSubject, $changeSet); + + $this->assertNotNull($result); + $this->assertStringContainsString('Attendee', $result); + $this->assertStringContainsString('Field:', $result); + $this->assertStringContainsString('schedules', $result); + $this->assertStringContainsString('Added IDs', $result); + } + + /** + * Test many-to-many association deletion with removed IDs + */ + public function testManyToManyAssociationDeleteWithRemovedIds(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $changeSet = $this->buildCollectionChangeSet( + added: [], + removed: [200, 201], + isDeletion: true + ); + + $result = $formatter->format($this->mockSubject, $changeSet); + + $this->assertNotNull($result); + $this->assertStringContainsString('Attendee Delete', $result); + $this->assertStringContainsString('Field:', $result); + $this->assertStringContainsString('presentations', $result); + $this->assertStringContainsString('Cleared IDs', $result); + } + + /** + * Test many-to-many update preserves correct formatting + */ + public function testCollectionUpdateWithTags(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $changeSet = $this->buildCollectionChangeSet( + added: [100, 101], + removed: [], + isDeletion: false + ); + + $result = $formatter->format($this->mockSubject, $changeSet); + + $this->assertNotNull($result); + $this->assertStringContainsString('Attendee', $result); + $this->assertStringContainsString('Field:', $result); + $this->assertStringContainsString('SummitMemberSchedule', $result); + } + + /** + * Test backward compatibility - entity creation + */ + public function testStandardEntityCreationStillWorks(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_ENTITY_CREATION + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $result = $formatter->format($this->mockSubject, []); + + $this->assertNotNull($result); + $this->assertStringContainsString('created', $result); + } + + /** + * Test backward compatibility - entity update + */ + public function testStandardEntityUpdateStillWorks(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_ENTITY_UPDATE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $changeSet = ['first_name' => [self::ATTENDEE_FIRST_NAME, 'Jane']]; + + $result = $formatter->format($this->mockSubject, $changeSet); + + $this->assertNotNull($result); + $this->assertStringContainsString('updated', $result); + } + + /** + * Test backward compatibility - entity deletion + */ + public function testStandardEntityDeletionStillWorks(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_ENTITY_DELETION + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $result = $formatter->format($this->mockSubject, []); + + $this->assertNotNull($result); + $this->assertStringContainsString('deleted', $result); + } + + /** + * Test formatter returns null for invalid subject type + */ + public function testFormatterReturnsNullForInvalidSubject(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $result = $formatter->format(new \stdClass(), []); + + $this->assertNull($result); + } + + /** + * Helper method to build collection change set with mocked collection and UnitOfWork + */ + private function buildCollectionChangeSet(array $added = [], array $removed = [], bool $isDeletion = false): array + { + $fieldName = $isDeletion ? 'presentations' : 'schedules'; + $targetEntity = $isDeletion ? self::PRESENTATION_MODEL : self::SCHEDULE_MODEL; + + // Mock the mapping data + $mapping = [ + 'fieldName' => $fieldName, + 'targetEntity' => $targetEntity, + 'type' => ClassMetadata::MANY_TO_MANY, + 'joinTable' => [ + 'name' => $isDeletion ? 'attendee_presentations' : 'attendee_schedules', + ], + ]; + + // Create concrete entity objects with getId() method (avoids method_exists issues with Mockery) + $addedEntities = array_map(function($id) { + return new class($id) { + public function __construct(private int $id) {} + public function getId(): int + { + return $this->id; + } + }; + }, $added); + + $removedEntities = array_map(function($id) { + return new class($id) { + public function __construct(private int $id) {} + public function getId(): int + { + return $this->id; + } + }; + }, $removed); + + // Create a concrete collection object to ensure is_object() and method_exists() work + $mockCollection = new class($mapping, $addedEntities, $removedEntities, $this->mockSubject) { + public function __construct( + private array $mapping, + private array $addedEntities, + private array $removedEntities, + private mixed $owner + ) {} + + public function getMapping(): array + { + return $this->mapping; + } + + public function getInsertDiff(): array + { + return $this->addedEntities; + } + + public function getDeleteDiff(): array + { + return $this->removedEntities; + } + + public function getOwner(): mixed + { + return $this->owner; + } + + public function toArray(): array + { + return array_merge($this->addedEntities, $this->removedEntities); + } + }; + + // Mock UnitOfWork - return original removed entities for recovery + $mockUow = \Mockery::mock(UnitOfWork::class); + $mockUow->shouldReceive('getOriginalEntityData')->andReturn([ + $fieldName => $removedEntities + ]); + + return [ + 'collection' => $mockCollection, + 'uow' => $mockUow, + 'is_deletion' => $isDeletion, + ]; + } +}