From c842e3352aec41d55663970b11788b8361c7c040 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 23 Jun 2026 18:45:39 +0000 Subject: [PATCH] feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe: Lifecycle trimiteri blocate (Val A): - submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare) - reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete (JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true` - retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat la reactivare/requeue - API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil Observabilitate: - app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces, redactare creds/PII la scriere (redact_pii/vin_partial) - request_id middleware + X-Request-ID pe toate raspunsurile - handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal) - audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii) - tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z - rar_error expus in GET /v1/prezentari/{id} (recovery observabil) pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/router.py | 114 +++++++++ app/auth.py | 26 +- app/config.py | 15 ++ app/db.py | 66 +++++ app/errors.py | 8 + app/main.py | 41 ++- app/models.py | 4 + app/observ.py | 147 +++++++++++ app/schema.sql | 23 ++ app/security.py | 40 +++ app/submissions_admin.py | 117 +++++++++ app/web/middleware.py | 33 +++ app/web/routes.py | 285 ++++++++++++++++++++- app/web/templates/_coada.html | 20 +- app/web/templates/_jurnal.html | 106 ++++++++ app/web/templates/_status.html | 40 ++- app/web/templates/_submissions.html | 24 ++ app/web/templates/_trimitere_detaliu.html | 19 ++ app/web/templates/base.html | 1 + app/worker/__main__.py | 108 ++++++-- docs/ROADMAP.md | 2 +- docs/api-rar-contract.md | 21 ++ docs/prd/prd-5.6-observabilitate-jurnal.md | 174 ++++++++++++- tests/test_api_lifecycle.py | 121 +++++++++ tests/test_audit_api.py | 72 ++++++ tests/test_dedup_error.py | 127 +++++++++ tests/test_error_handler.py | 91 +++++++ tests/test_get_scope_prezentari.py | 7 +- tests/test_jurnal_redactare.py | 77 ++++++ tests/test_jurnal_retentie.py | 94 +++++++ tests/test_observ.py | 105 ++++++++ tests/test_purge_blocate.py | 103 ++++++++ tests/test_request_id.py | 49 ++++ tests/test_submissions_admin.py | 116 +++++++++ tests/test_t16_purjare.py | 12 +- tests/test_web_jurnal.py | 112 ++++++++ tests/test_web_lifecycle.py | 173 +++++++++++++ tests/test_web_status_fragment.py | 61 +++++ tests/test_worker_observ.py | 122 +++++++++ tests/test_worker_reconcile.py | 39 +++ 40 files changed, 2851 insertions(+), 64 deletions(-) create mode 100644 app/observ.py create mode 100644 app/submissions_admin.py create mode 100644 app/web/middleware.py create mode 100644 app/web/templates/_jurnal.html create mode 100644 tests/test_api_lifecycle.py create mode 100644 tests/test_audit_api.py create mode 100644 tests/test_dedup_error.py create mode 100644 tests/test_error_handler.py create mode 100644 tests/test_jurnal_redactare.py create mode 100644 tests/test_jurnal_retentie.py create mode 100644 tests/test_observ.py create mode 100644 tests/test_purge_blocate.py create mode 100644 tests/test_request_id.py create mode 100644 tests/test_submissions_admin.py create mode 100644 tests/test_web_jurnal.py create mode 100644 tests/test_web_lifecycle.py create mode 100644 tests/test_worker_observ.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index b74f41f..26849a0 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -41,7 +41,14 @@ from ...models import ( ValidareResponse, ValidareResult, ) +from ...observ import log_event from ...payload_view import prezentare_din_payload +from ...submissions_admin import ( + SubmissionNotFound, + SubmissionStateConflict, + delete_submission, + requeue_submission, +) router = APIRouter(prefix="/v1", tags=["v1"]) @@ -93,6 +100,37 @@ def create_prezentari( (key,), ).fetchone() if existing: + # US-012: un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit + # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam + # creds + reset), printr-un UPDATE compare-and-swap pe status='error'. + if existing["status"] == "error": + cl = classify_prezentare(content, mapping, mapping_meta) + cur = conn.execute( + "UPDATE submissions SET status=?, payload_json=?, rar_error=?, " + "rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, " + "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, " + "updated_at=datetime('now') WHERE id=? AND status='error'", + (cl["status"], json.dumps(cl["content"], ensure_ascii=False), + cl["rar_error"], creds_enc, existing["id"]), + ) + if cur.rowcount == 1: + # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc, + # decizie #17) — ambele canale converg pe parola corectata. + if req.rar_credentials is not None: + conn.execute( + "UPDATE accounts SET rar_creds_enc=? WHERE id=?", + (encrypt_creds(req.rar_credentials.model_dump()), acct), + ) + results.append(SubmissionResult( + submission_id=existing["id"], status=cl["status"], reactivated=True, + )) + continue + # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE + # (rowcount==0) -> raspuns dedup pe starea CURENTA. + existing = conn.execute( + "SELECT id, status, id_prezentare FROM submissions WHERE id=?", + (existing["id"],), + ).fetchone() results.append( SubmissionResult( submission_id=existing["id"], @@ -112,6 +150,25 @@ def create_prezentari( (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), ) results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=cl["status"])) + + # US-004: audit cerere API per cont. Doar metadate (count + distributie status), + # NICIUN camp de payload PII integral. Reuse conn (T4 — fara contentie WAL). + dist: dict[str, int] = {} + for r in results: + if r.reactivated: + cheie = "reactivated" + elif r.deduped: + cheie = "deduped" + else: + cheie = r.status + dist[cheie] = dist.get(cheie, 0) + 1 + log_event( + "api_prezentari", + account_id=acct, + mesaj=f"{len(results)} prezentari procesate", + context={"count": len(results), "distributie": dist}, + conn=conn, + ) finally: conn.close() return PrezentariResponse(results=results) @@ -199,6 +256,10 @@ _PREZENTARE_FIELDS = frozenset({ "id", "status", "id_prezentare", "rar_status_code", "retry_count", "next_attempt_at", "created_at", "updated_at", "account_id", "batch_id", "row_index", "purge_after", + # T9: rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si + # erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza + # "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API. + "rar_error", }) @@ -224,6 +285,59 @@ def get_prezentare( conn.close() +@router.delete("/prezentari/{submission_id}") +def delete_prezentare( + submission_id: int, + account_id: int = Depends(resolve_account_id), +) -> dict: + """Sterge o trimitere blocata a contului cheii API (US-010). + + Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat + INAINTEA starii (decizie /autoplan #20): cross-account / inexistent -> 404 (acelasi + mesaj, B3); own-account `sent`/`sending` -> 409 (conflict de stare). + """ + conn = get_connection() + try: + try: + res = delete_submission(conn, account_id, submission_id) + except SubmissionNotFound: + raise HTTPException(status_code=404, detail="submission inexistent") + except SubmissionStateConflict as exc: + raise HTTPException( + status_code=409, + detail=f"trimiterea nu se poate sterge in starea '{exc.status}'", + ) + return {"ok": True, **res} + finally: + conn.close() + + +@router.post("/prezentari/{submission_id}/repune") +def repune_prezentare( + submission_id: int, + account_id: int = Depends(resolve_account_id), +) -> dict: + """Re-pune in coada o trimitere blocata a contului cheii API (US-010). + + `error -> queued` (peste helper US-009), re-ruleaza classify. Acelasi oracol de + scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending). + """ + conn = get_connection() + try: + try: + res = requeue_submission(conn, account_id, submission_id) + except SubmissionNotFound: + raise HTTPException(status_code=404, detail="submission inexistent") + except SubmissionStateConflict as exc: + raise HTTPException( + status_code=409, + detail=f"trimiterea nu se poate re-pune in starea '{exc.status}'", + ) + return {"ok": True, **res} + finally: + conn.close() + + @router.get("/nomenclator") def get_nomenclator() -> dict: conn = get_connection() diff --git a/app/auth.py b/app/auth.py index 748da15..e98c289 100644 --- a/app/auth.py +++ b/app/auth.py @@ -19,7 +19,7 @@ import hashlib import secrets import sqlite3 -from fastapi import Header, HTTPException +from fastapi import Header, HTTPException, Request from .config import get_settings from .db import get_connection @@ -111,7 +111,28 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None return None +def _log_auth_esuat(request: Request | None, plaintext: str | None, motiv: str) -> None: + """Eveniment de jurnal pentru un esec de auth (US-004): IP + prefix cheie, NU cheia. + + Best-effort (log_event inghite erorile). Import local: evita cuplarea la import-time + (observ -> db; auth -> db) si pastreaza auth.py importabil din CLI fara efecte. + """ + from .observ import log_event + ip = None + if request is not None and request.client is not None: + ip = request.client.host + prefix = (plaintext[:8] + "…") if plaintext else None + log_event( + "api_auth_esuat", + nivel="WARNING", + cod="RAR_CREDS_INVALIDE" if plaintext else None, + mesaj=motiv, + context={"ip": ip, "key_prefix": prefix}, + ) + + def resolve_account_id( + request: Request, x_api_key: str | None = Header(default=None, alias="X-API-Key"), authorization: str | None = Header(default=None), ) -> int: @@ -121,12 +142,14 @@ def resolve_account_id( - cheie invalida (prezenta) -> 401 (mereu, indiferent de flag) - fara cheie + flag off -> cont implicit (id=1), back-compat - fara cheie + flag on -> 401 + Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004). """ settings = get_settings() plaintext = _extract_key(x_api_key, authorization) if plaintext is None: if settings.require_api_key: + _log_auth_esuat(request, None, "cheie API lipsa (prod)") raise HTTPException(status_code=401, detail="cheie API lipsa") return DEFAULT_ACCOUNT_ID @@ -136,5 +159,6 @@ def resolve_account_id( finally: conn.close() if account_id is None: + _log_auth_esuat(request, plaintext, "cheie API invalida sau revocata") raise HTTPException(status_code=401, detail="cheie API invalida sau revocata") return account_id diff --git a/app/config.py b/app/config.py index 57cb153..b8306f6 100644 --- a/app/config.py +++ b/app/config.py @@ -22,6 +22,21 @@ class Settings(BaseSettings): # --- Bază de date --- db_path: Path = ROOT / "data" / "autopass.db" + # --- Observabilitate / jurnal aplicatie (PRD 5.6) --- + # Nivel minim al evenimentelor scrise in app_events + log text. Sub el, evenimentul + # e ignorat (best-effort). DEBUG|INFO|WARNING|ERROR|CRITICAL. + log_level: str = "INFO" + # Retentie jurnal (app_events) — aliniat cu submissions/import_batches (decizie §5). + log_retention_days: int = 90 + # Director pentru log-ul text rotativ (RotatingFileHandler in aplicatie, decizie §5). + # Fisier per-proces (app-api.log / app-worker.log) — rotatia nu e multiproces-safe. + log_dir: Path = ROOT / ".run" + log_file_max_bytes: int = 5_000_000 + log_file_backup_count: int = 5 + # Retentie randuri blocate (error/needs_data/needs_mapping). Mai scurt decat 90z + # ale `sent` — un blocat n-are valoare de audit (decizie §5). + blocked_retention_days: int = 30 + # --- Securitate (CORE) --- # Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie -> # cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA diff --git a/app/db.py b/app/db.py index 66e9abe..b66f150 100644 --- a/app/db.py +++ b/app/db.py @@ -143,3 +143,69 @@ def read_heartbeat(conn: sqlite3.Connection) -> sqlite3.Row | None: def queue_depth(conn: sqlite3.Connection) -> int: row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone() return int(row["n"]) if row else 0 + + +# --- Jurnal de aplicatie (app_events, PRD 5.6 US-003) --- + +def insert_app_event( + conn: sqlite3.Connection, + *, + request_id: str | None, + account_id: int | None, + sursa: str, + tip: str, + nivel: str, + cod: str | None, + mesaj: str | None, + context_json: str | None, + purge_after: str | None, +) -> None: + """Insert minimal intr-un rand app_events. Apelat DOAR prin observ.log_event + (care a redactat deja toate valorile). Nu redacteaza aici — separarea de + responsabilitati: db.py persista, observ.py/security.py curata.""" + conn.execute( + "INSERT INTO app_events (request_id, account_id, sursa, tip, nivel, cod, mesaj, " + "context_json, purge_after) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (request_id, account_id, sursa, tip, nivel, cod, mesaj, context_json, purge_after), + ) + + +def read_app_events( + conn: sqlite3.Connection, + *, + account_id: int | None = None, + tip: str | None = None, + nivel: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + limit: int = 100, + offset: int = 0, +) -> list[sqlite3.Row]: + """Citire paginata din app_events, ordine descrescatoare dupa id (cele mai noi intai). + + account_id=None -> toate conturile (admin). account_id=int -> scoped pe cont + (NULL apartine contului 1, ca restul UI-ului). Filtrele tip/nivel/data sunt optionale. + """ + where: list[str] = [] + params: list = [] + if account_id is not None: + where.append("(account_id = ? OR (account_id IS NULL AND ? = 1))") + params.extend([account_id, account_id]) + if tip: + where.append("tip = ?") + params.append(tip) + if nivel: + where.append("nivel = ?") + params.append(nivel) + if date_from: + where.append("date(ts) >= date(?)") + params.append(date_from) + if date_to: + where.append("date(ts) <= date(?)") + params.append(date_to) + sql = "SELECT id, ts, request_id, account_id, sursa, tip, nivel, cod, mesaj, context_json FROM app_events" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + return conn.execute(sql, params).fetchall() diff --git a/app/errors.py b/app/errors.py index 587e9e6..2c6e99c 100644 --- a/app/errors.py +++ b/app/errors.py @@ -162,6 +162,14 @@ CATALOG: dict[str, dict[str, str]] = { " (ghilimele duble, acolade inchise corect)." ), }, + "EROARE_INTERNA": { + "problema": "Eroare interna a gateway-ului", + "fix": ( + "Nu e o problema de date trimise de tine. Reincearca peste cateva" + " momente; daca persista, contacteaza administratorul cu identificatorul" + " cererii (request_id) afisat." + ), + }, } diff --git a/app/main.py b/app/main.py index dbc2ec2..358141c 100644 --- a/app/main.py +++ b/app/main.py @@ -20,14 +20,19 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from starlette.responses import RedirectResponse +import traceback + from . import __version__ +from . import errors from .api.v1.import_router import router as import_v1_router from .api.v1.integrare_router import router as integrare_v1_router from .api.v1.router import router as api_v1_router from .config import get_settings from .crypto import validate_creds_key from .db import get_connection, init_db, queue_depth, read_heartbeat -from .security import install_log_redaction +from .observ import log_event, request_id_var +from .security import install_log_redaction, scrub_text +from .web.middleware import RequestIDMiddleware from .web.routes import router as web_router from .web.auth_routes import router as auth_router from .web.admin_routes import router as admin_router @@ -56,6 +61,10 @@ app.add_middleware( https_only=settings.session_https_only, same_site="strict", ) +# US-002: request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza +# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile, +# inclusiv 401/404/422/500 produse mai in interior. +app.add_middleware(RequestIDMiddleware) @app.exception_handler(LoginRequired) @@ -86,6 +95,36 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE return JSONResponse(status_code=422, content={"detail": cleaned}) +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Orice excepție neprinsa -> 500 STRUCTURAT (3 niveluri, PRD 5.4) in loc de 500 brut. + + Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message) + + `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul + complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text). + Handlerele specifice (LoginRequired/AdminRequired/CSRF/RequestValidationError/HTTPException) + raman neatinse — acesta prinde doar ce nu are handler dedicat. + """ + request_id = getattr(request.state, "request_id", None) or request_id_var.get() + try: + account_id = request.session.get("account_id") + except (AssertionError, KeyError, AttributeError): + account_id = None + tb = scrub_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) + log_event( + "eroare_interna", + nivel="ERROR", + account_id=account_id, + cod="EROARE_INTERNA", + mesaj=f"{request.method} {request.url.path}: {type(exc).__name__}", + context={"path": request.url.path, "method": request.method, "traceback": tb}, + request_id=request_id, + ) + body = errors.eroare("EROARE_INTERNA") + body["request_id"] = request_id + return JSONResponse(status_code=500, content=body, headers={"X-Request-ID": request_id or ""}) + + # Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza # offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static # (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie diff --git a/app/models.py b/app/models.py index 624ad45..1b2b1ad 100644 --- a/app/models.py +++ b/app/models.py @@ -96,6 +96,10 @@ class SubmissionResult(BaseModel): status: str id_prezentare: int | None = None deduped: bool = False # True daca idempotency a intors un submission existent + # US-012 (decizie /autoplan #19): camp ADITIV. True cand un rand `error` cu aceeasi + # cheie de continut a fost RE-ACTIVAT (re-clasificat + creds actualizate) la resubmit. + # `deduped` pastreaza semantica actuala (clientii vechi care testeaza `deduped` nu se sparg). + reactivated: bool = False class PrezentariResponse(BaseModel): diff --git a/app/observ.py b/app/observ.py new file mode 100644 index 0000000..a7fd666 --- /dev/null +++ b/app/observ.py @@ -0,0 +1,147 @@ +"""Logger structurat central (PRD 5.6 US-003). + +Singurul punct prin care se emit evenimente de aplicatie: garanteaza format, +redactare si dublul canal (app_events in DB + log text rotativ) consistente si +imposibil de ocolit. Best-effort ca `notify_signup`: o cadere a jurnalului NU +doboara cererea/worker-ul. + +Redactare la SCRIERE (nu la afisare): toate valorile trec prin `redact_pii` +(creds/token mascate integral, VIN/nr partial) inainte de persistare (US-007). +""" + +from __future__ import annotations + +import contextvars +import json +import logging +import logging.handlers +from datetime import datetime, timedelta, timezone +from typing import Any + +from .config import get_settings +from .db import get_connection, insert_app_event +from .security import redact_pii, scrub_text + +# request_id al cererii curente (US-002). Setat de middleware-ul HTTP; disponibil +# in handlerul de erori (US-001) si aici, fara a polua semnaturile de functii. +request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "request_id", default=None +) + +_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "WARN": 30, "ERROR": 40, "CRITICAL": 50} + +# Sursa implicita a evenimentelor pentru procesul curent. API = 'api' (default); +# worker-ul cheama set_source('worker') la pornire (T5: fisier per-proces). +_DEFAULT_SOURCE = "api" + +_loggers: dict[str, logging.Logger] = {} + + +def set_source(sursa: str) -> None: + """Fixeaza sursa implicita a evenimentelor (apelata o data de worker la start).""" + global _DEFAULT_SOURCE + _DEFAULT_SOURCE = sursa + + +def _text_logger(sursa: str) -> logging.Logger: + """Logger cu RotatingFileHandler pe fisier per-proces (app-.log). + + Rotatia pe dimensiune e in aplicatie (decizie §5) — nu depindem de deploy. + Cheia de cache include calea: la schimbarea log_dir (teste) se creeaza un logger + nou, fara a acumula handlere duplicate pe acelasi fisier. + """ + settings = get_settings() + path = settings.log_dir / f"app-{sursa}.log" + key = str(path) + lg = _loggers.get(key) + if lg is not None: + return lg + lg = logging.getLogger(f"autopass.events::{key}") + lg.setLevel(logging.DEBUG) + lg.propagate = False + try: + settings.log_dir.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + path, + maxBytes=settings.log_file_max_bytes, + backupCount=settings.log_file_backup_count, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + lg.addHandler(handler) + except Exception: # noqa: BLE001 — fisier indisponibil nu trebuie sa doboare logul DB + pass + _loggers[key] = lg + return lg + + +def _purge_after(days: int) -> str: + """now (UTC) + days, in formatul SQLite datetime('now') ('YYYY-MM-DD HH:MM:SS').""" + return (datetime.now(timezone.utc) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + + +def log_event( + tip: str, + *, + nivel: str = "INFO", + account_id: int | None = None, + cod: str | None = None, + mesaj: str | None = None, + context: dict | None = None, + sursa: str | None = None, + request_id: str | None = None, + conn: Any = None, +) -> None: + """Emite un eveniment: un rand `app_events` + o linie in logul text (acelasi continut redactat). + + - `tip`: text liber documentat (lista extensibila, decizie §5). + - `nivel`: DEBUG|INFO|WARNING|ERROR|CRITICAL. Sub `AUTOPASS_LOG_LEVEL` -> ignorat. + - `context`: metadate (submission_id, count, status...) — NU payload PII integral. + - `conn`: reutilizeaza conexiunea apelantului pe calea fierbinte (evita contentie WAL, T4); + None -> deschide/inchide o conexiune proprie. + Best-effort: orice exceptie e inghitita (jurnalul nu trebuie sa rupa fluxul). + """ + try: + settings = get_settings() + min_lvl = _LEVELS.get((settings.log_level or "INFO").upper(), 20) + lvl = (nivel or "INFO").upper() + if _LEVELS.get(lvl, 20) < min_lvl: + return + + src = sursa or _DEFAULT_SOURCE + rid = request_id if request_id is not None else request_id_var.get() + mesaj_red = scrub_text(mesaj) if isinstance(mesaj, str) else mesaj + ctx_red = redact_pii(context) if context else None + ctx_json = ( + json.dumps(ctx_red, ensure_ascii=False, default=str) if ctx_red is not None else None + ) + purge_after = _purge_after(int(settings.log_retention_days)) + + own = conn is None + c = conn or get_connection() + try: + insert_app_event( + c, + request_id=rid, + account_id=account_id, + sursa=src, + tip=tip, + nivel=lvl, + cod=cod, + mesaj=mesaj_red, + context_json=ctx_json, + purge_after=purge_after, + ) + finally: + if own: + c.close() + + line = ( + f"[{src}] tip={tip} nivel={lvl} cont={account_id} cod={cod} " + f"rid={rid} {mesaj_red or ''}" + ) + if ctx_json: + line += f" ctx={ctx_json}" + _text_logger(src).info(scrub_text(line)) + except Exception: # noqa: BLE001 — jurnal best-effort (ca notify_signup) + pass diff --git a/app/schema.sql b/app/schema.sql index 720dfc4..1445bbf 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -154,6 +154,29 @@ CREATE TABLE IF NOT EXISTS users ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Jurnal de aplicatie la nivel de eveniment (PRD 5.6). Dublu canal: aici (vizibil +-- in tab "Jurnal") + log text rotativ (depanare). `tip` e text liber documentat +-- (lista extensibila, decizie §5) — adaugam tipuri fara migrare. Toate valorile +-- sunt REDACTATE la scriere (app/observ.py via app/security.py): parole/token -> +-- ***REDACTED***, VIN/nr partial. `context_json` = metadate (NU payload PII integral). +CREATE TABLE IF NOT EXISTS app_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + request_id TEXT, -- corelare cu raspunsul clientului (US-002) + account_id INTEGER, -- NULL = eveniment de sistem (fara cont) + sursa TEXT NOT NULL DEFAULT 'api' + CHECK (sursa IN ('api','worker')), + tip TEXT NOT NULL, -- ex. api_prezentari, rar_login, submission_repus + nivel TEXT NOT NULL DEFAULT 'INFO', + cod TEXT, -- cod din catalogul de erori (app/errors.py) daca aplica + mesaj TEXT, -- mesaj scurt redactat + context_json TEXT, -- JSON metadate redactate (submission_id, count, status...) + purge_after TEXT -- ts + log_retention_days (US-008) +); +CREATE INDEX IF NOT EXISTS idx_app_events_ts ON app_events(ts); +CREATE INDEX IF NOT EXISTS idx_app_events_account ON app_events(account_id, ts); +CREATE INDEX IF NOT EXISTS idx_app_events_tip ON app_events(tip); + -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici. CREATE TABLE IF NOT EXISTS worker_heartbeat ( id INTEGER PRIMARY KEY CHECK (id = 1), diff --git a/app/security.py b/app/security.py index 02d2a03..c20e756 100644 --- a/app/security.py +++ b/app/security.py @@ -39,6 +39,46 @@ SENSITIVE_KEYS = frozenset( ) +# Chei al caror continut e PII de identificare vehicul/proprietar: se logheaza DOAR +# partial (ultimele 4), niciodata integral (PRD 5.6 US-007, L.142/GDPR). +PII_PARTIAL_KEYS = frozenset({"vin", "nr_inmatriculare", "nr", "numar"}) + + +def vin_partial(value: Any) -> str: + """VIN/numar mascat partial: pastreaza ultimele 4 caractere, restul `…`. + + 'WVWZZZ1KZAW000123' -> 'WVW…0123'. Sub 4 caractere -> doar masca. Suficient + pentru a corela un rand fara a expune identificatorul integral in jurnal. + """ + s = str(value if value is not None else "").strip() + if not s: + return "" + if len(s) <= 4: + return "…" + return f"{s[:3]}…{s[-4:]}" if len(s) > 7 else f"…{s[-4:]}" + + +def redact_pii(obj: Any) -> Any: + """Ca `scrub`, plus mascare partiala a VIN/numar (PII_PARTIAL_KEYS). + + Folosit la scrierea jurnalului (observ.log_event): mai intai mascam credentialele + integral (scrub), apoi reducem VIN/nr la forma partiala. Recursiv pe dict/list. + """ + if isinstance(obj, dict): + out: dict = {} + for k, v in obj.items(): + if isinstance(k, str) and k.lower() in SENSITIVE_KEYS: + out[k] = MASK + elif isinstance(k, str) and k.lower() in PII_PARTIAL_KEYS and not isinstance(v, (dict, list)): + out[k] = vin_partial(v) + else: + out[k] = redact_pii(v) + return out + if isinstance(obj, (list, tuple)): + return [redact_pii(v) for v in obj] + return obj + + def scrub(obj: Any) -> Any: """Copie a structurii cu valorile cheilor sensibile mascate, recursiv. diff --git a/app/submissions_admin.py b/app/submissions_admin.py new file mode 100644 index 0000000..4f61a19 --- /dev/null +++ b/app/submissions_admin.py @@ -0,0 +1,117 @@ +"""Lifecycle trimiteri blocate: sterge / re-pune in coada (PRD 5.6 US-009). + +Inchide lacuna descoperita live: un rand `error` (creds RAR gresite) ramane altfel +permanent si nereparabil. Aceste helpere adauga DOUA tranzitii controlate — +stergere de randuri ne-sent si `blocate -> queued` (re-clasificat) — fara a atinge +logica de trimitere a worker-ului. + +Invariante (decizii §2 + /autoplan #20): + - Opereaza DOAR pe `error`/`needs_data`/`needs_mapping`. `sent` (dovada de trimitere + la RAR, audit) si `sending` (lease worker in zbor) sunt INTERZISE. + - Scope-ul (apartenenta la cont) se evalueaza INAINTEA starii: un rand inexistent SAU + al altui cont -> SubmissionNotFound (404, nu confirmam existenta, B3). Doar pe randuri + proprii in stare gresita -> SubmissionStateConflict (409). + - Ambele emit eveniment in jurnal (US-003): `submission_sters` / `submission_repus`. + +Functii cu `conn` (persistenta). Apelate din API (US-010) si din web (US-011). +""" + +from __future__ import annotations + +import json + +from .mapping import ( + account_or_default, + account_scope_clause, + classify_prezentare, + load_mapping_meta, +) +from .observ import log_event + +# Stari pe care le putem sterge / re-pune in coada (ne-sent, ne-in-zbor). +_GESTIONABILE = ("error", "needs_data", "needs_mapping") + + +class SubmissionNotFound(Exception): + """Randul nu exista SAU apartine altui cont (acelasi mesaj — nu confirmam existenta).""" + + +class SubmissionStateConflict(Exception): + """Randul exista si e al contului, dar e intr-o stare protejata (sent/sending).""" + + def __init__(self, status: str): + super().__init__(f"stare protejata: {status}") + self.status = status + + +def _fetch_scoped(conn, account_id: int, sid: int): + scope_sql, scope_params = account_scope_clause(account_id) + return conn.execute( + f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", + [sid] + scope_params, + ).fetchone() + + +def delete_submission(conn, account_id: int, sid: int) -> dict: + """Sterge un rand ne-sent al contului. Ridica SubmissionNotFound / SubmissionStateConflict. + + Intoarce {"submission_id", "status_anterior"} la succes. + """ + row = _fetch_scoped(conn, account_id, sid) + if row is None: + raise SubmissionNotFound() + status = row["status"] + if status not in _GESTIONABILE: + raise SubmissionStateConflict(status) + conn.execute("DELETE FROM submissions WHERE id=?", (sid,)) + log_event( + "submission_sters", + account_id=account_or_default(account_id), + mesaj=f"trimitere #{sid} stearsa din {status}", + context={"submission_id": sid, "status_anterior": status}, + conn=conn, + ) + return {"submission_id": sid, "status_anterior": status} + + +def requeue_submission(conn, account_id: int, sid: int) -> dict: + """Re-pune in coada un rand blocat al contului: re-ruleaza classify pe payload. + + `error -> queued` (cand continutul e valid) sau ramane `needs_data`/`needs_mapping` + daca clasificarea o cere. Reseteaza retry_count/next_attempt_at/sending_since si + CURATA `purge_after` (randul redevine activ, nu mai e candidat la purjare — US-013). + Ridica SubmissionNotFound / SubmissionStateConflict. Intoarce + {"submission_id", "status_anterior", "status_nou"}. + """ + row = _fetch_scoped(conn, account_id, sid) + if row is None: + raise SubmissionNotFound() + status = row["status"] + if status not in _GESTIONABILE: + raise SubmissionStateConflict(status) + + try: + content = json.loads(row["payload_json"]) if row["payload_json"] else {} + if not isinstance(content, dict): + content = {} + except (ValueError, TypeError): + content = {} + + mapping_meta = load_mapping_meta(conn, account_id) + mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()} + cl = classify_prezentare(content, mapping, mapping_meta) + + conn.execute( + "UPDATE submissions SET status=?, payload_json=?, rar_error=?, retry_count=0, " + "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, updated_at=datetime('now') " + "WHERE id=?", + (cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], sid), + ) + log_event( + "submission_repus", + account_id=account_or_default(account_id), + mesaj=f"trimitere #{sid} re-pusa: {status} -> {cl['status']}", + context={"submission_id": sid, "status_anterior": status, "status_nou": cl["status"]}, + conn=conn, + ) + return {"submission_id": sid, "status_anterior": status, "status_nou": cl["status"]} diff --git a/app/web/middleware.py b/app/web/middleware.py new file mode 100644 index 0000000..366160f --- /dev/null +++ b/app/web/middleware.py @@ -0,0 +1,33 @@ +"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002). + +Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite +unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar) +in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile. + +Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un +`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64). +""" + +from __future__ import annotations + +import secrets + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from ..observ import request_id_var + + +class RequestIDMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + incoming = request.headers.get("X-Request-ID") + request_id = (incoming.strip()[:64] if incoming and incoming.strip() else secrets.token_hex(8)) + token = request_id_var.set(request_id) + # Expune si pe request.state pentru handlerele care prefera accesul explicit. + request.state.request_id = request_id + try: + response = await call_next(request) + finally: + request_id_var.reset(token) + response.headers["X-Request-ID"] = request_id + return response diff --git a/app/web/routes.py b/app/web/routes.py index 9d31dad..bd00333 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -49,11 +49,17 @@ from ..api.v1.import_router import ( ) from ..config import get_settings from ..crypto import decrypt_creds, encrypt_creds -from ..db import get_connection, read_heartbeat +from ..db import get_connection, read_app_events, read_heartbeat from ..idempotency import build_key, canonicalize_row from ..validation import validate_prezentare from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file from ..users import is_account_admin +from ..submissions_admin import ( + SubmissionNotFound, + SubmissionStateConflict, + delete_submission, + requeue_submission, +) from ..mapping import ( DEFAULT_ACCOUNT_ID, account_or_default, @@ -138,7 +144,7 @@ def _rar_state(hb, worker_alive: bool) -> str: # cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404. # US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa. # ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan. -_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare"} +_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"} def _get_acasa_context(request: Request, conn, account_id: int) -> dict: @@ -186,13 +192,18 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: } -def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str: - """Randeaza panoul Acasa ca string HTML.""" +def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str: + """Randeaza panoul Acasa ca string HTML. + + `status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de + stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end). + """ if conn is None: return templates.get_template("_acasa.html").render( {"request": request, "csrf_token": get_csrf_token(request)} ) ctx = _get_acasa_context(request, conn, account_id) + ctx["status_filtru"] = status return templates.get_template("_acasa.html").render(ctx) @@ -286,10 +297,88 @@ def _render_integrare(request: Request, conn, account_id: int) -> str: }) -def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str: +_JURNAL_PAGE_SIZE = 50 + + +def _jurnal_context( + request: Request, conn, account_id: int, *, + tip: str | None = None, nivel: str | None = None, + data_de: str | None = None, data_pana: str | None = None, + cont: str | None = None, page: int = 0, +) -> dict: + """Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope. + + Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele + contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5. + """ + admin = is_account_admin(conn, account_id) + tip = (tip or "").strip() or None + nivel = (nivel or "").strip() or None + data_de = (data_de or "").strip() or None + data_pana = (data_pana or "").strip() or None + page = max(0, page) + + if admin: + cont_filtru = None + if cont and str(cont).strip(): + try: + cont_filtru = int(cont) + except (ValueError, TypeError): + cont_filtru = None + scope_account = cont_filtru # None = toate conturile + else: + scope_account = account_or_default(account_id) + + offset = page * _JURNAL_PAGE_SIZE + rows = read_app_events( + conn, account_id=scope_account, tip=tip, nivel=nivel, + date_from=data_de, date_to=data_pana, + limit=_JURNAL_PAGE_SIZE + 1, offset=offset, + ) + has_more = len(rows) > _JURNAL_PAGE_SIZE + rows = rows[:_JURNAL_PAGE_SIZE] + + evenimente = [] + for r in rows: + evenimente.append({ + "ts": format_data_rar(r["ts"]), + "sursa": r["sursa"], + "tip": r["tip"], + "nivel": r["nivel"], + "account_id": r["account_id"], + "cod": r["cod"], + "mesaj": r["mesaj"], + }) + tipuri = [r["tip"] for r in conn.execute("SELECT DISTINCT tip FROM app_events ORDER BY tip").fetchall()] + + return { + "request": request, + "evenimente": evenimente, + "tipuri": tipuri, + "is_admin": admin, + "f_tip": tip or "", + "f_nivel": nivel or "", + "f_data_de": data_de or "", + "f_data_pana": data_pana or "", + "f_cont": (cont or "") if admin else "", + "page": page, + "has_more": has_more, + "prev_page": page - 1 if page > 0 else None, + "next_page": page + 1 if has_more else None, + } + + +def _render_panel_jurnal(request: Request, conn, account_id: int) -> str: + """Randeaza panoul Jurnal ca string HTML (US-006).""" + return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id)) + + +def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, status: str | None = None) -> str: """Randeaza panoul corespunzator unui tab ca string HTML.""" if tab == "acasa": - return _render_panel_acasa(request, conn, account_id) + return _render_panel_acasa(request, conn, account_id, status=status) + if tab == "jurnal": + return _render_panel_jurnal(request, conn, account_id) if tab == "import": return _render_panel_import(request) if tab == "coada": @@ -306,18 +395,19 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> @router.get("/", response_class=HTMLResponse) -def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse: +def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse: """Dashboard principal cu tab-uri (US-003). Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS). - Tab invalid -> fallback la 'acasa'. + Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista + Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta"). """ account_id = require_login(request) active_tab = tab if tab in _TABS_VALIDE else "acasa" conn = get_connection() try: - panel_html = _render_panel_for_tab(request, conn, account_id, active_tab) + panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status) # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele # (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003). counts = _status_counts(conn, account_id) @@ -400,6 +490,32 @@ def fragment_integrare(request: Request) -> HTMLResponse: conn.close() +@router.get("/_fragments/jurnal", response_class=HTMLResponse) +def fragment_jurnal( + request: Request, + tip: str | None = None, + nivel: str | None = None, + data_de: str | None = None, + data_pana: str | None = None, + cont: str | None = None, + page: int = 0, +) -> HTMLResponse: + """Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont. + + Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii. + """ + account_id = require_login(request) + conn = get_connection() + try: + ctx = _jurnal_context( + request, conn, account_id, + tip=tip, nivel=nivel, data_de=data_de, data_pana=data_pana, cont=cont, page=page, + ) + return templates.TemplateResponse("_jurnal.html", ctx) + finally: + conn.close() + + @router.get("/_fragments/banner", response_class=HTMLResponse) def fragment_banner(request: Request) -> HTMLResponse: account_id = require_login(request) @@ -430,6 +546,45 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: return rezultat +# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014). +_BLOCATE_SAMPLE = 3 + + +def _blocate_actionabil(conn, account_id: int) -> list[dict]: + """Categorii blocate cu identificatorii primelor randuri + deep-link (US-014). + + Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori + (VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman. + Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge). + """ + from ..security import vin_partial + scope_sql, scope_params = account_scope_clause(account_id) + out: list[dict] = [] + for status in ("needs_mapping", "needs_data", "error"): + rows = conn.execute( + f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC", + scope_params + [status], + ).fetchall() + if not rows: + continue + sample = [] + for r in rows[:_BLOCATE_SAMPLE]: + prez = prezentare_din_payload(r["payload_json"]) + sample.append({ + "id": r["id"], + "vin": vin_partial(prez.get("vin") or ""), + "nr": prez.get("vehicul_nr") or "", + }) + out.append({ + "status": status, + "eticheta": eticheta_stare(status), + "n": len(rows), + "randuri": sample, + "rest": max(0, len(rows) - len(sample)), + }) + return out + + @router.get("/_fragments/status", response_class=HTMLResponse) def fragment_status(request: Request) -> HTMLResponse: """Bara de status persistenta cu etichete umane (US-002, PRD 3.4). @@ -466,6 +621,7 @@ def fragment_status(request: Request) -> HTMLResponse: "counts_sent": counts.get("sent", 0), "blocate_total": blocate_total, "blocate_defalcat": _blocate_defalcat(counts), + "blocate_actionabil": _blocate_actionabil(conn, account_id), "account_active": _account_active(conn, account_id), }) finally: @@ -496,6 +652,9 @@ def _submission_row_view(r) -> dict: "id_prezentare": r["id_prezentare"], "updated_at": format_data_rar(r["updated_at"]), "motiv": motiv_uman(r["status"], r["rar_error"]), + # US-011: 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, } @@ -566,6 +725,7 @@ def fragment_submissions( "request": request, "rows": view, "filtru_activ": filtru_activ, + "csrf_token": get_csrf_token(request), }) finally: conn.close() @@ -573,6 +733,25 @@ def fragment_submissions( # Stari ne-trimise blocate pe care le putem corecta inline (US-010). _CORECTABILE = ("needs_data", "needs_mapping") +# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada. +_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping") + + +def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse: + """Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011).""" + scope_sql, scope_params = account_scope_clause(account_id) + rows = conn.execute( + "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, " + f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200", + scope_params, + ).fetchall() + view = [_submission_row_view(r) for r in rows] + return templates.TemplateResponse("_submissions.html", { + "request": request, + "rows": view, + "filtru_activ": False, + "csrf_token": get_csrf_token(request), + }) def _payload_form_values(payload_json) -> dict: @@ -616,6 +795,8 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None, "next_attempt_at": format_data_rar(row["next_attempt_at"]), # randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu "editabil": row["status"] in _CORECTABILE, + # US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada + "gestionabil": row["status"] in _GESTIONABILE_WEB, "corectie_msg": message, "corectie_error": error, "corectie_errors": corectie_errors or [], @@ -792,6 +973,92 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR conn.close() +# =========================================================================== # +# US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada # +# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. # +# =========================================================================== # + +@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse) +async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse: + """Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard. + + Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza + panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista. + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + conn = get_connection() + try: + try: + requeue_submission(conn, account_id, submission_id) + except SubmissionNotFound: + raise HTTPException(status_code=404, detail="trimitere inexistenta") + except SubmissionStateConflict: + raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)") + row = _fetch_submission_scoped(conn, account_id, submission_id) + resp = templates.TemplateResponse( + "_trimitere_detaliu.html", + _detaliu_ctx(request, row, message="Re-pus in coada — pleaca la urmatoarea trimitere."), + ) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + +@router.post("/trimitere/{submission_id}/sterge", response_class=HTMLResponse) +async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLResponse: + """Sterge un rand blocat din dashboard. Scoped pe sesiune; sent/sending interzis (409).""" + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + conn = get_connection() + try: + try: + delete_submission(conn, account_id, submission_id) + except SubmissionNotFound: + raise HTTPException(status_code=404, detail="trimitere inexistenta") + except SubmissionStateConflict: + raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)") + resp = HTMLResponse( + '
Trimitere stearsa.
' + ) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + +@router.post("/trimiteri/sterge-bulk", response_class=HTMLResponse) +async def post_sterge_bulk(request: Request) -> HTMLResponse: + """Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune). + + Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a + opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri. + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + ids = form.getlist("submission_id") + conn = get_connection() + try: + for raw in ids: + try: + sid = int(str(raw)) + except (ValueError, TypeError): + continue + try: + delete_submission(conn, account_id, sid) + except (SubmissionNotFound, SubmissionStateConflict): + continue # doar blocate ale contului; restul sarite + resp = _render_submissions(request, conn, account_id) + resp.headers["HX-Trigger"] = "trimiteriChanged" + return resp + finally: + conn.close() + + def _load_saved_op_mappings(conn, account_id: int) -> list[dict]: """Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu).""" diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index 8c3627f..113b893 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -30,14 +30,17 @@ style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
+ {# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza + starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #} + {% set sf = status_filtru | default('') %}
@@ -57,7 +60,8 @@
se incarca…
diff --git a/app/web/templates/_jurnal.html b/app/web/templates/_jurnal.html new file mode 100644 index 0000000..f3b8c62 --- /dev/null +++ b/app/web/templates/_jurnal.html @@ -0,0 +1,106 @@ +{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6). + Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/ + data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #} +
+
+
+

Jurnal de aplicatie

+ {% if is_admin %} + admin: toate conturile + {% else %} + doar evenimentele contului tau + {% endif %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if is_admin %} +
+ + +
+ {% endif %} + +
+ +
+ {% if evenimente %} +
+ + + + + + + {% if is_admin %}{% endif %} + + + + + {% for e in evenimente %} + + + + + + {% if is_admin %}{% endif %} + + + + {% endfor %} + +
CandSursaTipNivelContCodMesaj
{{ e.ts }}{{ e.sursa }}{{ e.tip }} + {{ e.nivel }} + {{ e.account_id if e.account_id is not none else '—' }}{{ e.cod or '—' }}{{ e.mesaj or '' }}
+
+ + {# Paginare: prev/next pe acelasi set de filtre #} + {% if prev_page is not none or next_page is not none %} +
+ {% if prev_page is not none %} + ‹ mai noi + {% endif %} + pagina {{ page + 1 }} + {% if next_page is not none %} + mai vechi › + {% endif %} +
+ {% endif %} + {% else %} +
Niciun eveniment pe filtrul curent.
+ {% endif %} +
+
+
diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html index 1f04a0c..35749a9 100644 --- a/app/web/templates/_status.html +++ b/app/web/templates/_status.html @@ -47,21 +47,35 @@
- - {% if blocate_defalcat %} + + {% if blocate_actionabil %}
-
Necesita atentia ta
-
- {% for eticheta, n in blocate_defalcat %} - {% if n > 0 %} -
- {{ eticheta[0] }} - ({{ n }}) - {% if eticheta[1] %} -
{{ eticheta[1] }}
- {% endif %} +
Necesita atentia ta
+
+ {% for cat in blocate_actionabil %} +
+ {# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback + deep-link server-side (?tab=acasa&status=...). #} + + {{ cat.eticheta[0] }} ({{ cat.n }}) › + +
    + {% for r in cat.randuri %} +
  • + #{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %} +
  • + {% endfor %} + {% if cat.rest %} +
  • …si inca {{ cat.rest }}
  • + {% endif %} +
- {% endif %} {% endfor %}
diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index 7983a2b..44cd712 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -1,7 +1,24 @@ {% if rows %} +{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate + (gestionabil); sent/sending/queued nu au checkbox (read-only). #} +
+ +
+ +
+ @@ -19,6 +36,12 @@ hx-swap="innerHTML" style="cursor:pointer;" title="Click pentru detaliul complet"> +
# Stare Vehicul + {% if r.gestionabil %} + + {% endif %} + {{ r.id }} {{ r.stare_text }} @@ -37,6 +60,7 @@
+
{% elif filtru_activ %}
Nimic pe filtrul curent. diff --git a/app/web/templates/_trimitere_detaliu.html b/app/web/templates/_trimitere_detaliu.html index a180a28..c108884 100644 --- a/app/web/templates/_trimitere_detaliu.html +++ b/app/web/templates/_trimitere_detaliu.html @@ -46,6 +46,25 @@ {% endif %} + {# === Lifecycle (US-011): sterge / re-pune in coada — doar randuri blocate === #} + {% if gestionabil %} +
+
+ + +
+
+ + +
+
+ {% endif %} + {# === Corectie inline (US-010): doar randuri ne-trimise blocate === #} {% if editabil %} {% set err_map = {} %} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index a716543..249ccd8 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -184,6 +184,7 @@ Cont Integrare Nomenclator + Jurnal {% if is_admin|default(false) %}Conturi clienti{% endif %}
diff --git a/app/worker/__main__.py b/app/worker/__main__.py index 21d000e..246d302 100644 --- a/app/worker/__main__.py +++ b/app/worker/__main__.py @@ -38,6 +38,7 @@ from .. import errors from ..config import Settings, get_settings, load_test_credentials from ..crypto import decrypt_creds from ..db import get_connection, init_db, write_heartbeat +from ..observ import log_event, set_source from ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator from ..payload import build_rar_payload from ..reconcile import match_finalizata @@ -59,6 +60,14 @@ def _iso(dt: datetime) -> str: return dt.isoformat(timespec="seconds") +def _wlog(conn, tip: str, mesaj: str, *, nivel: str = "INFO", account_id=None, cod=None, context=None) -> None: + """Migrare print -> jurnal structurat (US-005): emite evenimentul (sursa=worker, dublu + canal DB+fisier) SI pastreaza linia in stdout (operatorul tailuieste .run/worker.log).""" + print(f"[worker] {mesaj}", flush=True) + log_event(tip, nivel=nivel, account_id=account_id, cod=cod, mesaj=mesaj, context=context, + conn=conn, sursa="worker") + + def _backoff_seconds(settings: Settings, retry_count: int) -> int: return min(settings.worker_retry_base_s * (2 ** max(0, retry_count - 1)), settings.worker_retry_max_s) @@ -75,13 +84,26 @@ def _is_transient(exc: Exception) -> bool: # --- Operatii pe submissions --- +# Stari blocate ne-sent care primesc retentie proprie (US-013). Mai scurta decat +# cele 90z ale `sent`: un blocat n-are valoare de audit ca o trimitere reusita. +_BLOCKED_STATES = ("error", "needs_data", "needs_mapping") + + def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None: if status == "sent": # T16: purge_after = sent + 90 zile (GDPR/L.142 retentie maxima). + purge_expr = "datetime('now', '+90 days')" + elif status in _BLOCKED_STATES: + # US-013: randurile blocate primesc si ele purge_after (altfel raman permanent). + days = int(get_settings().blocked_retention_days) + purge_expr = f"datetime('now', '+{days} days')" + else: + purge_expr = None + + if purge_expr is not None: conn.execute( "UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, " - "sending_since=NULL, updated_at=datetime('now'), " - "purge_after=datetime('now', '+90 days') WHERE id=?", + f"sending_since=NULL, updated_at=datetime('now'), purge_after={purge_expr} WHERE id=?", (status, rar_status_code, rar_error, id_prezentare, submission_id), ) else: @@ -99,19 +121,26 @@ _PURGE_INTERVAL_S = 3600 def purge_expired(conn) -> dict[str, int]: """Sterge randurile expirate (purge_after < now). - T16/OV-5: purge_after era exportat dar setat de nimeni si niciun job nu exista. - Acum: submissions sent + expirate, import_batches expirate (import_rows via CASCADE). - Intoarce {submissions_purged, batches_purged}. + T16/OV-5 + US-013/US-008: submissions `sent` SI blocate (error/needs_data/needs_mapping) + expirate; import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal). + EXCLUDE explicit `queued`/`sending` (randuri active — nu se purjeaza niciodata, chiar + daca ar avea un purge_after rezidual; reactivarea il curata oricum). + Intoarce {submissions_purged, batches_purged, events_purged}. """ cur_sub = conn.execute( - "DELETE FROM submissions WHERE purge_after IS NOT NULL AND purge_after < datetime('now') AND status='sent'" + "DELETE FROM submissions WHERE purge_after IS NOT NULL AND purge_after < datetime('now') " + "AND status IN ('sent','error','needs_data','needs_mapping')" ) cur_batch = conn.execute( "DELETE FROM import_batches WHERE purge_after IS NOT NULL AND purge_after < datetime('now')" ) + cur_events = conn.execute( + "DELETE FROM app_events WHERE purge_after IS NOT NULL AND purge_after < datetime('now')" + ) return { "submissions_purged": cur_sub.rowcount, "batches_purged": cur_batch.rowcount, + "events_purged": cur_events.rowcount, } @@ -186,7 +215,9 @@ def reconcile_and_mark(conn, rar: RarClient, token: str, submission_id: int, con if found_id is not None: mark(conn, submission_id, "sent", rar_status_code=200, id_prezentare=found_id, rar_error="reconciliat (raspuns pierdut)") - print(f"[worker] submission {submission_id} -> sent prin reconciliere (idPrezentare={found_id})", flush=True) + _wlog(conn, "submission_reconciliat", + f"submission {submission_id} -> sent prin reconciliere (idPrezentare={found_id})", + context={"submission_id": submission_id, "id_prezentare": found_id}) return True return False @@ -194,12 +225,14 @@ def reconcile_and_mark(conn, rar: RarClient, token: str, submission_id: int, con def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: dict) -> str: """Trimite o prezentare claimed. Intoarce starea finala (pentru teste/loguri).""" sid = claimed["id"] + account_id = claimed.get("account_id") content = claimed["content"] payload = build_rar_payload(content) try: data = rar.post_prezentare(token, payload) mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id")) - print(f"[worker] submission {sid} -> sent (idPrezentare={data.get('id')})", flush=True) + _wlog(conn, "submission_sent", f"submission {sid} -> sent (idPrezentare={data.get('id')})", + account_id=account_id, context={"submission_id": sid, "id_prezentare": data.get("id")}) return "sent" except RarError as exc: if exc.status_code == 400: @@ -212,13 +245,17 @@ def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: d enriched = [errors.eroare("RAR_VALIDARE", cauza=str(exc))] detail = json.dumps(enriched, ensure_ascii=False) mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail) - print(f"[worker] submission {sid} -> needs_data: {detail}", flush=True) + _wlog(conn, "submission_needs_data", f"submission {sid} -> needs_data (RAR 400)", + nivel="WARNING", account_id=account_id, cod="RAR_VALIDARE", + context={"submission_id": sid}) return "needs_data" if _is_transient(exc): return _handle_transient(conn, settings, rar, token, sid, content, str(exc)) # 4xx nerecuperabil (nu 400/401/408/429) -> error. mark(conn, sid, "error", rar_status_code=exc.status_code, rar_error=str(exc)) - print(f"[worker] submission {sid} -> error: {exc}", flush=True) + _wlog(conn, "submission_error", f"submission {sid} -> error: {exc}", + nivel="ERROR", account_id=account_id, + context={"submission_id": sid, "http": exc.status_code}) return "error" except (httpx.TimeoutException, httpx.TransportError) as exc: return _handle_transient(conn, settings, rar, token, sid, content, f"retea: {exc}") @@ -242,18 +279,23 @@ def recover_orphans(conn, settings: Settings, rar: RarClient, token: str, accoun `account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti (compat teste / single-account). """ - cutoff = _iso(_now() - timedelta(seconds=settings.worker_sending_lease_s)) + # Cutoff calculat SQLite-side, in ACELASI format ca sending_since (scris cu + # datetime('now') in claim_one -> 'YYYY-MM-DD HH:MM:SS', cu spatiu). Daca am + # compara cu _iso() (format ISO cu 'T'), spatiul (0x20) < 'T' (0x54) ar face + # orice rand 'sending' sa para mereu <= cutoff -> lease-ul de 120s ignorat, + # iar fiecare rand proaspat revendicat ar fi tratat instant ca orfan. + lease = f"-{int(settings.worker_sending_lease_s)} seconds" if account_id is not None: orphans = conn.execute( "SELECT id, payload_json FROM submissions WHERE status='sending' " - "AND (sending_since IS NULL OR sending_since <= ?) AND account_id=?", - (cutoff, account_id), + "AND (sending_since IS NULL OR sending_since <= datetime('now', ?)) AND account_id=?", + (lease, account_id), ).fetchall() else: orphans = conn.execute( "SELECT id, payload_json FROM submissions WHERE status='sending' " - "AND (sending_since IS NULL OR sending_since <= ?)", - (cutoff,), + "AND (sending_since IS NULL OR sending_since <= datetime('now', ?))", + (lease,), ).fetchall() recovered = 0 for row in orphans: @@ -308,11 +350,23 @@ class AccountSessions: rar = RarClient(self.settings) try: token = rar.login(creds["email"], creds["password"]) + except RarAuthError as exc: + rar.close() + # US-005: login esuat (401) — FARA email/parola (doar codul HTTP + contul). + log_event("rar_login", nivel="WARNING", account_id=account_id, + cod="RAR_CREDS_INVALIDE", + mesaj=f"login RAR esuat (cont {account_id}): {exc.status_code or 401}", + context={"rezultat": "esuat", "http": exc.status_code or 401}, + conn=conn, sursa="worker") + raise except Exception: rar.close() raise self._sessions[account_id] = (rar, token) write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})") + # US-005: login reusit (fara email/parola in clar — context curat). + log_event("rar_login", account_id=account_id, mesaj=f"login RAR ok (cont {account_id})", + context={"rezultat": "ok", "http": 200}, conn=conn, sursa="worker") # Creds efemere pe submissions nu mai sunt necesare: JWT acopera retry-urile -> sterge. # GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc. # Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart). @@ -371,6 +425,7 @@ def run() -> int: signal.signal(signal.SIGINT, _stop) settings = get_settings() + set_source("worker") # US-005: evenimentele worker-ului au sursa=worker (fisier app-worker.log) init_db() conn = get_connection() print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True) @@ -386,10 +441,10 @@ def run() -> int: now_ts = time.time() if now_ts - _last_purge_time >= _PURGE_INTERVAL_S: stats = purge_expired(conn) - if stats["submissions_purged"] or stats["batches_purged"]: + if stats["submissions_purged"] or stats["batches_purged"] or stats["events_purged"]: print( f"[worker] purjare: {stats['submissions_purged']} submissions, " - f"{stats['batches_purged']} batches sterse", + f"{stats['batches_purged']} batches, {stats['events_purged']} evenimente sterse", flush=True, ) _last_purge_time = now_ts @@ -408,6 +463,12 @@ def run() -> int: sid = claimed["id"] account_id = claimed["account_id"] + # T1/US-012: randul poarta creds proaspete (rar_creds_enc != NULL) — fie prima + # trimitere a contului, fie o REACTIVARE dupa creds gresite. Invalidam sesiunea + # RAR cache-uita ca un JWT vechi (30h) din parola GRESITA sa nu trimita cu ea, + # ignorand corectia. Re-login imediat cu creds-urile noi. + if claimed.get("creds_enc"): + sessions.invalidate(account_id) # T1/D4: incearca creds din submission (canal API efemer), cu fallback la # accounts.rar_creds_enc (canal web durabil). Canal web n-are re-pusher. creds = _creds_for(claimed, settings) or _creds_from_account(conn, account_id) @@ -418,7 +479,10 @@ def run() -> int: # Creds gresite (login 401): NU se face retry (plan, failure registry). mark(conn, sid, "error", rar_status_code=401, rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)) - print(f"[worker] submission {sid} (cont {account_id}) -> error: {exc}", flush=True) + # rar_login esuat e deja logat in get_token; aici doar tranzitia submission-ului. + _wlog(conn, "submission_error", f"submission {sid} (cont {account_id}) -> error: creds RAR invalide", + nivel="ERROR", account_id=account_id, cod="RAR_CREDS_INVALIDE", + context={"submission_id": sid, "http": 401}) continue if token is None: @@ -430,6 +494,14 @@ def run() -> int: rar = sessions.rar(account_id) # Recupereaza orfanii contului inainte de trimitere (acelasi token). recover_orphans(conn, settings, rar, token, account_id=account_id) + # Guard: recover_orphans putea atinge chiar randul tocmai revendicat + # (reconciliat 'sent' sau requeue 'queued'). Daca nu mai e 'sending', + # NU mai face POST — altfel s-ar crea un duplicat la RAR. + still_sending = conn.execute( + "SELECT 1 FROM submissions WHERE id=? AND status='sending'", (sid,) + ).fetchone() + if still_sending is None: + continue try: process_one(conn, settings, rar, token, claimed) except RarAuthError as exc: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8905125..37b2659 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-06-23 — HOTFIX livrat + 5.6 APROBAT. Hotfix 500 pe `POST /v1/prezentari` (raportat din client Visual FoxPro): `AUTOPASS_CREDS_KEY` din `.env` nu respecta formatul Fernet (32 bytes url-safe base64) → `ValueError` la primul `encrypt_creds` → 500 brut. Reparat: cheie Fernet valida in `.env` + `crypto.validate_creds_key()` apelata in `main.lifespan` (fail-fast la startup, mesaj clar in loc de 500 la primul POST). Confirmat live: POST VFP → 200 `queued`; trimitere reala pe RAR test → `sent idPrezentare=68818` (verificat independent in finalizate). Corectat si mesajul fals din dashboard pentru starea `error` in `labels.py` ("se reincearca automat" → starea e terminala, NU se reincearca). Investigatia a expus 3 goluri structurale (500 brut fara traducere 3 niveluri; lipsa jurnal de aplicatie la nivel de eveniment; lacune de lifecycle — randuri blocate permanente, dedup blocat de un rand `error`, banner "Necesita atentia ta" neactionabil) → **PRD 5.6 APROBAT** (14 stories; decizii §5 rezolvate cu user). PRD: [prd-5.6](prd/prd-5.6-observabilitate-jurnal.md). | ISTORIC: 5.5 LIVRAT (uniformizare/standardizare UI/UX: tabele la grila Trimiteri, meniu hamburger + tab-bar redus Acasa/Mapari, sterge Ajutor de pe Acasa, panou admin cu selectie+bulk pe model nou `accounts.status`. 9 stories in 3 valuri, UI pur cu o singura exceptie backend = stare cont; stergere soft cu purjare PII imediata GDPR. VERIFY 671 teste + E2E browser (2 bug-uri prinse) + `/code-review high` (2 bug-uri reale reparate). Commit `1fbd894`, vezi randul 5.5). | ISTORIC: 5.4 LIVRAT (Erori pe 3 niveluri problema+cauza+fix pe API si UI: catalog central pur `app/errors.py` ca SINGURA sursa de adevar cod→{problema,fix}, consumat de API+UI+worker — face imposibila divergenta intre canale, acelasi invariant ca 5.2. 8 stories in 5 valuri. Tot ADITIV: `field`/`message`/`error` pastrate la octet, adaugam `cod/problema/cauza/fix`; `rar_error` stocat = SUPERSET (chei vechi intacte → `labels.py` nu se rupe intre valuri, zero migrare). Scope = fluxul de declarare; login/signup/CSRF neatinse. UI progresiv: lista compacta, 3 niveluri complete in detaliu/preview, AA light+dark. VERIFY context curat PASS 628 teste (byte-compat+superset verificate direct, E2E API+web; live RAR neprobat — lipsa creds key). `/code-review high`: 2 bug-uri reale reparate in `labels.py` (`motiv_uman` fara ramura 3-niveluri → 401 creds garbled in coloana Motiv; `parse_erori` element gol pe `{}`). 631 teste. Backend trimitere + schema NEATINSE. PRD: [prd-5.4](prd/prd-5.4-erori-3-niveluri.md)). | ISTORIC: 5.3 LIVRAT (Light/Dark mode: tema light ca bloc `[data-theme="light"]` peste variabilele `:root` — dark NESCHIMBAT la octet; comutator soare/luna in header pe toate paginile, default OS-aware cu fallback dark, persistenta `localStorage` doar la comutare explicita, script anti-FOUC in `` pre-paint; suprafetele de stare hardcodate convertite la `color-mix` in `base.html` + 7 fragmente. Zero backend — pur frontend. VERIFY 2 runde: r1 FAIL a prins literalii dark ramasi in 7 fragmente HTMX (text invizibil in light, test vacuu pe doar base.html) → fix US-003 + test care scaneaza fragmentele; r2 PASS E2E browser (banner light ~13:1 contrast, toggle instant+persista+anti-FOUC, dark identic). `/code-review` high: 1 finding reparat (light `--ok` green sub AA ca text → green-700, ~5.0:1). 584 teste. PRD: [prd-5.3](prd/prd-5.3-light-dark-mode.md)). | ISTORIC: 5.2 LIVRAT (Endpoint dry-run `POST /v1/prezentari/valideaza`: valideaza payload + mapare si intoarce verdictul real — `status_estimat` queued/needs_data/needs_mapping + erori `[{field,message}]` + coduri nemapate + prestatii rezolvate — FARA enqueue, FARA creds, zero scriere DB). 1 story TDD. Cheia de design: helper pur partajat `classify_prezentare` folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine); `create_prezentari` refactorizat pe el cu comportament identic. Scope minim per decizie user: doar validare+mapare (fara idempotency/duplicat, `idempotency.py` neatins), hub `/integrare` amanat ca follow-up (descoperibilitate). VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run + fara leak creds in raspuns; regresia de aur verde; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint read-only nu atinge worker/coada/schema). `/code-review` high: 0 findings (refactor faithful, mutable-default Pydantic-safe, import local necesar anti-circular). PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md). | ISTORIC: 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). +**Ultima actualizare**: 2026-06-23 — 5.6 IMPLEMENTAT + VERIFY PASS (asteapta commit). Cele 14 stories din PRD 5.6 livrate TDD (RED->GREEN), `pytest -q` **741 passed, 0 failed**. Lifecycle trimiteri blocate (Val A primul, decizie #18): `app/submissions_admin.py` (sterge/repune scoped, 404-before-409); reactivare dedup peste `error` cu CAS + invalidare sesiune worker la creds noi (T1) + propagare `accounts.rar_creds_enc` (#17) + camp aditiv `reactivated:true` (#19); retentie randuri blocate 30z + `purge_after` curatat la reactivare/requeue (T2); API `DELETE`/`/repune` (200+JSON, #20); UI butoane + bulk + banner "Necesita atentia ta" actionabil cu deep-link. Observabilitate: `app/observ.py log_event` (dublu canal `app_events` DB + `RotatingFileHandler` per-proces, redactare creds/PII la scriere via `app/security.redact_pii`/`vin_partial`), `request_id` middleware + `X-Request-ID` pe toate raspunsurile (T8), handler global excepții -> 500 envelope 6-chei + request_id (T7), audit cerere API (`api_prezentari`/`api_auth_esuat`) + audit worker (`rar_login`/tranzitii), tab "Jurnal" filtrabil scoped (non-admin doar contul sau), retentie jurnal 90z. Live RAR `--send` NEPROBAT in sesiune (recomandat la deploy: confirma `rar_login` ok + `submission_sent` in jurnal). PRD actualizat cu raport VERIFY; contract actualizat cu endpointurile noi (T10). | ISTORIC: HOTFIX livrat + 5.6 APROBAT. Hotfix 500 pe `POST /v1/prezentari` (raportat din client Visual FoxPro): `AUTOPASS_CREDS_KEY` din `.env` nu respecta formatul Fernet (32 bytes url-safe base64) → `ValueError` la primul `encrypt_creds` → 500 brut. Reparat: cheie Fernet valida in `.env` + `crypto.validate_creds_key()` apelata in `main.lifespan` (fail-fast la startup, mesaj clar in loc de 500 la primul POST). Confirmat live: POST VFP → 200 `queued`; trimitere reala pe RAR test → `sent idPrezentare=68818` (verificat independent in finalizate). Corectat si mesajul fals din dashboard pentru starea `error` in `labels.py` ("se reincearca automat" → starea e terminala, NU se reincearca). Investigatia a expus 3 goluri structurale (500 brut fara traducere 3 niveluri; lipsa jurnal de aplicatie la nivel de eveniment; lacune de lifecycle — randuri blocate permanente, dedup blocat de un rand `error`, banner "Necesita atentia ta" neactionabil) → **PRD 5.6 APROBAT** (14 stories; decizii §5 rezolvate cu user). PRD: [prd-5.6](prd/prd-5.6-observabilitate-jurnal.md). | ISTORIC: 5.5 LIVRAT (uniformizare/standardizare UI/UX: tabele la grila Trimiteri, meniu hamburger + tab-bar redus Acasa/Mapari, sterge Ajutor de pe Acasa, panou admin cu selectie+bulk pe model nou `accounts.status`. 9 stories in 3 valuri, UI pur cu o singura exceptie backend = stare cont; stergere soft cu purjare PII imediata GDPR. VERIFY 671 teste + E2E browser (2 bug-uri prinse) + `/code-review high` (2 bug-uri reale reparate). Commit `1fbd894`, vezi randul 5.5). | ISTORIC: 5.4 LIVRAT (Erori pe 3 niveluri problema+cauza+fix pe API si UI: catalog central pur `app/errors.py` ca SINGURA sursa de adevar cod→{problema,fix}, consumat de API+UI+worker — face imposibila divergenta intre canale, acelasi invariant ca 5.2. 8 stories in 5 valuri. Tot ADITIV: `field`/`message`/`error` pastrate la octet, adaugam `cod/problema/cauza/fix`; `rar_error` stocat = SUPERSET (chei vechi intacte → `labels.py` nu se rupe intre valuri, zero migrare). Scope = fluxul de declarare; login/signup/CSRF neatinse. UI progresiv: lista compacta, 3 niveluri complete in detaliu/preview, AA light+dark. VERIFY context curat PASS 628 teste (byte-compat+superset verificate direct, E2E API+web; live RAR neprobat — lipsa creds key). `/code-review high`: 2 bug-uri reale reparate in `labels.py` (`motiv_uman` fara ramura 3-niveluri → 401 creds garbled in coloana Motiv; `parse_erori` element gol pe `{}`). 631 teste. Backend trimitere + schema NEATINSE. PRD: [prd-5.4](prd/prd-5.4-erori-3-niveluri.md)). | ISTORIC: 5.3 LIVRAT (Light/Dark mode: tema light ca bloc `[data-theme="light"]` peste variabilele `:root` — dark NESCHIMBAT la octet; comutator soare/luna in header pe toate paginile, default OS-aware cu fallback dark, persistenta `localStorage` doar la comutare explicita, script anti-FOUC in `` pre-paint; suprafetele de stare hardcodate convertite la `color-mix` in `base.html` + 7 fragmente. Zero backend — pur frontend. VERIFY 2 runde: r1 FAIL a prins literalii dark ramasi in 7 fragmente HTMX (text invizibil in light, test vacuu pe doar base.html) → fix US-003 + test care scaneaza fragmentele; r2 PASS E2E browser (banner light ~13:1 contrast, toggle instant+persista+anti-FOUC, dark identic). `/code-review` high: 1 finding reparat (light `--ok` green sub AA ca text → green-700, ~5.0:1). 584 teste. PRD: [prd-5.3](prd/prd-5.3-light-dark-mode.md)). | ISTORIC: 5.2 LIVRAT (Endpoint dry-run `POST /v1/prezentari/valideaza`: valideaza payload + mapare si intoarce verdictul real — `status_estimat` queued/needs_data/needs_mapping + erori `[{field,message}]` + coduri nemapate + prestatii rezolvate — FARA enqueue, FARA creds, zero scriere DB). 1 story TDD. Cheia de design: helper pur partajat `classify_prezentare` folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine); `create_prezentari` refactorizat pe el cu comportament identic. Scope minim per decizie user: doar validare+mapare (fara idempotency/duplicat, `idempotency.py` neatins), hub `/integrare` amanat ca follow-up (descoperibilitate). VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run + fara leak creds in raspuns; regresia de aur verde; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint read-only nu atinge worker/coada/schema). `/code-review` high: 0 findings (refactor faithful, mutable-default Pydantic-safe, import local necesar anti-circular). PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md). | ISTORIC: 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). > 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). diff --git a/docs/api-rar-contract.md b/docs/api-rar-contract.md index 162d00d..e648657 100644 --- a/docs/api-rar-contract.md +++ b/docs/api-rar-contract.md @@ -409,6 +409,27 @@ Endpointuri noi: - `POST /v1/mapari` `{account_id?, cod_op_service, cod_prestatie, auto_send}` — upsert mapare + re-rezolvare. Respinge `cod_prestatie` inexistent in nomenclator (422). - Web: `GET /_fragments/mapari` (editor HTMX), `POST /mapari` (form, salveaza + re-randeaza). +### Lifecycle trimiteri blocate (PRD 5.6) + +`POST /v1/prezentari` — camp **aditiv** in fiecare `SubmissionResult`: `reactivated: bool`. +La resubmit cu aceeasi cheie de continut peste un rand `error` (ex. parola RAR corectata), +randul se RE-ACTIVEAZA (re-clasificat + creds actualizate) si raspunsul poarta +`reactivated: true` + starea noua. `deduped` pastreaza semantica actuala (clientii vechi +care testeaza `deduped` nu se sparg). Pentru `sent`/`queued`/`sending`/`needs_*` -> +`deduped: true` (neschimbat). + +- `DELETE /v1/prezentari/{id}` — sterge o trimitere blocata a contului cheii API. + **200 + body JSON** `{ok, submission_id, status_anterior}` (NU 204 — clienti VFP string-parse). + Scope evaluat INAINTEA starii: cross-account / inexistent -> **404** (acelasi mesaj, B3); + own-account `sent`/`sending` -> **409** (conflict de stare). +- `POST /v1/prezentari/{id}/repune` — re-pune in coada (`error -> queued`, re-ruleaza classify). + **200 + body JSON** `{ok, submission_id, status_anterior, status_nou}`. Acelasi oracol scope/stare. +- `GET /v1/prezentari/{id}` expune ACUM si `rar_error` (T9) — recovery observabil prin API + (de ce a esuat); contine doar coduri/mesaje de validare RAR, niciodata creds. + +Web (dashboard, scoped pe sesiune + CSRF): `POST /trimitere/{id}/sterge`, +`POST /trimitere/{id}/repune`, `POST /trimiteri/sterge-bulk` (selectie multipla, doar blocate). + Fuzzy: `rapidfuzz.token_sort_ratio` pe denumire normalizata (fara diacritice, upper). Nomenclatorul se ia **live** din RAR (worker upsert la fiecare login); seed fallback de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline. diff --git a/docs/prd/prd-5.6-observabilitate-jurnal.md b/docs/prd/prd-5.6-observabilitate-jurnal.md index 21a6c6e..b0ae8c1 100644 --- a/docs/prd/prd-5.6-observabilitate-jurnal.md +++ b/docs/prd/prd-5.6-observabilitate-jurnal.md @@ -1,6 +1,7 @@ + # PRD 5.6 — Observabilitate, jurnal aplicatie & lifecycle trimiteri blocate -**Stare**: aprobat (decizii §5 rezolvate 2026-06-23) +**Stare**: aprobat + review /autoplan complet (4 decizii de gust rezolvate 2026-06-23; vezi Anexa /autoplan) > Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`. > Catalog erori (sursa de adevar coduri): `app/errors.py` (PRD 5.4). Redactare creds: `app/security.py`. @@ -262,8 +263,11 @@ formatul, redactarea si dublul canal (DB + fisier) sa fie consistente si imposib - **Test intai (RED)**: `tests/test_api_lifecycle.py` — `test_delete_scoped_pe_cheie`, `test_delete_sent_403`, `test_repune_error_queued`, `test_repune_inexistent_404` - **Acceptance criteria**: - - [ ] `DELETE /v1/prezentari/{id}` → 200/204 pe randuri ne-sent ale contului cheii; - 403 pe `sent`/`sending`; 404 cross-account/inexistent (acelasi mesaj, ca B3). + - [ ] `DELETE /v1/prezentari/{id}` → **200 + body JSON** `{ok, submission_id, status_anterior}` + (NU 204; clienti VFP string-parse) pe randuri ne-sent ale contului cheii. + - [ ] **Scope evaluat INAINTEA starii** (decizie /autoplan #20): cross-account / inexistent + → **404** (acelasi mesaj, B3 — nu confirmam existenta); own-account `sent`/`sending` + → **409** (conflict de stare). Test `test_delete_cross_account_sent_404`. - [ ] `POST /v1/prezentari/{id}/repune` → randul devine `queued` (peste helper US-009). - [ ] Scoped strict pe contul cheii API (nu se poate atinge alt cont). - **Verificare E2E**: cu cheia contului 2, `POST .../15/repune` → 200; worker il re-trimite (creds corecte). @@ -302,8 +306,14 @@ parola) sa fie acceptata **pentru ca** azi un rand `error` cu aceeasi cheie o bl - **Acceptance criteria**: - [ ] La enqueue, daca randul existent cu aceeasi `idempotency_key` e `error`: se RE-ACTIVEAZA acelasi rand (re-ruleaza `classify`, **actualizeaza `rar_creds_enc`** - cu creds-urile noi din cerere, reset `retry_count`/`next_attempt_at`), si raspunsul - NU mai e `deduped: true` ci starea noua (ex. `queued`). + cu creds-urile noi din cerere, reset `retry_count`/`next_attempt_at`, **`purge_after=NULL`**), + si raspunsul poarta **camp aditiv `reactivated: true`** + starea noua (ex. `queued`); + `deduped` ramane cu semantica actuala (decizie /autoplan #19, NU se repurpose-aza). + - [ ] **Reactivarea e un UPDATE compare-and-swap** (`WHERE id=? AND status='error'`); daca + `rowcount==0` (alt POST/requeue a schimbat starea intre timp) -> raspuns dedup pe starea curenta. + Worker-ul **invalideaza sesiunea RAR cache-uita** a contului cand randul claim-uit poarta + `rar_creds_enc != NULL` (altfel JWT vechi 30h trimite cu parola gresita — vezi T1 anexa). + - [ ] Creds noi se propaga si in **`accounts.rar_creds_enc`** (canal web durabil, decizie #17). - [ ] Pentru `sent`/`queued`/`sending`: comportament neschimbat → `deduped: true` (nu cream dubluri, nu deranjam in-flight/trimise). - [ ] `needs_data`/`needs_mapping`: raman `deduped` la resubmit (decizie §5) — corectia @@ -413,7 +423,157 @@ Val B: [US-010 API lifecycle] [US-011 UI lifecycle] [US-014 banner actionabil] --- +## Anexa /autoplan — Raport de review (2026-06-23) + +> Generat de `/autoplan` (CEO -> Design -> Eng -> DX), commit `f48346d`, branch `main`. +> Voci: Claude subagent per faza + Codex. **Codex INDISPONIBIL** (usage limit la runtime) +> -> toate fazele ruleaza `[subagent-only]`. Premisa "app_events table + tab Jurnal" +> confirmata de utilizator la poarta de premise (vs alternativa stdout-first). +> Restore point: vezi comentariul HTML din capul fisierului. + +### Consensus tables (Codex = N/A, subagent-only) + +``` +CEO: 1 premise flagged (substrate, CONFIRMAT keep) · 3 right-problem/scope · 4 alt-uri necomparate +DESIGN: 3 high (poll vs select, deep-link inexistent, banner->panel) · stari lipsa +ENG: 2 CRITICAL (US-012 race+JWT stale, purge_after) · 0 concurrency tests · WAL contention +DX: 5 high (500 envelope 6 chei, 403/404 oracle, deduped breaking, docs, rar_error allowlist) +``` + +### Diagrame + +US-012 reactivare `error` — masina de stari + cursa (fix necesar T1): +``` + POST /v1/prezentari (acelasi payload, parola corectata) + | + v + SELECT status WHERE idempotency_key=? ---- error ----> UPDATE ... SET status='queued', + | rar_creds_enc=, retry=0, + | sent/queued/sending/needs_* next_attempt_at=NULL, + v purge_after=NULL + deduped:true (neschimbat) | + v + CURSA (fara CAS): worker.claim_one (BEGIN IMMEDIATE) queued->sending + CURSA (JWT): AccountSessions[account_id] are token vechi (30h) din creds GRESITE + -> trimite cu parola veche, ignora corectia <-- BUG CENTRAL + FIX: UPDATE ... WHERE id=? AND status='error' (CAS; rowcount 0 -> deduped) + + la claim, daca randul poarta rar_creds_enc != NULL -> sessions.invalidate(account_id) +``` + +Retentie / purjare (fix T2): +``` + mark(sent) -> purge_after = now + 90z (existent) + mark(blocate) -> purge_after = now + 30z (US-013 nou; error/needs_data/needs_mapping) + reactivare/ -> purge_after = NULL (US-009/012; ALTFEL purjat inainte de claim) + re-pune coada + purge_expired WHERE purge_after 200+JSON, nu 204 (T11) | Mechanical | P5 | Consistent cu restul v1; clienti VFP string-parse | +| 13 | Design | poll vs bulk-select rezolvat (T12) | Mechanical | P1 | Selectie stearsa la 15s = defect | +| 14 | Design | plumbing deep-link status (T13) | Mechanical | P1 | Destinatia US-014 nu exista azi | +| 15 | Design | banner -> panou detaliu (T14) | Mechanical | P3 | Duce direct la butonul de actiune | +| 16 | Design | stari empty/loading/partial + collision checkbox (T15) | Mechanical | P1 | Acoperire stari = scope, nu afterthought | +| 17 | CEO | **REZOLVAT: DA** — resubmit/requeue cu creds noi reimprospateaza si `accounts.rar_creds_enc` (T16) | Taste | P1 | Utilizator: ambele canale converg pe parola corectata | +| 18 | CEO | **REZOLVAT: pastram bundled, lifecycle (Val A) PRIMUL** | Taste | P6 | Utilizator: §6 izoleaza deja valurile; overhead minim pe PRD aprobat | +| 19 | DX | **REZOLVAT: camp aditiv `reactivated:true`** (NU repurpose deduped) | Taste | P5 | Utilizator: backward-compat pentru clienti care testeaza `deduped` | +| 20 | DX | **REZOLVAT: cross-account 404 INAINTE de verificare status**; own-account sent/sending -> 409 | Taste(sec) | P1 | Utilizator: inchide oracolul de existenta (B3) | + +### Decizii /autoplan rezolvate la poarta finala (2026-06-23, obligatorii pentru executie) + +- **Bundling [#18]**: PRD 5.6 ramane unitar; ordinea de executie pune **Val A (lifecycle: US-009/012/013/011/014) inaintea** observabilitatii. Un singur VERIFY. +- **US-012 raspuns [#19]**: la reactivarea unui rand `error` se intoarce camp **aditiv `reactivated: true`** pe `SubmissionResult` (NU se repurpose-aza `deduped`). `deduped` ramane cu semantica actuala; clientii vechi nu se sparg. Update `app/models.py` + contract. +- **US-010 coduri [#20]**: scope-ul (cross-account) se evalueaza **inaintea** starii. Cross-account / inexistent -> **404** (acelasi mesaj, B3). Own-account `sent`/`sending` -> **409** (conflict de stare, nu 403). Test nou `test_delete_cross_account_sent_404`. +- **US-009/012 creds [#17]**: cand resubmit/requeue aduce creds noi, se reimprospateaza si `accounts.rar_creds_enc` (canalul web durabil), nu doar `submissions.rar_creds_enc`. Combinat cu invalidarea sesiunii worker (T1). + +### Implementation Tasks (auto-generate, vezi JSONL ~/.gstack/projects/romfast-rar-autopass/) + +P1 (blocheaza ship): T1 (US-012 CAS+sesiune), T2 (purge_after), T3 (teste concurenta), T4 (log_event conn), +T5 (log per-proces), T7 (500 6-chei), T8 (X-Request-ID global), T9 (rar_error allowlist), T10 (docs contract), +T12 (poll vs select), T13 (deep-link). +P2 (acelasi branch): T6 (vin_partial), T11 (DELETE 200+body), T14 (banner->panou), T15 (stari UI), T16 (creds web). + +### Completion Summaries + +``` + CEO | premise 1 (confirmat keep) · right-problem OK (lifecycle=10x) · 1 challenge bundling · F6 creds web + DESIGN | 3 high (poll/select, deep-link, banner) · stari lipsa · checkbox collision · AA de verificat + ENG | 2 CRITICAL (race+JWT, purge) · 0 concurrency tests · WAL contention · IDOR ordine 404 + DX | 5 high (500 envelope, oracle, deduped, docs, rar_error) · recovery matrix per-stare de documentat + Lake | toate auto-deciziile au ales optiunea completa (16/16 mechanical = ADD/fix complet) +``` + ## Raport VERIFY -> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. -> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe RAR test). Lipseste pana la VERIFY. +> Executie completa 2026-06-23 (TDD, RED->GREEN per story). Toate cele 14 stories livrate. + +### Rezultat teste + +`python3 -m pytest -q` -> **741 passed, 0 failed** (~64s). Baseline inainte de 5.6: 561 teste +(restul de 114 "esecuri" de la pornire erau artefact de mediu — `.env`-ul de testare live are +`AUTOPASS_REQUIRE_API_KEY=true`; rulat cu override-urile standard de test, baseline-ul e verde). +Teste noi adaugate (toate verzi): + +- US-001 `tests/test_error_handler.py` (5) — 500 structurat 6-chei + request_id, fara traceback/creds. +- US-002 `tests/test_request_id.py` (4) — X-Request-ID pe toate raspunsurile, contextvar. +- US-003 `tests/test_observ.py` (4) — dublu canal DB+fisier, redactare, nivel din env, best-effort. +- US-004 `tests/test_audit_api.py` (3) — `api_prezentari` (count+distributie), `api_auth_esuat` (IP+prefix). +- US-005 `tests/test_worker_observ.py` (3) — `rar_login` ok/esuat fara parola, tranzitii sent/error. +- US-007 `tests/test_jurnal_redactare.py` (4) — parola/token/VIN niciodata integral; fuzz chei sensibile. +- US-006 `tests/test_web_jurnal.py` (5) — scope non-admin/admin, filtru tip/nivel/cont, deep-link tab. +- US-008 `tests/test_jurnal_retentie.py` (5) — purge_after pe app_events, purjare, RotatingFileHandler. +- US-009 `tests/test_submissions_admin.py` (6) — sterge/repune scoped, 404 cross-account, classify la repune. +- US-010 `tests/test_api_lifecycle.py` (7) — DELETE/repune 200+JSON, scope-before-state (404 vs 409). +- US-011 `tests/test_web_lifecycle.py` (7) — butoane doar pe blocate, CSRF, bulk scoped. +- US-012 `tests/test_dedup_error.py` (5) — reactivare peste `error` + `reactivated:true`, creds noi; sent/queued/needs_* raman deduped. +- US-013 `tests/test_purge_blocate.py` (5) — purge_after pe blocate (30z), purjare exclude queued/sending. +- US-014 `tests/test_web_status_fragment.py` (+3) — categorie linkeaza la lista filtrata, identificator partial, scope. + +### Fix-uri tehnice cheie (din /autoplan) + +- **T1 (CRITICAL)**: reactivarea e UPDATE compare-and-swap (`WHERE id=? AND status='error'`); + worker-ul invalideaza sesiunea RAR cache-uita cand randul claim-uit poarta `rar_creds_enc != NULL` + (JWT vechi 30h din parola gresita nu mai trimite). Creds noi se propaga si in `accounts.rar_creds_enc`. +- **T2**: reactivare/requeue seteaza `purge_after=NULL`; `purge_expired` exclude explicit `queued`/`sending`. +- **T7**: 500 = envelope 6-chei (catalog) + `request_id`. **T8**: X-Request-ID pe TOATE raspunsurile (middleware). +- **T9**: `rar_error` in allowlist-ul `GET /v1/prezentari/{id}` (recovery observabil; test vechi actualizat). + +### Note + +- Teste modificate intentionat (comportament schimbat de PRD): `test_t16_purjare` (error primeste acum + purge_after — US-013), `test_get_scope_prezentari` (`rar_error` expus acum — T9). +- E2E live pe RAR test: NEPROBAT in aceasta sesiune (necesita creds RAR test + `--send`). Backend-ul de + trimitere e neatins ca logica; modificarile worker sunt aditive (evenimente + invalidare sesiune la creds noi). + Recomandat la deploy: o trimitere `--send` pentru a confirma `rar_login` ok + `submission_sent` in jurnal. diff --git a/tests/test_api_lifecycle.py b/tests/test_api_lifecycle.py new file mode 100644 index 0000000..a0801d4 --- /dev/null +++ b/tests/test_api_lifecycle.py @@ -0,0 +1,121 @@ +"""Teste US-010 (PRD 5.6): API DELETE + re-pune in coada, scoped + oracol scope/stare.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "life.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def _account_with_key(name="Cont"): + from app.accounts import create_account + from app.auth import create_api_key + from app.db import get_connection + conn = get_connection() + try: + aid = create_account(conn, name, active=True) + key = create_api_key(conn, aid) + conn.commit() + return aid, key + finally: + conn.close() + + +def _body(**over): + prez = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}]} + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]} + + +def _enqueue(client, key, **over): + r = client.post("/v1/prezentari", json=_body(**over), headers={"X-API-Key": key}) + assert r.status_code == 200, r.text + return r.json()["results"][0]["submission_id"] + + +def _force_status(sid, status): + from app.db import get_connection + conn = get_connection() + try: + conn.execute("UPDATE submissions SET status=? WHERE id=?", (status, sid)) + conn.commit() + finally: + conn.close() + + +def test_delete_scoped_pe_cheie(client): + _aid, key = _account_with_key() + sid = _enqueue(client, key) + _force_status(sid, "error") + r = client.request("DELETE", f"/v1/prezentari/{sid}", headers={"X-API-Key": key}) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["submission_id"] == sid + assert body["status_anterior"] == "error" + + +def test_delete_sent_409(client): + _aid, key = _account_with_key() + sid = _enqueue(client, key) + _force_status(sid, "sent") + r = client.request("DELETE", f"/v1/prezentari/{sid}", headers={"X-API-Key": key}) + assert r.status_code == 409 + + +def test_delete_inexistent_404(client): + _aid, key = _account_with_key() + r = client.request("DELETE", "/v1/prezentari/99999", headers={"X-API-Key": key}) + assert r.status_code == 404 + + +def test_delete_cross_account_sent_404(client): + """Scope INAINTEA starii: randul `sent` al ALTUI cont -> 404 (nu 409, nu confirmam existenta).""" + aid_a, key_a = _account_with_key("A") + aid_b, key_b = _account_with_key("B") + sid = _enqueue(client, key_b) + _force_status(sid, "sent") + # contul A incearca sa stearga randul lui B (sent) -> 404, NU 409 + r = client.request("DELETE", f"/v1/prezentari/{sid}", headers={"X-API-Key": key_a}) + assert r.status_code == 404 + + +def test_repune_error_queued(client): + _aid, key = _account_with_key() + sid = _enqueue(client, key) + _force_status(sid, "error") + r = client.post(f"/v1/prezentari/{sid}/repune", headers={"X-API-Key": key}) + assert r.status_code == 200 + assert r.json()["status_nou"] == "queued" + + +def test_repune_inexistent_404(client): + _aid, key = _account_with_key() + r = client.post("/v1/prezentari/99999/repune", headers={"X-API-Key": key}) + assert r.status_code == 404 + + +def test_repune_sending_409(client): + _aid, key = _account_with_key() + sid = _enqueue(client, key) + _force_status(sid, "sending") + r = client.post(f"/v1/prezentari/{sid}/repune", headers={"X-API-Key": key}) + assert r.status_code == 409 diff --git a/tests/test_audit_api.py b/tests/test_audit_api.py new file mode 100644 index 0000000..b17a1dd --- /dev/null +++ b/tests/test_audit_api.py @@ -0,0 +1,72 @@ +"""Teste US-004 (PRD 5.6): audit cerere API per cont in jurnal.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "audit.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false") + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def _body(**over): + prez = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}]} + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "secretaPP"}, "prezentari": [prez]} + + +def _events(tip): + from app.db import get_connection + conn = get_connection() + try: + return conn.execute("SELECT * FROM app_events WHERE tip=?", (tip,)).fetchall() + finally: + conn.close() + + +def test_post_prezentari_logheaza_eveniment_cont(client): + r = client.post("/v1/prezentari", json=_body()) + assert r.status_code == 200 + rows = _events("api_prezentari") + assert len(rows) == 1 + assert rows[0]["account_id"] == 1 + + +def test_eveniment_contine_status_si_count_fara_pii(client): + client.post("/v1/prezentari", json=_body()) + rows = _events("api_prezentari") + ctx = rows[0]["context_json"] + assert "distributie" in ctx + assert "queued" in ctx + assert "count" in ctx + # NICIUN PII integral (parola / VIN integral) + assert "secretaPP" not in ctx + assert "WVWZZZ1KZAW000123" not in ctx + + +def test_401_logat_ca_auth_esuat(client): + # cheie prezenta dar invalida -> 401 (indiferent de flag) + r = client.post("/v1/prezentari", json=_body(), headers={"X-API-Key": "rfak_invalidakey123"}) + assert r.status_code == 401 + rows = _events("api_auth_esuat") + assert len(rows) == 1 + ctx = rows[0]["context_json"] + # prefix cheie, NU cheia intreaga + assert "rfak_inv" in ctx + assert "rfak_invalidakey123" not in ctx diff --git a/tests/test_dedup_error.py b/tests/test_dedup_error.py new file mode 100644 index 0000000..36d2a83 --- /dev/null +++ b/tests/test_dedup_error.py @@ -0,0 +1,127 @@ +"""Teste US-012 (PRD 5.6): un rand `error` nu mai blocheaza retrimiterea (dedup). + +La resubmit cu aceeasi cheie de continut peste un rand `error`: se RE-ACTIVEAZA +(reactivated:true + stare noua), creds-urile se actualizeaza. Peste sent/queued/ +sending/needs_* ramane `deduped:true` (neschimbat). +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "dedup.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false") + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def _body(password="corecta", **over): + prez = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez]} + + +def _force_status(sid, status): + from app.db import get_connection + conn = get_connection() + try: + conn.execute("UPDATE submissions SET status=? WHERE id=?", (status, sid)) + conn.commit() + finally: + conn.close() + + +def _creds_enc(sid): + from app.db import get_connection + conn = get_connection() + try: + return conn.execute("SELECT rar_creds_enc FROM submissions WHERE id=?", (sid,)).fetchone()["rar_creds_enc"] + finally: + conn.close() + + +def test_resubmit_peste_error_reactiveaza(client): + r1 = client.post("/v1/prezentari", json=_body(password="gresita")) + sid = r1.json()["results"][0]["submission_id"] + _force_status(sid, "error") + + r2 = client.post("/v1/prezentari", json=_body(password="corecta")) + res = r2.json()["results"][0] + assert res["submission_id"] == sid + assert res["reactivated"] is True + assert res["status"] == "queued" + assert res.get("deduped", False) is False + + +def test_resubmit_actualizeaza_creds_pe_reactivare(client): + r1 = client.post("/v1/prezentari", json=_body(password="gresita")) + sid = r1.json()["results"][0]["submission_id"] + enc_initial = _creds_enc(sid) + _force_status(sid, "error") + + client.post("/v1/prezentari", json=_body(password="parolaNoua")) + 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) + 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() + finally: + conn.close() + assert row["rar_creds_enc"] is not None + assert decrypt_creds(row["rar_creds_enc"])["password"] == "parolaNoua" + + +def test_resubmit_peste_sent_ramane_deduped(client): + r1 = client.post("/v1/prezentari", json=_body()) + sid = r1.json()["results"][0]["submission_id"] + _force_status(sid, "sent") + r2 = client.post("/v1/prezentari", json=_body()) + res = r2.json()["results"][0] + assert res["submission_id"] == sid + assert res["deduped"] is True + assert res.get("reactivated", False) is False + assert res["status"] == "sent" + + +def test_resubmit_peste_queued_ramane_deduped(client): + r1 = client.post("/v1/prezentari", json=_body()) + sid = r1.json()["results"][0]["submission_id"] + # ramane queued + r2 = client.post("/v1/prezentari", json=_body()) + res = r2.json()["results"][0] + assert res["submission_id"] == sid + assert res["deduped"] is True + assert res.get("reactivated", False) is False + + +def test_resubmit_peste_needs_data_ramane_deduped(client): + r1 = client.post("/v1/prezentari", json=_body()) + sid = r1.json()["results"][0]["submission_id"] + _force_status(sid, "needs_data") + r2 = client.post("/v1/prezentari", json=_body()) + res = r2.json()["results"][0] + assert res["deduped"] is True + assert res.get("reactivated", False) is False diff --git a/tests/test_error_handler.py b/tests/test_error_handler.py new file mode 100644 index 0000000..eec0036 --- /dev/null +++ b/tests/test_error_handler.py @@ -0,0 +1,91 @@ +"""Teste US-001 (PRD 5.6): handler global de excepții -> 500 structurat (3 niveluri) + log.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "eh.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + # raise_server_exceptions=False: lasam handlerul sa produca raspunsul 500, nu sa propage + with TestClient(app, raise_server_exceptions=False) as c: + yield c + get_settings.cache_clear() + + +def _force_500(monkeypatch): + """Forteaza o excepție interna pe /healthz (queue_depth arunca).""" + import app.main as m + + def boom(*a, **k): + raise RuntimeError("parola=secreta123 explodeaza intern") + + monkeypatch.setattr(m, "queue_depth", boom) + + +def test_exceptie_neasteptata_da_500_structurat(client, monkeypatch): + _force_500(monkeypatch) + r = client.get("/healthz") + assert r.status_code == 500 + body = r.json() + assert body["cod"] == "EROARE_INTERNA" + # 3 niveluri (PRD 5.4): problema + fix + assert body["problema"] + assert body["fix"] + assert "request_id" in body + + +def test_raspuns_contine_request_id_fara_traceback(client, monkeypatch): + _force_500(monkeypatch) + r = client.get("/healthz", headers={"X-Request-ID": "rid-eroare"}) + body = r.json() + assert body["request_id"] == "rid-eroare" + assert r.headers.get("X-Request-ID") == "rid-eroare" + raw = r.text + # Fara traceback / mesaj brut de excepție + assert "Traceback" not in raw + assert "RuntimeError" not in raw + assert "explodeaza intern" not in raw + + +def test_creds_nu_apar_in_raspuns(client, monkeypatch): + _force_500(monkeypatch) + r = client.get("/healthz") + assert "secreta123" not in r.text + + +def test_traceback_in_jurnal_redactat(client, monkeypatch): + _force_500(monkeypatch) + client.get("/healthz") + # Evenimentul exista in app_events, cu cod EROARE_INTERNA si fara parola in clar + from app.db import get_connection + conn = get_connection() + try: + row = conn.execute( + "SELECT cod, context_json FROM app_events WHERE tip='eroare_interna' ORDER BY id DESC LIMIT 1" + ).fetchone() + finally: + conn.close() + assert row is not None + assert row["cod"] == "EROARE_INTERNA" + # parola redactata in traceback-ul logat + assert "secreta123" not in (row["context_json"] or "") + + +def test_handlerele_existente_neatinse(client): + """422 ramane 422 structurat (nu prins de handlerul generic).""" + r = client.post("/v1/prezentari", json={"prezentari": []}) + assert r.status_code == 422 + # 404 ramane 404 + r2 = client.get("/v1/prezentari/999999") + assert r2.status_code in (200, 404) diff --git a/tests/test_get_scope_prezentari.py b/tests/test_get_scope_prezentari.py index 3ad67ec..51f03fe 100644 --- a/tests/test_get_scope_prezentari.py +++ b/tests/test_get_scope_prezentari.py @@ -148,7 +148,10 @@ def test_fara_cheie_flag_off_vede_contul_1(env): def test_detaliu_nu_expune_creds(env): """B4: GET /v1/prezentari/{id} nu expune campuri sensibile (rar_creds_enc, payload_json, - idempotency_key, rar_error). + idempotency_key). + + NOTA T9 (PRD 5.6): `rar_error` e ACUM expus intentionat (recovery API observabil) — + contine doar coduri/mesaje de validare RAR, niciodata creds. """ with _client() as c: from app.auth import create_api_key @@ -165,5 +168,5 @@ def test_detaliu_nu_expune_creds(env): resp = c.get(f"/v1/prezentari/{sid}", headers={"X-API-Key": k1}) assert resp.status_code == 200 data = resp.json() - for field in ("rar_creds_enc", "payload_json", "idempotency_key", "rar_error"): + for field in ("rar_creds_enc", "payload_json", "idempotency_key"): assert field not in data, f"camp sensibil expus: {field}" diff --git a/tests/test_jurnal_redactare.py b/tests/test_jurnal_redactare.py new file mode 100644 index 0000000..9fdfbe0 --- /dev/null +++ b/tests/test_jurnal_redactare.py @@ -0,0 +1,77 @@ +"""Teste US-007 (PRD 5.6): gard de redactare PII/parole in jurnal (L.142/GDPR).""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "red.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import init_db + init_db() + yield tmp + get_settings.cache_clear() + + +def _all_events_text(): + from app.db import get_connection + conn = get_connection() + try: + rows = conn.execute("SELECT mesaj, context_json FROM app_events").fetchall() + finally: + conn.close() + return "\n".join((r["mesaj"] or "") + " " + (r["context_json"] or "") for r in rows) + + +def test_vin_logat_partial(): + from app.security import vin_partial + assert vin_partial("WVWZZZ1KZAW000123") == "WVW…0123" + assert "WVWZZZ1KZAW000123" not in vin_partial("WVWZZZ1KZAW000123") + assert vin_partial("") == "" + assert vin_partial("AB") == "…" + + +def test_parola_niciodata_in_app_events(env): + from app import observ + observ.log_event( + "test_creds", + account_id=1, + mesaj='login cu password="parolaABC" si token=eyJsecret', + context={"rar_credentials": {"email": "a@b.ro", "password": "parolaABC"}, + "password": "parolaABC", "token": "eyJsecret"}, + ) + blob = _all_events_text() + assert "parolaABC" not in blob + assert "eyJsecret" not in blob + assert "***REDACTED***" in blob + + +def test_payload_integral_nu_se_logheaza(env): + """Un VIN integral pus in context se reduce la partial (nu se logheaza intreg).""" + from app import observ + observ.log_event("test_vin", context={"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B123ABC"}) + blob = _all_events_text() + assert "WVWZZZ1KZAW000123" not in blob + assert "0123" in blob # partial pastrat + + +def test_fuzz_chei_sensibile_mascate(env): + """Orice cheie sensibila in context -> mascata, oricat de adanc.""" + from app import observ + observ.log_event("fuzz", context={ + "nivel1": {"secret": "AAA", "pwd": "BBB", "ok": "vizibil"}, + "lista": [{"jwt": "CCC"}, {"apikey": "DDD"}], + "authorization": "Bearer eyJxyz", + }) + blob = _all_events_text() + for leak in ("AAA", "BBB", "CCC", "DDD", "eyJxyz"): + assert leak not in blob, f"scurgere: {leak}" + assert "vizibil" in blob # campurile benigne raman diff --git a/tests/test_jurnal_retentie.py b/tests/test_jurnal_retentie.py new file mode 100644 index 0000000..d2c5c94 --- /dev/null +++ b/tests/test_jurnal_retentie.py @@ -0,0 +1,94 @@ +"""Teste US-008 (PRD 5.6): retentie / purjare jurnal + RotatingFileHandler.""" + +from __future__ import annotations + +import logging.handlers +import os +import tempfile + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "jr.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import init_db + init_db() + yield tmp + get_settings.cache_clear() + + +def _conn(): + from app.db import get_connection + return get_connection() + + +def test_app_events_primesc_purge_after(env): + from app import observ + observ.log_event("test", account_id=1, mesaj="x") + conn = _conn() + try: + row = conn.execute("SELECT purge_after FROM app_events ORDER BY id DESC LIMIT 1").fetchone() + assert row["purge_after"] is not None + is_future = conn.execute( + "SELECT purge_after > datetime('now') AS ok FROM app_events ORDER BY id DESC LIMIT 1" + ).fetchone()["ok"] + assert is_future + finally: + conn.close() + + +def test_retentie_configurabila(env, monkeypatch): + monkeypatch.setenv("AUTOPASS_LOG_RETENTION_DAYS", "10") + from app.config import get_settings + get_settings.cache_clear() + from app import observ + observ.log_event("test", mesaj="x") + conn = _conn() + try: + ok = conn.execute( + "SELECT purge_after < datetime('now','+11 days') AS ok FROM app_events ORDER BY id DESC LIMIT 1" + ).fetchone()["ok"] + assert ok + finally: + conn.close() + + +def test_purjare_sterge_evenimente_expirate(env): + from app import observ + import app.worker.__main__ as w + observ.log_event("vechi", mesaj="x") + conn = _conn() + try: + conn.execute("UPDATE app_events SET purge_after=datetime('now','-1 day')") + conn.commit() + stats = w.purge_expired(conn) + assert stats["events_purged"] >= 1 + assert conn.execute("SELECT COUNT(*) AS n FROM app_events").fetchone()["n"] == 0 + finally: + conn.close() + + +def test_purjare_pastreaza_neexpirate(env): + from app import observ + import app.worker.__main__ as w + observ.log_event("nou", mesaj="x") # purge_after in viitor (90z) + conn = _conn() + try: + stats = w.purge_expired(conn) + assert stats["events_purged"] == 0 + assert conn.execute("SELECT COUNT(*) AS n FROM app_events").fetchone()["n"] == 1 + finally: + conn.close() + + +def test_log_text_foloseste_rotating_file_handler(env): + from app import observ + observ.log_event("rotativ", mesaj="x") + lg = observ._text_logger("api") + assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in lg.handlers), \ + "logul text trebuie sa foloseasca RotatingFileHandler (rotatie in aplicatie)" diff --git a/tests/test_observ.py b/tests/test_observ.py new file mode 100644 index 0000000..1f8b094 --- /dev/null +++ b/tests/test_observ.py @@ -0,0 +1,105 @@ +"""Teste US-003 (PRD 5.6): logger structurat central observ.log_event. + +TDD: scrie in app_events SI in log text, redacteaza creds/PII, filtreaza pe nivel. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "observ.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import init_db + init_db() + yield tmp + get_settings.cache_clear() + + +def _events(tip=None): + from app.db import get_connection + conn = get_connection() + try: + if tip: + return conn.execute("SELECT * FROM app_events WHERE tip=?", (tip,)).fetchall() + return conn.execute("SELECT * FROM app_events").fetchall() + finally: + conn.close() + + +def test_log_event_scrie_in_db_si_fisier(env): + from app import observ + observ.log_event("test_eveniment", nivel="INFO", account_id=2, cod="X", mesaj="salut") + + rows = _events("test_eveniment") + assert len(rows) == 1 + assert rows[0]["account_id"] == 2 + assert rows[0]["cod"] == "X" + assert rows[0]["mesaj"] == "salut" + assert rows[0]["sursa"] == "api" + + # Log text scris + log_path = os.path.join(env, "logs", "app-api.log") + assert os.path.exists(log_path) + with open(log_path, encoding="utf-8") as f: + content = f.read() + assert "test_eveniment" in content + + +def test_log_event_redacteaza_pii_si_creds(env): + from app import observ + observ.log_event( + "cu_secrete", + context={ + "password": "parolaSuperSecreta", + "rar_credentials": {"email": "a@b.ro", "password": "x"}, + "vin": "WVWZZZ1KZAW000123", + "token": "eyJhbGciOi", + "count": 3, + }, + ) + rows = _events("cu_secrete") + assert len(rows) == 1 + ctx = rows[0]["context_json"] + assert "parolaSuperSecreta" not in ctx + assert "***REDACTED***" in ctx + # VIN doar partial + assert "WVWZZZ1KZAW000123" not in ctx + assert "0123" in ctx + # campuri benigne raman + assert "count" in ctx + # fisierul text nu contine parola + log_path = os.path.join(env, "logs", "app-api.log") + with open(log_path, encoding="utf-8") as f: + content = f.read() + assert "parolaSuperSecreta" not in content + + +def test_nivel_filtrat_din_env(env, monkeypatch): + monkeypatch.setenv("AUTOPASS_LOG_LEVEL", "WARNING") + from app.config import get_settings + get_settings.cache_clear() + from app import observ + observ.log_event("sub_nivel", nivel="INFO") + observ.log_event("peste_nivel", nivel="ERROR") + assert len(_events("sub_nivel")) == 0 + assert len(_events("peste_nivel")) == 1 + + +def test_log_event_best_effort_nu_propaga(env, monkeypatch): + """O eroare interna de scriere nu propaga (jurnal best-effort).""" + from app import observ + # Forteaza o eroare in insert pasand o conexiune invalida + class Boom: + def execute(self, *a, **k): + raise RuntimeError("boom") + # nu trebuie sa ridice + observ.log_event("nepasator", conn=Boom()) diff --git a/tests/test_purge_blocate.py b/tests/test_purge_blocate.py new file mode 100644 index 0000000..a82b8e4 --- /dev/null +++ b/tests/test_purge_blocate.py @@ -0,0 +1,103 @@ +"""Teste US-013 (PRD 5.6): retentie / purjare randuri ne-sent blocate.""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pb.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import init_db + init_db() + yield monkeypatch + get_settings.cache_clear() + + +@pytest.fixture() +def conn(env): + from app.db import get_connection + c = get_connection() + yield c + c.close() + + +def _ins(conn, status="queued"): + content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1", + "data_prestatie": "2026-06-15", "odometru_final": "1", + "prestatii": [{"cod_prestatie": "OE-1"}]} + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + (f"k-{os.urandom(4).hex()}", 1, status, json.dumps(content)), + ) + return int(cur.lastrowid) + + +def test_error_primeste_purge_after(conn): + import app.worker.__main__ as w + sid = _ins(conn) + w.mark(conn, sid, "error", rar_error="creds gresite") + row = conn.execute("SELECT purge_after FROM submissions WHERE id=?", (sid,)).fetchone() + assert row["purge_after"] is not None + # In viitor, dar mai aproape de 30z decat de 90z + is_future = conn.execute( + "SELECT purge_after > datetime('now') AS ok FROM submissions WHERE id=?", (sid,) + ).fetchone()["ok"] + assert is_future + + +def test_needs_data_si_needs_mapping_primesc_purge_after(conn): + import app.worker.__main__ as w + for st in ("needs_data", "needs_mapping"): + sid = _ins(conn) + w.mark(conn, sid, st) + row = conn.execute("SELECT purge_after FROM submissions WHERE id=?", (sid,)).fetchone() + assert row["purge_after"] is not None, f"{st} trebuie sa primeasca purge_after" + + +def test_purjare_sterge_error_expirat(conn): + import app.worker.__main__ as w + sid = _ins(conn) + conn.execute( + "UPDATE submissions SET status='error', purge_after=datetime('now','-1 day') WHERE id=?", + (sid,), + ) + stats = w.purge_expired(conn) + assert stats["submissions_purged"] == 1 + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is None + + +def test_purjare_nu_atinge_queued_sau_sending(conn): + import app.worker.__main__ as w + # chiar daca au un purge_after rezidual in trecut, queued/sending NU se purjeaza + for st in ("queued", "sending"): + sid = _ins(conn) + conn.execute( + "UPDATE submissions SET status=?, purge_after=datetime('now','-1 day') WHERE id=?", + (st, sid), + ) + stats = w.purge_expired(conn) + assert stats["submissions_purged"] == 0 + + +def test_retentie_blocate_configurabila(conn, monkeypatch): + monkeypatch.setenv("AUTOPASS_BLOCKED_RETENTION_DAYS", "7") + from app.config import get_settings + get_settings.cache_clear() + import app.worker.__main__ as w + sid = _ins(conn) + w.mark(conn, sid, "error") + # purge_after ~ now + 7 zile: e inainte de now + 8 zile + row = conn.execute( + "SELECT purge_after < datetime('now','+8 days') AS ok FROM submissions WHERE id=?", + (sid,), + ).fetchone() + assert row["ok"] diff --git a/tests/test_request_id.py b/tests/test_request_id.py new file mode 100644 index 0000000..a04c18c --- /dev/null +++ b/tests/test_request_id.py @@ -0,0 +1,49 @@ +"""Teste US-002 (PRD 5.6): request_id per cerere + header X-Request-ID.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rid.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def test_raspuns_are_header_x_request_id(client): + r = client.get("/healthz") + assert r.status_code == 200 + assert r.headers.get("X-Request-ID"), "lipseste header X-Request-ID" + + +def test_request_id_distinct_pe_cereri(client): + a = client.get("/healthz").headers.get("X-Request-ID") + b = client.get("/healthz").headers.get("X-Request-ID") + assert a and b and a != b + + +def test_request_id_pastrat_daca_clientul_trimite(client): + r = client.get("/healthz", headers={"X-Request-ID": "corelare-abc"}) + assert r.headers.get("X-Request-ID") == "corelare-abc" + + +def test_request_id_propagat_in_log(client): + """request_id e disponibil in log_event pe durata cererii (contextvar).""" + from app import observ + + r = client.get("/healthz", headers={"X-Request-ID": "rid-xyz"}) + assert r.headers["X-Request-ID"] == "rid-xyz" + # In afara cererii, contextvar revine la None (reset in middleware) + assert observ.request_id_var.get() is None diff --git a/tests/test_submissions_admin.py b/tests/test_submissions_admin.py new file mode 100644 index 0000000..932e686 --- /dev/null +++ b/tests/test_submissions_admin.py @@ -0,0 +1,116 @@ +"""Teste US-009 (PRD 5.6): helper sterge / re-pune in coada randuri blocate.""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "sa.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import init_db + init_db() + yield monkeypatch + get_settings.cache_clear() + + +@pytest.fixture() +def conn(env): + from app.db import get_connection + c = get_connection() + yield c + c.close() + + +def _ins(conn, *, account_id=1, status="error", valid=True, key=None): + if valid: + content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}]} + else: + content = {"vin": "BAD", "nr_inmatriculare": "B1", + "data_prestatie": "2026-06-15", "odometru_final": "1", + "prestatii": [{"cod_prestatie": "OE-1"}]} + sfx = key or os.urandom(4).hex() + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + (f"k-{sfx}", account_id, status, json.dumps(content)), + ) + return int(cur.lastrowid) + + +def test_sterge_rand_error_scoped(conn): + from app.submissions_admin import delete_submission + sid = _ins(conn, status="error") + res = delete_submission(conn, 1, sid) + assert res["status_anterior"] == "error" + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is None + + +def test_nu_sterge_sent_sau_sending(conn): + from app.submissions_admin import delete_submission, SubmissionStateConflict + for st in ("sent", "sending"): + sid = _ins(conn, status=st) + with pytest.raises(SubmissionStateConflict): + delete_submission(conn, 1, sid) + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is not None + + +def test_scope_cross_account_404(conn): + from app.accounts import create_account + from app.submissions_admin import delete_submission, requeue_submission, SubmissionNotFound + other = create_account(conn, "Alt cont", active=True) + sid = _ins(conn, account_id=other, status="error") + with pytest.raises(SubmissionNotFound): + delete_submission(conn, 1, sid) + with pytest.raises(SubmissionNotFound): + requeue_submission(conn, 1, sid) + # randul altui cont ramane intact + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is not None + + +def test_repune_error_devine_queued_reset_retry(conn): + from app.submissions_admin import requeue_submission + sid = _ins(conn, status="error", valid=True) + conn.execute( + "UPDATE submissions SET retry_count=5, next_attempt_at=datetime('now','+1 day'), " + "purge_after=datetime('now','+30 days') WHERE id=?", + (sid,), + ) + res = requeue_submission(conn, 1, sid) + assert res["status_nou"] == "queued" + row = conn.execute( + "SELECT status, retry_count, next_attempt_at, purge_after FROM submissions WHERE id=?", + (sid,), + ).fetchone() + assert row["status"] == "queued" + assert row["retry_count"] == 0 + assert row["next_attempt_at"] is None + assert row["purge_after"] is None + + +def test_repune_re_ruleaza_classify(conn): + """Continut invalid la re-pune -> ramane needs_data (classify re-ruleaza).""" + from app.submissions_admin import requeue_submission + sid = _ins(conn, status="error", valid=False) + res = requeue_submission(conn, 1, sid) + assert res["status_nou"] == "needs_data" + + +def test_actiunile_emit_eveniment(conn): + from app.submissions_admin import delete_submission, requeue_submission + sid1 = _ins(conn, status="error", valid=True) + requeue_submission(conn, 1, sid1) + sid2 = _ins(conn, status="error") + delete_submission(conn, 1, sid2) + tipuri = {r["tip"] for r in conn.execute("SELECT tip FROM app_events").fetchall()} + assert "submission_repus" in tipuri + assert "submission_sters" in tipuri diff --git a/tests/test_t16_purjare.py b/tests/test_t16_purjare.py index a08d13c..5a5c725 100644 --- a/tests/test_t16_purjare.py +++ b/tests/test_t16_purjare.py @@ -65,14 +65,18 @@ def test_mark_sent_seteaza_purge_after(conn): assert is_future, "purge_after trebuie sa fie in viitor" -def test_mark_other_status_nu_seteaza_purge_after(conn): - """Alte statusuri (error, needs_data) nu seteaza purge_after.""" +def test_mark_queued_nu_seteaza_purge_after(conn): + """Starile active (queued) nu primesc purge_after. + + NOTA US-013: error/needs_data/needs_mapping primesc ACUM purge_after (retentie + blocate, 30z) — vezi tests/test_purge_blocate.py. Doar queued/sending raman fara. + """ import app.worker.__main__ as w sid = _insert_submission(conn) - w.mark(conn, sid, "error", rar_error="test") + w.mark(conn, sid, "queued") row = conn.execute("SELECT purge_after FROM submissions WHERE id=?", (sid,)).fetchone() - assert row["purge_after"] is None, "purge_after nu trebuie setat la 'error'" + assert row["purge_after"] is None, "purge_after nu trebuie setat la 'queued'" # --- (b) purge_expired sterge randurile expirate --- diff --git a/tests/test_web_jurnal.py b/tests/test_web_jurnal.py new file mode 100644 index 0000000..e628ab7 --- /dev/null +++ b/tests/test_web_jurnal.py @@ -0,0 +1,112 @@ +"""Teste US-006 (PRD 5.6): tab Jurnal in dashboard (scoped + filtre).""" + +from __future__ import annotations + +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "jrnl.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def _account_user(email, name="Service", admin=False): + from app.accounts import create_account + from app.users import create_user, set_admin + from app.db import get_connection + conn = get_connection() + try: + aid = create_account(conn, name, active=True) + create_user(conn, aid, email, "parolasecreta10") + if admin: + set_admin(conn, aid, True) + conn.commit() + return aid + finally: + conn.close() + + +def _login(client, email): + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + resp = client.post("/login", data={"email": email, "parola": "parolasecreta10", "csrf_token": m.group(1)}) + assert resp.status_code == 303 + + +def _event(account_id, tip, nivel="INFO", mesaj="x"): + from app import observ + observ.log_event(tip, nivel=nivel, account_id=account_id, mesaj=mesaj) + + +def test_non_admin_vede_doar_evenimentele_contului_sau(client): + aid = _account_user("u@test.com") + other = _account_user("o@test.com", name="Alt") + _event(aid, "api_prezentari", mesaj="al meu MARKER_A") + _event(other, "api_prezentari", mesaj="al altuia MARKER_B") + _login(client, "u@test.com") + + html = client.get("/_fragments/jurnal").text + assert "MARKER_A" in html + assert "MARKER_B" not in html + + +def test_admin_vede_toate_si_filtru_cont(client): + admin = _account_user("admin@test.com", name="Admin", admin=True) + other = _account_user("client@test.com", name="Client") + _event(admin, "rar_login", mesaj="eveniment ADMINEV") + _event(other, "api_prezentari", mesaj="eveniment CLIENTEV") + _login(client, "admin@test.com") + + # admin vede tot + html = client.get("/_fragments/jurnal").text + assert "ADMINEV" in html + assert "CLIENTEV" in html + # filtru pe cont + html2 = client.get(f"/_fragments/jurnal?cont={other}").text + assert "CLIENTEV" in html2 + assert "ADMINEV" not in html2 + + +def test_filtru_pe_tip_si_nivel(client): + aid = _account_user("f@test.com") + _event(aid, "api_prezentari", nivel="INFO", mesaj="EV_INFO") + _event(aid, "submission_error", nivel="ERROR", mesaj="EV_ERR") + _login(client, "f@test.com") + + html = client.get("/_fragments/jurnal?tip=submission_error").text + assert "EV_ERR" in html + assert "EV_INFO" not in html + + html2 = client.get("/_fragments/jurnal?nivel=INFO").text + assert "EV_INFO" in html2 + assert "EV_ERR" not in html2 + + +def test_jurnal_necesita_login(client): + r = client.get("/_fragments/jurnal") + assert r.status_code in (303, 401) + + +def test_deep_link_tab_jurnal(client): + _account_user("d@test.com") + _login(client, "d@test.com") + r = client.get("/?tab=jurnal") + assert r.status_code == 200 + assert "Jurnal de aplicatie" in r.text diff --git a/tests/test_web_lifecycle.py b/tests/test_web_lifecycle.py new file mode 100644 index 0000000..0b78390 --- /dev/null +++ b/tests/test_web_lifecycle.py @@ -0,0 +1,173 @@ +"""Teste US-011 (PRD 5.6): butoane web sterge / re-pune in coada + bulk, scoped + CSRF.""" + +from __future__ import annotations + +import json +import os +import re +import tempfile + +import pytest +from starlette.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "wl.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true") + from app.config import get_settings + get_settings.cache_clear() + from app.web import ratelimit + ratelimit._hits.clear() + from app.main import app + with TestClient(app, follow_redirects=False) as c: + yield c + ratelimit._hits.clear() + get_settings.cache_clear() + + +def _account_user(email, name="Service", password="parolasecreta10"): + from app.accounts import create_account + from app.users import create_user + from app.db import get_connection + conn = get_connection() + try: + aid = create_account(conn, name, active=True) + create_user(conn, aid, email, password) + return aid + finally: + conn.close() + + +def _login(client, email, password="parolasecreta10"): + resp = client.get("/login") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \ + re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text) + resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)}) + assert resp.status_code == 303, resp.text[:200] + # set_session goleste sesiunea la login -> token CSRF nou, obtinut DUPA login. + return _csrf(client) + + +def _csrf(client): + resp = client.get("/?tab=acasa") + m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) + assert m, "csrf_token negasit dupa login" + return m.group(1) + + +def _ins(account_id, status="error"): + from app.db import get_connection + conn = get_connection() + try: + content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}]} + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + (f"k-{os.urandom(5).hex()}", account_id, status, json.dumps(content)), + ) + conn.commit() + return int(cur.lastrowid) + finally: + conn.close() + + +def test_buton_sterge_doar_pe_blocate(client): + aid = _account_user("b@test.com") + _login(client, "b@test.com") + sid_err = _ins(aid, "error") + sid_sent = _ins(aid, "sent") + + html_err = client.get(f"/_fragments/trimitere/{sid_err}").text + assert "Re-pune in coada" in html_err + assert "/trimitere/%d/sterge" % sid_err in html_err or f"/trimitere/{sid_err}/sterge" in html_err + + html_sent = client.get(f"/_fragments/trimitere/{sid_sent}").text + assert "Re-pune in coada" not in html_sent + assert f"/trimitere/{sid_sent}/sterge" not in html_sent + + +def test_repune_din_ui_scoped_sesiune(client): + aid = _account_user("r@test.com") + csrf = _login(client, "r@test.com") + sid = _ins(aid, "error") + r = client.post(f"/trimitere/{sid}/repune", data={"csrf_token": csrf}) + assert r.status_code == 200 + from app.db import get_connection + conn = get_connection() + try: + st = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"] + finally: + conn.close() + assert st == "queued" + + +def test_sterge_din_ui(client): + aid = _account_user("s@test.com") + csrf = _login(client, "s@test.com") + sid = _ins(aid, "error") + r = client.post(f"/trimitere/{sid}/sterge", data={"csrf_token": csrf}) + assert r.status_code == 200 + from app.db import get_connection + conn = get_connection() + try: + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is None + finally: + conn.close() + + +def test_sterge_sent_409(client): + aid = _account_user("se@test.com") + csrf = _login(client, "se@test.com") + sid = _ins(aid, "sent") + r = client.post(f"/trimitere/{sid}/sterge", data={"csrf_token": csrf}) + assert r.status_code == 409 + + +def test_csrf_enforce(client): + aid = _account_user("c@test.com") + _login(client, "c@test.com") + sid = _ins(aid, "error") + r = client.post(f"/trimitere/{sid}/sterge", data={"csrf_token": "gresit"}) + assert r.status_code == 403 + # randul ramane + from app.db import get_connection + conn = get_connection() + try: + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is not None + finally: + conn.close() + + +def test_bulk_sterge_doar_blocate_scoped(client): + aid = _account_user("bk@test.com") + csrf = _login(client, "bk@test.com") + sid_err = _ins(aid, "error") + sid_nd = _ins(aid, "needs_data") + sid_sent = _ins(aid, "sent") + r = client.post( + "/trimiteri/sterge-bulk", + data={"submission_id": [str(sid_err), str(sid_nd), str(sid_sent)], "csrf_token": csrf}, + ) + assert r.status_code == 200 + from app.db import get_connection + conn = get_connection() + try: + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid_err,)).fetchone() is None + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid_nd,)).fetchone() is None + # sent NU se sterge + assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid_sent,)).fetchone() is not None + finally: + conn.close() + + +def test_repune_cross_account_404(client): + aid = _account_user("x1@test.com", name="X1") + other = _account_user("x2@test.com", name="X2") + csrf = _login(client, "x1@test.com") + sid_other = _ins(other, "error") + r = client.post(f"/trimitere/{sid_other}/repune", data={"csrf_token": csrf}) + assert r.status_code == 404 diff --git a/tests/test_web_status_fragment.py b/tests/test_web_status_fragment.py index 7bf60fd..c01953c 100644 --- a/tests/test_web_status_fragment.py +++ b/tests/test_web_status_fragment.py @@ -185,3 +185,64 @@ def test_status_se_reincarca_htmx(client): assert 'id="status-bar"' in html, ( "Fragmentul nu are id='status-bar' pe containerul radacina" ) + + +# ============================================================ +# US-014: banner "Necesita atentia ta" actionabil +# ============================================================ + +def _insert_submission_vehicul(status, account_id, vin, nr): + from app.db import get_connection + import json + conn = get_connection() + try: + conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)", + (f"k-{os.urandom(5).hex()}", account_id, status, + json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": "2026-06-15", + "odometru_final": "1", "prestatii": [{"cod_prestatie": "OE-1"}]})), + ) + conn.commit() + finally: + conn.close() + + +def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client): + acct_id, _ = _create_account_user("link@test.com", "parolasecreta10") + _login(client, "link@test.com", "parolasecreta10") + _insert_submission("error", acct_id) + + html = client.get("/_fragments/status").text + # Link HTMX catre lista filtrata pe error + deep-link server-side + assert "/_fragments/submissions?status=error" in html + assert "tab=acasa&status=error" in html + + +def test_status_arata_identificator_rand_blocat(client): + acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10") + _login(client, "ident@test.com", "parolasecreta10") + _insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC") + + html = client.get("/_fragments/status").text + # VIN partial (ultimele 4) + nr inmatriculare + #id + assert "0123" in html, "lipseste VIN partial" + assert "B123ABC" in html, "lipseste nr inmatriculare" + assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus" + + +def test_scoped_pe_cont(client): + from app.accounts import create_account + from app.db import get_connection + acct_id, _ = _create_account_user("own@test.com", "parolasecreta10") + conn = get_connection() + try: + other = create_account(conn, "Alt", active=True) + finally: + conn.close() + _login(client, "own@test.com", "parolasecreta10") + _insert_submission_vehicul("error", other, "OTHERVIN000009999", "X999ZZZ") + + html = client.get("/_fragments/status").text + # randul altui cont NU apare in banner-ul meu + assert "X999ZZZ" not in html + assert "9999" not in html diff --git a/tests/test_worker_observ.py b/tests/test_worker_observ.py new file mode 100644 index 0000000..45cb3b4 --- /dev/null +++ b/tests/test_worker_observ.py @@ -0,0 +1,122 @@ +"""Teste US-005 (PRD 5.6): audit login RAR + ciclu de viata trimiteri (worker).""" + +from __future__ import annotations + +import json +import os +import tempfile + +import pytest + +from app.rar_client import RarAuthError + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "wo.db")) + monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs")) + from app.config import get_settings + get_settings.cache_clear() + from app.db import get_connection, init_db + init_db() + conn = get_connection() + settings = get_settings() + yield conn, settings + conn.close() + get_settings.cache_clear() + + +_CONTENT = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null"} + + +def _events(conn, tip=None): + if tip: + return conn.execute("SELECT * FROM app_events WHERE tip=?", (tip,)).fetchall() + return conn.execute("SELECT * FROM app_events").fetchall() + + +class FakeRar: + def __init__(self, *, token="JWT-TEST", login_exc=None, post_result=None): + self.token = token + self.login_exc = login_exc + self.post_result = post_result if post_result is not None else {"id": 1000} + self.closed = False + + def login(self, email, password): + if self.login_exc: + raise self.login_exc + return self.token + + def get_nomenclator(self, token): + return [] + + def post_prezentare(self, token, payload): + return self.post_result + + def close(self): + self.closed = True + + +def test_login_reusit_logat(env, monkeypatch): + conn, settings = env + import app.worker.__main__ as w + monkeypatch.setattr(w, "RarClient", lambda s: FakeRar()) + sessions = w.AccountSessions(settings) + tok = sessions.get_token(conn, 2, {"email": "a@b.ro", "password": "secretaXY"}) + assert tok == "JWT-TEST" + rows = _events(conn, "rar_login") + assert len(rows) == 1 + assert rows[0]["account_id"] == 2 + ctx = rows[0]["context_json"] + assert "ok" in ctx + # fara parola in clar nicaieri + assert "secretaXY" not in (rows[0]["mesaj"] or "") + assert "secretaXY" not in (ctx or "") + + +def test_login_401_logat_fara_parola(env, monkeypatch): + conn, settings = env + import app.worker.__main__ as w + monkeypatch.setattr(w, "RarClient", lambda s: FakeRar(login_exc=RarAuthError("401", status_code=401))) + sessions = w.AccountSessions(settings) + with pytest.raises(RarAuthError): + sessions.get_token(conn, 3, {"email": "a@b.ro", "password": "parolaGRESITA"}) + rows = _events(conn, "rar_login") + assert len(rows) == 1 + assert rows[0]["nivel"] == "WARNING" + assert "esuat" in rows[0]["context_json"] + assert "parolaGRESITA" not in (rows[0]["context_json"] or "") + assert "parolaGRESITA" not in (rows[0]["mesaj"] or "") + + +def test_tranzitie_sent_si_error_logate(env, monkeypatch): + conn, settings = env + import app.worker.__main__ as w + from app.accounts import create_account + acct = create_account(conn, "Service Worker Obs", active=True) + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, 'sending', ?)", + (f"k-{os.urandom(4).hex()}", acct, json.dumps(_CONTENT)), + ) + sid = int(cur.lastrowid) + claimed = {"id": sid, "account_id": acct, "content": _CONTENT} + rar = FakeRar() + res = w.process_one(conn, settings, rar, "tok", claimed) + assert res == "sent" + assert len(_events(conn, "submission_sent")) == 1 + assert _events(conn, "submission_sent")[0]["account_id"] == 2 + + # eroare 4xx nerecuperabila -> submission_error + from app.rar_client import RarError + cur2 = conn.execute( + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, 2, 'sending', ?)", + (f"k-{os.urandom(4).hex()}", json.dumps(_CONTENT)), + ) + sid2 = int(cur2.lastrowid) + rar_err = FakeRar() + rar_err.post_prezentare = lambda t, p: (_ for _ in ()).throw(RarError("403", status_code=403)) + w.process_one(conn, settings, rar_err, "tok", {"id": sid2, "account_id": 2, "content": _CONTENT}) + assert len(_events(conn, "submission_error")) == 1 diff --git a/tests/test_worker_reconcile.py b/tests/test_worker_reconcile.py index 8041b19..a11e39e 100644 --- a/tests/test_worker_reconcile.py +++ b/tests/test_worker_reconcile.py @@ -203,6 +203,45 @@ def test_recover_orphan_neinregistrat_requeue(env): assert _status(conn, sid)["status"] == "queued" +def test_recover_orphan_ignora_randul_proaspat_claim(env): + """Regresie format data: un rand PROASPAT revendicat NU trebuie tratat ca orfan. + + claim_one scrie sending_since cu datetime('now') -> 'YYYY-MM-DD HH:MM:SS' (spatiu). + Bug-ul: cutoff calculat cu _iso() era in format ISO ('T'), iar la comparatie de string + spatiul (0x20) < 'T' (0x54) facea ca ORICE rand 'sending' sa para <= cutoff -> lease-ul + de 120s era ignorat si fiecare rand revendicat era reconciliat/requeue-uit instant. + """ + from app.worker.__main__ import claim_one, recover_orphans + conn, settings = env + _insert(conn) # queued + claimed = claim_one(conn) # -> sending, sending_since=datetime('now') + assert claimed is not None + # Chiar daca recordul ar exista la RAR, randul proaspat nu trebuie atins. + rar = FakeRar(finalizate=[{"id": 999, "vin": "WVWZZZ1KZAW000123", + "dataPrestatie": "2026-06-15", "odometruFinal": 123456}]) + n = recover_orphans(conn, settings, rar, "tok") + assert n == 0 # nu l-a recuperat + assert rar.finalizate_calls == 0 # nici macar nu a interogat RAR + assert _status(conn, claimed["id"])["status"] == "sending" # neatins + + +def test_recover_orphan_lease_depasit_format_sqlite(env): + """Complementar: un rand 'sending' mai vechi decat lease-ul (format SQLite, spatiu) ESTE orfan.""" + from app.worker.__main__ import recover_orphans + conn, settings = env + cur = conn.execute( + "INSERT INTO submissions (idempotency_key, status, payload_json, sending_since) " + "VALUES (?, 'sending', ?, datetime('now', '-1 hour'))", + (f"key-{os.urandom(4).hex()}", json.dumps(_CONTENT)), + ) + sid = int(cur.lastrowid) + rar = FakeRar(finalizate=[{"id": 888, "vin": "WVWZZZ1KZAW000123", + "dataPrestatie": "2026-06-15", "odometruFinal": 123456}]) + n = recover_orphans(conn, settings, rar, "tok") + assert n == 1 + assert _status(conn, sid)["status"] == "sent" + + def test_claim_respecta_next_attempt_at(env): from app.worker.__main__ import claim_one conn, _ = env