diff --git a/app/accounts.py b/app/accounts.py index e52a9cc..204cba3 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -44,6 +44,8 @@ def create_account( cui: str | None = None, email: str | None = None, active: bool = True, + requested_plan: str | None = None, + consent_at: str | None = None, ) -> int: """Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1). @@ -51,12 +53,19 @@ def create_account( `email` se normalizeaza (trim+lower); sir gol -> ValueError. Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial `ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta. + + `requested_plan`: planul CERUT la signup (separat de `tier`). NU acorda drepturi — `tier` + ramane mereu 'free' la creare; planul cerut e doar o intentie pentru integrarea platilor. + Valoare invalida (nu e in VALID_TIERS) -> ignorata (stocata NULL), nu arunca. + `consent_at`: marca temporala consimtamant Termeni+GDPR (proba); None = fara flux consimtamant. """ name = (name or "").strip() if not name: raise ValueError("name gol (un cont are nevoie de nume)") cui = _norm_cui(cui) email = _norm_email(email) + # Planul cerut: pastram doar valori valide; orice altceva -> NULL (defensiv). + req_plan = requested_plan if requested_plan in VALID_TIERS else None try: # Trial Pro automat la creare (PRD 5.17 US-001): tier='free' + trial_until=now+30z. trial_until = ( @@ -64,10 +73,11 @@ def create_account( ) # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'. cur = conn.execute( - "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO accounts (name, cui, email, active, status, tier, trial_until, " + "requested_plan, consent_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (name, cui, email, 1 if active else 0, "active" if active else "pending", - "free", trial_until), + "free", trial_until, req_plan, consent_at), ) except sqlite3.IntegrityError: existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() @@ -185,6 +195,38 @@ def set_tier( pass +def set_trial(conn: sqlite3.Connection, account_id: int, trial_until: str | None) -> None: + """Seteaza DOAR `trial_until` (acorda/prelungeste/sterge trial Pro), fara a atinge `tier`. + + Trial Pro activ (trial_until in viitor) ridica planul efectiv la 'pro' (vezi + plans.effective_tier), indiferent de tier-ul de baza. Folosit din panoul admin ca sa + acorzi un trial fara a schimba tier-ul de baza (post-trial). + + Contul de sistem id=1 e protejat. Cont inexistent -> ValueError. + trial_until: string ISO UTC ("YYYY-MM-DD HH:MM:SS") sau None (sterge trial-ul). + """ + row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() + if not row: + raise ValueError(f"cont inexistent: {account_id}") + if account_id == _PROTECTED_ACCOUNT_ID: + raise ValueError("Contul default (id=1) nu poate primi trial (cont de sistem).") + conn.execute( + "UPDATE accounts SET trial_until=? WHERE id=?", (trial_until, account_id) + ) + # Audit in app_events (best-effort, fara PII nou — ca set_tier). + try: + from .observ import log_event + log_event( + "plan_trial_setat", + account_id=account_id, + mesaj=f"trial_until -> {trial_until or 'NULL'}", + context={"trial_until": trial_until}, + conn=conn, + ) + except Exception: # noqa: BLE001 — jurnal best-effort (ca observ.log_event) + pass + + def delete_account(conn: sqlite3.Connection, account_id: int) -> None: """Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API @@ -208,7 +250,8 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]: """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted' (stergere soft -> invizibile in panou).""" rows = conn.execute( - "SELECT id, name, cui, email, active, status, tier, trial_until, created_at FROM accounts " + "SELECT id, name, cui, email, active, status, tier, trial_until, " + "requested_plan, consent_at, created_at FROM accounts " "WHERE status != 'deleted' ORDER BY id" ).fetchall() return [dict(r) for r in rows] diff --git a/app/db.py b/app/db.py index 2327b08..dcb5d1b 100644 --- a/app/db.py +++ b/app/db.py @@ -109,6 +109,14 @@ def _migrate(conn: sqlite3.Connection) -> None: if "trial_until" not in acc_cols: # Trial Pro activ daca != NULL si > now. Nullable (NULL = fara trial). conn.execute("ALTER TABLE accounts ADD COLUMN trial_until TEXT") + if "requested_plan" not in acc_cols: + # Planul cerut la signup (integrare plati). NU acorda drepturi; `tier` ramane sursa + # de adevar pt API/volum. Nullable. ALTER nu poate adauga CHECK pe coloana noua in + # SQLite -> validarea valorilor se face in cod (signup, fata de VALID_TIERS). + conn.execute("ALTER TABLE accounts ADD COLUMN requested_plan TEXT") + if "consent_at" not in acc_cols: + # Marca temporala consimtamant Termeni+GDPR (proba). Nullable (NULL = CLI/legacy). + conn.execute("ALTER TABLE accounts ADD COLUMN consent_at TEXT") # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" diff --git a/app/schema.sql b/app/schema.sql index 94b68b7..a906130 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -32,6 +32,15 @@ CREATE TABLE IF NOT EXISTS accounts ( tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free','standard','pro','premium')), trial_until TEXT, -- ISO datetime UTC sau NULL; nullable + -- Planul CERUT de client la signup (separat de `tier`). NU acorda drepturi: + -- `tier` ramane sursa unica de adevar pentru gate-ul API (require_api_access) si volum. + -- Folosit la integrarea platilor: client cere plan -> plateste -> admin/webhook urca `tier` + -- -> API se deblocheaza. NULL = necunoscut (cont creat via CLI / inainte de coloana). + requested_plan TEXT + CHECK (requested_plan IS NULL OR requested_plan IN ('free','standard','pro','premium')), + -- Marca temporala a acceptarii Termenilor + politicii de confidentialitate (GDPR, L.142). + -- Setata la signup (proba de consimtamant). NULL = cont fara flux de consimtamant (CLI/legacy). + consent_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Un CUI = un cont (cand e prezent). NULL ramane distinct nativ in SQLite -> conturi diff --git a/app/web/admin_routes.py b/app/web/admin_routes.py index c221812..ced99d7 100644 --- a/app/web/admin_routes.py +++ b/app/web/admin_routes.py @@ -8,6 +8,7 @@ Rute: from __future__ import annotations +from datetime import datetime, timedelta, timezone from pathlib import Path from fastapi import APIRouter, Form, Request @@ -15,12 +16,42 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from .. import __version__ -from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account +from ..accounts import account_is_complete, list_accounts, set_active, set_status, set_tier, set_trial, delete_account from ..config import get_settings from ..db import get_connection +from ..plans import PLANS, effective_tier from ..web.csrf import get_csrf_token, verify_csrf from ..web.session import require_admin + +def _plan_label(code: str | None) -> str: + """Eticheta RO a unui cod de plan (din PLANS). None/necunoscut -> '—'.""" + if not code: + return "—" + plan = PLANS.get(code) + return plan["label"] if plan else code + + +def _trial_zile_ramase(trial_until_str: str | None, now: datetime) -> int | None: + """Zile ramase din trial (rotunjit in sus), sau None daca nu e trial activ/malformat. + + Acelasi parsing tolerant ca plans.effective_tier (UTC implicit pe valori naive). + """ + if not trial_until_str: + return None + try: + tu = datetime.fromisoformat(trial_until_str.strip().replace(" ", "T")) + if tu.tzinfo is None: + tu = tu.replace(tzinfo=timezone.utc) + now_cmp = now if now.tzinfo else now.replace(tzinfo=timezone.utc) + secunde = (tu - now_cmp).total_seconds() + if secunde <= 0: + return None + # Rotunjire in sus la zile (o fractie de zi ramasa = inca 1 zi afisata). + return int(secunde // 86400) + (1 if secunde % 86400 else 0) + except (ValueError, AttributeError, TypeError): + return None + router = APIRouter() _TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) @@ -47,10 +78,19 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co """Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare.""" accounts = list_accounts(conn) emails = _emails_by_account(conn) + now = datetime.now(timezone.utc) for acct in accounts: # Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului acct["is_complete"] = account_is_complete(acct) acct["email"] = emails.get(acct["id"]) + # Plan EFECTIV (ce are contul acum): trial Pro activ ridica `free` la `pro`. + # `tier` ramane sursa de adevar pentru drepturi; `requested_plan` e doar intentia de la signup. + eff = effective_tier(acct, now) + acct["tier_label"] = _plan_label(acct.get("tier")) # tier de baza (post-trial) + acct["tier_efectiv_label"] = _plan_label(eff) # plan efectiv ACUM + acct["trial_activ"] = eff != (acct.get("tier") or "free") + acct["trial_zile"] = _trial_zile_ramase(acct.get("trial_until"), now) + acct["requested_plan_label"] = _plan_label(acct.get("requested_plan")) # Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0) # ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts. pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1] @@ -146,6 +186,73 @@ async def admin_delete(request: Request, account_id: list[int] = Form(...), return _lifecycle_route(request, account_id, csrf_token, "delete") +@router.post("/admin/set-tier", response_class=HTMLResponse) +async def admin_set_tier( + request: Request, + account_id: int = Form(...), + tier: str = Form(...), + csrf_token: str = Form(default=""), +): + """Schimba planul (tier) unui cont din panou. require_admin + CSRF, PRG 303. + + Reuseaza accounts.set_tier (valideaza tier-ul, protejeaza id=1, logheaza schimbarea). + INCHEIE trial-ul (trial_until=NULL): alocarea manuala = plan real de-acum, cu efect + imediat — altfel trial-ul Pro universal (30z la signup) ar masca alegerea pana la + expirare (decizie user 2026-06-29). Tier invalid / cont protejat -> re-randare cu eroare. + """ + require_admin(request) + verify_csrf(request, csrf_token) + + conn = get_connection() + try: + try: + # trial_until=None: alocarea manuala incheie trial-ul si aplica tier-ul ales acum. + set_tier(conn, account_id, tier, trial_until=None) + conn.commit() + except ValueError as exc: + return _render_admin(request, conn, error=str(exc), status_code=422) + finally: + conn.close() + + return RedirectResponse("/admin", status_code=303) + + +@router.post("/admin/set-trial", response_class=HTMLResponse) +async def admin_set_trial( + request: Request, + account_id: int = Form(...), + trial_days: int = Form(...), + csrf_token: str = Form(default=""), +): + """Acorda/prelungeste un trial Pro de N zile (de la acum), fara a schimba tier-ul de baza. + + require_admin + CSRF, PRG 303. Reuseaza accounts.set_trial (protejeaza id=1, logheaza). + trial_days <= 0 sau peste plafon -> re-randare panou cu eroare (422). Plafon defensiv 3650z. + """ + require_admin(request) + verify_csrf(request, csrf_token) + + conn = get_connection() + try: + if trial_days <= 0 or trial_days > 3650: + return _render_admin( + request, conn, + error="Numarul de zile pentru trial trebuie sa fie intre 1 si 3650.", + status_code=422, + ) + try: + now = datetime.now(timezone.utc) + trial_until = (now + timedelta(days=trial_days)).strftime("%Y-%m-%d %H:%M:%S") + set_trial(conn, account_id, trial_until) + conn.commit() + except ValueError as exc: + return _render_admin(request, conn, error=str(exc), status_code=422) + finally: + conn.close() + + return RedirectResponse("/admin", status_code=303) + + @router.post("/admin/deactivate", response_class=HTMLResponse) async def admin_deactivate( request: Request, diff --git a/app/web/auth_routes.py b/app/web/auth_routes.py index 283eb9c..beff76a 100644 --- a/app/web/auth_routes.py +++ b/app/web/auth_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Form, Request @@ -9,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from .. import __version__ -from ..accounts import create_account +from ..accounts import VALID_TIERS, create_account from ..auth import create_api_key from ..config import get_settings from ..db import get_connection @@ -47,10 +48,18 @@ async def signup_post( cui: str = Form(default=""), email: str = Form(default=""), parola: str = Form(default=""), + plan: str = Form(default=""), + consent: str = Form(default=""), csrf_token: str = Form(default=""), ): verify_csrf(request, csrf_token) + # Planul CERUT (intentie, nu drept): pastram doar valori valide; orice altceva -> 'free'. + # `tier`-ul real ramane 'free' la creare; planul ales se onoreaza dupa plata (admin/webhook). + requested_plan = plan.strip().lower() if plan else "" + if requested_plan not in VALID_TIERS: + requested_plan = "free" + settings = get_settings() ip = request.client.host if request.client else "unknown" if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s): @@ -58,7 +67,7 @@ async def signup_post( request, csrf_token=get_csrf_token(request), error=_RATE_MSG, - name=name, cui=cui, email=email, + name=name, cui=cui, email=email, plan=requested_plan, ), status_code=429) if len(parola) < _PASSWORD_MIN: @@ -66,7 +75,7 @@ async def signup_post( request, csrf_token=get_csrf_token(request), error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.", - name=name, cui=cui, email=email, + name=name, cui=cui, email=email, plan=requested_plan, ), status_code=422) # CUI obligatoriu la signup (US-001, PRD 5.12) @@ -76,9 +85,19 @@ async def signup_post( request, csrf_token=get_csrf_token(request), error="CUI-ul firmei este obligatoriu.", - name=name, cui=cui, email=email, + name=name, cui=cui, email=email, plan=requested_plan, ), status_code=422) + # Consimtamant Termeni + GDPR obligatoriu (proba). Checkbox bifat -> valoare ne-goala. + if not (consent and consent.strip()): + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error="Trebuie sa accepti Termenii si prelucrarea datelor (GDPR) pentru a crea cont.", + name=name, cui=cui, email=email, plan=requested_plan, + ), status_code=422) + consent_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + # Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE, # astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1. conn = get_connection() @@ -86,7 +105,10 @@ async def signup_post( conn.execute("BEGIN IMMEDIATE") try: is_first = count_admins(conn) == 0 - account_id = create_account(conn, name, cui=cui_norm, email=email, active=False) + account_id = create_account( + conn, name, cui=cui_norm, email=email, active=False, + requested_plan=requested_plan, consent_at=consent_at, + ) user_id = create_user(conn, account_id, email, parola, is_admin=is_first) api_key = create_api_key(conn, account_id) conn.execute("COMMIT") @@ -121,7 +143,7 @@ async def signup_post( request, csrf_token=get_csrf_token(request), error=error_msg, - name=name, cui=cui, email=email, + name=name, cui=cui, email=email, plan=requested_plan, ), status_code=422) except Exception as exc: conn.execute("ROLLBACK") @@ -129,7 +151,7 @@ async def signup_post( request, csrf_token=get_csrf_token(request), error=str(exc), - name=name, cui=cui, email=email, + name=name, cui=cui, email=email, plan=requested_plan, ), status_code=422) finally: conn.close() diff --git a/app/web/templates/admin.html b/app/web/templates/admin.html index a11d5c2..08a48ae 100644 --- a/app/web/templates/admin.html +++ b/app/web/templates/admin.html @@ -10,6 +10,9 @@ 'delete': ('Sterge', '/admin/delete', 'danger') } %} +{# Tier-uri selectabile in panou (cod, eticheta). Aliniat cu app/plans.py#PLANS. #} +{% set TIERS = [('free', 'Gratuit'), ('standard', 'Standard'), ('pro', 'Pro'), ('premium', 'Premium')] %} + {% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}

