diff --git a/README.md b/README.md index 8723308..8c1e8a8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Proteções: login HTTP Basic em toda rota, CSRF via cookie de duplo envio (`app A rota `/log` mostra o histórico de ações do painel (tabela `LOG_PAINEL`, criada no banco de dev — **precisa ser criada manualmente em produção também**, DDL no início de `app/db/log.py`) e, se `N8N_API_URL`/`N8N_API_KEY` estiverem configuradas (`.env.example`), os últimos envios automáticos de chave via WhatsApp (lidos da Data Table `pagamentos_processados` do n8n, somente leitura). +## Login por funcionário + +Cada funcionário pode ter seu próprio usuário/senha (tabela `FUNCIONARIO_PAINEL`, criada no banco de dev — **precisa ser criada manualmente em produção também**, DDL no início de `app/db/funcionarios.py`; senha em hash `scrypt`, nunca texto puro). Gerencie em **Funcionários** no menu. + +`ADMIN_USER`/`ADMIN_PASSWORD` (`.env.example`) continuam funcionando como conta de bootstrap — é com ela que você entra a primeira vez para cadastrar o primeiro funcionário, e serve de plano B se a tabela ficar vazia. Login checa primeiro o admin do `.env`, depois `FUNCIONARIO_PAINEL` (só contas com `ATIVO=1`). O log de ações mostra o nome de quem fez cada coisa. + ## Rodando localmente (sem Docker) 1. Instale as dependências de desenvolvimento: diff --git a/app/auth.py b/app/auth.py index 68ba7c3..d8dc3e2 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,6 +1,15 @@ """ -Login simples via HTTP Basic Auth, usuário/senha vindos de variável de -ambiente. Sem cadastro de usuários, sem perfis, sem sessão/cookie. +Login HTTP Basic. Duas fontes de credencial, checadas nessa ordem: + +1. ADMIN_USER/ADMIN_PASSWORD (variáveis de ambiente) — conta única de + bootstrap, útil para o primeiro acesso (cadastrar o primeiro + funcionário em /funcionarios) e como plano B se a tabela de + funcionários ficar vazia/inacessível. Continua exigindo as duas + variáveis preenchidas (nunca autentica credencial vazia). +2. FUNCIONARIO_PAINEL (app/db/funcionarios.py) — conta individual por + funcionário, com senha em hash (app/senha.py). Só autentica se ATIVO=1. + +Sem sessão/cookie — o próprio navegador guarda a credencial Basic. """ import os import secrets @@ -8,27 +17,28 @@ import secrets from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials +from .db import funcionarios as db_funcionarios +from .senha import verificar_senha + _security = HTTPBasic() def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: - usuario_esperado = os.environ.get("ADMIN_USER", "") - senha_esperada = os.environ.get("ADMIN_PASSWORD", "") + usuario_admin = os.environ.get("ADMIN_USER", "") + senha_admin = 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"}, - ) + if usuario_admin and senha_admin: + usuario_ok = secrets.compare_digest(credenciais.username, usuario_admin) + senha_ok = secrets.compare_digest(credenciais.password, senha_admin) + if usuario_ok and senha_ok: + return credenciais.username - usuario_ok = secrets.compare_digest(credenciais.username, usuario_esperado) - senha_ok = secrets.compare_digest(credenciais.password, senha_esperada) + funcionario = db_funcionarios.obter_por_login(credenciais.username) + if funcionario and funcionario["ATIVO"] and verificar_senha(credenciais.password, funcionario["SENHA_HASH"]): + return funcionario["NOME"] - if not (usuario_ok and senha_ok): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Usuário ou senha inválidos.", - headers={"WWW-Authenticate": "Basic"}, - ) - return credenciais.username + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuário ou senha inválidos.", + headers={"WWW-Authenticate": "Basic"}, + ) diff --git a/app/db/funcionarios.py b/app/db/funcionarios.py new file mode 100644 index 0000000..55b26a5 --- /dev/null +++ b/app/db/funcionarios.py @@ -0,0 +1,119 @@ +""" +CRUD de FUNCIONARIO_PAINEL — contas individuais de funcionário para login +no painel (substitui/complementa o ADMIN_USER/ADMIN_PASSWORD único). + +A tabela já existe no banco de dev. Em qualquer outro ambiente, crie-a +primeiro com: + + CREATE TABLE FUNCIONARIO_PAINEL ( + ID INT UNSIGNED NOT NULL AUTO_INCREMENT, + NOME VARCHAR(80) NOT NULL, + LOGIN VARCHAR(40) NOT NULL, + SENHA_HASH VARCHAR(255) NOT NULL, + ATIVO TINYINT(1) NOT NULL DEFAULT 1, + CRIADO_EM DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (ID), + UNIQUE KEY UQ_FUNCIONARIO_PAINEL_LOGIN (LOGIN) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + +Nunca excluído de verdade — "excluir" aqui é alternar ATIVO para 0 +(mesma lógica de nunca apagar histórico usada no resto do projeto). +""" +from .connection import get_connection +from ..senha import gerar_hash + + +def listar_funcionarios() -> list[dict]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT ID, NOME, LOGIN, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL ORDER BY NOME") + return cur.fetchall() + finally: + conn.close() + + +def obter_funcionario(funcionario_id: int) -> dict | None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT ID, NOME, LOGIN, ATIVO, CRIADO_EM FROM FUNCIONARIO_PAINEL WHERE ID = %s", (funcionario_id,) + ) + return cur.fetchone() + finally: + conn.close() + + +def obter_por_login(login: str) -> dict | None: + """Usado pelo login (app/auth.py) — inclui SENHA_HASH, ao contrário das outras consultas.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT ID, NOME, LOGIN, SENHA_HASH, ATIVO FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s", (login,) + ) + return cur.fetchone() + finally: + conn.close() + + +def login_em_uso(login: str, ignorar_id: int | None = None) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + sql = "SELECT ID FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s" + params = [login] + if ignorar_id is not None: + sql += " AND ID != %s" + params.append(ignorar_id) + cur.execute(sql, params) + return cur.fetchone() is not None + finally: + conn.close() + + +def criar_funcionario(nome: str, login: str, senha: str) -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO FUNCIONARIO_PAINEL (NOME, LOGIN, SENHA_HASH, ATIVO) VALUES (%s, %s, %s, 1)", + (nome, login, gerar_hash(senha)), + ) + return cur.lastrowid + finally: + conn.close() + + +def atualizar_funcionario(funcionario_id: int, nome: str, login: str) -> None: + """Só nome/login — troca de senha é redefinir_senha().""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE FUNCIONARIO_PAINEL SET NOME = %s, LOGIN = %s WHERE ID = %s", (nome, login, funcionario_id) + ) + finally: + conn.close() + + +def redefinir_senha(funcionario_id: int, nova_senha: str) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE FUNCIONARIO_PAINEL SET SENHA_HASH = %s WHERE ID = %s", + (gerar_hash(nova_senha), funcionario_id), + ) + finally: + conn.close() + + +def alternar_ativo(funcionario_id: int) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("UPDATE FUNCIONARIO_PAINEL SET ATIVO = 1 - ATIVO WHERE ID = %s", (funcionario_id,)) + finally: + conn.close() diff --git a/app/main.py b/app/main.py index 096fce0..236a3ea 100644 --- a/app/main.py +++ b/app/main.py @@ -8,13 +8,14 @@ from fastapi.staticfiles import StaticFiles from .auth import exigir_login from .csrf import CSRF_COOKIE_NAME, gerar_token -from .routers import chave_manual, clientes, contatos, log +from .routers import chave_manual, clientes, contatos, funcionarios, log 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) app.include_router(chave_manual.router) +app.include_router(funcionarios.router) app.include_router(log.router) diff --git a/app/routers/funcionarios.py b/app/routers/funcionarios.py new file mode 100644 index 0000000..de31e3c --- /dev/null +++ b/app/routers/funcionarios.py @@ -0,0 +1,148 @@ +""" +Rotas de gestão de FUNCIONARIO_PAINEL: listar, criar (com senha), editar +nome/login, redefinir senha, ativar/desativar (nunca exclui de verdade). +""" +from typing import Optional + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from ..auth import exigir_login +from ..csrf import exigir_csrf, token_do_contexto +from ..db import funcionarios as db_funcionarios +from ..db import log as db_log + +router = APIRouter(prefix="/funcionarios") +templates = Jinja2Templates(directory="app/templates") + + +def _redirect_com_mensagem(mensagem: str, tipo: str = "success") -> RedirectResponse: + from urllib.parse import urlencode + + return RedirectResponse(f"/funcionarios?{urlencode({'mensagem': mensagem, 'mensagem_tipo': tipo})}", status_code=303) + + +@router.get("", response_class=HTMLResponse) +def listar( + request: Request, + mensagem: Optional[str] = None, + mensagem_tipo: Optional[str] = None, + usuario: str = Depends(exigir_login), +): + funcionarios = db_funcionarios.listar_funcionarios() + return templates.TemplateResponse( + request, + "funcionarios_lista.html", + { + "funcionarios": funcionarios, "csrf_token": token_do_contexto(request), + "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, + }, + ) + + +@router.get("/novo", response_class=HTMLResponse) +def form_novo(request: Request, usuario: str = Depends(exigir_login)): + return templates.TemplateResponse( + request, "funcionario_form.html", {"funcionario": None, "csrf_token": token_do_contexto(request)} + ) + + +@router.post("/novo", dependencies=[Depends(exigir_csrf)]) +def criar( + request: Request, + nome: str = Form(...), + login: str = Form(...), + senha: str = Form(...), + confirmar_senha: str = Form(...), + usuario: str = Depends(exigir_login), +): + nome = nome.strip() + login = login.strip() + + erro = None + if not nome or not login or not senha: + erro = "Preencha nome, login e senha." + elif senha != confirmar_senha: + erro = "As senhas digitadas não coincidem." + elif db_funcionarios.login_em_uso(login): + erro = f"O login \"{login}\" já está em uso." + + if erro: + return templates.TemplateResponse( + request, + "funcionario_form.html", + { + "funcionario": None, "erro": erro, "dados": {"NOME": nome, "LOGIN": login}, + "csrf_token": token_do_contexto(request), + }, + ) + + novo_id = db_funcionarios.criar_funcionario(nome, login, senha) + db_log.registrar(usuario, "criar", "funcionario", str(novo_id), True) + return _redirect_com_mensagem("Funcionário cadastrado.") + + +@router.get("/{funcionario_id}/editar", response_class=HTMLResponse) +def form_editar(request: Request, funcionario_id: int, usuario: str = Depends(exigir_login)): + funcionario = db_funcionarios.obter_funcionario(funcionario_id) + if funcionario is None: + return _redirect_com_mensagem("Funcionário não encontrado.", "danger") + return templates.TemplateResponse( + request, "funcionario_form.html", {"funcionario": funcionario, "csrf_token": token_do_contexto(request)} + ) + + +@router.post("/{funcionario_id}/editar", dependencies=[Depends(exigir_csrf)]) +def editar( + request: Request, + funcionario_id: int, + nome: str = Form(...), + login: str = Form(...), + usuario: str = Depends(exigir_login), +): + nome = nome.strip() + login = login.strip() + + erro = None + if not nome or not login: + erro = "Preencha nome e login." + elif db_funcionarios.login_em_uso(login, ignorar_id=funcionario_id): + erro = f"O login \"{login}\" já está em uso." + + if erro: + return templates.TemplateResponse( + request, + "funcionario_form.html", + { + "funcionario": {"ID": funcionario_id}, "erro": erro, "dados": {"NOME": nome, "LOGIN": login}, + "csrf_token": token_do_contexto(request), + }, + ) + + db_funcionarios.atualizar_funcionario(funcionario_id, nome, login) + db_log.registrar(usuario, "editar", "funcionario", str(funcionario_id), True) + return _redirect_com_mensagem("Funcionário atualizado.") + + +@router.post("/{funcionario_id}/redefinir-senha", dependencies=[Depends(exigir_csrf)]) +def redefinir_senha( + funcionario_id: int, + nova_senha: str = Form(...), + confirmar_nova_senha: str = Form(...), + usuario: str = Depends(exigir_login), +): + if not nova_senha or nova_senha != confirmar_nova_senha: + db_log.registrar(usuario, "redefinir_senha", "funcionario", str(funcionario_id), False, "senhas não coincidem") + return _redirect_com_mensagem("As senhas digitadas não coincidem.", "danger") + + db_funcionarios.redefinir_senha(funcionario_id, nova_senha) + db_log.registrar(usuario, "redefinir_senha", "funcionario", str(funcionario_id), True) + return _redirect_com_mensagem("Senha redefinida.") + + +@router.post("/{funcionario_id}/alternar-ativo", dependencies=[Depends(exigir_csrf)]) +def alternar_ativo(funcionario_id: int, usuario: str = Depends(exigir_login)): + db_funcionarios.alternar_ativo(funcionario_id) + db_log.registrar(usuario, "alternar_ativo", "funcionario", str(funcionario_id), True) + return _redirect_com_mensagem("Status do funcionário atualizado.") diff --git a/app/senha.py b/app/senha.py new file mode 100644 index 0000000..9c6050d --- /dev/null +++ b/app/senha.py @@ -0,0 +1,31 @@ +""" +Hash de senha de funcionário com scrypt (hashlib da biblioteca padrão — +sem dependência extra tipo bcrypt/passlib). Formato armazenado: +"scrypt$$". +""" +import hashlib +import secrets + +_N, _R, _P = 2**14, 8, 1 +_TAMANHO_HASH = 32 + + +def gerar_hash(senha: str) -> str: + salt = secrets.token_bytes(16) + resultado = hashlib.scrypt(senha.encode("utf-8"), salt=salt, n=_N, r=_R, p=_P, dklen=_TAMANHO_HASH) + return f"scrypt${salt.hex()}${resultado.hex()}" + + +def verificar_senha(senha: str, hash_armazenado: str) -> bool: + """Nunca lança — hash malformado ou senha errada só retorna False.""" + try: + algoritmo, salt_hex, hash_hex = hash_armazenado.split("$") + if algoritmo != "scrypt": + return False + salt = bytes.fromhex(salt_hex) + esperado = bytes.fromhex(hash_hex) + except (ValueError, AttributeError): + return False + + calculado = hashlib.scrypt(senha.encode("utf-8"), salt=salt, n=_N, r=_R, p=_P, dklen=len(esperado)) + return secrets.compare_digest(calculado, esperado) diff --git a/app/templates/base.html b/app/templates/base.html index 94bf5e3..2661d43 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,6 +26,7 @@
diff --git a/app/templates/funcionario_form.html b/app/templates/funcionario_form.html new file mode 100644 index 0000000..d7b246d --- /dev/null +++ b/app/templates/funcionario_form.html @@ -0,0 +1,26 @@ + +{% extends "base.html" %} +{% block titulo %}{{ "Editar" if funcionario else "Novo" }} funcionário{% endblock %} +{% block conteudo %} +

{{ "Editar" if funcionario else "Novo" }} funcionário

+ {% if erro %} +
{{ erro }}
+ {% endif %} +
+ + + + + + {% if not funcionario %} + + + + + {% endif %} + +
+ {% if funcionario %} +

Para trocar a senha, use "Redefinir senha" na lista de funcionários.

+ {% endif %} +{% endblock %} diff --git a/app/templates/funcionarios_lista.html b/app/templates/funcionarios_lista.html new file mode 100644 index 0000000..c98036d --- /dev/null +++ b/app/templates/funcionarios_lista.html @@ -0,0 +1,37 @@ + +{% extends "base.html" %} +{% block titulo %}Funcionários{% endblock %} +{% block conteudo %} +

Funcionários

+

+ Novo funcionário

+ + + {% for f in funcionarios %} + + + + + + + {% endfor %} +
NomeLoginStatus
{{ f.NOME }}{{ f.LOGIN }}{{ "Ativo" if f.ATIVO else "Inativo" }} + Editar +
+ Redefinir senha +
+ + + + + + +
+
+
+ + +
+
+{% endblock %} diff --git a/tests/test_auth.py b/tests/test_auth.py index 93194f8..2b56d44 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,12 @@ import base64 +import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from app.auth import exigir_login +from app.db import funcionarios as db_funcionarios +from app.db.connection import get_connection app_teste = FastAPI() @@ -45,3 +48,44 @@ def test_admin_user_e_senha_vazios_nao_autentica_com_credenciais_vazias(monkeypa monkeypatch.setenv("ADMIN_PASSWORD", "") resposta = client.get("/protegido", headers=_basic("", "")) assert resposta.status_code == 401 + + +def _limpar_login(login: str) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM FUNCIONARIO_PAINEL WHERE LOGIN = %s", (login,)) + finally: + conn.close() + + +@pytest.fixture +def funcionario_teste(): + login = "teste.login.auth" + _limpar_login(login) + funcionario_id = db_funcionarios.criar_funcionario("Fulano de Teste", login, "senhaForte123") + yield login + _limpar_login(login) + + +def test_funcionario_ativo_com_senha_correta_autentica(funcionario_teste): + resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) + assert resposta.status_code == 200 + assert resposta.json() == {"usuario": "Fulano de Teste"} + + +def test_funcionario_com_senha_errada_nao_autentica(funcionario_teste): + resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaErrada")) + assert resposta.status_code == 401 + + +def test_funcionario_inativo_nao_autentica(funcionario_teste): + funcionario = db_funcionarios.obter_por_login(funcionario_teste) + db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa + resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) + assert resposta.status_code == 401 + + +def test_login_inexistente_nao_autentica(): + resposta = client.get("/protegido", headers=_basic("nao-existe-esse-login", "qualquer")) + assert resposta.status_code == 401 diff --git a/tests/test_db_funcionarios.py b/tests/test_db_funcionarios.py new file mode 100644 index 0000000..80b3446 --- /dev/null +++ b/tests/test_db_funcionarios.py @@ -0,0 +1,78 @@ +"""Testes de integração contra o banco de dev (ver test_db_connection.py).""" +import pytest + +from app.db import funcionarios as db_funcionarios +from app.db.connection import get_connection + +LOGIN_TESTE = "teste.db.funcionarios" + + +def _limpar(): + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM FUNCIONARIO_PAINEL WHERE LOGIN LIKE 'teste.db.funcionarios%'") + finally: + conn.close() + + +@pytest.fixture +def funcionario_id(): + _limpar() + novo_id = db_funcionarios.criar_funcionario("Fulano Teste", LOGIN_TESTE, "senhaForte123") + yield novo_id + _limpar() + + +def test_criar_e_obter_funcionario(funcionario_id): + f = db_funcionarios.obter_funcionario(funcionario_id) + assert f is not None + assert f["NOME"] == "Fulano Teste" + assert f["LOGIN"] == LOGIN_TESTE + assert f["ATIVO"] == 1 + assert "SENHA_HASH" not in f # obter_funcionario não expõe o hash + + +def test_obter_por_login_inclui_senha_hash(funcionario_id): + f = db_funcionarios.obter_por_login(LOGIN_TESTE) + assert f is not None + assert "SENHA_HASH" in f + assert f["SENHA_HASH"] != "senhaForte123" # nunca em texto puro + + +def test_obter_por_login_inexistente(funcionario_id): + assert db_funcionarios.obter_por_login("nao-existe-esse-login-xyz") is None + + +def test_login_em_uso(funcionario_id): + assert db_funcionarios.login_em_uso(LOGIN_TESTE) is True + assert db_funcionarios.login_em_uso(LOGIN_TESTE, ignorar_id=funcionario_id) is False + assert db_funcionarios.login_em_uso("login-que-nao-existe") is False + + +def test_listar_funcionarios_inclui_o_criado(funcionario_id): + logins = [f["LOGIN"] for f in db_funcionarios.listar_funcionarios()] + assert LOGIN_TESTE in logins + + +def test_atualizar_funcionario(funcionario_id): + novo_login = "teste.db.funcionarios.editado" + db_funcionarios.atualizar_funcionario(funcionario_id, "Fulano Editado", novo_login) + f = db_funcionarios.obter_funcionario(funcionario_id) + assert f["NOME"] == "Fulano Editado" + assert f["LOGIN"] == novo_login + + +def test_redefinir_senha(funcionario_id): + hash_antes = db_funcionarios.obter_por_login(LOGIN_TESTE)["SENHA_HASH"] + db_funcionarios.redefinir_senha(funcionario_id, "outraSenha456") + hash_depois = db_funcionarios.obter_por_login(LOGIN_TESTE)["SENHA_HASH"] + assert hash_antes != hash_depois + + +def test_alternar_ativo(funcionario_id): + assert db_funcionarios.obter_funcionario(funcionario_id)["ATIVO"] == 1 + db_funcionarios.alternar_ativo(funcionario_id) + assert db_funcionarios.obter_funcionario(funcionario_id)["ATIVO"] == 0 + db_funcionarios.alternar_ativo(funcionario_id) + assert db_funcionarios.obter_funcionario(funcionario_id)["ATIVO"] == 1 diff --git a/tests/test_router_funcionarios.py b/tests/test_router_funcionarios.py new file mode 100644 index 0000000..99183ca --- /dev/null +++ b/tests/test_router_funcionarios.py @@ -0,0 +1,150 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.auth import exigir_login +from app.routers import funcionarios as router_funcionarios + +app_teste = FastAPI() +app_teste.include_router(router_funcionarios.router) +app_teste.dependency_overrides[exigir_login] = lambda: "teste" +client = TestClient(app_teste, follow_redirects=False) +CSRF_TESTE = "teste-csrf-token" +client.cookies.set("csrf_token", CSRF_TESTE) + + +@pytest.fixture(autouse=True) +def _sem_log_real(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_log, "registrar", lambda *args, **kwargs: None) + + +def test_listar_funcionarios(monkeypatch): + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "listar_funcionarios", + lambda: [{"ID": 1, "NOME": "Fulano", "LOGIN": "fulano", "ATIVO": 1, "CRIADO_EM": "2026-01-01"}], + ) + resposta = client.get("/funcionarios") + assert resposta.status_code == 200 + assert "Fulano" in resposta.text + + +def test_criar_funcionario_com_sucesso(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False) + criado = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "criar_funcionario", + lambda nome, login, senha: criado.setdefault("dados", (nome, login, senha)) and 1, + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", + "csrf_token": CSRF_TESTE, + }, + ) + assert resposta.status_code == 303 + assert criado["dados"] == ("Fulano", "fulano", "senhaForte123") + + +def test_criar_funcionario_senhas_diferentes_bloqueia(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False) + chamou = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "criar_funcionario", + lambda nome, login, senha: chamou.setdefault("ok", True), + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "outraCoisa", + "csrf_token": CSRF_TESTE, + }, + ) + assert resposta.status_code == 200 + assert "não coincidem" in resposta.text + assert "ok" not in chamou + + +def test_criar_funcionario_login_duplicado_bloqueia(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: True) + chamou = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "criar_funcionario", + lambda nome, login, senha: chamou.setdefault("ok", True), + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", + "csrf_token": CSRF_TESTE, + }, + ) + assert resposta.status_code == 200 + assert "já está em uso" in resposta.text + assert "ok" not in chamou + + +def test_editar_funcionario_com_sucesso(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False) + capturado = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "atualizar_funcionario", + lambda fid, nome, login: capturado.setdefault("dados", (fid, nome, login)), + ) + + resposta = client.post( + "/funcionarios/1/editar", + data={"nome": "Fulano Editado", "login": "fulano.editado", "csrf_token": CSRF_TESTE}, + ) + assert resposta.status_code == 303 + assert capturado["dados"] == (1, "Fulano Editado", "fulano.editado") + + +def test_redefinir_senha_com_sucesso(monkeypatch): + capturado = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "redefinir_senha", + lambda fid, senha: capturado.setdefault("dados", (fid, senha)), + ) + + resposta = client.post( + "/funcionarios/1/redefinir-senha", + data={"nova_senha": "novaSenha123", "confirmar_nova_senha": "novaSenha123", "csrf_token": CSRF_TESTE}, + ) + assert resposta.status_code == 303 + assert capturado["dados"] == (1, "novaSenha123") + + +def test_redefinir_senha_nao_coincide_bloqueia(monkeypatch): + chamou = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, "redefinir_senha", lambda fid, senha: chamou.setdefault("ok", True) + ) + + resposta = client.post( + "/funcionarios/1/redefinir-senha", + data={"nova_senha": "a", "confirmar_nova_senha": "b", "csrf_token": CSRF_TESTE}, + follow_redirects=True, + ) + assert "não coincidem" in resposta.text + assert "ok" not in chamou + + +def test_alternar_ativo(monkeypatch): + capturado = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: capturado.setdefault("id", fid) + ) + + resposta = client.post("/funcionarios/1/alternar-ativo", data={"csrf_token": CSRF_TESTE}) + assert resposta.status_code == 303 + assert capturado["id"] == 1 diff --git a/tests/test_senha.py b/tests/test_senha.py new file mode 100644 index 0000000..b6ab990 --- /dev/null +++ b/tests/test_senha.py @@ -0,0 +1,26 @@ +from app.senha import gerar_hash, verificar_senha + + +def test_hash_confere_com_senha_correta(): + h = gerar_hash("minhaSenha123") + assert verificar_senha("minhaSenha123", h) is True + + +def test_hash_rejeita_senha_errada(): + h = gerar_hash("minhaSenha123") + assert verificar_senha("senhaErrada", h) is False + + +def test_hashes_de_senhas_iguais_sao_diferentes(): + # Salt aleatório por chamada — dois hashes da mesma senha não são iguais. + h1 = gerar_hash("mesmaSenha") + h2 = gerar_hash("mesmaSenha") + assert h1 != h2 + assert verificar_senha("mesmaSenha", h1) is True + assert verificar_senha("mesmaSenha", h2) is True + + +def test_hash_malformado_nao_lanca(): + assert verificar_senha("qualquer", "isso-nao-e-um-hash-valido") is False + assert verificar_senha("qualquer", "") is False + assert verificar_senha("qualquer", "md5$abc$def") is False