refactor(audit): extract audit domain into self-contained module

Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.

Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers

Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n

Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
  surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling

All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:12:49 +01:00
parent 12a837bda9
commit 0c351f6aff
176 changed files with 2157 additions and 834 deletions

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserActivatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -42,14 +43,15 @@ final class UserActivatedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notificationService->expects($this->exactly(2))
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
$this->callback(static fn (int $tenantId): bool => in_array($tenantId, [1, 2], true)),
'user.activated',
$this->stringContains('Alice'),
null,
'admin/users/edit/user-uuid',
['user_id' => 10, 'uuid' => 'user-uuid'],
$this->callback(function (NotificationMessage $message): bool {
return $message->type() === 'user.activated'
&& $message->link() === 'admin/users/edit/user-uuid'
&& $message->data() === ['user_id' => 10, 'uuid' => 'user-uuid']
&& $message->titleKey() === 'User activated: %s';
}),
5,
$this->isArray()
);
@@ -74,13 +76,9 @@ final class UserActivatedNotificationListenerTest extends TestCase
->willReturn([20]);
$this->notificationService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
7,
'user.activated',
$this->anything(),
null,
'admin/users/edit/user-uuid',
$this->anything(),
null,
$this->anything()
@@ -92,7 +90,7 @@ final class UserActivatedNotificationListenerTest extends TestCase
public function testHandleSkipsInvalidPayload(): void
{
$this->userReadRepository->expects($this->never())->method('find');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsers');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.activated', ['user_id' => 0]);
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserAssignmentChangedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
@@ -38,27 +39,24 @@ final class UserAssignmentChangedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notificationService->expects($this->once())
->method('createForUser')
->method('createFromMessage')
->with(
10,
null,
'user.assignment_changed',
$this->stringContains('Alice'),
$this->isString(),
'admin/users/edit/user-uuid',
$this->isArray()
$this->callback(function (NotificationMessage $message): bool {
return $message->type() === 'user.assignment_changed'
&& $message->link() === 'admin/users/edit/user-uuid'
&& $message->titleKey() === 'User assignments updated: %s'
&& $message->bodyKey() === 'Tenant, role, or department assignments were changed.';
})
)
->willReturn(99);
$this->notificationService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.assignment_changed',
$this->stringContains('Alice'),
$this->isString(),
'admin/users/edit/user-uuid',
$this->isArray(),
$this->isInstanceOf(NotificationMessage::class),
7,
$this->isArray()
)
@@ -83,15 +81,11 @@ final class UserAssignmentChangedNotificationListenerTest extends TestCase
->method('listPrivilegedUserIdsByPermissionKeys')
->willReturn([20]);
$this->notificationService->expects($this->never())->method('createForUser');
$this->notificationService->expects($this->never())->method('createFromMessage');
$this->notificationService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.assignment_changed',
$this->anything(),
$this->anything(),
'admin/users/edit/user-uuid',
$this->anything(),
10,
$this->anything()
@@ -106,8 +100,8 @@ final class UserAssignmentChangedNotificationListenerTest extends TestCase
public function testHandleSkipsInvalidPayload(): void
{
$this->notificationService->expects($this->never())->method('createForUser');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsers');
$this->notificationService->expects($this->never())->method('createFromMessage');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.assignment_changed', ['user_id' => 0]);
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -39,15 +40,16 @@ class UserCreatedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notifService->expects($this->exactly(2))
->method('createForTenantAdminUsers')
->willReturnCallback(function (int $tenantId, string $type, string $title, ?string $body, ?string $link, array $data, ?int $excludeUserId, array $adminSet): int {
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message, ?int $excludeUserId, array $adminSet): int {
$this->assertContains($tenantId, [1, 2]);
$this->assertSame('user.created', $type);
$this->assertStringContainsString('Alice Smith', $title);
$this->assertSame('admin/users/edit/abc-uuid', $link);
$this->assertSame('user.created', $message->type());
$this->assertStringContainsString('Alice Smith', $message->title());
$this->assertSame('admin/users/edit/abc-uuid', $message->link());
$this->assertSame(5, $excludeUserId);
$this->assertArrayHasKey(20, $adminSet);
$this->assertArrayHasKey(30, $adminSet);
$this->assertSame('New user: %s', $message->titleKey());
return 2;
});
@@ -68,13 +70,9 @@ class UserCreatedNotificationListenerTest extends TestCase
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([20]);
$this->notifService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.created',
$this->anything(),
null,
'admin/users/edit/xyz',
$this->anything(),
7, // actor excluded
$this->anything()
@@ -90,7 +88,7 @@ class UserCreatedNotificationListenerTest extends TestCase
public function testHandleSkipsInvalidPayload(): void
{
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', ['uuid' => 'abc']);
$this->listener->handle('user.created', ['user_id' => 0, 'uuid' => 'abc']);
@@ -100,7 +98,7 @@ class UserCreatedNotificationListenerTest extends TestCase
public function testHandleSkipsWhenUserNotFound(): void
{
$this->userRepo->expects($this->any())->method('find')->with(99)->willReturn(null);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', [
'user_id' => 99,
@@ -116,7 +114,7 @@ class UserCreatedNotificationListenerTest extends TestCase
'display_name' => 'Alice',
]);
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', [
'user_id' => 10,
@@ -134,7 +132,7 @@ class UserCreatedNotificationListenerTest extends TestCase
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([1]);
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', [
'user_id' => 10,

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserDeactivatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -42,14 +43,15 @@ final class UserDeactivatedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notificationService->expects($this->exactly(2))
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
$this->callback(static fn (int $tenantId): bool => in_array($tenantId, [1, 2], true)),
'user.deactivated',
$this->stringContains('Alice'),
null,
'admin/users/edit/user-uuid',
['user_id' => 10, 'uuid' => 'user-uuid'],
$this->callback(function (NotificationMessage $message): bool {
return $message->type() === 'user.deactivated'
&& $message->link() === 'admin/users/edit/user-uuid'
&& $message->data() === ['user_id' => 10, 'uuid' => 'user-uuid']
&& $message->titleKey() === 'User deactivated: %s';
}),
5,
$this->isArray()
);
@@ -69,7 +71,7 @@ final class UserDeactivatedNotificationListenerTest extends TestCase
'display_name' => 'Alice',
]);
$this->userTenantRepository->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([]);
$this->notificationService->expects($this->never())->method('createForTenantAdminUsers');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deactivated', ['user_id' => 10]);
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
@@ -34,15 +35,16 @@ class UserDeletedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notifService->expects($this->exactly(2))
->method('createForTenantAdminUsers')
->willReturnCallback(function (int $tenantId, string $type, string $title, ?string $body, ?string $link, array $data, ?int $excludeUserId, array $adminSet): int {
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message, ?int $excludeUserId, array $adminSet): int {
$this->assertContains($tenantId, [1, 2]);
$this->assertSame('user.deleted', $type);
$this->assertStringContainsString('Alice', $title);
$this->assertNull($link);
$this->assertSame('user.deleted', $message->type());
$this->assertStringContainsString('Alice', $message->title());
$this->assertNull($message->link());
$this->assertSame(5, $excludeUserId);
$this->assertArrayHasKey(20, $adminSet);
$this->assertArrayHasKey(30, $adminSet);
$this->assertSame('User deleted: %s', $message->titleKey());
return 1;
});
@@ -57,7 +59,7 @@ class UserDeletedNotificationListenerTest extends TestCase
public function testHandleSkipsOnInvalidUserId(): void
{
$this->notifService->expects($this->never())->method('deleteAllForUser');
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', ['user_id' => 0]);
$this->listener->handle('user.deleted', []);
@@ -69,7 +71,7 @@ class UserDeletedNotificationListenerTest extends TestCase
->method('deleteAllForUser')
->with(10);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', [
'user_id' => 10,
@@ -84,7 +86,7 @@ class UserDeletedNotificationListenerTest extends TestCase
->method('deleteAllForUser')
->with(10);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', [
'user_id' => 10,
@@ -102,13 +104,9 @@ class UserDeletedNotificationListenerTest extends TestCase
->willReturn([20]);
$this->notifService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.deleted',
$this->anything(),
null,
null,
$this->anything(),
7, // actor excluded
$this->anything()
@@ -131,7 +129,7 @@ class UserDeletedNotificationListenerTest extends TestCase
->method('listPrivilegedUserIdsByPermissionKeys')
->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', [
'user_id' => 10,

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Service;
use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
@@ -282,4 +283,95 @@ class NotificationServiceTest extends TestCase
$result = $this->service->createForUser(5, 1, 'system', 'System', null, null, ['user_id' => 33]);
$this->assertSame(56, $result);
}
public function testCreateFromMessagePersistsLocalizationMetadata(): void
{
$message = NotificationMessage::localized(
'user.created',
'New user: %s',
['Alice'],
null,
[],
'admin/users/edit/abc',
['user_id' => 33]
);
$this->notifRepo->expects($this->once())
->method('create')
->with($this->callback(function (array $data): bool {
if (!isset($data['data']['__message']) || !is_array($data['data']['__message'])) {
return false;
}
return $data['type'] === 'user.created'
&& $data['data']['__message']['title_key'] === 'New user: %s'
&& ($data['data']['__message']['title_params'] ?? []) === ['Alice']
&& $data['data']['user_id'] === 33;
}))
->willReturn(57);
$result = $this->service->createFromMessage(5, 1, $message);
$this->assertSame(57, $result);
}
public function testCreateForTenantUsersFromMessageCreatesForAllEligibleUsers(): void
{
$message = NotificationMessage::plain('system', 'Hello', null, null, []);
$this->utRepo->expects($this->once())
->method('listActiveUserIdsByTenantId')
->with(1)
->willReturn([10, 20, 30]);
$this->notifRepo->expects($this->exactly(2))
->method('create')
->willReturn(1);
$result = $this->service->createForTenantUsersFromMessage(1, $message, 20);
$this->assertSame(2, $result);
}
public function testCreateForTenantAdminUsersFromMessageRejectsEmptyAllowedSet(): void
{
$message = NotificationMessage::plain('system', 'Hello', null, null, []);
$this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId');
$this->notifRepo->expects($this->never())->method('create');
$result = $this->service->createForTenantAdminUsersFromMessage(1, $message, null, []);
$this->assertSame(0, $result);
}
public function testListForUserLocalizesFromStoredMessageMetadata(): void
{
$this->notifRepo->expects($this->once())
->method('listByUser')
->with(3, null, 50, 0)
->willReturn([
[
'id' => 1,
'type' => 'user.created',
'title' => 'fallback',
'body' => null,
'link' => null,
'data' => json_encode([
'user_id' => 10,
'__message' => [
'title_key' => 'New user: %s',
'title_params' => ['Alice'],
'body_key' => '',
'body_params' => [],
],
], JSON_THROW_ON_ERROR),
'is_read' => 0,
'created' => '2026-03-19 10:00:00',
],
]);
$result = $this->service->listForUser(3);
$this->assertCount(1, $result);
$this->assertSame(t('New user: %s', 'Alice'), $result[0]['title']);
$this->assertSame(['user_id' => 10], $result[0]['data']);
}
}