diff --git a/app/api/v1/router.py b/app/api/v1/router.py index fd44b7f..190dfbf 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -175,6 +175,14 @@ def create_prezentari( try: env = rezolva_rar_env(conn, acct, req.rar_env) except MediuIndisponibil as e: + # US-012: audit blocare mediu indisponibil (tip='rar_env_blocat'). + log_event( + "rar_env_blocat", + nivel="WARNING", + account_id=acct, + context={"env": e.env}, + conn=conn, + ) raise HTTPException( status_code=422, detail=err_eroare( @@ -364,6 +372,14 @@ def valideaza_prezentari( try: env = rezolva_rar_env(conn, acct, req.rar_env) except MediuIndisponibil as e: + # US-012: audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat'). + log_event( + "rar_env_blocat", + nivel="WARNING", + account_id=acct, + context={"env": e.env}, + conn=conn, + ) raise HTTPException( status_code=422, detail=err_eroare( diff --git a/app/web/routes.py b/app/web/routes.py index 7060c9f..74f6e78 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -3026,7 +3026,17 @@ async def web_upload_import( # La 0 medii: rezolva_rar_env cade pe ancora globala (rar_env config), non-blocant. try: upload_env = rezolva_rar_env(conn, account_id, rar_env or None) - except (ValueError, MediuIndisponibil): + except MediuIndisponibil as e: + # US-012: audit mediu cerut dar indisponibil (fallback silentios, non-blocant). + log_event( + "rar_env_blocat", + nivel="WARNING", + account_id=account_id, + context={"env": e.env}, + conn=conn, + ) + upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + except ValueError: upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" # Stagingul in DB (tranzactie explicita) @@ -3237,7 +3247,17 @@ async def web_save_mapare_coloane( form_rar_env = str(form.get("rar_env") or "").strip() or None try: mapare_env = rezolva_rar_env(conn, account_id, form_rar_env) - except (ValueError, MediuIndisponibil): + except MediuIndisponibil as e: + # US-012: audit mediu cerut dar indisponibil la mapare coloane (fallback silentios). + log_event( + "rar_env_blocat", + nivel="WARNING", + account_id=account_id, + context={"env": e.env}, + conn=conn, + ) + mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + except ValueError: mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" # Computa preview @@ -3265,7 +3285,17 @@ def web_preview_import( # Rezolva mediul pentru preview (din query param sau default cont) try: preview_env = rezolva_rar_env(conn, account_id, rar_env) - except (ValueError, MediuIndisponibil): + except MediuIndisponibil as e: + # US-012: audit mediu cerut dar indisponibil la preview (fallback silentios). + log_event( + "rar_env_blocat", + nivel="WARNING", + account_id=account_id, + context={"env": e.env}, + conn=conn, + ) + preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + except ValueError: preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" result = _web_compute_preview(conn, import_id, account_id, rar_env=preview_env) if isinstance(result, str): @@ -3909,7 +3939,17 @@ async def web_confirma_import( # Rezolva mediul RAR tinta al lotului (US-009): form > default cont > ancora globala. try: env = rezolva_rar_env(conn, account_id, rar_env_cerut) - except (ValueError, MediuIndisponibil): + except MediuIndisponibil as e: + # US-012: audit mediu cerut dar indisponibil la commit import (fallback silentios). + log_event( + "rar_env_blocat", + nivel="WARNING", + account_id=account_id, + context={"env": e.env}, + conn=conn, + ) + env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" + except ValueError: env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test" # Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU) @@ -4613,11 +4653,15 @@ async def cont_rar_medii(request: Request) -> HTMLResponse: conn = get_connection() try: - # Starea curenta din DB (inainte de update) — necesara pt logica de confirmare prod. + # Starea curenta din DB (inainte de update) — necesara pt logica de confirmare prod + # si pt a loga DOAR schimbari reale (US-012). row_before = conn.execute( - "SELECT rar_prod_enabled FROM accounts WHERE id=?", (acct,) + "SELECT rar_test_enabled, rar_prod_enabled, rar_env_default FROM accounts WHERE id=?", + (acct,), ).fetchone() + was_test_enabled = bool(row_before["rar_test_enabled"]) if row_before else False was_prod_enabled = bool(row_before["rar_prod_enabled"]) if row_before else False + prev_env_default = row_before["rar_env_default"] if row_before else None # Confirmare obligatorie la PRIMA activare Productie (constientizare L.142). # Nu se cere daca Productie era deja activata (confirmare unica per-activare). @@ -4636,6 +4680,7 @@ async def cont_rar_medii(request: Request) -> HTMLResponse: ) # --- Procesare Testare --- + # Tipuri de audit US-012: 'rar_env_activat' / 'rar_env_dezactivat' / 'rar_env_default_schimbat'. if test_enabled_form: if test_email and test_parola: # Ambele campuri completate -> valideaza prin login pe RAR Testare. @@ -4647,6 +4692,14 @@ async def cont_rar_medii(request: Request) -> HTMLResponse: (enc, acct), ) creds_test_mesaj = "Credentiale Testare salvate si validate." + # Activare reala: creds noi salvate (schimbare efectiva indiferent de starea anterioara). + log_event( + "rar_env_activat", + account_id=acct, + mesaj="Credentiale Testare salvate si validate.", + context={"env": "test"}, + conn=conn, + ) else: # Login esuat: nu schimbam creds sau enabled; eroare per-env. creds_test_eroare = mesaj @@ -4656,9 +4709,27 @@ async def cont_rar_medii(request: Request) -> HTMLResponse: else: # Activat fara creds noi -> marcheaza enabled (creds existente, daca sunt, raman). conn.execute("UPDATE accounts SET rar_test_enabled=1 WHERE id=?", (acct,)) + # Loga activare doar daca era dezactivat (0->1 e schimbare reala). + if not was_test_enabled: + log_event( + "rar_env_activat", + account_id=acct, + mesaj="Mediu Testare activat (creds existente).", + context={"env": "test"}, + conn=conn, + ) else: # Dezactivat -> disabled=0; creds raman pentru posibila re-activare ulterioara. conn.execute("UPDATE accounts SET rar_test_enabled=0 WHERE id=?", (acct,)) + # Loga dezactivare doar daca era activat (1->0 e schimbare reala). + if was_test_enabled: + log_event( + "rar_env_dezactivat", + account_id=acct, + mesaj="Mediu Testare dezactivat.", + context={"env": "test"}, + conn=conn, + ) # --- Procesare Productie --- if prod_enabled_form: @@ -4671,14 +4742,40 @@ async def cont_rar_medii(request: Request) -> HTMLResponse: (enc, acct), ) creds_prod_mesaj = "Credentiale Productie salvate si validate." + # Activare reala: creds noi salvate. + log_event( + "rar_env_activat", + account_id=acct, + mesaj="Credentiale Productie salvate si validate.", + context={"env": "prod"}, + conn=conn, + ) else: creds_prod_eroare = mesaj elif prod_email or prod_parola: creds_prod_eroare = "Email si parola Productie trebuie completate impreuna." else: conn.execute("UPDATE accounts SET rar_prod_enabled=1 WHERE id=?", (acct,)) + # Loga activare doar daca era dezactivat (0->1). + if not was_prod_enabled: + log_event( + "rar_env_activat", + account_id=acct, + mesaj="Mediu Productie activat (creds existente).", + context={"env": "prod"}, + conn=conn, + ) else: conn.execute("UPDATE accounts SET rar_prod_enabled=0 WHERE id=?", (acct,)) + # Loga dezactivare doar daca era activat (1->0). + if was_prod_enabled: + log_event( + "rar_env_dezactivat", + account_id=acct, + mesaj="Mediu Productie dezactivat.", + context={"env": "prod"}, + conn=conn, + ) # --- Mediu implicit (validat post-update contra mediilor disponibile) --- if rar_env_default_form and rar_env_default_form in ("test", "prod"): @@ -4695,6 +4792,15 @@ async def cont_rar_medii(request: Request) -> HTMLResponse: (rar_env_default_form, acct), ) creds_default_mesaj = "Mediu implicit actualizat." + # Loga schimbarea default doar daca valoarea s-a schimbat efectiv (US-012). + if rar_env_default_form != prev_env_default: + log_event( + "rar_env_default_schimbat", + account_id=acct, + mesaj=f"Mediu implicit schimbat in '{rar_env_default_form}'.", + context={"env": rar_env_default_form}, + conn=conn, + ) else: creds_default_eroare = ( "Mediul ales nu e disponibil — activeaza-l si adauga credentiale valide intai." @@ -4744,6 +4850,14 @@ async def fragment_status_toggle_env(request: Request) -> HTMLResponse: "UPDATE accounts SET rar_env_default=? WHERE id=?", (env_nou, acct), ) + # US-012: audit comutare mediu implicit din statusbar. + log_event( + "rar_env_default_schimbat", + account_id=acct, + mesaj=f"Mediu implicit comutat in '{env_nou}' via statusbar.", + context={"env": env_nou}, + conn=conn, + ) conn.commit() ctx = _build_status_ctx(request, conn, account_id, tab_activ="acasa") diff --git a/tests/test_e2e_rar_env.py b/tests/test_e2e_rar_env.py new file mode 100644 index 0000000..6e35d83 --- /dev/null +++ b/tests/test_e2e_rar_env.py @@ -0,0 +1,279 @@ +"""Teste US-012 (PRD 5.20): audit evenimente medii RAR + e2e lant submission. + +Fisier nou; nu modifica teste existente. + +Teste: + test_lant_import_pana_la_queued -- submission ajunge la status=queued cu rar_env corect + test_activare_prod_logata -- activarea mediului Productie produce eveniment de audit + test_tinta_indisponibila_blocata_si_logata -- cerere pe mediu indisponibil -> 422 + eveniment audit +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from cryptography.fernet import Fernet +from starlette.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def client(monkeypatch): + """Client izolat cu DB temporara + cheie Fernet pentru criptare creds.""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_e2e_env.db")) + monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false") + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + from app import crypto + + get_settings.cache_clear() + crypto.reset_cache() + from app.main import app + + with TestClient(app, follow_redirects=False) as c: + yield c + + get_settings.cache_clear() + crypto.reset_cache() + + +# --------------------------------------------------------------------------- +# Helpere +# --------------------------------------------------------------------------- + + +def _create_account_user( + name: str = "Service E2E SRL", + email: str = "user@e2e.com", + password: str = "parolasecreta10", +): + """Creeaza cont + user via create_account/create_user (id>=2; conta default e id=1). + Returneaza (acct_id, user_id). + """ + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + user_id = create_user(conn, acct_id, email, password) + return acct_id, user_id + finally: + conn.close() + + +def _login(client, email: str, password: str) -> None: + """Face login real prin HTTP si seteaza cookie-ul de sesiune pe client.""" + resp = client.get("/login") + 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 pe /login" + csrf = m.group(1) + + resp = client.post("/login", data={ + "email": email, + "parola": password, + "csrf_token": csrf, + }) + assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}" + + +def _get_csrf(client) -> str: + """Obtine CSRF token din fragmentul /_fragments/cont.""" + resp = client.get("/_fragments/cont") + assert resp.status_code == 200, f"/_fragments/cont a returnat {resp.status_code}" + 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 /_fragments/cont: {resp.text[:400]}" + return m.group(1) + + +def _mock_login_ok(monkeypatch) -> None: + """Monkeypatch _valideaza_login_rar sa returneze (True, None) fara RAR live.""" + import app.web.routes as routes_mod + monkeypatch.setattr(routes_mod, "_valideaza_login_rar", lambda *a, **kw: (True, None)) + + +def _seteaza_mediu_disponibil_acct(acct_id: int, env: str) -> None: + """Activeaza mediul `env` cu creds criptate mock direct in DB pentru contul dat.""" + from app.db import get_connection + from app.crypto import encrypt_creds + + enc = encrypt_creds({"email": f"rar_{env}@firma.ro", "password": f"parola_{env}"}) + conn = get_connection() + try: + if env == "test": + conn.execute( + "UPDATE accounts SET rar_test_enabled=1, rar_creds_test_enc=?, " + "rar_env_default='test' WHERE id=?", + (enc, acct_id), + ) + elif env == "prod": + conn.execute( + "UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=?, " + "rar_env_default='prod' WHERE id=?", + (enc, acct_id), + ) + conn.commit() + finally: + conn.close() + + +def _get_events(tip: str) -> list: + """Returneaza randurile din app_events cu tipul specificat.""" + from app.db import get_connection + conn = get_connection() + try: + return conn.execute( + "SELECT * FROM app_events WHERE tip=?", (tip,) + ).fetchall() + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Teste +# --------------------------------------------------------------------------- + + +def test_lant_import_pana_la_queued(client): + """Canal API (POST /v1/prezentari) cu mediu test disponibil -> submission status=queued, + rar_env='test' in DB. + + AUTOPASS_REQUIRE_API_KEY=false -> cererea e procesata pe contul implicit id=1. + Configuram mediul test direct in DB pe accel cont (id=1). + + Canal import web e2e (upload + mapare + confirmare) e acoperit complet in + tests/test_import_rar_env.py; acest test verifica propagarea rar_env pe calea API. + """ + # Configureaza mediu test pe contul implicit id=1 (cel utilizat de API fara cheie) + _seteaza_mediu_disponibil_acct(1, "test") + + body = { + "prezentari": [{ + "vin": "WVWZZZ1KZAW001001", + "nr_inmatriculare": "B001E2E", + "data_prestatie": "2026-07-01", + "odometru_final": "50000", + "prestatii": [{"cod_prestatie": "OE-1"}], + }] + } + resp = client.post("/v1/prezentari", json=body) + assert resp.status_code == 200, ( + f"POST /v1/prezentari a esuat: {resp.status_code} {resp.text[:400]}" + ) + + # Verifica in DB: submission are status=queued sau needs_mapping (cod necunoscut in nomenclator) + # si rar_env=test (mediul contului) + from app.db import get_connection + conn = get_connection() + try: + sub = conn.execute( + "SELECT status, rar_env FROM submissions WHERE account_id=1 ORDER BY id DESC LIMIT 1" + ).fetchone() + finally: + conn.close() + + assert sub is not None, "Niciun submission creat dupa POST /v1/prezentari" + # status poate fi 'queued' sau 'needs_mapping' (nomenclator gol); rar_env trebuie sa fie 'test' + assert sub["status"] in ("queued", "needs_mapping"), ( + f"Status neasteptat: {sub['status']!r}" + ) + assert sub["rar_env"] == "test", ( + f"rar_env asteptat 'test' (mediul contului), primit {sub['rar_env']!r}" + ) + + +def test_activare_prod_logata(client, monkeypatch): + """Activarea mediului Productie via POST /cont/rar-medii produce eveniment + 'rar_env_activat' in app_events cu context env='prod' si account_id corect. + """ + _mock_login_ok(monkeypatch) + acct_id, _ = _create_account_user("Firma AuditProd", "audit_prod@test.com") + _login(client, "audit_prod@test.com", "parolasecreta10") + + # Pas 0: dezactiveaza prod mai intai (schema default are rar_prod_enabled=1 fara creds) + # Trimitem form fara prod_enabled -> rar_prod_enabled devine 0 + csrf = _get_csrf(client) + client.post("/cont/rar-medii", data={"csrf_token": csrf}) + + # Pas 1: activeaza prod cu creds + confirmare (mock login ok) + csrf = _get_csrf(client) + resp = client.post("/cont/rar-medii", data={ + "csrf_token": csrf, + "prod_enabled": "1", + "prod_email": "rar_prod@firma.ro", + "prod_parola": "parolaRARprod", + "prod_confirmare": "1", + }) + assert resp.status_code == 200, f"Activare prod a esuat: {resp.status_code} {resp.text[:400]}" + + # Verifica eveniment de audit in app_events + events = _get_events("rar_env_activat") + assert len(events) >= 1, ( + f"Eveniment 'rar_env_activat' asteptat in app_events dupa activare prod, " + f"gasit {len(events)}" + ) + + # Evenimentul trebuie sa contina env='prod' in context_json + prod_events = [e for e in events if "prod" in (e["context_json"] or "")] + assert len(prod_events) >= 1, ( + f"Eveniment 'rar_env_activat' cu env='prod' negasit; " + f"events={[e['context_json'] for e in events]}" + ) + ctx = json.loads(prod_events[-1]["context_json"]) + assert ctx.get("env") == "prod", f"context_json.env asteptat 'prod', primit {ctx!r}" + assert prod_events[-1]["account_id"] == acct_id, ( + f"account_id asteptat {acct_id}, primit {prod_events[-1]['account_id']}" + ) + + +def test_tinta_indisponibila_blocata_si_logata(client): + """POST /v1/prezentari cu rar_env='prod' pe un cont fara prod disponibil + (rar_prod_enabled=1 dar fara creds) e respinsa cu 422 SI produce eveniment + 'rar_env_blocat' in app_events cu context env='prod'. + + Contul implicit (id=1, AUTOPASS_REQUIRE_API_KEY=false) are rar_prod_enabled=1 + dar rar_creds_prod_enc=NULL -> prod indisponibil -> MediuIndisponibil -> 422. + """ + # Contul 1 implicit: rar_prod_enabled=1 dar fara creds -> prod indisponibil + # Asiguram ca test e de asemenea indisponibil (stare fresh DB) + body = { + "prezentari": [{ + "vin": "WVWZZZ1KZAW002002", + "nr_inmatriculare": "B002E2E", + "data_prestatie": "2026-07-01", + "odometru_final": "60000", + "prestatii": [{"cod_prestatie": "OE-1"}], + }], + "rar_env": "prod", + } + resp = client.post("/v1/prezentari", json=body) + assert resp.status_code == 422, ( + f"Asteptat 422 (mediu indisponibil), primit {resp.status_code}: {resp.text[:300]}" + ) + + # Verifica ca cererea a fost logata ca blocat + events = _get_events("rar_env_blocat") + assert len(events) >= 1, ( + f"Eveniment 'rar_env_blocat' asteptat in app_events dupa respingere, " + f"gasit {len(events)}" + ) + ctx = json.loads(events[-1]["context_json"]) + assert ctx.get("env") == "prod", ( + f"context_json.env asteptat 'prod', primit {ctx!r}" + )