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>
178 lines
5.6 KiB
Python
178 lines
5.6 KiB
Python
"""
|
|
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)
|