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:
213
tests/test_admin_panel.py
Normal file
213
tests/test_admin_panel.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Teste US-011 (PRD 3.3b): panou admin web /admin — conturi in asteptare + activare.
|
||||
|
||||
TDD strict: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Fisiere testate: app/web/admin_routes.py, app/web/templates/admin.html.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture client (web_auth_required=true -> CSRF enforce)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""TestClient pe aplicatia completa, cu DB izolata si web_auth_required=true."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_panel.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
# Ridica limita rate-limit pentru signup ca testele nu se blocheze intre ele
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
# Curata hits-urile rate-limit intre teste
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper-e
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_csrf(client: TestClient, url: str) -> str:
|
||||
"""Extrage csrf_token din pagina HTML."""
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, f"csrf_token negasit in {url}: {resp.text[:500]}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _signup(client: TestClient, name: str, email: str, password: str = "parola_test_001") -> int:
|
||||
"""Creeaza cont via POST /signup si intoarce account_id."""
|
||||
token = _get_csrf(client, "/signup")
|
||||
resp = client.post("/signup", data={
|
||||
"name": name,
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
||||
# Extrage account_id din raspuns (pagina afiseaza cheia rfak_ + account_id)
|
||||
m = re.search(r"cont=(\d+)", resp.text)
|
||||
if not m:
|
||||
# fallback: citeste din DB
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT account_id FROM users WHERE email=? COLLATE NOCASE", (email,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row, f"userul {email} nu a fost creat"
|
||||
return int(row["account_id"])
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def _login(client: TestClient, email: str, password: str = "parola_test_001") -> None:
|
||||
"""Autentifica userul (seteaza sesiunea cookie)."""
|
||||
token = _get_csrf(client, "/login")
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 303, f"login esuat cu {email}: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _make_admin(account_id: int) -> None:
|
||||
"""Marcheaza contul ca admin direct in DB."""
|
||||
from app.db import get_connection
|
||||
from app.users import set_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_account_active(account_id: int) -> bool:
|
||||
"""Citeste accounts.active din DB."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return bool(row["active"]) if row else False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cazuri de test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_vede_conturi_pending(client):
|
||||
"""Admin logat -> GET /admin contine numele contului pending (active=0)."""
|
||||
# Creeaza un cont pending (active=0 implicit la signup)
|
||||
_signup(client, "Service Pending SRL", "pending@test.ro")
|
||||
|
||||
# Creeaza contul admin
|
||||
admin_id = _signup(client, "Admin Corp SA", "admin@test.ro")
|
||||
_make_admin(admin_id)
|
||||
|
||||
# Login ca admin
|
||||
_login(client, "admin@test.ro")
|
||||
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200, f"GET /admin a returnat {resp.status_code}"
|
||||
assert "Service Pending SRL" in resp.text, (
|
||||
f"Contul pending nu apare in /admin. Raspuns: {resp.text[:600]}"
|
||||
)
|
||||
|
||||
|
||||
def test_activare_din_admin(client):
|
||||
"""POST /admin/activate cu CSRF -> accounts.active=1; redirect 303."""
|
||||
# Cont pending
|
||||
pending_id = _signup(client, "Firma De Activat SRL", "firma@test.ro")
|
||||
assert not _get_account_active(pending_id), "contul trebuie sa fie inactiv initial"
|
||||
|
||||
# Cont admin
|
||||
admin_id = _signup(client, "Admin Activator SA", "activator@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "activator@test.ro")
|
||||
|
||||
# Obtine CSRF din pagina /admin
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit in /admin"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp2 = client.post("/admin/activate", data={
|
||||
"account_id": str(pending_id),
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp2.status_code == 303, (
|
||||
f"POST /admin/activate trebuia redirect 303, got {resp2.status_code}: {resp2.text[:300]}"
|
||||
)
|
||||
|
||||
assert _get_account_active(pending_id), "contul trebuia sa fie activat dupa POST /admin/activate"
|
||||
|
||||
|
||||
def test_non_admin_403(client):
|
||||
"""User logat NON-admin -> GET /admin -> 403.
|
||||
|
||||
Creeaza intai un cont admin (bootstrap: primul user devine admin),
|
||||
apoi un al doilea cont (non-admin) si verifica ca al doilea primeste 403.
|
||||
"""
|
||||
# Primul signup devine automat admin (bootstrap US-010)
|
||||
_signup(client, "Admin Bootstrap SA", "bootstrap@test.ro")
|
||||
|
||||
# Al doilea user NU e admin
|
||||
_signup(client, "User Simplu SRL", "user@test.ro")
|
||||
_login(client, "user@test.ro")
|
||||
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 403, (
|
||||
f"User non-admin trebuia 403 pe /admin, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
def test_admin_nelogat_redirect(client):
|
||||
"""Fara sesiune -> GET /admin -> 303 redirect la /login."""
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 303, (
|
||||
f"Nelogat pe /admin trebuia 303, got {resp.status_code}"
|
||||
)
|
||||
loc = resp.headers.get("location", "")
|
||||
assert "/login" in loc, f"Redirect gresit: {loc}"
|
||||
|
||||
|
||||
def test_activate_fara_csrf_403(client):
|
||||
"""Admin logat, POST /admin/activate fara token CSRF -> 403."""
|
||||
pending_id = _signup(client, "Firma Fara CSRF SRL", "nocsrf@test.ro")
|
||||
|
||||
admin_id = _signup(client, "Admin CSRF Test SA", "csrfadmin@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "csrfadmin@test.ro")
|
||||
|
||||
# POST fara token (sau token gol)
|
||||
resp = client.post("/admin/activate", data={
|
||||
"account_id": str(pending_id),
|
||||
"csrf_token": "",
|
||||
})
|
||||
assert resp.status_code == 403, (
|
||||
f"POST fara CSRF trebuia 403, got {resp.status_code}"
|
||||
)
|
||||
Reference in New Issue
Block a user