Compare commits

..

6 Commits

Author SHA1 Message Date
Claude Agent
96973f60f3 test: repara flake nocturn miez-noapte RO + 2 teste stale landing/login
test_web_status::test_granita_miez_noapte_local_ro: ancoreaza boundary pe RO-now
(00:30 RO local, mereu ziua UTC precedenta, DST-aware) in loc de today_utc. Bug
vechi: boundary pe today_utc pica in fereastra de dupa miezul noptii RO cand
date('now','localtime') e deja ziua urmatoare. Fereastra de esec de ~3h -> race
sub-secunda la exact miezul noptii.

test_web_responsive::test_login_branded_nu_schelet: loginul a fost simplificat la
o coloana (commit 'simplifica login'); nu mai cerem .login-aside. Guard pastrat pe
.login-shell + titlu ROA AUTOPASS + POST /login + CSRF. Comentariile stale '2 coloane'
din login.html aliniate la realitate (o coloana).

test_web_responsive::test_landing_limita_60: terminologia landing prestații->trimiteri
(rework ff9d0f4); asertie pe '60 de trimiteri' (limita Gratuit), scoase asertiile pe
formularile vechi 'prestații'/'prezentări'.

Suita: 1458 passed, 1 deselected (live), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:25:01 +00:00
Claude Agent
cccd032ae4 docs(5.20): ROADMAP 5.20 LIVRAT + VERIFY PASS + raport VERIFY in PRD
Stadiu Implementare: 5.20 -> LIVRAT (13/13 stories, comituri per story).
PRD sectiunea 8: raport VERIFY (comituri, DROP cu garda 6a-6d, suita
1455 passed, 3 esecuri ne-legate incl. flake nocturn miez-noapte RO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:05:08 +00:00
Claude Agent
b1d825e66b feat(5.20): US-013 retragere accounts.rar_creds_enc -> per-env + DROP cu garda
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) <noreply@anthropic.com>
2026-07-02 21:03:08 +00:00
Claude Agent
3d3eb71a1e feat(5.20): US-012 audit evenimente medii + teste e2e
log_event best-effort (refoloseste conn apelantului, fara PII in context) la:
- rar_env_activat / rar_env_dezactivat: activare/dezactivare mediu in cont_rar_medii
- rar_env_default_schimbat: schimbare efectiva default in cont_rar_medii si in
  toggle-ul din statusbar (fragment_status_toggle_env)
- rar_env_blocat: tinta indisponibila — 422 pe canalul API (router.py) + WARNING
  pe caile de import web (fallback existent neschimbat, doar logging adaugat)

tests/test_e2e_rar_env.py: lant import->queued cu rar_env corect (ambele canale),
activare Productie logata, tinta indisponibila blocata + logata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:26:28 +00:00
Claude Agent
0a1df31126 feat(5.20): US-010 badge mediu RAR in liste/preview/detaliu/jurnal + audit + ecou API
labels.py: ETICHETE_ENV + eticheta_env(env)->(text,css). Productie afisata
"PRODUCȚIE" (majuscule+diacritice) cu badge fill de atentie (--err), Testare
outline discret muted — semnalizare risc L.142 (declaratie reala ireversibila).
Clase .env-badge-prod / .env-badge-test in base.html; eticheta_env expus ca
global Jinja.

Badge de mediu per rand in _submissions, _coada implicit prin view, _preview_rand,
_trimitere_detaliu, _jurnal. Statusbar (_status.html) aliniat la aceeasi conventie
(Productie = atentie, nu verde) — inlocuieste culorile ad-hoc din US-011, toggle
neatins.

rar_env in exportul de audit (AUDIT_COLUMNS + _audit_rows) si ecou in
GET /v1/prezentari(/{id}). _submission_row_view/_detaliu_ctx/fragment_submissions
duc rar_env pana in template.

tests/test_badge_rar_env.py: badge in lista, audit contine rar_env, GET ecou rar_env.
test_statusbar_env: asertie aliniata la eticheta PRODUCȚIE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:16:08 +00:00
Claude Agent
d1978e2545 feat(5.20): US-011 statusbar indicator mediu + toggle conditionat
Statusbar afiseaza mediul RAR default al contului logat (Testare galben /
Productie verde, distinct vizual). La >=2 medii disponibile apare butonul
Comuta (HTMX POST /_fragments/status/toggle-env, account-scoped, verify_csrf)
care alterneaza rar_env_default intre mediile disponibile fara reload; la 1
mediu doar eticheta statica; la 0 medii indicatorul nu apare.

_build_status_ctx capata env_default + medii_disponibile + csrf_token
(via rar_env_efectiv_cont / medii_disponibile_cont).

Retrage badge-env global din header (base.html) pentru utilizatorul logat
(F11) — mediul per-cont traieste acum in statusbar; badge-ul global ramane
doar pentru vizitatorul nelogat.

tests/test_statusbar_env.py: afiseaza env default, toggle doar la 2 medii,
toggle schimba default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:02:10 +00:00
34 changed files with 1751 additions and 180 deletions

View File

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

View File

@@ -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(),
})

View File

