Adiciona testes do conector com fixtures offline

Cobre o caminho completo sem depender da VPS estar no ar: cifra com uma
chave de teste descartavel, troca a chave do app por monkeypatch e exercita
a rota.

8 casos: recusa sem token e com token errado; caminho feliz; envelope
adulterado (o GCM tem que estourar, nao passar); cifrado com outra chave;
payload fora do contrato (422, nao 200 nem 500); e dois travando o contrato
com a API da VPS — se o PayloadCompras dos dois lados divergir, quebra aqui
em vez de em producao.

Fixtures em tests/fixtures/: par de chaves so pra teste, o payload real do
Holmes e o payload ja tratado. O .gitignore abre excecao estreita pra
tests/fixtures/*.pem — chave real continua barrada.

Tambem:
- 422 passa a usar HTTP_422_UNPROCESSABLE_CONTENT (o _ENTITY deprecou)
- app/services/holmes.py removido: o conector nao fala com o Holmes
- app/utils/cesar.py entra como veio

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ricardo 2026-08-18 06:13:13 -03:00
parent f902ccbecb
commit 7a9b0f8aa5
9 changed files with 283 additions and 705 deletions

28
tests/fixtures/chave_teste_privada.pem vendored Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/B6IxhXHEgkmR
INcZoR2py+NlOU0AihMvoPGcZcgD5YsDOLI2WuNlPn4d4bPLN3hyfSsduROUEpwn
78q/ZuEpHEW4YBE8wWrhPOTcMSQ4gwnJ/vDlXhXu1I5iwauOGn4qd8tUtqPavqZ0
+H7UCwo/JXmiPSB4z95rY8ZU+AhRY+YAYa2KP/Omjypb2bKurlglY3NId0862lgK
gfAMe6fCu16Cbw74A4lU1xtv3v98zb8QtRR19qzz4UuSTGPGHWBsHbNnmBmq/sHv
IL0BDSFuWxUdtiT61f+mzmCI0bqdvi9EzgyMjcLChzKUJzQnYiJXUd9anS3poHX9
L0uVQxSRAgMBAAECgf84DKS6hzLyZvqd9jUTVNunsq1rJ6OvBz585ob40HoKx6EL
tlz0Vwiup0HvgpIq/5knRu5MVl0fx87UcDAp0gD2E85NyExoQ4Nqr+cuD+r/1UKR
RZzEoUWKjvncTVd8otCSNjz52BWXUv1AgENacXiLW93IW+NzonHopjccCUngQCdF
CSgxCNUZYJN8+DqcaPos4DyBhyQa7jIlGINaAQjvXfy0OzPeTRkEYKUd/UvjArpe
CYk0JEQSh5GIdFy5ljMqRmMzKGfbvlSXASfpH8Rv1UP/Z9meLd241rhvvSicOHKe
W7t6jgjberbgYon+VY6nioTWrFKP7htdvRNwQ/ECgYEA7lSEaeS+yx9RWwRLqHnX
z8JWe8RUD2k7fgtIr9dADqxtBjpSZD5Ito6yZkrENlKKdtmrChOkO+6lQdwMShcd
TuWg/83UxzAdSzmobPGw4nehZ3FvO0HhVV5GCXjFqLdlIFLC/q4CTA+Kr9Zq8eJw
jxVVjT8e+utGPNwSL3Hx0n0CgYEAzTFcW07PqG0wxP9VIQyn1fAO+NnjgP0rO61l
0pX8e7bbX/LezT1RaeZ6GXKNcpTthoZbM79QMq+yrwVU+IiG2gocGJ0ta5F1PLoN
Ndr2MpH4XIR/w3Hc5QJqqo7FXQpaVwBmtxkMcncJzXkayU6iuX5rTMMgNuK9i5aM
M7aSMqUCgYEAz6bp/A2mwvnVe5ThirgmdclgatPjXc4VXLveJ/9Gu8I198Am+1pd
JlpsS74G+UGvjOAYK15zsRg7+ocMWh2LlqtyPI8NPkPIsjtZaRqoQJl1Etj9zkaX
WzcZlLUamua3gJM7fIUGUVkVQCF06q2d3rz63mdJydvmRa6FVbtUtE0CgYEAloH5
U4Q4bztARY9gGvDUfukpokD4ThnlR03F3TRk0T3sJbHY2UR73ijPMLFErWIt47nT
Vd6jbbpQX26SyAYkm/REbY2Ezl0QWG3D2Nf2NFu7h+ksaeiv9U7TfK1ieP9qClzh
+rWl2qQUDaIiErzaQSNIgzKxA3FHRQc1aY0mKX0CgYEAxFOdYHbi08BdGvigs4jG
BaXDYhKo4+B+N8Rim0DcfEmMTl03mIhcHPiprOspKntcryUirkizXr/KYJsAoegS
e/BdvEAfl3Nv/gabCMLHch3As5plCa0MJ1lFaAJVzbo7h7YnpdCsrxBxJ4xUTZKG
JO8fje7X8wfQ4DUxXZHkbkY=
-----END PRIVATE KEY-----

View file

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvweiMYVxxIJJkSDXGaEd
qcvjZTlNAIoTL6DxnGXIA+WLAziyNlrjZT5+HeGzyzd4cn0rHbkTlBKcJ+/Kv2bh
KRxFuGARPMFq4Tzk3DEkOIMJyf7w5V4V7tSOYsGrjhp+KnfLVLaj2r6mdPh+1AsK
PyV5oj0geM/ea2PGVPgIUWPmAGGtij/zpo8qW9myrq5YJWNzSHdPOtpYCoHwDHun
wrtegm8O+AOJVNcbb97/fM2/ELUUdfas8+FLkkxjxh1gbB2zZ5gZqv7B7yC9AQ0h
blsVHbYk+tX/ps5giNG6nb4vRM4MjI3CwocylCc0J2IiV1HfWp0t6aB1/S9LlUMU
kQIDAQAB
-----END PUBLIC KEY-----

1
tests/fixtures/holmes_compras.json vendored Normal file
View file

@ -0,0 +1 @@
{"id":"6a6a59266f8555a6725125a7","author":{"id":"69b00f9897ee2e2f33fc7dc8","name":"Acrisio Dos Santos","email":"acrisio@comercialscardua.com.br"},"created_at":null,"properties":{"protocolo":"protocolo","Tipo de Compra":"COMPRA INTERNA - DESPESAS","Fornecedores":"ACIMAQ EQUIPAMENTOS INDUSTRIAIS E COMERCIAIS LTDA","Prioridade Alta - Motivo":"","Observações":"","Possui Adiantamento do Cliente?":"Não","Fornecedor - Exclusividade?":false,"Cond. Pagamento - 1 (Em dias ex. 30/60/90)":"3060","Forma de Pagamento - 1 ":"Boleto","Descrição da Compra":"ertyhrewerth","ORIGEM CENTRO CUSTO":"32456","PEDIDO_LINX":345,"Pagamento Antecipado Fornecedor":"Sim","Valor Adiantamento fornecedor":null,"parcela1":100.0,"parcela2":100.0,"parcela3":100.0,"parcela4":100.0,"parcela5":100.0,"parcela6":100.0,"parcela7":100.0,"parcela8":100.0,"parcela9":100.0,"vencimento parcela3":"2026-07-10T00:00:00.000Z","vencimento parcela4":"2026-07-10T00:00:00.000Z","vencimento parcela5":"2026-07-22T00:00:00.000Z","vencimento parcela6":"2026-07-17T00:00:00.000Z","vencimento parcela7":"2026-07-11T00:00:00.000Z","vencimento parcela8":"2026-07-23T00:00:00.000Z","vencimento parcela9":"2026-07-17T00:00:00.000Z","vencimento parcela11":"2026-07-23T00:00:00.000Z","vencimento parcela12":"2026-07-03T00:00:00.000Z","parcela11":100720.26,"parcela12":100720.26,"num. parcelas":11,"CNPJ":"28482230000153"},"documents":[]}

21
tests/fixtures/payload_tratado.json vendored Normal file
View file

@ -0,0 +1,21 @@
{
"id_processo": "6a6a59266f8555a6725125a7",
"protocolo": "protocolo",
"cnpj": "28482230000153",
"pedido_linx": "345",
"fornecedor": "ACIMAQ EQUIPAMENTOS INDUSTRIAIS E COMERCIAIS LTDA",
"tipo": "COMPRA INTERNA - DESPESAS",
"nf_entrada": null,
"aprovador": "Acrisio Dos Santos",
"valor_total": 202340.52,
"parcelas": {
"1": {
"valor": 100.0,
"vencimento": null
},
"2": {
"valor": 100.0,
"vencimento": null
}
}
}

178
tests/test_conector.py Normal file
View file

@ -0,0 +1,178 @@
"""
Testes do caminho completo do conector, sem depender da VPS estar no ar.
A chave em tests/fixtures/ e descartavel, gerada so pra isto nao tem
relacao com a do configs.json. O teste cifra com a publica de teste e
troca a chave do app pela privada de teste, entao roda offline.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from app.schemas import PayloadCompras
from app.services import cripto
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi.testclient import TestClient
FIXTURES = Path(__file__).parent / "fixtures"
ROTA = "/v1/compras/dados"
_OAEP = padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)
def _cifrar(dados: dict) -> dict:
"""Faz o papel da API da VPS: cifra com a publica de teste."""
pem = (FIXTURES / "chave_teste_publica.pem").read_bytes()
publica = serialization.load_pem_public_key(pem)
chave_aes = AESGCM.generate_key(bit_length=256)
nonce = b"\x00" * 12 # fixo no teste: nao precisa de aleatoriedade aqui
corpo = json.dumps(dados, ensure_ascii=False, separators=(",", ":")).encode()
import base64
return {
"alg": cripto.ALGORITMO,
"chave": base64.b64encode(publica.encrypt(chave_aes, _OAEP)).decode(),
"nonce": base64.b64encode(nonce).decode(),
"dados": base64.b64encode(AESGCM(chave_aes).encrypt(nonce, corpo, None)).decode(),
}
@pytest.fixture
def payload() -> dict:
return json.loads((FIXTURES / "payload_tratado.json").read_text(encoding="utf-8"))
@pytest.fixture
def cliente(monkeypatch) -> TestClient:
"""App com a chave de teste no lugar da real."""
from app.v1 import compras
privada = cripto.carregar_chave_privada(
(FIXTURES / "chave_teste_privada.pem").read_text()
)
monkeypatch.setattr(compras, "_CHAVE", privada)
from main import app
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def token() -> str:
from app.config import settings
return settings.vps.token
# --- autenticacao ---------------------------------------------------------
def test_sem_token_recusa(cliente, payload):
r = cliente.post(ROTA, json=_cifrar(payload))
assert r.status_code == 401
def test_token_errado_recusa(cliente, payload):
r = cliente.post(ROTA, json=_cifrar(payload), headers={"X-Token": "chute"})
assert r.status_code == 401
# --- caminho feliz --------------------------------------------------------
def test_recebe_e_valida(cliente, payload, token):
r = cliente.post(ROTA, json=_cifrar(payload), headers={"X-Token": token})
assert r.status_code == 200
corpo = r.json()
assert corpo["id_processo"] == payload["id_processo"]
assert corpo["parcelas"] == len(payload["parcelas"])
# Enquanto nao houver Oracle isto tem que continuar False — se virar
# True sem camada de banco, alguem mentiu na resposta.
assert corpo["persistido"] is False
# --- rejeicoes ------------------------------------------------------------
def test_envelope_adulterado_recusa(cliente, payload, token):
"""O GCM autentica: mexer no corpo tem que estourar, nao passar."""
envelope = _cifrar(payload)
envelope["dados"] = "A" + envelope["dados"][1:]
r = cliente.post(ROTA, json=envelope, headers={"X-Token": token})
assert r.status_code == 400
def test_chave_errada_recusa(cliente, payload, token):
"""Cifrado com outra publica: a privada do conector nao abre."""
from cryptography.hazmat.primitives.asymmetric import rsa
outra = rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key()
envelope = _cifrar(payload)
import base64
envelope["chave"] = base64.b64encode(
outra.encrypt(AESGCM.generate_key(bit_length=256), _OAEP)
).decode()
r = cliente.post(ROTA, json=envelope, headers={"X-Token": token})
assert r.status_code == 400
def test_payload_fora_do_contrato_recusa(cliente, payload, token):
"""
Decifra mas o formato mudou: os dois lados sairam de sincronia.
Tem que dar 422 (nao 200 nem 500) falhar explicito e melhor do que
gravar dado torto no Oracle depois.
"""
torto = dict(payload)
del torto["cnpj"]
r = cliente.post(ROTA, json=_cifrar(torto), headers={"X-Token": token})
assert r.status_code == 422
# --- contrato -------------------------------------------------------------
def test_contrato_aceita_o_payload_da_api(payload):
"""
Trava o contrato com a API da VPS.
Se este teste quebrar, o PayloadCompras daqui e o de la divergiram
conferir os dois antes de qualquer deploy.
"""
dados = PayloadCompras.model_validate(payload)
assert dados.id_processo
assert dados.valor_total > 0
assert dados.parcelas
for numero, parcela in dados.parcelas.items():
assert isinstance(numero, int)
assert parcela.valor > 0
assert parcela.vencimento is None or isinstance(parcela.vencimento, datetime)
def test_vencimento_com_z_vira_datetime_utc():
"""O Holmes manda '2026-07-10T00:00:00.000Z'; tem que virar datetime aware."""
dados = PayloadCompras.model_validate(
{
"id_processo": "x", "protocolo": "p", "cnpj": "1", "pedido_linx": "2",
"fornecedor": "F", "tipo": "T", "aprovador": "A", "valor_total": 10.0,
"parcelas": {"3": {"valor": 10.0, "vencimento": "2026-07-10T00:00:00.000Z"}},
}
)
vencimento = dados.parcelas[3].vencimento
assert vencimento == datetime(2026, 7, 10, tzinfo=timezone.utc)