4 Commits

Author SHA1 Message Date
da3ea113ef refactor(sbp): refund 2026-06-25 16:05:46 +03:00
Anatoliy Poloz
3437cba042 payoff list 2026-06-16 22:25:04 +03:00
Anatoliy
d93b1569f5 refactor(logging) 2026-06-09 16:11:15 +03:00
Anatoliy Poloz
7ea5836f44 repair(PayoffRequest): data struct 2026-06-08 15:21:32 +03:00
16 changed files with 637 additions and 110 deletions

View File

@@ -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;
}
// </SECTION>
@@ -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);
}
// </SECTION>
}

View File

@@ -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
}
// </SECTION>
// <SECTION name="logging_mutator" intent="Allow attaching a PSR-3 logger to the connector for debugging.">
// <BELIEF_STATE "Logging should be an optional, opt-in feature. This method provides a fluent interface to attach a logging middleware, which will automatically sanitize sensitive data before output.">
// <SECTION name="logging_mutator" intent="Attach a PSR-3 logger to the connector for debugging.">
// <BELIEF_STATE "Logging is enabled by default with a NullLogger (silent). Pass a real logger to start seeing output, or disable entirely via withoutLogging().">
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,
];
}
// </SECTION>
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
// MODULE_CONTRACT: Defines the data structure for the GET /payoffs list response.
// RESPONSIBILITY: To hold and type-safely represent the paginated list of payoffs returned by the bank API.
// INPUTS: Raw JSON object from the API response.
// OUTPUTS: A structured object containing pagination metadata, account number, and an array of payoff items.
// LINKS: Requests/NominalAccounts/Payouts/GetPayoffsRequest.php, PayoffListItemData.php
*/
namespace Apoloz\AlfaBank\Data\NominalAccounts\Payouts;
use Apoloz\AlfaBank\Data\Shared\PaginationData;
use Spatie\LaravelData\Data;
/**
* DTO for the GET /payoffs list response.
*
* @property-read PaginationData $pagination Pagination metadata
* @property-read string $accountNumber Номер номинального счёта
* @property-read PayoffListItemData[] $payoffs Список выплат
*/
class PayOffsListResponseData extends Data
{
/**
* @param PaginationData $pagination Pagination metadata
* @param string $accountNumber Номер номинального счёта
* @param PayoffListItemData[] $payoffs Список выплат
*/
public function __construct(
public PaginationData $pagination,
public string $accountNumber,
public array $payoffs,
) {}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
// MODULE_CONTRACT: Defines the data structure for a single payoff item in the list response from GET /payoffs.
// RESPONSIBILITY: To hold and type-safely represent comprehensive information about a single payoff from the list endpoint.
// INPUTS: Raw JSON object from the API response.
// OUTPUTS: A structured object containing payoff details.
// LINKS: PayOffsListResponseData.php, Requests/NominalAccounts/Payouts/GetPayoffsRequest.php
*/
namespace Apoloz\AlfaBank\Data\NominalAccounts\Payouts;
use Spatie\LaravelData\Data;
/**
* DTO for a single payoff item in the GET /payoffs list response.
*
* Based on OpenAPI PayoffItemResponse schema.
* Поля payer и recipient — array (raw JSON) вместо typed DTO, потому что
* существующие PayerInfoData/RecipientInfoData имеют другую структуру полей
* (accountNumber вместо bankAccount, нет bankName/bankBik/beneficiaryId в нужных местах)
* и их модификация может сломать DetailedPayoffData.
*
* @property-read string $id Идентификатор платежа (UUID)
* @property-read string|null $dealId Идентификатор сделки (UUID)
* @property-read string|null $stepId Идентификатор этапа сделки (UUID)
* @property-read string $payoffType Тип платежа
* @property-read string $type Тип платежа (PayoffType)
* @property-read string $channel Направление: INTERNAL|EXTERNAL
* @property-read string $requisiteType Тип реквизитов
* @property-read int $amount Сумма в рублях
* @property-read string|null $purpose Назначение платежа
* @property-read array|null $payer Плательщик (raw JSON)
* @property-read array|null $recipient Получатель (raw JSON)
* @property-read string $status Статус
* @property-read string|null $createdAt Дата создания (YYYY-MM-DD)
* @property-read string|null $updatedAt Дата обновления (YYYY-MM-DD)
*/
class PayoffListItemData extends Data
{
public function __construct(
public string $id,
public ?string $dealId = null,
public ?string $stepId = null,
public string $payoffType = 'REGULAR',
public string $type = 'REGULAR',
public string $channel = 'EXTERNAL',
public string $requisiteType = 'ACCOUNT',
public int $amount = 0,
public ?string $purpose = null,
public ?array $payer = null,
public ?array $recipient = null,
public string $status = 'NEW',
public ?string $createdAt = null,
public ?string $updatedAt = null,
) {}
}

View File

@@ -47,5 +47,10 @@ class PayoffRequestData extends Data
public ?DataCollection $requisites = null,
public ?TaxData $tax = null,
) {}
public function toArray(): array
{
return array_filter(parent::toArray(), fn ($v) => $v !== null);
}
// </SECTION>
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
// MODULE_CONTRACT: Defines the data structure for pagination metadata.
// RESPONSIBILITY: To hold and type-safely represent pagination information from paginated API responses.
// INPUTS: Raw JSON object from the API response.
// OUTPUTS: A structured object containing pagination details.
// LINKS: NominalAccounts/Payouts/PayOffsListResponseData.php
*/
namespace Apoloz\AlfaBank\Data\Shared;
use Spatie\LaravelData\Data;
/**
* DTO for pagination metadata.
*
* @property-read int $limit Maximum items per page
* @property-read int $offset Number of items skipped
* @property-read int $totalCount Total items across all pages
*/
class PaginationData extends Data
{
public function __construct(
public int $limit,
public int $offset,
public int $totalCount,
) {}
}

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Http\Middleware;
use Psr\Log\LoggerInterface;
use Saloon\Contracts\RequestMiddleware;
use Saloon\Contracts\ResponseMiddleware;
use Saloon\Http\PendingRequest;
use Saloon\Http\Response;
class LoggingMiddleware implements RequestMiddleware, ResponseMiddleware
{
// <SECTION name="constructor_definition" intent="Initialize the middleware with a logger instance.">
// <BELIEF_STATE "The constructor injects a `LoggerInterface` instance, making the logging functionality easily testable and configurable. This adheres to dependency inversion principles, allowing the middleware to log messages without being tightly coupled to a specific logging implementation.">
public function __construct(protected LoggerInterface $logger) {}
// </SECTION>
// <SECTION name="on_request_method" intent="Log outgoing API requests.">
// <BELIEF_STATE "The `onRequest` method intercepts each outgoing HTTP request. It logs the request method and endpoint, along with masked headers and body content. Masking of sensitive information like authorization headers and payment data is performed by `maskHeaders` and `maskBody` methods, ensuring that debugging logs do not compromise security.">
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()),
]);
}
// </SECTION>
// <SECTION name="on_response_method" intent="Log incoming API responses.">
// <BELIEF_STATE "The `onResponse` method intercepts each incoming HTTP response. It logs the response status and the masked response body. This provides valuable information for debugging API integrations and understanding the server's responses, all while maintaining the privacy of potentially sensitive data within the response payload.">
public function onResponse(Response $response): void
{
$this->logger->info("AlfaBank Response: {$response->status()}", [
'body' => $this->maskBody($response->json()),
]);
}
// </SECTION>
// <SECTION name="mask_headers_method" intent="Mask sensitive information in HTTP headers.">
// <BELIEF_STATE "This protected helper method iterates through the request headers and replaces the `Authorization` header's value with a generic placeholder. This prevents sensitive tokens from being inadvertently exposed in logs, which is a crucial security measure.">
protected function maskHeaders(array $headers): array
{
if (isset($headers['Authorization'])) {
$headers['Authorization'] = 'Bearer *****';
}
return $headers;
}
// </SECTION>
// <SECTION name="mask_body_method" intent="Mask sensitive information in request/response bodies.">
// <BELIEF_STATE "This protected recursive helper method inspects the request or response body for predefined sensitive keys (e.g., `pan`, `client_secret`). If a sensitive key is found, its value is replaced with a mask. The method recursively processes nested arrays, ensuring that sensitive data is masked even within complex JSON structures, thereby enhancing log security.">
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;
}
// </SECTION>
}
// </SECTION>

