diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..9ba5185 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,52 @@ + $_ENV['LOG_CHANNEL'] ?? 'daily', + 'level' => $_ENV['LOG_LEVEL'] ?? 'debug', + 'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['daily', 'stdout'], + ], + 'single' => [ + 'driver' => 'single', + 'path' => $_ENV['LOG_PATH'] ?? 'storage/logs/pivotphp.log', + 'level' => $_ENV['LOG_LEVEL'] ?? 'debug', + 'formatter' => 'line', + ], + 'daily' => [ + 'driver' => 'daily', + 'path' => $_ENV['LOG_PATH'] ?? 'storage/logs/pivotphp.log', + 'level' => $_ENV['LOG_LEVEL'] ?? 'debug', + 'days' => (int) ($_ENV['LOG_DAILY_DAYS'] ?? 14), + 'formatter' => 'line', + ], + 'stdout' => [ + 'driver' => 'stdout', + 'stream' => 'php://stdout', + 'level' => $_ENV['LOG_LEVEL'] ?? 'info', + 'formatter' => 'json', + ], + 'stderr' => [ + 'driver' => 'stderr', + 'stream' => 'php://stderr', + 'level' => 'error', + 'formatter' => 'line', + ], + 'syslog' => [ + 'driver' => 'syslog', + 'ident' => 'pivotphp', + 'level' => 'notice', + ], + 'null' => [ + 'driver' => 'null', + ], + ], + 'mask_sensitive' => true, +]; + diff --git a/src/Formatters/JsonLogFormatter.php b/src/Formatters/JsonLogFormatter.php new file mode 100644 index 0000000..42f8901 --- /dev/null +++ b/src/Formatters/JsonLogFormatter.php @@ -0,0 +1,65 @@ +dateFormat = DateTimeInterface::RFC3339_EXTENDED; + } + + /** + * Formats a log record into a structured JSON line. + */ + public function format(LogRecord $record): string + { + /** @var array $normalized */ + $normalized = $this->normalizeRecord($record); + + $payload = [ + 'timestamp' => $record->datetime->format($this->dateFormat), + 'channel' => $record->channel, + 'level' => $record->level->getName(), + 'message' => $record->message, + 'context' => $normalized['context'] ?? [], + 'extra' => $normalized['extra'] ?? [], + ]; + + if ($this->ignoreEmptyContextAndExtra) { + if (empty($payload['context'])) { + unset($payload['context']); + } + if (empty($payload['extra'])) { + unset($payload['extra']); + } + } + + return $this->toJson($payload, true) . ($this->appendNewline ? "\n" : ''); + } +} diff --git a/src/LoggerFactory.php b/src/LoggerFactory.php index 2833200..9f0069b 100644 --- a/src/LoggerFactory.php +++ b/src/LoggerFactory.php @@ -4,21 +4,199 @@ namespace PivotPHP\Logger; +use InvalidArgumentException; +use Monolog\Formatter\FormatterInterface; +use Monolog\Formatter\LineFormatter; +use Monolog\Handler\GroupHandler; +use Monolog\Handler\HandlerInterface; +use Monolog\Handler\NullHandler; +use Monolog\Handler\RotatingFileHandler; +use Monolog\Handler\StreamHandler; +use Monolog\Handler\SyslogHandler; +use Monolog\Level; +use Monolog\Logger; +use PivotPHP\Logger\Formatters\JsonLogFormatter; +use PivotPHP\Logger\Processors\MaskSensitiveDataProcessor; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; /** - * Factory for creating PSR-3 Logger instances. + * Factory for creating PSR-3 compliant Monolog logger instances with multi-channel support. */ class LoggerFactory { + public const DEFAULT_CHANNEL_NAME = 'pivotphp'; + public const DEFAULT_LOG_FILENAME = 'pivotphp.log'; + /** - * Creates a new Logger instance based on configuration. + * Creates a new LoggerInterface instance based on configuration and environment variables. * * @param array $config */ public static function create(array $config = []): LoggerInterface { - return new NullLogger(); + $channelName = (string) ($config['name'] ?? self::DEFAULT_CHANNEL_NAME); + $defaultChannel = (string) ($config['default'] ?? ($_ENV['LOG_CHANNEL'] ?? 'single')); + + return self::channel($defaultChannel, $config, $channelName); + } + + /** + * Creates a logger configured for a specific channel name. + * + * @param string $channel + * @param array $config + * @param string|null $loggerName + */ + public static function channel(string $channel, array $config = [], ?string $loggerName = null): LoggerInterface + { + $name = $loggerName ?? (string) ($config['name'] ?? self::DEFAULT_CHANNEL_NAME); + $logger = new Logger($name); + + $handler = self::resolveHandler($channel, $config); + $logger->pushHandler($handler); + + // Apply sensitive data masking processor by default + $maskSensitive = $config['mask_sensitive'] ?? true; + if ($maskSensitive !== false) { + $sensitiveKeys = isset($config['sensitive_keys']) && is_array($config['sensitive_keys']) + /** @var list $keys */ + ? array_values((array) $config['sensitive_keys']) + : null; + + $processor = $sensitiveKeys !== null + ? new MaskSensitiveDataProcessor($sensitiveKeys) + : new MaskSensitiveDataProcessor(); + + $logger->pushProcessor($processor); + } + + return $logger; + } + + /** + * Resolves and builds a Monolog HandlerInterface for a given channel name. + * + * @param string $channel + * @param array $config + */ + public static function resolveHandler(string $channel, array $config): HandlerInterface + { + /** @var array $channelConfig */ + $channelConfig = $config['channels'][$channel] ?? ['driver' => $channel]; + $driver = (string) ($channelConfig['driver'] ?? $channel); + + $fallbackLevel = (string) ($config['level'] ?? ($_ENV['LOG_LEVEL'] ?? 'debug')); + $levelStr = (string) ($channelConfig['level'] ?? $fallbackLevel); + $level = self::parseLevel($levelStr); + $bubble = (bool) ($channelConfig['bubble'] ?? true); + + $formatter = self::resolveFormatter($channelConfig['formatter'] ?? 'line'); + + switch ($driver) { + case 'single': + $defaultPath = sys_get_temp_dir() . '/' . self::DEFAULT_LOG_FILENAME; + $path = (string) ($channelConfig['path'] ?? ($_ENV['LOG_PATH'] ?? $defaultPath)); + $handler = new StreamHandler($path, $level, $bubble); + $handler->setFormatter($formatter); + return $handler; + + case 'daily': + $defaultPath = sys_get_temp_dir() . '/' . self::DEFAULT_LOG_FILENAME; + $path = (string) ($channelConfig['path'] ?? ($_ENV['LOG_PATH'] ?? $defaultPath)); + $days = (int) ($channelConfig['days'] ?? ($_ENV['LOG_DAILY_DAYS'] ?? 14)); + $handler = new RotatingFileHandler($path, $days, $level, $bubble); + $handler->setFormatter($formatter); + return $handler; + + case 'stdout': + $stream = (string) ($channelConfig['stream'] ?? 'php://stdout'); + $handler = new StreamHandler($stream, $level, $bubble); + $handler->setFormatter($formatter); + return $handler; + + case 'stderr': + $stream = (string) ($channelConfig['stream'] ?? 'php://stderr'); + $handler = new StreamHandler($stream, $level, $bubble); + $handler->setFormatter($formatter); + return $handler; + + case 'syslog': + $ident = (string) ($channelConfig['ident'] ?? 'pivotphp'); + $facility = $channelConfig['facility'] ?? LOG_USER; + $handler = new SyslogHandler($ident, $facility, $level, $bubble); + $handler->setFormatter($formatter); + return $handler; + + case 'null': + return new NullHandler(); + + case 'stack': + /** @var list $stackChannels */ + $stackChannels = (array) ($channelConfig['channels'] ?? ['single']); + $handlers = []; + foreach ($stackChannels as $subChannel) { + // Prevent infinite recursion if stack references itself + if ($subChannel !== $channel) { + $handlers[] = self::resolveHandler($subChannel, $config); + } + } + return new GroupHandler($handlers, $bubble); + + default: + throw new InvalidArgumentException("Unsupported log channel driver: '{$driver}'"); + } + } + + /** + * Resolves a FormatterInterface instance based on string identifier or instance. + * + * @param FormatterInterface|string|null $formatter + */ + public static function resolveFormatter(mixed $formatter): FormatterInterface + { + if ($formatter instanceof FormatterInterface) { + return $formatter; + } + + if ($formatter === 'json') { + return new JsonLogFormatter(); + } + + // Default: LineFormatter with ISO standard timestamp and inline linebreaks + return new LineFormatter( + format: "[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n", + dateFormat: 'Y-m-d H:i:s', + allowInlineLineBreaks: true, + ignoreEmptyContextAndExtra: true + ); + } + + /** + * Parses a log level name or integer into a Monolog\Level enum instance. + */ + public static function parseLevel(string|int|Level $level, Level $default = Level::Debug): Level + { + if ($level instanceof Level) { + return $level; + } + + if (is_int($level)) { + $fromInt = Level::tryFrom($level); + return $fromInt ?? $default; + } + + $upper = strtoupper(trim($level)); + + return match ($upper) { + 'DEBUG' => Level::Debug, + 'INFO' => Level::Info, + 'NOTICE' => Level::Notice, + 'WARNING' => Level::Warning, + 'ERROR' => Level::Error, + 'CRITICAL' => Level::Critical, + 'ALERT' => Level::Alert, + 'EMERGENCY' => Level::Emergency, + default => $default, + }; } } diff --git a/src/LoggerServiceProvider.php b/src/LoggerServiceProvider.php new file mode 100644 index 0000000..03fa29f --- /dev/null +++ b/src/LoggerServiceProvider.php @@ -0,0 +1,104 @@ + Optional configuration override + */ + protected array $config; + + /** + * @param object $app Application or Container instance + * @param array $config + */ + public function __construct(object $app, array $config = []) + { + $this->app = $app; + $this->config = $config; + } + + /** + * Registers LoggerInterface and its aliases into the application container. + */ + public function register(): void + { + $config = $this->resolveConfig(); + + if (method_exists($this->app, 'singleton')) { + $this->app->singleton( + LoggerInterface::class, + static function () use ($config): LoggerInterface { + return LoggerFactory::create($config); + } + ); + } + + if (method_exists($this->app, 'alias')) { + $this->app->alias('log', LoggerInterface::class); + $this->app->alias('logger', LoggerInterface::class); + } + } + + /** + * Convenient static helper to register into an application instance. + * + * @param object $app + * @param array $config + */ + public static function registerTo(object $app, array $config = []): void + { + $provider = new self($app, $config); + $provider->register(); + } + + /** + * Resolves logging configuration from explicit config, app getConfig() or environment. + * + * @return array + */ + protected function resolveConfig(): array + { + if (!empty($this->config)) { + return $this->config; + } + + if (method_exists($this->app, 'getConfig')) { + /** @var mixed $appConfig */ + $appConfig = $this->app->getConfig('logging', []); + if (is_array($appConfig) && !empty($appConfig)) { + /** @var array $appConfig */ + return $appConfig; + } + } + + return []; + } + + /** + * Get the services provided by this provider. + * + * @return list + */ + public function provides(): array + { + return [ + LoggerInterface::class, + 'log', + 'logger', + ]; + } +} diff --git a/src/Processors/MaskSensitiveDataProcessor.php b/src/Processors/MaskSensitiveDataProcessor.php new file mode 100644 index 0000000..369b6c3 --- /dev/null +++ b/src/Processors/MaskSensitiveDataProcessor.php @@ -0,0 +1,90 @@ + + */ + private array $sensitiveKeys; + + private string $mask; + + /** + * @param list $sensitiveKeys List of key names to mask (case-insensitive) + * @param string $mask Replacement string for sensitive values + */ + public function __construct( + array $sensitiveKeys = [ + 'password', + 'password_confirmation', + 'token', + 'access_token', + 'refresh_token', + 'secret', + 'api_key', + 'apikey', + 'authorization', + 'auth', + 'cpf', + 'card_number', + 'cvv', + 'credit_card', + ], + string $mask = '********' + ) { + $this->sensitiveKeys = array_values(array_map('strtolower', $sensitiveKeys)); + $this->mask = $mask; + } + + /** + * Masks sensitive entries within context and extra records. + */ + public function __invoke(LogRecord $record): LogRecord + { + /** @var array $context */ + $context = $this->maskArray($record->context); + + /** @var array $extra */ + $extra = $this->maskArray($record->extra); + + return $record->with( + context: $context, + extra: $extra + ); + } + + /** + * Recursively masks sensitive keys in arrays. + * + * @param array $data + * @return array + */ + private function maskArray(array $data): array + { + $result = []; + + foreach ($data as $key => $value) { + $isSensitive = is_string($key) && in_array(strtolower($key), $this->sensitiveKeys, true); + + if ($isSensitive) { + $result[$key] = $this->mask; + } elseif (is_array($value)) { + $result[$key] = $this->maskArray($value); + } else { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/tests/Formatters/JsonLogFormatterTest.php b/tests/Formatters/JsonLogFormatterTest.php new file mode 100644 index 0000000..1260490 --- /dev/null +++ b/tests/Formatters/JsonLogFormatterTest.php @@ -0,0 +1,92 @@ + 42], + extra: ['ip' => '127.0.0.1'] + ); + + $output = $formatter->format($record); + + $this->assertStringEndsWith("\n", $output); + + $decoded = json_decode($output, true); + $this->assertIsArray($decoded); + $this->assertSame('api', $decoded['channel']); + $this->assertSame('INFO', $decoded['level']); + $this->assertSame('User logged in', $decoded['message']); + $this->assertSame(['user_id' => 42], $decoded['context']); + $this->assertSame(['ip' => '127.0.0.1'], $decoded['extra']); + $this->assertArrayHasKey('timestamp', $decoded); + } + + public function testIgnoreEmptyContextAndExtraWhenConfigured(): void + { + $formatter = new JsonLogFormatter( + ignoreEmptyContextAndExtra: true + ); + + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'cli', + level: Level::Warning, + message: 'Memory threshold warning', + context: [], + extra: [] + ); + + $output = $formatter->format($record); + $decoded = json_decode($output, true); + + $this->assertIsArray($decoded); + $this->assertArrayNotHasKey('context', $decoded); + $this->assertArrayNotHasKey('extra', $decoded); + } + + public function testHandlesNonUtf8StringsWithoutThrowing(): void + { + $formatter = new JsonLogFormatter(); + + $invalidUtf8 = chr(193) . chr(175); + $latin1 = (string) iconv('UTF-8', 'ISO-8859-1', 'Ação'); + + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'encoding', + level: Level::Info, + message: 'Mensagem com ISO: ' . $latin1, + context: [ + 'binary' => $invalidUtf8, + 'latin1' => $latin1, + ], + extra: [] + ); + + $output = $formatter->format($record); + + $decoded = json_decode($output, true); + $this->assertIsArray($decoded); + $this->assertSame('encoding', $decoded['channel']); + $this->assertArrayHasKey('binary', $decoded['context']); + $this->assertArrayHasKey('latin1', $decoded['context']); + } +} diff --git a/tests/LoggerFactoryTest.php b/tests/LoggerFactoryTest.php index 48c1a2b..4f0ae6a 100644 --- a/tests/LoggerFactoryTest.php +++ b/tests/LoggerFactoryTest.php @@ -4,16 +4,339 @@ namespace PivotPHP\Logger\Tests; +use InvalidArgumentException; +use Monolog\Handler\TestHandler; +use Monolog\Level; +use Monolog\Logger; use PHPUnit\Framework\TestCase; +use PivotPHP\Logger\Formatters\JsonLogFormatter; use PivotPHP\Logger\LoggerFactory; use Psr\Log\LoggerInterface; class LoggerFactoryTest extends TestCase { + private string $tempLogFile; + + protected function setUp(): void + { + $this->tempLogFile = sys_get_temp_dir() . '/test_pivotphp_' . uniqid('', true) . '.log'; + unset($_ENV['LOG_CHANNEL'], $_ENV['LOG_LEVEL'], $_ENV['LOG_PATH']); + } + + protected function tearDown(): void + { + if (file_exists($this->tempLogFile)) { + @unlink($this->tempLogFile); + } + foreach (glob(sys_get_temp_dir() . '/test_pivotphp_*.log') ?: [] as $file) { + @unlink($file); + } + } + public function testCreateReturnsLoggerInterface(): void { $logger = LoggerFactory::create(); $this->assertInstanceOf(LoggerInterface::class, $logger); + $this->assertInstanceOf(Logger::class, $logger); + } + + public function testSingleChannelWritesToFile(): void + { + $logger = LoggerFactory::create([ + 'default' => 'single', + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => $this->tempLogFile, + 'level' => 'debug', + ], + ], + ]); + + $logger->info('Hello from single channel', ['app' => 'pivotphp']); + + $this->assertFileExists($this->tempLogFile); + $content = (string) file_get_contents($this->tempLogFile); + $this->assertStringContainsString('INFO: Hello from single channel', $content); + $this->assertStringContainsString('"app":"pivotphp"', $content); + } + + public function testDailyChannelCreatesFile(): void + { + $logger = LoggerFactory::create([ + 'default' => 'daily', + 'channels' => [ + 'daily' => [ + 'driver' => 'daily', + 'path' => $this->tempLogFile, + 'days' => 7, + 'level' => 'info', + ], + ], + ]); + + $logger->info('Hello daily rotation'); + + // RotatingFileHandler produces: {filename}-{date}.log + $pattern = sys_get_temp_dir() . '/' . pathinfo($this->tempLogFile, PATHINFO_FILENAME) . '-*.log'; + $rotatedFiles = glob($pattern) ?: []; + $this->assertNotEmpty($rotatedFiles); + + $content = (string) file_get_contents($rotatedFiles[0]); + $this->assertStringContainsString('Hello daily rotation', $content); + } + + public function testStdoutChannelWithJsonFormatter(): void + { + $logger = LoggerFactory::create([ + 'default' => 'stdout', + 'channels' => [ + 'stdout' => [ + 'driver' => 'stdout', + 'stream' => $this->tempLogFile, // Use temp file to inspect stream write + 'level' => 'info', + 'formatter' => 'json', + ], + ], + ]); + + $logger->warning('System alert', ['code' => 503]); + + $content = (string) file_get_contents($this->tempLogFile); + $decoded = json_decode(trim($content), true); + $this->assertIsArray($decoded); + $this->assertSame('WARNING', $decoded['level']); + $this->assertSame('System alert', $decoded['message']); + $this->assertSame(503, $decoded['context']['code']); + } + + public function testStderrChannel(): void + { + $logger = LoggerFactory::create([ + 'default' => 'stderr', + 'channels' => [ + 'stderr' => [ + 'driver' => 'stderr', + 'stream' => $this->tempLogFile, + 'level' => 'error', + ], + ], + ]); + + $logger->error('Fatal crash averted'); + + $content = (string) file_get_contents($this->tempLogFile); + $this->assertStringContainsString('ERROR: Fatal crash averted', $content); + } + + public function testNullChannelDiscardsLogs(): void + { + $logger = LoggerFactory::create([ + 'default' => 'null', + 'channels' => [ + 'null' => [ + 'driver' => 'null', + ], + ], + ]); + + $logger->emergency('Will not write anywhere'); + + $this->assertFileDoesNotExist($this->tempLogFile); + } + + public function testSyslogChannelInstantiation(): void + { + $logger = LoggerFactory::create([ + 'default' => 'syslog', + 'channels' => [ + 'syslog' => [ + 'driver' => 'syslog', + 'ident' => 'test-app', + 'level' => 'notice', + ], + ], + ]); + + $this->assertInstanceOf(LoggerInterface::class, $logger); + } + + public function testStackChannelAggregatesHandlers(): void + { + $fileA = sys_get_temp_dir() . '/stack_a_' . uniqid() . '.log'; + $fileB = sys_get_temp_dir() . '/stack_b_' . uniqid() . '.log'; + + $logger = LoggerFactory::create([ + 'default' => 'stack', + 'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['chan_a', 'chan_b'], + ], + 'chan_a' => [ + 'driver' => 'single', + 'path' => $fileA, + 'level' => 'debug', + ], + 'chan_b' => [ + 'driver' => 'single', + 'path' => $fileB, + 'level' => 'debug', + ], + ], + ]); + + $logger->info('Multi-cast message'); + + $this->assertFileExists($fileA); + $this->assertFileExists($fileB); + $this->assertStringContainsString('Multi-cast message', (string) file_get_contents($fileA)); + $this->assertStringContainsString('Multi-cast message', (string) file_get_contents($fileB)); + + @unlink($fileA); + @unlink($fileB); + } + + public function testLogLevelFiltering(): void + { + $logger = LoggerFactory::create([ + 'default' => 'single', + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => $this->tempLogFile, + 'level' => 'warning', // Filter out debug and info! + ], + ], + ]); + + $logger->debug('Ignored debug'); + $logger->info('Ignored info'); + $logger->warning('Captured warning'); + $logger->error('Captured error'); + + $content = (string) file_get_contents($this->tempLogFile); + $this->assertStringNotContainsString('Ignored debug', $content); + $this->assertStringNotContainsString('Ignored info', $content); + $this->assertStringContainsString('Captured warning', $content); + $this->assertStringContainsString('Captured error', $content); + } + + public function testMaskSensitiveDataIntegratedByDefault(): void + { + $logger = LoggerFactory::create([ + 'default' => 'single', + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => $this->tempLogFile, + 'level' => 'debug', + ], + ], + ]); + + $logger->info('Auth event', [ + 'username' => 'admin', + 'password' => 'super_secret', + 'token' => 'jwt_secret', + ]); + + $content = (string) file_get_contents($this->tempLogFile); + $this->assertStringContainsString('admin', $content); + $this->assertStringNotContainsString('super_secret', $content); + $this->assertStringNotContainsString('jwt_secret', $content); + $this->assertStringContainsString('********', $content); + } + + public function testMaskSensitiveDataCanBeDisabled(): void + { + $logger = LoggerFactory::create([ + 'default' => 'single', + 'mask_sensitive' => false, + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => $this->tempLogFile, + 'level' => 'debug', + ], + ], + ]); + + $logger->info('Debug raw', ['password' => 'visible_plain_password']); + + $content = (string) file_get_contents($this->tempLogFile); + $this->assertStringContainsString('visible_plain_password', $content); + } + + public function testChannelMethod(): void + { + $logger = LoggerFactory::channel('custom', [ + 'channels' => [ + 'custom' => [ + 'driver' => 'single', + 'path' => $this->tempLogFile, + 'level' => 'notice', + ], + ], + ]); + + $logger->notice('Custom channel message'); + + $this->assertStringContainsString('Custom channel message', (string) file_get_contents($this->tempLogFile)); + } + + public function testInvalidChannelDriverThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Unsupported log channel driver: 'non_existent'"); + + LoggerFactory::create([ + 'default' => 'broken', + 'channels' => [ + 'broken' => [ + 'driver' => 'non_existent', + ], + ], + ]); + } + + public function testParseLevel(): void + { + $this->assertSame(Level::Debug, LoggerFactory::parseLevel('debug')); + $this->assertSame(Level::Info, LoggerFactory::parseLevel('INFO')); + $this->assertSame(Level::Warning, LoggerFactory::parseLevel('warning')); + $this->assertSame(Level::Error, LoggerFactory::parseLevel('error')); + $this->assertSame(Level::Critical, LoggerFactory::parseLevel('critical')); + $this->assertSame(Level::Alert, LoggerFactory::parseLevel('alert')); + $this->assertSame(Level::Emergency, LoggerFactory::parseLevel('emergency')); + $this->assertSame(Level::Debug, LoggerFactory::parseLevel('unknown_fallback')); + } + + public function testHandlesNonUtf8StringsWithoutThrowing(): void + { + $logger = LoggerFactory::create([ + 'default' => 'single', + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => $this->tempLogFile, + ], + ], + ]); + + $invalidUtf8 = chr(193) . chr(175); + $latin1 = (string) iconv('UTF-8', 'ISO-8859-1', 'Ação'); + + $logger->info('Log com ISO: ' . $latin1, [ + 'binary' => $invalidUtf8, + 'latin1' => $latin1, + 'nested' => ['binary_sub' => $invalidUtf8], + ]); + + $this->assertFileExists($this->tempLogFile); + $content = (string) file_get_contents($this->tempLogFile); + $this->assertStringContainsString('Log com ISO:', $content); + $this->assertStringContainsString('INFO:', $content); } } diff --git a/tests/LoggerServiceProviderTest.php b/tests/LoggerServiceProviderTest.php new file mode 100644 index 0000000..2ad4af2 --- /dev/null +++ b/tests/LoggerServiceProviderTest.php @@ -0,0 +1,97 @@ + */ + public array $singletons = []; + + /** @var array */ + public array $aliases = []; + + public function singleton(string $abstract, callable $concrete): void + { + $this->singletons[$abstract] = $concrete; + } + + public function alias(string $alias, string $abstract): void + { + $this->aliases[$alias] = $abstract; + } + + public function getConfig(string $key, mixed $default = null): mixed + { + if ($key === 'logging') { + return [ + 'default' => 'null', + 'channels' => [ + 'null' => ['driver' => 'null'], + ], + ]; + } + return $default; + } + }; + + $provider = new LoggerServiceProvider($app); + $provider->register(); + + $this->assertArrayHasKey(LoggerInterface::class, $app->singletons); + $this->assertArrayHasKey('log', $app->aliases); + $this->assertArrayHasKey('logger', $app->aliases); + $this->assertSame(LoggerInterface::class, $app->aliases['log']); + $this->assertSame(LoggerInterface::class, $app->aliases['logger']); + + $factoryClosure = $app->singletons[LoggerInterface::class]; + $instance = $factoryClosure(); + + $this->assertInstanceOf(LoggerInterface::class, $instance); + } + + public function testRegisterToStaticHelper(): void + { + $app = new class () { + /** @var array */ + public array $singletons = []; + + public function singleton(string $abstract, callable $concrete): void + { + $this->singletons[$abstract] = $concrete; + } + + public function alias(string $alias, string $abstract): void + { + } + }; + + LoggerServiceProvider::registerTo($app, [ + 'default' => 'null', + 'channels' => ['null' => ['driver' => 'null']], + ]); + + $this->assertArrayHasKey(LoggerInterface::class, $app->singletons); + $instance = ($app->singletons[LoggerInterface::class])(); + $this->assertInstanceOf(LoggerInterface::class, $instance); + } + + public function testProvidesList(): void + { + $dummyApp = new \stdClass(); + $provider = new LoggerServiceProvider($dummyApp); + + $this->assertSame( + [LoggerInterface::class, 'log', 'logger'], + $provider->provides() + ); + } +} diff --git a/tests/Processors/MaskSensitiveDataProcessorTest.php b/tests/Processors/MaskSensitiveDataProcessorTest.php new file mode 100644 index 0000000..67d048c --- /dev/null +++ b/tests/Processors/MaskSensitiveDataProcessorTest.php @@ -0,0 +1,124 @@ + $context + * @param array $extra + */ + private function createRecord(array $context = [], array $extra = []): LogRecord + { + return new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Info, + message: 'Test message', + context: $context, + extra: $extra + ); + } + + public function testMasksDefaultSensitiveKeysInContext(): void + { + $processor = new MaskSensitiveDataProcessor(); + + $record = $this->createRecord([ + 'username' => 'john_doe', + 'password' => 'super_secret_123', + 'token' => 'jwt.token.abc', + 'cpf' => '123.456.789-00', + 'status' => 'active', + ]); + + $processed = $processor($record); + + $this->assertSame('john_doe', $processed->context['username']); + $this->assertSame('********', $processed->context['password']); + $this->assertSame('********', $processed->context['token']); + $this->assertSame('********', $processed->context['cpf']); + $this->assertSame('active', $processed->context['status']); + } + + public function testCaseInsensitiveKeyMatching(): void + { + $processor = new MaskSensitiveDataProcessor(); + + $record = $this->createRecord([ + 'PASSWORD' => 'secret1', + 'Token' => 'secret2', + 'API_KEY' => 'key123', + ]); + + $processed = $processor($record); + + $this->assertSame('********', $processed->context['PASSWORD']); + $this->assertSame('********', $processed->context['Token']); + $this->assertSame('********', $processed->context['API_KEY']); + } + + public function testRecursiveMaskingInNestedArrays(): void + { + $processor = new MaskSensitiveDataProcessor(); + + $record = $this->createRecord([ + 'user' => [ + 'name' => 'Alice', + 'credentials' => [ + 'password' => 'secret', + 'refresh_token' => 'rf_token_999', + ], + ], + ]); + + $processed = $processor($record); + + $this->assertSame('Alice', $processed->context['user']['name']); + $this->assertSame('********', $processed->context['user']['credentials']['password']); + $this->assertSame('********', $processed->context['user']['credentials']['refresh_token']); + } + + public function testMasksExtraRecordFields(): void + { + $processor = new MaskSensitiveDataProcessor(); + + $record = $this->createRecord( + context: ['info' => 'ok'], + extra: ['secret' => 'extra_secret', 'env' => 'prod'] + ); + + $processed = $processor($record); + + $this->assertSame('********', $processed->extra['secret']); + $this->assertSame('prod', $processed->extra['env']); + } + + public function testCustomKeysAndMask(): void + { + $processor = new MaskSensitiveDataProcessor( + sensitiveKeys: ['ssn', 'pin'], + mask: '[REDACTED]' + ); + + $record = $this->createRecord([ + 'ssn' => '999-00-1111', + 'pin' => '1234', + 'password' => 'unaffected_since_not_in_custom_list', + ]); + + $processed = $processor($record); + + $this->assertSame('[REDACTED]', $processed->context['ssn']); + $this->assertSame('[REDACTED]', $processed->context['pin']); + $this->assertSame('unaffected_since_not_in_custom_list', $processed->context['password']); + } +}