diff --git a/app/Audit/AuditLogFormatterFactory.php b/app/Audit/AuditLogFormatterFactory.php index 5a0306c48..a66d2032b 100644 --- a/app/Audit/AuditLogFormatterFactory.php +++ b/app/Audit/AuditLogFormatterFactory.php @@ -1,4 +1,7 @@ -config = config('audit_log', []); + try { + $this->config = config('audit_log', []); + } catch (\Exception $e) { + $this->config = []; + } } public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLogFormatter diff --git a/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php b/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php new file mode 100644 index 000000000..80b6267ef --- /dev/null +++ b/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php @@ -0,0 +1,358 @@ +defaultContext = AuditContextBuilder::default()->build(); + } + + private function discoverFormatters(string $directory = null): array + { + $directory = $directory ?? self::BASE_FORMATTERS_DIR; + $formatters = []; + + if (!is_dir($directory)) { + return $formatters; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php' || strpos($file->getPathname(), self::CHILD_ENTITY_DIR_NAME) !== false) { + continue; + } + + $className = $this->buildClassName($file->getPathname(), $directory); + + if (class_exists($className) && $this->isMainFormatter($className)) { + $formatters[] = $className; + } + } + + return array_values($formatters); + } + + + private function buildClassName(string $filePath, string $basePath): string + { + $relativePath = str_replace([$basePath . DIRECTORY_SEPARATOR, '.php'], '', $filePath); + $classPath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath); + return self::BASE_FORMATTERS_NAMESPACE . $classPath; + } + + private function isMainFormatter(string $className): bool + { + try { + $reflection = new \ReflectionClass($className); + + if ($reflection->isAbstract() || $reflection->isInterface()) { + return false; + } + + $genericFormatters = [ + 'EntityCreationAuditLogFormatter', + 'EntityDeletionAuditLogFormatter', + 'EntityUpdateAuditLogFormatter', + 'EntityCollectionUpdateAuditLogFormatter', + ]; + + return !in_array($reflection->getShortName(), $genericFormatters) && + $reflection->isSubclassOf('App\Audit\AbstractAuditLogFormatter'); + } catch (\ReflectionException $e) { + return false; + } + } + + public function testAllFormattersCanBeInstantiated(): void + { + foreach ($this->discoverFormatters() as $formatterClass) { + try { + $formatter = FormatterTestHelper::assertFormatterCanBeInstantiated( + $formatterClass, + IAuditStrategy::EVENT_ENTITY_CREATION + ); + + FormatterTestHelper::assertFormatterHasSetContextMethod($formatter); + $formatter->setContext($this->defaultContext); + $this->assertNotNull($formatter); + } catch (\Exception $e) { + $this->fail("Failed to validate {$formatterClass}: " . $e->getMessage()); + } + } + } + + public function testAllFormatterConstructorParametersRequired(): void + { + $errors = []; + $count = 0; + + foreach ($this->discoverFormatters() as $formatterClass) { + try { + FormatterTestHelper::assertFormatterHasValidConstructor($formatterClass); + $count++; + } catch (\Exception $e) { + $errors[] = "{$formatterClass}: " . $e->getMessage(); + } + } + + $this->assertEmpty($errors, implode("\n", $errors)); + $this->assertGreaterThan(0, $count, 'At least one formatter should be validated'); + } + + public function testAllFormattersHandleAllEventTypes(): void + { + $eventTypes = [ + IAuditStrategy::EVENT_ENTITY_CREATION, + IAuditStrategy::EVENT_ENTITY_UPDATE, + IAuditStrategy::EVENT_ENTITY_DELETION, + IAuditStrategy::EVENT_COLLECTION_UPDATE, + ]; + + $errors = []; + $unsupported = []; + + foreach ($this->discoverFormatters() as $formatterClass) { + foreach ($eventTypes as $eventType) { + try { + $formatter = FormatterTestHelper::assertFormatterCanBeInstantiated( + $formatterClass, + $eventType + ); + $formatter->setContext($this->defaultContext); + $this->assertNotNull($formatter); + } catch (\Exception $e) { + if (strpos($e->getMessage(), 'event type') !== false) { + $unsupported[] = "{$formatterClass} does not support {$eventType}"; + } else { + $errors[] = "{$formatterClass} with {$eventType}: " . $e->getMessage(); + } + } + } + } + + $this->assertEmpty($errors, "Event type handling failed:\n" . implode("\n", $errors)); + } + + public function testAllFormattersHandleInvalidSubjectGracefully(): void + { + $errors = []; + $count = 0; + + foreach ($this->discoverFormatters() as $formatterClass) { + try { + $formatter = FormatterTestHelper::assertFormatterCanBeInstantiated( + $formatterClass, + IAuditStrategy::EVENT_ENTITY_CREATION + ); + $formatter->setContext($this->defaultContext); + + FormatterTestHelper::assertFormatterHandlesInvalidSubjectGracefully($formatter, new \stdClass()); + $count++; + } catch (\Exception $e) { + $errors[] = "{$formatterClass}: " . $e->getMessage(); + } + } + + $this->assertEmpty($errors, implode("\n", $errors)); + $this->assertGreaterThan(0, $count, 'At least one formatter should be validated'); + } + + public function testAllFormattersHandleMissingContextGracefully(): void + { + $errors = []; + $count = 0; + + foreach ($this->discoverFormatters() as $formatterClass) { + try { + $formatter = FormatterTestHelper::assertFormatterCanBeInstantiated( + $formatterClass, + IAuditStrategy::EVENT_ENTITY_CREATION + ); + + $result = $formatter->format(new \stdClass(), []); + + $this->assertNull( + $result, + "{$formatterClass}::format() must return null when context not set, got " . + (is_string($result) ? "'{$result}'" : gettype($result)) + ); + $count++; + } catch (\Exception $e) { + $errors[] = "{$formatterClass} threw exception without context: " . $e->getMessage(); + } + } + + $this->assertEmpty($errors, implode("\n", $errors)); + $this->assertGreaterThan(0, $count, 'At least one formatter should be validated'); + } + + public function testFormattersHandleEmptyChangeSetGracefully(): void + { + $errors = []; + $count = 0; + + foreach ($this->discoverFormatters() as $formatterClass) { + try { + $formatter = FormatterTestHelper::assertFormatterCanBeInstantiated( + $formatterClass, + IAuditStrategy::EVENT_ENTITY_UPDATE + ); + $formatter->setContext($this->defaultContext); + + FormatterTestHelper::assertFormatterHandlesEmptyChangesetGracefully($formatter); + $count++; + } catch (\Exception $e) { + $errors[] = "{$formatterClass}: " . $e->getMessage(); + } + } + + $this->assertEmpty($errors, implode("\n", $errors)); + $this->assertGreaterThan(0, $count, 'At least one formatter should be validated'); + } + + public function testAllFormattersImplementCorrectInterfaces(): void + { + $errors = []; + $count = 0; + + foreach ($this->discoverFormatters() as $formatterClass) { + try { + FormatterTestHelper::assertFormatterExtendsAbstractFormatter($formatterClass); + FormatterTestHelper::assertFormatterHasValidFormatMethod($formatterClass); + $count++; + } catch (\Exception $e) { + $errors[] = "{$formatterClass}: " . $e->getMessage(); + } + } + + $this->assertEmpty($errors, implode("\n", $errors)); + $this->assertGreaterThan(0, $count, 'At least one formatter should be validated'); + } + + public function testAllFormattersHaveCorrectFormatMethodSignature(): void + { + $errors = []; + $count = 0; + + foreach ($this->discoverFormatters() as $formatterClass) { + try { + FormatterTestHelper::assertFormatterHasValidFormatMethod($formatterClass); + $count++; + } catch (\Exception $e) { + $errors[] = "{$formatterClass}: " . $e->getMessage(); + } + } + + $this->assertEmpty($errors, implode("\n", $errors)); + $this->assertGreaterThan(0, $count, 'At least one formatter should be validated'); + } + + + public function testAuditContextHasRequiredFields(): void + { + $context = $this->defaultContext; + + $this->assertIsInt($context->userId); + $this->assertGreaterThan(0, $context->userId); + + $this->assertIsString($context->userEmail); + $this->assertNotEmpty($context->userEmail); + $this->assertNotFalse(filter_var($context->userEmail, FILTER_VALIDATE_EMAIL), + "User email '{$context->userEmail}' is not valid"); + + $this->assertIsString($context->userFirstName); + $this->assertNotEmpty($context->userFirstName); + + $this->assertIsString($context->userLastName); + $this->assertNotEmpty($context->userLastName); + + $this->assertIsString($context->uiApp); + $this->assertNotEmpty($context->uiApp); + + $this->assertIsString($context->uiFlow); + $this->assertNotEmpty($context->uiFlow); + + $this->assertIsString($context->route); + $this->assertNotEmpty($context->route); + + $this->assertIsString($context->httpMethod); + $this->assertNotEmpty($context->httpMethod); + + $this->assertIsString($context->clientIp); + $this->assertNotEmpty($context->clientIp); + $this->assertNotFalse(filter_var($context->clientIp, FILTER_VALIDATE_IP), + "Client IP '{$context->clientIp}' is not valid"); + + $this->assertIsString($context->userAgent); + $this->assertNotEmpty($context->userAgent); + } + + public function testAuditStrategyDefinesAllEventTypes(): void + { + $this->assertTrue(defined('App\Audit\Interfaces\IAuditStrategy::EVENT_ENTITY_CREATION')); + $this->assertTrue(defined('App\Audit\Interfaces\IAuditStrategy::EVENT_ENTITY_UPDATE')); + $this->assertTrue(defined('App\Audit\Interfaces\IAuditStrategy::EVENT_ENTITY_DELETION')); + $this->assertTrue(defined('App\Audit\Interfaces\IAuditStrategy::EVENT_COLLECTION_UPDATE')); + } + + public function testFactoryInstantiatesCorrectFormatterForSubject(): void + { + $factory = new AuditLogFormatterFactory(); + + $unknownSubject = new \stdClass(); + $formatter = $factory->make($this->defaultContext, $unknownSubject, IAuditStrategy::EVENT_ENTITY_CREATION); + + $this->assertTrue( + $formatter === null || $formatter instanceof IAuditLogFormatter, + 'Factory must return null or IAuditLogFormatter for unknown subject type' + ); + + $validSubject = new class { + public function __toString() { return 'MockEntity'; } + }; + + $formatter = $factory->make($this->defaultContext, $validSubject, IAuditStrategy::EVENT_ENTITY_CREATION); + + if ($formatter !== null) { + $this->assertInstanceOf( + IAuditLogFormatter::class, + $formatter, + 'Factory must return IAuditLogFormatter instance for valid subject' + ); + + $this->assertNotNull($formatter, 'Returned formatter must not be null'); + } + } +} diff --git a/tests/OpenTelemetry/Formatters/Support/AuditContextBuilder.php b/tests/OpenTelemetry/Formatters/Support/AuditContextBuilder.php new file mode 100644 index 000000000..2b8f62655 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/Support/AuditContextBuilder.php @@ -0,0 +1,125 @@ +withUserId(1) + ->withUserEmail('test@example.com') + ->withUserName('Test', 'User') + ->withUiApp('test-app') + ->withUiFlow('test-flow') + ->withRoute('api.test.route') + ->withHttpMethod('POST') + ->withClientIp('127.0.0.1') + ->withUserAgent('Test-Agent/1.0'); + } + + public function withUserId(?int $userId): self + { + $this->userId = $userId; + return $this; + } + + public function withUserEmail(?string $userEmail): self + { + $this->userEmail = $userEmail; + return $this; + } + + public function withUserName(?string $firstName, ?string $lastName): self + { + $this->userFirstName = $firstName; + $this->userLastName = $lastName; + return $this; + } + + public function withUiApp(?string $uiApp): self + { + $this->uiApp = $uiApp; + return $this; + } + + public function withUiFlow(?string $uiFlow): self + { + $this->uiFlow = $uiFlow; + return $this; + } + + public function withRoute(?string $route): self + { + $this->route = $route; + return $this; + } + + public function withHttpMethod(?string $httpMethod): self + { + $this->httpMethod = $httpMethod; + return $this; + } + + public function withClientIp(?string $clientIp): self + { + $this->clientIp = $clientIp; + return $this; + } + + public function withUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + return $this; + } + + /** + * Build the AuditContext with the configured values + */ + public function build(): AuditContext + { + return new AuditContext( + userId: $this->userId, + userEmail: $this->userEmail, + userFirstName: $this->userFirstName, + userLastName: $this->userLastName, + uiApp: $this->uiApp, + uiFlow: $this->uiFlow, + route: $this->route, + rawRoute: null, + httpMethod: $this->httpMethod, + clientIp: $this->clientIp, + userAgent: $this->userAgent, + ); + } +} diff --git a/tests/OpenTelemetry/Formatters/Support/FormatterTestHelper.php b/tests/OpenTelemetry/Formatters/Support/FormatterTestHelper.php new file mode 100644 index 000000000..888b56fcd --- /dev/null +++ b/tests/OpenTelemetry/Formatters/Support/FormatterTestHelper.php @@ -0,0 +1,172 @@ +newInstance($eventType); + } catch (\Throwable $e) { + $formatter = $reflection->newInstance(); + } + + if (!$formatter instanceof IAuditLogFormatter) { + throw new \Exception("Formatter must implement IAuditLogFormatter"); + } + + return $formatter; + } catch (\ReflectionException $e) { + throw new \Exception("Failed to instantiate {$formatterClass}: " . $e->getMessage()); + } + } + + public static function assertFormatterHasSetContextMethod(IAuditLogFormatter $formatter): void + { + $reflection = new ReflectionClass($formatter); + + if (!$reflection->hasMethod('setContext')) { + throw new \Exception( + get_class($formatter) . " must have a setContext method" + ); + } + } + + public static function assertFormatterHasValidConstructor(string $formatterClass): void + { + try { + $reflection = new ReflectionClass($formatterClass); + + if ($reflection->isAbstract()) { + throw new \Exception("Cannot test abstract formatter: {$formatterClass}"); + } + + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return; + } + + try { + $reflection->newInstance(IAuditStrategy::EVENT_ENTITY_CREATION); + return; + } catch (\Throwable $e) { + try { + $reflection->newInstance(); + return; + } catch (\Throwable $e) { + $requiredParams = []; + foreach ($constructor->getParameters() as $param) { + if (!$param->isOptional() && !$param->allowsNull()) { + $requiredParams[] = $param->getName(); + } + } + + if (!empty($requiredParams)) { + throw new \Exception( + "{$formatterClass} has required constructor parameters: " . + implode(', ', $requiredParams) . + ". These parameters must either have default values or be optionally injectable." + ); + } + throw $e; + } + } + } catch (\ReflectionException $e) { + throw new \Exception("Failed to validate constructor for {$formatterClass}: " . $e->getMessage()); + } + } + + public static function assertFormatterHandlesInvalidSubjectGracefully( + IAuditLogFormatter $formatter, + mixed $invalidSubject + ): void { + try { + $formatter->format($invalidSubject, []); + } catch (\Throwable $e) { + throw new \Exception( + get_class($formatter) . " must handle invalid subjects gracefully: " . $e->getMessage() + ); + } + } + + public static function assertFormatterHandlesEmptyChangesetGracefully( + IAuditLogFormatter $formatter + ): void { + try { + $formatter->format(new \stdClass(), []); + } catch (\Throwable $e) { + throw new \Exception( + get_class($formatter) . " must handle empty changesets gracefully: " . $e->getMessage() + ); + } + } + + public static function assertFormatterExtendsAbstractFormatter(string $formatterClass): void + { + try { + $reflection = new ReflectionClass($formatterClass); + + if (!$reflection->isSubclassOf('App\Audit\AbstractAuditLogFormatter')) { + throw new \Exception( + "{$formatterClass} must extend AbstractAuditLogFormatter" + ); + } + } catch (\ReflectionException $e) { + throw new \Exception("Failed to validate {$formatterClass}: " . $e->getMessage()); + } + } + + public static function assertFormatterHasValidFormatMethod(string $formatterClass): void + { + try { + $reflection = new ReflectionClass($formatterClass); + + if (!$reflection->hasMethod('format')) { + throw new \Exception( + "{$formatterClass} must have a format() method" + ); + } + + $method = $reflection->getMethod('format'); + + if ($method->isAbstract()) { + throw new \Exception( + "{$formatterClass}::format() must not be abstract" + ); + } + + $params = $method->getParameters(); + if (count($params) < 1) { + throw new \Exception( + "{$formatterClass}::format() must accept at least 1 parameter (subject)" + ); + } + } catch (\ReflectionException $e) { + throw new \Exception("Failed to validate format method for {$formatterClass}: " . $e->getMessage()); + } + } +}