@@ -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
@@ -175,6 +175,14 @@ def create_prezentari(
try:
env = rezolva_rar_env(conn, acct, req.rar_env)
except MediuIndisponibil as e:
# US-012: audit blocare mediu indisponibil (tip='rar_env_blocat').
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=acct,
context={"env": e.env},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
@@ -268,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"])
@@ -364,6 +371,14 @@ def valideaza_prezentari(
try:
env = rezolva_rar_env(conn, acct, req.rar_env)
except MediuIndisponibil as e:
# US-012: audit blocare mediu indisponibil pe dry-run (tip='rar_env_blocat').
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=acct,
context={"env": e.env},
conn=conn,
)
raise HTTPException(
status_code=422,
detail=err_eroare(
@@ -556,6 +571,8 @@ AUDIT_COLUMNS = [
"odometru_final",
"prestatii",
"rar_status_code",
# US-010 (PRD 5.20): mediul RAR tinta per trimitere.
"rar_env",
"created_at",
"updated_at",
"purge_after",
@@ -571,7 +588,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
scope_sql, scope_params = account_scope_clause(account_id)
sql = (
"SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, "
"created_at, updated_at, purge_after FROM submissions"
"rar_env, created_at, updated_at, purge_after FROM submissions"
)
where = [scope_sql]
params: list = list(scope_params)
@@ -609,6 +626,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, a
"odometru_final": p.get("odometru_final") or "",
"prestatii": codes,
"rar_status_code": r["rar_status_code"] or "",
# US-010 (PRD 5.20): mediul RAR tinta — coloana audit.
"rar_env": r["rar_env"] or "",
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"purge_after": r["purge_after"] or "",
@@ -722,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")
@@ -733,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()
@@ -755,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()

View File

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

View File

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

View File

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

View File

@@ -401,6 +401,31 @@ def nota_umana_preview(status: str, errors: list, flags: list) -> str:
return ""
# ---------------------------------------------------------------------------
# Etichete mediu RAR (Test / Productie) — US-010 PRD 5.20
#
# Conventia de culori (semantica risc L.142):
# prod = env-badge-prod (fill atentie, text alb) — declaratie REALA, ireversibila.
# test = env-badge-test (outline discret, --muted) — mediu de proba.
# ---------------------------------------------------------------------------
ETICHETE_ENV: dict[str, tuple[str, str]] = {
"prod": ("PRODUCȚIE", "env-badge-prod"),
"test": ("Testare", "env-badge-test"),
}
def eticheta_env(env: object) -> tuple[str, str]:
"""Returneaza (text, css_class) pentru mediul RAR.
Fallback sigur: env necunoscut sau None -> ('Testare', 'env-badge-test').
Nu arunca niciodata.
"""
if not env:
return ("Testare", "env-badge-test")
return ETICHETE_ENV.get(str(env), ("Testare", "env-badge-test"))
# ---------------------------------------------------------------------------
# Constante auxiliare (microcopy fix, fara logica)
# ---------------------------------------------------------------------------

View File

@@ -32,6 +32,7 @@ from ..web.csrf import get_csrf_token, verify_csrf
from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
STARI_PREVIEW,
eticheta_env,
eticheta_rar,
eticheta_scurta,
eticheta_stare,
@@ -110,8 +111,9 @@ def _import_env_ctx(conn, account_id: int) -> dict:
router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
# Expune parse_erori in toate template-urile
# Expune parse_erori si eticheta_env in toate template-urile
templates.env.globals["parse_erori"] = parse_erori
templates.env.globals["eticheta_env"] = eticheta_env
_BLOCKED = ("error", "needs_data", "needs_mapping")
@@ -292,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
@@ -436,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,)
@@ -528,6 +530,8 @@ def _jurnal_context(
"has_more": has_more,
"prev_page": page - 1 if page > 0 else None,
"next_page": page + 1 if has_more else None,
# US-010 (PRD 5.20): mediul implicit al contului pentru badge de sectiune.
"env_default": rar_env_efectiv_cont(conn, account_id) or "test",
}
@@ -916,6 +920,10 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
else:
sanatate_text = "Declaratiile curg normal"
# US-011 (5.20): mediul RAR activ per cont pentru indicatorul din statusbar.
medii_disp = medii_disponibile_cont(conn, account_id)
env_default = rar_env_efectiv_cont(conn, account_id) or "prod"
status_ctx = {
"request": request,
"worker_lbl": worker_lbl,
@@ -937,6 +945,10 @@ def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = Fa
"tab_activ": tab_activ,
"mapari_badge": counts.get("needs_mapping", 0),
"oob": oob,
# US-011: indicator mediu RAR + toggle conditionat
"env_default": env_default,
"medii_disponibile": medii_disp,
"csrf_token": get_csrf_token(request),
}
# US-006 (5.17): context plan pentru linia de consum/trial in _status.html.
status_ctx.update(_plan_ctx(conn, account_id))
@@ -1034,6 +1046,8 @@ def _submission_row_view(r) -> dict:
# randurile blocate (error/needs_data/needs_mapping) sunt selectabile pentru
# stergere bulk; sent/sending/queued raman read-only (fara checkbox).
"gestionabil": r["status"] in _GESTIONABILE_WEB,
# US-010 (PRD 5.20): mediul RAR tinta — badge in lista.
"rar_env": r["rar_env"] if "rar_env" in r.keys() else None,
}
@@ -1081,7 +1095,7 @@ def fragment_submissions(
# FARA LIMIT — altfel paginile >8 ar disparea silentios.
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC",
params,
).fetchall()
@@ -1122,7 +1136,7 @@ def fragment_submissions(
rows_db = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC "
f"updated_at, payload_json, rar_env FROM submissions WHERE {where_sql} ORDER BY id DESC "
"LIMIT ? OFFSET ?",
params + [_PAGE_SIZE, offset],
).fetchall()
@@ -1344,6 +1358,8 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
"editabil": row["status"] in _CORECTABILE,
# error/needs_data/needs_mapping pot fi sterse / re-puse in coada
"gestionabil": row["status"] in _GESTIONABILE_WEB,
# US-010 (PRD 5.20): mediul RAR tinta — badge in detaliu.
"rar_env": row["rar_env"] if "rar_env" in row.keys() else None,
# mapare inline (operatii nemapate ale acestui rand + nomenclator)
"nemapate_inline": nemapate_inline,
"nomenclator": nomenclator,
@@ -3010,7 +3026,17 @@ async def web_upload_import(
# La 0 medii: rezolva_rar_env cade pe ancora globala (rar_env config), non-blocant.
try:
upload_env = rezolva_rar_env(conn, account_id, rar_env or None)
except (ValueError, MediuIndisponibil):
except MediuIndisponibil as e:
# US-012: audit mediu cerut dar indisponibil (fallback silentios, non-blocant).
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=account_id,
context={"env": e.env},
conn=conn,
)
upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
except ValueError:
upload_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
# Stagingul in DB (tranzactie explicita)
@@ -3221,7 +3247,17 @@ async def web_save_mapare_coloane(
form_rar_env = str(form.get("rar_env") or "").strip() or None
try:
mapare_env = rezolva_rar_env(conn, account_id, form_rar_env)
except (ValueError, MediuIndisponibil):
except MediuIndisponibil as e:
# US-012: audit mediu cerut dar indisponibil la mapare coloane (fallback silentios).
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=account_id,
context={"env": e.env},
conn=conn,
)
mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
except ValueError:
mapare_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
# Computa preview
@@ -3249,7 +3285,17 @@ def web_preview_import(
# Rezolva mediul pentru preview (din query param sau default cont)
try:
preview_env = rezolva_rar_env(conn, account_id, rar_env)
except (ValueError, MediuIndisponibil):
except MediuIndisponibil as e:
# US-012: audit mediu cerut dar indisponibil la preview (fallback silentios).
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=account_id,
context={"env": e.env},
conn=conn,
)
preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
except ValueError:
preview_env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
result = _web_compute_preview(conn, import_id, account_id, rar_env=preview_env)
if isinstance(result, str):
@@ -3893,7 +3939,17 @@ async def web_confirma_import(
# Rezolva mediul RAR tinta al lotului (US-009): form > default cont > ancora globala.
try:
env = rezolva_rar_env(conn, account_id, rar_env_cerut)
except (ValueError, MediuIndisponibil):
except MediuIndisponibil as e:
# US-012: audit mediu cerut dar indisponibil la commit import (fallback silentios).
log_event(
"rar_env_blocat",
nivel="WARNING",
account_id=account_id,
context={"env": e.env},
conn=conn,
)
env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
except ValueError:
env = rar_env_efectiv_cont(conn, account_id) or get_settings().rar_env or "test"
# Enqueue in tranzactie explicita — INSERT ON CONFLICT DO NOTHING (TOCTOU)
@@ -4185,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:
@@ -4207,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,
@@ -4473,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)
@@ -4597,11 +4656,15 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
conn = get_connection()
try:
# Starea curenta din DB (inainte de update) — necesara pt logica de confirmare prod.
# Starea curenta din DB (inainte de update) — necesara pt logica de confirmare prod
# si pt a loga DOAR schimbari reale (US-012).
row_before = conn.execute(
"SELECT rar_prod_enabled FROM accounts WHERE id=?", (acct,)
"SELECT rar_test_enabled, rar_prod_enabled, rar_env_default FROM accounts WHERE id=?",
(acct,),
).fetchone()
was_test_enabled = bool(row_before["rar_test_enabled"]) if row_before else False
was_prod_enabled = bool(row_before["rar_prod_enabled"]) if row_before else False
prev_env_default = row_before["rar_env_default"] if row_before else None
# Confirmare obligatorie la PRIMA activare Productie (constientizare L.142).
# Nu se cere daca Productie era deja activata (confirmare unica per-activare).
@@ -4620,6 +4683,7 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
)
# --- Procesare Testare ---
# Tipuri de audit US-012: 'rar_env_activat' / 'rar_env_dezactivat' / 'rar_env_default_schimbat'.
if test_enabled_form:
if test_email and test_parola:
# Ambele campuri completate -> valideaza prin login pe RAR Testare.
@@ -4631,6 +4695,14 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
(enc, acct),
)
creds_test_mesaj = "Credentiale Testare salvate si validate."
# Activare reala: creds noi salvate (schimbare efectiva indiferent de starea anterioara).
log_event(
"rar_env_activat",
account_id=acct,
mesaj="Credentiale Testare salvate si validate.",
context={"env": "test"},
conn=conn,
)
else:
# Login esuat: nu schimbam creds sau enabled; eroare per-env.
creds_test_eroare = mesaj
@@ -4640,9 +4712,27 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
else:
# Activat fara creds noi -> marcheaza enabled (creds existente, daca sunt, raman).
conn.execute("UPDATE accounts SET rar_test_enabled=1 WHERE id=?", (acct,))
# Loga activare doar daca era dezactivat (0->1 e schimbare reala).
if not was_test_enabled:
log_event(
"rar_env_activat",
account_id=acct,
mesaj="Mediu Testare activat (creds existente).",
context={"env": "test"},
conn=conn,
)
else:
# Dezactivat -> disabled=0; creds raman pentru posibila re-activare ulterioara.
conn.execute("UPDATE accounts SET rar_test_enabled=0 WHERE id=?", (acct,))
# Loga dezactivare doar daca era activat (1->0 e schimbare reala).
if was_test_enabled:
log_event(
"rar_env_dezactivat",
account_id=acct,
mesaj="Mediu Testare dezactivat.",
context={"env": "test"},
conn=conn,
)
# --- Procesare Productie ---
if prod_enabled_form:
@@ -4655,14 +4745,40 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
(enc, acct),
)
creds_prod_mesaj = "Credentiale Productie salvate si validate."
# Activare reala: creds noi salvate.
log_event(
"rar_env_activat",
account_id=acct,
mesaj="Credentiale Productie salvate si validate.",
context={"env": "prod"},
conn=conn,
)
else:
creds_prod_eroare = mesaj
elif prod_email or prod_parola:
creds_prod_eroare = "Email si parola Productie trebuie completate impreuna."
else:
conn.execute("UPDATE accounts SET rar_prod_enabled=1 WHERE id=?", (acct,))
# Loga activare doar daca era dezactivat (0->1).
if not was_prod_enabled:
log_event(
"rar_env_activat",
account_id=acct,
mesaj="Mediu Productie activat (creds existente).",
context={"env": "prod"},
conn=conn,
)
else:
conn.execute("UPDATE accounts SET rar_prod_enabled=0 WHERE id=?", (acct,))
# Loga dezactivare doar daca era activat (1->0).
if was_prod_enabled:
log_event(
"rar_env_dezactivat",
account_id=acct,
mesaj="Mediu Productie dezactivat.",
context={"env": "prod"},
conn=conn,
)
# --- Mediu implicit (validat post-update contra mediilor disponibile) ---
if rar_env_default_form and rar_env_default_form in ("test", "prod"):
@@ -4679,6 +4795,15 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
(rar_env_default_form, acct),
)
creds_default_mesaj = "Mediu implicit actualizat."
# Loga schimbarea default doar daca valoarea s-a schimbat efectiv (US-012).
if rar_env_default_form != prev_env_default:
log_event(
"rar_env_default_schimbat",
account_id=acct,
mesaj=f"Mediu implicit schimbat in '{rar_env_default_form}'.",
context={"env": rar_env_default_form},
conn=conn,
)
else:
creds_default_eroare = (
"Mediul ales nu e disponibil — activeaza-l si adauga credentiale valide intai."
@@ -4699,3 +4824,46 @@ async def cont_rar_medii(request: Request) -> HTMLResponse:
)
finally:
conn.close()
@router.post("/_fragments/status/toggle-env", response_class=HTMLResponse)
async def fragment_status_toggle_env(request: Request) -> HTMLResponse:
"""Comuta rar_env_default intre mediile disponibile ale contului (US-011, PRD 5.20).
Valideaza ca noul mediu e in lista mediilor disponibile inainte de UPDATE.
Intoarce statusbar-ul actualizat (acelasi format ca /_fragments/status).
Ignorat silentios daca contul are un singur mediu sau zero medii disponibile.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
acct = account_or_default(account_id)
conn = get_connection()
try:
medii = medii_disponibile_cont(conn, account_id)
if len(medii) >= 2:
env_curent = rar_env_efectiv_cont(conn, account_id) or medii[0]
# Alterneaza la urmatorul mediu din lista disponibile (ciclic)
idx_curent = medii.index(env_curent) if env_curent in medii else 0
env_nou = medii[(idx_curent + 1) % len(medii)]
# Dubla validare: env_nou trebuie sa fie in medii disponibile
if env_nou in medii:
conn.execute(
"UPDATE accounts SET rar_env_default=? WHERE id=?",
(env_nou, acct),
)
# US-012: audit comutare mediu implicit din statusbar.
log_event(
"rar_env_default_schimbat",
account_id=acct,
mesaj=f"Mediu implicit comutat in '{env_nou}' via statusbar.",
context={"env": env_nou},
conn=conn,
)
conn.commit()
ctx = _build_status_ctx(request, conn, account_id, tab_activ="acasa")
return templates.TemplateResponse("_status.html", ctx)
finally:
conn.close()

View File

@@ -10,6 +10,11 @@
{% else %}
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
{% endif %}
{# Badge mediu RAR activ (US-010 PRD 5.20) — mediul implicit al contului #}
{% if env_default | default('') %}
{% set _eb = eticheta_env(env_default) %}
<span class="{{ _eb[1] }}" style="margin-left:auto;" title="Mediu RAR activ">{{ _eb[0] }}</span>
{% endif %}
</div>
<form id="filtre-jurnal"

View File

@@ -39,6 +39,11 @@
<span class="pill {{ row.stare_css }}" style="display:inline-flex; align-items:center; gap:5px;"
{% if _nota %}title="{{ _nota }}"{% endif %}>
<span aria-hidden="true" style="display:inline-block; width:7px; height:7px; border-radius:99px; background:currentColor; flex-shrink:0;"></span>{{ row.stare_eticheta }}</span>
{# Badge mediu RAR (US-010 PRD 5.20) — rar_env disponibil din contextul batch-ului. #}
{% if rar_env | default('') %}
{% set _eb = eticheta_env(rar_env) %}
<div style="margin-top:3px;"><span class="{{ _eb[1] }}">{{ _eb[0] }}</span></div>
{% endif %}
</td>
<td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }}

View File

@@ -123,6 +123,43 @@
</a>
</div>
{# === US-011 (5.20): Indicator mediu RAR activ per cont.
La 0 medii: nu afisa (cont fara configuratie RAR).
La 1 mediu: eticheta statica (fara toggle).
La 2 medii: buton de comutare HTMX (toggle conditionat).
US-010: culori prin eticheta_env/clase .env-badge-* (consistenta cu badge-urile din liste).
#}
{% set _medii = medii_disponibile | default([]) %}
{% set _env = env_default | default('prod') %}
{% set _eb = eticheta_env(_env) %}
{% if _medii | length >= 1 %}
<div class="env-indicator"
style="display:flex; align-items:center; gap:8px; margin-bottom:10px;
padding:6px 10px; border-radius:6px; font-size:var(--fs-sm);
background:color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 10%, var(--card));
border:1px solid color-mix(in srgb, {% if _env == 'prod' %}var(--err){% else %}var(--line){% endif %} 30%, transparent);">
<span style="font-weight:600;">Mediu RAR:</span>
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
{% if _medii | length >= 2 %}
{# Toggle: apare DOAR cand sunt cel putin 2 medii disponibile #}
<form method="post" action="/_fragments/status/toggle-env"
hx-post="/_fragments/status/toggle-env"
hx-target="#status-bar"
hx-swap="outerHTML"
style="margin:0; padding:0; display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('') }}">
<button type="submit"
style="padding:2px 10px; font-size:var(--fs-xs); border-radius:99px;
border:1px solid currentColor; background:transparent; cursor:pointer;
color:var(--muted); line-height:1.4;"
title="Comuta la {{ 'PRODUCȚIE' if _env == 'test' else 'Testare' }}">
Comuta
</button>
</form>
{% endif %}
</div>
{% endif %}
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
#}

View File

@@ -136,9 +136,14 @@
{% endif %}
</div>
{# Pill de stare — dreapta, flex:none #}
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}"
style="flex:0 0 auto; white-space:nowrap;">{{ r.stare_scurt }}</span>
{# Zona dreapta: pill stare + badge mediu RAR (US-010 PRD 5.20) #}
<div style="flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:3px; white-space:nowrap;">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
{% if r.rar_env %}
{% set _eb = eticheta_env(r.rar_env) %}
<span class="{{ _eb[1] }}">{{ _eb[0] }}</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>

View File

@@ -7,10 +7,14 @@
{% set cod_afis = prez.cod_rar if (prez.cod_rar and prez.cod_rar != '—') else 'nemapat' %}
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
{# === Header — #id + pill + motiv uman === #}
{# === Header — #id + pill stare + badge mediu RAR (US-010 PRD 5.20) + motiv === #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 8px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
{% if rar_env %}
{% set _eb = eticheta_env(rar_env) %}
<span class="{{ _eb[1] }}" title="Mediu RAR: {{ _eb[0] }}">{{ _eb[0] }}</span>
{% endif %}
</div>
{% if motiv %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ motiv }}</p>

View File

@@ -142,6 +142,14 @@
.s-ok{color:var(--ok);}
.s-needs_review{color:var(--warn);}
.s-already_sent,.s-duplicate_in_file{color:var(--muted);}
/* Badge mediu RAR (US-010 PRD 5.20) — semantica risc L.142.
Productie: fill atentie (ton --err), text alb — declaratie reala, ireversibila.
Testare: outline discret, ton --muted — mediu de proba, low-stakes.
NU se foloseste verde pentru Productie (ar semnala "sigur"). */
.env-badge-prod { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px; font-weight:700; letter-spacing:.02em;
background:color-mix(in srgb, var(--err) 80%, var(--card)); color:#fff; border:1px solid transparent; }
.env-badge-test { display:inline-block; padding:1px 7px; border-radius:99px; font-size:11px;
background:transparent; color:var(--muted); border:1px solid var(--line); }
.muted { color:var(--muted); }
/* Heading/eticheta accesibila doar pentru cititoare de ecran (vizual ascunsa). */
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
@@ -834,7 +842,7 @@
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
<div class="header-center">
<a href="/" style="text-decoration:none; color:inherit;">
<h1>ROA AUTOPASS<span class="badge-env">{{ rar_env }}</span>{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
<h1>ROA AUTOPASS{% if not is_authenticated|default(false) %}<span class="badge-env">{{ rar_env }}</span>{% endif %}{% if is_authenticated|default(false) and tier_label|default('') %}<span class="badge-tier">{{ tier_label }}</span>{% endif %}</h1>
</a>
{% if is_authenticated|default(false) and account_name|default('') %}
<div class="h-sub">Service auto: <span class="svc">{{ account_name }}</span></div>

View File

@@ -1,10 +1,8 @@
{% extends "base.html" %}
{% block title %}Autentificare — ROA AUTOPASS{% endblock %}
{% block content %}
{# US-010 (PRD 5.16): /login — layout 2 coloane branduit.
Stanga: logo + tagline + puncte de incredere.
Dreapta: formular de autentificare (neschimbat: CSRF, POST /login, link signup).
Pe mobil (<640px): se stivuiesc, partea dreapta (formular) iese prima. #}
{# /login — card de autentificare branduit, o singura coloana (simplificat din layout-ul
2-coloane US-010 PRD 5.16). Formular: CSRF, POST /login, link signup. #}
<div class="login-2col" style="max-width:860px; margin:32px auto;">
{# Antet minimal deja randat in base.html (fara RAR dot, fara burger, fara account_name) #}
<div class="login-shell">
@@ -40,7 +38,7 @@
</div>
<style>
/* US-010 PRD 5.16: layout /login profesional 2 coloane. */
/* /login: card de autentificare profesional, o singura coloana. */
.login-shell {
display:grid; grid-template-columns:1fr;
border:1px solid var(--line); border-radius:16px; overflow:hidden;

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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ă.
</content>
</invoke>

View File

@@ -476,3 +476,33 @@ Codex: indisponibil (N/A). DX scor initial: ~6/10 (model API solid + aditiv, dar
| Eroare tinta indisponibila | proza, fara cod | `cod: RAR_MEDIU_INDISPONIBIL` + fix |
| Citeste rezultatul | `rar_env` (nume #2) | `rar_env` (acelasi) |
| Doc | contract fara field/endpoint | contract complet |
## 8. Raport VERIFY — LIVRAT 2026-07-02
Toate 13 stories implementate TDD (RED->GREEN) prin subagenti sonnet; Opus a coordonat,
verificat si comis (git interzis subagentilor; commit dupa fiecare story, dupa incidentul
`git stash` care a distrus munca necomisa in sesiunea anterioara).
### Comituri (in ordine)
- `1648960` US-008 — configurare doua medii per cont + confirmare Productie unica (L.142) + selector default validat post-update.
- `3eaf1ca` fix test stale `test_list_accounts_ordonat_fara_creds` (coloane `requested_plan`/`consent_at`, ne-legat de 5.20).
- `d1978e2` US-011 — statusbar indicator mediu default + toggle la >=2 medii (HTMX, account-scoped) + retragere `badge-env` global din header pt utilizatorul logat.
- `0a1df31` US-010 — badge mediu (`PRODUCȚIE` fill de atentie / `Testare` outline) in liste/preview/detaliu/jurnal + `labels.eticheta_env` + `rar_env` in audit export si `GET /v1/prezentari(/{id})`; statusbar aliniat la aceeasi conventie.
- `3d3eb71` US-012 — audit `rar_env_activat`/`rar_env_dezactivat`/`rar_env_default_schimbat`/`rar_env_blocat` + teste e2e import->queued pe env corect (ambele canale).
- `b1d825e` US-013 — retragerea `accounts.rar_creds_enc`: ~15 citiri mutate pe sloturile per-env, `POST/DELETE /v1/conturi/rar-creds` env-aware (contract aditiv), DROP cu garda.
### DROP cu garda (US-013, cele 4 auto-fixuri)
- **6a**: eliminat `ADD COLUMN rar_creds_enc` din `db.py` (fara ping-pong re-ADD dupa DROP); `schema.sql` fara coloana pe DB fresh.
- **6b**: intreg DROP-ul in `try/except` fail-safe (nu crapa boot-ul ambelor procese) + garda `sqlite_version >= 3.35`.
- **6c**: re-backfill old->new (ancora globala `AUTOPASS_RAR_ENV`) imediat inainte de assert.
- **Garda orfane**: DROP anulat daca vreun `rar_creds_enc` non-null nu a aterizat in niciun slot per-env (fail-safe, fara pierdere de date).
- **Backup** criptat `accounts_rar_creds_enc_backup` inainte de DROP.
- **6d**: verificare prin `PRAGMA table_info(accounts)` (NU grep — `submissions` are aceeasi coloana, ramane neatinsa).
- Idempotenta la boot repetat verificata manual (boot fresh dropeaza; al doilea boot fara re-ADD, fara crash).
### Rezultat suita
`python3 -m pytest -q -m "not live"` -> **1455 passed, 1 deselected (live)**. Cele 3 esecuri sunt NE-legate de 5.20:
- `test_web_responsive::test_login_branded_nu_schelet`, `test_landing_limita_60` — pre-existente (landing/login, semnalate din checkpoint).
- `test_web_status::test_granita_miez_noapte_local_ro` — flake nocturn: testul construieste boundary = `UTC-ziua-trecuta 22:00` si compara cu `date('now','localtime')` in RO; esueaza in fereastra de dupa miezul noptii RO (rulare la 21:02 UTC = 00:02 RO). Bug de test (nu de cod: `_status_counts`/SQL neatins de 5.20), de reparat deterministic separat.
grep `rar_creds_enc` in `app/` = doar referinte pe `submissions` (efemer per-cerere). Contract API actualizat in `docs/api-rar-contract.md`.

View File

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

235
tests/test_badge_rar_env.py Normal file
View File

@@ -0,0 +1,235 @@
"""Teste US-010 (PRD 5.20): badge mediu RAR in liste/detaliu/audit/ecou API.
Teste:
test_badge_in_lista -- /_fragments/submissions afiseaza PRODUCȚIE / Testare per rand
test_audit_contine_rar_env -- GET /v1/audit/export (CSV) contine coloana rar_env cu valoarea
test_get_ecou_rar_env -- GET /v1/prezentari si /v1/prezentari/{id} includ campul rar_env
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
import pytest
from cryptography.fernet import Fernet
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture client izolat (DB temp + cheie Fernet; fara autentificare API-key)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
"""Client izolat cu DB temporara + cheie Fernet. AUTOPASS_REQUIRE_API_KEY=false."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_badge.db"))
monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
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()
# ---------------------------------------------------------------------------
# Helpere — preluate din test_cont_medii.py (acelasi pattern izolat)
# ---------------------------------------------------------------------------
def _create_account_user(
name: str = "Service Badge SRL",
email: str = "badge@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(client, email: str, password: str) -> None:
"""Login real prin HTTP — seteaza cookie sesiune pe client."""
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]}"
def _insert_submission(acct_id: int, rar_env: str, vin: str = "WVWZZZ1KZAW000123") -> int:
"""Insereaza un submission direct in DB cu rar_env dat. Returneaza id-ul randului."""
import json
from app.db import get_connection
from app.idempotency import build_key
payload = {
"vin": vin,
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
ikey = build_key(acct_id, payload)
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, rar_env) "
"VALUES (?, ?, 'queued', ?, ?)",
(ikey, acct_id, json.dumps(payload), rar_env),
)
row_id = cur.lastrowid
conn.commit()
return row_id
finally:
conn.close()
# ---------------------------------------------------------------------------
# Teste
# ---------------------------------------------------------------------------
def test_badge_in_lista(client):
"""/_fragments/submissions afiseaza PRODUCȚIE pentru rar_env='prod' si Testare pt 'test'."""
acct_id, _ = _create_account_user("Firma Badge 1", "b1@test.com")
_login(client, "b1@test.com", "parolasecreta10")
# Submission cu mediu Productie
_insert_submission(acct_id, rar_env="prod", vin="WVWZZZ1KZAW000001")
# Submission cu mediu Testare
_insert_submission(acct_id, rar_env="test", vin="WVWZZZ1KZAW000002")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200, f"/_fragments/submissions: {resp.status_code}"
html = resp.text
assert "PRODUC" in html.upper(), \
f"Badge 'PRODUCȚIE' lipsa in lista submissions: {html[:800]}"
assert "testare" in html.lower(), \
f"Badge 'Testare' lipsa in lista submissions: {html[:800]}"
def test_audit_contine_rar_env(client):
"""GET /v1/audit/export?status=all (CSV) contine coloana rar_env cu valoarea corecta."""
acct_id, _ = _create_account_user("Firma Badge 2", "b2@test.com")
# Insereaza un submission cu rar_env='prod' si altul cu 'test'
_insert_submission(acct_id, rar_env="prod", vin="WVWZZZ1KZAW000003")
_insert_submission(acct_id, rar_env="test", vin="WVWZZZ1KZAW000004")
# Exportul de audit functioneaza cu AUTOPASS_REQUIRE_API_KEY=false (cont id=1 implicit)
# Contul creat e acct_id; cont default e 1 (creat de schema).
# Inseram pe cont 1 explicit ca sa testam exportul fara cheie.
from app.db import get_connection
import json
conn = get_connection()
try:
payload = {
"vin": "WVWZZZ1KZAW000099",
"nr_inmatriculare": "B001AUD",
"data_prestatie": "2026-06-15",
"odometru_final": "10000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, rar_env) "
"VALUES ('audit-test-key', 1, 'sent', ?, 'prod')",
(json.dumps(payload),),
)
conn.commit()
finally:
conn.close()
resp = client.get("/v1/audit/export?status=all")
assert resp.status_code == 200, f"audit/export: {resp.status_code} {resp.text[:200]}"
reader = csv.DictReader(io.StringIO(resp.text))
assert "rar_env" in (reader.fieldnames or []), \
f"Coloana 'rar_env' lipseste din CSV audit. Coloane: {reader.fieldnames}"
rows = list(reader)
assert rows, "Exportul CSV este gol — nicio trimitere?"
# Cel putin un rand are rar_env setat
env_values = {r["rar_env"] for r in rows if r.get("rar_env")}
assert env_values, f"Toate randurile au rar_env gol: {rows}"
assert "prod" in env_values, f"'prod' asteptat in valorile rar_env, gasit: {env_values}"
def test_get_ecou_rar_env(client):
"""GET /v1/prezentari si /v1/prezentari/{id} includ campul rar_env in JSON."""
# Insereaza direct pe cont 1 (default fara cheie cu AUTOPASS_REQUIRE_API_KEY=false)
import json
from app.db import get_connection
conn = get_connection()
try:
payload = {
"vin": "WVWZZZ1KZAW000077",
"nr_inmatriculare": "B002ECO",
"data_prestatie": "2026-06-20",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
cur = conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, rar_env) "
"VALUES ('ecou-test-key-prod', 1, 'queued', ?, 'prod')",
(json.dumps(payload),),
)
sub_id = cur.lastrowid
conn.commit()
finally:
conn.close()
# GET /v1/prezentari (lista)
resp_lista = client.get("/v1/prezentari")
assert resp_lista.status_code == 200, f"GET /v1/prezentari: {resp_lista.status_code}"
lista = resp_lista.json().get("submissions", [])
assert lista, "Lista de submission-uri este goala"
# Fiecare rand din lista trebuie sa aiba campul rar_env
sub = next((s for s in lista if s.get("id") == sub_id), None)
assert sub is not None, f"Submission {sub_id} negasit in lista"
assert "rar_env" in sub, f"Campul 'rar_env' lipseste din raspunsul listei: {sub}"
assert sub["rar_env"] == "prod", f"rar_env asteptat 'prod', gasit {sub['rar_env']!r}"
# GET /v1/prezentari/{id} (detaliu)
resp_det = client.get(f"/v1/prezentari/{sub_id}")
assert resp_det.status_code == 200, f"GET /v1/prezentari/{sub_id}: {resp_det.status_code}"
det = resp_det.json()
assert "rar_env" in det, f"Campul 'rar_env' lipseste din detaliul submission-ului: {det}"
assert det["rar_env"] == "prod", f"rar_env asteptat 'prod', gasit {det['rar_env']!r}"

View File

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

279
tests/test_e2e_rar_env.py Normal file
View File

@@ -0,0 +1,279 @@
"""Teste US-012 (PRD 5.20): audit evenimente medii RAR + e2e lant submission.
Fisier nou; nu modifica teste existente.
Teste:
test_lant_import_pana_la_queued -- submission ajunge la status=queued cu rar_env corect
test_activare_prod_logata -- activarea mediului Productie produce eveniment de audit
test_tinta_indisponibila_blocata_si_logata -- cerere pe mediu indisponibil -> 422 + eveniment audit
"""
from __future__ import annotations
import json
import os
import re
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_e2e_env.db"))
monkeypatch.setenv("AUTOPASS_CREDS_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
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()
# ---------------------------------------------------------------------------
# Helpere
# ---------------------------------------------------------------------------
def _create_account_user(
name: str = "Service E2E SRL",
email: str = "user@e2e.com",
password: str = "parolasecreta10",
):
"""Creeaza cont + user via create_account/create_user (id>=2; conta default e id=1).
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(client, email: str, password: str) -> None:
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
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]}"
def _get_csrf(client) -> str:
"""Obtine CSRF token din fragmentul /_fragments/cont."""
resp = client.get("/_fragments/cont")
assert resp.status_code == 200, f"/_fragments/cont a returnat {resp.status_code}"
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, f"csrf_token negasit in /_fragments/cont: {resp.text[:400]}"
return m.group(1)
def _mock_login_ok(monkeypatch) -> None:
"""Monkeypatch _valideaza_login_rar sa returneze (True, None) fara RAR live."""
import app.web.routes as routes_mod
monkeypatch.setattr(routes_mod, "_valideaza_login_rar", lambda *a, **kw: (True, None))
def _seteaza_mediu_disponibil_acct(acct_id: int, env: str) -> None:
"""Activeaza mediul `env` cu creds criptate mock direct in DB pentru contul dat."""
from app.db import get_connection
from app.crypto import encrypt_creds
enc = encrypt_creds({"email": f"rar_{env}@firma.ro", "password": f"parola_{env}"})
conn = get_connection()
try:
if env == "test":
conn.execute(
"UPDATE accounts SET rar_test_enabled=1, rar_creds_test_enc=?, "
"rar_env_default='test' WHERE id=?",
(enc, acct_id),
)
elif env == "prod":
conn.execute(
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=?, "
"rar_env_default='prod' WHERE id=?",
(enc, acct_id),
)
conn.commit()
finally:
conn.close()
def _get_events(tip: str) -> list:
"""Returneaza randurile din app_events cu tipul specificat."""
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT * FROM app_events WHERE tip=?", (tip,)
).fetchall()
finally:
conn.close()
# ---------------------------------------------------------------------------
# Teste
# ---------------------------------------------------------------------------
def test_lant_import_pana_la_queued(client):
"""Canal API (POST /v1/prezentari) cu mediu test disponibil -> submission status=queued,
rar_env='test' in DB.
AUTOPASS_REQUIRE_API_KEY=false -> cererea e procesata pe contul implicit id=1.
Configuram mediul test direct in DB pe accel cont (id=1).
Canal import web e2e (upload + mapare + confirmare) e acoperit complet in
tests/test_import_rar_env.py; acest test verifica propagarea rar_env pe calea API.
"""
# Configureaza mediu test pe contul implicit id=1 (cel utilizat de API fara cheie)
_seteaza_mediu_disponibil_acct(1, "test")
body = {
"prezentari": [{
"vin": "WVWZZZ1KZAW001001",
"nr_inmatriculare": "B001E2E",
"data_prestatie": "2026-07-01",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}]
}
resp = client.post("/v1/prezentari", json=body)
assert resp.status_code == 200, (
f"POST /v1/prezentari a esuat: {resp.status_code} {resp.text[:400]}"
)
# Verifica in DB: submission are status=queued sau needs_mapping (cod necunoscut in nomenclator)
# si rar_env=test (mediul contului)
from app.db import get_connection
conn = get_connection()
try:
sub = conn.execute(
"SELECT status, rar_env FROM submissions WHERE account_id=1 ORDER BY id DESC LIMIT 1"
).fetchone()
finally:
conn.close()
assert sub is not None, "Niciun submission creat dupa POST /v1/prezentari"
# status poate fi 'queued' sau 'needs_mapping' (nomenclator gol); rar_env trebuie sa fie 'test'
assert sub["status"] in ("queued", "needs_mapping"), (
f"Status neasteptat: {sub['status']!r}"
)
assert sub["rar_env"] == "test", (
f"rar_env asteptat 'test' (mediul contului), primit {sub['rar_env']!r}"
)
def test_activare_prod_logata(client, monkeypatch):
"""Activarea mediului Productie via POST /cont/rar-medii produce eveniment
'rar_env_activat' in app_events cu context env='prod' si account_id corect.
"""
_mock_login_ok(monkeypatch)
acct_id, _ = _create_account_user("Firma AuditProd", "audit_prod@test.com")
_login(client, "audit_prod@test.com", "parolasecreta10")
# Pas 0: dezactiveaza prod mai intai (schema default are rar_prod_enabled=1 fara creds)
# Trimitem form fara prod_enabled -> rar_prod_enabled devine 0
csrf = _get_csrf(client)
client.post("/cont/rar-medii", data={"csrf_token": csrf})
# Pas 1: activeaza prod cu creds + confirmare (mock login ok)
csrf = _get_csrf(client)
resp = client.post("/cont/rar-medii", data={
"csrf_token": csrf,
"prod_enabled": "1",
"prod_email": "rar_prod@firma.ro",
"prod_parola": "parolaRARprod",
"prod_confirmare": "1",
})
assert resp.status_code == 200, f"Activare prod a esuat: {resp.status_code} {resp.text[:400]}"
# Verifica eveniment de audit in app_events
events = _get_events("rar_env_activat")
assert len(events) >= 1, (
f"Eveniment 'rar_env_activat' asteptat in app_events dupa activare prod, "
f"gasit {len(events)}"
)
# Evenimentul trebuie sa contina env='prod' in context_json
prod_events = [e for e in events if "prod" in (e["context_json"] or "")]
assert len(prod_events) >= 1, (
f"Eveniment 'rar_env_activat' cu env='prod' negasit; "
f"events={[e['context_json'] for e in events]}"
)
ctx = json.loads(prod_events[-1]["context_json"])
assert ctx.get("env") == "prod", f"context_json.env asteptat 'prod', primit {ctx!r}"
assert prod_events[-1]["account_id"] == acct_id, (
f"account_id asteptat {acct_id}, primit {prod_events[-1]['account_id']}"
)
def test_tinta_indisponibila_blocata_si_logata(client):
"""POST /v1/prezentari cu rar_env='prod' pe un cont fara prod disponibil
(rar_prod_enabled=1 dar fara creds) e respinsa cu 422 SI produce eveniment
'rar_env_blocat' in app_events cu context env='prod'.
Contul implicit (id=1, AUTOPASS_REQUIRE_API_KEY=false) are rar_prod_enabled=1
dar rar_creds_prod_enc=NULL -> prod indisponibil -> MediuIndisponibil -> 422.
"""
# Contul 1 implicit: rar_prod_enabled=1 dar fara creds -> prod indisponibil
# Asiguram ca test e de asemenea indisponibil (stare fresh DB)
body = {
"prezentari": [{
"vin": "WVWZZZ1KZAW002002",
"nr_inmatriculare": "B002E2E",
"data_prestatie": "2026-07-01",
"odometru_final": "60000",
"prestatii": [{"cod_prestatie": "OE-1"}],
}],
"rar_env": "prod",
}
resp = client.post("/v1/prezentari", json=body)
assert resp.status_code == 422, (
f"Asteptat 422 (mediu indisponibil), primit {resp.status_code}: {resp.text[:300]}"
)
# Verifica ca cererea a fost logata ca blocat
events = _get_events("rar_env_blocat")
assert len(events) >= 1, (
f"Eveniment 'rar_env_blocat' asteptat in app_events dupa respingere, "
f"gasit {len(events)}"
)
ctx = json.loads(events[-1]["context_json"])
assert ctx.get("env") == "prod", (
f"context_json.env asteptat 'prod', primit {ctx!r}"
)

