Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
302 lines
11 KiB
PHP
302 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Audit\AuditLogger;
|
|
use App\Framework\Audit\InMemoryAuditLogger;
|
|
use App\Framework\Audit\ValueObjects\AuditEntry;
|
|
use App\Framework\Audit\ValueObjects\AuditableAction;
|
|
use App\Framework\DateTime\Clock;
|
|
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
|
|
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
|
|
|
describe('ExceptionAuditLogger', function () {
|
|
beforeEach(function () {
|
|
$this->auditLogger = new InMemoryAuditLogger();
|
|
$this->clock = new class implements Clock {
|
|
private DateTimeImmutable $now;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->now = new DateTimeImmutable('2024-01-01 12:00:00');
|
|
}
|
|
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return $this->now;
|
|
}
|
|
|
|
public function fromTimestamp(\App\Framework\Core\ValueObjects\Timestamp $timestamp): DateTimeImmutable
|
|
{
|
|
return DateTimeImmutable::createFromTimestamp($timestamp->value);
|
|
}
|
|
|
|
public function fromString(string $dateTime, ?string $format = null): DateTimeImmutable
|
|
{
|
|
return DateTimeImmutable::createFromFormat($format ?? 'Y-m-d H:i:s', $dateTime);
|
|
}
|
|
|
|
public function today(): DateTimeImmutable
|
|
{
|
|
return $this->now;
|
|
}
|
|
|
|
public function yesterday(): DateTimeImmutable
|
|
{
|
|
return $this->now->modify('-1 day');
|
|
}
|
|
|
|
public function tomorrow(): DateTimeImmutable
|
|
{
|
|
return $this->now->modify('+1 day');
|
|
}
|
|
|
|
public function time(): \App\Framework\Core\ValueObjects\Timestamp
|
|
{
|
|
return new \App\Framework\Core\ValueObjects\Timestamp($this->now->getTimestamp());
|
|
}
|
|
};
|
|
$this->contextProvider = new ExceptionContextProvider();
|
|
$this->exceptionAuditLogger = new ExceptionAuditLogger(
|
|
$this->auditLogger,
|
|
$this->clock,
|
|
$this->contextProvider
|
|
);
|
|
});
|
|
|
|
it('logs auditable exception as audit entry', function () {
|
|
$exception = new RuntimeException('User not found');
|
|
$context = ExceptionContextData::forOperation('user.lookup', 'UserRepository')
|
|
->addData(['user_id' => '123'])
|
|
->withUserId('user-456')
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
// Check that audit entry was created
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
expect($entries)->toHaveCount(1);
|
|
|
|
$entry = $entries[0];
|
|
expect($entry->success)->toBeFalse();
|
|
expect($entry->errorMessage)->toBe('User not found');
|
|
expect($entry->userId)->toBe('user-456');
|
|
expect($entry->entityType)->toBe('userrepository');
|
|
expect($entry->action)->toBeInstanceOf(AuditableAction::class);
|
|
});
|
|
|
|
it('does not log non-auditable exception', function () {
|
|
$exception = new RuntimeException('Validation error');
|
|
$context = ExceptionContextData::forOperation('validation.check')
|
|
->withAuditable(false);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
// Check that no audit entry was created
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
expect($entries)->toHaveCount(0);
|
|
});
|
|
|
|
it('logs exception without context as auditable by default', function () {
|
|
$exception = new RuntimeException('Generic error');
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
// Check that audit entry was created (default: auditable)
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
expect($entries)->toHaveCount(1);
|
|
});
|
|
|
|
it('includes context data in audit entry metadata', function () {
|
|
$exception = new RuntimeException('Operation failed');
|
|
$context = ExceptionContextData::forOperation('order.process', 'OrderService')
|
|
->addData(['order_id' => 'order-789', 'amount' => 1000])
|
|
->withUserId('user-123')
|
|
->withRequestId('req-456')
|
|
->withTags('payment', 'external_api');
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
$entry = $entries[0];
|
|
|
|
expect($entry->metadata)->toHaveKey('operation');
|
|
expect($entry->metadata['operation'])->toBe('order.process');
|
|
expect($entry->metadata)->toHaveKey('component');
|
|
expect($entry->metadata['component'])->toBe('OrderService');
|
|
expect($entry->metadata)->toHaveKey('context_data');
|
|
expect($entry->metadata['context_data']['order_id'])->toBe('order-789');
|
|
expect($entry->metadata)->toHaveKey('tags');
|
|
expect($entry->metadata['tags'])->toBe(['payment', 'external_api']);
|
|
});
|
|
|
|
it('determines audit action from operation name', function () {
|
|
$testCases = [
|
|
['operation' => 'user.create', 'expected' => AuditableAction::CREATE],
|
|
['operation' => 'order.update', 'expected' => AuditableAction::UPDATE],
|
|
['operation' => 'product.delete', 'expected' => AuditableAction::DELETE],
|
|
['operation' => 'data.read', 'expected' => AuditableAction::READ],
|
|
];
|
|
|
|
foreach ($testCases as $testCase) {
|
|
$exception = new RuntimeException('Test');
|
|
$context = ExceptionContextData::forOperation($testCase['operation'])
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
$entry = end($entries);
|
|
|
|
expect($entry->action)->toBe($testCase['expected']);
|
|
}
|
|
});
|
|
|
|
it('extracts entity ID from context data', function () {
|
|
$exception = new RuntimeException('Entity not found');
|
|
$context = ExceptionContextData::forOperation('entity.get')
|
|
->addData(['entity_id' => 'entity-123'])
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
$entry = $entries[0];
|
|
|
|
expect($entry->entityId)->toBe('entity-123');
|
|
});
|
|
|
|
it('handles IP address and user agent from context with Value Objects', function () {
|
|
$exception = new RuntimeException('Security violation');
|
|
$context = ExceptionContextData::forOperation('security.check')
|
|
->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.1'))
|
|
->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0'))
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
$entry = $entries[0];
|
|
|
|
expect($entry->ipAddress)->not->toBeNull();
|
|
expect((string) $entry->ipAddress)->toBe('192.168.1.1');
|
|
expect($entry->userAgent)->not->toBeNull();
|
|
expect($entry->userAgent->value)->toBe('Mozilla/5.0');
|
|
});
|
|
|
|
it('handles IP address and user agent from context with strings (backward compatibility)', function () {
|
|
$exception = new RuntimeException('Security violation');
|
|
$context = ExceptionContextData::forOperation('security.check')
|
|
->withClientIp('192.168.1.1')
|
|
->withUserAgent('Mozilla/5.0')
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
$entry = $entries[0];
|
|
|
|
expect($entry->ipAddress)->not->toBeNull();
|
|
expect((string) $entry->ipAddress)->toBe('192.168.1.1');
|
|
expect($entry->userAgent)->not->toBeNull();
|
|
expect($entry->userAgent->value)->toBe('Mozilla/5.0');
|
|
});
|
|
|
|
it('does not throw when audit logging fails', function () {
|
|
$failingAuditLogger = new class implements AuditLogger {
|
|
public function log(AuditEntry $entry): void
|
|
{
|
|
throw new RuntimeException('Audit logging failed');
|
|
}
|
|
|
|
public function find(\App\Framework\Audit\ValueObjects\AuditId $id): ?AuditEntry
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public function query(\App\Framework\Audit\ValueObjects\AuditQuery $query): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
public function count(\App\Framework\Audit\ValueObjects\AuditQuery $query): int
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
public function purgeOlderThan(DateTimeImmutable $date): int
|
|
{
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
$logger = new ExceptionAuditLogger(
|
|
$failingAuditLogger,
|
|
$this->clock,
|
|
$this->contextProvider
|
|
);
|
|
|
|
$exception = new RuntimeException('Test error');
|
|
$context = ExceptionContextData::forOperation('test')
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
// Should not throw
|
|
expect(fn() => $logger->logIfAuditable($exception))->not->toThrow();
|
|
});
|
|
|
|
it('includes previous exception in metadata', function () {
|
|
$previous = new InvalidArgumentException('Invalid input');
|
|
$exception = new RuntimeException('Operation failed', 0, $previous);
|
|
$context = ExceptionContextData::forOperation('operation.execute')
|
|
->withAuditable(true);
|
|
|
|
$this->contextProvider->attach($exception, $context);
|
|
|
|
$this->exceptionAuditLogger->logIfAuditable($exception);
|
|
|
|
$entries = $this->getAllAuditEntries($this->auditLogger);
|
|
$entry = $entries[0];
|
|
|
|
expect($entry->metadata)->toHaveKey('previous_exception');
|
|
expect($entry->metadata['previous_exception']['class'])->toBe(InvalidArgumentException::class);
|
|
expect($entry->metadata['previous_exception']['message'])->toBe('Invalid input');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Helper to get all audit entries from InMemoryAuditLogger
|
|
*
|
|
* @return array<AuditEntry>
|
|
*/
|
|
function getAllAuditEntries(AuditLogger $logger): array
|
|
{
|
|
if ($logger instanceof InMemoryAuditLogger) {
|
|
// Use reflection to access private entries property
|
|
$reflection = new ReflectionClass($logger);
|
|
$property = $reflection->getProperty('entries');
|
|
$property->setAccessible(true);
|
|
return array_values($property->getValue($logger));
|
|
}
|
|
|
|
// For other implementations, query all entries
|
|
$query = new \App\Framework\Audit\ValueObjects\AuditQuery();
|
|
return $logger->query($query);
|
|
}
|
|
|