fix: validacao de formulario, auth fail-closed, loop de confirmacao, hardening e docs (achados da revisao final)

- Fix 1 (critico): cliente_form.html agora tem required/type por campo
  (date/number) e o router captura DataError/IntegrityError em
  criar/editar, reexibindo o form com erro_gravacao em vez de 500.
- Fix 2 (importante): auth.py falha fechado quando ADMIN_USER/
  ADMIN_PASSWORD nao estao configurados (nao tenta mais compare_digest
  com string vazia).
- Fix 3 (importante): avisos de codigo duplicado e DV invalido agora
  carregam a flag de confirmacao um do outro adiante, quebrando o loop
  quando ambos disparam no mesmo cliente.
- Fix 4 (menor): corrige precedencia 'or 42' -> 'and 42' no lambda de
  teste de criar_cliente.
- Fix 5 (menor): desabilita /docs, /redoc, /openapi.json no FastAPI().
- Fix 6 (menor): comentario explicando o valor de Prorrogacao no fixture
  de teste de chaves.
- Fix 7 (menor): .env.example usa host.docker.internal por padrao,
  docker-compose.yml ganha extra_hosts para Linux, README.md novo com
  quickstart real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-13 10:46:23 -03:00
co-authored by Claude Sonnet 5
parent 145f784577
commit 0d839949fe
11 changed files with 203 additions and 11 deletions
+4 -3
View File
@@ -4,9 +4,10 @@ ADMIN_USER=admin
ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE
# Banco de dev (mesmo container do projeto n8n / contatos-chave-web): # Banco de dev (mesmo container do projeto n8n / contatos-chave-web):
# DB_HOST=host.docker.internal (se este app tambem rodar em container) # DB_HOST=host.docker.internal (padrao — para rodar via docker-compose.yml,
# DB_HOST=127.0.0.1 (se este app rodar direto na maquina) # que executa este app dentro de um container)
DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1 (se este app rodar direto na maquina, fora do Docker)
DB_HOST=host.docker.internal
DB_PORT=3307 DB_PORT=3307
DB_NAME=gtisoftw_clientes_dev DB_NAME=gtisoftw_clientes_dev
DB_USER=root DB_USER=root
+37
View File
@@ -0,0 +1,37 @@
# painel-clientes-web
Painel administrativo (FastAPI + Jinja2) para gestão de `CLIENTE`, `CLIENTE_CONTATO` e geração manual de chave de licenciamento no MySQL do sistema legado.
## Rodando localmente (sem Docker)
1. Instale as dependências de desenvolvimento:
```
pip install -r requirements-dev.txt
```
2. Defina as variáveis de ambiente necessárias (`ADMIN_USER`, `ADMIN_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — veja `.env.example`) diretamente no shell antes de subir a aplicação. **Importante:** apesar de `python-dotenv` estar nas dependências, nada no código chama `load_dotenv()` hoje — um arquivo `.env` sozinho NÃO é lido automaticamente fora do Docker. Exporte as variáveis manualmente (ex.: `set -a; source .env; set +a` no bash, ou `$env:...` no PowerShell) ou defina-as por outro meio.
3. Rode a aplicação:
```
uvicorn app.main:app --reload
```
4. Rodando direto na máquina (fora de container), use `DB_HOST=127.0.0.1` (porta `3307`, a mesma do container de dev `licenciamento-mysql-dev`).
## Rodando os testes
```
pytest -v
```
Os testes usam defaults definidos em `tests/conftest.py` (equivalentes ao `.env.example`) e incluem testes de integração que precisam do container MySQL de dev (`licenciamento-mysql-dev`, `127.0.0.1:3307`) rodando.
## Rodando via Docker
1. Copie o arquivo de exemplo de variáveis de ambiente e edite `ADMIN_PASSWORD` (e demais valores, se necessário):
```
cp .env.example .env
```
2. Suba o container:
```
docker compose up -d --build
```
O `docker-compose.yml` já usa `env_file: .env` e mapeia `host.docker.internal` para o host (via `extra_hosts`), então o valor padrão `DB_HOST=host.docker.internal` do `.env.example` funciona também em hosts Docker Linux, não só no Docker Desktop (Windows/Mac).
3. A aplicação fica disponível em `http://localhost:8010`.
+7
View File
@@ -15,6 +15,13 @@ def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str:
usuario_esperado = os.environ.get("ADMIN_USER", "") usuario_esperado = os.environ.get("ADMIN_USER", "")
senha_esperada = os.environ.get("ADMIN_PASSWORD", "") senha_esperada = os.environ.get("ADMIN_PASSWORD", "")
if not usuario_esperado or not senha_esperada:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuário ou senha inválidos.",
headers={"WWW-Authenticate": "Basic"},
)
usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado) usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado)
senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) senha_ok = secrets.compare_digest(credenciais.password, senha_esperada)
+1 -1
View File
@@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles
from .auth import exigir_login from .auth import exigir_login
from .routers import chave_manual, clientes, contatos from .routers import chave_manual, clientes, contatos
app = FastAPI(title="Painel de Clientes") app = FastAPI(title="Painel de Clientes", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(clientes.router) app.include_router(clientes.router)
app.include_router(contatos.router) app.include_router(contatos.router)
+29 -5
View File
@@ -6,6 +6,7 @@ Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import pymysql
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -59,12 +60,17 @@ 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)
confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1"
confirmar_dv = form.get("confirmar_dv_invalido") == "1"
if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]): if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]):
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True}, {
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_codigo_duplicado": True,
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
},
) )
documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"])
@@ -73,14 +79,25 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
dv_invalido = not ( dv_invalido = not (
validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado) validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado)
) )
if dv_invalido and form.get("confirmar_dv_invalido") != "1": if dv_invalido and not confirmar_dv:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"cliente_form.html", "cliente_form.html",
{"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True}, {
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
"aviso_dv_invalido": True,
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
},
) )
novo_id = db_clientes.criar_cliente(dados) try:
novo_id = db_clientes.criar_cliente(dados)
except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc:
return templates.TemplateResponse(
request,
"cliente_form.html",
{"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)},
)
return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.")
@@ -133,7 +150,14 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
{"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},
) )
db_clientes.atualizar_cliente(cliente_id, dados) try:
db_clientes.atualizar_cliente(cliente_id, dados)
except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc:
return templates.TemplateResponse(
request,
"cliente_form.html",
{"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)},
)
return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.")
+14 -1
View File
@@ -9,6 +9,7 @@
<form method="post"> <form method="post">
{% 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 '' }}">
<button type="submit">Cadastrar mesmo assim</button> <button type="submit">Cadastrar mesmo assim</button>
</form> </form>
</div> </div>
@@ -19,14 +20,26 @@
<form method="post"> <form method="post">
{% 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 '' }}">
<button type="submit">Cadastrar mesmo assim</button> <button type="submit">Cadastrar mesmo assim</button>
</form> </form>
</div> </div>
{% endif %} {% endif %}
{% if erro_gravacao %}
<div class="mensagem danger">
Não foi possível salvar: {{ erro_gravacao }}
</div>
{% endif %}
<form method="post"> <form method="post">
{% for campo in campos %} {% for campo in campos %}
<label for="{{ campo }}">{{ campo }}</label> <label for="{{ campo }}">{{ campo }}</label>
<input type="text" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}"> {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %}
<input type="date" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" required>
{% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %}
<input type="number" step="0.01" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" required>
{% else %}
<input type="text" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" required>
{% endif %}
{% endfor %} {% endfor %}
<button type="submit">Salvar</button> <button type="submit">Salvar</button>
</form> </form>
+2
View File
@@ -5,4 +5,6 @@ services:
- "8010:8000" - "8010:8000"
env_file: env_file:
- .env - .env
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
+9
View File
@@ -36,3 +36,12 @@ def test_credenciais_corretas_retorna_200():
resposta = client.get("/protegido", headers=_basic("admin", "teste123")) resposta = client.get("/protegido", headers=_basic("admin", "teste123"))
assert resposta.status_code == 200 assert resposta.status_code == 200
assert resposta.json() == {"usuario": "admin"} assert resposta.json() == {"usuario": "admin"}
def test_admin_user_e_senha_vazios_nao_autentica_com_credenciais_vazias(monkeypatch):
# Sem ADMIN_USER/ADMIN_PASSWORD configurados, o painel não pode ficar
# aberto para uma requisição com usuário/senha vazios (Basic Og==).
monkeypatch.setenv("ADMIN_USER", "")
monkeypatch.setenv("ADMIN_PASSWORD", "")
resposta = client.get("/protegido", headers=_basic("", ""))
assert resposta.status_code == 401
+2
View File
@@ -14,6 +14,8 @@ def _linha_vazia(codigo: str, cliente: str) -> dict:
linha = {f"M{i}": "" for i in range(1, 13)} linha = {f"M{i}": "" for i in range(1, 13)}
linha["ID"] = codigo linha["ID"] = codigo
linha["Cliente"] = cliente linha["Cliente"] = cliente
# "0000-00-00" (o valor real usado no legado) é rejeitado pelo MySQL em modo estrito
# (NO_ZERO_DATE) — usamos uma data valida qualquer, só para satisfazer a coluna NOT NULL.
linha["Prorrogacao"] = "2099-12-31" linha["Prorrogacao"] = "2099-12-31"
return linha return linha
+6
View File
@@ -10,6 +10,12 @@ def test_raiz_sem_login_pede_credenciais():
assert resposta.status_code == 401 assert resposta.status_code == 401
def test_docs_desabilitado_retorna_404():
assert client.get("/docs").status_code == 404
assert client.get("/redoc").status_code == 404
assert client.get("/openapi.json").status_code == 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
+92 -1
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.auth import exigir_login from app.auth import exigir_login
from app.db.connection import get_connection
from app.routers import clientes as router_clientes from app.routers import clientes as router_clientes
app_teste = FastAPI() app_teste = FastAPI()
@@ -32,7 +33,7 @@ def test_criar_cliente_codigo_duplicado_exige_confirmacao(monkeypatch):
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True) monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True)
criado = {} criado = {}
monkeypatch.setattr( monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) or 42 router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
) )
dados_form = {campo: "000001" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE} dados_form = {campo: "000001" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE}
@@ -79,3 +80,93 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch):
resposta = client.post("/clientes/1/excluir", follow_redirects=True) resposta = client.post("/clientes/1/excluir", 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
def _limpar_codigo(codigo: str) -> None:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM CLIENTE WHERE CODIGO = %s", (codigo,))
finally:
conn.close()
def test_criar_cliente_com_data_e_decimais_vazios_nao_gera_500():
"""
Integração com o banco real de dev: DATA_CONTRATO/decimais são NOT NULL
e o dev roda em STRICT_TRANS_TABLES — submeter strings vazias deve ser
capturado pelo router (pymysql.err.DataError) e reexibir o formulário
com erro_gravacao, nunca estourar como 500.
"""
codigo = "999906"
dados_form = {campo: "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE}
dados_form.update(
{
"CODIGO": codigo,
"CNPJ": "11222333000181",
"IMPLANTACAO": "",
"MENSALIDADE": "",
"JUROS_MORA": "",
"JUROS_MULTA": "",
"DATA_CONTRATO": "",
"VENCIMENTO_CONTRATO": "",
"DIA_VENCIMENTO": "10",
}
)
try:
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "Não foi possível salvar" in resposta.text
finally:
_limpar_codigo(codigo)
def test_criar_cliente_com_ambos_avisos_confirma_ambos_de_uma_vez(monkeypatch):
"""
Reproduz Fix 3: um cliente com CODIGO duplicado E DV de documento
inválido não pode ficar preso em loop entre os dois avisos — confirmar
os dois de uma vez (hidden fields carregados de um aviso para o outro)
deve efetivamente criar o cliente.
"""
monkeypatch.setattr(router_clientes.db_clientes, "codigo_em_uso", lambda codigo, ignorar_id=None: True)
criado = {}
monkeypatch.setattr(
router_clientes.db_clientes, "criar_cliente", lambda dados: criado.setdefault("ok", True) and 42
)
dados_form = {campo: "000002" if campo == "CODIGO" else "x" for campo in router_clientes.db_clientes.CAMPOS_CLIENTE}
dados_form.update(
{
"IMPLANTACAO": "0",
"MENSALIDADE": "100",
"JUROS_MORA": "0",
"JUROS_MULTA": "0",
"DATA_CONTRATO": "2026-01-01",
"VENCIMENTO_CONTRATO": "2027-01-01",
"DIA_VENCIMENTO": "10",
"CNPJ": "11144477734", # CPF com DV inválido (1 dígito diferente do válido "...735")
}
)
# Round 1: nem codigo nem dv confirmados -> deve pedir confirmação de código duplicado.
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "já está em uso" in resposta.text
assert "ok" not in criado
# Round 2: confirma o código duplicado -> agora deve pedir confirmação do DV inválido.
dados_form["confirmar_codigo_duplicado"] = "1"
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 200
assert "não confere" in resposta.text
assert "ok" not in criado
# A raiz do Fix 3: o form do aviso de DV precisa carregar
# confirmar_codigo_duplicado=1 adiante (senão confirmar só o DV a seguir
# reabriria o aviso de código duplicado, em loop infinito).
assert 'name="confirmar_codigo_duplicado" value="1"' in resposta.text
# Round 3: confirma ambos de uma vez -> deve criar (303), sem cair em outro loop.
dados_form["confirmar_dv_invalido"] = "1"
resposta = client.post("/clientes/novo", data=dados_form)
assert resposta.status_code == 303
assert criado.get("ok") is True