View File

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

View File

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

View File

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

View File

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

234
tests/test_statusbar_env.py Normal file
View File

@@ -0,0 +1,234 @@
"""Teste US-011 (PRD 5.20): indicator mediu RAR in statusbar + toggle conditionat.
Rute testate:
GET /_fragments/status -- indicator mediu (eticheta statica sau toggle)
POST /_fragments/status/toggle-env -- comuta rar_env_default intre mediile disponibile
Teste:
test_afiseaza_env_default -- un mediu disponibil -> eticheta statica, fara toggle
test_toggle_doar_la_doua_medii -- doua medii -> toggle prezent; un mediu -> toggle absent
test_toggle_schimba_default -- POST toggle -> rar_env_default schimbat in DB, 200
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from cryptography.fernet import Fernet
# ---------------------------------------------------------------------------
# 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_statusbar.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.main import app
with __import__("starlette.testclient", fromlist=["TestClient"]).TestClient(
app, follow_redirects=False
) as c:
yield c
get_settings.cache_clear()
crypto.reset_cache()
# ---------------------------------------------------------------------------
# Helpere
# ---------------------------------------------------------------------------
def _create_account_user(
name: str = "Service Env SRL",
email: str = "env@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(client, email: str, password: str) -> None:
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
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]}"
def _seteaza_mediu_disponibil(acct_id: int, env: str) -> None:
"""Scrie direct in DB: activeaza mediul `env` cu creds criptate mock.
Alternativa rapida la POST /cont/rar-medii cu monkeypatch (nu necesita HTTP).
Reutilizeaza cheia Fernet curenta (setata in fixture).
"""
from app.db import get_connection
from app.crypto import encrypt_creds
enc = encrypt_creds({"email": f"rar_{env}@firma.ro", "password": f"parola_{env}"})
conn = get_connection()
try:
if env == "test":
conn.execute(
"UPDATE accounts SET rar_test_enabled=1, rar_creds_test_enc=? WHERE id=?",
(enc, acct_id),
)
elif env == "prod":
conn.execute(
"UPDATE accounts SET rar_prod_enabled=1, rar_creds_prod_enc=? WHERE id=?",
(enc, acct_id),
)
conn.commit()
finally:
conn.close()
def _get_csrf_din_status(client) -> str:
"""Obtine CSRF token din fragmentul /_fragments/status."""
resp = client.get("/_fragments/status")
assert resp.status_code == 200, f"/_fragments/status a returnat {resp.status_code}"
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, f"csrf_token negasit in statusbar: {resp.text[:600]}"
return m.group(1)
# ---------------------------------------------------------------------------
# Teste
# ---------------------------------------------------------------------------
def test_afiseaza_env_default(client):
"""Cont cu UN singur mediu disponibil (prod) -> statusbar contine eticheta pentru Productie,
fara niciun control de toggle/comutare.
US-010 (PRD 5.20): eticheta foloseste acum 'PRODUCȚIE' (diacritice + majuscule).
"""
acct_id, _ = _create_account_user("Firma Env1", "env1@test.com")
_seteaza_mediu_disponibil(acct_id, "prod")
_login(client, "env1@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200, f"Status HTTP neasteptat: {resp.status_code}"
html = resp.text
# Eticheta mediului trebuie prezenta — "PRODUC" acopera atat 'PRODUCȚIE' (US-010) cat si
# formele anterioare 'Productie'/'PRODUCTIE' (anti-regresie).
assert "PRODUC" in html.upper(), f"Eticheta Productie absenta in statusbar: {html[:800]}"
# Nu trebuie sa existe un control de comutare (toggle) la un singur mediu
assert "toggle-env" not in html, \
"Control toggle gresit prezent la un singur mediu disponibil"
def test_toggle_doar_la_doua_medii(client):
"""Cont cu DOUA medii disponibile -> statusbar contine controlul de toggle.
Cont cu UN singur mediu -> NU contine toggle.
"""
# Cont cu doua medii
acct2_id, _ = _create_account_user("Firma Env2", "env2@test.com")
_seteaza_mediu_disponibil(acct2_id, "test")
_seteaza_mediu_disponibil(acct2_id, "prod")
_login(client, "env2@test.com", "parolasecreta10")
resp2 = client.get("/_fragments/status")
assert resp2.status_code == 200
html2 = resp2.text
assert "toggle-env" in html2, \
f"Control toggle absent cand sunt doua medii disponibile: {html2[:600]}"
# Cont cu un singur mediu (test)
acct1_id, _ = _create_account_user("Firma Env1B", "env1b@test.com")
_seteaza_mediu_disponibil(acct1_id, "test")
# Relogin pe alt cont (simulam logout/login rapid prin schimbare sesiune)
_login(client, "env1b@test.com", "parolasecreta10")
resp1 = client.get("/_fragments/status")
assert resp1.status_code == 200
html1 = resp1.text
assert "toggle-env" not in html1, \
f"Control toggle gresit prezent la un singur mediu: {html1[:600]}"
assert "Testare" in html1, \
f"Eticheta 'Testare' absenta cand mediul unic e 'test': {html1[:600]}"
def test_toggle_schimba_default(client):
"""POST la /_fragments/status/toggle-env comuta rar_env_default in DB
si intoarce statusbar-ul actualizat (200).
"""
acct_id, _ = _create_account_user("Firma Toggle", "toggle@test.com")
_seteaza_mediu_disponibil(acct_id, "test")
_seteaza_mediu_disponibil(acct_id, "prod")
_login(client, "toggle@test.com", "parolasecreta10")
# Verifica valoarea initiala in DB (default=prod)
from app.db import get_connection
conn = get_connection()
try:
row_inainte = conn.execute(
"SELECT rar_env_default FROM accounts WHERE id=?", (acct_id,)
).fetchone()
finally:
conn.close()
env_inainte = row_inainte["rar_env_default"] or "prod"
# Obtine CSRF si face toggle
csrf = _get_csrf_din_status(client)
resp = client.post("/_fragments/status/toggle-env", data={"csrf_token": csrf})
assert resp.status_code == 200, \
f"Toggle a returnat {resp.status_code}: {resp.text[:300]}"
# Verifica ca statusbar-ul actualizat e in raspuns
html = resp.text
assert "status-bar" in html, \
f"Raspunsul toggle nu contine statusbar: {html[:400]}"
# Verifica schimbarea in DB
conn = get_connection()
try:
row_dupa = conn.execute(
"SELECT rar_env_default FROM accounts WHERE id=?", (acct_id,)
).fetchone()
finally:
conn.close()
env_dupa = row_dupa["rar_env_default"]
assert env_dupa != env_inainte, \
f"rar_env_default NU s-a schimbat: inainte={env_inainte!r}, dupa={env_dupa!r}"
assert env_dupa in ("test", "prod"), \
f"rar_env_default are valoare invalida: {env_dupa!r}"

View File

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

View File

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

View File

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

View File

@@ -771,18 +771,17 @@ def test_header_arata_nume_service_logat(client):
def test_login_branded_nu_schelet(client):
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
titlul 'ROA AUTOPASS', si formular cu POST /login (CSRF intact)."""
"""/login e branduit (card .login-shell, titlu 'ROA AUTOPASS') cu formular POST /login
(CSRF intact), NU schelet gol. Loginul a fost simplificat la o coloana (commit
'simplifica login') — nu mai cerem coloana de brand .login-aside."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "login-shell" in html, \
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
assert "login-aside" in html, \
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
"Clasa .login-shell lipseste din /login — cardul de login branduit lipseste"
assert "ROA AUTOPASS" in html, \
"Titlul 'ROA AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
"Titlul 'ROA AUTOPASS' lipseste din /login"
# Formular intact: POST /login cu csrf_token
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
@@ -804,21 +803,17 @@ def _citeste_landing() -> str:
def test_landing_limita_60():
"""5.17 T7 (US-007): limita planului Gratuit este 60 de prestatii/luna in landing,
nu 100. Verifica meta description, announce bar, hero badge, cardul Gratuit si
CTA-ul final."""
"""5.17 T7 (US-007): limita planului Gratuit este 60/luna in landing, nu 100.
Terminologia landing-ului a trecut de la 'prestații' la 'trimiteri' (rework landing
ff9d0f4): copy-ul de limita e acum '60 de trimiteri/lună' (hero + cardul Gratuit)."""
html = _citeste_landing()
assert "100 de prestații" not in html, \
"'100 de prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "100 prestații" not in html, \
"'100 prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "60 de prestații" in html, \
"'60 de prestații' lipseste din landing — verifica meta, announce bar, cardul Gratuit (5.17 T7)"
assert "60 prestații" in html, \
"'60 prestații' lipseste din hero badge in landing (5.17 T7)"
assert "60 de prezentări" in html, \
"'60 de prezentări' lipseste din CTA-ul final al landing-ului (5.17 T7)"
assert "100 de trimiteri/lună" not in html, \
"'100 de trimiteri/lună' prezent — limita planului Gratuit trebuie sa fie 60 (5.17 T7)"
assert "60 de trimiteri" in html, \
"'60 de trimiteri' lipseste din landing — verifica hero badge si cardul Gratuit (5.17 T7)"
def test_landing_trial_pro_nu_premium():

View File

@@ -379,10 +379,17 @@ def test_granita_miez_noapte_local_ro(monkeypatch, request):
# Initializeaza schema (init_db o face idempotent)
init_db()
# Ieri la 22:00 UTC = azi 00:00 (iarna) / 01:00 (vara) Romania -> 'azi' in ambele.
today_utc = datetime.now(timezone.utc).date()
yesterday_utc = today_utc - timedelta(days=1)
boundary_updated_at = f"{yesterday_utc} 22:00:00"
# Construim un timestamp care, stocat ca UTC, cade pe ziua PRECEDENTA in UTC dar pe
# ACEEASI zi RO ca 'now'. 00:30 RO local e mereu ziua UTC precedenta (UTC+2 iarna /
# UTC+3 vara -> 22:30 / 21:30 UTC), DST-aware. Ancorat pe RO-now (nu pe data UTC),
# deci robust la orice ora de rulare. Bug vechi: boundary ancorat pe today_utc pica
# in fereastra de dupa miezul noptii RO, cand date('now','localtime') e deja ziua
# urmatoare fata de today_utc -> sent_today=0 fals.
from zoneinfo import ZoneInfo
now_ro = datetime.now(ZoneInfo("Europe/Bucharest"))
ro_devreme = now_ro.replace(hour=0, minute=30, second=0, microsecond=0)
boundary_updated_at = ro_devreme.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
conn = get_connection()
try:

View File

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