diff --git a/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php b/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php index a55b904..d4b193c 100644 --- a/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php +++ b/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php @@ -23,6 +23,7 @@ use MintyPHP\Module\Audit\Service\AuditMetadataEnricher; use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService; use MintyPHP\Module\Audit\Service\ImportAuditService; use MintyPHP\Module\Audit\Service\SystemAuditRedactionService; +use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter; use MintyPHP\Module\Audit\Service\SystemAuditService; use MintyPHP\Module\Audit\Service\UserLifecycleAuditService; use MintyPHP\Repository\User\UserReadRepository; @@ -47,6 +48,7 @@ final class AuditContainerRegistrar implements ContainerRegistrar // Redaction $container->set(SystemAuditRedactionService::class, static fn (): SystemAuditRedactionService => new SystemAuditRedactionService()); + $container->set(SystemAuditRowPresenter::class, static fn (): SystemAuditRowPresenter => new SystemAuditRowPresenter()); // Concrete audit services $container->set(SystemAuditService::class, static fn (AppContainer $c): SystemAuditService => new SystemAuditService( diff --git a/modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php b/modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php new file mode 100644 index 0000000..dbd785a --- /dev/null +++ b/modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php @@ -0,0 +1,86 @@ + $row + * @return array{ + * id: int, + * created_at: string, + * event_type: string, + * outcome: string, + * outcome_badge: string, + * outcome_label: string, + * channel: string, + * channel_label: string, + * actor_user_id: int, + * actor_user_uuid: string, + * actor_user_label: string, + * actor_user_email: string, + * target_type: string, + * target_uuid: string, + * request_id: string, + * error_code: string + * } + */ + public function present(array $row): array + { + $outcome = SystemAuditOutcome::normalizeOr((string) ($row['outcome'] ?? ''), SystemAuditOutcome::Success); + $channel = SystemAuditChannel::normalizeOr((string) ($row['channel'] ?? ''), SystemAuditChannel::Web); + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'created_at' => (string) dt((string) ($row['created_at'] ?? '')), + 'event_type' => (string) ($row['event_type'] ?? ''), + 'outcome' => $outcome->value, + 'outcome_badge' => $outcome->badgeVariant(), + 'outcome_label' => (string) t($outcome->labelToken()), + 'channel' => strtoupper($channel->labelToken()), + 'channel_label' => strtoupper($channel->labelToken()), + 'actor_user_id' => (int) ($row['actor_user_id'] ?? 0), + 'actor_user_uuid' => (string) ($row['actor_user_uuid'] ?? ''), + 'actor_user_label' => $this->resolveActorLabel($row), + 'actor_user_email' => (string) ($row['actor_user_email'] ?? ''), + 'target_type' => (string) ($row['target_type'] ?? ''), + 'target_uuid' => (string) ($row['target_uuid'] ?? ''), + 'request_id' => (string) ($row['request_id'] ?? ''), + 'error_code' => (string) ($row['error_code'] ?? ''), + ]; + } + + /** + * @api Called from data and export endpoints + * @param iterable> $rows + * @return list> + */ + public function presentAll(iterable $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[] = $this->present($row); + } + return $result; + } + + private function resolveActorLabel(array $row): string + { + $name = trim((string) ($row['actor_user_display_name'] ?? '')); + if ($name !== '') { + return $name; + } + $email = trim((string) ($row['actor_user_email'] ?? '')); + return $email !== '' ? $email : '-'; + } +} diff --git a/modules/audit/pages/audit/system-audit/data().php b/modules/audit/pages/audit/system-audit/data().php index 3ff5a6b..162c467 100644 --- a/modules/audit/pages/audit/system-audit/data().php +++ b/modules/audit/pages/audit/system-audit/data().php @@ -1,8 +1,7 @@ listPaged($filters); - -$rows = []; -foreach ((array) ($result['rows'] ?? []) as $row) { - $outcome = SystemAuditOutcome::normalizeOr((string) ($row['outcome'] ?? ''), SystemAuditOutcome::Success); - $channel = SystemAuditChannel::normalizeOr((string) ($row['channel'] ?? ''), SystemAuditChannel::Web); - - $actorLabel = trim((string) ($row['actor_user_display_name'] ?? '')); - if ($actorLabel === '') { - $actorLabel = trim((string) ($row['actor_user_email'] ?? '')); - } - - $rows[] = [ - 'id' => (int) ($row['id'] ?? 0), - 'created_at' => dt((string) ($row['created_at'] ?? '')), - 'event_type' => (string) ($row['event_type'] ?? ''), - 'outcome' => $outcome->value, - 'outcome_badge' => $outcome->badgeVariant(), - 'outcome_label' => t($outcome->labelToken()), - 'channel' => strtoupper($channel->labelToken()), - 'actor_user_id' => (int) ($row['actor_user_id'] ?? 0), - 'actor_user_uuid' => (string) ($row['actor_user_uuid'] ?? ''), - 'actor_user_label' => $actorLabel !== '' ? $actorLabel : '-', - 'target_type' => (string) ($row['target_type'] ?? ''), - 'target_uuid' => (string) ($row['target_uuid'] ?? ''), - 'request_id' => (string) ($row['request_id'] ?? ''), - 'error_code' => (string) ($row['error_code'] ?? ''), - ]; -} +$rows = app(SystemAuditRowPresenter::class)->presentAll((array) ($result['rows'] ?? [])); gridJsonDataResult($rows, (int) ($result['total'] ?? 0)); diff --git a/modules/audit/pages/audit/system-audit/export().php b/modules/audit/pages/audit/system-audit/export().php index aeb4651..d8b5bc2 100644 --- a/modules/audit/pages/audit/system-audit/export().php +++ b/modules/audit/pages/audit/system-audit/export().php @@ -1,8 +1,7 @@ query('limit'), 5000); $filters['offset'] = 0; $result = app(SystemAuditService::class)->listPaged($filters); - -$resolveOutcomeLabel = static function (string $raw): string { - $outcome = SystemAuditOutcome::normalizeOr($raw, SystemAuditOutcome::Success); - return (string) t($outcome->labelToken()); -}; - -$resolveChannelLabel = static function (string $raw): string { - $channel = SystemAuditChannel::normalizeOr($raw, SystemAuditChannel::Web); - return strtoupper($channel->labelToken()); -}; - -$resolveActor = static function (array $row): string { - $name = trim((string) ($row['actor_user_display_name'] ?? '')); - if ($name !== '') { - return $name; - } - $email = trim((string) ($row['actor_user_email'] ?? '')); - return $email !== '' ? $email : ''; -}; +$rows = app(SystemAuditRowPresenter::class)->presentAll((array) ($result['rows'] ?? [])); $columns = [ new ExportColumn(t('ID'), static fn (array $r): string => (string) ($r['id'] ?? '')), - new ExportColumn(t('Created'), static fn (array $r): string => (string) dt((string) ($r['created_at'] ?? ''), 'Y-m-d H:i:s')), + new ExportColumn(t('Created'), static fn (array $r): string => (string) ($r['created_at'] ?? '')), new ExportColumn(t('Event'), static fn (array $r): string => (string) ($r['event_type'] ?? '')), - new ExportColumn(t('Status'), static fn (array $r): string => $resolveOutcomeLabel((string) ($r['outcome'] ?? ''))), - new ExportColumn(t('Channel'), static fn (array $r): string => $resolveChannelLabel((string) ($r['channel'] ?? ''))), - new ExportColumn(t('Actor'), static fn (array $r): string => $resolveActor($r)), + new ExportColumn(t('Status'), static fn (array $r): string => (string) ($r['outcome_label'] ?? '')), + new ExportColumn(t('Channel'), static fn (array $r): string => (string) ($r['channel_label'] ?? '')), + new ExportColumn(t('Actor'), static fn (array $r): string => (string) ($r['actor_user_label'] ?? '')), new ExportColumn(t('Actor email'), static fn (array $r): string => (string) ($r['actor_user_email'] ?? '')), new ExportColumn(t('Target type'), static fn (array $r): string => (string) ($r['target_type'] ?? '')), new ExportColumn(t('Target UUID'), static fn (array $r): string => (string) ($r['target_uuid'] ?? '')), @@ -54,4 +35,4 @@ $columns = [ $filename = 'system-audit-' . date('Ymd-His') . '.csv'; -exportSendCsv($filename, (array) ($result['rows'] ?? []), $columns, exportResolveFlavor($request)); +exportSendCsv($filename, $rows, $columns, exportResolveFlavor($request)); diff --git a/modules/audit/tests/Module/Audit/Service/SystemAuditRowPresenterTest.php b/modules/audit/tests/Module/Audit/Service/SystemAuditRowPresenterTest.php new file mode 100644 index 0000000..80938ff --- /dev/null +++ b/modules/audit/tests/Module/Audit/Service/SystemAuditRowPresenterTest.php @@ -0,0 +1,115 @@ +presenter = new SystemAuditRowPresenter(); + } + + public function testPresentNormalizesEnumsAndFillsDefaults(): void + { + $out = $this->presenter->present([ + 'id' => 42, + 'outcome' => 'success', + 'channel' => 'web', + 'event_type' => 'user.login', + ]); + + $this->assertSame(42, $out['id']); + $this->assertSame('success', $out['outcome']); + $this->assertSame('user.login', $out['event_type']); + $this->assertNotSame('', $out['outcome_label'], 'outcome_label should be translated, not empty'); + $this->assertNotSame('', $out['outcome_badge'], 'outcome_badge variant should be set'); + $this->assertSame(strtoupper($out['channel_label']), $out['channel_label'], 'channel label is uppercased'); + $this->assertSame($out['channel'], $out['channel_label'], 'channel and channel_label stay in sync'); + } + + public function testPresentFallsBackToDefaultsForUnknownEnumValues(): void + { + $out = $this->presenter->present([ + 'outcome' => 'not-a-valid-outcome', + 'channel' => 'not-a-valid-channel', + ]); + + $this->assertSame('success', $out['outcome'], 'unknown outcome falls back to success'); + $this->assertNotSame('', $out['channel'], 'unknown channel falls back to web/default'); + } + + public function testActorLabelPrefersDisplayName(): void + { + $out = $this->presenter->present([ + 'actor_user_display_name' => 'Alice Doe', + 'actor_user_email' => 'alice@example.com', + ]); + + $this->assertSame('Alice Doe', $out['actor_user_label']); + $this->assertSame('alice@example.com', $out['actor_user_email']); + } + + public function testActorLabelFallsBackToEmail(): void + { + $out = $this->presenter->present([ + 'actor_user_display_name' => '', + 'actor_user_email' => 'bob@example.com', + ]); + + $this->assertSame('bob@example.com', $out['actor_user_label']); + } + + public function testActorLabelFallsBackToDashWhenNothingAvailable(): void + { + $out = $this->presenter->present([]); + $this->assertSame('-', $out['actor_user_label']); + } + + public function testActorLabelTrimsWhitespace(): void + { + $out = $this->presenter->present([ + 'actor_user_display_name' => " \t ", + 'actor_user_email' => ' carol@example.com ', + ]); + + $this->assertSame('carol@example.com', $out['actor_user_label']); + } + + public function testMissingFieldsAreSafeDefaults(): void + { + $out = $this->presenter->present([]); + + $this->assertSame(0, $out['id']); + $this->assertSame('', $out['event_type']); + $this->assertSame('', $out['target_type']); + $this->assertSame('', $out['target_uuid']); + $this->assertSame('', $out['request_id']); + $this->assertSame('', $out['error_code']); + $this->assertSame(0, $out['actor_user_id']); + } + + public function testPresentAllMapsIterableToList(): void + { + $rows = (function () { + yield ['id' => 1, 'outcome' => 'success']; + yield ['id' => 2, 'outcome' => 'failed']; + })(); + + $out = $this->presenter->presentAll($rows); + + $this->assertCount(2, $out); + $this->assertSame(1, $out[0]['id']); + $this->assertSame(2, $out[1]['id']); + $this->assertSame('failed', $out[1]['outcome']); + } + + public function testPresentAllWithEmptyIterableReturnsEmptyArray(): void + { + $this->assertSame([], $this->presenter->presentAll([])); + } +} diff --git a/tests/Architecture/StatusTaxonomyContractFiles.php b/tests/Architecture/StatusTaxonomyContractFiles.php index b696f07..9b1921d 100644 --- a/tests/Architecture/StatusTaxonomyContractFiles.php +++ b/tests/Architecture/StatusTaxonomyContractFiles.php @@ -62,7 +62,9 @@ final class StatusTaxonomyContractFiles public static function taxonomyDataEndpointFiles(): array { return [ - 'modules/audit/pages/audit/system-audit/data().php', + // System audit: data + export endpoints delegate badge/label resolution + // to SystemAuditRowPresenter, which is the enforced source of truth. + 'modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php', 'modules/audit/pages/audit/user-lifecycle-audit/data().php', 'modules/audit/pages/audit/import-audit/data().php', 'pages/admin/scheduled-jobs/data().php', @@ -79,6 +81,7 @@ final class StatusTaxonomyContractFiles { return [ 'modules/audit/lib/Module/Audit/Service/SystemAuditService.php', + 'modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php', 'modules/audit/lib/Module/Audit/Repository/SystemAuditLogRepository.php', 'modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php', 'modules/audit/lib/Module/Audit/Service/UserLifecycleAuditService.php',