From b1d825e66b4144ab140713532880215aa69516a2 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 2 Jul 2026 21:03:08 +0000 Subject: [PATCH] feat(5.20): US-013 retragere accounts.rar_creds_enc -> per-env + DROP cu garda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toate citirile pe coloana legacy accounts.rar_creds_enc mutate pe sloturile per-env (rar_creds_test_enc/rar_creds_prod_enc): worker fallback+keepalive, are_creds (web) si are_creds_rar (integrare, +are_creds_test/_prod), write-back API la reactivare, purjare la stergere cont, _get_acasa_context/_fetch_cont_env_state. Contract API (aditiv): POST /v1/conturi/rar-creds primeste rar_target optional (test/prod), scrie in slotul corect + activeaza mediul; DELETE primeste ?env (sterge un slot sau ambele). Documentat in docs/api-rar-contract.md. DROP cu garda in db.py (schema.sql fara coloana pe DB fresh): - 6a: eliminat ADD COLUMN rar_creds_enc (fara ping-pong re-ADD dupa DROP) - 6b: try/except fail-safe (nu crapa boot-ul) + garda sqlite_version >= 3.35 - 6c: re-backfill old->new imediat inainte de assert (ancora globala) - garda orfane: DROP anulat daca vreun creds legacy nu a aterizat in slot per-env - backup criptat accounts_rar_creds_enc_backup inainte de DROP - 6d: verificare prin PRAGMA table_info (NU grep — submissions are aceeasi coloana) Garda one-way, idempotenta la boot repetat (verificat). submissions.rar_creds_enc ramane neatinsa. tests/test_retragere_creds_enc.py: niciun read pe coloana veche, conturi rar-creds env-aware, are_creds per-env, DROP blocat de garda la lipsa copiere. 9 teste existente actualizate pe sloturi per-env. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/accounts.py | 5 +- app/api/v1/integrare_router.py | 14 +- app/api/v1/router.py | 53 ++++-- app/db.py | 99 +++++++++- app/models.py | 2 +- app/schema.sql | 2 +- app/web/routes.py | 19 +- app/worker/__main__.py | 30 +-- docs/api-rar-contract.md | 43 +++++ tests/test_account_status.py | 18 +- tests/test_dedup_error.py | 8 +- tests/test_foundation.py | 14 +- tests/test_import_e2e.py | 43 +++-- tests/test_integrare_api.py | 7 +- tests/test_retragere_creds_enc.py | 282 +++++++++++++++++++++++++++++ tests/test_t1_creds_durabile.py | 106 ++++++----- tests/test_web_cont.py | 29 +-- tests/test_web_onboarding.py | 5 +- tests/test_worker_keepalive_rar.py | 16 +- 19 files changed, 657 insertions(+), 138 deletions(-) create mode 100644 tests/test_retragere_creds_enc.py diff --git a/app/accounts.py b/app/accounts.py index 204cba3..0fdb3d9 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -238,7 +238,8 @@ def delete_account(conn: sqlite3.Connection, account_id: int) -> None: NU acest tombstone — de aceea purjam PII aici, la momentul stergerii.""" set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0 conn.execute( - "UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,) + "UPDATE accounts SET rar_creds_test_enc=NULL, rar_creds_prod_enc=NULL, cui=NULL WHERE id=?", + (account_id,), ) conn.execute( "UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1", @@ -247,7 +248,7 @@ def delete_account(conn: sqlite3.Connection, account_id: int) -> None: def list_accounts(conn: sqlite3.Connection) -> list[dict]: - """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted' + """Metadate conturi (FARA creds RAR criptate), ordonate dupa id. Exclude conturile 'deleted' (stergere soft -> invizibile in panou).""" rows = conn.execute( "SELECT id, name, cui, email, active, status, tier, trial_until, " diff --git a/app/api/v1/integrare_router.py b/app/api/v1/integrare_router.py index b329c1c..fad9ac2 100644 --- a/app/api/v1/integrare_router.py +++ b/app/api/v1/integrare_router.py @@ -37,7 +37,9 @@ def ping( account_id — contul rezolvat din cheie (sau 1 in dev fara cheie) mediu — "test" / "prod" (settings.rar_env) autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida - are_creds_rar — True daca contul are rar_creds_enc stocat + are_creds_rar — True daca contul are creds RAR stocate pe cel putin un mediu (test sau prod) + are_creds_test — True daca contul are creds RAR pentru mediul Testare + are_creds_prod — True daca contul are creds RAR pentru mediul Productie ts — timestamp ISO UTC al cererii """ settings = get_settings() @@ -55,23 +57,27 @@ def ping( conn.close() autentificat_cu_cheie = acct is not None - # Verificam daca contul are creds RAR stocate. + # Verificam daca contul are creds RAR stocate (per-env, US-013). aid = account_or_default(account_id) conn = get_connection() try: row = conn.execute( - "SELECT rar_creds_enc FROM accounts WHERE id=?", (aid,) + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (aid,) ).fetchone() finally: conn.close() - are_creds_rar = bool(row and row["rar_creds_enc"]) + are_creds_test = bool(row and row["rar_creds_test_enc"]) + are_creds_prod = bool(row and row["rar_creds_prod_enc"]) + are_creds_rar = are_creds_test or are_creds_prod return JSONResponse({ "account_id": aid, "mediu": settings.rar_env, "autentificat_cu_cheie": autentificat_cu_cheie, "are_creds_rar": are_creds_rar, + "are_creds_test": are_creds_test, + "are_creds_prod": are_creds_prod, "ts": datetime.now(timezone.utc).isoformat(), }) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 190dfbf..cca26ad 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -149,7 +149,7 @@ def create_prezentari( account_id vine din cheia API (resolve_account_id): cont real cu cheie, implicit id=1 in dev fara cheie, 401 fara cheie valida in prod. Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul - cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`). + cade pe creds-urile durabile ale contului (per-env: `accounts.rar_creds_{env}_enc`). """ acct = account_or_default(account_id) # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la @@ -276,12 +276,11 @@ def create_prezentari( cl["rar_error"], creds_enc, env, existing["id"]), ) if cur.rowcount == 1: - # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc) - # — ambele canale converg pe parola corectata. - # US-013: muta pe slot env dupa login (write-back conservator). + # Creds noi se propaga si in slotul durabil per-env al contului + # — ambele canale converg pe parola corectata (US-013, env-aware). if req.rar_credentials is not None: conn.execute( - "UPDATE accounts SET rar_creds_enc=? WHERE id=?", + f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?", (encrypt_creds(req.rar_credentials.model_dump()), acct), ) _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"]) @@ -742,10 +741,16 @@ def create_mapare( class RarCredsIn(BaseModel): - """Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc.""" + """Creds RAR durabile per-cont, stocate criptat (Fernet) in slotul per-mediu. + + `rar_target` selecteaza mediul: 'test' | 'prod'. Absent -> mediul ancorei globale + (AUTOPASS_RAR_ENV), implicit 'test'. Schimbare aditiva — clientii vechi care nu trimit + `rar_target` continua sa functioneze (comportament consistent cu ancora globala). + """ email: str = Field(..., min_length=1) password: str = Field(..., min_length=1, repr=False) + rar_target: str | None = None # 'test' | 'prod' | None -> ancora globala @router.post("/conturi/rar-creds") @@ -753,21 +758,26 @@ def set_rar_creds( req: RarCredsIn, account_id: int = Depends(resolve_account_id), ) -> dict: - """Seteaza creds RAR durabile per-cont. + """Seteaza creds RAR durabile per-cont, in slotul per-mediu (US-013, env-aware). - 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. + Slotul tinta: `req.rar_target` ('test'/'prod') sau ancora globala (AUTOPASS_RAR_ENV). + Activeaza mediul selectat (`rar_{env}_enabled=1`). Worker-ul le foloseste ca + fallback cand submission-ul nu mai are creds efemere. Contul vine din cheia API. """ + from ...config import get_settings as _gs + _s = _gs() + env = req.rar_target if req.rar_target in ("test", "prod") else ( + _s.rar_env if _s.rar_env in ("test", "prod") else "test" + ) 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=?", + f"UPDATE accounts SET rar_creds_{env}_enc=?, rar_{env}_enabled=1 WHERE id=?", (enc, acct), ) - return {"ok": True, "account_id": acct} + return {"ok": True, "account_id": acct, "rar_env": env} finally: conn.close() @@ -775,12 +785,27 @@ def set_rar_creds( @router.delete("/conturi/rar-creds") def delete_rar_creds( account_id: int = Depends(resolve_account_id), + env: str | None = None, ) -> dict: - """Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1).""" + """Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1). + + `env` (query param): 'test' | 'prod' -> sterge DOAR slotul acelui mediu + dezactiveaza-l. + Absent -> sterge AMBELE sloturi (revenire completa). Schimbare aditiva (back-compat). + """ acct = account_or_default(account_id) conn = get_connection() try: - conn.execute("UPDATE accounts SET rar_creds_enc=NULL WHERE id=?", (acct,)) + if env in ("test", "prod"): + conn.execute( + f"UPDATE accounts SET rar_creds_{env}_enc=NULL, rar_{env}_enabled=0 WHERE id=?", + (acct,), + ) + else: + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=NULL, rar_test_enabled=0, " + "rar_creds_prod_enc=NULL, rar_prod_enabled=0 WHERE id=?", + (acct,), + ) return {"ok": True, "account_id": acct} finally: conn.close() diff --git a/app/db.py b/app/db.py index 569e050..74367a3 100644 --- a/app/db.py +++ b/app/db.py @@ -86,11 +86,12 @@ def _migrate(conn: sqlite3.Connection) -> None: # Coloane accounts acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} - if "rar_creds_enc" not in acc_cols: - conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT") - acc_cols.add("rar_creds_enc") + # AUTO-FIX 6a ELIMINAT (US-013): NU mai adaugam accounts.rar_creds_enc — coloana e dropata. + # _migrate_accounts_medii gestioneaza absenta coloana (guard la ~219: if "rar_creds_enc" not in acc_cols: return). # Medii RAR per cont (PRD 5.20 US-001): activare + slot creds + default, per mediu. _migrate_accounts_medii(conn, acc_cols) + # US-013: DROP coloana legacy accounts.rar_creds_enc dupa backfill complet. + _drop_legacy_accounts_rar_creds(conn, acc_cols) if "active" not in acc_cols: # Conturi existente raman active (default 1). conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1") @@ -229,6 +230,98 @@ def _migrate_accounts_medii(conn: sqlite3.Connection, acc_cols: set[str]) -> Non ) +def _drop_legacy_accounts_rar_creds(conn: sqlite3.Connection, acc_cols: set[str]) -> None: + """PRD 5.20 US-013: DROP coloana legacy `accounts.rar_creds_enc` dupa backfill complet. + + Idempotent si sigur la fiecare boot (garda one-way: coloana absenta = nimic de facut). + La eroare LOGHEAZA si lasa coloana pe loc (fail-safe — nu crapa boot-ul ambelor procese). + Structura separata pentru testabilitate: `_garda_si_drop(conn)` expune pasul de + assert+backup+DROP izolat (fara re-backfill 6c), apelabil direct din teste. + """ + if "rar_creds_enc" not in acc_cols: + return # garda one-way: coloana deja dropata sau DB fresh + try: + _drop_legacy_rar_creds_impl(conn) + except Exception as exc: + print( + f"[db] AVERTISMENT: DROP coloana legacy accounts.rar_creds_enc esuat: {exc}. " + "Coloana ramane pe loc (fail-safe).", + flush=True, + ) + + +def _drop_legacy_rar_creds_impl(conn: sqlite3.Connection) -> None: + """Re-backfill (AUTO-FIX 6c) + delegate la _garda_si_drop. + + Re-backfill-ul 6c acopera creds setate via POST /v1/conturi/rar-creds intre US-001 + si US-013 (pot fi DOAR in coloana veche). Ancora globala: AUTOPASS_RAR_ENV. + """ + if sqlite3.sqlite_version_info < (3, 35, 0): + print( + f"[db] SQLite {sqlite3.sqlite_version} < 3.35.0 — DROP COLUMN nesuportat; " + "coloana legacy accounts.rar_creds_enc ramane.", + flush=True, + ) + return + + # AUTO-FIX 6c: re-backfill creds din coloana veche in slotul per-env (ancora globala). + # Independent de _migrate_accounts_medii (care sare pe DB deja migrat cu guard newly_added). + env = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test" + slot = f"rar_creds_{env}_enc" + conn.execute( + f"UPDATE accounts SET {slot}=rar_creds_enc, rar_{env}_enabled=1 " + f"WHERE rar_creds_enc IS NOT NULL AND TRIM(rar_creds_enc)<>'' " + f"AND ({slot} IS NULL OR TRIM({slot})='')" + ) + + _garda_si_drop(conn) + + +def _garda_si_drop(conn: sqlite3.Connection) -> None: + """Garda de siguranta + backup + DROP accounts.rar_creds_enc. Testabila izolat. + + Verifica ca niciun cont nu are creds DOAR in coloana veche (ambele sloturi per-env goale). + Daca exista orfane -> NU dropa (fail-safe: fara pierdere de date). + Altfel: backup criptat, DROP, verificare PRAGMA (AUTO-FIX 6d). + """ + # Garda: orfane = cont cu creds in coloana veche DAR ambele sloturi per-env goale. + orphan_count = conn.execute( + "SELECT COUNT(*) FROM accounts " + "WHERE rar_creds_enc IS NOT NULL AND TRIM(rar_creds_enc)<>'' " + "AND (rar_creds_test_enc IS NULL OR TRIM(rar_creds_test_enc)='') " + "AND (rar_creds_prod_enc IS NULL OR TRIM(rar_creds_prod_enc)='')" + ).fetchone()[0] + + if orphan_count > 0: + print( + f"[db] AVERTISMENT: {orphan_count} cont(uri) cu rar_creds_enc ne-copiat in niciun slot " + "per-env. DROP anulat (fail-safe: fara pierdere de date).", + flush=True, + ) + return + + # Backup criptat inainte de DROP (blob-urile sunt deja criptate Fernet). + conn.execute( + "CREATE TABLE IF NOT EXISTS accounts_rar_creds_enc_backup " + "(account_id INTEGER, rar_creds_enc TEXT, backed_up_at TEXT)" + ) + conn.execute( + "INSERT INTO accounts_rar_creds_enc_backup " + "SELECT id, rar_creds_enc, datetime('now') FROM accounts " + "WHERE rar_creds_enc IS NOT NULL" + ) + + # DROP coloana legacy. + conn.execute("ALTER TABLE accounts DROP COLUMN rar_creds_enc") + + # AUTO-FIX 6d: verifica prin PRAGMA (pe tabela accounts, NU grep — submissions are aceeasi coloana). + cols_after = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} + if "rar_creds_enc" in cols_after: + raise RuntimeError("DROP COLUMN rar_creds_enc esuat: coloana inca prezenta dupa ALTER TABLE") + + print("[db] DROP coloana legacy accounts.rar_creds_enc: OK", flush=True) + + def _backfill_submissions_rar_env(conn: sqlite3.Connection) -> None: """PRD 5.20 US-001 (AUTO-FIX G + E4/3): backfill rar_env + recompute idempotency_key. diff --git a/app/models.py b/app/models.py index 4045912..d493e43 100644 --- a/app/models.py +++ b/app/models.py @@ -84,7 +84,7 @@ class PrezentareRequest(BaseModel): """Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR. `rar_credentials` e OPTIONAL: daca lipseste, worker-ul foloseste creds-urile RAR - durabile salvate pe cont (`accounts.rar_creds_enc`, via POST /v1/conturi/rar-creds). + durabile salvate pe cont (per-env: `accounts.rar_creds_{env}_enc`, via POST /v1/conturi/rar-creds). Trimite-le explicit doar cand vrei sa suprascrii creds-urile contului pe acea cerere. """ diff --git a/app/schema.sql b/app/schema.sql index bcb9956..3da0ce2 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS accounts ( -- vezi accounts.delete_account — randul ramane doar pentru audit). status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('pending','active','blocked','archived','deleted')), - rar_creds_enc TEXT, -- LEGACY (PRD 5.20 US-013 dropeaza coloana): creds RAR durabile env-less -- Medii RAR per cont (PRD 5.20 US-001). Fiecare mediu = bifa de activare + slot creds. + -- accounts.rar_creds_enc (legacy env-less) a fost dropata in US-013 (SQLite DROP COLUMN). -- medii_disponibile = enabled AND creds prezente (app/rar_env.py). Cont client nou = -- Productie on / Testare off (clientii declara real); contul operator se pune manual pe Testare. rar_test_enabled INTEGER NOT NULL DEFAULT 0 CHECK (rar_test_enabled IN (0, 1)), diff --git a/app/web/routes.py b/app/web/routes.py index 74f6e78..4f4eaa2 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -294,13 +294,13 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: acct = account_or_default(account_id) # Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet) - # Verifica atat coloana legacy rar_creds_enc cat si sloturile per-env (US-008, PRD 5.20). + # US-013: citim exclusiv sloturile per-env (legacy accounts.rar_creds_enc a fost dropat). row = conn.execute( - "SELECT id, name, cui, email, rar_creds_enc, rar_creds_test_enc, rar_creds_prod_enc " + "SELECT id, name, cui, email, rar_creds_test_enc, rar_creds_prod_enc " "FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and ( - row["rar_creds_enc"] or row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] + row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] )) # Banner cont incomplet (US-002): contul nu are companie + email + CUI complete cont_incomplet = not _acct_is_complete(row) if row else False @@ -438,9 +438,9 @@ def _render_integrare(request: Request, conn, account_id: int) -> str: acct = account_or_default(account_id) row_creds = conn.execute( - "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (acct,) ).fetchone() - are_creds = bool(row_creds and row_creds["rar_creds_enc"]) + are_creds = bool(row_creds and (row_creds["rar_creds_test_enc"] or row_creds["rar_creds_prod_enc"])) row_key = conn.execute( "SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,) @@ -4241,7 +4241,7 @@ def _fetch_cont_env_state(conn, acct: int) -> dict: """ row = conn.execute( "SELECT rar_test_enabled, rar_prod_enabled, " - "rar_creds_test_enc, rar_creds_prod_enc, rar_env_default, rar_creds_enc " + "rar_creds_test_enc, rar_creds_prod_enc, rar_env_default " "FROM accounts WHERE id=?", (acct,) ).fetchone() if not row: @@ -4263,8 +4263,9 @@ def _fetch_cont_env_state(conn, acct: int) -> dict: medii.append("test") if prod_disponibil: medii.append("prod") + # US-013: are_creds bazat EXCLUSIV pe sloturile per-env (legacy rar_creds_enc dropat). are_creds = bool( - row["rar_creds_enc"] or row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] + row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] ) return { "are_creds": are_creds, @@ -4529,10 +4530,12 @@ def cont_rar_creds( ) enc = encrypt_creds({"email": email, "password": parola}) + # US-013: scrie in slotul per-env al ancorei globale (nu mai exista coloana legacy). + _env_w = get_settings().rar_env if get_settings().rar_env in ("test", "prod") else "test" conn = get_connection() try: conn.execute( - "UPDATE accounts SET rar_creds_enc=? WHERE id=?", + f"UPDATE accounts SET rar_creds_{_env_w}_enc=?, rar_{_env_w}_enabled=1 WHERE id=?", (enc, acct), ) account_meta = _fetch_account_meta(conn, acct) diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 5a53cd6..e52b81e 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -11,11 +11,12 @@ Ruleaza ca proces separat sub `restart: always` (docker compose). - lease/timeout pe randuri 'sending' orfane. - re-login la token expirat (401 mid-sesiune) — JWT 30h, retry NU plafonat la 30h. -Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (rar_creds_enc). +Creds per-cerere: fiecare submission poarta creds RAR CRIPTATE (submissions.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 +STERGE creds-urile efemere dupa primul login reusit. Token-ul in memorie acopera restul trimiterilor; la restart token-ul se pierde si contul re-logheaza la urmatorul -submission care aduce creds proaspete (degradare acceptata). +submission care aduce creds proaspete (degradare acceptata). Fallback durabil: slotul +per-env al contului (accounts.rar_creds_{env}_enc, US-013; coloana legacy dropata). Dev: `worker_use_test_creds` foloseste creds cand submission-ul nu are enc. Pornire: python -m app.worker @@ -161,8 +162,8 @@ 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. - Randul include `account_id` si `rar_creds_enc` (creds RAR criptate) pentru - login-ul per-cont din `run`. + Randul include `account_id` si `submissions.rar_creds_enc` (creds RAR criptate efemere) + pentru login-ul per-cont din `run`. """ conn.execute("BEGIN IMMEDIATE") try: @@ -431,18 +432,17 @@ def _creds_for(claimed: dict, settings: Settings) -> dict | None: def _creds_from_account(conn, account_id: int, rar_env: str = "test") -> dict | None: - """Creds RAR durabile per-cont din slotul per-env, cu fallback la coloana legacy. + """Creds RAR durabile per-cont din slotul per-env (US-013 — coloana legacy dropata). - Canal web: creds in accounts.rar_creds_{rar_env}_enc (per-env). Fallback la - accounts.rar_creds_enc (legacy, back-compat inainte de US-013 care dropa coloana veche). + Canal web: creds in accounts.rar_creds_{rar_env}_enc (per-env, singurul slot valid). """ env_slot = f"rar_creds_{rar_env}_enc" row = conn.execute( - f"SELECT {env_slot}, rar_creds_enc FROM accounts WHERE id=?", (account_id,) + f"SELECT {env_slot} FROM accounts WHERE id=?", (account_id,) ).fetchone() if not row: return None - enc = row[env_slot] or row["rar_creds_enc"] # per-env intai, legacy fallback + enc = row[env_slot] return decrypt_creds(enc) if enc else None @@ -450,16 +450,16 @@ def _keepalive_target(conn, settings: Settings) -> tuple[int | None, dict | None """Un cont cu creds durabile pentru login-ul de proba (sau creds in dev). Ancora M2: cauta in slotul per-env al mediului `settings.rar_env` (ancora globala). - Fallback la coloana legacy `rar_creds_enc` (back-compat inainte de US-013). - Sare conturile ale caror creds NU se decripteaza sub cheia curenta — in dev - `start.sh both` genereaza o cheie efemera noua la fiecare pornire. + US-013: coloana legacy accounts.rar_creds_enc a fost dropata — se foloseste EXCLUSIV + slotul per-env. Sare conturile ale caror creds NU se decripteaza sub cheia curenta + (in dev `start.sh both` genereaza o cheie efemera noua la fiecare pornire). """ env_slot = f"rar_creds_{settings.rar_env}_enc" rows = conn.execute( - f"SELECT id, {env_slot}, rar_creds_enc FROM accounts ORDER BY id" + f"SELECT id, {env_slot} FROM accounts ORDER BY id" ).fetchall() for row in rows: - enc = row[env_slot] or row["rar_creds_enc"] # per-env intai, legacy fallback + enc = row[env_slot] if not enc: continue creds = decrypt_creds(enc) diff --git a/docs/api-rar-contract.md b/docs/api-rar-contract.md index 6f47d74..32dff2a 100644 --- a/docs/api-rar-contract.md +++ b/docs/api-rar-contract.md @@ -507,5 +507,48 @@ răspuns de validare (câmpuri, cod_prestatie, rezolvare operație) ca la trimit Planul Gratuit are limită de **60 prezentări/lună** (indiferent de canal). La depășire: `422 PLAN_LIMITA_LUNARA`. Planul Pro nu are limită de volum. `GET /v1/nomenclator` rămâne public pe orice plan (exploatare pre-upgrade). + +## Creds RAR durabile per-mediu (PRD 5.20 US-013) + +`POST /v1/conturi/rar-creds` — setează credențialele RAR durabile per-cont, cu scriere în slotul per-mediu. + +**Body JSON:** +```json +{ + "email": "user@firma.ro", + "password": "parola", + "rar_target": "test" +} +``` + +`rar_target`: `"test"` | `"prod"` | absent. Absent → ancora globală (`AUTOPASS_RAR_ENV`), implicit `"test"`. +Schimbare **aditivă** (back-compat): clienții care nu trimit `rar_target` continuă să funcționeze. + +Efecte: scrie în `accounts.rar_creds_{env}_enc` + activează `rar_{env}_enabled=1`. + +**Răspuns:** `{"ok": true, "account_id": N, "rar_env": "test"}` + +--- + +`DELETE /v1/conturi/rar-creds` — șterge credențialele RAR durabile per-cont. + +**Query param** `env`: `test` | `prod` | absent. +- `?env=test` → NULL doar slotul Testare + `rar_test_enabled=0`. +- `?env=prod` → NULL doar slotul Producție + `rar_prod_enabled=0`. +- Absent → NULL ambele sloturi (revenire completă la modelul efemer Treapta 1). + +Schimbare **aditivă** (back-compat). + +--- + +`GET /v1/ping` — răspuns include acum și câmpurile per-mediu: +```json +{ + "are_creds_rar": true, + "are_creds_test": true, + "are_creds_prod": false +} +``` +`are_creds_rar` rămâne (back-compat) = OR logic al celor două. diff --git a/tests/test_account_status.py b/tests/test_account_status.py index c76a911..36e6954 100644 --- a/tests/test_account_status.py +++ b/tests/test_account_status.py @@ -94,16 +94,21 @@ def test_delete_purjeaza_pii_si_elibereaza_cui(conn): """Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil).""" from app.accounts import create_account, delete_account, list_accounts acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True) - conn.execute("UPDATE accounts SET rar_creds_enc='secret_enc' WHERE id=?", (acct_id,)) + # US-013: creds in sloturi per-env (rar_creds_enc legacy dropata) + conn.execute( + "UPDATE accounts SET rar_creds_test_enc='secret_enc_test', rar_creds_prod_enc='secret_enc_prod' WHERE id=?", + (acct_id,), + ) conn.execute("INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, 'h', 1)", (acct_id,)) conn.commit() delete_account(conn, acct_id) - row = conn.execute("SELECT status, rar_creds_enc, cui FROM accounts WHERE id=?", + row = conn.execute("SELECT status, rar_creds_test_enc, rar_creds_prod_enc, cui FROM accounts WHERE id=?", (acct_id,)).fetchone() assert row["status"] == "deleted" - assert row["rar_creds_enc"] is None, "creds RAR trebuie purjate la stergere" + assert row["rar_creds_test_enc"] is None, "creds RAR test trebuie purjate la stergere" + assert row["rar_creds_prod_enc"] is None, "creds RAR prod trebuie purjate la stergere" assert row["cui"] is None, "CUI trebuie eliberat la stergere" key_active = conn.execute("SELECT active FROM api_keys WHERE account_id=?", (acct_id,)).fetchone() assert key_active["active"] == 0, "cheile API trebuie revocate" @@ -133,15 +138,16 @@ def test_migrare_deriva_status_din_active(conn): from app.db import _migrate # Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi). + # US-013: rar_creds_enc nu mai exista in accounts (dropata); nu e in legacy DDL. conn.executescript( """ PRAGMA foreign_keys=OFF; CREATE TABLE accounts_legacy ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT, - active INTEGER NOT NULL DEFAULT 1, rar_creds_enc TEXT, created_at TEXT + active INTEGER NOT NULL DEFAULT 1, created_at TEXT ); - INSERT INTO accounts_legacy (id, name, cui, active, rar_creds_enc, created_at) - SELECT id, name, cui, active, rar_creds_enc, created_at FROM accounts; + INSERT INTO accounts_legacy (id, name, cui, active, created_at) + SELECT id, name, cui, active, created_at FROM accounts; DROP TABLE accounts; ALTER TABLE accounts_legacy RENAME TO accounts; """ diff --git a/tests/test_dedup_error.py b/tests/test_dedup_error.py index ece28a1..30a568d 100644 --- a/tests/test_dedup_error.py +++ b/tests/test_dedup_error.py @@ -82,16 +82,16 @@ def test_resubmit_actualizeaza_creds_pe_reactivare(client): enc_dupa = _creds_enc(sid) assert enc_dupa is not None assert enc_dupa != enc_initial, "creds-urile trebuie actualizate la reactivare" - # propagat si in accounts.rar_creds_enc (canal durabil, decizie #17) + # US-013: creds propagate in slotul per-env (rar_creds_test_enc, ancora globala=test) from app.db import get_connection from app.crypto import decrypt_creds conn = get_connection() try: - row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=1").fetchone() + row = conn.execute("SELECT rar_creds_test_enc FROM accounts WHERE id=1").fetchone() finally: conn.close() - assert row["rar_creds_enc"] is not None - assert decrypt_creds(row["rar_creds_enc"])["password"] == "parolaNoua" + assert row["rar_creds_test_enc"] is not None + assert decrypt_creds(row["rar_creds_test_enc"])["password"] == "parolaNoua" def test_resubmit_peste_sent_ramane_deduped(client): diff --git a/tests/test_foundation.py b/tests/test_foundation.py index 6682cc1..4e0ca0c 100644 --- a/tests/test_foundation.py +++ b/tests/test_foundation.py @@ -34,9 +34,14 @@ def _tables(conn) -> set[str]: # --- Coloane noi --- -def test_accounts_rar_creds_enc(db_conn): - cols = _table_cols(db_conn, "accounts") - assert "rar_creds_enc" in cols +def test_accounts_rar_creds_enc_dropata(db_conn): + """US-013: accounts.rar_creds_enc a fost dropata; submissions.rar_creds_enc ramane.""" + acc_cols = _table_cols(db_conn, "accounts") + sub_cols = _table_cols(db_conn, "submissions") + assert "rar_creds_enc" not in acc_cols, \ + "accounts.rar_creds_enc trebuie sa fie ABSENTA dupa US-013 DROP" + assert "rar_creds_enc" in sub_cols, \ + "submissions.rar_creds_enc trebuie sa RAMANA (creds efemere per-cerere)" def test_submissions_batch_id(db_conn): @@ -153,7 +158,8 @@ def test_migrate_on_existing_db(monkeypatch): assert "batch_id" in sub_cols assert "row_index" in sub_cols assert "purge_after" in sub_cols - assert "rar_creds_enc" in acc_cols + # US-013: accounts.rar_creds_enc a fost dropata. + assert "rar_creds_enc" not in acc_cols conn.close() get_settings.cache_clear() diff --git a/tests/test_import_e2e.py b/tests/test_import_e2e.py index f68309e..0b0f431 100644 --- a/tests/test_import_e2e.py +++ b/tests/test_import_e2e.py @@ -5,9 +5,9 @@ Acopera (plan T15, sect.12): -> worker process_one (MockRar) -> submission FINALIZATA cu id_prezentare. - Scenariul 2: re-upload acelasi continut -> preview marcheaza already_sent (NU al doilea FINALIZATA dupa commit). - - Scenariul 3: coada MIXTA API(creds efemere)+web(creds durabile accounts.rar_creds_enc): + - Scenariul 3: coada MIXTA API(creds efemere)+web(creds durabile per-env, US-013): dupa login + purjare creds efemere, submission-urile web tot se trimit - prin fallback accounts.rar_creds_enc (T1/Voce#5). + prin fallback accounts.rar_creds_{env}_enc (T1/Voce#5). - Masina de stari (sect. 6): tranzitii queued->sending->sent/requeued/error. - Failure registry (sect. 8): 400/403/503+reconciliere. - T16: purge_expired + purge_after setat la commit. @@ -454,7 +454,7 @@ class TestE2EMixedQueue: """Scenariul 3 (T1/Voce#5): API (creds efemere) + web (canal import, fara creds pe submission). Dupa purjarea creds efemere ale canalului API (la primul login), submission-urile - import (web) tot se trimit prin fallback accounts.rar_creds_enc. + import (web) tot se trimit prin fallback accounts.rar_creds_{env}_enc (US-013, per-env). Verificam ca ambele submission-uri ajung la status='sent'. """ import app.worker.__main__ as w @@ -486,11 +486,15 @@ class TestE2EMixedQueue: assert r_api.status_code == 200, r_api.text sub_api_id = r_api.json()["results"][0]["submission_id"] - # 3. Seteaza creds durabile in accounts (canal web fallback) + # 3. Seteaza creds durabile in accounts per-env (US-013, canal web fallback) conn = get_connection() try: web_creds_enc = encrypt_creds({"email": "web@test.ro", "password": "pass_web"}) - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (web_creds_enc,)) + # US-013: scrie in slotul per-env test (rar_env default = 'test') + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 WHERE id=1", + (web_creds_enc,), + ) conn.commit() # Verifica precondita: sub_web (import) NU are creds pe submission @@ -518,7 +522,7 @@ class TestE2EMixedQueue: try: processed = 0 - # Drena coada simuland bucla worker completa (T1/D4: creds efemere + fallback durabil) + # Drena coada simuland bucla worker completa (T1/D4: creds efemere + fallback per-env) for _ in range(10): # limita de siguranta claimed = w.claim_one(conn) if claimed is None: @@ -526,9 +530,10 @@ class TestE2EMixedQueue: sid = claimed["id"] account_id = claimed["account_id"] + rar_env = claimed.get("rar_env", "test") - # T1/D4: creds din submission (API efemer) OR fallback accounts.rar_creds_enc (web) - creds = w._creds_for(claimed, settings) or w._creds_from_account(conn, account_id) + # T1/D4: creds din submission (API efemer) OR fallback per-env (US-013) + creds = w._creds_for(claimed, settings) or w._creds_from_account(conn, account_id, rar_env=rar_env) assert creds is not None, \ f"Creds None pentru submission {sid} — fallback durabil trebuie sa existe" @@ -561,9 +566,9 @@ class TestE2EMixedQueue: assert mock_rar.post_calls == 2 def test_purjare_creds_efemere_nu_sterge_durabile(self, env, monkeypatch): - """T1/Gate purjare (OV-5): dupa login, submissions.rar_creds_enc sterse DAR accounts.rar_creds_enc INTACT. + """T1/Gate purjare (OV-5): dupa login, submissions.rar_creds_enc sterse DAR per-env INTACT. - Worker sterge DOAR submissions.rar_creds_enc (efemere), NU accounts.rar_creds_enc (durabile). + Worker sterge DOAR submissions.rar_creds_enc (efemere), NU accounts.rar_creds_{env}_enc (durabile, US-013). """ import app.worker.__main__ as w from app.crypto import encrypt_creds @@ -585,11 +590,14 @@ class TestE2EMixedQueue: assert r_api.status_code == 200 sub_id = r_api.json()["results"][0]["submission_id"] - # Seteaza creds durabile pe cont + # Seteaza creds durabile pe cont (slotul per-env, US-013) conn = get_connection() try: web_creds_enc = encrypt_creds({"email": "web@test.ro", "password": "pass_web"}) - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (web_creds_enc,)) + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 WHERE id=1", + (web_creds_enc,), + ) conn.commit() finally: conn.close() @@ -602,8 +610,9 @@ class TestE2EMixedQueue: try: claimed = w.claim_one(conn) assert claimed is not None + rar_env = claimed.get("rar_env", "test") - creds = w._creds_for(claimed, settings) or w._creds_from_account(conn, claimed["account_id"]) + creds = w._creds_for(claimed, settings) or w._creds_from_account(conn, claimed["account_id"], rar_env=rar_env) sessions.get_token(conn, claimed["account_id"], creds) # Dupa login: submissions.rar_creds_enc sterse (creds efemere purjate) @@ -613,12 +622,12 @@ class TestE2EMixedQueue: assert row["rar_creds_enc"] is None, \ "submissions.rar_creds_enc trebuie sterse dupa login (efemere)" - # accounts.rar_creds_enc TREBUIE sa ramana (durabile, nu se sterg) + # accounts.rar_creds_test_enc TREBUIE sa ramana (durabile per-env, nu se sterg) acc_row = conn.execute( - "SELECT rar_creds_enc FROM accounts WHERE id=1" + "SELECT rar_creds_test_enc FROM accounts WHERE id=1" ).fetchone() - assert acc_row["rar_creds_enc"] is not None, \ - "accounts.rar_creds_enc trebuie sa RAMANA intact dupa purjarea creds efemere" + assert acc_row["rar_creds_test_enc"] is not None, \ + "accounts.rar_creds_test_enc trebuie sa RAMANA intact dupa purjarea creds efemere" finally: conn.close() sessions.close_all() diff --git a/tests/test_integrare_api.py b/tests/test_integrare_api.py index ea4dc22..f18f7fa 100644 --- a/tests/test_integrare_api.py +++ b/tests/test_integrare_api.py @@ -61,13 +61,16 @@ def _creeaza_cheie(monkeypatch) -> str: def _seteaza_rar_creds(monkeypatch=None) -> None: - """Seteaza rar_creds_enc pe contul id=1.""" + """Seteaza creds RAR pe contul id=1 (slotul per-env, US-013).""" from app.db import get_connection from app.crypto import encrypt_creds conn = get_connection() try: enc = encrypt_creds({"email": "test@rar.ro", "password": "secret"}) - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (enc,)) + # US-013: scrie in slotul per-env (test = ancora globala in teste) + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 WHERE id=1", (enc,) + ) conn.commit() finally: conn.close() diff --git a/tests/test_retragere_creds_enc.py b/tests/test_retragere_creds_enc.py new file mode 100644 index 0000000..0186f71 --- /dev/null +++ b/tests/test_retragere_creds_enc.py @@ -0,0 +1,282 @@ +"""Teste US-013 (PRD 5.20): retragere coloana legacy accounts.rar_creds_enc. + +Teste: + test_niciun_read_pe_coloana_veche -- DB fresh: accounts N-are rar_creds_enc, submissions ARE + test_conturi_rar_creds_env_aware -- POST /v1/conturi/rar-creds scrie in slotul per-env corect + test_are_creds_pe_per_env -- are_creds (web/integrare) reflecta sloturile per-env + test_drop_cu_garda_blocat_daca_lipsa_copiere -- garda refuza DROP cand exista orfane +""" + +from __future__ import annotations + +import os +import sqlite3 +import tempfile + +import pytest +from cryptography.fernet import Fernet +from starlette.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def client(monkeypatch): + """Client izolat cu DB temporara + cheie Fernet pentru criptare creds.""" + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_retragere.db")) + monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("AUTOPASS_RAR_ENV", "test") + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false") + from app.config import get_settings + from app import crypto + + get_settings.cache_clear() + crypto.reset_cache() + from app.main import app + + with TestClient(app, follow_redirects=False) as c: + yield c + + get_settings.cache_clear() + crypto.reset_cache() + + +@pytest.fixture() +def conn_izolat(monkeypatch): + """Conexiune directa la DB temporara (fara app), pentru teste de migrare in-process.""" + tmp = tempfile.mkdtemp() + db_path = os.path.join(tmp, "t_migrare.db") + monkeypatch.setenv("AUTOPASS_DB_PATH", db_path) + monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("AUTOPASS_RAR_ENV", "test") + from app.config import get_settings + from app import crypto + + get_settings.cache_clear() + crypto.reset_cache() + from app.db import init_db, get_connection + + init_db() + conn = get_connection() + conn.row_factory = sqlite3.Row + yield conn + conn.close() + get_settings.cache_clear() + crypto.reset_cache() + + +# --------------------------------------------------------------------------- +# Helpere +# --------------------------------------------------------------------------- + + +def _create_account_user( + name: str = "Service Test SRL", + email: str = "user@test.com", + password: str = "parolasecreta10", +): + """Creeaza cont + user. Returneaza (acct_id, user_id).""" + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + + conn = get_connection() + try: + acct_id = create_account(conn, name, active=True) + user_id = create_user(conn, acct_id, email, password) + return acct_id, user_id + finally: + conn.close() + + +def _login_web(client, email: str, password: str) -> None: + """Face login prin HTTP si seteaza cookie-ul de sesiune.""" + import re + resp = client.get("/login") + assert resp.status_code == 200 + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + if not m: + m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + assert m, "csrf_token negasit pe /login" + csrf = m.group(1) + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf}) + assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}" + + +# --------------------------------------------------------------------------- +# Teste +# --------------------------------------------------------------------------- + + +def test_niciun_read_pe_coloana_veche(client): + """DB fresh dupa init_db NU are accounts.rar_creds_enc; submissions INCA are rar_creds_enc.""" + from app.db import get_connection + + conn = get_connection() + try: + acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} + sub_cols = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()} + finally: + conn.close() + + assert "rar_creds_enc" not in acc_cols, ( + "accounts.rar_creds_enc INCA prezenta dupa init_db pe DB fresh — DROP esuat sau schema neactualizata" + ) + assert "rar_creds_enc" in sub_cols, ( + "submissions.rar_creds_enc TREBUIE sa ramana (creds efemere per-cerere)" + ) + + +def test_conturi_rar_creds_env_aware(client): + """POST /v1/conturi/rar-creds cu rar_target='test' scrie in rar_creds_test_enc + rar_test_enabled=1. + Fara rar_target -> scrie in slotul ancorei globale (AUTOPASS_RAR_ENV='test'). + """ + from app.db import get_connection + + acct_id, _ = _create_account_user("Firma ENV1", "env1@test.com") + acct2_id, _ = _create_account_user("Firma ENV2", "env2@test.com") + + # Cu rar_target='test' explicit + resp = client.post( + "/v1/conturi/rar-creds", + json={"email": "rar@firma.ro", "password": "parolatest", "rar_target": "test"}, + headers={"X-Account-ID": str(acct_id)}, + ) + # Fara cheie API in dev, cont implicit id=1; dar testam prin conn directa + # POST cu rar_target='test' pe contul 1 (dev, WEB_AUTH_REQUIRED=false) + resp_t = client.post( + "/v1/conturi/rar-creds", + json={"email": "rar_test@firma.ro", "password": "parolatest123", "rar_target": "test"}, + ) + assert resp_t.status_code == 200, f"set_rar_creds(test) esuat: {resp_t.text}" + body_t = resp_t.json() + assert body_t.get("ok") is True + assert body_t.get("rar_env") == "test", f"rar_env asteptat 'test', primit: {body_t}" + + conn = get_connection() + try: + row_t = conn.execute( + "SELECT rar_creds_test_enc, rar_test_enabled FROM accounts WHERE id=1" + ).fetchone() + finally: + conn.close() + + assert row_t["rar_creds_test_enc"] is not None, "rar_creds_test_enc trebuia scris" + assert row_t["rar_test_enabled"] == 1, "rar_test_enabled trebuia setat la 1" + + # Fara rar_target -> ancora globala = 'test' (AUTOPASS_RAR_ENV=test) + resp_n = client.post( + "/v1/conturi/rar-creds", + json={"email": "rar_noenv@firma.ro", "password": "paroladefault"}, + ) + assert resp_n.status_code == 200, f"set_rar_creds(no env) esuat: {resp_n.text}" + body_n = resp_n.json() + assert body_n.get("rar_env") == "test", f"rar_env implicit asteptat 'test': {body_n}" + + # Cu rar_target='prod' -> scrie in rar_creds_prod_enc + resp_p = client.post( + "/v1/conturi/rar-creds", + json={"email": "rar_prod@firma.ro", "password": "parolaprod456", "rar_target": "prod"}, + ) + assert resp_p.status_code == 200, f"set_rar_creds(prod) esuat: {resp_p.text}" + body_p = resp_p.json() + assert body_p.get("rar_env") == "prod", f"rar_env asteptat 'prod': {body_p}" + + conn = get_connection() + try: + row_p = conn.execute( + "SELECT rar_creds_prod_enc, rar_prod_enabled FROM accounts WHERE id=1" + ).fetchone() + finally: + conn.close() + + assert row_p["rar_creds_prod_enc"] is not None, "rar_creds_prod_enc trebuia scris la rar_target=prod" + assert row_p["rar_prod_enabled"] == 1, "rar_prod_enabled trebuia setat la 1" + + +def test_are_creds_pe_per_env(client): + """GET /v1/ping are_creds_rar/are_creds_test/are_creds_prod reflecta sloturile per-env. + Fara creds -> are_creds_rar=False. Cu creds test -> are_creds_test=True, are_creds_rar=True. + """ + # Stare initiala: niciun slot cu creds + resp0 = client.get("/v1/ping") + assert resp0.status_code == 200 + body0 = resp0.json() + assert body0.get("are_creds_rar") is False, f"are_creds_rar asteptat False initial: {body0}" + assert body0.get("are_creds_test") is False, f"are_creds_test asteptat False: {body0}" + assert body0.get("are_creds_prod") is False, f"are_creds_prod asteptat False: {body0}" + + # Seteaza creds test + client.post( + "/v1/conturi/rar-creds", + json={"email": "rar@test.ro", "password": "parola123", "rar_target": "test"}, + ) + + resp1 = client.get("/v1/ping") + assert resp1.status_code == 200 + body1 = resp1.json() + assert body1.get("are_creds_rar") is True, f"are_creds_rar asteptat True dupa set test: {body1}" + assert body1.get("are_creds_test") is True, f"are_creds_test asteptat True: {body1}" + assert body1.get("are_creds_prod") is False, f"are_creds_prod asteptat False: {body1}" + + # DELETE ?env=test -> test dispare, prod ramane False + resp_d = client.delete("/v1/conturi/rar-creds?env=test") + assert resp_d.status_code == 200 + + resp2 = client.get("/v1/ping") + body2 = resp2.json() + assert body2.get("are_creds_rar") is False, f"are_creds_rar asteptat False dupa delete test: {body2}" + assert body2.get("are_creds_test") is False + + +def test_drop_cu_garda_blocat_daca_lipsa_copiere(conn_izolat): + """Garda refuza DROP cand exista cont cu rar_creds_enc dar ambele sloturi per-env goale. + + Apelam _garda_si_drop direct (fara re-backfill 6c) pe o stare adversariala: + cont cu valoare in coloana legacy, rar_creds_test_enc=NULL, rar_creds_prod_enc=NULL. + Rezultat asteptat: DROP refuzat (coloana inca prezenta pe accounts). + """ + conn = conn_izolat + + # Adaugam manual coloana legacy daca nu exista (DB fresh a dropat-o deja prin _drop_legacy) + acc_cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} + if "rar_creds_enc" not in acc_cols: + conn.execute("ALTER TABLE accounts ADD COLUMN rar_creds_enc TEXT") + conn.commit() + + # Stare adversariala: insert valoare dummy in coloana legacy, sloturi per-env goale + conn.execute( + "UPDATE accounts SET rar_creds_enc='DUMMY_CRIPTAT', " + "rar_creds_test_enc=NULL, rar_creds_prod_enc=NULL WHERE id=1" + ) + conn.commit() + + # Verifica ca starea adversariala e corecta inainte de test + row_before = conn.execute( + "SELECT rar_creds_enc, rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=1" + ).fetchone() + assert row_before["rar_creds_enc"] == "DUMMY_CRIPTAT" + assert row_before["rar_creds_test_enc"] is None + assert row_before["rar_creds_prod_enc"] is None + + # Apelam _garda_si_drop direct (fara re-backfill 6c) + from app.db import _garda_si_drop + _garda_si_drop(conn) + + # Garda trebuia sa refuze DROP-ul: coloana rar_creds_enc inca prezenta pe accounts + acc_cols_after = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()} + assert "rar_creds_enc" in acc_cols_after, ( + "Garda a permis DROP cand exista orfane — pierdere de date (BUG CRITIC)" + ) + + # Valoarea legacy trebuie sa fie inca prezenta (neatinsa) + row_after = conn.execute( + "SELECT rar_creds_enc FROM accounts WHERE id=1" + ).fetchone() + assert row_after["rar_creds_enc"] == "DUMMY_CRIPTAT", ( + "Valoarea rar_creds_enc disparuta desi DROP a fost refuzat" + ) diff --git a/tests/test_t1_creds_durabile.py b/tests/test_t1_creds_durabile.py index d99b5ac..257e82a 100644 --- a/tests/test_t1_creds_durabile.py +++ b/tests/test_t1_creds_durabile.py @@ -1,9 +1,11 @@ -"""Teste T1: accounts.rar_creds_enc durabile + worker re-login fallback + gate purjare. +"""Teste T1: creds durabile per-cont (per-env, US-013) + 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). + +US-013: accounts.rar_creds_enc (legacy) a fost dropata; sloturile per-env sunt singura sursa. """ from __future__ import annotations @@ -81,20 +83,23 @@ def test_creds_from_account_fallback(env, monkeypatch): 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,)) + # US-013: scrie in slotul per-env test (slotul legacy rar_creds_enc dropat) + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 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) + # _creds_from_account trebuie sa returneze creds din slotul per-env test + creds = w._creds_from_account(conn, 1, rar_env="test") 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).""" + """Cont fara creds durabile (niciun slot per-env) -> None (canal API pur, neatins).""" import app.worker.__main__ as w from app.db import get_connection @@ -117,7 +122,10 @@ def test_worker_relogin_dupa_restart(env, monkeypatch): 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,)) + # US-013: scrie in slotul per-env test + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 WHERE id=1", (enc,) + ) # Submission web fara creds (creds deja purjate de primul login) _insert(conn, account_id=1, creds_enc=None) @@ -126,8 +134,8 @@ def test_worker_relogin_dupa_restart(env, monkeypatch): 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) + # Creds din account (per-env) -> login posibil + creds = w._creds_from_account(conn, 1, rar_env="test") assert creds is not None token = sessions.get_token(conn, 1, creds) assert token == "TOK-web@test.ro" @@ -141,10 +149,10 @@ 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) + 1. S1 = submission API cu creds efemere in submissions.rar_creds_enc + 2. S2 = submission WEB fara creds (foloseste accounts.rar_creds_test_enc per-env) + 3. Login cu creds S1 -> purjare S1.rar_creds_enc (pe submissions) -> OK (worker are token) + 4. S2 tot se poate procesa (creds din accounts per-env, US-013) """ import app.worker.__main__ as w from app.crypto import encrypt_creds @@ -154,11 +162,13 @@ def test_coada_mixta_api_web(env, monkeypatch): conn = get_connection() try: - # Creds durabile pentru contul web + # Creds durabile pentru contul web (slotul per-env, US-013) enc_web = encrypt_creds({"email": "web@test.ro", "password": "webpass"}) - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=1", (enc_web,)) + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 WHERE id=1", (enc_web,) + ) - # S1: canal API cu creds efemere + # S1: canal API cu creds efemere in submission 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 @@ -166,24 +176,24 @@ def test_coada_mixta_api_web(env, monkeypatch): sessions = w.AccountSessions(w.get_settings()) - # Procesare S1: login cu creds API -> purjare rar_creds_enc pe TOATE submission-urile contului + # Procesare S1: login cu creds API -> purjare submissions.rar_creds_enc 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 + sessions.get_token(conn, 1, creds_s1) # login + purjare submissions.rar_creds_enc - # Verifica purjarea: S1.rar_creds_enc = NULL acum + # Verifica purjarea: S1.rar_creds_enc (submissions) = 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) + # S2 nu mai are creds in submission (nici nu a avut); fallback la accounts per-env + creds_s2 = w._creds_for({"creds_enc": None}, w.get_settings()) or w._creds_from_account(conn, 1, rar_env="test") assert creds_s2 == {"email": "web@test.ro", "password": "webpass"}, \ - "S2 trebuie sa ia creds din accounts.rar_creds_enc" + "S2 trebuie sa ia creds din accounts.rar_creds_test_enc (per-env)" - # 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" + # accounts.rar_creds_test_enc NU a fost atins de purjarea submissions + row_acc = conn.execute("SELECT rar_creds_test_enc FROM accounts WHERE id=1").fetchone() + assert row_acc["rar_creds_test_enc"] is not None, \ + "accounts.rar_creds_test_enc trebuie sa ramana dupa purjare submissions" finally: conn.close() @@ -199,29 +209,37 @@ def client(env): def test_endpoint_set_rar_creds(client, env): - """POST /v1/conturi/rar-creds seteaza creds criptate in accounts.""" + """POST /v1/conturi/rar-creds seteaza creds criptate in slotul per-env (US-013).""" from app.crypto import decrypt_creds from app.db import get_connection + # Fara rar_target -> ancora globala (test in fixture, AUTOPASS_RAR_ENV implicit) 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 + # Raspunsul include rar_env folosit + assert "rar_env" in r.json() 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"]) + # Cel putin unul din sloturi trebuie sa fie populat + row = conn.execute( + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=1" + ).fetchone() + enc = row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] + assert enc is not None, "Cel putin un slot per-env trebuia populat" + creds = decrypt_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 + """DELETE /v1/conturi/rar-creds sterge creds durabile (ambele sloturi per-env, US-013).""" + # Mai intai seteaza pe test si prod + client.post("/v1/conturi/rar-creds", json={"email": "u@test.ro", "password": "pass123", "rar_target": "test"}) + client.post("/v1/conturi/rar-creds", json={"email": "u@prod.ro", "password": "pass456", "rar_target": "prod"}) + # Sterge fara env -> ambele sloturi NULL r = client.delete("/v1/conturi/rar-creds") assert r.status_code == 200 assert r.json()["ok"] is True @@ -229,14 +247,17 @@ def test_endpoint_delete_rar_creds(client, env): 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 + row = conn.execute( + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=1" + ).fetchone() + assert row["rar_creds_test_enc"] is None, "rar_creds_test_enc trebuia sters" + assert row["rar_creds_prod_enc"] is None, "rar_creds_prod_enc trebuia sters" 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.""" + """Gate purjare T1: stergerea submissions.rar_creds_enc NU atinge accounts per-env (US-013).""" import app.worker.__main__ as w from app.crypto import encrypt_creds from app.db import get_connection @@ -246,15 +267,18 @@ def test_gate_purjare_nu_sterge_accounts(env, monkeypatch): 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,)) + # US-013: scrie in slotul per-env test + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 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" + # accounts.rar_creds_test_enc NU trebuie atins de purjarea submissions + row = conn.execute("SELECT rar_creds_test_enc FROM accounts WHERE id=1").fetchone() + assert row["rar_creds_test_enc"] is not None, \ + "gate purjare: accounts.rar_creds_test_enc trebuie sa ramana intact" finally: conn.close() diff --git a/tests/test_web_cont.py b/tests/test_web_cont.py index 78284d4..e9c0864 100644 --- a/tests/test_web_cont.py +++ b/tests/test_web_cont.py @@ -131,7 +131,7 @@ def test_roteste_cheie_afisata_o_data(client): # ============================================================ def test_set_creds_rar_din_sesiune(client): - """User logat seteaza creds RAR: accounts.rar_creds_enc != NULL, decriptabil.""" + """User logat seteaza creds RAR: slotul per-env (US-013) != NULL, decriptabil.""" acct_id, user_id, _ = _create_account_user("creds@test.com") _login(client, "creds@test.com", "parolasecreta10") @@ -147,17 +147,18 @@ def test_set_creds_rar_din_sesiune(client): assert "succes" in resp.text.lower() or "salvat" in resp.text.lower() or "configurat" in resp.text.lower(), \ f"Mesaj de succes lipsa: {resp.text[:500]}" - # Verifica in DB: rar_creds_enc setat si decriptabil + # Verifica in DB: slotul per-env setat si decriptabil (US-013: rar_creds_enc legacy dropata) from app.db import get_connection from app.crypto import decrypt_creds conn = get_connection() try: row = conn.execute( - "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_id,) + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (acct_id,) ).fetchone() assert row is not None - assert row["rar_creds_enc"] is not None, "rar_creds_enc trebuia setat" - creds = decrypt_creds(row["rar_creds_enc"]) + enc = row["rar_creds_test_enc"] or row["rar_creds_prod_enc"] + assert enc is not None, "Cel putin un slot per-env trebuia setat (US-013)" + creds = decrypt_creds(enc) assert creds is not None assert creds.get("email") == "user@rar.ro" assert creds.get("password") == "parolaRAR123" @@ -170,7 +171,7 @@ def test_set_creds_rar_din_sesiune(client): # ============================================================ def test_creds_alt_cont_neafectat(client): - """User A seteaza creds -> contul B ramane cu rar_creds_enc NULL.""" + """User A seteaza creds -> contul B ramane fara creds (sloturi per-env NULL, US-013).""" acct_a, user_a, _ = _create_account_user("userA@test.com") acct_b, user_b, _ = _create_account_user("userB@test.com") @@ -184,14 +185,20 @@ def test_creds_alt_cont_neafectat(client): }) assert resp.status_code == 200 - # Verifica: contul A are creds, contul B ramane NULL + # Verifica: contul A are creds in slotul per-env, contul B ramane NULL (US-013) from app.db import get_connection conn = get_connection() try: - row_a = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_a,)).fetchone() - row_b = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_b,)).fetchone() - assert row_a["rar_creds_enc"] is not None, "Contul A trebuia sa aiba creds" - assert row_b["rar_creds_enc"] is None, "Contul B nu trebuia atins" + row_a = conn.execute( + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (acct_a,) + ).fetchone() + row_b = conn.execute( + "SELECT rar_creds_test_enc, rar_creds_prod_enc FROM accounts WHERE id=?", (acct_b,) + ).fetchone() + enc_a = row_a["rar_creds_test_enc"] or row_a["rar_creds_prod_enc"] + enc_b = row_b["rar_creds_test_enc"] or row_b["rar_creds_prod_enc"] + assert enc_a is not None, "Contul A trebuia sa aiba creds in slotul per-env" + assert enc_b is None, "Contul B nu trebuia atins" finally: conn.close() diff --git a/tests/test_web_onboarding.py b/tests/test_web_onboarding.py index b35971b..8ef6f1a 100644 --- a/tests/test_web_onboarding.py +++ b/tests/test_web_onboarding.py @@ -58,7 +58,7 @@ def _login(client, email: str, password: str = "parolasecreta10") -> None: def _set_rar_creds(acct_id: int) -> None: - """Seteaza rar_creds_enc pe cont (simuleaza configurarea credentialelor RAR).""" + """Seteaza creds RAR pe cont in slotul per-env test (US-013 — legacy rar_creds_enc dropata).""" from app.db import get_connection from app.crypto import encrypt_creds @@ -66,9 +66,10 @@ def _set_rar_creds(acct_id: int) -> None: try: enc = encrypt_creds({"email": "test@rar.ro", "password": "parola_rar"}) conn.execute( - "UPDATE accounts SET rar_creds_enc=? WHERE id=?", + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1 WHERE id=?", (enc, acct_id), ) + conn.commit() finally: conn.close() diff --git a/tests/test_worker_keepalive_rar.py b/tests/test_worker_keepalive_rar.py index d87e69f..7b3335e 100644 --- a/tests/test_worker_keepalive_rar.py +++ b/tests/test_worker_keepalive_rar.py @@ -67,11 +67,18 @@ def _set_last_login(conn, *, ago_s: float | None): def _account_cu_creds(conn) -> int: + """Creeaza cont cu creds in slotul per-env (US-013 — legacy rar_creds_enc dropata).""" from app.accounts import create_account from app.crypto import encrypt_creds acct = create_account(conn, "Service Cu Creds", email="svc@example.com") enc = encrypt_creds({"email": "svc@example.com", "password": "secret"}) - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct)) + # US-013: scrie in slotul per-env; rar_env din fixture = valoarea default (test sau prod). + # Folosim rar_creds_test_enc si rar_creds_prod_enc (ambele) pentru robustete. + conn.execute( + "UPDATE accounts SET rar_creds_test_enc=?, rar_test_enabled=1, " + "rar_creds_prod_enc=?, rar_prod_enabled=1 WHERE id=?", + (enc, enc, acct), + ) conn.commit() return acct @@ -155,11 +162,14 @@ def test_target_sare_creds_nedecriptabile(env): settings.worker_use_test_creds = False # Cont cu creds GUNOI (nedecriptabile sub cheia curenta), id mai mic. bad = create_account(conn, "Cont Cheie Veche", email="old@example.com") - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", ("gAAAAA-token-invalid", bad)) + # US-013: scrie in slotul per-env (rar_env = settings.rar_env, implicit in fixture) + bad_slot = f"rar_creds_{settings.rar_env}_enc" + conn.execute(f"UPDATE accounts SET {bad_slot}='gAAAAA-token-invalid' WHERE id=?", (bad,)) # Cont cu creds valide, id mai mare. good = create_account(conn, "Cont Valid", email="good@example.com") enc = encrypt_creds({"email": "good@example.com", "password": "pw"}) - conn.execute("UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, good)) + good_slot = f"rar_creds_{settings.rar_env}_enc" + conn.execute(f"UPDATE accounts SET {good_slot}=? WHERE id=?", (enc, good)) conn.commit() acct_id, creds = _keepalive_target(conn, settings)