Files

401 lines
16 KiB
Python

"""Task 6: suíte de CONTRATO HTTP -- design spec's "os 3 seguros de
portabilidade", item (a): "suíte de contrato HTTP language-agnostic (bate na
API, não no interior)". A REGRA DESTE ARQUIVO: zero import de `fiscal_svc.*`
além de `fiscal_svc.main.app` (para montar o `ASGITransport` -- inevitável
em Python; um cliente HTTP real bateria numa URL, não precisaria nem disso).
Nenhum import de model/service/schema interno -- é a suíte que uma
implementação GO deste MESMO contrato (`docs/openapi-v1.json`) teria que
passar batendo na porta 8140 de fora, sem NUNCA ver este código-fonte.
Provisionamento de fixtures (criar um Product/API key) é feito via
`scripts/create_product.py`'s MESMA função (`tenancy.service.create_
product`) em qualquer OUTRO arquivo de teste deste repo -- mas aqui, para
manter a regra "zero import do interior", cada teste usa o endpoint HTTP
disponível (upload de certificado, criação de série) e trata a criação do
Product como um pré-requisito de infraestrutura resolvido pela fixture
`product_api_key` (que É a única concessão: sem UM jeito de provisionar um
Product por HTTP -- design spec decisão #5, "provisionar um Product é uma
ação de operador, não self-service" -- não há como popular um cenário sem
tocar a camada de app UMA vez por teste; a asserção em si nunca o faz)."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from cryptography import x509
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509.oid import NameOID
from httpx import ASGITransport, AsyncClient
from fiscal_svc.core.db import get_session
from fiscal_svc.main import app
_CNPJ = "14200166000187"
@pytest.fixture(autouse=True)
def _override_db(db_session):
async def _get_session_override():
yield db_session
app.dependency_overrides[get_session] = _get_session_override
yield
app.dependency_overrides.clear()
@pytest.fixture(autouse=True)
def _fiscal_cert_encryption_key(monkeypatch):
monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii"))
yield
@pytest_asyncio.fixture
async def product_api_key(db_session):
"""A ÚNICA concessão à regra "zero import do interior" -- provisionar
um Product não tem endpoint HTTP (decisão de design deliberada, ver o
docstring do módulo), então esta fixture chama a função de serviço
diretamente. Nenhum teste abaixo importa nada além disto."""
from fiscal_svc.tenancy.service import create_product
key = f"k-{uuid.uuid4().hex}"
await create_product(db_session, name=f"contract-{uuid.uuid4().hex[:8]}", api_key=key)
return key
def _headers(api_key: str) -> dict[str, str]:
return {"X-Api-Key": api_key}
def _build_test_pfx(cnpj: str = _CNPJ, password: str = "senha123") -> bytes:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, f"EMPRESA TESTE LTDA:{cnpj}"),
x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj),
]
)
now = datetime.now(timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=365))
.sign(key, hashes.SHA256())
)
return pkcs12.serialize_key_and_certificates(
name=b"test", key=key, cert=cert, cas=None,
encryption_algorithm=serialization.BestAvailableEncryption(password.encode("utf-8")),
)
# --- health ------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_returns_200_ok_shape():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/v1/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
# --- auth (require_product) --------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,path,kwargs",
[
("get", "/v1/certificados", {"params": {"tenant_ref": "t", "branch_ref": "b"}}),
("get", "/v1/series", {"params": {"tenant_ref": "t", "branch_ref": "b"}}),
("get", "/v1/documentos", {}),
("post", "/v1/series", {"json": {"tenant_ref": "t", "branch_ref": "b", "document_model": "55", "serie": 1, "next_number": 1}}),
],
)
async def test_every_v1_route_requires_api_key(method, path, kwargs):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await getattr(client, method)(path, **kwargs)
assert response.status_code == 401, response.text
@pytest.mark.asyncio
async def test_wrong_api_key_is_401():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/v1/series",
params={"tenant_ref": "t", "branch_ref": "b"},
headers=_headers("not-a-real-key"),
)
assert response.status_code == 401, response.text
# --- series --------------------------------------------------------------
@pytest.mark.asyncio
async def test_series_create_list_patch_shapes(product_api_key):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(product_api_key),
)
assert create_response.status_code == 201, create_response.text
body = create_response.json()
for field in ("id", "product_id", "tenant_ref", "branch_ref", "document_model", "serie", "next_number"):
assert field in body, f"campo {field!r} ausente no shape de FiscalSeriesRead"
series_id = body["id"]
duplicate_response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 2},
headers=_headers(product_api_key),
)
assert duplicate_response.status_code == 409, duplicate_response.text
assert duplicate_response.json()["detail"]["code"] == "duplicate_fiscal_series"
list_response = await client.get(
"/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
)
assert list_response.status_code == 200, list_response.text
assert isinstance(list_response.json(), list)
assert len(list_response.json()) == 1
patch_response = await client.patch(
f"/v1/series/{series_id}", json={"next_number": 5}, headers=_headers(product_api_key)
)
assert patch_response.status_code == 200, patch_response.text
assert patch_response.json()["next_number"] == 5
not_found_response = await client.patch(
f"/v1/series/{uuid.uuid4()}", json={"next_number": 5}, headers=_headers(product_api_key)
)
assert not_found_response.status_code == 404, not_found_response.text
# --- certificados ----------------------------------------------------------
@pytest.mark.asyncio
async def test_certificados_upload_get_delete_shapes(product_api_key):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
upload_response = await client.post(
"/v1/certificados",
params={"tenant_ref": "t1", "branch_ref": "b1"},
files={"file": ("cert.pfx", _build_test_pfx(), "application/x-pkcs12")},
data={"password": "senha123", "cnpj": _CNPJ},
headers=_headers(product_api_key),
)
assert upload_response.status_code == 201, upload_response.text
body = upload_response.json()
for field in (
"id", "product_id", "tenant_ref", "branch_ref", "cnpj",
"subject_cn", "cnpj_certificado", "not_valid_before", "not_valid_after", "created_at",
):
assert field in body, f"campo {field!r} ausente no shape de FiscalCertificateRead"
assert "pfx_encrypted" not in body
assert "password_encrypted" not in body
get_response = await client.get(
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
)
assert get_response.status_code == 200, get_response.text
delete_response = await client.delete(
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
)
assert delete_response.status_code == 204, delete_response.text
get_after_delete_response = await client.get(
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
)
assert get_after_delete_response.status_code == 404, get_after_delete_response.text
@pytest.mark.asyncio
async def test_certificado_invalido_e_422(product_api_key):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/v1/certificados",
params={"tenant_ref": "t1", "branch_ref": "b1"},
files={"file": ("cert.pfx", b"nao e um pfx", "application/x-pkcs12")},
data={"password": "qualquer", "cnpj": _CNPJ},
headers=_headers(product_api_key),
)
assert response.status_code == 422, response.text
# --- emissão ---------------------------------------------------------------
def _minimal_emissao_payload(*, tenant_ref="t1", branch_ref="b1", serie=1) -> dict:
return {
"tenant_ref": tenant_ref,
"branch_ref": branch_ref,
"document_model": "55",
"serie": serie,
"emitente": {
"cnpj": _CNPJ,
"razao_social": "EMPRESA TESTE LTDA",
"nome_fantasia": None,
"ie": "1234567890",
"crt": "1",
"address_street": "Rua Teste",
"address_number": "100",
"address_complement": None,
"address_district": "Centro",
"address_city": "Curitiba",
"address_state": "PR",
"address_zip": "80000000",
"address_city_ibge_code": "4106902",
},
"itens": [
{
"codigo": "PC001",
"descricao": "Peca teste",
"ncm": "84212300",
"cfop": "5102",
"unidade_comercial": "UN",
"unidade_tributavel": "UN",
"quantidade": "1",
"valor_unitario": "10.00",
"fiscal_result": {
"cfop": "5102",
"cst": None,
"csosn": "102",
"origem": "0",
"consumidor_final": True,
"indicador_ie": "9",
"tributos": [],
},
}
],
"pagamento": {"tpag": "01", "valor": "10.00", "indpag": "0"},
"ambiente": "homologacao",
"uf_destino_tipo": "interna",
"destinatario": None,
"ver_proc": "contract-test/1.0",
}
@pytest.mark.asyncio
async def test_emissao_requires_idempotency_key_header(product_api_key):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/v1/emissoes", json=_minimal_emissao_payload(), headers=_headers(product_api_key)
)
assert response.status_code == 422, response.text
@pytest.mark.asyncio
async def test_emissao_requires_ver_proc(product_api_key):
payload = _minimal_emissao_payload()
del payload["ver_proc"]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/v1/emissoes", json=payload,
headers={**_headers(product_api_key), "Idempotency-Key": f"k-{uuid.uuid4().hex}"},
)
assert response.status_code == 422, response.text
@pytest.mark.asyncio
async def test_emissao_without_certificate_or_series_is_409_fiscal_config_missing(product_api_key):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/v1/emissoes", json=_minimal_emissao_payload(),
headers={**_headers(product_api_key), "Idempotency-Key": f"k-{uuid.uuid4().hex}"},
)
assert response.status_code == 409, response.text
body = response.json()
assert body["detail"]["code"] == "fiscal_config_missing"
assert "message" in body["detail"]
@pytest.mark.asyncio
async def test_full_emissao_happy_path_shapes_and_idempotency(product_api_key):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
upload_response = await client.post(
"/v1/certificados",
params={"tenant_ref": "t1", "branch_ref": "b1"},
files={"file": ("cert.pfx", _build_test_pfx(), "application/x-pkcs12")},
data={"password": "senha123", "cnpj": _CNPJ},
headers=_headers(product_api_key),
)
assert upload_response.status_code == 201, upload_response.text
series_response = await client.post(
"/v1/series",
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
headers=_headers(product_api_key),
)
assert series_response.status_code == 201, series_response.text
idem_key = f"k-{uuid.uuid4().hex}"
first_response = await client.post(
"/v1/emissoes", json=_minimal_emissao_payload(),
headers={**_headers(product_api_key), "Idempotency-Key": idem_key},
)
assert first_response.status_code == 201, first_response.text
body = first_response.json()
for field in (
"id", "product_id", "tenant_ref", "branch_ref", "series_id", "document_model",
"serie", "numero", "chave_acesso", "codigo_numerico", "status", "ambiente",
"rejeicao_codigo", "rejeicao_motivo", "protocolo", "autorizada_em", "created_at",
):
assert field in body, f"campo {field!r} ausente no shape de FiscalDocumentRead"
assert "xml_assinado" not in body
assert body["status"] == "ASSINADO"
document_id = body["id"]
second_response = await client.post(
"/v1/emissoes", json=_minimal_emissao_payload(),
headers={**_headers(product_api_key), "Idempotency-Key": idem_key},
)
assert second_response.status_code == 200, second_response.text
assert second_response.json()["id"] == document_id
get_response = await client.get(f"/v1/documentos/{document_id}", headers=_headers(product_api_key))
assert get_response.status_code == 200, get_response.text
assert "xml_assinado" not in get_response.json()
xml_response = await client.get(f"/v1/documentos/{document_id}/xml", headers=_headers(product_api_key))
assert xml_response.status_code == 200
assert xml_response.headers["content-type"].startswith("application/xml")
assert "<NFe" in xml_response.text
list_response = await client.get(
"/v1/documentos", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
)
assert list_response.status_code == 200, list_response.text
assert any(d["id"] == document_id for d in list_response.json())
not_found_response = await client.get(
f"/v1/documentos/{uuid.uuid4()}", headers=_headers(product_api_key)
)
assert not_found_response.status_code == 404, not_found_response.text