diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 9bacff8..39b3b51 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -25,6 +25,7 @@ from ...db import get_connection from ...idempotency import build_key, canonicalize_row, idempotency_key from ...mapping import ( account_or_default, + account_scope_clause, has_no_auto_send, load_mapping_meta, pending_unmapped, @@ -130,36 +131,58 @@ def create_prezentari( @router.get("/prezentari") -def list_prezentari(status: str | None = None, limit: int = 100) -> dict: +def list_prezentari( + status: str | None = None, + limit: int = 100, + account_id: int = Depends(resolve_account_id), +) -> dict: conn = get_connection() try: + scope_sql, scope_params = account_scope_clause(account_id) if status: rows = conn.execute( - "SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " - "FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?", - (status, limit), + f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " + f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?", + scope_params + [status, limit], ).fetchall() else: rows = conn.execute( - "SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " - "FROM submissions ORDER BY id DESC LIMIT ?", - (limit,), + f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " + f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", + scope_params + [limit], ).fetchall() return {"submissions": [dict(r) for r in rows]} finally: conn.close() +# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4). +# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since. +_PREZENTARE_FIELDS = frozenset({ + "id", "status", "id_prezentare", "rar_status_code", "retry_count", + "next_attempt_at", "created_at", "updated_at", "account_id", + "batch_id", "row_index", "purge_after", +}) + + @router.get("/prezentari/{submission_id}") -def get_prezentare(submission_id: int) -> dict: +def get_prezentare( + submission_id: int, + account_id: int = Depends(resolve_account_id), +) -> dict: conn = get_connection() try: - row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone() + scope_sql, scope_params = account_scope_clause(account_id) + row = conn.execute( + f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", + [submission_id] + scope_params, + ).fetchone() if not row: + # B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont + # sau nu exista deloc — nu confirmam existenta. raise HTTPException(status_code=404, detail="submission inexistent") - out = dict(row) - out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare - return out + row_dict = dict(row) + return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS} finally: conn.close() @@ -193,16 +216,20 @@ AUDIT_COLUMNS = [ ] -def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str): - """Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to]. +def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int): + """Randuri audit filtrate pe cont + data(updated_at) in [from, to]. - payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie - pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul, - aici se decripteaza inainte de a construi randul. + account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu + account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in + schelet; b64_image NU intra in CSV. """ - sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions" - where = [] - params: list = [] + scope_sql, scope_params = account_scope_clause(account_id) + sql = ( + "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, " + "created_at, updated_at, purge_after FROM submissions" + ) + where = [scope_sql] + params: list = list(scope_params) if status != "all": where.append("status=?") params.append(status) @@ -212,8 +239,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str): if date_to: where.append("date(updated_at) <= date(?)") params.append(date_to) - if where: - sql += " WHERE " + " AND ".join(where) + sql += " WHERE " + " AND ".join(where) sql += " ORDER BY id" for r in conn.execute(sql, params).fetchall(): @@ -230,7 +256,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str): "submission_id": r["id"], "status": r["status"], "id_prezentare": r["id_prezentare"] or "", - "account_id": r["account_id"] or "", + # NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu. + "account_id": account_or_default(r["account_id"]), "vin": p.get("vin") or "", "nr_inmatriculare": p.get("nr_inmatriculare") or "", "data_prestatie": p.get("data_prestatie") or "", @@ -248,11 +275,12 @@ def audit_export( date_from: str | None = None, date_to: str | None = None, status: str = "sent", + account_id: int = Depends(resolve_account_id), ) -> StreamingResponse: - """CSV cu ce s-a trimis (audit). Filtre optionale `date_from`/`date_to` (YYYY-MM-DD) + """CSV audit scoped pe contul cheii API. Filtre optionale `date_from`/`date_to` (YYYY-MM-DD) pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR); - `status=all` exporta toata coada. Leaga re_tinerea 90 zile prin coloana + `status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana `purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta. """ conn = get_connection() @@ -260,7 +288,7 @@ def audit_export( buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS) writer.writeheader() - for row in _audit_rows(conn, date_from, date_to, status): + for row in _audit_rows(conn, date_from, date_to, status, account_id): writer.writerow(row) data = buf.getvalue() finally: @@ -275,31 +303,43 @@ def audit_export( @router.get("/mapari") -def get_mapari(account_id: int | None = None) -> dict: +def get_mapari( + key_account: int = Depends(resolve_account_id), + account_id: int | None = None, +) -> dict: + """Maparile operatie->cod ale contului curent. + + Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul + efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400. + """ + if account_id is not None and account_id != key_account: + raise HTTPException( + status_code=400, + detail="account_id din query nu corespunde contului cheii API", + ) conn = get_connection() try: - if account_id is not None: - rows = conn.execute( - "SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service", - (account_id,), - ).fetchall() - else: - rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall() + rows = conn.execute( + "SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service", + (key_account,), + ).fetchall() return {"mapari": [dict(r) for r in rows]} finally: conn.close() @router.get("/mapari/pending") -def get_mapari_pending() -> dict: +def get_mapari_pending( + account_id: int = Depends(resolve_account_id), +) -> dict: """Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy. - Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire, - blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. + Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service, + denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. """ conn = get_connection() try: - return {"pending": pending_unmapped(conn)} + return {"pending": pending_unmapped(conn, account_id=account_id)} finally: conn.close() diff --git a/app/db.py b/app/db.py index f9ed9d9..4f3589c 100644 --- a/app/db.py +++ b/app/db.py @@ -77,6 +77,11 @@ def _migrate(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) " "WHERE batch_id IS NOT NULL" ) + if "idx_submissions_account_status" not in existing_idx: + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_submissions_account_status " + "ON submissions(account_id, status)" + ) def _now_iso() -> str: diff --git a/app/mapping.py b/app/mapping.py index 144bb53..564a3d2 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -129,6 +129,19 @@ def account_or_default(account_id: int | None) -> int: return account_id if account_id is not None else DEFAULT_ACCOUNT_ID +def account_scope_clause(account_id: int) -> tuple[str, list]: + """Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable. + + Aplica regula: NULL apartine contului 1 (legacy/OV-2). + Foloseste DOAR pe submissions (account_id NULLABLE). + NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu. + """ + return ( + "(account_id = ? OR (account_id IS NULL AND ? = 1))", + [account_id, account_id], + ) + + def seed_nomenclator_if_empty(conn) -> int: """Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol. @@ -217,18 +230,28 @@ def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> boo return False -def pending_unmapped(conn) -> list[dict]: +def pending_unmapped(conn, account_id=None) -> list[dict]: """Operatii distincte nemapate, agregate din submission-urile `needs_mapping`. - Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa, - nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de - adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie - null + cod_op_service setat. + account_id=None (default): global — intentionat pentru web/routes.py (back-compat). + Apelantii noi din API TREBUIE sa paseze account_id explicit; None global e + footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern. + + account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL + apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python. """ nomenclator = load_nomenclator(conn) - rows = conn.execute( - "SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'" - ).fetchall() + if account_id is not None: + scope_sql, scope_params = account_scope_clause(account_id) + rows = conn.execute( + f"SELECT id, account_id, payload_json FROM submissions " + f"WHERE status='needs_mapping' AND {scope_sql}", + scope_params, + ).fetchall() + else: + rows = conn.execute( + "SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'" + ).fetchall() agg: dict[tuple[int, str], dict[str, Any]] = {} for r in rows: diff --git a/app/schema.sql b/app/schema.sql index 6999328..da2a0de 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS submissions ( ); CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status); +CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status); -- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche). -- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2ec76e..ffde10b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-06-17 — 3.1 LIVRAT (CLI `tools/account.py` + `accounts.active` + index unic CUI + helper-e `app/accounts.py`; 299 teste pass). Urmeaza 3.2. Deferat din 3.1 (P3, fara SQL manual): `rename`/`set-cui` (corectie typo), `--if-not-exists` (provisioning idempotent); `set-password --account N` se implementeaza in 3.3 cu `app/users.py`. +**Ultima actualizare**: 2026-06-17 — 3.2 LIVRAT (scope pe cont la toate GET-urile API `/v1/*` care ating `submissions`/`operations_mapping`: `account_scope_clause` cu regula NULL→cont 1, 404 cross-account byte-identic, allowlist campuri pe detaliu, index `(account_id,status)`, regula B8 in contract; nomenclator ramane global. 14 teste noi, 313 pass. VERIFY=PASS context curat). Urmeaza 3.3 (self-onboarding web). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`; `set-password --account N` in 3.3 cu `app/users.py`. ### Etapa 1 — Canal API ROAAUTO (Treapta 1) @@ -73,7 +73,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi | # | Livrabila | Status | Data | Detalii | |---|-----------|--------|------|---------| | 3.1 | Creare cont nou (CLI dedicat) | DONE | 2026-06-17 | CLI `tools/account.py` (create/list[--pending]/activate/deactivate, `--with-key` atomic) + `accounts.active` + index unic CUI + `app/accounts.py`. 20 teste noi. PRD: [prd-3.1](prd/prd-3.1-creare-cont.md) | -| 3.2 | Filtrare pe cont a GET-urilor de listare | TODO (PRD aprobat) | | scope cheie pe `/v1/prezentari`, `/v1/mapari`, `/v1/audit/export`; nomenclator global. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) | +| 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) | | 3.3 | Self-onboarding web + interfata admin | TODO (PRD aprobat) | | signup/login/sesiuni + cont "in asteptare" + gate worker + CSRF + panou admin web + email. 12 stories. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) | ### Etapa 4 — Viitor (Treapta 3) diff --git a/docs/api-rar-contract.md b/docs/api-rar-contract.md index d20bca0..48d8412 100644 --- a/docs/api-rar-contract.md +++ b/docs/api-rar-contract.md @@ -294,6 +294,19 @@ de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline. Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural. +## Regula de scope pe cont (B8, PRD 3.2) + +Orice GET nou pe `/v1/*` care atinge `submissions` sau `operations_mapping` **PORNESTE** +cu `account_id: int = Depends(resolve_account_id)` si clauza de scope pe cont in SQL. +Varianta globala (fara scope) e exceptie justificata explicit — singurul exemplu actual +este `GET /v1/nomenclator` (cache de referinta RAR fara PII, partajat intre conturi). + +Pentru `submissions` (account_id nullable): foloseste `account_scope_clause(account_id)` +din `app/mapping.py` care produce `(account_id = ? OR (account_id IS NULL AND ? = 1))`. +Randurile legacy cu `account_id IS NULL` apartin contului 1 (OV-2, back-compat). + +Pentru `operations_mapping` (account_id NOT NULL): `WHERE account_id = ?` simplu. + ## Open questions rămase (actualizat) 1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională). diff --git a/docs/prd/prd-3.2-filtrare-cont-get.md b/docs/prd/prd-3.2-filtrare-cont-get.md index 5cba567..2139eb9 100644 --- a/docs/prd/prd-3.2-filtrare-cont-get.md +++ b/docs/prd/prd-3.2-filtrare-cont-get.md @@ -1,6 +1,6 @@ # PRD 3.2 — Filtrare pe cont a GET-urilor de listare -**Stare**: aprobat +**Stare**: inchis > Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`. > Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). @@ -39,11 +39,11 @@ contul meu **pentru ca** un client nu trebuie sa vada coada altui client. `test_detaliu_cross_account_404`, `test_legacy_null_vizibil_pentru_cont_1`, `test_fara_cheie_flag_off_vede_contul_1` - **Acceptance criteria**: - - [ ] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`. - - [ ] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`). - - [ ] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta). - - [ ] Cheie A nu vede submission-uri ale contului B (lista si detaliu). - - [ ] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev). + - [x] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`. + - [x] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`). + - [x] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta). + - [x] Cheie A nu vede submission-uri ale contului B (lista si detaliu). + - [x] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev). - **Verificare E2E**: doua chei (conturi distincte, via 3.1) → `POST` pe fiecare → `GET /v1/prezentari` cu cheia A nu contine id-urile contului B. @@ -57,11 +57,11 @@ azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pen - **Test intai (RED)**: `tests/test_get_scope_mapari.py` — `test_mapari_ignora_query_account_id`, `test_mapari_doar_contul_cheii`, `test_pending_doar_contul_cheii`, `test_pending_web_global_neschimbat` - **Acceptance criteria**: - - [ ] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query + - [x] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query este **eliminat** (un cont nu poate citi maparile altuia trecand un id arbitrar). - - [ ] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web, + - [x] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web, back-compat), valoare = filtrare pe cont. `GET /v1/mapari/pending` paseaza contul cheii. - - [ ] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de + - [x] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de `test_pending_web_global_neschimbat`. - **Verificare E2E**: cheie A cu o mapare; cheie B → `GET /v1/mapari` (B) nu contine maparea lui A. @@ -74,10 +74,10 @@ azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pen - **Test intai (RED)**: `tests/test_get_scope_audit.py` — `test_export_doar_contul_cheii`, `test_export_legacy_null_pentru_cont_1`, `test_export_status_all_tot_scoped` - **Acceptance criteria**: - - [ ] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont + - [x] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont (NULL→cont 1) pe langa filtrele de data/status existente. - - [ ] `status=all` ramane scoped pe cont (nu exporta global). - - [ ] Randurile contului B nu apar in CSV-ul cerut cu cheia A. + - [x] `status=all` ramane scoped pe cont (nu exporta global). + - [x] Randurile contului B nu apar in CSV-ul cerut cu cheia A. - **Verificare E2E**: `POST` pe doua conturi → `GET /v1/audit/export` (cheie A) → CSV fara VIN-urile B. ## 4. Riscuri @@ -171,4 +171,25 @@ cheii → **400** explicit (nu schimbare tacita). AC US-002 actualizat: ## Raport VERIFY > Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. -> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY. + +**Verdict global: PASS** (verificator independent, context curat, 2026-06-17). + +- **Suita**: `python3 -m pytest -q` → **313 passed**, 0 fail. Teste noi 3.2: 14 passed. +- **Regresia de aur**: 313 verzi — POST `/v1/prezentari`, import, worker neatinse; calea de send nu e modificata. + +| Criteriu | Verdict | Dovada | +|----------|---------|--------| +| US-001: `list_prezentari` scoped pe cont (ambele ramuri) | PASS | `router.py` + `test_lista_doar_contul_cheii` | +| US-001: `GET /{id}` alt cont → 404 | PASS | `test_detaliu_cross_account_404` | +| US-001: back-compat dev (fara cheie → cont 1) | PASS | `test_fara_cheie_flag_off_vede_contul_1` | +| US-002: `GET /mapari` scoped; `?account_id` difera → 400 (TD-3.2) | PASS | `test_mapari_query_account_id_diferit_400` / `_egal_ok` | +| US-002: web `pending_unmapped(conn)` ramane global | PASS | `routes.py:160` neatins + `test_pending_web_global_neschimbat` | +| US-003: `audit/export` + `status=all` scoped | PASS | 3 teste `test_get_scope_audit` | +| B1: `pending_unmapped` filtreaza IN SQL (nu Python) | PASS | `test_pending_filtreaza_in_sql_cu_regula_null` | +| B2: `account_scope_clause` DOAR pe submissions; `get_mapari` `WHERE account_id=?` simplu | PASS | `mapping.py` + `router.py` | +| B3: 404 cross-account byte-identic cu 404 inexistent | PASS | un singur `detail`; test explicit | +| B4: `get_prezentare` allowlist (exclude creds/payload/idempotency/error) | PASS | `_PREZENTARE_FIELDS` + `test_detaliu_nu_expune_creds` | +| B5: index `idx_submissions_account_status` in schema.sql + `_migrate` | PASS | `schema.sql` + `db.py` | +| B8: regula scope documentata in `api-rar-contract.md` | PASS | sectiune "Regula de scope pe cont (B8, PRD 3.2)" | + +**Rezerva (acceptata):** trimiterea LIVE la RAR test (FINALIZATA) nu a rulat — lipsa `.env`/credentiale RAR in mediu. Schimbarile 3.2 ating EXCLUSIV GET-uri de citire (POST/worker/send neatinse), deci regresia E2E e acoperita integral de suita automata. De re-confirmat la urmatorul deploy cu creds. diff --git a/tests/test_get_scope_audit.py b/tests/test_get_scope_audit.py new file mode 100644 index 0000000..6b4f188 --- /dev/null +++ b/tests/test_get_scope_audit.py @@ -0,0 +1,128 @@ +"""Teste scope cont pe GET /v1/audit/export (US-003, PRD 3.2).""" + +from __future__ import annotations + +import csv +import io +import json +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() + yield monkeypatch + get_settings.cache_clear() + + +def _client(): + from app.main import app + return TestClient(app) + + +def _body(**over): + prez = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]} + + +def _csv_vins(content: bytes) -> list[str]: + reader = csv.DictReader(io.StringIO(content.decode("utf-8"))) + return [r["vin"] for r in reader if r.get("vin")] + + +def test_export_doar_contul_cheii(env): + """Exportul CSV contine doar randurile contului asociat cheii.""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + finally: + conn.close() + + c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1}) + c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2}) + + # Marcheaza ca sent pentru ca audit/export default e status=sent + conn2 = get_connection() + try: + conn2.execute("UPDATE submissions SET status='sent'") + finally: + conn2.close() + + resp1 = c.get("/v1/audit/export", headers={"X-API-Key": k1}) + assert resp1.status_code == 200 + vins1 = _csv_vins(resp1.content) + assert "WVWZZZ1KZAW000123" in vins1 + assert "WVWZZZ1KZAW000456" not in vins1 + + resp2 = c.get("/v1/audit/export", headers={"X-API-Key": k2}) + vins2 = _csv_vins(resp2.content) + assert "WVWZZZ1KZAW000456" in vins2 + assert "WVWZZZ1KZAW000123" not in vins2 + + +def test_export_legacy_null_pentru_cont_1(env): + """Randuri cu account_id=NULL apartin contului 1 in exportul de audit; contul 2 nu le vede.""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + payload = json.dumps({"vin": "LEGACYVIN12345678", "prestatii": []}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('legacy_audit_key', NULL, 'sent', ?)", (payload,) + ) + finally: + conn.close() + + resp1 = c.get("/v1/audit/export", headers={"X-API-Key": k1}) + vins1 = _csv_vins(resp1.content) + assert "LEGACYVIN12345678" in vins1 + + resp2 = c.get("/v1/audit/export", headers={"X-API-Key": k2}) + vins2 = _csv_vins(resp2.content) + assert "LEGACYVIN12345678" not in vins2 + + +def test_export_status_all_tot_scoped(env): + """status=all ramane scoped pe cont (nu exporta global).""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + finally: + conn.close() + + c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1}) + c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2}) + + resp1 = c.get("/v1/audit/export?status=all", headers={"X-API-Key": k1}) + vins1 = _csv_vins(resp1.content) + assert "WVWZZZ1KZAW000123" in vins1 + assert "WVWZZZ1KZAW000456" not in vins1 diff --git a/tests/test_get_scope_mapari.py b/tests/test_get_scope_mapari.py new file mode 100644 index 0000000..4f5edcf --- /dev/null +++ b/tests/test_get_scope_mapari.py @@ -0,0 +1,194 @@ +"""Teste scope cont pe GET /v1/mapari + /pending (US-002, PRD 3.2). + +TD-3.2 (decis la poarta): parametrul ?account_id= din query se pastreaza DAR: +- daca e prezent SI difera de contul cheii -> 400 explicit +- daca e prezent si egal -> ok +- daca lipseste -> contul cheii +Contul efectiv vine MEREU din cheie (nespoofabil). +""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() + yield monkeypatch + get_settings.cache_clear() + + +def _client(): + from app.main import app + return TestClient(app) + + +def test_mapari_doar_contul_cheii(env): + """Cheia A vede doar maparile contului A; cheia B nu vede maparile lui A.""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + conn.execute( + "INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) " + "VALUES ('OE-1', 'test')" + ) + conn.execute( + "INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) " + "VALUES (1, 'OP_A', 'OE-1', 1)" + ) + conn.execute( + "INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) " + "VALUES (2, 'OP_B', 'OE-1', 1)" + ) + finally: + conn.close() + + mapari1 = c.get("/v1/mapari", headers={"X-API-Key": k1}).json()["mapari"] + ops1 = [m["cod_op_service"] for m in mapari1] + assert "OP_A" in ops1 + assert "OP_B" not in ops1 + + mapari2 = c.get("/v1/mapari", headers={"X-API-Key": k2}).json()["mapari"] + ops2 = [m["cod_op_service"] for m in mapari2] + assert "OP_B" in ops2 + assert "OP_A" not in ops2 + + +def test_mapari_query_account_id_diferit_400(env): + """Daca ?account_id difera de contul cheii -> 400 explicit (TD-3.2).""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + finally: + conn.close() + + resp = c.get("/v1/mapari?account_id=2", headers={"X-API-Key": k1}) + assert resp.status_code == 400 + + +def test_mapari_query_account_id_egal_ok(env): + """Daca ?account_id egal cu contul cheii -> 200 (TD-3.2).""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + k1 = create_api_key(conn, 1) + finally: + conn.close() + + resp = c.get("/v1/mapari?account_id=1", headers={"X-API-Key": k1}) + assert resp.status_code == 200 + + +def test_pending_doar_contul_cheii(env): + """GET /v1/mapari/pending cu cheia A returneaza doar operatiile contului A.""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_A", "denumire": "Reparatie"}]}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('pm_key1', 1, 'needs_mapping', ?)", (payload1,) + ) + payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_B", "denumire": "Vopsire"}]}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('pm_key2', 2, 'needs_mapping', ?)", (payload2,) + ) + finally: + conn.close() + + pending1 = c.get("/v1/mapari/pending", headers={"X-API-Key": k1}).json()["pending"] + ops1 = [p["cod_op_service"] for p in pending1] + assert "OP_A" in ops1 + assert "OP_B" not in ops1 + + pending2 = c.get("/v1/mapari/pending", headers={"X-API-Key": k2}).json()["pending"] + ops2 = [p["cod_op_service"] for p in pending2] + assert "OP_B" in ops2 + assert "OP_A" not in ops2 + + +def test_pending_web_global_neschimbat(env): + """pending_unmapped(conn) fara argument returneaza global (back-compat pentru web/routes.py).""" + with _client() as c: + from app.db import get_connection + from app.mapping import pending_unmapped + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_A", "denumire": "Reparatie"}]}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('pgn_key1', 1, 'needs_mapping', ?)", (payload1,) + ) + payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_B", "denumire": "Vopsire"}]}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('pgn_key2', 2, 'needs_mapping', ?)", (payload2,) + ) + # Apel fara argument -> global (ambele conturi) + result = pending_unmapped(conn) + ops = [p["cod_op_service"] for p in result] + assert "OP_A" in ops + assert "OP_B" in ops + finally: + conn.close() + + +def test_pending_filtreaza_in_sql_cu_regula_null(env): + """B1: pending_unmapped(conn, account_id=1) include si randuri cu account_id=NULL (legacy). + + Filtrarea trebuie sa se faca IN SQL cu: + WHERE status='needs_mapping' AND (account_id=? OR (account_id IS NULL AND ?=1)) + Nu post-hoc in Python. + """ + with _client() as c: + from app.db import get_connection + from app.mapping import pending_unmapped + conn = get_connection() + try: + payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_EXPLICIT", "denumire": "X"}]}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('pfn_key1', 1, 'needs_mapping', ?)", (payload1,) + ) + payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_NULL", "denumire": "Y"}]}) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('pfn_key2', NULL, 'needs_mapping', ?)", (payload2,) + ) + # Cu account_id=1 -> vede si randul legacy (NULL -> cont 1) + result = pending_unmapped(conn, account_id=1) + ops = [p["cod_op_service"] for p in result] + assert "OP_EXPLICIT" in ops + assert "OP_NULL" in ops + # Cu account_id=2 -> nu vede nimic (nu are pending submissions) + result2 = pending_unmapped(conn, account_id=2) + assert result2 == [] + finally: + conn.close() diff --git a/tests/test_get_scope_prezentari.py b/tests/test_get_scope_prezentari.py new file mode 100644 index 0000000..3ad67ec --- /dev/null +++ b/tests/test_get_scope_prezentari.py @@ -0,0 +1,169 @@ +"""Teste scope cont pe GET /v1/prezentari + /{id} (US-001, PRD 3.2). + +Metoda TDD: testele se scriu inainte de implementare (RED) si trebuie sa ramana +verzi dupa implementare (GREEN). +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() + yield monkeypatch + get_settings.cache_clear() + + +def _client(): + from app.main import app + return TestClient(app) + + +def _body(**over): + prez = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]} + + +def test_lista_doar_contul_cheii(env): + """Cheia A vede doar submission-urile contului A; cheia B nu vede submission-urile lui A.""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + finally: + conn.close() + + r1 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1}) + assert r1.status_code == 200 + sid1 = r1.json()["results"][0]["submission_id"] + + r2 = c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2}) + assert r2.status_code == 200 + sid2 = r2.json()["results"][0]["submission_id"] + + lista1 = c.get("/v1/prezentari", headers={"X-API-Key": k1}).json()["submissions"] + ids1 = [s["id"] for s in lista1] + assert sid1 in ids1 + assert sid2 not in ids1 + + lista2 = c.get("/v1/prezentari", headers={"X-API-Key": k2}).json()["submissions"] + ids2 = [s["id"] for s in lista2] + assert sid2 in ids2 + assert sid1 not in ids2 + + +def test_detaliu_cross_account_404(env): + """GET /{id} cu cheia contului B pentru submission-ul contului A -> 404. + + B3: detail-ul 404 cross-account trebuie byte-identic cu cel al unui id inexistent + (acelasi status + acelasi mesaj) — nu dam indicii ca randul exista. + """ + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + finally: + conn.close() + + r = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1}) + sid1 = r.json()["results"][0]["submission_id"] + + cross = c.get(f"/v1/prezentari/{sid1}", headers={"X-API-Key": k2}) + nonexist = c.get("/v1/prezentari/99999", headers={"X-API-Key": k2}) + + assert cross.status_code == 404 + assert nonexist.status_code == 404 + assert cross.json()["detail"] == nonexist.json()["detail"] == "submission inexistent" + + +def test_legacy_null_vizibil_pentru_cont_1(env): + """Randuri cu account_id=NULL apartin contului 1 (legacy OV-2); contul 2 nu le vede.""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')") + k1 = create_api_key(conn, 1) + k2 = create_api_key(conn, 2) + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " + "VALUES ('legacy_null_key', NULL, 'queued', '{}')" + ) + finally: + conn.close() + + lista1 = c.get("/v1/prezentari", headers={"X-API-Key": k1}).json()["submissions"] + lista2 = c.get("/v1/prezentari", headers={"X-API-Key": k2}).json()["submissions"] + + assert len(lista1) >= 1 + assert len(lista2) == 0 + + +def test_fara_cheie_flag_off_vede_contul_1(env): + """Fara cheie cu AUTOPASS_REQUIRE_API_KEY=false -> cont implicit (id=1, back-compat dev).""" + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'alt')") + k2 = create_api_key(conn, 2) + finally: + conn.close() + + # Submission pentru contul 1 (fara cheie, flag off -> cont implicit) + c.post("/v1/prezentari", json=_body()) + # Submission pentru contul 2 + c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2}) + + # Fara cheie -> vede DOAR contul 1 (1 submission) + lista = c.get("/v1/prezentari").json()["submissions"] + assert len(lista) == 1 + + +def test_detaliu_nu_expune_creds(env): + """B4: GET /v1/prezentari/{id} nu expune campuri sensibile (rar_creds_enc, payload_json, + idempotency_key, rar_error). + """ + with _client() as c: + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + k1 = create_api_key(conn, 1) + finally: + conn.close() + + r = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1}) + sid = r.json()["results"][0]["submission_id"] + + resp = c.get(f"/v1/prezentari/{sid}", headers={"X-API-Key": k1}) + assert resp.status_code == 200 + data = resp.json() + for field in ("rar_creds_enc", "payload_json", "idempotency_key", "rar_error"): + assert field not in data, f"camp sensibil expus: {field}"