From 12f0ca3a81f53ba7213b6bafed5aaf8f6e0315bb Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 16 Jun 2026 20:18:41 +0000 Subject: [PATCH] feat(import): T1 accounts.rar_creds_enc durabil + worker fallback + gate purjare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worker: _creds_from_account(conn, account_id) — fallback la accounts.rar_creds_enc cand submission n-are creds (canal web fara re-pusher, restart worker) - run(): creds = _creds_for(claimed, settings) OR _creds_from_account(conn, account_id) - gate purjare (Voce#5): comentariu explicit — sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc (inofensiv pt canal web, neatins pt canal API) - POST /v1/conturi/rar-creds: seteaza creds durabile criptate Fernet per cont - DELETE /v1/conturi/rar-creds: revenire la modelul efemer Treapta 1 - 7 teste: fallback, restart, coada mixta, endpoint set/delete, gate purjare Co-Authored-By: Claude Sonnet 4.6 --- app/api/v1/router.py | 45 ++++++ app/worker/__main__.py | 23 ++- tests/test_t1_creds_durabile.py | 256 ++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 tests/test_t1_creds_durabile.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 557dea2..e7f92cd 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -323,3 +323,48 @@ def create_mapare( return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats} finally: conn.close() + + +class RarCredsIn(BaseModel): + """Creds RAR durabile per-cont (D4). Stocate criptate (Fernet) in accounts.rar_creds_enc.""" + + email: str = Field(..., min_length=1) + password: str = Field(..., min_length=1, repr=False) + + +@router.post("/conturi/rar-creds") +def set_rar_creds( + req: RarCredsIn, + account_id: int = Depends(resolve_account_id), +) -> dict: + """Seteaza creds RAR durabile per-cont (D4/T1). + + Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback + cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker). + Contul vine din cheia API. + """ + acct = account_or_default(account_id) + enc = encrypt_creds({"email": req.email, "password": req.password}) + conn = get_connection() + try: + conn.execute( + "UPDATE accounts SET rar_creds_enc=? WHERE id=?", + (enc, acct), + ) + return {"ok": True, "account_id": acct} + finally: + conn.close() + + +@router.delete("/conturi/rar-creds") +def delete_rar_creds( + account_id: int = Depends(resolve_account_id), +) -> dict: + """Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1).""" + acct = account_or_default(account_id) + conn = get_connection() + try: + conn.execute("UPDATE accounts SET rar_creds_enc=NULL WHERE id=?", (acct,)) + return {"ok": True, "account_id": acct} + finally: + conn.close() diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 4417a65..97124b9 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -267,7 +267,10 @@ class AccountSessions: 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. + # Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge. + # GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc. + # Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart). + # Canal API pur: purjarea e identica cu Treapta 1 (neatinsa). conn.execute( "UPDATE submissions SET rar_creds_enc=NULL WHERE account_id=? AND rar_creds_enc IS NOT NULL", (account_id,), @@ -303,6 +306,20 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None: return None +def _creds_from_account(conn, account_id: int) -> dict | None: + """Fallback T1/D4: crede RAR durabile per-cont din accounts.rar_creds_enc. + + Canal web nu are re-pusher. Cand submission n-are creds (sterse dupa primul login + sau upload web fara creds), worker-ul re-citeste din cont si poate re-login oricand. + """ + row = conn.execute( + "SELECT rar_creds_enc FROM accounts WHERE id=?", (account_id,) + ).fetchone() + if row and row["rar_creds_enc"]: + return decrypt_creds(row["rar_creds_enc"]) + return None + + def run() -> int: signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGINT, _stop) @@ -332,7 +349,9 @@ def run() -> int: sid = claimed["id"] account_id = claimed["account_id"] - creds = _creds_for(claimed, settings) + # T1/D4: incearca creds din submission (canal API efemer), cu fallback la + # accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher. + creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id) try: token = sessions.get_token(conn, account_id, creds) diff --git a/tests/test_t1_creds_durabile.py b/tests/test_t1_creds_durabile.py new file mode 100644 index 0000000..e52fa1e --- /dev/null +++ b/tests/test_t1_creds_durabile.py @@ -0,0 +1,256 @@ +"""Teste T1: accounts.rar_creds_enc durabile + worker re-login fallback + gate purjare. + +Verify: +(a) Serie web, worker restart (sesiune goala), token expirat -> re-login din accounts -> trimite. +(b) Coada MIXTA API(efemer)+web(durabil): dupa login web, submission-urile API tot se trimit + (purjarea nu le-a rupt prematur). +""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pytest +from cryptography.fernet import Fernet + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t1.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 init_db + init_db() + yield monkeypatch + get_settings.cache_clear() + crypto.reset_cache() + + +class FakeRar: + """Stub RarClient pentru teste.""" + + def __init__(self, settings=None): + self.login_calls = 0 + self.closed = False + + def login(self, email, password): + self.login_calls += 1 + 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", key_suffix=""): + content = { + "vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1", + "data_prestatie": "2026-06-15", "odometru_final": "1", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + suffix = key_suffix or os.urandom(4).hex() + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_creds_enc) " + "VALUES (?, ?, ?, ?, ?)", + (f"k-{suffix}", account_id, status, json.dumps(content), creds_enc), + ) + return int(cur.lastrowid) + + +# --- (a) re-login din accounts dupa restart --- + +def test_creds_from_account_fallback(env, monkeypatch): + """Worker re-citeste creds din accounts daca submission n-are creds_enc.""" + import app.worker.__main__ as w + from app.crypto import encrypt_creds + from app.db import get_connection + + monkeypatch.setattr(w, "RarClient", FakeRar) + conn = get_connection() + try: + enc = encrypt_creds({"email": "web@test.ro", "password": "webpass"}) + conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (enc,)) + + # Submission web fara creds_enc (ex: dupa ce s-au purjat) + _insert(conn, account_id=1, creds_enc=None) + + # _creds_from_account trebuie sa returneze creds + creds = w._creds_from_account(conn, 1) + assert creds == {"email": "web@test.ro", "password": "webpass"} + finally: + conn.close() + + +def test_creds_from_account_no_creds(env): + """Cont fara rar_creds_enc -> None (canal API pur, neatins).""" + import app.worker.__main__ as w + from app.db import get_connection + + conn = get_connection() + try: + assert w._creds_from_account(conn, 1) is None + finally: + conn.close() + + +def test_worker_relogin_dupa_restart(env, monkeypatch): + """(a) Worker restart: sesiune goala, submission fara creds -> re-login din accounts.""" + import app.worker.__main__ as w + from app.crypto import encrypt_creds + from app.db import get_connection + + FakeRar.login_calls_total = 0 + monkeypatch.setattr(w, "RarClient", FakeRar) + + conn = get_connection() + try: + enc = encrypt_creds({"email": "web@test.ro", "password": "webpass"}) + conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (enc,)) + + # Submission web fara creds (creds deja purjate de primul login) + _insert(conn, account_id=1, creds_enc=None) + + # Sesiune noua (simuleaza restart) — cache gol + sessions = w.AccountSessions(w.get_settings()) + assert sessions.get_token(conn, 1, None) is None # fara creds directe + + # Creds din account -> login posibil + creds = w._creds_from_account(conn, 1) + assert creds is not None + token = sessions.get_token(conn, 1, creds) + assert token == "TOK-web@test.ro" + finally: + conn.close() + + +# --- (b) coada MIXTA API+web --- + +def test_coada_mixta_api_web(env, monkeypatch): + """(b) Coada mixta: dupa login web, submission-urile API (efemere) tot se trimit. + + Scenariul: + 1. S1 = submission API cu creds efemere in submission.rar_creds_enc + 2. S2 = submission WEB fara creds (foloseste accounts.rar_creds_enc) + 3. Login cu creds S1 -> purjare S1.rar_creds_enc -> OK (worker are token) + 4. S2 tot se poate procesa (creds din accounts) + """ + import app.worker.__main__ as w + from app.crypto import encrypt_creds + from app.db import get_connection + + monkeypatch.setattr(w, "RarClient", FakeRar) + + conn = get_connection() + try: + # Creds durabile pentru contul web + enc_web = encrypt_creds({"email": "web@test.ro", "password": "webpass"}) + conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (enc_web,)) + + # S1: canal API cu creds efemere + enc_api = encrypt_creds({"email": "api@test.ro", "password": "apipass"}) + s1 = _insert(conn, account_id=1, creds_enc=enc_api, key_suffix="api1") + # S2: canal web fara creds in submission + s2 = _insert(conn, account_id=1, creds_enc=None, key_suffix="web1") + + sessions = w.AccountSessions(w.get_settings()) + + # Procesare S1: login cu creds API -> purjare rar_creds_enc pe TOATE submission-urile contului + creds_s1 = w._creds_for({"creds_enc": enc_api}, w.get_settings()) + assert creds_s1 is not None + sessions.get_token(conn, 1, creds_s1) # login + purjare + + # Verifica purjarea: S1.rar_creds_enc = NULL acum + row_s1 = conn.execute("SELECT rar_creds_enc FROM submissions WHERE id=?", (s1,)).fetchone() + assert row_s1["rar_creds_enc"] is None, "creds efemere trebuie sterse dupa login" + + # S2 nu mai are creds in submission (nici nu a avut); fallback la accounts + creds_s2 = w._creds_for({"creds_enc": None}, w.get_settings()) or w._creds_from_account(conn, 1) + assert creds_s2 == {"email": "web@test.ro", "password": "webpass"}, \ + "S2 trebuie sa ia creds din accounts.rar_creds_enc" + + # accounts.rar_creds_enc NU a fost sters de purjare + row_acc = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=1").fetchone() + assert row_acc["rar_creds_enc"] is not None, \ + "accounts.rar_creds_enc trebuie sa ramana dupa purjare submissions" + finally: + conn.close() + + +# --- Endpoint API set/delete rar-creds --- + +@pytest.fixture() +def client(env): + from app.main import app + from fastapi.testclient import TestClient + with TestClient(app) as c: + yield c + + +def test_endpoint_set_rar_creds(client, env): + """POST /v1/conturi/rar-creds seteaza creds criptate in accounts.""" + from app.crypto import decrypt_creds + from app.db import get_connection + + r = client.post("/v1/conturi/rar-creds", json={"email": "u@test.ro", "password": "pass123"}) + assert r.status_code == 200 + assert r.json()["ok"] is True + + conn = get_connection() + try: + row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=1").fetchone() + assert row["rar_creds_enc"] is not None + creds = decrypt_creds(row["rar_creds_enc"]) + assert creds == {"email": "u@test.ro", "password": "pass123"} + finally: + conn.close() + + +def test_endpoint_delete_rar_creds(client, env): + """DELETE /v1/conturi/rar-creds sterge creds durabile.""" + # Mai intai seteaza + client.post("/v1/conturi/rar-creds", json={"email": "u@test.ro", "password": "pass123"}) + # Sterge + r = client.delete("/v1/conturi/rar-creds") + assert r.status_code == 200 + assert r.json()["ok"] is True + + from app.db import get_connection + conn = get_connection() + try: + row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=1").fetchone() + assert row["rar_creds_enc"] is None + finally: + conn.close() + + +def test_gate_purjare_nu_sterge_accounts(env, monkeypatch): + """Gate purjare T1: stergerea submissions.rar_creds_enc NU atinge accounts.rar_creds_enc.""" + import app.worker.__main__ as w + from app.crypto import encrypt_creds + from app.db import get_connection + + monkeypatch.setattr(w, "RarClient", FakeRar) + + conn = get_connection() + try: + enc = encrypt_creds({"email": "u@test.ro", "password": "p"}) + conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (enc,)) + _insert(conn, account_id=1, creds_enc=enc) + + sessions = w.AccountSessions(w.get_settings()) + sessions.get_token(conn, 1, {"email": "u@test.ro", "password": "p"}) + + # accounts.rar_creds_enc trebuie sa fie intact + row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=1").fetchone() + assert row["rar_creds_enc"] is not None, \ + "gate purjare: accounts.rar_creds_enc trebuie sa ramana intact" + finally: + conn.close()