fix(audit): harden module isolation and fix post-extraction drift

Fix 5 broken FQCN references (runtime errors) where core pages referenced
non-existent MintyPHP\Service\Audit\AuditMetadataEnricher and SystemAuditService.
Add AuditMetadataEnricherInterface with NullAuditMetadataEnricher fallback so
deactivating the audit module no longer crashes core edit pages.

Move audit search resources from core Search*Provider files into the module
via a new AuditSearchResourceProvider implementing the existing
SearchResourceProvider contract. Add module-specific purge permissions
(audit.api.purge, audit.imports.purge) replacing core ABILITY_ADMIN_SETTINGS_UPDATE.

Also fixes: session key prefix convention, hardcoded English string, $_SERVER
superglobal fallback, and manifest schema drift (i18n_path, group in ui_slots).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:03:27 +01:00
parent 1acb4d1934
commit 7a222f0b1f
26 changed files with 381 additions and 91 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -0,0 +1,21 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* Core-side contract for enriching entity audit metadata (created_by, modified_by, etc.)
* with user display labels and UUIDs.
*
* Implemented by the audit module's AuditMetadataEnricher when the module is active.
* Falls back to NullAuditMetadataEnricher (no-op) when the audit module is disabled.
*/
interface AuditMetadataEnricherInterface
{
/**
* Resolve audit user IDs on an entity into display labels and UUIDs.
*
* @param array<string,mixed> $entity The entity array (modified in place).
* @param list<string> $fields Audit field names to resolve (e.g. 'created_by', 'modified_by').
*/
public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* No-op metadata enricher used when the audit module is disabled.
*/
final class NullAuditMetadataEnricher implements AuditMetadataEnricherInterface
{
public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void
{
// No-op: audit module is not active.
}
}

View File

@@ -34,28 +34,6 @@ class SearchItemMapperProvider
$label = trim($label . ' — ' . strtoupper($status));
}
$url = lurl('admin/mail-log/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
} 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'] ?? ''));
$label = trim($method . ' ' . $path);
if ($statusCode > 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;

View File

@@ -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],
],
];
}

View File

@@ -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(''),
};
}

View File

@@ -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',
];

View File

@@ -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(

View File

@@ -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),

View File

@@ -0,0 +1,107 @@
<?php
namespace MintyPHP\Module\Audit\Providers;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
/**
* Provides audit search resources (API audit, system audit) for the global search.
*/
final class AuditSearchResourceProvider implements SearchResourceProvider
{
public function resources(): array
{
return [
'api-audit' => [
'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<string, mixed> $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<string, mixed> $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',
];
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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',

View File

@@ -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');

View File

@@ -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');
}

View File

@@ -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');

View File

@@ -0,0 +1,158 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Providers;
use MintyPHP\Module\Audit\Providers\AuditSearchResourceProvider;
use PHPUnit\Framework\TestCase;
class AuditSearchResourceProviderTest extends TestCase
{
private AuditSearchResourceProvider $provider;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -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;

View File

@@ -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 = [];

View File

@@ -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 = [];

View File

@@ -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 = [];

View File

@@ -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();

View File

@@ -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,

View File

@@ -17,8 +17,10 @@ final class AuditModuleIsolationContractTest extends TestCase
));
$allowed = [
'AuditMetadataEnricherInterface.php',
'AuditRecorderInterface.php',
'ImportAuditInterface.php',
'NullAuditMetadataEnricher.php',
'NullAuditRecorder.php',
'NullImportAudit.php',
'NullUserLifecycleAudit.php',

View File

@@ -0,0 +1,32 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
use PHPUnit\Framework\TestCase;
class NullAuditMetadataEnricherTest extends TestCase
{
public function testEnrichDoesNotModifyEntity(): void
{
$enricher = new NullAuditMetadataEnricher();
$entity = ['created_by' => 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);
}
}