diff --git a/app/routers/funcionarios.py b/app/routers/funcionarios.py index de31e3c..bbc35b5 100644 --- a/app/routers/funcionarios.py +++ b/app/routers/funcionarios.py @@ -1,6 +1,8 @@ """ -Rotas de gestão de FUNCIONARIO_PAINEL: listar, criar (com senha), editar -nome/login, redefinir senha, ativar/desativar (nunca exclui de verdade). +Rotas de gestão de FUNCIONARIO_PAINEL: listar, criar (com senha e perfil), +editar nome/login/perfil, redefinir senha, ativar/desativar (nunca exclui +de verdade). Router inteiro protegido por exigir_admin — só administradores +acessam qualquer rota daqui (ver app/auth.py). """ from typing import Optional @@ -8,12 +10,14 @@ from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from ..auth import exigir_login +from ..auth import UsuarioLogado, exigir_admin 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") +PERFIS_VALIDOS = {"ADMIN", "PADRAO"} + +router = APIRouter(prefix="/funcionarios", dependencies=[Depends(exigir_admin)]) templates = Jinja2Templates(directory="app/templates") @@ -28,7 +32,7 @@ def listar( request: Request, mensagem: Optional[str] = None, mensagem_tipo: Optional[str] = None, - usuario: str = Depends(exigir_login), + usuario: UsuarioLogado = Depends(exigir_admin), ): funcionarios = db_funcionarios.listar_funcionarios() return templates.TemplateResponse( @@ -42,7 +46,7 @@ def listar( @router.get("/novo", response_class=HTMLResponse) -def form_novo(request: Request, usuario: str = Depends(exigir_login)): +def form_novo(request: Request, usuario: UsuarioLogado = Depends(exigir_admin)): return templates.TemplateResponse( request, "funcionario_form.html", {"funcionario": None, "csrf_token": token_do_contexto(request)} ) @@ -55,7 +59,8 @@ def criar( login: str = Form(...), senha: str = Form(...), confirmar_senha: str = Form(...), - usuario: str = Depends(exigir_login), + perfil: str = Form(...), + usuario: UsuarioLogado = Depends(exigir_admin), ): nome = nome.strip() login = login.strip() @@ -65,6 +70,8 @@ def criar( erro = "Preencha nome, login e senha." elif senha != confirmar_senha: erro = "As senhas digitadas não coincidem." + elif perfil not in PERFIS_VALIDOS: + erro = "Perfil inválido." elif db_funcionarios.login_em_uso(login): erro = f"O login \"{login}\" já está em uso." @@ -73,18 +80,18 @@ def criar( request, "funcionario_form.html", { - "funcionario": None, "erro": erro, "dados": {"NOME": nome, "LOGIN": login}, + "funcionario": None, "erro": erro, "dados": {"NOME": nome, "LOGIN": login, "PERFIL": perfil}, "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) + novo_id = db_funcionarios.criar_funcionario(nome, login, senha, perfil) + db_log.registrar(str(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)): +def form_editar(request: Request, funcionario_id: int, usuario: UsuarioLogado = Depends(exigir_admin)): funcionario = db_funcionarios.obter_funcionario(funcionario_id) if funcionario is None: return _redirect_com_mensagem("Funcionário não encontrado.", "danger") @@ -99,7 +106,8 @@ def editar( funcionario_id: int, nome: str = Form(...), login: str = Form(...), - usuario: str = Depends(exigir_login), + perfil: str = Form(...), + usuario: UsuarioLogado = Depends(exigir_admin), ): nome = nome.strip() login = login.strip() @@ -107,21 +115,35 @@ def editar( erro = None if not nome or not login: erro = "Preencha nome e login." + elif perfil not in PERFIS_VALIDOS: + erro = "Perfil inválido." elif db_funcionarios.login_em_uso(login, ignorar_id=funcionario_id): erro = f"O login \"{login}\" já está em uso." + else: + atual = db_funcionarios.obter_funcionario(funcionario_id) + rebaixando_ultimo_admin = ( + atual is not None + and atual["PERFIL"] == "ADMIN" + and atual["ATIVO"] == 1 + and perfil == "PADRAO" + and db_funcionarios.contar_admins_ativos() <= 1 + ) + if rebaixando_ultimo_admin: + erro = "Não é possível — este é o único administrador ativo." if erro: return templates.TemplateResponse( request, "funcionario_form.html", { - "funcionario": {"ID": funcionario_id}, "erro": erro, "dados": {"NOME": nome, "LOGIN": login}, + "funcionario": {"ID": funcionario_id}, "erro": erro, + "dados": {"NOME": nome, "LOGIN": login, "PERFIL": perfil}, "csrf_token": token_do_contexto(request), }, ) - db_funcionarios.atualizar_funcionario(funcionario_id, nome, login) - db_log.registrar(usuario, "editar", "funcionario", str(funcionario_id), True) + db_funcionarios.atualizar_funcionario(funcionario_id, nome, login, perfil) + db_log.registrar(str(usuario), "editar", "funcionario", str(funcionario_id), True) return _redirect_com_mensagem("Funcionário atualizado.") @@ -130,19 +152,30 @@ def redefinir_senha( funcionario_id: int, nova_senha: str = Form(...), confirmar_nova_senha: str = Form(...), - usuario: str = Depends(exigir_login), + usuario: UsuarioLogado = Depends(exigir_admin), ): 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") + db_log.registrar(str(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) + db_log.registrar(str(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)): +def alternar_ativo(funcionario_id: int, usuario: UsuarioLogado = Depends(exigir_admin)): + atual = db_funcionarios.obter_funcionario(funcionario_id) + if atual is not None and atual["ATIVO"] == 1: + # Só desativações (ATIVO 1 -> 0) de um admin passam pela checagem — + # reativar (0 -> 1) nunca reduz a contagem de admins ativos. + vai_desativar_ultimo_admin = atual["PERFIL"] == "ADMIN" and db_funcionarios.contar_admins_ativos() <= 1 + if vai_desativar_ultimo_admin: + db_log.registrar( + str(usuario), "alternar_ativo", "funcionario", str(funcionario_id), False, "único administrador ativo" + ) + return _redirect_com_mensagem("Não é possível desativar — este é o único administrador ativo.", "danger") + db_funcionarios.alternar_ativo(funcionario_id) - db_log.registrar(usuario, "alternar_ativo", "funcionario", str(funcionario_id), True) + db_log.registrar(str(usuario), "alternar_ativo", "funcionario", str(funcionario_id), True) return _redirect_com_mensagem("Status do funcionário atualizado.") diff --git a/app/templates/funcionario_form.html b/app/templates/funcionario_form.html index d7b246d..df56c27 100644 --- a/app/templates/funcionario_form.html +++ b/app/templates/funcionario_form.html @@ -12,6 +12,12 @@ + + {% set perfil_atual = dados.PERFIL if dados else (funcionario.PERFIL if funcionario else 'PADRAO') %} + {% if not funcionario %} diff --git a/app/templates/funcionarios_lista.html b/app/templates/funcionarios_lista.html index c98036d..adc98ec 100644 --- a/app/templates/funcionarios_lista.html +++ b/app/templates/funcionarios_lista.html @@ -5,11 +5,12 @@
| Nome | Login | Status | ||
|---|---|---|---|---|
| Nome | Login | Perfil | Status | |
| {{ f.NOME }} | {{ f.LOGIN }} | +{{ "Administrador" if f.PERFIL == "ADMIN" else "Padrão" }} | {{ "Ativo" if f.ATIVO else "Inativo" }} | Editar diff --git a/tests/test_router_funcionarios.py b/tests/test_router_funcionarios.py index 99183ca..c59d12d 100644 --- a/tests/test_router_funcionarios.py +++ b/tests/test_router_funcionarios.py @@ -2,12 +2,12 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from app.auth import exigir_login +from app.auth import UsuarioLogado, 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" +app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="admin", nome="Admin", perfil="ADMIN") client = TestClient(app_teste, follow_redirects=False) CSRF_TESTE = "teste-csrf-token" client.cookies.set("csrf_token", CSRF_TESTE) @@ -18,11 +18,26 @@ def _sem_log_real(monkeypatch): monkeypatch.setattr(router_funcionarios.db_log, "registrar", lambda *args, **kwargs: None) +@pytest.fixture(autouse=True) +def _admin_como_padrao(monkeypatch): + """Todo teste roda como admin por padrão (override acima). Testes que + querem simular usuário padrão sobrescrevem de novo dentro do próprio + teste e restauram no fim via yield.""" + yield + app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="admin", nome="Admin", perfil="ADMIN") + + +def test_usuario_padrao_toma_403_em_qualquer_rota(): + app_teste.dependency_overrides[exigir_login] = lambda: UsuarioLogado(login="fulano", nome="Fulano", perfil="PADRAO") + resposta = client.get("/funcionarios") + assert resposta.status_code == 403 + + 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"}], + lambda: [{"ID": 1, "NOME": "Fulano", "LOGIN": "fulano", "PERFIL": "PADRAO", "ATIVO": 1, "CRIADO_EM": "2026-01-01"}], ) resposta = client.get("/funcionarios") assert resposta.status_code == 200 @@ -35,18 +50,18 @@ def test_criar_funcionario_com_sucesso(monkeypatch): monkeypatch.setattr( router_funcionarios.db_funcionarios, "criar_funcionario", - lambda nome, login, senha: criado.setdefault("dados", (nome, login, senha)) and 1, + lambda nome, login, senha, perfil: criado.setdefault("dados", (nome, login, senha, perfil)) and 1, ) resposta = client.post( "/funcionarios/novo", data={ "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", - "csrf_token": CSRF_TESTE, + "perfil": "PADRAO", "csrf_token": CSRF_TESTE, }, ) assert resposta.status_code == 303 - assert criado["dados"] == ("Fulano", "fulano", "senhaForte123") + assert criado["dados"] == ("Fulano", "fulano", "senhaForte123", "PADRAO") def test_criar_funcionario_senhas_diferentes_bloqueia(monkeypatch): @@ -55,14 +70,14 @@ def test_criar_funcionario_senhas_diferentes_bloqueia(monkeypatch): monkeypatch.setattr( router_funcionarios.db_funcionarios, "criar_funcionario", - lambda nome, login, senha: chamou.setdefault("ok", True), + lambda nome, login, senha, perfil: chamou.setdefault("ok", True), ) resposta = client.post( "/funcionarios/novo", data={ "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "outraCoisa", - "csrf_token": CSRF_TESTE, + "perfil": "PADRAO", "csrf_token": CSRF_TESTE, }, ) assert resposta.status_code == 200 @@ -76,14 +91,14 @@ def test_criar_funcionario_login_duplicado_bloqueia(monkeypatch): monkeypatch.setattr( router_funcionarios.db_funcionarios, "criar_funcionario", - lambda nome, login, senha: chamou.setdefault("ok", True), + lambda nome, login, senha, perfil: chamou.setdefault("ok", True), ) resposta = client.post( "/funcionarios/novo", data={ "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", - "csrf_token": CSRF_TESTE, + "perfil": "PADRAO", "csrf_token": CSRF_TESTE, }, ) assert resposta.status_code == 200 @@ -91,21 +106,93 @@ def test_criar_funcionario_login_duplicado_bloqueia(monkeypatch): assert "ok" not in chamou +def test_criar_funcionario_perfil_invalido_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, perfil: chamou.setdefault("ok", True), + ) + + resposta = client.post( + "/funcionarios/novo", + data={ + "nome": "Fulano", "login": "fulano", "senha": "senhaForte123", "confirmar_senha": "senhaForte123", + "perfil": "SUPERUSUARIO", "csrf_token": CSRF_TESTE, + }, + ) + assert resposta.status_code == 200 + 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) + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "obter_funcionario", + lambda fid: {"ID": fid, "NOME": "Fulano", "LOGIN": "fulano", "PERFIL": "PADRAO", "ATIVO": 1}, + ) capturado = {} monkeypatch.setattr( router_funcionarios.db_funcionarios, "atualizar_funcionario", - lambda fid, nome, login: capturado.setdefault("dados", (fid, nome, login)), + lambda fid, nome, login, perfil: capturado.setdefault("dados", (fid, nome, login, perfil)), ) resposta = client.post( "/funcionarios/1/editar", - data={"nome": "Fulano Editado", "login": "fulano.editado", "csrf_token": CSRF_TESTE}, + data={"nome": "Fulano Editado", "login": "fulano.editado", "perfil": "PADRAO", "csrf_token": CSRF_TESTE}, ) assert resposta.status_code == 303 - assert capturado["dados"] == (1, "Fulano Editado", "fulano.editado") + assert capturado["dados"] == (1, "Fulano Editado", "fulano.editado", "PADRAO") + + +def test_editar_ultimo_admin_para_padrao_bloqueia(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False) + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "obter_funcionario", + lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 1}, + ) + monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", lambda: 1) + chamou = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "atualizar_funcionario", + lambda fid, nome, login, perfil: chamou.setdefault("ok", True), + ) + + resposta = client.post( + "/funcionarios/1/editar", + data={"nome": "Admin", "login": "admin.db", "perfil": "PADRAO", "csrf_token": CSRF_TESTE}, + ) + assert resposta.status_code == 200 + assert "único administrador" in resposta.text + assert "ok" not in chamou + + +def test_editar_admin_para_padrao_com_outros_admins_permite(monkeypatch): + monkeypatch.setattr(router_funcionarios.db_funcionarios, "login_em_uso", lambda login, ignorar_id=None: False) + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "obter_funcionario", + lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 1}, + ) + monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", lambda: 2) + capturado = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "atualizar_funcionario", + lambda fid, nome, login, perfil: capturado.setdefault("dados", (fid, nome, login, perfil)), + ) + + resposta = client.post( + "/funcionarios/1/editar", + data={"nome": "Admin", "login": "admin.db", "perfil": "PADRAO", "csrf_token": CSRF_TESTE}, + ) + assert resposta.status_code == 303 + assert capturado["dados"] == (1, "Admin", "admin.db", "PADRAO") def test_redefinir_senha_com_sucesso(monkeypatch): @@ -140,6 +227,53 @@ def test_redefinir_senha_nao_coincide_bloqueia(monkeypatch): def test_alternar_ativo(monkeypatch): + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "obter_funcionario", + lambda fid: {"ID": fid, "NOME": "Fulano", "LOGIN": "fulano", "PERFIL": "PADRAO", "ATIVO": 1}, + ) + 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 + + +def test_desativar_ultimo_admin_ativo_bloqueia(monkeypatch): + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "obter_funcionario", + lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 1}, + ) + monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", lambda: 1) + chamou = {} + monkeypatch.setattr( + router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: chamou.setdefault("ok", True) + ) + + resposta = client.post( + "/funcionarios/1/alternar-ativo", data={"csrf_token": CSRF_TESTE}, follow_redirects=True + ) + assert "único administrador" in resposta.text + assert "ok" not in chamou + + +def test_reativar_funcionario_nao_passa_pela_checagem_de_ultimo_admin(monkeypatch): + # ATIVO indo de 0 para 1 (reativação) nunca reduz a contagem de admins + # ativos — não precisa nem chamar contar_admins_ativos(). + monkeypatch.setattr( + router_funcionarios.db_funcionarios, + "obter_funcionario", + lambda fid: {"ID": fid, "NOME": "Admin", "LOGIN": "admin.db", "PERFIL": "ADMIN", "ATIVO": 0}, + ) + + def _falha_se_chamado(): + raise AssertionError("contar_admins_ativos não deveria ser chamado ao reativar") + + monkeypatch.setattr(router_funcionarios.db_funcionarios, "contar_admins_ativos", _falha_se_chamado) capturado = {} monkeypatch.setattr( router_funcionarios.db_funcionarios, "alternar_ativo", lambda fid: capturado.setdefault("id", fid) |