feat(web): self-service cheie/creds + admin web + email signup (PRD 3.3b)

US-007: rute web proprii /cont/roteste-cheie + /cont/rar-creds scoped pe
sesiune (C13), sectiune "Contul meu" cu cheie afisata o data.
US-010: rol admin (users.is_admin) + require_admin->403 + CLI set-admin +
bootstrap primul cont=admin (count_admins in BEGIN IMMEDIATE, anti-race).
US-011: panou /admin (activare/dezactivare conturi, CSRF + PRG), link admin
+ logout pe dashboard.
US-012: app/email.py notify_signup best-effort degradat fara SMTP + config smtp_*.
Fix: migrare defensiva users.is_admin/email_verified in _migrate.

VERIFY x2 context curat (PASS) + /code-review high. 393 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-18 17:19:06 +00:00
parent 504b490d3b
commit b92055eb01
21 changed files with 1766 additions and 10 deletions

277
tests/test_admin_role.py Normal file
View File

@@ -0,0 +1,277 @@
"""Teste US-010 (PRD 3.3b): rol admin + bootstrap + guard require_admin.
Fisiere testate: app/users.py (helper-e admin), app/web/session.py (require_admin),
tools/account.py (subcomanda set-admin).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture DB
# ---------------------------------------------------------------------------
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_role.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
@pytest.fixture()
def account_id(conn):
"""Cont de test (nu default id=1)."""
from app.accounts import create_account
return create_account(conn, "Service Test Admin")
@pytest.fixture()
def user_id(conn, account_id):
"""User de test pe contul de test."""
from app.users import create_user
return create_user(conn, account_id, "admin_test@exemplu.ro", "parola_sigura_123")
@pytest.fixture()
def env_cli(monkeypatch):
"""Fixture pentru CLI: DB separata + clear settings cache."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_cli.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Helper app minimal pentru require_admin
# ---------------------------------------------------------------------------
def _make_app_admin() -> FastAPI:
"""App FastAPI minimal cu ruta protejata de require_admin."""
mini = FastAPI()
mini.add_middleware(
SessionMiddleware,
secret_key="test-secret-admin",
session_cookie="autopass_session",
https_only=False,
same_site="strict",
)
from app.web.session import LoginRequired, AdminRequired
@mini.exception_handler(LoginRequired)
async def login_required_handler(request: Request, exc: LoginRequired):
return JSONResponse(status_code=401, content={"detail": "neautentificat"})
@mini.exception_handler(AdminRequired)
async def admin_required_handler(request: Request, exc: AdminRequired):
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
@mini.get("/set-session")
def set_sess(request: Request, account_id: int = 1, user_id: int = 1):
from app.web.session import set_session
set_session(request, account_id, user_id)
return {"ok": True}
@mini.get("/admin-only")
def admin_only(request: Request):
from app.web.session import require_admin
aid = require_admin(request)
return {"account_id": aid}
return mini
# ---------------------------------------------------------------------------
# Teste helper-e app/users.py
# ---------------------------------------------------------------------------
def test_count_admins_initial_zero(conn):
"""Fara niciun user cu is_admin=1, count_admins returneaza 0."""
from app.users import count_admins
assert count_admins(conn) == 0
def test_set_admin_marcheaza_userii_contului(conn, account_id, user_id):
"""set_admin(conn, account_id) seteaza is_admin=1 pe toti userii contului."""
from app.users import set_admin, count_admins
assert count_admins(conn) == 0
set_admin(conn, account_id, is_admin=True)
assert count_admins(conn) == 1
def test_create_user_is_admin_flag(conn, account_id):
"""create_user cu is_admin=True seteaza coloana la 1."""
from app.users import create_user, count_admins
create_user(conn, account_id, "newadmin@exemplu.ro", "parola_sigura_456", is_admin=True)
assert count_admins(conn) == 1
def test_is_account_admin(conn, account_id, user_id):
"""is_account_admin returneaza False inainte si True dupa set_admin."""
from app.users import is_account_admin, set_admin
assert is_account_admin(conn, account_id) is False
set_admin(conn, account_id, is_admin=True)
assert is_account_admin(conn, account_id) is True
def test_list_admin_emails(conn, account_id):
"""list_admin_emails returneaza emailurile userilor cu is_admin=1."""
from app.users import create_user, set_admin, list_admin_emails
uid = create_user(conn, account_id, "admin1@exemplu.ro", "parola_sigura_789")
# Inainte de set_admin -> lista goala
assert list_admin_emails(conn) == []
set_admin(conn, account_id, is_admin=True)
emails = list_admin_emails(conn)
assert "admin1@exemplu.ro" in emails
def test_set_admin_cont_inexistent_valueerror(conn):
"""set_admin pe cont care nu exista ridica ValueError."""
from app.users import set_admin
with pytest.raises(ValueError, match="cont inexistent"):
set_admin(conn, 9999, is_admin=True)
def test_set_admin_cont_fara_users_silentios(conn):
"""set_admin pe cont existent fara useri e no-op silentios (nu ridica exceptie)."""
from app.accounts import create_account
from app.users import set_admin, count_admins
acct_fara_user = create_account(conn, "Cont Fara User")
# Nu trebuie sa ridice
set_admin(conn, acct_fara_user, is_admin=True)
assert count_admins(conn) == 0 # niciun user modificat
# ---------------------------------------------------------------------------
# Teste require_admin (app/web/session.py) prin TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def client_admin(monkeypatch):
"""TestClient pe app cu require_admin; DB cu cont admin + cont non-admin."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_require_admin.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
from app.accounts import create_account
from app.users import create_user, set_admin
# Cont admin (id=2)
acct_admin = create_account(conn, "Admin Corp")
uid_admin = create_user(conn, acct_admin, "admin@corp.ro", "parola_admin_001")
set_admin(conn, acct_admin, is_admin=True)
# Cont non-admin (id=3)
acct_user = create_account(conn, "User Corp")
uid_user = create_user(conn, acct_user, "user@corp.ro", "parola_user_001")
conn.close()
app = _make_app_admin()
with TestClient(app, follow_redirects=False) as c:
yield c, acct_admin, uid_admin, acct_user, uid_user
get_settings.cache_clear()
def test_require_admin_blocheaza_non_admin(client_admin):
"""User logat NON-admin pe ruta admin -> 403."""
client, acct_admin, uid_admin, acct_user, uid_user = client_admin
client.get(f"/set-session?account_id={acct_user}&user_id={uid_user}")
resp = client.get("/admin-only")
assert resp.status_code == 403
assert "admin" in resp.json()["detail"]
def test_require_admin_lasa_admin(client_admin):
"""User logat ADMIN pe ruta admin -> 200 cu account_id."""
client, acct_admin, uid_admin, acct_user, uid_user = client_admin
client.get(f"/set-session?account_id={acct_admin}&user_id={uid_admin}")
resp = client.get("/admin-only")
assert resp.status_code == 200
assert resp.json()["account_id"] == acct_admin
def test_require_admin_nelogat_ridica_login_required(client_admin):
"""Fara sesiune, require_admin ridica LoginRequired (-> 401 in app-ul nostru de test)."""
client, *_ = client_admin
resp = client.get("/admin-only")
# In app-ul de test, LoginRequired -> 401
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Teste CLI tools/account.py set-admin
# ---------------------------------------------------------------------------
def _run_account(argv):
from tools.account import main
return main(argv)
def test_cli_set_admin_marcheaza_contul(env_cli, capsys):
"""CLI set-admin --account N seteaza is_admin=1 pe userii contului."""
# Creeaza cont cu user
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user, is_account_admin
conn = get_connection()
acct_id = create_account(conn, "Service CLI Admin")
create_user(conn, acct_id, "cli_admin@corp.ro", "parola_cli_admin_1")
conn.close()
rc = _run_account(["set-admin", "--account", str(acct_id)])
out = capsys.readouterr().out
assert rc == 0
assert str(acct_id) in out or "admin" in out.lower()
conn2 = get_connection()
assert is_account_admin(conn2, acct_id) is True
conn2.close()
def test_cli_set_admin_remove(env_cli, capsys):
"""CLI set-admin --account N --remove scoate adminul."""
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user, set_admin, is_account_admin
conn = get_connection()
acct_id = create_account(conn, "Service CLI Remove")
create_user(conn, acct_id, "remove@corp.ro", "parola_remove_001")
set_admin(conn, acct_id, is_admin=True)
conn.close()
rc = _run_account(["set-admin", "--account", str(acct_id), "--remove"])
assert rc == 0
conn2 = get_connection()
assert is_account_admin(conn2, acct_id) is False
conn2.close()
def test_cli_set_admin_cont_inexistent_exit_2(env_cli, capsys):
"""CLI set-admin pe cont inexistent -> exit code 2 + mesaj pe stderr."""
rc = _run_account(["set-admin", "--account", "9999"])
err = capsys.readouterr().err
assert rc == 2
assert "inexistent" in err or "eroare" in err.lower()