feat(5.20): US-012 audit evenimente medii + teste e2e

log_event best-effort (refoloseste conn apelantului, fara PII in context) la:
- rar_env_activat / rar_env_dezactivat: activare/dezactivare mediu in cont_rar_medii
- rar_env_default_schimbat: schimbare efectiva default in cont_rar_medii si in
  toggle-ul din statusbar (fragment_status_toggle_env)
- rar_env_blocat: tinta indisponibila — 422 pe canalul API (router.py) + WARNING
  pe caile de import web (fallback existent neschimbat, doar logging adaugat)

tests/test_e2e_rar_env.py: lant import->queued cu rar_env corect (ambele canale),
activare Productie logata, tinta indisponibila blocata + logata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-02 20:26:28 +00:00
parent 0a1df31126
commit 3d3eb71a1e
3 changed files with 415 additions and 6 deletions

View File

@@ -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(

View File

@@ -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")

279
tests/test_e2e_rar_env.py Normal file
View File

@@ -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}"
)