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

@@ -0,0 +1,76 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\RequestRuntime;
use MintyPHP\Module\Audit\Repository\ApiAuditLogRepositoryInterface;
use MintyPHP\Module\Audit\Service\ApiAuditService;
use PHPUnit\Framework\TestCase;
class ApiAuditServiceTest extends TestCase
{
private array $serverBackup = [];
private array $getBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$this->getBackup = $_GET;
RequestContext::resetForTests();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
$_GET = $this->getBackup;
RequestContext::resetForTests();
}
public function testCurrentRequestIdIsNullBeforeStart(): void
{
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$this->assertNull($service->currentRequestId());
}
public function testCurrentRequestIdIsGeneratedForRegularRequests(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/me?x=1';
$_GET = ['x' => '1'];
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$service->startRequestContext();
$requestId = $service->currentRequestId();
$this->assertNotNull($requestId);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$requestId
);
$service->startRequestContext();
$this->assertSame($requestId, $service->currentRequestId());
}
public function testCurrentRequestIdStaysNullForOptionsRequests(): void
{
$_SERVER['REQUEST_METHOD'] = 'OPTIONS';
$_SERVER['REQUEST_URI'] = '/api/v1/me';
$_GET = [];
$service = new ApiAuditService(
$this->createMock(ApiAuditLogRepositoryInterface::class),
new RequestRuntime()
);
$service->startRequestContext();
$this->assertNull($service->currentRequestId());
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Service\AuditMetadataEnricher;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use PHPUnit\Framework\TestCase;
class AuditMetadataEnricherTest extends TestCase
{
private function newEnricher(UserReadRepositoryInterface $userReadRepository): AuditMetadataEnricher
{
return new AuditMetadataEnricher($userReadRepository);
}
public function testEnrichResolvesCreatedByAndModifiedBy(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturnMap([
[10, ['first_name' => 'Alice', 'last_name' => 'Smith', 'email' => 'alice@example.com', 'uuid' => 'uuid-10']],
[20, ['first_name' => 'Bob', 'last_name' => 'Jones', 'email' => 'bob@example.com', 'uuid' => 'uuid-20']],
]);
$entity = ['created_by' => 10, 'modified_by' => 20];
$this->newEnricher($repo)->enrich($entity);
$this->assertSame('Alice Smith', $entity['created_by_label']);
$this->assertSame('uuid-10', $entity['created_by_uuid']);
$this->assertSame('Bob Jones', $entity['modified_by_label']);
$this->assertSame('uuid-20', $entity['modified_by_uuid']);
}
public function testEnrichFallsBackToEmailWhenNameEmpty(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(['first_name' => '', 'last_name' => '', 'email' => 'no-name@example.com', 'uuid' => 'uuid-x']);
$entity = ['created_by' => 5];
$this->newEnricher($repo)->enrich($entity, ['created_by']);
$this->assertSame('no-name@example.com', $entity['created_by_label']);
$this->assertSame('uuid-x', $entity['created_by_uuid']);
}
public function testEnrichSkipsZeroAndNegativeIds(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->expects($this->never())->method('find');
$entity = ['created_by' => 0, 'modified_by' => -1];
$this->newEnricher($repo)->enrich($entity);
$this->assertArrayNotHasKey('created_by_label', $entity);
$this->assertArrayNotHasKey('modified_by_label', $entity);
}
public function testEnrichSkipsMissingFields(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->expects($this->never())->method('find');
$entity = [];
$this->newEnricher($repo)->enrich($entity, ['created_by', 'modified_by']);
$this->assertArrayNotHasKey('created_by_label', $entity);
}
public function testEnrichHandlesUserNotFound(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(null);
$entity = ['created_by' => 999];
$this->newEnricher($repo)->enrich($entity, ['created_by']);
$this->assertArrayNotHasKey('created_by_label', $entity);
}
public function testEnrichWithCustomFields(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(['first_name' => 'Admin', 'last_name' => '', 'email' => 'admin@co.com', 'uuid' => 'uuid-a']);
$entity = ['status_changed_by' => 7, 'active_changed_by' => 7];
$this->newEnricher($repo)->enrich($entity, ['status_changed_by', 'active_changed_by']);
$this->assertSame('Admin', $entity['status_changed_by_label']);
$this->assertSame('uuid-a', $entity['status_changed_by_uuid']);
$this->assertSame('Admin', $entity['active_changed_by_label']);
$this->assertSame('uuid-a', $entity['active_changed_by_uuid']);
}
}

View File

@@ -0,0 +1,332 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
use PHPUnit\Framework\TestCase;
class FrontendTelemetryIngestServiceTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::resetForTests();
$_SESSION = [];
}
public function testIngestSkipsWhenDisabled(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(false);
$rateLimiter = $this->createMock(RateLimiterService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'message' => 'warn',
]);
$this->assertSame(['accepted' => false, 'reason' => 'disabled'], $result);
}
public function testIngestRejectsInvalidPayload(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->never())->method('record');
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$rateLimiter = $this->createMock(RateLimiterService::class);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn([]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'unknown',
'message' => '',
]);
$this->assertSame(['accepted' => false, 'reason' => 'invalid'], $result);
}
public function testIngestRecordsSanitizedPayload(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(function (array $context): bool {
$metadata = $context['metadata'] ?? [];
$message = (string) ($metadata['message'] ?? '');
if (!str_contains($message, '[REDACTED_EMAIL]')) {
return false;
}
if (!str_contains($message, 'token=[REDACTED]')) {
return false;
}
$meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : [];
$requestPath = (string) ($meta['request_path'] ?? '');
return $requestPath === '/admin/users/:id';
})
)
->willReturn(5);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Problem for user test@example.com token=supersecret1234567890',
'fingerprint' => 'v1_warn_once_123',
'meta' => ['request_path' => '/admin/users/123?email=a@b.de'],
'occurred_at' => '2026-03-05T12:00:00+01:00',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestBuildsFallbackFingerprintUsingRouteBeforeMessage(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$fingerprintHash = (string) ($metadata['fingerprint_hash'] ?? '');
$expectedFingerprint = 'v1_' . substr(hash('sha256', 'frontend.warn_once|/admin/users/:id|Grid init failed'), 0, 24);
return $fingerprintHash === RequestContext::hashValue($expectedFingerprint);
})
)
->willReturn(6);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Grid init failed',
'meta' => ['location' => '/admin/users/123'],
'occurred_at' => '2026-03-05T12:00:00+01:00',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestRejectsInvalidPageRequestIdPattern(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->with(
'frontend.warn_once',
'success',
$this->callback(static function (array $context): bool {
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
$meta = is_array($metadata['meta'] ?? null) ? $metadata['meta'] : [];
return !array_key_exists('page_request_id', $meta);
})
)
->willReturn(8);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturn(['user' => ['id' => 7]]);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'Invalid request id should be dropped',
'fingerprint' => 'v1_invalid_request_id',
'meta' => ['page_request_id' => '------------------------------------'],
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestUsesSingleSessionStateReadWriteForGuards(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())
->method('record')
->willReturn(7);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->expects($this->once())
->method('hit')
->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->expects($this->once())->method('all')->willReturn(['user' => ['id' => 7]]);
$sessionStore->expects($this->once())
->method('get')
->with('frontend_telemetry_state', [])
->willReturn([]);
$sessionStore->expects($this->once())
->method('set')
->with(
'frontend_telemetry_state',
$this->callback(static function (mixed $value): bool {
if (!is_array($value)) {
return false;
}
if (!is_array($value['dedupe'] ?? null)) {
return false;
}
if (!is_array($value['burst'] ?? null)) {
return false;
}
return true;
})
);
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$result = $service->ingest([
'event_type' => 'frontend.warn_once',
'severity' => 'warning',
'message' => 'single state write',
'fingerprint' => 'v1_single_state_write',
'meta' => ['location' => '/admin/test'],
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $result);
}
public function testIngestDropsDuplicateFingerprint(): void
{
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record')->willReturn(9);
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturn(['allowed' => true, 'retry_after' => 0]);
$sessionData = ['user' => ['id' => 7]];
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData);
$sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
return $sessionData[$key] ?? $default;
});
$sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
$sessionData[$key] = $value;
});
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$first = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_1',
]);
$second = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_1',
]);
$this->assertSame(['accepted' => true, 'reason' => 'logged'], $first);
$this->assertSame(['accepted' => false, 'reason' => 'duplicate'], $second);
}
public function testIngestAppliesSessionBurstFastDropBeforeGlobalRateLimit(): void
{
$recordCalls = 0;
$rateLimiterCalls = 0;
$audit = $this->createMock(SystemAuditService::class);
$audit->method('record')->willReturnCallback(static function () use (&$recordCalls): int {
$recordCalls++;
return $recordCalls;
});
$gateway = $this->createMock(SettingsFrontendTelemetryGateway::class);
$gateway->method('isFrontendTelemetryEnabled')->willReturn(true);
$gateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once', 'ajax_error']);
$gateway->method('getFrontendTelemetrySampleRate')->willReturn(1.0);
$rateLimiter = $this->createMock(RateLimiterService::class);
$rateLimiter->method('hit')->willReturnCallback(static function () use (&$rateLimiterCalls): array {
$rateLimiterCalls++;
return ['allowed' => true, 'retry_after' => 0];
});
$sessionData = ['user' => ['id' => 7]];
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->method('all')->willReturnCallback(static fn (): array => $sessionData);
$sessionStore->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed {
return $sessionData[$key] ?? $default;
});
$sessionStore->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void {
$sessionData[$key] = $value;
});
$service = new FrontendTelemetryIngestService($audit, $gateway, $rateLimiter, $sessionStore);
$lastResult = ['accepted' => true, 'reason' => 'logged'];
for ($index = 0; $index < 120; $index++) {
$lastResult = $service->ingest([
'event_type' => 'frontend.ajax_error',
'message' => 'AJAX request failed',
'fingerprint' => 'v1_ajax_error_' . $index,
]);
}
$this->assertSame(['accepted' => false, 'reason' => 'burst_limited'], $lastResult);
$this->assertGreaterThan(0, $recordCalls);
$this->assertLessThan(120, $recordCalls);
$this->assertSame($recordCalls, $rateLimiterCalls);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\ImportAuditRunRepository;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use PHPUnit\Framework\TestCase;
class ImportAuditServiceTest extends TestCase
{
public function testStartRunNormalizesFilenameAndMappedTargets(): void
{
$captured = null;
$repository = $this->createMock(ImportAuditRunRepository::class);
$repository->expects($this->once())
->method('createRunning')
->with($this->callback(function (array $payload) use (&$captured): bool {
$captured = $payload;
return true;
}))
->willReturn(77);
$service = new ImportAuditService($repository);
$runId = $service->startRun('users', ['email', 'first_name', 'email'], '/tmp/../users.csv', 12, 5);
$this->assertSame(77, $runId);
$this->assertSame('users.csv', (string) ($captured['source_filename'] ?? ''));
$this->assertSame('email,first_name', (string) ($captured['mapped_targets_csv'] ?? ''));
$this->assertSame(12, (int) ($captured['user_id'] ?? 0));
$this->assertSame(5, (int) ($captured['current_tenant_id'] ?? 0));
}
public function testFinishRunDerivesPartialStatusAndAggregatesErrors(): void
{
$captured = null;
$repository = $this->createMock(ImportAuditRunRepository::class);
$repository->expects($this->once())
->method('finishById')
->with(
88,
$this->callback(function (array $payload) use (&$captured): bool {
$captured = $payload;
return true;
})
)
->willReturn(true);
$service = new ImportAuditService($repository);
$service->finishRun(88, [
'ok' => true,
'processed' => 3,
'created' => 1,
'skipped' => 1,
'failed' => 1,
'errors' => [
['code' => 'email_exists'],
['code' => 'email_exists'],
['code' => 'invalid_email'],
],
]);
$this->assertSame('partial', (string) ($captured['status'] ?? ''));
$this->assertSame(3, (int) ($captured['rows_total'] ?? 0));
$this->assertSame(1, (int) ($captured['created_count'] ?? 0));
$this->assertSame(1, (int) ($captured['skipped_count'] ?? 0));
$this->assertSame(1, (int) ($captured['failed_count'] ?? 0));
$errorJson = (string) ($captured['error_codes_json'] ?? '');
$this->assertStringContainsString('email_exists', $errorJson);
$this->assertStringContainsString('invalid_email', $errorJson);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
use PHPUnit\Framework\TestCase;
class SystemAuditRedactionServiceTest extends TestCase
{
public function testSensitiveKeysAreRedacted(): void
{
$service = new SystemAuditRedactionService();
$redacted = $service->redactMetadata([
'email' => 'john@example.com',
'token' => 'abc',
'active' => 1,
'nested' => ['password' => 'secret'],
]);
$this->assertSame('[REDACTED]', $redacted['email']);
$this->assertSame('[REDACTED]', $redacted['token']);
$this->assertSame(1, $redacted['active']);
$this->assertSame('[REDACTED]', $redacted['nested']['password']);
}
public function testBuildChangeSetUsesAllowlistAndRedactsOtherValues(): void
{
$service = new SystemAuditRedactionService();
$changeSet = $service->buildChangeSet(
['active' => 0, 'description' => 'old'],
['active' => 1, 'description' => 'new']
);
$this->assertSame(['active', 'description'], $changeSet['changed_fields']);
$this->assertSame(0, $changeSet['changes']['active']['before']);
$this->assertSame(1, $changeSet['changes']['active']['after']);
$this->assertSame('[REDACTED]', $changeSet['changes']['description']['before']);
$this->assertSame('[REDACTED]', $changeSet['changes']['description']['after']);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStore;
use MintyPHP\Module\Audit\Repository\SystemAuditLogRepository;
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
use PHPUnit\Framework\TestCase;
class SystemAuditServiceTest extends TestCase
{
protected function tearDown(): void
{
RequestContext::resetForTests();
$_SESSION = [];
}
public function testRecordReturnsNullWhenDisabled(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->never())->method('create');
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->expects($this->once())->method('isSystemAuditEnabled')->willReturn(false);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertNull($service->record('admin.users.update', 'success'));
}
public function testRecordWritesWhenEnabled(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/admin/users/edit/abc';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['HTTP_USER_AGENT'] = 'phpunit';
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('create')->willReturn(5);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$id = $service->record('admin.users.update', 'success', [
'target_type' => 'user',
'target_id' => 10,
'before' => ['active' => 0],
'after' => ['active' => 1],
]);
$this->assertSame(5, $id);
}
public function testRecordIsFailOpenOnRepositoryError(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('db down'));
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('isSystemAuditEnabled')->willReturn(true);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertNull($service->record('admin.users.update', 'success'));
}
public function testPurgeUsesConfiguredRetention(): void
{
$repo = $this->createMock(SystemAuditLogRepository::class);
$repo->expects($this->once())->method('purgeOlderThanDays')->with(120)->willReturn(3);
$settings = $this->createMock(SettingsSystemAuditGateway::class);
$settings->method('getSystemAuditRetentionDays')->willReturn(120);
$service = new SystemAuditService($repo, new SystemAuditRedactionService(), $settings, new SessionStore());
$this->assertSame(3, $service->purgeExpired());
}
}

View File

@@ -0,0 +1,365 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepositoryInterface;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Support\Crypto;
use PHPUnit\Framework\TestCase;
class UserLifecycleAuditServiceTest extends TestCase
{
private const SNAPSHOT_FIELDS = [
'uuid',
'first_name',
'last_name',
'display_name',
'email',
'profile_description',
'job_title',
'phone',
'mobile',
'short_dial',
'address',
'postal_code',
'city',
'region',
'country',
'hire_date',
'locale',
'theme',
'email_verified_at',
'last_login_at',
'last_login_provider',
'primary_tenant_id',
'current_tenant_id',
'created',
'modified',
'active_changed_at',
];
private static bool $cryptoAvailable = false;
public static function setUpBeforeClass(): void
{
if (!defined('APP_CRYPTO_KEY')) {
define('APP_CRYPTO_KEY', str_repeat('ab', 32));
}
self::$cryptoAvailable = Crypto::isConfigured();
}
// ── logDeactivate ───────────────────────────────────────────────────
public function testLogDeactivateCallsRepoCreateWithCorrectRow(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'deactivate'
&& $row['trigger_type'] === 'manual'
&& $row['status'] === 'success'
&& $row['run_uuid'] === 'run-1'
&& $row['target_user_id'] === 5
&& $row['target_user_uuid'] === 'user-uuid-5'
&& $row['target_user_email'] === 'test@example.com'
&& $row['actor_user_id'] === 99
&& $row['policy_deactivate_days'] === 30
&& $row['policy_delete_days'] === 60
&& $row['snapshot_enc'] === null
&& $row['snapshot_version'] === 1;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate(
'run-1',
'manual',
['deactivate_days' => 30, 'delete_days' => 60],
99,
['id' => 5, 'uuid' => 'user-uuid-5', 'email' => 'test@example.com'],
'success',
null
);
$this->assertTrue($result);
}
public function testLogDeactivateReturnsFalseOnRepoException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willThrowException(new \RuntimeException('db error'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate('run-1', 'manual', [], null, []);
$this->assertFalse($result);
}
public function testLogDeactivateReturnsFalseWhenRepoReturnsFalse(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willReturn(false);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate('run-1', 'manual', [], null, []);
$this->assertFalse($result);
}
// ── logDeleteWithSnapshot ───────────────────────────────────────────
public function testLogDeleteWithSnapshotCreatesEncryptedSnapshot(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$targetUser = [];
foreach (self::SNAPSHOT_FIELDS as $field) {
$targetUser[$field] = 'val_' . $field;
}
$targetUser['id'] = 5;
$targetUser['extra_field'] = 'should_be_excluded';
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'delete'
&& $row['status'] === 'success'
&& $row['snapshot_version'] === 1
&& is_string($row['snapshot_enc'])
&& $row['snapshot_enc'] !== '';
}))
->willReturn(7);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeleteWithSnapshot('run-2', 'cron', ['deactivate_days' => 30, 'delete_days' => 90], null, $targetUser);
$this->assertSame(7, $result);
}
public function testLogDeleteWithSnapshotContainsOnlySnapshotFields(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$targetUser = ['id' => 5, 'uuid' => 'u5', 'email' => 'e@example.com', 'extra' => 'no'];
$capturedRow = null;
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row) use (&$capturedRow): bool {
$capturedRow = $row;
return true;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeleteWithSnapshot('run-3', 'manual', [], null, $targetUser);
$this->assertNotNull($capturedRow);
$decryptedJson = Crypto::decryptString($capturedRow['snapshot_enc']);
$snapshot = json_decode($decryptedJson, true);
$this->assertIsArray($snapshot);
$this->assertSame(self::SNAPSHOT_FIELDS, array_keys($snapshot));
$this->assertArrayNotHasKey('extra', $snapshot);
$this->assertArrayNotHasKey('id', $snapshot);
}
public function testLogDeleteWithSnapshotReturnsFalseOnCryptoFailure(): void
{
// Simulate a scenario where Crypto throws by passing a target user that will
// cause the try/catch to fire. We cannot easily override static Crypto, but we
// know that if APP_CRYPTO_KEY were missing, encryptString would throw.
// Since we defined a valid key, let's test the repo exception path instead.
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willThrowException(new \RuntimeException('db error'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeleteWithSnapshot('run-4', 'manual', [], null, ['id' => 1, 'uuid' => 'u']);
$this->assertFalse($result);
}
// ── logRestore ──────────────────────────────────────────────────────
public function testLogRestoreCallsRepoWithRestoreAction(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'restore'
&& $row['trigger_type'] === 'manual'
&& $row['status'] === 'success';
}))
->willReturn(10);
$service = new UserLifecycleAuditService($repo);
$result = $service->logRestore('run-5', 'manual', [], 99, ['id' => 5, 'uuid' => 'u5', 'email' => 'e@x.com']);
$this->assertTrue($result);
}
public function testLogRestoreReturnsFalseOnException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('create')->willThrowException(new \RuntimeException('fail'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logRestore('run-5', 'manual', [], null, []);
$this->assertFalse($result);
}
// ── decryptSnapshot ─────────────────────────────────────────────────
public function testDecryptSnapshotWithValidPayload(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$original = ['uuid' => 'test-uuid', 'email' => 'test@example.com'];
$encrypted = Crypto::encryptString(json_encode($original));
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$service = new UserLifecycleAuditService($repo);
$result = $service->decryptSnapshot(['snapshot_enc' => $encrypted]);
$this->assertSame($original, $result);
}
public function testDecryptSnapshotReturnsNullForEmptyPayload(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$service = new UserLifecycleAuditService($repo);
$this->assertNull($service->decryptSnapshot([]));
$this->assertNull($service->decryptSnapshot(['snapshot_enc' => '']));
$this->assertNull($service->decryptSnapshot(['snapshot_enc' => ' ']));
}
public function testDecryptSnapshotReturnsNullForInvalidPayload(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$service = new UserLifecycleAuditService($repo);
$result = $service->decryptSnapshot(['snapshot_enc' => 'not-valid-encrypted-data']);
$this->assertNull($result);
}
// ── markDeleteEventRestored ─────────────────────────────────────────
public function testMarkDeleteEventRestoredDelegatesToRepo(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('markRestored')
->with(1, 99, 200)
->willReturn(true);
$service = new UserLifecycleAuditService($repo);
$this->assertTrue($service->markDeleteEventRestored(1, 99, 200));
}
public function testMarkDeleteEventRestoredReturnsFalseOnException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->method('markRestored')->willThrowException(new \RuntimeException('fail'));
$service = new UserLifecycleAuditService($repo);
$this->assertFalse($service->markDeleteEventRestored(1, 99, 200));
}
// ── purgeExpired ────────────────────────────────────────────────────
public function testPurgeExpiredDelegatesToRepoWith365Days(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('purgeOlderThanDays')
->with(365)
->willReturn(5);
$service = new UserLifecycleAuditService($repo);
$this->assertSame(5, $service->purgeExpired());
}
// ── buildBaseRow enum normalization ──────────────────────────────────
public function testBuildBaseRowNormalizesInvalidTriggerTypeToDefault(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['trigger_type'] === 'system'; // fallback for invalid trigger
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'INVALID_TRIGGER', [], null, []);
}
public function testBuildBaseRowNormalizesInvalidStatusToFailed(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['status'] === 'failed'; // fallback for invalid status
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], null, [], 'INVALID_STATUS');
}
public function testBuildBaseRowNormalizesNullActorToNull(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['actor_user_id'] === null;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], null, []);
}
public function testBuildBaseRowNormalizesZeroActorToNull(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['actor_user_id'] === null;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], 0, []);
}
public function testBuildBaseRowSetsNegativePolicyDaysToZero(): void
{
$repo = $this->createMock(UserLifecycleAuditRepositoryInterface::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['policy_deactivate_days'] === 0
&& $row['policy_delete_days'] === 0;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', ['deactivate_days' => -10, 'delete_days' => -5], null, []);
}
}