diff --git a/.agents/contracts/module-manifest.schema.json b/.agents/contracts/module-manifest.schema.json index 081e6a6..1b539b9 100644 --- a/.agents/contracts/module-manifest.schema.json +++ b/.agents/contracts/module-manifest.schema.json @@ -89,7 +89,8 @@ "order": { "type": "integer", "default": 100 }, "details_storage": { "type": "string" }, "details_open_active": { "type": "boolean" }, - "base_url": { "type": "string" } + "base_url": { "type": "string" }, + "group": { "type": "string", "description": "Sidebar nav group key for grouping related items under a details/summary section." } } } } @@ -203,6 +204,11 @@ "type": ["string", "null"], "description": "Relative path to SQL migration scripts. Resolved against module basePath.", "default": null + }, + "i18n_path": { + "type": ["string", "null"], + "description": "Relative path to i18n translation files. Resolved against module basePath.", + "default": null } }, "additionalProperties": false diff --git a/lib/App/registerContainer.php b/lib/App/registerContainer.php index 5c2c5ea..4ef62d1 100644 --- a/lib/App/registerContainer.php +++ b/lib/App/registerContainer.php @@ -12,8 +12,10 @@ use MintyPHP\App\Container\Registrars\SettingsRegistrar; use MintyPHP\App\Container\Registrars\UserRegistrar; use MintyPHP\App\Module\ModuleEventDispatcher; use MintyPHP\App\Module\ModuleRegistry; +use MintyPHP\Service\Audit\AuditMetadataEnricherInterface; use MintyPHP\Service\Audit\AuditRecorderInterface; use MintyPHP\Service\Audit\ImportAuditInterface; +use MintyPHP\Service\Audit\NullAuditMetadataEnricher; use MintyPHP\Service\Audit\NullAuditRecorder; use MintyPHP\Service\Audit\NullImportAudit; use MintyPHP\Service\Audit\NullUserLifecycleAudit; @@ -82,5 +84,8 @@ if (!$container->has(UserLifecycleAuditInterface::class)) { if (!$container->has(ImportAuditInterface::class)) { $container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit()); } +if (!$container->has(AuditMetadataEnricherInterface::class)) { + $container->set(AuditMetadataEnricherInterface::class, static fn (): AuditMetadataEnricherInterface => new NullAuditMetadataEnricher()); +} return $container; diff --git a/lib/Service/Audit/AuditMetadataEnricherInterface.php b/lib/Service/Audit/AuditMetadataEnricherInterface.php new file mode 100644 index 0000000..91f069a --- /dev/null +++ b/lib/Service/Audit/AuditMetadataEnricherInterface.php @@ -0,0 +1,21 @@ + $entity The entity array (modified in place). + * @param list $fields Audit field names to resolve (e.g. 'created_by', 'modified_by'). + */ + public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void; +} diff --git a/lib/Service/Audit/NullAuditMetadataEnricher.php b/lib/Service/Audit/NullAuditMetadataEnricher.php new file mode 100644 index 0000000..8981589 --- /dev/null +++ b/lib/Service/Audit/NullAuditMetadataEnricher.php @@ -0,0 +1,14 @@ + 0) { - $label = trim($label . ' — ' . $statusCode); - } - if ($label === '' && $requestId !== '') { - $label = $requestId; - } - $url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); - } elseif ($key === 'system-audit') { - $eventType = trim((string) ($data['event_type'] ?? '')); - $outcome = strtolower(trim((string) ($data['outcome'] ?? ''))); - $requestId = trim((string) ($data['request_id'] ?? '')); - $label = $eventType !== '' ? $eventType : $requestId; - if ($outcome !== '') { - $label = trim($label . ' — ' . $outcome); - } - $url = lurl('admin/system-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); } elseif ($key === 'pages') { $label = (string) ($data['slug'] ?? ''); $url = lurl('page/' . $label) . '?search=' . urlencode($query); @@ -106,24 +84,6 @@ class SearchItemMapperProvider $toEmail = trim((string) ($data['to_email'] ?? '')); $description = trim(($status !== '' ? strtoupper($status) : '') . ($toEmail !== '' ? ' — ' . $toEmail : '')); $url = lurl('admin/mail-log/view/' . ($data['id'] ?? '')); - } elseif ($key === 'api-audit') { - $method = strtoupper(trim((string) ($data['method'] ?? ''))); - $path = trim((string) ($data['path'] ?? '')); - $statusCode = (int) ($data['status_code'] ?? 0); - $requestId = trim((string) ($data['request_id'] ?? '')); - $title = trim($method . ' ' . $path); - if ($title === '') { - $title = $requestId; - } - $description = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : '')); - $url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')); - } elseif ($key === 'system-audit') { - $eventType = trim((string) ($data['event_type'] ?? '')); - $outcome = strtolower(trim((string) ($data['outcome'] ?? ''))); - $requestId = trim((string) ($data['request_id'] ?? '')); - $title = $eventType !== '' ? $eventType : $requestId; - $description = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : '')); - $url = lurl('admin/system-audit/view/' . ($data['id'] ?? '')); } else { $title = (string) ($data['description'] ?? ''); $description = $label; diff --git a/lib/Support/Search/SearchSqlResourceProvider.php b/lib/Support/Search/SearchSqlResourceProvider.php index a621cf2..d08dfdb 100644 --- a/lib/Support/Search/SearchSqlResourceProvider.php +++ b/lib/Support/Search/SearchSqlResourceProvider.php @@ -110,28 +110,6 @@ class SearchSqlResourceProvider 'resultSql' => "select id, to_email, subject, status, created_at from mail_log where (to_email like ? escape '\\\\' or subject like ? escape '\\\\' or template like ? escape '\\\\' or provider_message_id like ? escape '\\\\') order by created_at desc", 'resultParams' => [$like, $like, $like, $like], ], - [ - 'key' => 'api-audit', - 'label' => t('API audit'), - 'permission' => 'api_audit.view', - 'countSql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')", - 'countParams' => [$like, $like, $like, $like], - 'previewSql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc limit ?", - 'previewParams' => [$like, $like, $like, $like], - 'resultSql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc", - 'resultParams' => [$like, $like, $like, $like], - ], - [ - 'key' => 'system-audit', - 'label' => t('System audit'), - 'permission' => 'system_audit.view', - 'countSql' => "select count(*) from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\')", - 'countParams' => [$like, $like, $like, $like], - 'previewSql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc limit ?", - 'previewParams' => [$like, $like, $like, $like], - 'resultSql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc", - 'resultParams' => [$like, $like, $like, $like], - ], ]; } diff --git a/lib/Support/Search/SearchUiMetaProvider.php b/lib/Support/Search/SearchUiMetaProvider.php index f627333..ca82c52 100644 --- a/lib/Support/Search/SearchUiMetaProvider.php +++ b/lib/Support/Search/SearchUiMetaProvider.php @@ -13,8 +13,6 @@ class SearchUiMetaProvider 'scheduled-jobs' => 'bi-calendar-check', 'pages' => 'bi-file-text', 'mail-log' => 'bi-envelope-paper', - 'api-audit' => 'bi-shield-check', - 'system-audit' => 'bi-journal-lock', 'docs' => 'bi-journal-text', ]; @@ -27,8 +25,6 @@ class SearchUiMetaProvider 'scheduled-jobs', 'pages', 'mail-log', - 'api-audit', - 'system-audit', ]; public static function iconForKey(string $key): string @@ -49,8 +45,6 @@ class SearchUiMetaProvider 'scheduled-jobs' => lurl('admin/scheduled-jobs') . '?search=' . $encoded, 'pages' => lurl(''), 'mail-log' => lurl('admin/mail-log') . '?search=' . $encoded, - 'api-audit' => lurl('admin/api-audit') . '?search=' . $encoded, - 'system-audit' => lurl('admin/system-audit') . '?search=' . $encoded, default => lurl(''), }; } diff --git a/modules/audit/lib/Module/Audit/AuditAuthorizationPolicy.php b/modules/audit/lib/Module/Audit/AuditAuthorizationPolicy.php index af46669..c99da49 100644 --- a/modules/audit/lib/Module/Audit/AuditAuthorizationPolicy.php +++ b/modules/audit/lib/Module/Audit/AuditAuthorizationPolicy.php @@ -9,17 +9,21 @@ use MintyPHP\Service\Access\PermissionService; final class AuditAuthorizationPolicy implements AuthorizationPolicyInterface { public const ABILITY_API_AUDIT_VIEW = 'audit.api.view'; + public const ABILITY_API_AUDIT_PURGE = 'audit.api.purge'; public const ABILITY_SYSTEM_AUDIT_VIEW = 'audit.system.view'; public const ABILITY_SYSTEM_AUDIT_PURGE = 'audit.system.purge'; public const ABILITY_IMPORTS_AUDIT_VIEW = 'audit.imports.view'; + public const ABILITY_IMPORTS_AUDIT_PURGE = 'audit.imports.purge'; public const ABILITY_USER_LIFECYCLE_VIEW = 'audit.user_lifecycle.view'; public const ABILITY_USER_LIFECYCLE_RESTORE = 'audit.user_lifecycle.restore'; private const ABILITY_PERMISSION_MAP = [ self::ABILITY_API_AUDIT_VIEW => 'api_audit.view', + self::ABILITY_API_AUDIT_PURGE => 'audit.api.purge', self::ABILITY_SYSTEM_AUDIT_VIEW => 'system_audit.view', self::ABILITY_SYSTEM_AUDIT_PURGE => 'system_audit.purge', self::ABILITY_IMPORTS_AUDIT_VIEW => 'imports.audit.view', + self::ABILITY_IMPORTS_AUDIT_PURGE => 'audit.imports.purge', self::ABILITY_USER_LIFECYCLE_VIEW => 'user_lifecycle_audit.view', self::ABILITY_USER_LIFECYCLE_RESTORE => 'users.lifecycle_restore', ]; diff --git a/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php b/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php index 685ad5b..ef1218f 100644 --- a/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php +++ b/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php @@ -12,16 +12,17 @@ use MintyPHP\Module\Audit\Handler\SystemAuditPurgeJobHandler; use MintyPHP\Module\Audit\Handler\UserLifecycleAuditPurgeJobHandler; use MintyPHP\Module\Audit\Http\ApiSystemAuditReporter; use MintyPHP\Module\Audit\Providers\AuditLayoutProvider; -use MintyPHP\Module\Audit\Service\AuditRepositoryFactory; -use MintyPHP\Module\Audit\Service\AuditServicesFactory; use MintyPHP\Module\Audit\Service\ApiAuditService; use MintyPHP\Module\Audit\Service\AuditMetadataEnricher; +use MintyPHP\Module\Audit\Service\AuditRepositoryFactory; +use MintyPHP\Module\Audit\Service\AuditServicesFactory; use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService; use MintyPHP\Module\Audit\Service\ImportAuditService; use MintyPHP\Module\Audit\Service\SystemAuditService; use MintyPHP\Module\Audit\Service\UserLifecycleAuditService; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Audit\AuditMetadataEnricherInterface; use MintyPHP\Service\Audit\AuditRecorderInterface; use MintyPHP\Service\Audit\ImportAuditInterface; use MintyPHP\Service\Audit\UserLifecycleAuditInterface; @@ -74,6 +75,7 @@ final class AuditContainerRegistrar implements ContainerRegistrar $container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher( $c->get(UserReadRepository::class) )); + $container->set(AuditMetadataEnricherInterface::class, static fn (AppContainer $c): AuditMetadataEnricherInterface => $c->get(AuditMetadataEnricher::class)); // Authorization policy $container->set(AuditAuthorizationPolicy::class, static fn (AppContainer $c): AuditAuthorizationPolicy => new AuditAuthorizationPolicy( diff --git a/modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php b/modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php index 0542089..5f8729d 100644 --- a/modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php +++ b/modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php @@ -32,16 +32,12 @@ class ApiSystemAuditReporter RequestContext::ensureStarted(); $requestContext = RequestContext::context(); - $method = strtoupper(trim((string) ($requestContext['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? 'GET')))); + $method = strtoupper(trim((string) ($requestContext['method'] ?? 'GET'))); if ($method === '') { $method = 'GET'; } $path = trim((string) ($requestContext['path'] ?? '')); - if ($path === '') { - $fallbackPath = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); - $path = is_string($fallbackPath) ? trim($fallbackPath) : ''; - } $this->context = [ 'started_at' => microtime(true), diff --git a/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php b/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php new file mode 100644 index 0000000..26f6376 --- /dev/null +++ b/modules/audit/lib/Module/Audit/Providers/AuditSearchResourceProvider.php @@ -0,0 +1,107 @@ + [ + 'label' => t('API audit'), + 'permission' => 'api_audit.view', + 'count_sql' => "select count(*) from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\')", + 'preview_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc limit ?", + 'result_sql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc", + ], + 'system-audit' => [ + 'label' => t('System audit'), + 'permission' => 'system_audit.view', + 'count_sql' => "select count(*) from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\')", + 'preview_sql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc limit ?", + 'result_sql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc", + ], + ]; + } + + public function mapResultItem(string $resourceKey, array $row): ?array + { + if ($resourceKey === 'api-audit') { + return $this->mapApiAuditItem($row); + } + + if ($resourceKey === 'system-audit') { + return $this->mapSystemAuditItem($row); + } + + return null; + } + + public function listUrl(string $resourceKey, string $encodedSearch): string + { + return match ($resourceKey) { + 'api-audit' => lurl('admin/api-audit') . '?search=' . $encodedSearch, + 'system-audit' => lurl('admin/system-audit') . '?search=' . $encodedSearch, + default => '', + }; + } + + /** + * @param array $row + * @return array{title: string, subtitle: string, url: string, icon: string}|null + */ + private function mapApiAuditItem(array $row): ?array + { + $method = strtoupper(trim((string) ($row['method'] ?? ''))); + $path = trim((string) ($row['path'] ?? '')); + $statusCode = (int) ($row['status_code'] ?? 0); + $requestId = trim((string) ($row['request_id'] ?? '')); + + $title = trim($method . ' ' . $path); + if ($title === '') { + $title = $requestId; + } + if ($title === '') { + return null; + } + + $subtitle = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : '')); + + return [ + 'title' => $title, + 'subtitle' => $subtitle, + 'url' => lurl('admin/api-audit/view/' . ($row['id'] ?? '')), + 'icon' => 'bi-shield-check', + ]; + } + + /** + * @param array $row + * @return array{title: string, subtitle: string, url: string, icon: string}|null + */ + private function mapSystemAuditItem(array $row): ?array + { + $eventType = trim((string) ($row['event_type'] ?? '')); + $outcome = strtolower(trim((string) ($row['outcome'] ?? ''))); + $requestId = trim((string) ($row['request_id'] ?? '')); + + $title = $eventType !== '' ? $eventType : $requestId; + if ($title === '') { + return null; + } + + $subtitle = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : '')); + + return [ + 'title' => $title, + 'subtitle' => $subtitle, + 'url' => lurl('admin/system-audit/view/' . ($row['id'] ?? '')), + 'icon' => 'bi-journal-lock', + ]; + } +} diff --git a/modules/audit/lib/Module/Audit/Service/AuditMetadataEnricher.php b/modules/audit/lib/Module/Audit/Service/AuditMetadataEnricher.php index 3727dc0..bbd859a 100644 --- a/modules/audit/lib/Module/Audit/Service/AuditMetadataEnricher.php +++ b/modules/audit/lib/Module/Audit/Service/AuditMetadataEnricher.php @@ -3,8 +3,9 @@ namespace MintyPHP\Module\Audit\Service; use MintyPHP\Repository\User\UserReadRepositoryInterface; +use MintyPHP\Service\Audit\AuditMetadataEnricherInterface; -class AuditMetadataEnricher +class AuditMetadataEnricher implements AuditMetadataEnricherInterface { public function __construct( private readonly UserReadRepositoryInterface $userReadRepository diff --git a/modules/audit/lib/Module/Audit/Service/FrontendTelemetryIngestService.php b/modules/audit/lib/Module/Audit/Service/FrontendTelemetryIngestService.php index 98ae028..e4c6fce 100644 --- a/modules/audit/lib/Module/Audit/Service/FrontendTelemetryIngestService.php +++ b/modules/audit/lib/Module/Audit/Service/FrontendTelemetryIngestService.php @@ -13,7 +13,7 @@ use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway; class FrontendTelemetryIngestService { private const INGEST_RATE_SCOPE = 'frontend.telemetry.ingest'; - private const SESSION_STATE_KEY = 'frontend_telemetry_state'; + private const SESSION_STATE_KEY = 'module.audit.frontend_telemetry_state'; private const INGEST_MAX_HITS = 80; private const INGEST_WINDOW_SECONDS = 300; diff --git a/modules/audit/module.php b/modules/audit/module.php index 805dc6c..d852116 100644 --- a/modules/audit/module.php +++ b/modules/audit/module.php @@ -99,10 +99,18 @@ return [ 'key' => 'audit.system.purge', 'description' => 'Purge system audit logs', ], + [ + 'key' => 'audit.api.purge', + 'description' => 'Purge API audit logs', + ], [ 'key' => 'audit.imports.view', 'description' => 'View import audit logs', ], + [ + 'key' => 'audit.imports.purge', + 'description' => 'Purge import audit logs', + ], [ 'key' => 'audit.user_lifecycle.view', 'description' => 'View user lifecycle audit logs', @@ -178,7 +186,9 @@ return [ 'session_providers' => [], 'event_listeners' => [], - 'search_resources' => [], + 'search_resources' => [ + \MintyPHP\Module\Audit\Providers\AuditSearchResourceProvider::class, + ], 'asset_groups' => [], 'migrations_path' => 'db/updates', 'i18n_path' => 'i18n', diff --git a/modules/audit/pages/audit/api-audit/purge().php b/modules/audit/pages/audit/api-audit/purge().php index d8e5f07..66716d3 100644 --- a/modules/audit/pages/audit/api-audit/purge().php +++ b/modules/audit/pages/audit/api-audit/purge().php @@ -6,7 +6,7 @@ use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE); +Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_API_AUDIT_PURGE); if (strtoupper((string) (requestInput()->method())) !== 'POST') { Router::redirect('audit/api-audit'); diff --git a/modules/audit/pages/audit/api-audit/view($id).php b/modules/audit/pages/audit/api-audit/view($id).php index 74381f3..e311cb8 100644 --- a/modules/audit/pages/audit/api-audit/view($id).php +++ b/modules/audit/pages/audit/api-audit/view($id).php @@ -11,7 +11,7 @@ Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_A $auditId = (int) ($id ?? 0); $auditLog = $auditId > 0 ? app(\MintyPHP\Module\Audit\Service\ApiAuditService::class)->find($auditId) : null; if (!$auditLog) { - Flash::error('API audit entry not found', 'audit/api-audit', 'api_audit_not_found'); + Flash::error(t('API audit entry not found'), 'audit/api-audit', 'api_audit_not_found'); Router::redirect('audit/api-audit'); } diff --git a/modules/audit/pages/audit/import-audit/purge().php b/modules/audit/pages/audit/import-audit/purge().php index ae0a9ec..fdc8317 100644 --- a/modules/audit/pages/audit/import-audit/purge().php +++ b/modules/audit/pages/audit/import-audit/purge().php @@ -7,7 +7,7 @@ use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requireAbility(\MintyPHP\Service\Access\SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE); +Guard::requireAbility(\MintyPHP\Module\Audit\AuditAuthorizationPolicy::ABILITY_IMPORTS_AUDIT_PURGE); if (strtoupper((string) (requestInput()->method())) !== 'POST') { Router::redirect('audit/import-audit'); diff --git a/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php b/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php new file mode 100644 index 0000000..f033046 --- /dev/null +++ b/modules/audit/tests/Module/Audit/Providers/AuditSearchResourceProviderTest.php @@ -0,0 +1,158 @@ +provider = new AuditSearchResourceProvider(); + } + + public function testResourcesReturnsBothAuditKeys(): void + { + $resources = $this->provider->resources(); + + $this->assertArrayHasKey('api-audit', $resources); + $this->assertArrayHasKey('system-audit', $resources); + $this->assertCount(2, $resources); + } + + public function testResourcesContainRequiredSqlKeys(): void + { + foreach ($this->provider->resources() as $key => $resource) { + $this->assertArrayHasKey('label', $resource, "Resource '{$key}' missing label"); + $this->assertArrayHasKey('permission', $resource, "Resource '{$key}' missing permission"); + $this->assertArrayHasKey('count_sql', $resource, "Resource '{$key}' missing count_sql"); + $this->assertArrayHasKey('preview_sql', $resource, "Resource '{$key}' missing preview_sql"); + $this->assertArrayHasKey('result_sql', $resource, "Resource '{$key}' missing result_sql"); + } + } + + public function testResourcePermissionKeys(): void + { + $resources = $this->provider->resources(); + + $this->assertSame('api_audit.view', $resources['api-audit']['permission']); + $this->assertSame('system_audit.view', $resources['system-audit']['permission']); + } + + public function testMapApiAuditItem(): void + { + $row = [ + 'id' => 42, + 'method' => 'POST', + 'path' => '/api/v1/users', + 'status_code' => 201, + 'request_id' => 'req-abc', + ]; + + $result = $this->provider->mapResultItem('api-audit', $row); + + $this->assertNotNull($result); + $this->assertSame('POST /api/v1/users', $result['title']); + $this->assertStringContains('201', $result['subtitle']); + $this->assertStringContains('req-abc', $result['subtitle']); + $this->assertSame('bi-shield-check', $result['icon']); + $this->assertStringContainsString('admin/api-audit/view/42', $result['url']); + } + + public function testMapApiAuditItemFallsBackToRequestId(): void + { + $row = ['id' => 1, 'method' => '', 'path' => '', 'status_code' => 0, 'request_id' => 'req-fallback']; + + $result = $this->provider->mapResultItem('api-audit', $row); + + $this->assertNotNull($result); + $this->assertSame('req-fallback', $result['title']); + } + + public function testMapApiAuditItemReturnsNullWhenEmpty(): void + { + $row = ['id' => 1, 'method' => '', 'path' => '', 'status_code' => 0, 'request_id' => '']; + + $result = $this->provider->mapResultItem('api-audit', $row); + + $this->assertNull($result); + } + + public function testMapSystemAuditItem(): void + { + $row = [ + 'id' => 99, + 'event_type' => 'admin.settings.update', + 'outcome' => 'success', + 'request_id' => 'req-xyz', + ]; + + $result = $this->provider->mapResultItem('system-audit', $row); + + $this->assertNotNull($result); + $this->assertSame('admin.settings.update', $result['title']); + $this->assertStringContains('SUCCESS', $result['subtitle']); + $this->assertSame('bi-journal-lock', $result['icon']); + $this->assertStringContainsString('admin/system-audit/view/99', $result['url']); + } + + public function testMapSystemAuditItemFallsBackToRequestId(): void + { + $row = ['id' => 1, 'event_type' => '', 'outcome' => '', 'request_id' => 'req-fallback']; + + $result = $this->provider->mapResultItem('system-audit', $row); + + $this->assertNotNull($result); + $this->assertSame('req-fallback', $result['title']); + } + + public function testMapSystemAuditItemReturnsNullWhenEmpty(): void + { + $row = ['id' => 1, 'event_type' => '', 'outcome' => '', 'request_id' => '']; + + $result = $this->provider->mapResultItem('system-audit', $row); + + $this->assertNull($result); + } + + public function testMapResultItemReturnsNullForUnknownKey(): void + { + $result = $this->provider->mapResultItem('unknown', ['id' => 1]); + + $this->assertNull($result); + } + + public function testListUrlForApiAudit(): void + { + $url = $this->provider->listUrl('api-audit', 'test%20query'); + + $this->assertStringContainsString('admin/api-audit', $url); + $this->assertStringContainsString('search=test%20query', $url); + } + + public function testListUrlForSystemAudit(): void + { + $url = $this->provider->listUrl('system-audit', 'test'); + + $this->assertStringContainsString('admin/system-audit', $url); + $this->assertStringContainsString('search=test', $url); + } + + public function testListUrlReturnsEmptyForUnknownKey(): void + { + $url = $this->provider->listUrl('unknown', 'test'); + + $this->assertSame('', $url); + } + + /** + * Helper to check string containment (mirrors assertStringContainsString for readability). + */ + private static function assertStringContains(string $needle, string $haystack): void + { + self::assertStringContainsString($needle, $haystack); + } +} diff --git a/modules/audit/tests/Module/Audit/Service/FrontendTelemetryIngestServiceTest.php b/modules/audit/tests/Module/Audit/Service/FrontendTelemetryIngestServiceTest.php index ddb6ef6..f4e1a02 100644 --- a/modules/audit/tests/Module/Audit/Service/FrontendTelemetryIngestServiceTest.php +++ b/modules/audit/tests/Module/Audit/Service/FrontendTelemetryIngestServiceTest.php @@ -210,12 +210,12 @@ class FrontendTelemetryIngestServiceTest extends TestCase $sessionStore->expects($this->once())->method('all')->willReturn(['user' => ['id' => 7]]); $sessionStore->expects($this->once()) ->method('get') - ->with('frontend_telemetry_state', []) + ->with('module.audit.frontend_telemetry_state', []) ->willReturn([]); $sessionStore->expects($this->once()) ->method('set') ->with( - 'frontend_telemetry_state', + 'module.audit.frontend_telemetry_state', $this->callback(static function (mixed $value): bool { if (!is_array($value)) { return false; diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index 9ab8c1f..298d330 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -61,7 +61,7 @@ if (!$canViewPage) { return; } -app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($department); +app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($department); $errorBag = formErrors(); $errors = []; diff --git a/pages/admin/roles/edit($id).php b/pages/admin/roles/edit($id).php index ca0d257..08f8fba 100644 --- a/pages/admin/roles/edit($id).php +++ b/pages/admin/roles/edit($id).php @@ -40,7 +40,7 @@ $capabilities = $contextDecision->attribute('capabilities', []); $canUpdateRole = is_array($capabilities) ? (bool) ($capabilities['can_edit_role'] ?? false) : false; $canDeleteRole = is_array($capabilities) ? (bool) ($capabilities['can_delete_role'] ?? false) : false; -app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($role); +app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($role); $errorBag = formErrors(); $errors = []; diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index 1a471f0..aca5d1d 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -69,7 +69,7 @@ $ssoConfig = $canManageSso ? $tenantSsoService->getTenantMicrosoftAuth($tenantId $ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState($tenantId, $ssoConfig) : []; $ldapConfig = $canManageSso ? $tenantSsoService->getTenantLdapAuth($tenantId) : []; $ldapUiState = $canManageSso ? $tenantSsoService->buildLdapUiState($tenantId) : []; -app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']); +app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']); $errorBag = formErrors(); $errors = []; diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 40d4117..d8f1b0e 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -86,7 +86,7 @@ if (!$canViewPage) { } if ($canViewUserMeta || $canViewUserAudit) { - app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($user, ['created_by', 'modified_by', 'active_changed_by']); + app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($user, ['created_by', 'modified_by', 'active_changed_by']); } $errorBag = formErrors(); diff --git a/pages/api/v1/settings/index().php b/pages/api/v1/settings/index().php index 09d29e2..13b3524 100644 --- a/pages/api/v1/settings/index().php +++ b/pages/api/v1/settings/index().php @@ -3,7 +3,7 @@ use MintyPHP\Http\ApiAuth; use MintyPHP\Http\ApiBootstrap; use MintyPHP\Http\ApiResponse; -use MintyPHP\Service\Audit\SystemAuditService; +use MintyPHP\Service\Audit\AuditRecorderInterface; use MintyPHP\Service\Settings\SettingsApiPolicyGateway; use MintyPHP\Service\Settings\SettingsAppGateway; use MintyPHP\Service\Settings\SettingsDefaultsGateway; @@ -103,7 +103,7 @@ if ($method === 'PUT' || $method === 'PATCH') { } if ($errors->hasAny()) { - app(SystemAuditService::class)->record('admin.settings.update', 'failed', [ + app(AuditRecorderInterface::class)->record('admin.settings.update', 'failed', [ 'actor_user_id' => ApiAuth::userId(), 'error_code' => 'validation_error', 'metadata' => ['errors' => array_keys($errors->toArray())], @@ -121,7 +121,7 @@ if ($method === 'PUT' || $method === 'PATCH') { $settingsMicrosoftGateway, $settingsSmtpGateway, ); - app(SystemAuditService::class)->record('admin.settings.update', 'success', [ + app(AuditRecorderInterface::class)->record('admin.settings.update', 'success', [ 'actor_user_id' => ApiAuth::userId(), 'before' => $beforeSettings, 'after' => $afterSettings, diff --git a/tests/Architecture/AuditModuleIsolationContractTest.php b/tests/Architecture/AuditModuleIsolationContractTest.php index a65e830..ee425e7 100644 --- a/tests/Architecture/AuditModuleIsolationContractTest.php +++ b/tests/Architecture/AuditModuleIsolationContractTest.php @@ -17,8 +17,10 @@ final class AuditModuleIsolationContractTest extends TestCase )); $allowed = [ + 'AuditMetadataEnricherInterface.php', 'AuditRecorderInterface.php', 'ImportAuditInterface.php', + 'NullAuditMetadataEnricher.php', 'NullAuditRecorder.php', 'NullImportAudit.php', 'NullUserLifecycleAudit.php', diff --git a/tests/Service/Audit/NullAuditMetadataEnricherTest.php b/tests/Service/Audit/NullAuditMetadataEnricherTest.php new file mode 100644 index 0000000..f5dc411 --- /dev/null +++ b/tests/Service/Audit/NullAuditMetadataEnricherTest.php @@ -0,0 +1,32 @@ + 10, 'modified_by' => 20, 'name' => 'Test']; + + $enricher->enrich($entity); + + $this->assertSame(['created_by' => 10, 'modified_by' => 20, 'name' => 'Test'], $entity); + $this->assertArrayNotHasKey('created_by_label', $entity); + $this->assertArrayNotHasKey('modified_by_label', $entity); + } + + public function testEnrichWithCustomFieldsDoesNotModifyEntity(): void + { + $enricher = new NullAuditMetadataEnricher(); + $entity = ['status_changed_by' => 5]; + + $enricher->enrich($entity, ['status_changed_by']); + + $this->assertSame(['status_changed_by' => 5], $entity); + $this->assertArrayNotHasKey('status_changed_by_label', $entity); + } +}