From d93b1569f5ee50f0d516d4e062ad734ba2157eb8 Mon Sep 17 00:00:00 2001 From: Anatoliy Date: Tue, 9 Jun 2026 16:11:15 +0300 Subject: [PATCH] refactor(logging) --- src/AlfaBank.php | 17 +- src/Core/AlfaBankConnector.php | 35 ++- src/Http/Middleware/LoggingMiddleware.php | 75 ----- .../Middleware/LoggingRequestMiddleware.php | 12 +- .../Middleware/LoggingResponseMiddleware.php | 18 +- src/Laravel/AlfaBankServiceProvider.php | 16 +- src/Laravel/Config/alfa-bank.php | 13 + src/Sbp/SbpConnector.php | 38 ++- tests/Feature/LoggingTest.php | 294 ++++++++++++++++++ 9 files changed, 408 insertions(+), 110 deletions(-) delete mode 100644 src/Http/Middleware/LoggingMiddleware.php create mode 100644 tests/Feature/LoggingTest.php diff --git a/src/AlfaBank.php b/src/AlfaBank.php index b0ce6cd..01f766a 100644 --- a/src/AlfaBank.php +++ b/src/AlfaBank.php @@ -12,6 +12,7 @@ use Apoloz\AlfaBank\Enums\Environment; use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest; use Apoloz\AlfaBank\Sbp\SbpConnector; use Apoloz\AlfaBank\Services\OpenSslCmsSigner; +use Psr\Log\LoggerInterface; class AlfaBank { @@ -43,7 +44,8 @@ class AlfaBank ?string $clientSecret = null, ?string $redirectUri = null, ?string $signingCertificatePath = null, - ?string $signingPrivateKeyPath = null + ?string $signingPrivateKeyPath = null, + ?LoggerInterface $logger = null, ): AlfaBankConnector { $config = new AlfaConfig( baseUrl: $environment->value, @@ -62,7 +64,13 @@ class AlfaBank $signer = new OpenSslCmsSigner($signerCert, $signerKey); - return new AlfaBankConnector($config, $signer); + $connector = new AlfaBankConnector($config, $signer); + + if ($logger) { + $connector->withLogging($logger); + } + + return $connector; } // @@ -75,7 +83,8 @@ class AlfaBank Environment $environment = Environment::SANDBOX, ?string $caBundlePath = null, ?string $signingCertificatePath = null, - ?string $signingPrivateKeyPath = null + ?string $signingPrivateKeyPath = null, + ?LoggerInterface $logger = null, ): SbpConnector { $baseUrl = $environment === Environment::PRODUCTION ? Environment::SBP_PRODUCTION->value @@ -90,7 +99,7 @@ class AlfaBank signingPrivateKeyPath: $signingPrivateKeyPath ); - return new SbpConnector($config, $keyNameAlias); + return new SbpConnector($config, $keyNameAlias, logger: $logger); } // } diff --git a/src/Core/AlfaBankConnector.php b/src/Core/AlfaBankConnector.php index a2e6626..039cee0 100644 --- a/src/Core/AlfaBankConnector.php +++ b/src/Core/AlfaBankConnector.php @@ -15,6 +15,7 @@ use Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware; use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator; use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Saloon\Helpers\OAuth2\OAuthConfig; use Saloon\Http\Connector; use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest; @@ -37,10 +38,13 @@ class AlfaBankConnector extends Connector implements HasPagination public function __construct( protected AlfaConfig $alfaConfig, - protected ?SignerInterface $signer = null + protected ?SignerInterface $signer = null, + protected bool $loggingEnabled = true, + protected ?LoggerInterface $logger = null, ) { $this->tries = 3; $this->retryInterval = 500; + $this->logger = $this->logger ?? new NullLogger; } public function resolveBaseUrl(): string @@ -50,7 +54,12 @@ class AlfaBankConnector extends Connector implements HasPagination public function boot(PendingRequest $pendingRequest): void { - // + $pendingRequest->middleware()->onRequest(new RequestIdMiddleware); + + if ($this->loggingEnabled) { + $pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger)); + $pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger)); + } } public function paginate(Request $request): OffsetPaginator @@ -101,12 +110,19 @@ class AlfaBankConnector extends Connector implements HasPagination } // - //
- // + //
+ // public function withLogging(LoggerInterface $logger): static { - $this->middleware()->onRequest(new LoggingRequestMiddleware($logger)); - $this->middleware()->onResponse(new LoggingResponseMiddleware($logger)); + $this->logger = $logger; + $this->loggingEnabled = true; + + return $this; + } + + public function withoutLogging(): static + { + $this->loggingEnabled = false; return $this; } @@ -159,12 +175,5 @@ class AlfaBankConnector extends Connector implements HasPagination // OAuth токен запросы требуют application/x-www-form-urlencoded ]; } - - protected function defaultMiddleware(): array - { - return [ - new RequestIdMiddleware, - ]; - } //
} diff --git a/src/Http/Middleware/LoggingMiddleware.php b/src/Http/Middleware/LoggingMiddleware.php deleted file mode 100644 index da2c82b..0000000 --- a/src/Http/Middleware/LoggingMiddleware.php +++ /dev/null @@ -1,75 +0,0 @@ - - // - public function __construct(protected LoggerInterface $logger) {} - //
- - //
- // - public function onRequest(PendingRequest $request): void - { - $this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [ - 'headers' => $this->maskHeaders($request->headers()->all()), - 'body' => $this->maskBody($request->body()?->all()), - ]); - } - //
- - //
- // - public function onResponse(Response $response): void - { - $this->logger->info("AlfaBank Response: {$response->status()}", [ - 'body' => $this->maskBody($response->json()), - ]); - } - //
- - //
- // - protected function maskHeaders(array $headers): array - { - if (isset($headers['Authorization'])) { - $headers['Authorization'] = 'Bearer *****'; - } - - return $headers; - } - //
- - //
- // - protected function maskBody(mixed $body): mixed - { - if (! is_array($body)) { - return $body; - } - - $sensitiveKeys = ['pan', 'cvc', 'digestSignatures', 'client_secret', 'refresh_token', 'access_token']; - - foreach ($body as $key => $value) { - if (in_array($key, $sensitiveKeys, true)) { - $body[$key] = '*****'; - } elseif (is_array($value)) { - $body[$key] = $this->maskBody($value); - } - } - - return $body; - } - //
-} -// diff --git a/src/Http/Middleware/LoggingRequestMiddleware.php b/src/Http/Middleware/LoggingRequestMiddleware.php index 6d28384..1533541 100644 --- a/src/Http/Middleware/LoggingRequestMiddleware.php +++ b/src/Http/Middleware/LoggingRequestMiddleware.php @@ -14,10 +14,14 @@ class LoggingRequestMiddleware implements RequestMiddleware public function __invoke(PendingRequest $request): void { - $this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [ - 'headers' => $this->maskHeaders($request->headers()->all()), - 'body' => $this->maskBody($request->body()?->all()), - ]); + $this->logger->info( + "AlfaBank Request: {$request->getMethod()->value} {$request->getUrl()}", + [ + 'host' => $request->getConnector()->resolveBaseUrl(), + 'headers' => $this->maskHeaders($request->headers()->all()), + 'body' => $this->maskBody($request->body()?->all()), + ] + ); } protected function maskHeaders(array $headers): array diff --git a/src/Http/Middleware/LoggingResponseMiddleware.php b/src/Http/Middleware/LoggingResponseMiddleware.php index d091084..bb1917c 100644 --- a/src/Http/Middleware/LoggingResponseMiddleware.php +++ b/src/Http/Middleware/LoggingResponseMiddleware.php @@ -14,9 +14,21 @@ class LoggingResponseMiddleware implements ResponseMiddleware public function __invoke(Response $response): void { - $this->logger->info("AlfaBank Response: {$response->status()}", [ - 'body' => $this->maskBody($response->json()), - ]); + $pendingRequest = $response->getPendingRequest(); + + try { + $body = $this->maskBody($response->json()); + } catch (\JsonException) { + $body = $response->body(); + } + + $this->logger->info( + "AlfaBank Response: {$response->status()} {$pendingRequest->getUrl()}", + [ + 'host' => $pendingRequest->getConnector()->resolveBaseUrl(), + 'body' => $body, + ] + ); } protected function maskBody(mixed $body): mixed diff --git a/src/Laravel/AlfaBankServiceProvider.php b/src/Laravel/AlfaBankServiceProvider.php index b849dca..98ca239 100644 --- a/src/Laravel/AlfaBankServiceProvider.php +++ b/src/Laravel/AlfaBankServiceProvider.php @@ -24,6 +24,7 @@ use Apoloz\AlfaBank\Services\OpenSslCmsSigner; use Apoloz\AlfaBank\Services\PhpseclibCardEncryptor; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Psr\Log\LoggerInterface; // @@ -68,10 +69,16 @@ class AlfaBankServiceProvider extends ServiceProvider }); $this->app->singleton(AlfaBankConnector::class, function ($app) { - return new AlfaBankConnector( + $loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true; + + $connector = new AlfaBankConnector( alfaConfig: $app->make('alfa.baas.config'), - signer: $app->make(SignerInterface::class) + signer: $app->make(SignerInterface::class), + loggingEnabled: $loggingEnabled, + logger: $loggingEnabled ? $app->make(LoggerInterface::class) : null, ); + + return $connector; }); $this->app->alias(AlfaBankConnector::class, 'alfa.baas'); @@ -90,10 +97,13 @@ class AlfaBankServiceProvider extends ServiceProvider $this->app->singleton(SbpConnector::class, function ($app) { $config = $app['config']['alfa-bank.sbp']; + $loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true; return new SbpConnector( alfaConfig: $app->make('alfa.sbp.config'), - keyNameAlias: $config['key_name_alias'] ?? null + keyNameAlias: $config['key_name_alias'] ?? null, + loggingEnabled: $loggingEnabled, + logger: $loggingEnabled ? $app->make(LoggerInterface::class) : null, ); }); $this->app->alias(SbpConnector::class, 'alfa.sbp'); diff --git a/src/Laravel/Config/alfa-bank.php b/src/Laravel/Config/alfa-bank.php index c5b3398..11d632f 100644 --- a/src/Laravel/Config/alfa-bank.php +++ b/src/Laravel/Config/alfa-bank.php @@ -60,5 +60,18 @@ return [ 'encryption' => [ 'card_public_key' => env('ALFA_CARD_PUBLIC_KEY'), ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + | + | By default, all requests and responses are logged using the Laravel logger. + | Set ALFA_LOGGING_ENABLED=false to disable. + | + */ + 'logging' => [ + 'enabled' => env('ALFA_LOGGING_ENABLED', true), + ], ]; // diff --git a/src/Sbp/SbpConnector.php b/src/Sbp/SbpConnector.php index b633bac..0dff45d 100644 --- a/src/Sbp/SbpConnector.php +++ b/src/Sbp/SbpConnector.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Apoloz\AlfaBank\Sbp; use Apoloz\AlfaBank\Core\AlfaConfig; +use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware; +use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware; use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware; use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator; use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling; use Apoloz\AlfaBank\Utils\CmsSigner; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Saloon\Http\Connector; use Saloon\Http\PendingRequest; use Saloon\Http\Request; @@ -27,10 +31,13 @@ class SbpConnector extends Connector implements HasPagination public function __construct( protected AlfaConfig $alfaConfig, - protected ?string $keyNameAlias = null + protected ?string $keyNameAlias = null, + protected bool $loggingEnabled = true, + protected ?LoggerInterface $logger = null, ) { $this->tries = 3; $this->retryInterval = 500; + $this->logger = $this->logger ?? new NullLogger; } public function resolveBaseUrl(): string @@ -64,6 +71,13 @@ class SbpConnector extends Connector implements HasPagination public function boot(PendingRequest $pendingRequest): void { + $pendingRequest->middleware()->onRequest(new RequestIdMiddleware); + + if ($this->loggingEnabled) { + $pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger)); + $pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger)); + } + $bodyRepo = $pendingRequest->body(); if (! $bodyRepo) { return; @@ -107,6 +121,21 @@ class SbpConnector extends Connector implements HasPagination return $this; } + public function withLogging(LoggerInterface $logger): static + { + $this->logger = $logger; + $this->loggingEnabled = true; + + return $this; + } + + public function withoutLogging(): static + { + $this->loggingEnabled = false; + + return $this; + } + public function paginate(Request $request): OffsetPaginator { return new AlfaOffsetPaginator($this, $request); @@ -133,11 +162,4 @@ class SbpConnector extends Connector implements HasPagination 'Accept' => 'application/json', ]; } - - protected function defaultMiddleware(): array - { - return [ - new RequestIdMiddleware, - ]; - } } diff --git a/tests/Feature/LoggingTest.php b/tests/Feature/LoggingTest.php new file mode 100644 index 0000000..bcc51e2 --- /dev/null +++ b/tests/Feature/LoggingTest.php @@ -0,0 +1,294 @@ +createMock(LoggerInterface::class); + + $logger->expects($this->exactly(2)) + ->method('info') + ->with( + $this->logicalOr( + $this->stringContains('AlfaBank Request'), + $this->stringContains('AlfaBank Response') + ), + $this->callback(function (array $context): bool { + return isset($context['host']) && $context['host'] === 'https://test.alfabank.ru'; + }) + ); + + $mockClient = new MockClient([ + '*' => MockResponse::make(['accounts' => []], 200), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger); + $connector->withMockClient($mockClient); + + $connector->send(new GetAccountsRequest); +}); + +test('AlfaBankConnector logs response with host by default', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $calls = []; + $logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function (string $message, array $context) use (&$calls): void { + $calls[] = $message; + }); + + $mockClient = new MockClient([ + '*' => MockResponse::make(['accounts' => []], 200), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger); + $connector->withMockClient($mockClient); + + $connector->send(new GetAccountsRequest); + + expect($calls)->toHaveCount(2); + expect($calls[0])->toContain('AlfaBank Request'); + expect($calls[1])->toContain('AlfaBank Response'); +}); + +test('AlfaBankConnector does not log when loggingEnabled is false', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->never())->method('info'); + + $mockClient = new MockClient([ + '*' => MockResponse::make(['accounts' => []], 200), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: false, logger: $logger); + $connector->withMockClient($mockClient); + + $connector->send(new GetAccountsRequest); +}); + +test('withoutLogging disables logging', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->never())->method('info'); + + $mockClient = new MockClient([ + '*' => MockResponse::make(['accounts' => []], 200), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger); + $connector->withoutLogging(); + $connector->withMockClient($mockClient); + + $connector->send(new GetAccountsRequest); +}); + +test('withLogging re-enables logging after withoutLogging', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->exactly(2))->method('info'); + + $mockClient = new MockClient([ + '*' => MockResponse::make(['accounts' => []], 200), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: false, logger: $logger); + $connector->withLogging($logger); + $connector->withMockClient($mockClient); + + $connector->send(new GetAccountsRequest); +}); + +test('SbpConnector logs request with host by default', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->exactly(2)) + ->method('info') + ->with( + $this->logicalOr( + $this->stringContains('AlfaBank Request'), + $this->stringContains('AlfaBank Response') + ), + $this->callback(function (array $context): bool { + return isset($context['host']) && $context['host'] === 'https://test.alfabank.ru'; + }) + ); + + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + $config = createLoggingConfig(); + $connector = new SbpConnector($config, 'alias', loggingEnabled: true, logger: $logger); + $connector->withMockClient($mockClient); + + $connector->send(new class extends \Saloon\Http\Request + { + protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET; + + public function resolveEndpoint(): string + { + return '/test'; + } + }); +}); + +test('SbpConnector withoutLogging disables logging', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->never())->method('info'); + + $mockClient = new MockClient([ + '*' => MockResponse::make([], 200), + ]); + + $config = createLoggingConfig(); + $connector = new SbpConnector($config, 'alias', loggingEnabled: true, logger: $logger); + $connector->withoutLogging(); + $connector->withMockClient($mockClient); + + $connector->send(new class extends \Saloon\Http\Request + { + protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET; + + public function resolveEndpoint(): string + { + return '/test'; + } + }); +}); + +test('LoggingRequestMiddleware masks sensitive headers', function (): void { + $middleware = new LoggingRequestMiddleware( + $this->createMock(LoggerInterface::class) + ); + + $reflection = new ReflectionMethod($middleware, 'maskHeaders'); + $result = $reflection->invoke($middleware, [ + 'Authorization' => 'Bearer secret-token', + 'Accept' => 'application/json', + ]); + + expect($result['Authorization'])->toBe('Bearer *****'); + expect($result['Accept'])->toBe('application/json'); +}); + +test('LoggingRequestMiddleware masks sensitive body fields', function (): void { + $middleware = new LoggingRequestMiddleware( + $this->createMock(LoggerInterface::class) + ); + + $reflection = new ReflectionMethod($middleware, 'maskBody'); + $result = $reflection->invoke($middleware, [ + 'pan' => '4276123456789012', + 'cvc' => '123', + 'amount' => 1000, + 'nested' => [ + 'client_secret' => 'super-secret', + ], + ]); + + expect($result['pan'])->toBe('*****'); + expect($result['cvc'])->toBe('*****'); + expect($result['amount'])->toBe(1000); + expect($result['nested']['client_secret'])->toBe('*****'); +}); + +test('AlfaBankConnector without logger uses NullLogger silently', function (): void { + $mockClient = new MockClient([ + '*' => MockResponse::make(['accounts' => []], 200), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: true); + $connector->withMockClient($mockClient); + + expect($connector->send(new GetAccountsRequest))->status()->toBe(200); +}); + +test('logs non-JSON response body without throwing', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $loggedBody = null; + $logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function (string $message, array $context) use (&$loggedBody): void { + if (str_contains($message, 'Response')) { + $loggedBody = $context['body']; + } + }); + + $mockClient = new MockClient([ + '*' => MockResponse::make('eyJhbGciOiJIUzI1NiJ9...', 200, ['Content-Type' => 'application/jwt']), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger); + $connector->withMockClient($mockClient); + + $response = $connector->send(new GetAccountsRequest); + + expect($response->status())->toBe(200); + expect($loggedBody)->toBe('eyJhbGciOiJIUzI1NiJ9...'); +}); + +test('logs plain text response without throwing', function (): void { + /** @var LoggerInterface&MockObject */ + $logger = $this->createMock(LoggerInterface::class); + + $loggedBody = null; + $logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function (string $message, array $context) use (&$loggedBody): void { + if (str_contains($message, 'Response')) { + $loggedBody = $context['body']; + } + }); + + $mockClient = new MockClient([ + '*' => MockResponse::make('Internal Server Error', 200, ['Content-Type' => 'text/plain']), + ]); + + $config = createLoggingConfig(); + $connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger); + $connector->withMockClient($mockClient); + + $response = $connector->send(new GetAccountsRequest); + + expect($response->status())->toBe(200); + expect($loggedBody)->toBe('Internal Server Error'); +});