2026-03-06 00:44:52 +01:00
|
|
|
<?php
|
|
|
|
|
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
namespace MintyPHP\Tests\Module\Audit\Service;
|
2026-03-06 00:44:52 +01:00
|
|
|
|
|
|
|
|
use MintyPHP\Http\RequestContext;
|
|
|
|
|
use MintyPHP\Http\SessionStoreInterface;
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService;
|
|
|
|
|
use MintyPHP\Module\Audit\Service\SystemAuditService;
|
2026-03-06 00:44:52 +01:00
|
|
|
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')
|
2026-03-26 10:03:27 +01:00
|
|
|
->with('module.audit.frontend_telemetry_state', [])
|
2026-03-06 00:44:52 +01:00
|
|
|
->willReturn([]);
|
|
|
|
|
$sessionStore->expects($this->once())
|
|
|
|
|
->method('set')
|
|
|
|
|
->with(
|
2026-03-26 10:03:27 +01:00
|
|
|
'module.audit.frontend_telemetry_state',
|
2026-03-06 00:44:52 +01:00
|
|
|
$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);
|
|
|
|
|
}
|
|
|
|
|
}
|