View File

@@ -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

View File

@@ -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

View File

@@ -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;
// </SECTION>
@@ -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');

View File

@@ -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),
],
];
// </SECTION>

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/*
// MODULE_CONTRACT: Represents the API call to retrieve a list of payoffs by account number.
// RESPONSIBILITY: To define the GET request for fetching a paginated list of payoffs with optional filters.
// INPUTS: accountNumber (required), limit, offset, channel, status, createdDateFrom/To, updatedDateFrom/To.
// OUTPUTS: PayOffsListResponseData (DTO).
// LINKS: Data/NominalAccounts/Payouts/PayOffsListResponseData.php
*/
namespace Apoloz\AlfaBank\Requests\NominalAccounts\Payouts;
use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\PayOffsListResponseData;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
/**
* Request to list payoffs by account number (GET /api/na/jp/v1/payoffs).
*
* Supports filtering by status, date range, and pagination.
*/
class GetPayoffsRequest extends Request
{
protected Method $method = Method::GET;
/**
* @param string $accountNumber Номер номинального счёта (required, 20 digits)
* @param int|null $limit Количество объектов на странице (1-100, default 100)
* @param int|null $offset Количество объектов, которые необходимо пропустить (default 0)
* @param string|null $channel Направление платежа: EXTERNAL|INTERNAL
* @param string|null $status Статус платежа: NEW|IN_PROGRESS|FAILED|AWAIT_EXTERNAL|READY
* @param string|null $createdDateFrom Начало выборки по дате создания (YYYY-MM-DD)
* @param string|null $createdDateTo Конец выборки по дате создания (YYYY-MM-DD)
* @param string|null $updatedDateFrom Начало выборки по дате обновления (YYYY-MM-DD)
* @param string|null $updatedDateTo Конец выборки по дате обновления (YYYY-MM-DD)
*/
public function __construct(
protected string $accountNumber,
protected ?int $limit = 100,
protected ?int $offset = 0,
protected ?string $channel = null,
protected ?string $status = null,
protected ?string $createdDateFrom = null,
protected ?string $createdDateTo = null,
protected ?string $updatedDateFrom = null,
protected ?string $updatedDateTo = null,
) {}
public function resolveEndpoint(): string
{
$params = [];
$params['accountNumber'] = $this->accountNumber;
if ($this->limit !== null) {
$params['limit'] = $this->limit;
}
if ($this->offset !== null) {
$params['offset'] = $this->offset;
}
if ($this->channel !== null) {
$params['channel'] = $this->channel;
}
if ($this->status !== null) {
$params['status'] = $this->status;
}
if ($this->createdDateFrom !== null) {
$params['createdDateFrom'] = $this->createdDateFrom;
}
if ($this->createdDateTo !== null) {
$params['createdDateTo'] = $this->createdDateTo;
}
if ($this->updatedDateFrom !== null) {
$params['updatedDateFrom'] = $this->updatedDateFrom;
}
if ($this->updatedDateTo !== null) {
$params['updatedDateTo'] = $this->updatedDateTo;
}
return '/api/na/jp/v1/payoffs?' . http_build_query($params);
}
public function createDtoFromResponse(Response $response): PayOffsListResponseData
{
return PayOffsListResponseData::from($response->json());
}
}

