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
+2
View File
@@ -2,6 +2,8 @@
Painel administrativo (FastAPI + Jinja2) para gestão de `CLIENTE`, `CLIENTE_CONTATO` e geração manual de chave de licenciamento no MySQL do sistema legado. Painel administrativo (FastAPI + Jinja2) para gestão de `CLIENTE`, `CLIENTE_CONTATO` e geração manual de chave de licenciamento no MySQL do sistema legado.
Proteções: login HTTP Basic em toda rota, CSRF via cookie de duplo envio (`app/csrf.py` — todo formulário que muda estado embute o mesmo token do cookie `csrf_token`, definido pelo middleware em `app/main.py`) e a lista de clientes é paginada (100 por página).
## Rodando localmente (sem Docker) ## Rodando localmente (sem Docker)
1. Instale as dependências de desenvolvimento: 1. Instale as dependências de desenvolvimento:
+44
View File
@@ -0,0 +1,44 @@
"""
Proteção CSRF via "double-submit cookie": o middleware registrado em
app/main.py garante que todo navegador que acessa o painel tenha um cookie
"csrf_token" (gerado uma vez, aleatório, HttpOnly). Todo formulário que
muda estado (POST) embute esse mesmo valor como campo oculto — os
templates leem de request.state.csrf_token, que o middleware preenche a
cada requisição. Toda rota POST que muda estado usa Depends(exigir_csrf),
que confere se o valor enviado no formulário bate com o valor do cookie.
Por quê isso barra CSRF: um site hostil consegue forçar o navegador da
vítima a mandar um POST para o painel (o cookie vai junto automaticamente),
mas não consegue LER o valor do cookie para replicá-lo como campo oculto do
formulário forjado — só JavaScript rodando na própria origem do painel
consegue (e não roda em outra origem, por causa do same-origin policy).
"""
import secrets
from fastapi import HTTPException, Request, status
CSRF_COOKIE_NAME = "csrf_token"
def gerar_token() -> str:
return secrets.token_urlsafe(32)
def token_do_contexto(request: Request) -> str:
"""
Lê o token que o middleware colocou em request.state.csrf_token.
Retorna "" se o middleware não estiver montado (por exemplo, testes que
sobem só um router isolado, sem app/main.py) — nesse caso o formulário
simplesmente não terá csrf_token preenchido, e exigir_csrf abaixo vai
rejeitar o POST a menos que o teste monte o cookie/campo manualmente.
"""
return getattr(request.state, "csrf_token", "")
async def exigir_csrf(request: Request) -> None:
"""Dependency para toda rota POST que muda estado."""
form = await request.form()
token_formulario = str(form.get(CSRF_COOKIE_NAME, ""))
token_cookie = request.cookies.get(CSRF_COOKIE_NAME, "")
if not token_cookie or not token_formulario or not secrets.compare_digest(token_formulario, token_cookie):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Token CSRF ausente ou inválido.")
+24 -4
View File
@@ -21,7 +21,7 @@ class ClienteComDependenciasError(Exception):
"""Levantada ao tentar excluir um CLIENTE que ainda tem CLIENTE_CONTATO.""" """Levantada ao tentar excluir um CLIENTE que ainda tem CLIENTE_CONTATO."""
def listar_clientes(busca: str | None = None, limite: int = 100) -> list[dict]: def listar_clientes(busca: str | None = None, limite: int = 100, offset: int = 0) -> list[dict]:
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -29,17 +29,37 @@ def listar_clientes(busca: str | None = None, limite: int = 100) -> list[dict]:
sql = ( sql = (
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE " f"SELECT {_COLUNAS_SELECT} FROM CLIENTE "
"WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s " "WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s "
"ORDER BY RAZAO LIMIT %s" "ORDER BY RAZAO LIMIT %s OFFSET %s"
) )
termo = f"%{busca}%" termo = f"%{busca}%"
cur.execute(sql, (termo, termo, termo, termo, limite)) cur.execute(sql, (termo, termo, termo, termo, limite, offset))
else: else:
cur.execute(f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s", (limite,)) cur.execute(
f"SELECT {_COLUNAS_SELECT} FROM CLIENTE ORDER BY RAZAO LIMIT %s OFFSET %s", (limite, offset)
)
return cur.fetchall() return cur.fetchall()
finally: finally:
conn.close() conn.close()
def contar_clientes(busca: str | None = None) -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
if busca:
sql = (
"SELECT COUNT(*) AS total FROM CLIENTE "
"WHERE CODIGO LIKE %s OR CNPJ LIKE %s OR RAZAO LIKE %s OR FANTASIA LIKE %s"
)
termo = f"%{busca}%"
cur.execute(sql, (termo, termo, termo, termo))
else:
cur.execute("SELECT COUNT(*) AS total FROM CLIENTE")
return cur.fetchone()["total"]
finally:
conn.close()
def obter_cliente(cliente_id: int) -> dict | None: def obter_cliente(cliente_id: int) -> dict | None:
conn = get_connection() conn = get_connection()
try: try:
+23 -1
View File
@@ -2,11 +2,12 @@
Painel de Clientes — monta os routers de CLIENTE, CLIENTE_CONTATO e o Painel de Clientes — monta os routers de CLIENTE, CLIENTE_CONTATO e o
gerador de chave manual. Ver docs/superpowers/specs/2026-08-13-painel-clientes-web-design.md. gerador de chave manual. Ver docs/superpowers/specs/2026-08-13-painel-clientes-web-design.md.
""" """
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .auth import exigir_login from .auth import exigir_login
from .csrf import CSRF_COOKIE_NAME, gerar_token
from .routers import chave_manual, clientes, contatos from .routers import chave_manual, clientes, contatos
app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None)
@@ -16,6 +17,27 @@ app.include_router(contatos.router)
app.include_router(chave_manual.router) app.include_router(chave_manual.router)
@app.middleware("http")
async def csrf_cookie_middleware(request: Request, call_next):
"""
Garante que todo navegador que acessa o painel tenha um cookie
csrf_token (gerado uma vez, HttpOnly). Disponibiliza o valor em
request.state.csrf_token para os templates embutirem em formulários —
ver app/csrf.py para a validação do lado do POST.
"""
token_existente = request.cookies.get(CSRF_COOKIE_NAME)
precisa_definir_cookie = token_existente is None
request.state.csrf_token = token_existente or gerar_token()
response = await call_next(request)
if precisa_definir_cookie:
response.set_cookie(
CSRF_COOKIE_NAME, request.state.csrf_token, httponly=True, samesite="strict"
)
return response
@app.get("/") @app.get("/")
def raiz(usuario: str = Depends(exigir_login)): def raiz(usuario: str = Depends(exigir_login)):
return RedirectResponse(url="/clientes") return RedirectResponse(url="/clientes")
+44 -12
View File
@@ -1,8 +1,9 @@
# app/routers/clientes.py # app/routers/clientes.py
""" """
Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe Rotas de gestão de CLIENTE: listar/buscar (paginado), criar, editar, ver
(contatos + status de Clientes_Chaves), excluir. detalhe (contatos + status de Clientes_Chaves), excluir.
""" """
import math
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -12,6 +13,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from ..auth import exigir_login from ..auth import exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..db import chaves as db_chaves from ..db import chaves as db_chaves
from ..db import clientes as db_clientes from ..db import clientes as db_clientes
from ..db import contatos as db_contatos from ..db import contatos as db_contatos
@@ -20,6 +22,8 @@ from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cp
router = APIRouter(prefix="/clientes") router = APIRouter(prefix="/clientes")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
TAMANHO_PAGINA = 100
def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse: def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse:
separador = "&" if "?" in url else "?" separador = "&" if "?" in url else "?"
@@ -48,15 +52,25 @@ def _normalizar_documento_e_telefones(dados: dict) -> None:
def listar( def listar(
request: Request, request: Request,
busca: Optional[str] = None, busca: Optional[str] = None,
pagina: int = 1,
mensagem: Optional[str] = None, mensagem: Optional[str] = None,
mensagem_tipo: Optional[str] = None, mensagem_tipo: Optional[str] = None,
usuario: str = Depends(exigir_login), usuario: str = Depends(exigir_login),
): ):
clientes = db_clientes.listar_clientes(busca=busca or None) pagina = max(pagina, 1)
total = db_clientes.contar_clientes(busca=busca or None)
total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1)
pagina = min(pagina, total_paginas)
offset = (pagina - 1) * TAMANHO_PAGINA
clientes = db_clientes.listar_clientes(busca=busca or None, limite=TAMANHO_PAGINA, offset=offset)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"clientes_lista.html", "clientes_lista.html",
{"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo}, {
"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
"pagina": pagina, "total_paginas": total_paginas, "total": total,
},
) )
@@ -65,11 +79,14 @@ def form_novo(request: Request, usuario: str = Depends(exigir_login)):
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE}, {
"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE,
"csrf_token": token_do_contexto(request),
},
) )
@router.post("/novo") @router.post("/novo", dependencies=[Depends(exigir_csrf)])
async def criar(request: Request, usuario: str = Depends(exigir_login)): async def criar(request: Request, usuario: str = Depends(exigir_login)):
form = dict((await request.form())) form = dict((await request.form()))
dados = _ler_form_cliente(form) dados = _ler_form_cliente(form)
@@ -85,6 +102,7 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_codigo_duplicado": True, "aviso_codigo_duplicado": True,
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
"csrf_token": token_do_contexto(request),
}, },
) )
@@ -102,6 +120,7 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_dv_invalido": True, "aviso_dv_invalido": True,
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
"csrf_token": token_do_contexto(request),
}, },
) )
@@ -111,7 +130,10 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)}, {
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc),
"csrf_token": token_do_contexto(request),
},
) )
return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.")
@@ -136,6 +158,7 @@ def detalhe(
{ {
"cliente": cliente, "contatos": contatos, "status_chaves": status, "cliente": cliente, "contatos": contatos, "status_chaves": status,
"mensagem": mensagem, "mensagem_tipo": mensagem_tipo, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
"csrf_token": token_do_contexto(request),
}, },
) )
@@ -148,11 +171,14 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE}, {
"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE,
"csrf_token": token_do_contexto(request),
},
) )
@router.post("/{cliente_id}/editar") @router.post("/{cliente_id}/editar", dependencies=[Depends(exigir_csrf)])
async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)): async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)):
form = dict((await request.form())) form = dict((await request.form()))
dados = _ler_form_cliente(form) dados = _ler_form_cliente(form)
@@ -163,7 +189,10 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, {
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_codigo_duplicado": True, "csrf_token": token_do_contexto(request),
},
) )
try: try:
@@ -172,12 +201,15 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)}, {
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"erro_gravacao": str(exc), "csrf_token": token_do_contexto(request),
},
) )
return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.")
@router.post("/{cliente_id}/excluir") @router.post("/{cliente_id}/excluir", dependencies=[Depends(exigir_csrf)])
def excluir(cliente_id: int, usuario: str = Depends(exigir_login)): def excluir(cliente_id: int, usuario: str = Depends(exigir_login)):
try: try:
db_clientes.excluir_cliente(cliente_id) db_clientes.excluir_cliente(cliente_id)
+4 -3
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from ..auth import exigir_login from ..auth import exigir_login
from ..csrf import exigir_csrf
from ..db import clientes as db_clientes from ..db import clientes as db_clientes
from ..db import contatos as db_contatos from ..db import contatos as db_contatos
from ..normalize import normalizar_telefone from ..normalize import normalizar_telefone
@@ -21,7 +22,7 @@ def _redirect_com_mensagem(cliente_id: int, mensagem: str, tipo: str = "success"
return RedirectResponse(f"/clientes/{cliente_id}?{query}", status_code=303) return RedirectResponse(f"/clientes/{cliente_id}?{query}", status_code=303)
@router.post("/{cliente_id}/contatos") @router.post("/{cliente_id}/contatos", dependencies=[Depends(exigir_csrf)])
def adicionar( def adicionar(
cliente_id: int, cliente_id: int,
nome: str = Form(""), nome: str = Form(""),
@@ -43,7 +44,7 @@ def adicionar(
return _redirect_com_mensagem(cliente_id, "Contato adicionado.") return _redirect_com_mensagem(cliente_id, "Contato adicionado.")
@router.post("/{cliente_id}/contatos/{contato_id}/editar") @router.post("/{cliente_id}/contatos/{contato_id}/editar", dependencies=[Depends(exigir_csrf)])
def editar( def editar(
cliente_id: int, cliente_id: int,
contato_id: int, contato_id: int,
@@ -63,7 +64,7 @@ def editar(
return _redirect_com_mensagem(cliente_id, "Contato atualizado.") return _redirect_com_mensagem(cliente_id, "Contato atualizado.")
@router.post("/{cliente_id}/contatos/{contato_id}/excluir") @router.post("/{cliente_id}/contatos/{contato_id}/excluir", dependencies=[Depends(exigir_csrf)])
def excluir(cliente_id: int, contato_id: int, usuario: str = Depends(exigir_login)): def excluir(cliente_id: int, contato_id: int, usuario: str = Depends(exigir_login)):
db_contatos.excluir_contato(contato_id, cliente_id) db_contatos.excluir_contato(contato_id, cliente_id)
return _redirect_com_mensagem(cliente_id, "Contato excluído.") return _redirect_com_mensagem(cliente_id, "Contato excluído.")
+4
View File
@@ -7,6 +7,7 @@
<p> <p>
<a class="botao" href="/clientes/{{ cliente.ID }}/editar">Editar</a> <a class="botao" href="/clientes/{{ cliente.ID }}/editar">Editar</a>
<form method="post" action="/clientes/{{ cliente.ID }}/excluir" style="display:inline"> <form method="post" action="/clientes/{{ cliente.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este cliente?')">Excluir</button> <button type="submit" onclick="return confirm('Excluir este cliente?')">Excluir</button>
</form> </form>
</p> </p>
@@ -21,6 +22,7 @@
<details style="display:inline-block"> <details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Editar</summary> <summary class="botao" style="display:inline-block;cursor:pointer">Editar</summary>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/editar" style="margin-top:8px"> <form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/editar" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome-{{ ct.ID }}">Nome</label> <label for="nome-{{ ct.ID }}">Nome</label>
<input type="text" id="nome-{{ ct.ID }}" name="nome" value="{{ ct.NOME or '' }}"> <input type="text" id="nome-{{ ct.ID }}" name="nome" value="{{ ct.NOME or '' }}">
<label for="telefone-{{ ct.ID }}">Telefone</label> <label for="telefone-{{ ct.ID }}">Telefone</label>
@@ -31,6 +33,7 @@
</form> </form>
</details> </details>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/excluir" style="display:inline"> <form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este contato?')">Excluir</button> <button type="submit" onclick="return confirm('Excluir este contato?')">Excluir</button>
</form> </form>
</td> </td>
@@ -38,6 +41,7 @@
{% endfor %} {% endfor %}
</table> </table>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos"> <form method="post" action="/clientes/{{ cliente.ID }}/contatos">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome">Nome</label> <label for="nome">Nome</label>
<input type="text" id="nome" name="nome"> <input type="text" id="nome" name="nome">
<label for="telefone">Telefone</label> <label for="telefone">Telefone</label>
+3
View File
@@ -7,6 +7,7 @@
<div class="mensagem warning"> <div class="mensagem warning">
O código {{ dados.CODIGO }} já está em uso por outro cliente. O código {{ dados.CODIGO }} já está em uso por outro cliente.
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %} {% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %}
<input type="hidden" name="confirmar_codigo_duplicado" value="1"> <input type="hidden" name="confirmar_codigo_duplicado" value="1">
<input type="hidden" name="confirmar_dv_invalido" value="{{ '1' if (aviso_dv_invalido or confirmar_dv_invalido) else '' }}"> <input type="hidden" name="confirmar_dv_invalido" value="{{ '1' if (aviso_dv_invalido or confirmar_dv_invalido) else '' }}">
@@ -18,6 +19,7 @@
<div class="mensagem warning"> <div class="mensagem warning">
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar. O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %} {% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %}
<input type="hidden" name="confirmar_dv_invalido" value="1"> <input type="hidden" name="confirmar_dv_invalido" value="1">
<input type="hidden" name="confirmar_codigo_duplicado" value="{{ '1' if (aviso_codigo_duplicado or confirmar_codigo_duplicado) else '' }}"> <input type="hidden" name="confirmar_codigo_duplicado" value="{{ '1' if (aviso_codigo_duplicado or confirmar_codigo_duplicado) else '' }}">
@@ -31,6 +33,7 @@
</div> </div>
{% endif %} {% endif %}
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for campo in campos %} {% for campo in campos %}
<label for="{{ campo }}">{{ campo }}</label> <label for="{{ campo }}">{{ campo }}</label>
{% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %}
+11
View File
@@ -20,4 +20,15 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% if total_paginas and total_paginas > 1 %}
<p>
{% if pagina > 1 %}
<a class="botao" href="/clientes?pagina={{ pagina - 1 }}{{ '&busca=' + busca if busca else '' }}">&larr; Anterior</a>
{% endif %}
Página {{ pagina }} de {{ total_paginas }} ({{ total }} clientes)
{% if pagina < total_paginas %}
<a class="botao" href="/clientes?pagina={{ pagina + 1 }}{{ '&busca=' + busca if busca else '' }}">Próxima &rarr;</a>
{% endif %}
</p>
{% endif %}
{% endblock %} {% endblock %}
+37 -4
View File
@@ -1,8 +1,11 @@
import base64
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
client = TestClient(app) client = TestClient(app)
_AUTH_HEADERS = {"Authorization": f"Basic {base64.b64encode(b'admin:teste123').decode()}"}
def test_raiz_sem_login_pede_credenciais(): 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): def test_raiz_redireciona_para_clientes_com_login(monkeypatch):
from app.routers import clientes as router_clientes from app.routers import clientes as router_clientes
monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100: []) monkeypatch.setattr(router_clientes.db_clientes, "listar_clientes", lambda busca=None, limite=100, offset=0: [])
import base64 monkeypatch.setattr(router_clientes.db_clientes, "contar_clientes", lambda busca=None: 0)
token = base64.b64encode(b"admin:teste123").decode() resposta = client.get("/", headers=_AUTH_HEADERS, follow_redirects=False)
resposta = client.get("/", headers={"Authorization": f"Basic {token}"}, follow_redirects=False)
assert resposta.status_code == 307 assert resposta.status_code == 307
assert resposta.headers["location"] == "/clientes" 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
+32 -2
View File
@@ -14,21 +14,46 @@ app_teste = FastAPI()
app_teste.include_router(router_clientes.router) app_teste.include_router(router_clientes.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste" app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste, follow_redirects=False) 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): def test_listar_clientes(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
router_clientes.db_clientes, router_clientes.db_clientes,
"listar_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"} {"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") resposta = client.get("/clientes")
assert resposta.status_code == 200 assert resposta.status_code == 200
assert "Cliente Um" in resposta.text 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): def test_detalhe_cliente_mostra_formulario_de_edicao_de_contato(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
router_clientes.db_clientes, router_clientes.db_clientes,
@@ -67,6 +92,7 @@ def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch):
"VENCIMENTO_CONTRATO": "2027-01-01", "VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10", "DIA_VENCIMENTO": "10",
"CNPJ": "11222333000181", "CNPJ": "11222333000181",
"csrf_token": CSRF_TESTE,
} }
) )
@@ -105,6 +131,7 @@ def test_criar_cliente_normaliza_cnpj_e_telefones(monkeypatch):
"VENCIMENTO_CONTRATO": "2027-01-01", "VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10", "DIA_VENCIMENTO": "10",
"confirmar_dv_invalido": "1", "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", "DATA_CONTRATO": "2026-01-01",
"VENCIMENTO_CONTRATO": "2027-01-01", "VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10", "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) 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 resposta.status_code == 200
assert "tem contato" in resposta.text assert "tem contato" in resposta.text
@@ -196,6 +224,7 @@ def test_criar_cliente_com_data_e_decimais_vazios_nao_gera_500():
"DATA_CONTRATO": "", "DATA_CONTRATO": "",
"VENCIMENTO_CONTRATO": "", "VENCIMENTO_CONTRATO": "",
"DIA_VENCIMENTO": "10", "DIA_VENCIMENTO": "10",
"csrf_token": CSRF_TESTE,
} }
) )
try: try:
@@ -230,6 +259,7 @@ def test_criar_cliente_com_ambos_avisos_confirma_ambos_de_uma_vez(monkeypatch):
"VENCIMENTO_CONTRATO": "2027-01-01", "VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10", "DIA_VENCIMENTO": "10",
"CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735") "CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735")
"csrf_token": CSRF_TESTE,
} }
) )
+19 -5
View File
@@ -10,6 +10,11 @@ app_teste.include_router(router_clientes.router)
app_teste.include_router(router_contatos.router) app_teste.include_router(router_contatos.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste" app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste, follow_redirects=False) 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): 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)), 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 resposta.status_code == 303
assert chamadas["dados"] == (1, "Fulano", "75999090001", None) 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_contatos, "listar_contatos_por_cliente", lambda cid: [])
monkeypatch.setattr(router_clientes.db_chaves, "status_chaves", lambda codigo: None) 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 assert "já está cadastrado" in resposta.text
@@ -52,7 +63,10 @@ def test_editar_contato_com_sucesso(monkeypatch):
resposta = client.post( resposta = client.post(
"/clientes/1/contatos/5/editar", "/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 resposta.status_code == 303
assert chamadas["dados"] == (5, 1, "Fulano Editado", "75999090002", "fulano@example.com") 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( resposta = client.post(
"/clientes/1/contatos/5/editar", "/clientes/1/contatos/5/editar",
data={"nome": "Fulano", "telefone": "75999090001"}, data={"nome": "Fulano", "telefone": "75999090001", "csrf_token": CSRF_TESTE},
follow_redirects=True, follow_redirects=True,
) )
assert "já está cadastrado" in resposta.text 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)) 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 resposta.status_code == 303
assert chamadas["dados"] == (5, 1) assert chamadas["dados"] == (5, 1)