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:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user