From 0d839949fecc6a2090813f92f816b5cc05af7062 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 10:46:23 -0300 Subject: [PATCH] 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 --- .env.example | 7 +-- README.md | 37 +++++++++++++ app/auth.py | 7 +++ app/main.py | 2 +- app/routers/clientes.py | 34 ++++++++++-- app/templates/cliente_form.html | 15 +++++- docker-compose.yml | 2 + tests/test_auth.py | 9 ++++ tests/test_db_chaves.py | 2 + tests/test_main.py | 6 +++ tests/test_router_clientes.py | 93 ++++++++++++++++++++++++++++++++- 11 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 README.md diff --git a/.env.example b/.env.example index 0ad832f..1f2dd60 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,10 @@ ADMIN_USER=admin ADMIN_PASSWORD=SUBSTITUA_POR_UMA_SENHA_FORTE # 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=127.0.0.1 (se este app rodar direto na maquina) -DB_HOST=127.0.0.1 +# DB_HOST=host.docker.internal (padrao — para rodar via docker-compose.yml, +# que executa este app dentro de um container) +# 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_NAME=gtisoftw_clientes_dev DB_USER=root diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5c4475 --- /dev/null +++ b/README.md @@ -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`. diff --git a/app/auth.py b/app/auth.py index 9bbbc26..68ba7c3 100644 --- a/app/auth.py +++ b/app/auth.py @@ -15,6 +15,13 @@ def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: usuario_esperado = os.environ.get("ADMIN_USER", "") 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) senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) diff --git a/app/main.py b/app/main.py index d2f4e7d..b44e859 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ from fastapi.staticfiles import StaticFiles from .auth import exigir_login 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.include_router(clientes.router) app.include_router(contatos.router) diff --git a/app/routers/clientes.py b/app/routers/clientes.py index bcec73c..bb0384a 100644 --- a/app/routers/clientes.py +++ b/app/routers/clientes.py @@ -6,6 +6,7 @@ Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe from typing import Optional from urllib.parse import urlencode +import pymysql from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates @@ -59,12 +60,17 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) 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"]): return templates.TemplateResponse( request, "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"]) @@ -73,14 +79,25 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)): dv_invalido = not ( 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( request, "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.") @@ -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}, ) - 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.") diff --git a/app/templates/cliente_form.html b/app/templates/cliente_form.html index d1e38da..aebc6a6 100644 --- a/app/templates/cliente_form.html +++ b/app/templates/cliente_form.html @@ -9,6 +9,7 @@
{% for campo, valor in dados.items() %}{% endfor %} +
@@ -19,14 +20,26 @@
{% for campo, valor in dados.items() %}{% endfor %} +
{% endif %} + {% if erro_gravacao %} +
+ Não foi possível salvar: {{ erro_gravacao }} +
+ {% endif %}
{% for campo in campos %} - + {% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %} + + {% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %} + + {% else %} + + {% endif %} {% endfor %}
diff --git a/docker-compose.yml b/docker-compose.yml index 8e8d2a6..dba63b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,6 @@ services: - "8010:8000" env_file: - .env + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped diff --git a/tests/test_auth.py b/tests/test_auth.py index 20e1f84..93194f8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -36,3 +36,12 @@ def test_credenciais_corretas_retorna_200(): resposta = client.get("/protegido", headers=_basic("admin", "teste123")) assert resposta.status_code == 200 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 diff --git a/tests/test_db_chaves.py b/tests/test_db_chaves.py index 4a455a1..fcf3f27 100644 --- a/tests/test_db_chaves.py +++ b/tests/test_db_chaves.py @@ -14,6 +14,8 @@ def _linha_vazia(codigo: str, cliente: str) -> dict: linha = {f"M{i}": "" for i in range(1, 13)} linha["ID"] = codigo 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" return linha diff --git a/tests/test_main.py b/tests/test_main.py index d20ca84..825bcf5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,6 +10,12 @@ def test_raiz_sem_login_pede_credenciais(): 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): from app.routers import clientes as router_clientes diff --git a/tests/test_router_clientes.py b/tests/test_router_clientes.py index 16f9983..d5907ef 100644 --- a/tests/test_router_clientes.py +++ b/tests/test_router_clientes.py @@ -7,6 +7,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from app.auth import exigir_login +from app.db.connection import get_connection from app.routers import clientes as router_clientes 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) criado = {} 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} @@ -79,3 +80,93 @@ def test_excluir_cliente_com_dependencia_mostra_erro(monkeypatch): resposta = client.post("/clientes/1/excluir", follow_redirects=True) assert resposta.status_code == 200 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