Files
michaelschiemer/tests/Unit/Framework/ExceptionHandling/Audit/ExceptionAuditLoggerTest.php
Michael Schiemer 36ef2a1e2c
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
fix: Gitea Traefik routing and connection pool optimization
- 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
2025-11-09 14:46:15 +01:00

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);
}