Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/Config/CertConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Config;

/**
* Immutable certificate configuration for NFS-e mTLS authentication.
*
* Holds the contributor CNPJ, the filesystem path to the PFX bundle, and
* the OpenBao KV path from which the PFX password is retrieved just-in-time.
* The password is never cached across job boundaries.
*/
final readonly class CertConfig
{
public function __construct(
/** CNPJ do prestador de serviço (only digits, 14 chars). */
public string $cnpj,

/** Absolute filesystem path to the PFX certificate bundle. */
public string $pfxPath,

/** OpenBao KV path for the PFX password (e.g. "secret/nfse/29842527000145"). */
public string $vaultPath,
) {
}
}
34 changes: 34 additions & 0 deletions src/Config/EnvironmentConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Config;

/**
* Immutable configuration for the NFS-e environment (sandbox vs. production).
*
* When no custom base URL is supplied the appropriate official endpoint is
* selected automatically from the sandboxMode flag:
*
* - Production: https://nfse.fazenda.gov.br/NFS-e/api/v1
* - Sandbox: https://hml.nfse.fazenda.gov.br/NFS-e/api/v1
*/
final readonly class EnvironmentConfig
{
private const BASE_URL_PROD = 'https://nfse.fazenda.gov.br/NFS-e/api/v1';
private const BASE_URL_SANDBOX = 'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1';

public string $baseUrl;

public function __construct(
public bool $sandboxMode = false,
?string $baseUrl = null,
) {
$this->baseUrl = $baseUrl ?? ($sandboxMode
? self::BASE_URL_SANDBOX
: self::BASE_URL_PROD);
}
}
14 changes: 5 additions & 9 deletions src/Http/NfseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

namespace LibreCodeCoop\NfsePHP\Http;

use LibreCodeCoop\NfsePHP\Config\CertConfig;
use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
use LibreCodeCoop\NfsePHP\Contracts\NfseClientInterface;
use LibreCodeCoop\NfsePHP\Contracts\SecretStoreInterface;
use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface;
Expand All @@ -25,25 +27,19 @@
*
* Communicates with the SEFIN gateway to issue, query, and cancel NFS-e.
* All requests carry a signed DPS XML payload.
*
* Gateway sandbox base URL: https://hml.nfse.fazenda.gov.br/NFS-e/api/v1
* Gateway production base URL: https://nfse.fazenda.gov.br/NFS-e/api/v1
*/
class NfseClient implements NfseClientInterface
{
private const BASE_URL_PROD = 'https://nfse.fazenda.gov.br/NFS-e/api/v1';
private const BASE_URL_SANDBOX = 'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1';

private readonly string $baseUrl;
private readonly XmlSignerInterface $signer;

public function __construct(
private readonly EnvironmentConfig $environment,
private readonly CertConfig $cert,
private readonly SecretStoreInterface $secretStore,
private readonly bool $sandboxMode = false,
?string $baseUrlOverride = null,
?XmlSignerInterface $signer = null,
) {
$this->baseUrl = $baseUrlOverride ?? ($sandboxMode ? self::BASE_URL_SANDBOX : self::BASE_URL_PROD);
$this->baseUrl = $environment->baseUrl;
$this->signer = $signer ?? new DpsSigner($secretStore);
}

Expand Down
69 changes: 69 additions & 0 deletions tests/Unit/Config/CertConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Tests\Unit\Config;

use LibreCodeCoop\NfsePHP\Config\CertConfig;
use LibreCodeCoop\NfsePHP\Tests\TestCase;

/**
* @covers \LibreCodeCoop\NfsePHP\Config\CertConfig
*/
class CertConfigTest extends TestCase
{
public function testStoresAllProperties(): void
{
$config = new CertConfig(
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
);

self::assertSame('29842527000145', $config->cnpj);
self::assertSame('/etc/nfse/certs/company.pfx', $config->pfxPath);
self::assertSame('secret/nfse/29842527000145', $config->vaultPath);
}

public function testCnpjIsReadonly(): void
{
$config = new CertConfig(
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
);

$this->expectException(\Error::class);
/** @phpstan-ignore-next-line */
$config->cnpj = 'other';
}

public function testPfxPathIsReadonly(): void
{
$config = new CertConfig(
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
);

$this->expectException(\Error::class);
/** @phpstan-ignore-next-line */
$config->pfxPath = 'other';
}

public function testVaultPathIsReadonly(): void
{
$config = new CertConfig(
cnpj: '29842527000145',
pfxPath: '/etc/nfse/certs/company.pfx',
vaultPath: 'secret/nfse/29842527000145',
);

$this->expectException(\Error::class);
/** @phpstan-ignore-next-line */
$config->vaultPath = 'other';
}
}
56 changes: 56 additions & 0 deletions tests/Unit/Config/EnvironmentConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Tests\Unit\Config;

use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
use LibreCodeCoop\NfsePHP\Tests\TestCase;

/**
* @covers \LibreCodeCoop\NfsePHP\Config\EnvironmentConfig
*/
class EnvironmentConfigTest extends TestCase
{
public function testDefaultsToProductionUrl(): void
{
$config = new EnvironmentConfig();

self::assertFalse($config->sandboxMode);
self::assertSame(
'https://nfse.fazenda.gov.br/NFS-e/api/v1',
$config->baseUrl,
);
}

public function testSandboxModeSelectsSandboxUrl(): void
{
$config = new EnvironmentConfig(sandboxMode: true);

self::assertTrue($config->sandboxMode);
self::assertSame(
'https://hml.nfse.fazenda.gov.br/NFS-e/api/v1',
$config->baseUrl,
);
}

public function testCustomBaseUrlOverridesMode(): void
{
$custom = 'http://localhost:8080/NFS-e/api/v1';
$config = new EnvironmentConfig(sandboxMode: false, baseUrl: $custom);

self::assertFalse($config->sandboxMode);
self::assertSame($custom, $config->baseUrl);
}

public function testCustomBaseUrlOverridesSandboxUrl(): void
{
$custom = 'http://mock-server/NFS-e/api/v1';
$config = new EnvironmentConfig(sandboxMode: true, baseUrl: $custom);

self::assertSame($custom, $config->baseUrl);
}
}
70 changes: 27 additions & 43 deletions tests/Unit/Http/NfseClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

use donatj\MockWebServer\MockWebServer;
use donatj\MockWebServer\Response;
use LibreCodeCoop\NfsePHP\Config\CertConfig;
use LibreCodeCoop\NfsePHP\Config\EnvironmentConfig;
use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface;
use LibreCodeCoop\NfsePHP\Dto\DpsData;
use LibreCodeCoop\NfsePHP\Exception\CancellationException;
Expand Down Expand Up @@ -65,13 +67,7 @@ public function testEmitReturnsReceiptDataOnSuccess(): void
new Response($payload, ['Content-Type' => 'application/json'], 200)
);