{{ title }} ({{ rows|length }})

@@ -34,7 +37,7 @@ - IDCompanieCUIEmailStareInregistratActiuni + IDCompanieCUIEmailPlan curentPlan cerutStareInregistratActiuni {% for acct in rows %} @@ -46,6 +49,45 @@ {{ acct.name }} {{ acct.cui or "—" }} {{ acct.email or "—" }} + + {# Plan EFECTIV acum (prominent): trial Pro activ ridica free->pro. #} +
+ {{ acct.tier_efectiv_label }} + {% if acct.trial_activ %} + + trial{% if acct.trial_zile %} · {{ acct.trial_zile }} {{ 'zi' if acct.trial_zile == 1 else 'zile' }} ramase{% endif %} + → apoi {{ acct.tier_label }} + + {% endif %} +
+ {# Schimbare plan inline: select tier de baza + Aplica. Form propriu (nu imbricat in bulk-form). + Aplica INCHEIE trial-ul si seteaza planul ales ca real, cu efect imediat. #} +
+ + + + +
+ {# Acorda/prelungeste trial Pro de N zile, fara a schimba tier-ul de baza. #} +
+ + + + +
+ + {{ acct.requested_plan_label }} {{ acct.status }} {{ acct.created_at or "—" }} diff --git a/app/web/templates/landing.html b/app/web/templates/landing.html index 7d5f403..665d459 100644 --- a/app/web/templates/landing.html +++ b/app/web/templates/landing.html @@ -259,7 +259,7 @@
Fără import API
- +
@@ -273,7 +273,7 @@
Fără import API
- +
@@ -288,7 +288,7 @@
Categorisire automată, cu confirmare la operațiile incerte
- +
@@ -301,7 +301,7 @@
Suport telefonic și online
Asistență și onboarding dedicate
- + @@ -348,12 +348,12 @@
- + - - + +
Ai deja cont? Autentifică-te
diff --git a/app/web/templates/signup.html b/app/web/templates/signup.html index 0b3d02a..6af80cf 100644 --- a/app/web/templates/signup.html +++ b/app/web/templates/signup.html @@ -37,33 +37,53 @@ }); {% else %} -

Inregistrare cont nou

+

Creează cont nou

{% if error %} {% endif %} + {# Format aliniat la formularul de inregistrare din landing (#inregistrare): aceleasi campuri, + etichete, placeholder-uri si stil. Valorile `plan` = coduri tier (free/standard/pro/premium), + normalizate server-side. #}
-

-
- -

-

-
- -

-

-
- -

-

-
- -

- + + + + + + +

Ai deja cont? Autentificare diff --git a/tests/test_admin_lifecycle.py b/tests/test_admin_lifecycle.py index 0561f73..291d2dc 100644 --- a/tests/test_admin_lifecycle.py +++ b/tests/test_admin_lifecycle.py @@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"): tok = _csrf(client, "/signup") resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email, "parola": password, - "csrf_token": tok}, follow_redirects=True) + "consent": "1", "csrf_token": tok}, + follow_redirects=True) assert resp.status_code == 200 from app.db import get_connection conn = get_connection() diff --git a/tests/test_admin_panel.py b/tests/test_admin_panel.py index c80b441..632e029 100644 --- a/tests/test_admin_panel.py +++ b/tests/test_admin_panel.py @@ -62,6 +62,7 @@ def _signup(client: TestClient, name: str, email: str, password: str = "parola_t "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]}" @@ -261,3 +262,158 @@ def test_activare_cont_incomplet_refuzata(client): 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" diff --git a/tests/test_signup.py b/tests/test_signup.py index ae684bc..42cc6c8 100644 --- a/tests/test_signup.py +++ b/tests/test_signup.py @@ -65,6 +65,7 @@ def test_signup_fara_cui_422(client): "cui": "", "email": "fara_cui@test.com", "parola": "parolasecreta123", + "consent": "1", "csrf_token": token, }) # trebuie sa returneze 422 (sau sa randeze formularul cu eroare) @@ -96,6 +97,7 @@ def test_signup_scrie_email_pe_account(client): "cui": "RO9999001", "email": "cu_email@test.com", "parola": "parolasecreta123", + "consent": "1", "csrf_token": token, }) assert resp.status_code == 200 @@ -131,6 +133,7 @@ def test_signup_email_duplicat_mesaj_email(client): "cui": make_test_cui("email-dup-c1"), "email": "emaildup@test.com", "parola": "parolasecreta123", + "consent": "1", "csrf_token": token, }) assert resp1.status_code == 200 @@ -145,6 +148,7 @@ def test_signup_email_duplicat_mesaj_email(client): "cui": cui_nou, "email": "emaildup@test.com", "parola": "parolasecreta456", + "consent": "1", "csrf_token": token2, }) @@ -179,6 +183,7 @@ def test_signup_cui_existent_mesaj_prietenos(client): "cui": "RO8888001", "email": "firma1@test.com", "parola": "parolasecreta123", + "consent": "1", "csrf_token": token, }) @@ -190,6 +195,7 @@ def test_signup_cui_existent_mesaj_prietenos(client): "cui": "RO8888001", "email": "firma2@test.com", "parola": "parolasecreta456", + "consent": "1", "csrf_token": token2, }) diff --git a/tests/test_signup_notify.py b/tests/test_signup_notify.py index dc484d2..1a0e938 100644 --- a/tests/test_signup_notify.py +++ b/tests/test_signup_notify.py @@ -52,6 +52,7 @@ def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecret "cui": make_test_cui(email), "email": email, "parola": parola, + "consent": "1", "csrf_token": token, }) diff --git a/tests/test_web_admin.py b/tests/test_web_admin.py index ed4e3ee..25831ad 100644 --- a/tests/test_web_admin.py +++ b/tests/test_web_admin.py @@ -40,7 +40,8 @@ def _signup(client, name, email, password="parola_test_001"): from tests.conftest import make_test_cui tok = _csrf(client, "/signup") client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email, - "parola": password, "csrf_token": tok}, follow_redirects=True) + "parola": password, "consent": "1", "csrf_token": tok}, + follow_redirects=True) from app.db import get_connection conn = get_connection() try: diff --git a/tests/test_web_signup.py b/tests/test_web_signup.py index 19e16dd..5b90606 100644 --- a/tests/test_web_signup.py +++ b/tests/test_web_signup.py @@ -48,6 +48,7 @@ def test_signup_creeaza_cont_user_si_cheie(client): "cui": "RO12345678", "email": "test@example.com", "parola": "parolasecreta", + "consent": "1", "csrf_token": token, }) assert resp.status_code == 200 @@ -87,6 +88,7 @@ def test_signup_email_duplicat_eroare(client): "cui": make_test_cui("dup@example.com"), "email": "dup@example.com", "parola": "parolasecreta", + "consent": "1", "csrf_token": token, }) @@ -102,6 +104,7 @@ def test_signup_email_duplicat_eroare(client): "cui": make_test_cui("dup-b@example.com"), "email": "dup@example.com", "parola": "altaparola123", + "consent": "1", "csrf_token": token, }) assert resp2.status_code in (200, 422) @@ -139,6 +142,72 @@ def test_signup_parola_scurta_eroare(client): conn.close() +def test_signup_fara_consent_eroare(client): + """Consimtamant GDPR lipsa -> 422, fara creare cont; mesaj despre Termeni/GDPR. + + Checkbox-ul de consimtamant trebuie validat server-side (functional, nu doar client-side): + fara el contul nu se creeaza si planul/datele introduse se pastreaza in re-render. + """ + from tests.conftest import make_test_cui + resp = client.get("/signup") + token = _csrf(resp.text) + + resp = client.post("/signup", data={ + "name": "Service Fara Consent", + "cui": make_test_cui("fara-consent@test.com"), + "email": "fara-consent@test.com", + "parola": "parolasecreta123", + # fara "consent" + "csrf_token": token, + }) + assert resp.status_code == 422 + assert "rfak_" not in resp.text + assert "GDPR" in resp.text or "Termeni" in resp.text + + from app.db import get_connection + conn = get_connection() + try: + acct = conn.execute( + "SELECT * FROM accounts WHERE name='Service Fara Consent'" + ).fetchone() + assert acct is None, "Cont creat desi consimtamantul lipsea" + finally: + conn.close() + + +def test_signup_salveaza_requested_plan_si_consent(client): + """POST /signup cu plan ales -> accounts.requested_plan = codul ales, consent_at setat, + iar tier RAMANE 'free' (planul cerut NU acorda drepturi).""" + from tests.conftest import make_test_cui + resp = client.get("/signup") + token = _csrf(resp.text) + + resp = client.post("/signup", data={ + "name": "Service Plan Pro", + "cui": make_test_cui("plan-pro@test.com"), + "email": "plan-pro@test.com", + "parola": "parolasecreta123", + "plan": "pro", + "consent": "1", + "csrf_token": token, + }) + assert resp.status_code == 200 + assert "rfak_" in resp.text + + from app.db import get_connection + conn = get_connection() + try: + acct = conn.execute( + "SELECT * FROM accounts WHERE name='Service Plan Pro'" + ).fetchone() + assert acct is not None + assert acct["requested_plan"] == "pro", "Planul cerut nu a fost salvat" + assert acct["tier"] == "free", "tier NU trebuie urcat din planul cerut (doar dupa plata)" + assert acct["consent_at"], "consent_at trebuie setat la signup cu consimtamant" + finally: + conn.close() + + def test_cheie_afisata_o_data(client): """Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine.""" from tests.conftest import make_test_cui @@ -150,6 +219,7 @@ def test_cheie_afisata_o_data(client): "cui": make_test_cui("cheie@test.com"), "email": "cheie@test.com", "parola": "parolasecreta", + "consent": "1", "csrf_token": token, }) assert resp_post.status_code == 200