"""US-011 (PRD 5.15): account-scope pe GET-urile de listare API (securitate). Verifica: - GET /v1/prezentari: un cont nu vede submissions ale altui cont - GET /v1/prezentari/{id}: 404-before-leak pe id strain - Unauthenticated access cu require_api_key=True -> 401 - Filtre si paginare nu sparg scope-ul Legatura cu implementare: resolve_account_id + account_scope_clause (mecanisme existente, reutilizate). Testele LOCK DOWN comportamentul deja implementat si verifica alinierea cu AUTOPASS_REQUIRE_API_KEY (dev vs prod). """ from __future__ import annotations import json import os import tempfile import pytest from starlette.testclient import TestClient def _create_account_with_key(name: str): """Creeaza cont + cheie API. Intoarce (account_id, plaintext_key).""" from app.accounts import create_account from app.auth import create_api_key from app.db import get_connection conn = get_connection() try: acct_id = create_account(conn, name, active=True) key = create_api_key(conn, acct_id) conn.commit() return acct_id, key finally: conn.close() def _insert_submission(acct: int, vin: str = "WVWZZZ1JZXW000777") -> int: from app.db import get_connection conn = get_connection() try: p = { "vin": vin, "nr_inmatriculare": "B777TST", "data_prestatie": "2026-06-18", "odometru_final": "50000", "prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}], } cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " "VALUES (?, ?, ?, ?)", (f"api-scope-{acct}-{os.urandom(4).hex()}", acct, "sent", json.dumps(p)), ) conn.commit() rid = cur.lastrowid assert rid is not None return int(rid) finally: conn.close() @pytest.fixture() def client(monkeypatch): """Client cu DB izolata, REQUIRE_API_KEY=false (dev default).""" tmp = tempfile.mkdtemp() monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "api-scope.db")) monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false") from app.config import get_settings get_settings.cache_clear() from app.main import app with TestClient(app) as c: yield c get_settings.cache_clear() @pytest.fixture() def client_prod(monkeypatch): """Client cu require_api_key=True (comportament productie).""" tmp = tempfile.mkdtemp() monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "api-scope-prod.db")) monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true") from app.config import get_settings get_settings.cache_clear() from app.main import app with TestClient(app) as c: yield c get_settings.cache_clear() # --------------------------------------------------------------------------- # Test 1: cross-account isolation pe listare submissions (dev mode) # --------------------------------------------------------------------------- def test_get_listare_scoped_cont(client): """Un cont NU vede submissions (VIN/PII) ale altui cont in GET /v1/prezentari. Contul A are un submission cu VIN_A; contul B cu cheie proprie nu trebuie sa vada VIN_A in listarea sa. """ VIN_A = "WVWZZZ1JZXWAPI1AA" VIN_B = "WVWZZZ1JZXWAPI2BB" acct_a, key_a = _create_account_with_key("ApiContA") acct_b, key_b = _create_account_with_key("ApiContB") _insert_submission(acct_a, vin=VIN_A) _insert_submission(acct_b, vin=VIN_B) # Cont B listeaza cu propria cheie resp = client.get("/v1/prezentari", headers={"X-API-Key": key_b}) assert resp.status_code == 200 data = resp.json() vins = [s["prezentare"]["vin"] for s in data["submissions"] if s.get("prezentare")] assert VIN_B in vins, "Contul B ar trebui sa vada propriul submission" assert VIN_A not in vins, ( "Scurgere cross-account: VIN-ul contului A vizibil contului B prin API" ) # --------------------------------------------------------------------------- # Test 2: unauthenticated 401 in prod mode # --------------------------------------------------------------------------- def test_get_listare_neautentificat_401(client_prod): """Fara cheie API cu require_api_key=True, GET /v1/prezentari -> 401.""" resp = client_prod.get("/v1/prezentari") assert resp.status_code == 401, ( f"Asteptat 401 fara cheie API in mod prod, primit {resp.status_code}." ) def test_get_listare_cheie_invalida_401(client_prod): """Cheie API invalida (prezenta dar gresita) -> 401, indiferent de flag.""" resp = client_prod.get("/v1/prezentari", headers={"X-API-Key": "rfak_invalida_xxx"}) assert resp.status_code == 401 # --------------------------------------------------------------------------- # Test 3: 404-before-leak pe detaliu id strain # --------------------------------------------------------------------------- def test_get_detaliu_scoped_404(client): """GET /v1/prezentari/{id} pe un id al altui cont -> 404 (fara leak). Acelasi 404 pentru id inexistent = nu confirmam existenta. """ acct_a, key_a = _create_account_with_key("DetApiA") acct_b, key_b = _create_account_with_key("DetApiB") sid_a = _insert_submission(acct_a, vin="WVWZZZ1JZXWDET100") # Cont B incearca sa acceseze submission-ul contului A resp = client.get(f"/v1/prezentari/{sid_a}", headers={"X-API-Key": key_b}) assert resp.status_code == 404, ( f"Asteptat 404, primit {resp.status_code}. " "Nu trebuie confirmata existenta unui submission al altui cont." ) # Id inexistent -> acelasi 404 resp2 = client.get("/v1/prezentari/999999", headers={"X-API-Key": key_b}) assert resp2.status_code == 404 def test_get_detaliu_neautentificat_401(client_prod): """GET /v1/prezentari/{id} fara cheie API in prod -> 401.""" resp = client_prod.get("/v1/prezentari/1") assert resp.status_code == 401 # --------------------------------------------------------------------------- # Test 4: contul implicit id=1 in dev (nu trebuie spart de scope) # --------------------------------------------------------------------------- def test_get_listare_cont_implicit_dev(client): """In dev (require_api_key=False), fara cheie -> cont implicit id=1. Contul 1 vede propriile submissions (NULL account_id = cont 1 prin account_scope_clause). NU trebuie sa vada submissions cu alt account_id. """ # Inserare submission pt cont id=1 (account_id NULL = legacy cont 1) from app.db import get_connection conn = get_connection() try: p = json.dumps({ "vin": "WVWZZZ1JZXWDEV001", "nr_inmatriculare": "B001DEV", "data_prestatie": "2026-06-18", "odometru_final": "10000", "prestatii": [{"cod_prestatie": "OE-1"}], }) conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " "VALUES (?, NULL, 'sent', ?)", ("dev-null-key-001", p), ) conn.commit() finally: conn.close() # Fara cheie in dev -> cont implicit 1, vede submission-ul cu account_id NULL resp = client.get("/v1/prezentari") assert resp.status_code == 200 vins = [s["prezentare"]["vin"] for s in resp.json()["submissions"] if s.get("prezentare")] assert "WVWZZZ1JZXWDEV001" in vins, "Contul implicit 1 trebuie sa vada submissions NULL" # --------------------------------------------------------------------------- # Test 5: izolare status filter nu sparge scope-ul # --------------------------------------------------------------------------- def test_get_listare_filtru_status_nu_sparge_scope(client): """Filtrul ?status= nu poate scoate randuri din alt cont.""" VIN_A = "WVWZZZ1JZXWSTA1AA" acct_a, key_a = _create_account_with_key("StatApiA") acct_b, key_b = _create_account_with_key("StatApiB") _insert_submission(acct_a, vin=VIN_A) # Cont B filtreaza dupa status 'sent' - nu trebuie sa vada VIN_A resp = client.get("/v1/prezentari?status=sent", headers={"X-API-Key": key_b}) assert resp.status_code == 200 vins = [s["prezentare"]["vin"] for s in resp.json()["submissions"] if s.get("prezentare")] assert VIN_A not in vins, ( "Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)." ) # ============================================================================ # PRD 5.17 — enforcement planuri: gate API (T4) + volum (T3) + kill-switch (T5) # ============================================================================ _PREZ_PLAN = { "vin": "WVWZZZ1KZAW900001", "nr_inmatriculare": "B900TST", "data_prestatie": "2026-06-15", "odometru_final": "50000", "prestatii": [{"cod_op_service": "OP-PLAN-T4", "denumire": "Test plan gate"}], } def _set_tier_acct(account_id: int, tier: str, trial_until=None) -> None: """Seteaza tier si trial_until pe un cont existent.""" from app.db import get_connection conn = get_connection() try: conn.execute( "UPDATE accounts SET tier=?, trial_until=? WHERE id=?", (tier, trial_until, account_id), ) conn.commit() finally: conn.close() def _insert_n_submissions(account_id: int, n: int) -> None: """Insereaza N submissions queued in luna curenta pentru cont.""" from datetime import datetime, timezone from app.db import get_connection conn = get_connection() try: now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") for i in range(n): conn.execute( "INSERT INTO submissions " "(idempotency_key, account_id, status, payload_json, created_at) " "VALUES (?, ?, 'queued', '{}', ?)", (f"plan-seed-{account_id}-{i}-{os.urandom(4).hex()}", account_id, now_str), ) conn.commit() finally: conn.close() # --------------------------------------------------------------------------- # T4: Gate API — free/standard NU pot accesa rutele de ingestie API # --------------------------------------------------------------------------- def test_free_fara_api_403(client): """Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari. T4 PRD 5.17: gate API refuza conturi cu api_access=False in PLANS. """ acct_id, key = _create_account_with_key("FreePlanGate") _set_tier_acct(acct_id, "free", trial_until=None) resp = client.post( "/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}, headers={"X-API-Key": key}, ) assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}: {resp.text}" detail = resp.json().get("detail", {}) assert detail.get("cod") == "PLAN_FARA_API", f"Cod eroare gresit: {detail}" def test_standard_fara_api_403(client): """Cont standard (non-default) → 403 PLAN_FARA_API pe POST /v1/prezentari. Standard are api_access=False, deci gate-ul API respinge la fel ca free. """ acct_id, key = _create_account_with_key("StdPlanGate") _set_tier_acct(acct_id, "standard", trial_until=None) resp = client.post( "/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}, headers={"X-API-Key": key}, ) assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}" assert resp.json().get("detail", {}).get("cod") == "PLAN_FARA_API" def test_pro_api_ok(client): """Cont pro (non-default) → 200 pe POST /v1/prezentari (trece gate-ul API).""" acct_id, key = _create_account_with_key("ProPlanGate") _set_tier_acct(acct_id, "pro", trial_until=None) resp = client.post( "/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}, headers={"X-API-Key": key}, ) assert resp.status_code == 200, ( f"Pro trebuie sa aiba acces API, primit: {resp.status_code}: {resp.text}" ) def test_trial_pro_api_ok(client): """Cont free cu trial Pro activ → 200 pe POST /v1/prezentari. effective_tier() intoarce 'pro' cand trial_until > now, deci gate-ul permite. """ from datetime import datetime, timedelta, timezone acct_id, key = _create_account_with_key("TrialPlanGate") trial_until = (datetime.now(timezone.utc) + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S") _set_tier_acct(acct_id, "free", trial_until=trial_until) resp = client.post( "/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}, headers={"X-API-Key": key}, ) assert resp.status_code == 200, ( f"Trial Pro trebuie sa treaca gate-ul API, primit: {resp.status_code}" ) def test_dry_run_valideaza_ramane_permis(client): """POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free. Decizie design (PRD 5.17): valideaza nu face enqueue si nu consuma cota, deci NU e protejat de gate-ul API — integratorii pot testa fara upgrade. """ acct_id, key = _create_account_with_key("FreeValideazaTest") _set_tier_acct(acct_id, "free", trial_until=None) resp = client.post( "/v1/prezentari/valideaza", json={"prezentari": [_PREZ_PLAN]}, headers={"X-API-Key": key}, ) assert resp.status_code == 200, ( f"valideaza trebuie permis pe plan free (fara gate API), " f"primit {resp.status_code}: {resp.text}" ) # --------------------------------------------------------------------------- # T3: Enforce volum — limita 60/luna pe planul Gratuit # Dev account (id=1) are bypass la gate-ul API, dar NU la verificarea de volum # --------------------------------------------------------------------------- def test_free_peste_60_respins_api(client): """Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA. Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API (T4), dar NU la verificarea de volum (T3). La 60/60, urmatoarea cerere trebuie respinsa 422. """ _set_tier_acct(1, "free", trial_until=None) _insert_n_submissions(1, 60) # A 61-a cerere (fara cheie = cont 1 in dev mode, bypass gate API, dar volumul e plin) resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}) assert resp.status_code == 422, ( f"Asteptat 422 la depasire volum, primit {resp.status_code}: {resp.text}" ) detail = resp.json().get("detail", {}) assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Cod gresit: {detail}" def test_eroare_3_niveluri_plan_limita(client): """Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix. Pattern standard (PRD 5.17): eroare structurata pe 3 niveluri — nu 500, nu catch-all. """ _set_tier_acct(1, "free", trial_until=None) _insert_n_submissions(1, 60) resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}) assert resp.status_code == 422 detail = resp.json().get("detail", {}) assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Camp 'cod' gresit: {detail}" assert detail.get("problema"), f"Camp 'problema' lipsa: {detail}" assert detail.get("cauza"), f"Camp 'cauza' lipsa: {detail}" assert detail.get("fix"), f"Camp 'fix' lipsa: {detail}" def test_pro_si_trial_nelimitat(client): """Pro si trial Pro nu sunt blocati de volum indiferent de numarul de submissions. PLANS['pro']['monthly_limit'] is None -> nelimitat; la fel trial Pro activ. """ from datetime import datetime, timedelta, timezone # Cont pro cu 70 submissions (peste limita free de 60) pro_id, pro_key = _create_account_with_key("ProVolumTest") _set_tier_acct(pro_id, "pro", trial_until=None) _insert_n_submissions(pro_id, 70) # Cont trial Pro cu 70 submissions trial_id, trial_key = _create_account_with_key("TrialVolumTest") trial_until = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S") _set_tier_acct(trial_id, "free", trial_until=trial_until) _insert_n_submissions(trial_id, 70) # Pro: trebuie 200 (nu 422 de volum) r_pro = client.post( "/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}, headers={"X-API-Key": pro_key}, ) assert r_pro.status_code == 200, f"Pro trebuie sa fie nelimitat, primit: {r_pro.text}" # Trial Pro: trebuie 200 (nu 422 de volum) prez2 = dict(_PREZ_PLAN, vin="WVWZZZ1KZAW900002", nr_inmatriculare="B900TS2") r_trial = client.post( "/v1/prezentari", json={"prezentari": [prez2]}, headers={"X-API-Key": trial_key}, ) assert r_trial.status_code == 200, f"Trial Pro trebuie sa fie nelimitat, primit: {r_trial.text}" def test_retry_idempotent_nu_consuma_cota(client): """Un retry idempotent al aceleiasi prestatii nu consuma cota de doua ori. Invariant idempotenta (arhitectura): monthly_usage creste O SINGURA DATA per submission unic. Retryurile (acelasi idempotency_key) sunt dedup-ate, deci usage ramine la 1 dupa doua trimiteri identice. Folsim cod_prestatie "OE-1" (in nomenclatorul seed) ca sa obtinem status 'queued' (statusuri "needs_mapping" nu se numara in monthly_usage). """ from datetime import datetime, timezone from app.plans import monthly_usage from app.db import get_connection _set_tier_acct(1, "free", trial_until=None) # cod_prestatie "OE-1" e in nomenclatorul seed -> submission va fi 'queued' (contat in usage) prez_unic = { "vin": "WVWZZZ1KZAW901001", "nr_inmatriculare": "B901TST", "data_prestatie": "2026-06-15", "odometru_final": "50000", "prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}], } # Prima trimitere — submission noua r1 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]}) assert r1.status_code == 200, f"Prima trimitere trebuie sa treaca: {r1.text}" assert not r1.json()["results"][0].get("deduped"), "Prima trimitere nu trebuia sa fie deduped" conn = get_connection() try: usage_1 = monthly_usage(conn, 1, datetime.now(timezone.utc)) finally: conn.close() assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}" # Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou) r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]}) assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}" assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped" conn = get_connection() try: usage_2 = monthly_usage(conn, 1, datetime.now(timezone.utc)) finally: conn.close() assert usage_2 == 1, ( f"Retry nu trebuia sa creasca usage: inainte={usage_1}, dupa retry={usage_2}" ) def test_dev_id1_neblocat(client): """Dev account (id=1) in dev mode (require_api_key=False) nu e blocat de gate-ul API. Bypass explicit in require_api_access: require_api_key=False + account_id==DEFAULT_ACCOUNT_ID -> skip gate, indiferent de tier. DB proaspata (0 submissions -> fara blocare volum). """ _set_tier_acct(1, "free", trial_until=None) resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}) assert resp.status_code == 200, ( f"Dev id=1 nu trebuie blocat de gate-ul API in dev mode, " f"primit {resp.status_code}: {resp.text}" ) def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch): """Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan. T5 PRD 5.17: flag de operare pentru debugging sau rollback rapid fara revert de cod. Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit. """ from app.config import get_settings monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false") get_settings.cache_clear() # Cont dev (id=1) pe free la 60/60 (normalmente respins) _set_tier_acct(1, "free", trial_until=None) _insert_n_submissions(1, 60) resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]}) assert resp.status_code == 200, ( f"Cu enforce_plans=False, enforcement trebuia sarat. Primit: {resp.status_code}" ) get_settings.cache_clear() # curata cache-ul dupa test