feat(api): scope pe cont la GET-urile de listare /v1/* (PRD 3.2)
Inchide scurgerea cross-account pe GET /v1/prezentari(/{id}),
/v1/mapari(/pending) si /v1/audit/export. Toate primesc
Depends(resolve_account_id) + account_scope_clause (regula NULL->cont 1,
OV-2). Nomenclatorul ramane global (referinta partajata, fara PII).
- B3: 404 cross-account byte-identic cu 404 inexistent (fara oracol enumerare)
- B4: get_prezentare cu allowlist de campuri (nu mai expune rar_creds_enc/
payload_json/idempotency_key/rar_error)
- B1: pending_unmapped filtreaza in SQL; default None = global doar pentru web
- B2: helper account_scope_clause (DRY, doar pe submissions nullable)
- B5: index idx_submissions_account_status
- B8: regula de scope documentata in api-rar-contract.md
- TD-3.2: ?account_id != contul cheii -> 400
14 teste noi (cross-account, legacy NULL, B3, B4); suita 313 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ from ...db import get_connection
|
|||||||
from ...idempotency import build_key, canonicalize_row, idempotency_key
|
from ...idempotency import build_key, canonicalize_row, idempotency_key
|
||||||
from ...mapping import (
|
from ...mapping import (
|
||||||
account_or_default,
|
account_or_default,
|
||||||
|
account_scope_clause,
|
||||||
has_no_auto_send,
|
has_no_auto_send,
|
||||||
load_mapping_meta,
|
load_mapping_meta,
|
||||||
pending_unmapped,
|
pending_unmapped,
|
||||||
@@ -130,36 +131,58 @@ def create_prezentari(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
if status:
|
if status:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||||
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?",
|
f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?",
|
||||||
(status, limit),
|
scope_params + [status, limit],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||||
"FROM submissions ORDER BY id DESC LIMIT ?",
|
f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
||||||
(limit,),
|
scope_params + [limit],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {"submissions": [dict(r) for r in rows]}
|
return {"submissions": [dict(r) for r in rows]}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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}")
|
@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()
|
conn = get_connection()
|
||||||
try:
|
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:
|
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")
|
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||||
out = dict(row)
|
row_dict = dict(row)
|
||||||
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
|
return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS}
|
||||||
return out
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -193,16 +216,20 @@ AUDIT_COLUMNS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int):
|
||||||
"""Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to].
|
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
||||||
|
|
||||||
payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie
|
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
||||||
pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul,
|
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
|
||||||
aici se decripteaza inainte de a construi randul.
|
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"
|
scope_sql, scope_params = account_scope_clause(account_id)
|
||||||
where = []
|
sql = (
|
||||||
params: list = []
|
"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":
|
if status != "all":
|
||||||
where.append("status=?")
|
where.append("status=?")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
@@ -212,7 +239,6 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
|||||||
if date_to:
|
if date_to:
|
||||||
where.append("date(updated_at) <= date(?)")
|
where.append("date(updated_at) <= date(?)")
|
||||||
params.append(date_to)
|
params.append(date_to)
|
||||||
if where:
|
|
||||||
sql += " WHERE " + " AND ".join(where)
|
sql += " WHERE " + " AND ".join(where)
|
||||||
sql += " ORDER BY id"
|
sql += " ORDER BY id"
|
||||||
|
|
||||||
@@ -230,7 +256,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
|||||||
"submission_id": r["id"],
|
"submission_id": r["id"],
|
||||||
"status": r["status"],
|
"status": r["status"],
|
||||||
"id_prezentare": r["id_prezentare"] or "",
|
"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 "",
|
"vin": p.get("vin") or "",
|
||||||
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
||||||
"data_prestatie": p.get("data_prestatie") or "",
|
"data_prestatie": p.get("data_prestatie") or "",
|
||||||
@@ -248,11 +275,12 @@ def audit_export(
|
|||||||
date_from: str | None = None,
|
date_from: str | None = None,
|
||||||
date_to: str | None = None,
|
date_to: str | None = None,
|
||||||
status: str = "sent",
|
status: str = "sent",
|
||||||
|
account_id: int = Depends(resolve_account_id),
|
||||||
) -> StreamingResponse:
|
) -> 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);
|
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.
|
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
|
||||||
"""
|
"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -260,7 +288,7 @@ def audit_export(
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
||||||
writer.writeheader()
|
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)
|
writer.writerow(row)
|
||||||
data = buf.getvalue()
|
data = buf.getvalue()
|
||||||
finally:
|
finally:
|
||||||
@@ -275,31 +303,43 @@ def audit_export(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/mapari")
|
@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()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
if account_id is not None:
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
||||||
(account_id,),
|
(key_account,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
|
||||||
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
|
|
||||||
return {"mapari": [dict(r) for r in rows]}
|
return {"mapari": [dict(r) for r in rows]}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mapari/pending")
|
@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.
|
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
|
||||||
|
|
||||||
Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire,
|
Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service,
|
||||||
blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
||||||
"""
|
"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
return {"pending": pending_unmapped(conn)}
|
return {"pending": pending_unmapped(conn, account_id=account_id)}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) "
|
"CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) "
|
||||||
"WHERE batch_id IS NOT NULL"
|
"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:
|
def _now_iso() -> str:
|
||||||
|
|||||||
@@ -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
|
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:
|
def seed_nomenclator_if_empty(conn) -> int:
|
||||||
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
||||||
|
|
||||||
@@ -217,15 +230,25 @@ def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> boo
|
|||||||
return False
|
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`.
|
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
|
||||||
|
|
||||||
Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa,
|
account_id=None (default): global — intentionat pentru web/routes.py (back-compat).
|
||||||
nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de
|
Apelantii noi din API TREBUIE sa paseze account_id explicit; None global e
|
||||||
adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie
|
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
|
||||||
null + cod_op_service setat.
|
|
||||||
|
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)
|
nomenclator = load_nomenclator(conn)
|
||||||
|
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(
|
rows = conn.execute(
|
||||||
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -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_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).
|
-- 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).
|
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
||||||
|
|||||||
@@ -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:
|
> 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".
|
> 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)
|
### 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 |
|
| # | 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.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) |
|
| 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)
|
### Etapa 4 — Viitor (Treapta 3)
|
||||||
|
|||||||
@@ -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
|
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.
|
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)
|
## Open questions rămase (actualizat)
|
||||||
|
|
||||||
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PRD 3.2 — Filtrare pe cont a GET-urilor de listare
|
# 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`.
|
> 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).
|
> 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_detaliu_cross_account_404`, `test_legacy_null_vizibil_pentru_cont_1`,
|
||||||
`test_fara_cheie_flag_off_vede_contul_1`
|
`test_fara_cheie_flag_off_vede_contul_1`
|
||||||
- **Acceptance criteria**:
|
- **Acceptance criteria**:
|
||||||
- [ ] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`.
|
- [x] 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`).
|
- [x] `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).
|
- [x] `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).
|
- [x] 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] `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`
|
- **Verificare E2E**: doua chei (conturi distincte, via 3.1) → `POST` pe fiecare → `GET /v1/prezentari`
|
||||||
cu cheia A nu contine id-urile contului B.
|
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 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`
|
`test_mapari_doar_contul_cheii`, `test_pending_doar_contul_cheii`, `test_pending_web_global_neschimbat`
|
||||||
- **Acceptance criteria**:
|
- **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).
|
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.
|
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`.
|
`test_pending_web_global_neschimbat`.
|
||||||
- **Verificare E2E**: cheie A cu o mapare; cheie B → `GET /v1/mapari` (B) nu contine maparea lui A.
|
- **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 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`
|
`test_export_legacy_null_pentru_cont_1`, `test_export_status_all_tot_scoped`
|
||||||
- **Acceptance criteria**:
|
- **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.
|
(NULL→cont 1) pe langa filtrele de data/status existente.
|
||||||
- [ ] `status=all` ramane scoped pe cont (nu exporta global).
|
- [x] `status=all` ramane scoped pe cont (nu exporta global).
|
||||||
- [ ] Randurile contului B nu apar in CSV-ul cerut cu cheia A.
|
- [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.
|
- **Verificare E2E**: `POST` pe doua conturi → `GET /v1/audit/export` (cheie A) → CSV fara VIN-urile B.
|
||||||
|
|
||||||
## 4. Riscuri
|
## 4. Riscuri
|
||||||
@@ -171,4 +171,25 @@ cheii → **400** explicit (nu schimbare tacita). AC US-002 actualizat:
|
|||||||
## Raport VERIFY
|
## Raport VERIFY
|
||||||
|
|
||||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
> 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.
|
||||||
|
|||||||
128
tests/test_get_scope_audit.py
Normal file
128
tests/test_get_scope_audit.py
Normal file
@@ -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
|
||||||
194
tests/test_get_scope_mapari.py
Normal file
194
tests/test_get_scope_mapari.py
Normal file
@@ -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()
|
||||||
169
tests/test_get_scope_prezentari.py
Normal file
169
tests/test_get_scope_prezentari.py
Normal file
@@ -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}"
|
||||||
Reference in New Issue
Block a user