feat: protecao CSRF (double-submit cookie) e paginacao da lista de clientes

CSRF: novo app/csrf.py + middleware em app/main.py garantem um cookie
csrf_token por navegador; todo formulario que muda estado (criar/editar/
excluir cliente, adicionar/editar/excluir contato) embute esse valor
como campo oculto, e Depends(exigir_csrf) confere no POST que o valor
enviado bate com o cookie antes de deixar a rota executar. Verificado
ponta a ponta com o app real rodando (curl): token errado -> 403,
token correto -> 303.

Paginacao: db_clientes.listar_clientes ganha offset, nova
contar_clientes(); GET /clientes aceita ?pagina=N (100 clientes por
pagina), template mostra Anterior/Proxima e o total.

53/53 testes passando (8 novos).
This commit is contained in:
Gustavo
2026-08-13 11:02:17 -03:00
parent 96cbd8a294
commit bb8fd596b1
12 changed files with 247 additions and 31 deletions
+32 -2
View File
@@ -14,21 +14,46 @@ app_teste = FastAPI()
app_teste.include_router(router_clientes.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste, follow_redirects=False)
# Este app de teste não tem o middleware de CSRF (só o main.py real o monta).
# Simula um navegador que já tem o cookie definido: os testes de POST
# precisam mandar o mesmo valor no campo oculto "csrf_token".
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
def test_listar_clientes(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100: [
lambda busca=None, limite=100, offset=0: [
{"ID": 1, "CODIGO": "000001", "RAZAO": "Cliente Um", "FANTASIA": "Um", "CNPJ": "11222333000181"}
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 1)
resposta = client.get("/clientes")
assert resposta.status_code == 200
assert "Cliente Um" in resposta.text
def test_listar_clientes_pagina_com_controles_de_paginacao(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
"listar_clientes",
lambda busca=None, limite=100, offset=0: [
{"ID": i, "CODIGO": f"{i:06d}", "RAZAO": f"Cliente {i}", "FANTASIA": f"C{i}", "CNPJ": "11222333000181"}
for i in range(1, 6)
],
)
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 250)
resposta = client.get("/clientes?pagina=2")
assert resposta.status_code == 200
assert "Página 2 de 3" in resposta.text
assert "250 clientes" in resposta.text
assert "Anterior" in resposta.text
assert "Próxima" in resposta.text
def test_detalhe_cliente_mostra_formulario_de_edicao_de_contato(monkeypatch):
monkeypatch.setattr(
router_clientes.db_clientes,
@@ -67,6 +92,7 @@ def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch):
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"CNPJ": "11222333000181",
"csrf_token": CSRF_TESTE,
}
)
@@ -105,6 +131,7 @@ def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"confirmar_dv_invalido": "1",
"csrf_token": CSRF_TESTE,
}
)
@@ -138,6 +165,7 @@ def test_editar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
"DATA_CONTRATO": "2026-01-01",
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"csrf_token": CSRF_TESTE,
}
)
@@ -162,7 +190,7 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", _excluir)
resposta = client.post("/clientes/1/excluir", follow_redirects=True)
resposta = client.post("/clientes/1/excluir", data={"csrf_token": CSRF_TESTE}, follow_redirects=True)
assert resposta.status_code == 200
assert "tem contato" in resposta.text
@@ -196,6 +224,7 @@ def test_criar_cliente_com_data_e_decimais_vazios_nao_gera_500():
"DATA_CONTRATO": "",
"VENCIMENTO_CONTRATO": "",
"DIA_VENCIMENTO": "10",
"csrf_token": CSRF_TESTE,
}
)
try:
@@ -230,6 +259,7 @@ def test_criar_cliente_com_ambos_avisos_confirma_ambos_de_uma_vez(monkeypatch):
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735")
"csrf_token": CSRF_TESTE,
}
)