$store = new NoOpSecretStore();
$client = new NfseClient(
secretStore: $store,
sandboxMode: false,
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
signer: $this->signer,
);
$client = $this->makeClient($this->signer);

$dps = $this->makeDps();
$receipt = $client->emit($dps);
Expand All @@ -94,11 +90,7 @@ public function testQueryReturnsReceiptDataOnSuccess(): void
new Response($payload, ['Content-Type' => 'application/json'], 200)
);

$store = new NoOpSecretStore();
$client = new NfseClient(
secretStore: $store,
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
);
$client = $this->makeClient();

$receipt = $client->query('xyz-456');

Expand All @@ -112,11 +104,7 @@ public function testCancelReturnsTrueOnSuccess(): void
new Response('{}', ['Content-Type' => 'application/json'], 200)
);

$store = new NoOpSecretStore();
$client = new NfseClient(
secretStore: $store,
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
);
$client = $this->makeClient();

self::assertTrue($client->cancel('abc-123', 'Cancelamento a pedido do tomador'));
}
Expand All @@ -134,11 +122,7 @@ public function testEmitThrowsIssuanceExceptionWhenGatewayRejects(): void
new Response($payload, ['Content-Type' => 'application/json'], 422),
);

$client = new NfseClient(
secretStore: new NoOpSecretStore(),
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
signer: $this->signer,
);
$client = $this->makeClient($this->signer);

$this->expectException(IssuanceException::class);
$client->emit($this->makeDps());
Expand All @@ -153,11 +137,7 @@ public function testIssuanceExceptionCarriesErrorCodeHttpStatusAndUpstreamPayloa
new Response(json_encode($errorData, JSON_THROW_ON_ERROR), ['Content-Type' => 'application/json'], 422),
);

$client = new NfseClient(
secretStore: new NoOpSecretStore(),
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
signer: $this->signer,
);
$client = $this->makeClient($this->signer);

try {
$client->emit($this->makeDps());
Expand All @@ -176,10 +156,7 @@ public function testQueryThrowsQueryExceptionWhenGatewayReturnsError(): void
new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404),
);

$client = new NfseClient(
secretStore: new NoOpSecretStore(),
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
);
$client = $this->makeClient();

$this->expectException(QueryException::class);
$client->query('missing-key');
Expand All @@ -192,10 +169,7 @@ public function testQueryExceptionCarriesErrorCodeAndHttpStatus(): void
new Response('{"error":"not found"}', ['Content-Type' => 'application/json'], 404),
);

$client = new NfseClient(
secretStore: new NoOpSecretStore(),
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
);
$client = $this->makeClient();

try {
$client->query('missing-key');
Expand All @@ -213,10 +187,7 @@ public function testCancelThrowsCancellationExceptionWhenGatewayReturnsError():
new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409),
);

$client = new NfseClient(
secretStore: new NoOpSecretStore(),
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
);
$client = $this->makeClient();

$this->expectException(CancellationException::class);
$client->cancel('blocked-key', 'a pedido do tomador');
Expand All @@ -229,10 +200,7 @@ public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void
new Response('{"error":"cannot cancel"}', ['Content-Type' => 'application/json'], 409),
);

$client = new NfseClient(
secretStore: new NoOpSecretStore(),
baseUrlOverride: self::$server->getServerRoot() . '/NFS-e/api/v1',
);
$client = $this->makeClient();

try {
$client->cancel('blocked-key', 'a pedido do tomador');
Expand All @@ -245,6 +213,22 @@ public function testCancellationExceptionCarriesErrorCodeAndHttpStatus(): void

// -------------------------------------------------------------------------

private function makeClient(?XmlSignerInterface $signer = null): NfseClient
{
return new NfseClient(
environment: new EnvironmentConfig(
baseUrl: self::$server->getServerRoot() . '/NFS-e/api/v1',
),
cert: new CertConfig(
cnpj: '29842527000145',
pfxPath: '/dev/null',
vaultPath: 'secret/nfse/29842527000145',
),
secretStore: new NoOpSecretStore(),
signer: $signer,
);
}

private function makeDps(): DpsData
{
return new DpsData(
Expand Down
Loading