fix(emission): validate emitente CNPJ against branch certificate at emission

CRITICAL (F2 review, Opus+Fable): the service decoupled emitente
(free-form payload.emitente.cnpj) from the certificate (looked up by
branch_ref) with nothing re-checking the equality the auto had structurally
via Branch.cnpj. A caller passing emitente.cnpj=B with a branch whose
certificate CNPJ=A got a signed, persisted ASSINADO document with
chave/emit=B but signature=A, having burned a nNF.

Adds EmitenteCertificateCnpjMismatchError, checked in the completeness
block (before allocate_fiscal_number, so a mismatch never consumes a
número), normalizing both sides (digits only) before comparing. Router maps
it to 409 emitente_certificate_cnpj_mismatch; message never leaks the
certificate's CNPJ.

Test: tests/emission/test_emissao.py::
test_emitente_cnpj_divergente_do_certificado_e_409_e_nao_queima_numero --
uploads a cert for CNPJ A, POSTs emissão with the same branch_ref but
emitente.cnpj=B, asserts 409 + no fiscal number consumed (a follow-up
emission with the matching CNPJ gets the number that would have been
burned).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 15:30:58 -03:00
co-authored by Claude Opus 4.8
parent d9c1c444fb
commit 3e3a1abc6f
3 changed files with 94 additions and 2 deletions
+2
View File
@@ -32,6 +32,8 @@ async def emitir_documento_endpoint(
document, created = await service.emitir_documento(session, product, payload, idempotency_key) document, created = await service.emitir_documento(session, product, payload, idempotency_key)
except service.FiscalConfigMissingError as exc: except service.FiscalConfigMissingError as exc:
raise conflict("fiscal_config_missing", str(exc)) from exc raise conflict("fiscal_config_missing", str(exc)) from exc
except service.EmitenteCertificateCnpjMismatchError as exc:
raise conflict("emitente_certificate_cnpj_mismatch", str(exc)) from exc
except service.FiscalDocumentConflictError as exc: except service.FiscalDocumentConflictError as exc:
raise conflict("fiscal_document_conflict", str(exc)) from exc raise conflict("fiscal_document_conflict", str(exc)) from exc
response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
+37 -2
View File
@@ -15,9 +15,11 @@ for it.
ORDER (mirrors the auto's own emissao.py, same reason: `gerar_cnf(numero)` ORDER (mirrors the auto's own emissao.py, same reason: `gerar_cnf(numero)`
needs the number ALREADY allocated -- cNF != nNF is NT2019.001): needs the number ALREADY allocated -- cNF != nNF is NT2019.001):
1. idempotency pre-check (`Idempotency-Key` -> existing document, if any). 1. idempotency pre-check (`Idempotency-Key` -> existing document, if any).
2. completeness: live certificate for branch_ref, existing series for 2. completeness: live certificate for branch_ref (+ its CNPJ against
`payload.emitente.cnpj`, F2 review FIX 1), existing series for
(document_model, serie) -- BOTH checked BEFORE touching (document_model, serie) -- BOTH checked BEFORE touching
`allocate_fiscal_number`, so a missing config never wastes a número. `allocate_fiscal_number`, so a missing config/mismatch never wastes a
número.
3. `allocate_fiscal_number` (lock, no commit) -> `gerar_cnf` -> chave. 3. `allocate_fiscal_number` (lock, no commit) -> `gerar_cnf` -> chave.
4. `build_nfe` (pure, `sowai_fiscal`) -> serialize -> assina. 4. `build_nfe` (pure, `sowai_fiscal`) -> serialize -> assina.
5. `FiscalDocument(ASSINADO)` + `FiscalIdempotencyKey` added to the SAME 5. `FiscalDocument(ASSINADO)` + `FiscalIdempotencyKey` added to the SAME
@@ -95,6 +97,23 @@ class FiscalConfigMissingError(Exception):
super().__init__("Configuração fiscal ausente para emissão: " + ", ".join(missing)) super().__init__("Configuração fiscal ausente para emissão: " + ", ".join(missing))
class EmitenteCertificateCnpjMismatchError(Exception):
"""Fail-closed (spec decisão #4: o CNPJ é validado "contra o certificado
no upload e contra o emitente na emissão"). O upload garante `FiscalCertificate.
cnpj_certificado == FiscalCertificate.cnpj` (`certificates.service.
upload_certificate`'s `CertificateCnpjMismatchError`), mas NADA amarrava
o `payload.emitente.cnpj` (free-form, digitado pelo caller a cada
emissão) a ESSE certificado -- um caller podia declarar `branch_ref`=X
(cujo certificado é do CNPJ A) com `emitente.cnpj`=B e receber um
documento ASSINADO com chave/emit=B mas assinatura=A. Checado ANTES de
`allocate_fiscal_number` (mesma completude estrutural de `FiscalConfig
MissingError`), para o mismatch nunca queimar um número. A mensagem
NÃO leak o CNPJ do certificado -- só confirma que divergem."""
def __init__(self) -> None:
super().__init__("emitente CNPJ diverge do certificado da filial")
class FiscalDocumentConflictError(Exception): class FiscalDocumentConflictError(Exception):
"""`chave_acesso` é UNIQUE GLOBAL (`uq_fiscal_documents_chave_acesso`, """`chave_acesso` é UNIQUE GLOBAL (`uq_fiscal_documents_chave_acesso`,
Task 3) -- uma colisão no INSERT final (cNF repetido por acaso para o Task 3) -- uma colisão no INSERT final (cNF repetido por acaso para o
@@ -212,6 +231,13 @@ def _to_pagamento(payload: PagamentoDataPayload) -> PagamentoData:
# que o auto documenta em `fiscal.emissao._get_live_certificate`) ---------- # que o auto documenta em `fiscal.emissao._get_live_certificate`) ----------
def _normalize_cnpj(value: str) -> str:
"""Só dígitos -- mesma normalização que `EmitenteCertificateCnpjMismatch
Error` promete no docstring, para o CNPJ do payload e o do certificado
nunca divergirem por formatação (pontuação/máscara) em vez de conteúdo."""
return "".join(ch for ch in value if ch.isdigit())
async def _get_live_certificate( async def _get_live_certificate(
session: AsyncSession, product_id: uuid.UUID, branch_ref: str session: AsyncSession, product_id: uuid.UUID, branch_ref: str
) -> FiscalCertificate | None: ) -> FiscalCertificate | None:
@@ -292,6 +318,15 @@ async def emitir_documento(
if missing: if missing:
raise FiscalConfigMissingError(missing) raise FiscalConfigMissingError(missing)
# `certificate` está garantidamente não-None e não-vencido aqui (senão
# `missing` teria disparado acima) -- o vínculo forte (spec decisão #4)
# é o CNPJ: o payload declara livremente `emitente.cnpj` a CADA
# emissão, então nada além desta checagem amarra essa declaração ao
# certificado realmente carregado para o branch_ref. ANTES de alocar
# número -- um mismatch nunca queima um nNF.
if _normalize_cnpj(payload.emitente.cnpj) != _normalize_cnpj(certificate.cnpj_certificado):
raise EmitenteCertificateCnpjMismatchError()
# 3. aloca o número (lock, sem commit) -> cNF -> chave -------------- # 3. aloca o número (lock, sem commit) -> cNF -> chave --------------
try: try:
numero = await allocate_fiscal_number( numero = await allocate_fiscal_number(
+55
View File
@@ -355,6 +355,61 @@ async def test_serie_ausente_e_409_fiscal_config_missing(db_session):
assert response.json()["detail"]["code"] == "fiscal_config_missing" assert response.json()["detail"]["code"] == "fiscal_config_missing"
# --- FIX 1 (F2 review): emitente.cnpj vs certificado do branch_ref ----------
@pytest.mark.asyncio
async def test_emitente_cnpj_divergente_do_certificado_e_409_e_nao_queima_numero(db_session):
"""Certificado do branch_ref carrega o CNPJ A; o payload declara
`emitente.cnpj`=B (outro CNPJ válido, 14 dígitos) -- a auto teria
barrado isso estruturalmente (emitente vem de `Branch.cnpj`, o MESMO
vínculo do certificado); este serviço, com `emitente` free-form no
payload, precisa da checagem explícita ou assina B com a chave de A."""
product, key = await _product_and_key(db_session)
payload, raw = _payload_from_golden("caso_padrao_intra")
cnpj_divergente = "99887766000155"
assert cnpj_divergente != _CNPJ_EMITENTE
payload["emitente"] = {**payload["emitente"], "cnpj": cnpj_divergente}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await _setup_certificate_and_series(
db_session, client, key, tenant_ref="t1", branch_ref="b1",
serie=raw["serie"], next_number=raw["numero"], cnpj=_CNPJ_EMITENTE,
)
mismatch_response = await client.post(
"/v1/emissoes", json=payload,
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
)
assert mismatch_response.status_code == 409, mismatch_response.text
assert mismatch_response.json()["detail"]["code"] == "emitente_certificate_cnpj_mismatch"
# A mensagem NÃO deve vazar o CNPJ real do certificado.
assert _CNPJ_EMITENTE not in mismatch_response.text
result = await db_session.execute(
select(FiscalSeries).where(
FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1"
)
)
series = result.scalar_one()
assert series.next_number == raw["numero"], (
"o número NÃO pode ter sido queimado por um emitente.cnpj divergente do certificado"
)
# O número que seria queimado acima segue disponível -- uma emissão
# com o CNPJ CORRETO recebe exatamente esse número.
matching_payload, _ = _payload_from_golden("caso_padrao_intra")
success_response = await client.post(
"/v1/emissoes", json=matching_payload,
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
)
assert success_response.status_code == 201, success_response.text
assert success_response.json()["numero"] == raw["numero"]
# --- prova do outbox -------------------------------------------------------- # --- prova do outbox --------------------------------------------------------