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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"] %}
|
||||||
|
|||||||
@@ -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 '' }}">← 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 →</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+37
-4
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user