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:
+37
-4
@@ -1,8 +1,11 @@
|
||||
import base64
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
_AUTH_HEADERS = {"Authorization": f"Basic {base64.b64encode(b'admin:teste123').decode()}"}
|
||||
|
||||
|
||||
def test_raiz_sem_login_pede_credenciais():
|
||||
@@ -19,9 +22,39 @@ def test_docs_desabilitado_retorna_404():
|
||||
def test_raiz_redireciona_para_clientes_com_login(monkeypatch):
|
||||
from app.routers import clientes as router_clientes
|
||||
|
||||
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100: [])
|
||||
import base64
|
||||
token = base64.b64encode(b"admin:teste123").decode()
|
||||
resposta = client.get("/", headers={"Authorization": f"Basic {token}"}, follow_redirects=False)
|
||||
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0: [])
|
||||
monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 0)
|
||||
resposta = client.get("/", headers=_AUTH_HEADERS, follow_redirects=False)
|
||||
assert resposta.status_code == 307
|
||||
assert resposta.headers["location"] == "/clientes"
|
||||
|
||||
|
||||
def test_csrf_middleware_define_cookie_em_qualquer_resposta():
|
||||
client.cookies.clear()
|
||||
resposta = client.get("/docs")
|
||||
assert resposta.status_code == 404
|
||||
assert "csrf_token" in resposta.cookies
|
||||
|
||||
|
||||
def test_csrf_bloqueia_post_com_token_que_nao_bate_com_cookie():
|
||||
client.cookies.clear()
|
||||
resposta = client.post(
|
||||
"/clientes/1/excluir", headers=_AUTH_HEADERS, data={"csrf_token": "token-forjado-sem-cookie"}
|
||||
)
|
||||
assert resposta.status_code == 403
|
||||
|
||||
|
||||
def test_csrf_libera_post_quando_token_bate_com_cookie(monkeypatch):
|
||||
from app.routers import clientes as router_clientes
|
||||
|
||||
monkeypatch.setattr(router_clientes.db_clientes, "excluir_cliente", lambda cliente_id: None)
|
||||
|
||||
client.cookies.clear()
|
||||
client.get("/docs") # só para o middleware definir o cookie csrf_token no client de teste
|
||||
token_cookie = client.cookies.get("csrf_token")
|
||||
assert token_cookie
|
||||
|
||||
resposta = client.post(
|
||||
"/clientes/1/excluir", headers=_AUTH_HEADERS, data={"csrf_token": token_cookie}, follow_redirects=False
|
||||
)
|
||||
assert resposta.status_code == 303
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ app_teste.include_router(router_clientes.router)
|
||||
app_teste.include_router(router_contatos.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_adicionar_contato_com_sucesso(monkeypatch):
|
||||
@@ -22,7 +27,9 @@ def test_adicionar_contato_com_sucesso(monkeypatch):
|
||||
lambda cid, nome, tel, email=None: chamadas.setdefault("dados", (cid, nome, tel, email)),
|
||||
)
|
||||
|
||||
resposta = client.post("/clientes/1/contatos", data={"nome": "Fulano", "telefone": "(75) 99909-0001"})
|
||||
resposta = client.post(
|
||||
"/clientes/1/contatos", data={"nome": "Fulano", "telefone": "(75) 99909-0001", "csrf_token": CSRF_TESTE}
|
||||
)
|
||||
assert resposta.status_code == 303
|
||||
assert chamadas["dados"] == (1, "Fulano", "75999090001", None)
|
||||
|
||||
@@ -35,7 +42,11 @@ def test_adicionar_contato_duplicado_avisa(monkeypatch):
|
||||
monkeypatch.setattr(router_clientes.db_contatos, "listar_contatos_por_cliente", lambda cid: [])
|
||||
monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None)
|
||||
|
||||
resposta = client.post("/clientes/1/contatos", data={"nome": "Fulano", "telefone": "75999090001"}, follow_redirects=True)
|
||||
resposta = client.post(
|
||||
"/clientes/1/contatos",
|
||||
data={"nome": "Fulano", "telefone": "75999090001", "csrf_token": CSRF_TESTE},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert "já está cadastrado" in resposta.text
|
||||
|
||||
|
||||
@@ -52,7 +63,10 @@ def test_editar_contato_com_sucesso(monkeypatch):
|
||||
|
||||
resposta = client.post(
|
||||
"/clientes/1/contatos/5/editar",
|
||||
data={"nome": "Fulano Editado", "telefone": "(75) 99909-0002", "email": "fulano@example.com"},
|
||||
data={
|
||||
"nome": "Fulano Editado", "telefone": "(75) 99909-0002", "email": "fulano@example.com",
|
||||
"csrf_token": CSRF_TESTE,
|
||||
},
|
||||
)
|
||||
assert resposta.status_code == 303
|
||||
assert chamadas["dados"] == (5, 1, "Fulano Editado", "75999090002", "fulano@example.com")
|
||||
@@ -66,7 +80,7 @@ def test_editar_contato_duplicado_avisa(monkeypatch):
|
||||
|
||||
resposta = client.post(
|
||||
"/clientes/1/contatos/5/editar",
|
||||
data={"nome": "Fulano", "telefone": "75999090001"},
|
||||
data={"nome": "Fulano", "telefone": "75999090001", "csrf_token": CSRF_TESTE},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert "já está cadastrado" in resposta.text
|
||||
@@ -79,6 +93,6 @@ def test_excluir_contato(monkeypatch):
|
||||
router_contatos.db_contatos, "excluir_contato", lambda contato_id, cid: chamadas.setdefault("dados", (contato_id, cid))
|
||||
)
|
||||
|
||||
resposta = client.post("/clientes/1/contatos/5/excluir")
|
||||
resposta = client.post("/clientes/1/contatos/5/excluir", data={"csrf_token": CSRF_TESTE})
|
||||
assert resposta.status_code == 303
|
||||
assert chamadas["dados"] == (5, 1)
|
||||
|
||||
Reference in New Issue
Block a user