View File

@@ -14,6 +14,7 @@ namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
// <SECTION name="module_imports" intent="Import the base Spatie Laravel Data class.">
// <BELIEF_STATE "Extending `Spatie\LaravelData\Data` enables automatic casting from JSON and provides DTO benefits.">
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapName;
// </SECTION>
@@ -36,6 +37,7 @@ class GetQrcReversalDataResponseData extends Data
public ?string $qrcId = null,
public ?string $trxDT = null,
public ?string $trxId = null,
#[MapName('RestAmount')]
public ?string $restAmount = null,
) {}
// </SECTION>

View File

@@ -14,6 +14,7 @@ namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
// <SECTION name="module_imports" intent="Import the base Spatie Laravel Data class.">
// <BELIEF_STATE "Extending `Spatie\LaravelData\Data` enables automatic casting from JSON and provides DTO benefits.">
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapName;
// </SECTION>
@@ -33,6 +34,7 @@ class QrcReversalResponseData extends Data
public ?string $trxDT = null,
public ?string $trxId = null,
public ?string $payrrn = null,
#[MapName('RestAmount')]
public ?string $restAmount = null,
) {}
// </SECTION>

View File

@@ -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,
];
}
}

View File

@@ -0,0 +1,294 @@
<?php
use Apoloz\AlfaBank\Core\AlfaBankConnector;
use Apoloz\AlfaBank\Core\AlfaConfig;
use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware;
use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest;
use Apoloz\AlfaBank\Sbp\SbpConnector;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
function createLoggingConfig(): AlfaConfig
{
$cert = tempnam(sys_get_temp_dir(), 'cert');
$key = tempnam(sys_get_temp_dir(), 'key');
return new AlfaConfig(
baseUrl: 'https://test.alfabank.ru',
certificatePath: $cert,
privateKeyPath: $key
);
}
test('AlfaBankConnector 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(['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');
});