Signup: - /signup aliniat ca format la formularul din landing (campuri, etichete, placeholder-uri, select plan, checkbox GDPR, buton). Eticheta `name` = "Companie" (corecta: backendul salveaza nume de firma), uniform si in landing. - Consimtamant GDPR validat server-side (functional, nu doar client-side) + salvat cu marca temporala (accounts.consent_at). - Plan ales la signup salvat in accounts.requested_plan (intentie, NU drept): tier ramane sursa de adevar pentru gate-ul API; coloana pregateste integrarea platilor. - landing: valorile `plan` = coduri tier (free/standard/pro/premium), data-plan sincronizat pe butoanele de pret; checkbox consimtamant primeste name. Schema/DB: - accounts: coloane noi requested_plan + consent_at (cu migrare aditiva in db.py). Panou admin: - Coloane noi: Plan curent (plan EFECTIV acum + zile trial ramase) si Plan cerut. - Buton "Aplica" (POST /admin/set-tier): aloca plan real si INCHEIE trial-ul (efect imediat; altfel trial-ul Pro universal de 30z masca alegerea). - Control "Trial Pro N zile" (POST /admin/set-trial via accounts.set_trial): acorda/prelungeste trial fara a schimba tier-ul de baza. Teste: signup (consent obligatoriu, requested_plan persistat, tier ramane free), panou admin (set-tier incheie trial, free opreste Pro imediat, set-trial, validari + CSRF). Call-site-urile existente POST /signup actualizate cu consent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
420 lines
15 KiB
Python
420 lines
15 KiB
Python
"""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."""
|
|
from tests.conftest import make_test_cui
|
|
token = _get_csrf(client, "/signup")
|
|
resp = client.post("/signup", data={
|
|
"name": name,
|
|
"cui": make_test_cui(email),
|
|
"email": email,
|
|
"parola": password,
|
|
"consent": "1",
|
|
"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}"
|
|
)
|
|
|
|
|
|
def test_activare_cont_incomplet_refuzata(client):
|
|
"""Admin nu poate activa un cont incomplet (fara email/CUI) — contul ramane pending.
|
|
|
|
Gate pe account_is_complete: un cont fara companie+email+CUI nu poate fi activat
|
|
de admin (buton dezactivat in UI + server refuza activarea).
|
|
"""
|
|
# Cream cont pending INCOMPLET direct prin create_account (fara email/CUI)
|
|
from app.accounts import create_account
|
|
from app.users import create_user
|
|
from app.db import get_connection
|
|
|
|
conn = get_connection()
|
|
try:
|
|
incomplete_id = create_account(conn, "Firma Incompleta SRL", active=False)
|
|
create_user(conn, incomplete_id, "incompleta@test.ro", "parola_test_001")
|
|
finally:
|
|
conn.close()
|
|
|
|
# Admin
|
|
admin_id = _signup(client, "Admin Gate SA", "admin_gate@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "admin_gate@test.ro")
|
|
|
|
# Obtine CSRF din /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)
|
|
|
|
# Incearca sa activeze contul incomplet
|
|
resp2 = client.post("/admin/activate", data={
|
|
"account_id": str(incomplete_id),
|
|
"csrf_token": csrf,
|
|
})
|
|
# Fie 303 redirect, fie pagina cu eroare — important: contul NU e activat
|
|
assert resp2.status_code in (200, 303, 422), (
|
|
f"Raspuns neasteptat: {resp2.status_code}"
|
|
)
|
|
|
|
# Verifica in DB: contul ramane pending (neactivat)
|
|
assert not _get_account_active(incomplete_id), (
|
|
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
|
|
)
|
|
|
|
|
|
def _get_tier_trial(account_id: int) -> tuple[str, str | None]:
|
|
"""Citeste (tier, trial_until) din DB."""
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT tier, trial_until FROM accounts WHERE id=?", (account_id,)
|
|
).fetchone()
|
|
return (row["tier"], row["trial_until"]) if row else ("", None)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _get_tier(account_id: int) -> str:
|
|
"""Citeste accounts.tier din DB."""
|
|
return _get_tier_trial(account_id)[0]
|
|
|
|
|
|
def test_set_tier_din_admin_incheie_trial(client):
|
|
"""POST /admin/set-tier -> tier actualizat, trial_until=NULL (trial incheiat), 303.
|
|
|
|
Contul nou are trial Pro 30z; alocarea manuala trebuie sa-l incheie ca alegerea
|
|
sa aiba efect imediat (decizie user 2026-06-29)."""
|
|
target_id = _signup(client, "Firma Upgrade SRL", "upgrade@test.ro")
|
|
tier0, trial0 = _get_tier_trial(target_id)
|
|
assert tier0 == "free", "cont nou trebuie sa porneasca pe free"
|
|
assert trial0, "cont nou trebuie sa aiba trial_until setat (trial Pro 30z)"
|
|
|
|
admin_id = _signup(client, "Admin Tier SA", "admintier@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "admintier@test.ro")
|
|
|
|
csrf = _get_csrf(client, "/admin")
|
|
resp = client.post("/admin/set-tier", data={
|
|
"account_id": str(target_id),
|
|
"tier": "pro",
|
|
"csrf_token": csrf,
|
|
})
|
|
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
|
tier1, trial1 = _get_tier_trial(target_id)
|
|
assert tier1 == "pro", "tier-ul nu a fost mutat pe pro"
|
|
assert trial1 is None, "trial_until trebuie sters la alocarea manuala (efect imediat)"
|
|
|
|
|
|
def test_set_tier_free_opreste_pro_imediat(client):
|
|
"""Setarea pe 'free' pe un cont in trial -> efectiv 'free' acum (trial incheiat).
|
|
|
|
Fara stergerea trial-ului, effective_tier ar fi ramas 'pro' inca ~30 zile."""
|
|
from datetime import datetime, timezone
|
|
from app.plans import effective_tier
|
|
|
|
target_id = _signup(client, "Firma Abuz Trial SRL", "abuztrial@test.ro")
|
|
admin_id = _signup(client, "Admin Stop SA", "adminstop@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "adminstop@test.ro")
|
|
|
|
csrf = _get_csrf(client, "/admin")
|
|
resp = client.post("/admin/set-tier", data={
|
|
"account_id": str(target_id),
|
|
"tier": "free",
|
|
"csrf_token": csrf,
|
|
})
|
|
assert resp.status_code == 303
|
|
|
|
tier1, trial1 = _get_tier_trial(target_id)
|
|
assert tier1 == "free" and trial1 is None
|
|
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
|
assert eff == "free", "dupa setarea pe free, planul efectiv trebuie sa fie free imediat"
|
|
|
|
|
|
def test_set_tier_invalid_respins(client):
|
|
"""Tier invalid -> nu schimba nimic (re-randare cu eroare sau ignorat)."""
|
|
target_id = _signup(client, "Firma Tier Invalid SRL", "tierinvalid@test.ro")
|
|
admin_id = _signup(client, "Admin TI SA", "adminti@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "adminti@test.ro")
|
|
|
|
csrf = _get_csrf(client, "/admin")
|
|
resp = client.post("/admin/set-tier", data={
|
|
"account_id": str(target_id),
|
|
"tier": "platinum", # invalid
|
|
"csrf_token": csrf,
|
|
})
|
|
assert resp.status_code in (200, 422), f"tier invalid ar trebui respins, primit {resp.status_code}"
|
|
assert _get_tier(target_id) == "free", "tier invalid nu trebuie aplicat"
|
|
|
|
|
|
def test_set_tier_fara_csrf_respins(client):
|
|
"""POST /admin/set-tier fara token CSRF valid -> respins, tier neschimbat."""
|
|
target_id = _signup(client, "Firma CSRF Tier SRL", "csrftier@test.ro")
|
|
admin_id = _signup(client, "Admin CSRF SA", "admincsrf@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "admincsrf@test.ro")
|
|
|
|
resp = client.post("/admin/set-tier", data={
|
|
"account_id": str(target_id),
|
|
"tier": "pro",
|
|
"csrf_token": "token-fals",
|
|
})
|
|
assert resp.status_code in (400, 403), f"CSRF invalid trebuie respins, primit {resp.status_code}"
|
|
assert _get_tier(target_id) == "free", "tier schimbat desi CSRF era invalid"
|
|
|
|
|
|
def test_set_trial_din_admin(client):
|
|
"""POST /admin/set-trial -> trial_until setat, tier de baza neschimbat, efectiv pro, 303."""
|
|
from datetime import datetime, timezone
|
|
from app.plans import effective_tier
|
|
|
|
target_id = _signup(client, "Firma Trial SRL", "trialnou@test.ro")
|
|
# incheie intai orice trial (set-tier free) ca sa pornim de la baza curata
|
|
admin_id = _signup(client, "Admin Trial SA", "admintrialacord@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "admintrialacord@test.ro")
|
|
csrf = _get_csrf(client, "/admin")
|
|
client.post("/admin/set-tier", data={
|
|
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
|
})
|
|
assert _get_tier_trial(target_id) == ("free", None)
|
|
|
|
# acorda trial Pro 15 zile
|
|
csrf = _get_csrf(client, "/admin")
|
|
resp = client.post("/admin/set-trial", data={
|
|
"account_id": str(target_id),
|
|
"trial_days": "15",
|
|
"csrf_token": csrf,
|
|
})
|
|
assert resp.status_code == 303, f"asteptat 303 PRG, primit {resp.status_code}"
|
|
tier1, trial1 = _get_tier_trial(target_id)
|
|
assert tier1 == "free", "tier-ul de baza NU trebuie schimbat de acordarea de trial"
|
|
assert trial1, "trial_until trebuie setat"
|
|
eff = effective_tier({"tier": tier1, "trial_until": trial1}, datetime.now(timezone.utc))
|
|
assert eff == "pro", "trial activ trebuie sa ridice planul efectiv la pro"
|
|
|
|
|
|
def test_set_trial_zile_invalide_respins(client):
|
|
"""trial_days <= 0 -> 422, trial neschimbat."""
|
|
target_id = _signup(client, "Firma Trial Invalid SRL", "trialinvalid@test.ro")
|
|
admin_id = _signup(client, "Admin TInv SA", "admintinv@test.ro")
|
|
_make_admin(admin_id)
|
|
_login(client, "admintinv@test.ro")
|
|
# porneste de la trial sters
|
|
csrf = _get_csrf(client, "/admin")
|
|
client.post("/admin/set-tier", data={
|
|
"account_id": str(target_id), "tier": "free", "csrf_token": csrf,
|
|
})
|
|
csrf = _get_csrf(client, "/admin")
|
|
resp = client.post("/admin/set-trial", data={
|
|
"account_id": str(target_id),
|
|
"trial_days": "0",
|
|
"csrf_token": csrf,
|
|
})
|
|
assert resp.status_code == 422
|
|
assert _get_tier_trial(target_id) == ("free", None), "trial nu trebuie setat la zile invalide"
|