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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-15 20:16:16 +00:00
parent c17c1aa4f4
commit fbb2695336
9 changed files with 472 additions and 41 deletions

View File

@@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ...auth import resolve_account_id from ...auth import resolve_account_id
from ...crypto import encrypt_creds
from ...db import get_connection from ...db import get_connection
from ...idempotency import idempotency_key from ...idempotency import idempotency_key
from ...mapping import ( from ...mapping import (
@@ -49,6 +50,10 @@ def create_prezentari(
pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea.
""" """
acct = account_or_default(account_id) 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() conn = get_connection()
results: list[SubmissionResult] = [] results: list[SubmissionResult] = []
try: try:
@@ -90,9 +95,9 @@ def create_prezentari(
status, rar_error = "queued", None status, rar_error = "queued", None
cur = conn.execute( cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) " "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
"VALUES (?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?)",
(key, acct, status, json.dumps(content, ensure_ascii=False), rar_error), (key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc),
) )
results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status))
finally: finally:

View File

@@ -28,6 +28,12 @@ class Settings(BaseSettings):
# dar invalida da 401 indiferent de flag. # dar invalida da 401 indiferent de flag.
require_api_key: bool = False 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 ---
rar_env: str = "test" # "test" | "prod" rar_env: str = "test" # "test" | "prod"
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"

57
app/crypto.py Normal file
View File

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

View File

@@ -46,6 +46,8 @@ def _migrate(conn: sqlite3.Connection) -> None:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()} cols = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()}
if "next_attempt_at" not in cols: if "next_attempt_at" not in cols:
conn.execute("ALTER TABLE submissions ADD COLUMN next_attempt_at TEXT") 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: def _now_iso() -> str:

View File

@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS submissions (
status TEXT NOT NULL DEFAULT 'queued' status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')), CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error')),
payload_json TEXT NOT NULL, -- TODO(P2): inlocuit cu BLOB criptat 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_status_code INTEGER,
rar_error TEXT, rar_error TEXT,
id_prezentare INTEGER, -- data.id intors de RAR la succes id_prezentare INTEGER, -- data.id intors de RAR la succes

View File

@@ -12,8 +12,14 @@ T2 implementat:
- lease/timeout pe randuri 'sending' orfane. - lease/timeout pe randuri 'sending' orfane.
- re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h. - 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 <test>), Creds per-cerere (plan sect. 5): fiecare submission poarta creds RAR CRIPTATE
criptare PII at-rest (P2), b64Image mare pe disc (P2). (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 <test> 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 Pornire: python -m app.worker
""" """
@@ -29,8 +35,9 @@ from datetime import datetime, timedelta, timezone
import httpx import httpx
from ..config import Settings, get_settings, load_test_credentials from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds
from ..db import get_connection, init_db, write_heartbeat 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 ..payload import build_rar_payload
from ..reconcile import match_finalizata from ..reconcile import match_finalizata
from ..rar_client import RarAuthError, RarClient, RarError 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: 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") conn.execute("BEGIN IMMEDIATE")
try: try:
row = conn.execute( 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", "AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY id LIMIT 1",
(_iso(_now()),), (_iso(_now()),),
).fetchone() ).fetchone()
@@ -110,7 +121,12 @@ def claim_one(conn) -> dict | None:
(row["id"],), (row["id"],),
) )
conn.execute("COMMIT") 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: except Exception:
conn.execute("ROLLBACK") conn.execute("ROLLBACK")
raise raise
@@ -174,14 +190,25 @@ def _handle_transient(conn, settings: Settings, rar: RarClient, token: str, sid:
return "requeued" return "requeued"
def recover_orphans(conn, settings: Settings, rar: RarClient, token: str) -> int: 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.""" """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)) cutoff = _iso(_now() - timedelta(seconds=settings.worker_sending_lease_s))
orphans = conn.execute( if account_id is not None:
"SELECT id, payload_json FROM submissions WHERE status='sending' " orphans = conn.execute(
"AND (sending_since IS NULL OR sending_since <= ?)", "SELECT id, payload_json FROM submissions WHERE status='sending' "
(cutoff,), "AND (sending_since IS NULL OR sending_since <= ?) AND account_id=?",
).fetchall() (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 recovered = 0
for row in orphans: for row in orphans:
sid = row["id"] 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) 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 <test> (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: def run() -> int:
signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGINT, _stop)
@@ -222,9 +312,7 @@ def run() -> int:
conn = get_connection() conn = get_connection()
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True) 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 sessions = AccountSessions(settings)
rar: RarClient | None = None
token: str | None = None
while _running: while _running:
try: try:
@@ -233,38 +321,49 @@ def run() -> int:
if not settings.worker_send_enabled: if not settings.worker_send_enabled:
time.sleep(settings.worker_poll_interval_s) time.sleep(settings.worker_poll_interval_s)
continue 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) claimed = claim_one(conn)
if claimed is None: 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) time.sleep(settings.worker_poll_interval_s)
continue 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 except Exception as exc: # noqa: BLE001 — loop top-level: o eroare punctuala nu opreste worker-ul
print(f"[worker] eroare neasteptata: {exc}", flush=True) print(f"[worker] eroare neasteptata: {exc}", flush=True)
time.sleep(settings.worker_poll_interval_s) time.sleep(settings.worker_poll_interval_s)
if rar is not None: sessions.close_all()
rar.close()
conn.close() conn.close()
print("[worker] oprit curat", flush=True) print("[worker] oprit curat", flush=True)
return 0 return 0

View File

@@ -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 `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 `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 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 `<test>` î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. - [ ] **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). - [ ] **T7 (P2) — deploy:** SQLite pe volum persistent numit + backup (singura copie durabilă, re-push scos).
Verify: recreare container → coada supraviețuiește. Verify: recreare container → coada supraviețuiește.

View File

@@ -9,6 +9,8 @@ pydantic-settings==2.*
python-multipart==0.0.* python-multipart==0.0.*
# Fuzzy lookup pentru editorul de mapari operatii (app/mapping.py). Pur Python/C, fara build extern. # Fuzzy lookup pentru editorul de mapari operatii (app/mapping.py). Pur Python/C, fara build extern.
rapidfuzz==3.14.5 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. # Migrare DBF (tools/import_dbf.py). Necesar doar pentru import optional, nu pentru runtime.
dbfread==2.0.7 dbfread==2.0.7

View File

@@ -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 <test>,
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 <test>.
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"}