From fbb2695336517922e932e31d67ff24815126c615 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 15 Jun 2026 20:16:16 +0000 Subject: [PATCH] feat(creds): livrare creds per-cerere la worker (criptat efemer + sesiuni per-cont) Plan sect.5: parola RAR vine per-cerere, stocata CRIPTATA in submission pana la primul login reusit pe cont, apoi stearsa; JWT 30h acopera restul. - app/crypto.py: Fernet, cheie din AUTOPASS_creds_key (nesetata -> efemera la runtime, creds nu supravietuiesc restartului). encrypt/decrypt_creds. - schema + migrare: submissions.rar_creds_enc (creds criptate). - ingestie: cripteaza rar_credentials, le lipeste de fiecare submission nou. Niciodata in clar in DB. - worker: AccountSessions (login per-cont cu creds decriptate, cache JWT in memorie, sterge creds-urile contului dupa primul login + refresh nomenclator). 401 creds gresite -> error fara retry; token expirat -> invalidare + requeue; fara creds (restart) -> requeue "indisponibile" (ROAAUTO re-trimite). claim_one intoarce account_id + creds_enc; recover_orphans filtrabil pe cont. - requirements: cryptography==46.0.5. Nota: refresh nomenclator e acum lazy la primul login per-cont (nu la pornire); seed-ul fallback acopera editorul offline. 10 teste noi (tests/test_creds_delivery.py). 95 pass total. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/router.py | 11 +- app/config.py | 6 + app/crypto.py | 57 ++++++++ app/db.py | 2 + app/schema.sql | 1 + app/worker/__main__.py | 173 +++++++++++++++++++----- docs/plans/plan.md | 9 +- requirements.txt | 2 + tests/test_creds_delivery.py | 252 +++++++++++++++++++++++++++++++++++ 9 files changed, 472 insertions(+), 41 deletions(-) create mode 100644 app/crypto.py create mode 100644 tests/test_creds_delivery.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index cbc943b..9493d2f 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from ...auth import resolve_account_id +from ...crypto import encrypt_creds from ...db import get_connection from ...idempotency import idempotency_key from ...mapping import ( @@ -49,6 +50,10 @@ def create_prezentari( pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. """ acct = account_or_default(account_id) + # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la + # primul login reusit pentru cont (worker le sterge atunci). Zero-storage at + # rest — niciodata in clar in DB/loguri (plan sect. 5). + creds_enc = encrypt_creds(req.rar_credentials.model_dump()) conn = get_connection() results: list[SubmissionResult] = [] try: @@ -90,9 +95,9 @@ def create_prezentari( status, rar_error = "queued", None cur = conn.execute( - "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) " - "VALUES (?, ?, ?, ?, ?)", - (key, acct, status, json.dumps(content, ensure_ascii=False), rar_error), + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " + "VALUES (?, ?, ?, ?, ?, ?)", + (key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc), ) results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) finally: diff --git a/app/config.py b/app/config.py index a575065..69c4361 100644 --- a/app/config.py +++ b/app/config.py @@ -28,6 +28,12 @@ class Settings(BaseSettings): # dar invalida da 401 indiferent de flag. require_api_key: bool = False + # Cheie Fernet pentru criptarea creds RAR efemere in submissions (zero-storage + # at rest). Nesetata -> cheie efemera la runtime (creds nu supravietuiesc + # restartului). In productie seteaz-o persistent. Genereaza: + # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + creds_key: str | None = None + # --- RAR --- rar_env: str = "test" # "test" | "prod" rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..8ea2b27 --- /dev/null +++ b/app/crypto.py @@ -0,0 +1,57 @@ +"""Criptare simetrica pentru credentialele RAR efemere (zero-storage at rest). + +Plan sect. 5: parola RAR vine per-cerere, se stocheaza CRIPTATA in submission +pana la primul login reusit pentru cont, apoi se sterge. JWT (30h) acopera +restul trimiterilor. Cheia traieste doar in `AUTOPASS_creds_key` (env), niciodata +in cod sau in DB. + +Daca `AUTOPASS_creds_key` nu e setat, generam o cheie EFEMERA la prima folosire: +creds criptate NU supravietuiesc unui restart (acceptabil — modelul e efemer, +ROAAUTO re-trimite). Pentru productie seteaza o cheie persistenta (vezi README/deploy). +""" + +from __future__ import annotations + +import json +from functools import lru_cache + +from cryptography.fernet import Fernet, InvalidToken + +from .config import get_settings + + +@lru_cache +def _fernet() -> Fernet: + key = get_settings().creds_key + if key: + return Fernet(key.encode() if isinstance(key, str) else key) + generated = Fernet.generate_key() + print( + "[crypto] AUTOPASS_creds_key nesetat — cheie efemera generata; creds " + "criptate NU supravietuiesc restartului worker-ului/API-ului", + flush=True, + ) + return Fernet(generated) + + +def reset_cache() -> None: + """Reseteaza cheia memorata (pentru teste care schimba env-ul).""" + _fernet.cache_clear() + + +def encrypt_creds(creds: dict) -> str: + """Cripteaza un dict de creds -> token Fernet (str). Compact, fara spatii.""" + blob = json.dumps(creds, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return _fernet().encrypt(blob).decode("ascii") + + +def decrypt_creds(token: str | None) -> dict | None: + """Decripteaza un token Fernet -> dict, sau None daca lipseste/cheie gresita/corupt.""" + if not token: + return None + try: + plain = _fernet().decrypt(token.encode("ascii")) + data = json.loads(plain.decode("utf-8")) + return data if isinstance(data, dict) else None + except (InvalidToken, ValueError, TypeError): + return None diff --git a/app/db.py b/app/db.py index dfebbb0..5b0e6ed 100644 --- a/app/db.py +++ b/app/db.py @@ -46,6 +46,8 @@ def _migrate(conn: sqlite3.Connection) -> None: cols = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()} if "next_attempt_at" not in cols: conn.execute("ALTER TABLE submissions ADD COLUMN next_attempt_at TEXT") + if "rar_creds_enc" not in cols: + conn.execute("ALTER TABLE submissions ADD COLUMN rar_creds_enc TEXT") def _now_iso() -> str: diff --git a/app/schema.sql b/app/schema.sql index f7ee3ed..107e8e9 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS submissions ( status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')), payload_json TEXT NOT NULL, -- TODO(P2): inlocuit cu BLOB criptat + rar_creds_enc TEXT, -- creds RAR criptate (Fernet), sterse dupa primul login reusit (plan sect.5) rar_status_code INTEGER, rar_error TEXT, id_prezentare INTEGER, -- data.id intors de RAR la succes diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 6864df7..4417a65 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -12,8 +12,14 @@ T2 implementat: - lease/timeout pe randuri 'sending' orfane. - re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h. -Ce NU e inca: livrarea creds per-cerere de la ROAAUTO (in schelet folosim creds ), -criptare PII at-rest (P2), b64Image mare pe disc (P2). +Creds per-cerere (plan sect. 5): fiecare submission poarta creds RAR CRIPTATE +(rar_creds_enc). Worker-ul face login per CONT cu acele creds, cache-uieste JWT +(30h) in memorie si STERGE creds-urile contului dupa primul login reusit. Token-ul +in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul +re-loghează la urmatorul submission care aduce creds proaspete (degradare acceptata). +Dev: `worker_use_test_creds` foloseste creds cand submission-ul nu are enc. + +Ce NU e inca: criptare PII payload at-rest (P2), b64Image mare pe disc (P2). Pornire: python -m app.worker """ @@ -29,8 +35,9 @@ from datetime import datetime, timedelta, timezone import httpx from ..config import Settings, get_settings, load_test_credentials +from ..crypto import decrypt_creds from ..db import get_connection, init_db, write_heartbeat -from ..mapping import upsert_nomenclator +from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator from ..payload import build_rar_payload from ..reconcile import match_finalizata from ..rar_client import RarAuthError, RarClient, RarError @@ -93,11 +100,15 @@ def requeue_with_backoff(conn, settings: Settings, submission_id: int, *, reason def claim_one(conn) -> dict | None: - """Claim atomic 'queued' -> 'sending', respectand next_attempt_at. Intoarce randul sau None.""" + """Claim atomic 'queued' -> 'sending', respectand next_attempt_at. Intoarce randul sau None. + + Randul include `account_id` si `rar_creds_enc` (creds RAR criptate) pentru + login-ul per-cont din `run`. + """ conn.execute("BEGIN IMMEDIATE") try: row = conn.execute( - "SELECT id, payload_json FROM submissions WHERE status='queued' " + "SELECT id, account_id, payload_json, rar_creds_enc FROM submissions WHERE status='queued' " "AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY id LIMIT 1", (_iso(_now()),), ).fetchone() @@ -110,7 +121,12 @@ def claim_one(conn) -> dict | None: (row["id"],), ) conn.execute("COMMIT") - return {"id": row["id"], "content": json.loads(row["payload_json"])} + return { + "id": row["id"], + "account_id": row["account_id"] if row["account_id"] is not None else DEFAULT_ACCOUNT_ID, + "creds_enc": row["rar_creds_enc"], + "content": json.loads(row["payload_json"]), + } except Exception: conn.execute("ROLLBACK") raise @@ -174,14 +190,25 @@ def _handle_transient(conn, settings: Settings, rar: RarClient, token: str, sid: return "requeued" -def recover_orphans(conn, settings: Settings, rar: RarClient, token: str) -> int: - """Randuri 'sending' mai vechi de lease (worker mort mid-POST). Reconciliaza; altfel requeue.""" +def recover_orphans(conn, settings: Settings, rar: RarClient, token: str, account_id: int | None = None) -> int: + """Randuri 'sending' mai vechi de lease (worker mort mid-POST). Reconciliaza; altfel requeue. + + `account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti + (compat teste / single-account). + """ cutoff = _iso(_now() - timedelta(seconds=settings.worker_sending_lease_s)) - orphans = conn.execute( - "SELECT id, payload_json FROM submissions WHERE status='sending' " - "AND (sending_since IS NULL OR sending_since <= ?)", - (cutoff,), - ).fetchall() + if account_id is not None: + orphans = conn.execute( + "SELECT id, payload_json FROM submissions WHERE status='sending' " + "AND (sending_since IS NULL OR sending_since <= ?) AND account_id=?", + (cutoff, account_id), + ).fetchall() + else: + orphans = conn.execute( + "SELECT id, payload_json FROM submissions WHERE status='sending' " + "AND (sending_since IS NULL OR sending_since <= ?)", + (cutoff,), + ).fetchall() recovered = 0 for row in orphans: sid = row["id"] @@ -213,6 +240,69 @@ def _refresh_nomenclator(conn, rar: RarClient, token: str) -> None: print(f"[worker] nomenclator refresh esuat (continui): {exc}", flush=True) +class AccountSessions: + """Sesiuni RAR per cont: login lazy cu creds din submission + cache JWT (30h). + + La primul login reusit pentru un cont sterge creds-urile criptate ale contului + (token-ul in memorie acopera restul). Pe 401 mid-sesiune se invalideaza sesiunea + -> re-login la urmatorul submission cu creds. + """ + + def __init__(self, settings: Settings): + self.settings = settings + self._sessions: dict[int, tuple[RarClient, str]] = {} + + def get_token(self, conn, account_id: int, creds: dict | None) -> str | None: + """Token valid pentru cont. Login daca lipseste din cache si avem creds; altfel None.""" + sess = self._sessions.get(account_id) + if sess is not None: + return sess[1] + if not creds or not creds.get("email") or not creds.get("password"): + return None + rar = RarClient(self.settings) + try: + token = rar.login(creds["email"], creds["password"]) + except Exception: + rar.close() + raise + self._sessions[account_id] = (rar, token) + write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})") + # Creds nu mai sunt necesare: JWT acopera retry-urile -> sterge la rest. + conn.execute( + "UPDATE submissions SET rar_creds_enc=NULL WHERE account_id=? AND rar_creds_enc IS NOT NULL", + (account_id,), + ) + # Nomenclator live (autoritativ) la fiecare login proaspat. + _refresh_nomenclator(conn, rar, token) + return token + + def rar(self, account_id: int) -> RarClient: + return self._sessions[account_id][0] + + def active(self) -> list[tuple[int, RarClient, str]]: + return [(acct, rar, tok) for acct, (rar, tok) in self._sessions.items()] + + def invalidate(self, account_id: int) -> None: + sess = self._sessions.pop(account_id, None) + if sess is not None: + sess[0].close() + + def close_all(self) -> None: + for rar, _tok in self._sessions.values(): + rar.close() + self._sessions.clear() + + +def _creds_for(claimed: dict, settings: Settings) -> dict | None: + """Creds pentru un submission: decripteaza enc-ul lipit; altfel creds (dev).""" + creds = decrypt_creds(claimed.get("creds_enc")) + if creds: + return creds + if settings.worker_use_test_creds: + return load_test_credentials() + return None + + def run() -> int: signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGINT, _stop) @@ -222,9 +312,7 @@ def run() -> int: conn = get_connection() print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True) - creds = load_test_credentials() if settings.worker_use_test_creds else None - rar: RarClient | None = None - token: str | None = None + sessions = AccountSessions(settings) while _running: try: @@ -233,38 +321,49 @@ def run() -> int: if not settings.worker_send_enabled: time.sleep(settings.worker_poll_interval_s) continue - if not creds: - time.sleep(settings.worker_poll_interval_s) - continue - - # Login lazy + token cache (JWT 30h). - if rar is None or token is None: - rar = RarClient(settings) - token = rar.login(creds["email"], creds["password"]) - write_heartbeat(conn, rar_login_ok=True, detail="login RAR ok") - # Refresh nomenclator live (autoritativ) la fiecare login proaspat — - # alimenteaza fuzzy lookup-ul din editorul de mapari. - _refresh_nomenclator(conn, rar, token) - - recover_orphans(conn, settings, rar, token) claimed = claim_one(conn) if claimed is None: + # Nimic de trimis: recupereaza orfanii conturilor deja logate. + for acct, rar, tok in sessions.active(): + recover_orphans(conn, settings, rar, tok, account_id=acct) time.sleep(settings.worker_poll_interval_s) continue - process_one(conn, settings, rar, token, claimed) + sid = claimed["id"] + account_id = claimed["account_id"] + creds = _creds_for(claimed, settings) + + try: + token = sessions.get_token(conn, account_id, creds) + except RarAuthError as exc: + # Creds gresite (login 401): NU se face retry (plan, failure registry). + mark(conn, sid, "error", rar_status_code=401, rar_error="credentiale RAR invalide") + print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True) + continue + + if token is None: + # Fara creds disponibile (token pierdut la restart + creds sterse). + # Re-pune in coada cu backoff; ROAAUTO re-trimite creds proaspete. + requeue_with_backoff(conn, settings, sid, reason="creds RAR indisponibile (astept re-trimitere)") + continue + + rar = sessions.rar(account_id) + # Recupereaza orfanii contului inainte de trimitere (acelasi token). + recover_orphans(conn, settings, rar, token, account_id=account_id) + try: + process_one(conn, settings, rar, token, claimed) + except RarAuthError as exc: + # Token expirat mid-sesiune: invalideaza sesiunea, re-pune randul. + print(f"[worker] cont {account_id} token expirat: {exc}; re-login data viitoare", flush=True) + sessions.invalidate(account_id) + requeue_with_backoff(conn, settings, sid, reason="token RAR expirat") - except RarAuthError as exc: - print(f"[worker] login esuat / token expirat: {exc}", flush=True) - token = None # forteaza re-login (acopera si expirarea JWT la 30h) - time.sleep(settings.worker_poll_interval_s) except Exception as exc: # noqa: BLE001 — loop top-level: o eroare punctuala nu opreste worker-ul print(f"[worker] eroare neasteptata: {exc}", flush=True) time.sleep(settings.worker_poll_interval_s) - if rar is not None: - rar.close() + sessions.close_all() conn.close() print("[worker] oprit curat", flush=True) return 0 diff --git a/docs/plans/plan.md b/docs/plans/plan.md index f9d6948..30c531c 100644 --- a/docs/plans/plan.md +++ b/docs/plans/plan.md @@ -213,7 +213,14 @@ Nimic din cod nu e scris încă (`app/`, `tools/` nu există). Ordine recomandat `password` cu `repr=False` în model. Auth: hash SHA-256 în `api_keys` (cheia în clar emisă o singură dată), header `X-API-Key` / `Authorization: Bearer`, enforcement pe flag `AUTOPASS_require_api_key` (prod on→401, dev off→cont default id=1; cheie prezentă invalidă→401 mereu). `account_id` real curge din cheie în ingestie + mapare. Verify: 16 teste - (`tests/test_security.py`). **Rămas:** livrare creds per-cerere ROAAUTO→worker (zero-storage, ramășiță T2). + (`tests/test_security.py`). +- [x] **Livrare creds per-cerere (P1)** ✅ 2026-06-15. `app/crypto.py` (Fernet, cheie din `AUTOPASS_creds_key`; nesetată → + cheie efemeră la runtime). Creds RAR criptate per submission (`submissions.rar_creds_enc`) la ingestie — niciodată în + clar în DB. Worker: `AccountSessions` face login PER CONT cu creds decriptate, cache JWT 30h în memorie, ȘTERGE creds-urile + contului după primul login reușit (token-ul acoperă restul). Fallback creds `` în dev. 401 creds greșite → error fără + retry; token expirat → invalidare sesiune + requeue; fără creds (restart) → requeue „indisponibile" (ROAAUTO re-trimite). + Verify: 10 teste (`tests/test_creds_delivery.py`). **Risc acceptat:** la restart token+creds se pierd → contul re-loghează + la următorul submission cu creds (degradare per modelul efemer). - [ ] **T6 (P2) — worker proces/container propriu supravegheat;** `/healthz` pică → restart. Verify: worker omorât → restart automat. - [ ] **T7 (P2) — deploy:** SQLite pe volum persistent numit + backup (singura copie durabilă, re-push scos). Verify: recreare container → coada supraviețuiește. diff --git a/requirements.txt b/requirements.txt index c87d57b..e6cdc33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,8 @@ pydantic-settings==2.* python-multipart==0.0.* # Fuzzy lookup pentru editorul de mapari operatii (app/mapping.py). Pur Python/C, fara build extern. rapidfuzz==3.14.5 +# Criptare creds RAR efemere in submissions (app/crypto.py, Fernet). Zero-storage at rest. +cryptography==46.0.5 # Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime. dbfread==2.0.7 diff --git a/tests/test_creds_delivery.py b/tests/test_creds_delivery.py new file mode 100644 index 0000000..35e5bd5 --- /dev/null +++ b/tests/test_creds_delivery.py @@ -0,0 +1,252 @@ +"""Teste livrare creds per-cerere: criptare efemera + sesiuni worker per-cont. + +Acopera: round-trip crypto, stocarea creds criptate la ingestie (niciodata in +clar), login per-cont cu stergere creds dupa primul login, fallback creds , +401 creds gresite -> error fara retry. +""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pytest +from cryptography.fernet import Fernet +from fastapi.testclient import TestClient + +from app.rar_client import RarAuthError + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode()) + from app.config import get_settings + from app import crypto + + get_settings.cache_clear() + crypto.reset_cache() + from app.db import get_connection, init_db + + init_db() + yield monkeypatch + get_settings.cache_clear() + crypto.reset_cache() + + +# --------------------------------------------------------------------------- # +# Crypto round-trip # +# --------------------------------------------------------------------------- # + +def test_crypto_roundtrip(env): + from app.crypto import decrypt_creds, encrypt_creds + + creds = {"email": "x@y.ro", "password": "HUNTER2"} + tok = encrypt_creds(creds) + assert "HUNTER2" not in tok # criptat, nu in clar + assert decrypt_creds(tok) == creds + + +def test_crypto_bad_token_returns_none(env): + from app.crypto import decrypt_creds + + assert decrypt_creds(None) is None + assert decrypt_creds("") is None + assert decrypt_creds("garbage-not-a-token") is None + + +def test_crypto_wrong_key_returns_none(env, monkeypatch): + from app import crypto + from app.crypto import encrypt_creds + + tok = encrypt_creds({"email": "a", "password": "b"}) + # Rotim cheia -> token-ul vechi nu se mai decripteaza (degradare acceptata). + monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode()) + from app.config import get_settings + + get_settings.cache_clear() + crypto.reset_cache() + assert crypto.decrypt_creds(tok) is None + + +# --------------------------------------------------------------------------- # +# Ingestie: creds stocate criptat, niciodata in clar # +# --------------------------------------------------------------------------- # + +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": "SECRETPW"}, "prezentari": [prez]} + + +def test_ingestie_stocheaza_creds_criptate(env): + from app.crypto import decrypt_creds + from app.db import get_connection + from app.main import app + + with TestClient(app) as c: + r = c.post("/v1/prezentari", json=_body()) + assert r.status_code == 200 + sid = r.json()["results"][0]["submission_id"] + + conn = get_connection() + try: + row = conn.execute("SELECT rar_creds_enc, payload_json FROM submissions WHERE id=?", (sid,)).fetchone() + finally: + conn.close() + # Creds criptate prezente, dar parola NU apare in clar nicaieri in rand. + assert row["rar_creds_enc"] + assert "SECRETPW" not in row["rar_creds_enc"] + assert "SECRETPW" not in row["payload_json"] + assert decrypt_creds(row["rar_creds_enc"]) == {"email": "x@y.ro", "password": "SECRETPW"} + + +# --------------------------------------------------------------------------- # +# Worker: sesiuni per-cont # +# --------------------------------------------------------------------------- # + +class FakeRarClient: + """Stub RarClient: login intoarce token determinist, get_nomenclator gol.""" + + made: list = [] + + def __init__(self, settings=None, login_exc=None): + self.closed = False + self.login_calls = 0 + self._login_exc = login_exc + FakeRarClient.made.append(self) + + def login(self, email, password): + self.login_calls += 1 + if self._login_exc is not None: + raise self._login_exc + return f"TOK-{email}" + + def get_nomenclator(self, token): + return [] + + def close(self): + self.closed = True + + +def _insert(conn, account_id=1, creds_enc=None, status="queued"): + content = { + "vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1", + "data_prestatie": "2026-06-15", "odometru_final": "1", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_creds_enc) " + "VALUES (?, ?, ?, ?, ?)", + (f"k-{os.urandom(4).hex()}", account_id, status, json.dumps(content), creds_enc), + ) + return int(cur.lastrowid) + + +def test_get_token_login_clears_creds(env, monkeypatch): + import app.worker.__main__ as w + from app.crypto import encrypt_creds + from app.db import get_connection + + FakeRarClient.made = [] + monkeypatch.setattr(w, "RarClient", FakeRarClient) + + conn = get_connection() + try: + enc = encrypt_creds({"email": "a@b.ro", "password": "p"}) + s1 = _insert(conn, account_id=1, creds_enc=enc) + s2 = _insert(conn, account_id=1, creds_enc=enc) + sessions = w.AccountSessions(w.get_settings()) + + token = sessions.get_token(conn, 1, {"email": "a@b.ro", "password": "p"}) + assert token == "TOK-a@b.ro" + # Creds sterse pentru tot contul dupa primul login. + for sid in (s1, s2): + assert conn.execute("SELECT rar_creds_enc FROM submissions WHERE id=?", (sid,)).fetchone()["rar_creds_enc"] is None + # Token cache-uit: al doilea apel NU re-loghează. + assert sessions.get_token(conn, 1, None) == "TOK-a@b.ro" + assert FakeRarClient.made[0].login_calls == 1 + finally: + conn.close() + + +def test_get_token_no_creds_returns_none(env, monkeypatch): + import app.worker.__main__ as w + from app.db import get_connection + + monkeypatch.setattr(w, "RarClient", FakeRarClient) + conn = get_connection() + try: + sessions = w.AccountSessions(w.get_settings()) + assert sessions.get_token(conn, 1, None) is None + finally: + conn.close() + + +def test_get_token_bad_creds_raises(env, monkeypatch): + import app.worker.__main__ as w + from app.db import get_connection + + def _factory(settings=None): + return FakeRarClient(settings, login_exc=RarAuthError("Credentiale RAR invalide", status_code=401)) + + monkeypatch.setattr(w, "RarClient", _factory) + conn = get_connection() + try: + sessions = w.AccountSessions(w.get_settings()) + with pytest.raises(RarAuthError): + sessions.get_token(conn, 1, {"email": "a", "password": "x"}) + finally: + conn.close() + + +def test_per_account_isolation(env, monkeypatch): + import app.worker.__main__ as w + from app.db import get_connection + + FakeRarClient.made = [] + monkeypatch.setattr(w, "RarClient", FakeRarClient) + conn = get_connection() + try: + conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'doi')") + sessions = w.AccountSessions(w.get_settings()) + t1 = sessions.get_token(conn, 1, {"email": "a@b.ro", "password": "p"}) + t2 = sessions.get_token(conn, 2, {"email": "c@d.ro", "password": "q"}) + assert t1 == "TOK-a@b.ro" and t2 == "TOK-c@d.ro" + assert len(sessions.active()) == 2 + sessions.invalidate(1) + assert len(sessions.active()) == 1 + assert FakeRarClient.made[0].closed is True + finally: + conn.close() + + +def test_creds_for_fallback_test_creds(env, monkeypatch): + import app.worker.__main__ as w + + # Fara enc + flag test on -> creds . + monkeypatch.setattr(w, "load_test_credentials", lambda: {"email": "t@test", "password": "tp"}) + settings = w.get_settings() + object.__setattr__(settings, "worker_use_test_creds", True) + assert w._creds_for({"creds_enc": None}, settings) == {"email": "t@test", "password": "tp"} + + # Flag off -> None. + object.__setattr__(settings, "worker_use_test_creds", False) + assert w._creds_for({"creds_enc": None}, settings) is None + + +def test_creds_for_prefers_enc(env): + import app.worker.__main__ as w + from app.crypto import encrypt_creds + + enc = encrypt_creds({"email": "real@x", "password": "rp"}) + settings = w.get_settings() + assert w._creds_for({"creds_enc": enc}, settings) == {"email": "real@x", "password": "rp"}