feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate

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) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 18:45:39 +00:00
parent f48346de5c
commit c842e3352a
40 changed files with 2851 additions and 64 deletions

View File

@@ -41,7 +41,14 @@ from ...models import (
ValidareResponse, ValidareResponse,
ValidareResult, ValidareResult,
) )
from ...observ import log_event
from ...payload_view import prezentare_din_payload from ...payload_view import prezentare_din_payload
from ...submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
delete_submission,
requeue_submission,
)
router = APIRouter(prefix="/v1", tags=["v1"]) router = APIRouter(prefix="/v1", tags=["v1"])
@@ -93,6 +100,37 @@ def create_prezentari(
(key,), (key,),
).fetchone() ).fetchone()
if existing: 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( results.append(
SubmissionResult( SubmissionResult(
submission_id=existing["id"], 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), (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"])) 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: finally:
conn.close() conn.close()
return PrezentariResponse(results=results) return PrezentariResponse(results=results)
@@ -199,6 +256,10 @@ _PREZENTARE_FIELDS = frozenset({
"id", "status", "id_prezentare", "rar_status_code", "retry_count", "id", "status", "id_prezentare", "rar_status_code", "retry_count",
"next_attempt_at", "created_at", "updated_at", "account_id", "next_attempt_at", "created_at", "updated_at", "account_id",
"batch_id", "row_index", "purge_after", "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() 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") @router.get("/nomenclator")
def get_nomenclator() -> dict: def get_nomenclator() -> dict:
conn = get_connection() conn = get_connection()

View File

@@ -19,7 +19,7 @@ import hashlib
import secrets import secrets
import sqlite3 import sqlite3
from fastapi import Header, HTTPException from fastapi import Header, HTTPException, Request
from .config import get_settings from .config import get_settings
from .db import get_connection from .db import get_connection
@@ -111,7 +111,28 @@ def _extract_key(x_api_key: str | None, authorization: str | None) -> str | None
return 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( def resolve_account_id(
request: Request,
x_api_key: str | None = Header(default=None, alias="X-API-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"),
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
) -> int: ) -> int:
@@ -121,12 +142,14 @@ def resolve_account_id(
- cheie invalida (prezenta) -> 401 (mereu, indiferent de flag) - cheie invalida (prezenta) -> 401 (mereu, indiferent de flag)
- fara cheie + flag off -> cont implicit (id=1), back-compat - fara cheie + flag off -> cont implicit (id=1), back-compat
- fara cheie + flag on -> 401 - fara cheie + flag on -> 401
Esecurile de auth (401) emit `api_auth_esuat` cu IP + prefix cheie (US-004).
""" """
settings = get_settings() settings = get_settings()
plaintext = _extract_key(x_api_key, authorization) plaintext = _extract_key(x_api_key, authorization)
if plaintext is None: if plaintext is None:
if settings.require_api_key: if settings.require_api_key:
_log_auth_esuat(request, None, "cheie API lipsa (prod)")
raise HTTPException(status_code=401, detail="cheie API lipsa") raise HTTPException(status_code=401, detail="cheie API lipsa")
return DEFAULT_ACCOUNT_ID return DEFAULT_ACCOUNT_ID
@@ -136,5 +159,6 @@ def resolve_account_id(
finally: finally:
conn.close() conn.close()
if account_id is None: 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") raise HTTPException(status_code=401, detail="cheie API invalida sau revocata")
return account_id return account_id

View File

@@ -22,6 +22,21 @@ class Settings(BaseSettings):
# --- Bază de date --- # --- Bază de date ---
db_path: Path = ROOT / "data" / "autopass.db" 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) --- # --- Securitate (CORE) ---
# Enforcement auth API-key pe /v1/* protejat. False (dev/test): fara cheie -> # 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 # cont implicit id=1. True (prod): fara cheie valida -> 401. O cheie PREZENTA

View File

@@ -143,3 +143,69 @@ def read_heartbeat(conn: sqlite3.Connection) -> sqlite3.Row | None:
def queue_depth(conn: sqlite3.Connection) -> int: def queue_depth(conn: sqlite3.Connection) -> int:
row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone() row = conn.execute("SELECT COUNT(*) AS n FROM submissions WHERE status='queued'").fetchone()
return int(row["n"]) if row else 0 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()

View File

@@ -162,6 +162,14 @@ CATALOG: dict[str, dict[str, str]] = {
" (ghilimele duble, acolade inchise corect)." " (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."
),
},
} }

View File

@@ -20,14 +20,19 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
import traceback
from . import __version__ from . import __version__
from . import errors
from .api.v1.import_router import router as import_v1_router 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.integrare_router import router as integrare_v1_router
from .api.v1.router import router as api_v1_router from .api.v1.router import router as api_v1_router
from .config import get_settings from .config import get_settings
from .crypto import validate_creds_key from .crypto import validate_creds_key
from .db import get_connection, init_db, queue_depth, read_heartbeat 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.routes import router as web_router
from .web.auth_routes import router as auth_router from .web.auth_routes import router as auth_router
from .web.admin_routes import router as admin_router from .web.admin_routes import router as admin_router
@@ -56,6 +61,10 @@ app.add_middleware(
https_only=settings.session_https_only, https_only=settings.session_https_only,
same_site="strict", 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) @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}) 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 # Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza
# offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static # offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static
# (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie # (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie

View File

@@ -96,6 +96,10 @@ class SubmissionResult(BaseModel):
status: str status: str
id_prezentare: int | None = None id_prezentare: int | None = None
deduped: bool = False # True daca idempotency a intors un submission existent 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): class PrezentariResponse(BaseModel):

147
app/observ.py Normal file
View File

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

View File

@@ -154,6 +154,29 @@ CREATE TABLE IF NOT EXISTS users (
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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. -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat ( CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),

View File

@@ -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: def scrub(obj: Any) -> Any:
"""Copie a structurii cu valorile cheilor sensibile mascate, recursiv. """Copie a structurii cu valorile cheilor sensibile mascate, recursiv.

117
app/submissions_admin.py Normal file
View File

@@ -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"]}

33
app/web/middleware.py Normal file
View File

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

View File

@@ -49,11 +49,17 @@ from ..api.v1.import_router import (
) )
from ..config import get_settings from ..config import get_settings
from ..crypto import decrypt_creds, encrypt_creds 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 ..idempotency import build_key, canonicalize_row
from ..validation import validate_prezentare from ..validation import validate_prezentare
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
from ..users import is_account_admin from ..users import is_account_admin
from ..submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
delete_submission,
requeue_submission,
)
from ..mapping import ( from ..mapping import (
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
account_or_default, 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. # 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. # 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. # ?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: 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: def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
"""Randeaza panoul Acasa ca string HTML.""" """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: if conn is None:
return templates.get_template("_acasa.html").render( return templates.get_template("_acasa.html").render(
{"request": request, "csrf_token": get_csrf_token(request)} {"request": request, "csrf_token": get_csrf_token(request)}
) )
ctx = _get_acasa_context(request, conn, account_id) ctx = _get_acasa_context(request, conn, account_id)
ctx["status_filtru"] = status
return templates.get_template("_acasa.html").render(ctx) 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.""" """Randeaza panoul corespunzator unui tab ca string HTML."""
if tab == "acasa": 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": if tab == "import":
return _render_panel_import(request) return _render_panel_import(request)
if tab == "coada": 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) @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). """Dashboard principal cu tab-uri (US-003).
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat 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). 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) account_id = require_login(request)
active_tab = tab if tab in _TABS_VALIDE else "acasa" active_tab = tab if tab in _TABS_VALIDE else "acasa"
conn = get_connection() conn = get_connection()
try: 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 # Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003). # (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003).
counts = _status_counts(conn, account_id) counts = _status_counts(conn, account_id)
@@ -400,6 +490,32 @@ def fragment_integrare(request: Request) -> HTMLResponse:
conn.close() 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) @router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse: def fragment_banner(request: Request) -> HTMLResponse:
account_id = require_login(request) account_id = require_login(request)
@@ -430,6 +546,45 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
return rezultat 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) @router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse: def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4). """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), "counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total, "blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts), "blocate_defalcat": _blocate_defalcat(counts),
"blocate_actionabil": _blocate_actionabil(conn, account_id),
"account_active": _account_active(conn, account_id), "account_active": _account_active(conn, account_id),
}) })
finally: finally:
@@ -496,6 +652,9 @@ def _submission_row_view(r) -> dict:
"id_prezentare": r["id_prezentare"], "id_prezentare": r["id_prezentare"],
"updated_at": format_data_rar(r["updated_at"]), "updated_at": format_data_rar(r["updated_at"]),
"motiv": motiv_uman(r["status"], r["rar_error"]), "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, "request": request,
"rows": view, "rows": view,
"filtru_activ": filtru_activ, "filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request),
}) })
finally: finally:
conn.close() conn.close()
@@ -573,6 +733,25 @@ def fragment_submissions(
# Stari ne-trimise blocate pe care le putem corecta inline (US-010). # Stari ne-trimise blocate pe care le putem corecta inline (US-010).
_CORECTABILE = ("needs_data", "needs_mapping") _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: 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"]), "next_attempt_at": format_data_rar(row["next_attempt_at"]),
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu # randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
"editabil": row["status"] in _CORECTABILE, "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_msg": message,
"corectie_error": error, "corectie_error": error,
"corectie_errors": corectie_errors or [], "corectie_errors": corectie_errors or [],
@@ -792,6 +973,92 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
conn.close() 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(
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
)
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]: def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele """Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu).""" prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""

View File

@@ -30,14 +30,17 @@
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div> <div>
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label> <label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
{# 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('') %}
<select id="f-status" name="status"> <select id="f-status" name="status">
<option value="">toate</option> <option value="" {% if not sf %}selected{% endif %}>toate</option>
<option value="queued">in asteptare</option> <option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
<option value="sent">declarate la RAR</option> <option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
<option value="needs_mapping">lipsa cod</option> <option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
<option value="needs_data">date incomplete</option> <option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
<option value="error">eroare</option> <option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
<option value="sending">se trimite</option> <option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
</select> </select>
</div> </div>
<div> <div>
@@ -57,7 +60,8 @@
<!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) --> <!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) -->
<div id="submissions-wrap" <div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-trigger="load, every 15s" hx-get="/_fragments/submissions"
hx-trigger="load, every 15s, trimiteriChanged from:body"
hx-include="#filtre-trimiteri" hx-swap="innerHTML"> hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div> <div class="empty">se incarca…</div>
</div> </div>

View File

@@ -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). #}
<section id="jurnal-section" aria-labelledby="jurnal-heading">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="jurnal-heading" style="font-size:15px; margin:0;">Jurnal de aplicatie</h2>
{% if is_admin %}
<span class="pill s-sent" style="font-size:11px;">admin: toate conturile</span>
{% else %}
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
{% endif %}
</div>
<form id="filtre-jurnal"
hx-get="/_fragments/jurnal"
hx-target="#jurnal-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="j-tip" class="muted" style="display:block; font-size:12px;">Tip eveniment</label>
<select id="j-tip" name="tip">
<option value="">toate</option>
{% for t in tipuri %}
<option value="{{ t }}" {% if f_tip == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="j-nivel" class="muted" style="display:block; font-size:12px;">Nivel</label>
<select id="j-nivel" name="nivel">
<option value="">toate</option>
{% for nv in ("INFO", "WARNING", "ERROR", "CRITICAL", "DEBUG") %}
<option value="{{ nv }}" {% if f_nivel == nv %}selected{% endif %}>{{ nv }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="j-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
<input id="j-data-de" type="date" name="data_de" value="{{ f_data_de }}">
</div>
<div>
<label for="j-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="j-data-pana" type="date" name="data_pana" value="{{ f_data_pana }}">
</div>
{% if is_admin %}
<div>
<label for="j-cont" class="muted" style="display:block; font-size:12px;">Cont (id)</label>
<input id="j-cont" type="number" name="cont" value="{{ f_cont }}" placeholder="toate" style="max-width:100px;">
</div>
{% endif %}
<button type="submit">Filtreaza</button>
</form>
<div id="jurnal-wrap">
{% if evenimente %}
<div class="tablewrap">
<table>
<thead><tr>
<th>Cand</th>
<th>Sursa</th>
<th>Tip</th>
<th>Nivel</th>
{% if is_admin %}<th>Cont</th>{% endif %}
<th>Cod</th>
<th>Mesaj</th>
</tr></thead>
<tbody>
{% for e in evenimente %}
<tr>
<td class="muted" style="white-space:nowrap;">{{ e.ts }}</td>
<td>{{ e.sursa }}</td>
<td>{{ e.tip }}</td>
<td>
<span class="{% if e.nivel in ('ERROR','CRITICAL') %}s-error{% elif e.nivel == 'WARNING' %}s-needs_data{% else %}muted{% endif %}">{{ e.nivel }}</span>
</td>
{% if is_admin %}<td class="muted">{{ e.account_id if e.account_id is not none else '—' }}</td>{% endif %}
<td class="muted">{{ e.cod or '—' }}</td>
<td style="white-space:normal; max-width:360px;">{{ e.mesaj or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Paginare: prev/next pe acelasi set de filtre #}
{% if prev_page is not none or next_page is not none %}
<div style="display:flex; gap:10px; margin-top:12px; align-items:center;">
{% if prev_page is not none %}
<a href="#" hx-get="/_fragments/jurnal?page={{ prev_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
hx-target="#jurnal-wrap" hx-swap="innerHTML">&lsaquo; mai noi</a>
{% endif %}
<span class="muted" style="font-size:12px;">pagina {{ page + 1 }}</span>
{% if next_page is not none %}
<a href="#" hx-get="/_fragments/jurnal?page={{ next_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
hx-target="#jurnal-wrap" hx-swap="innerHTML">mai vechi &rsaquo;</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty">Niciun eveniment pe filtrul curent.</div>
{% endif %}
</div>
</div>
</section>

View File

@@ -47,21 +47,35 @@
</span> </span>
</div> </div>
<!-- Defalcare blocate pe motiv (doar daca exista) --> <!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
{% if blocate_defalcat %} + identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
{% if blocate_actionabil %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);"> <div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div> <div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
<div style="display:flex; gap:16px; flex-wrap:wrap;"> <div style="display:flex; gap:18px; flex-wrap:wrap;">
{% for eticheta, n in blocate_defalcat %} {% for cat in blocate_actionabil %}
{% if n > 0 %} <div style="min-width:200px;">
<div> {# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span> deep-link server-side (?tab=acasa&status=...). #}
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span> <a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
{% if eticheta[1] %} href="/?tab=acasa&status={{ cat.status }}"
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div> hx-get="/_fragments/submissions?status={{ cat.status }}"
{% endif %} hx-target="#submissions-wrap" hx-swap="innerHTML"
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
{{ cat.eticheta[0] }} ({{ cat.n }}) &rsaquo;
</a>
<ul style="list-style:none; margin:6px 0 0; padding:0;">
{% for r in cat.randuri %}
<li class="muted" style="font-size:12px;">
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
</li>
{% endfor %}
{% if cat.rest %}
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
{% endif %}
</ul>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,24 @@
{% if rows %} {% if rows %}
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
<form id="bulk-trimiteri"
hx-post="/trimiteri/sterge-bulk"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
hx-confirm="Stergi definitiv trimiterile selectate?"
hx-disinherit="hx-confirm"
style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:flex; justify-content:flex-end; margin-bottom:8px;">
<button type="submit" id="bulk-sterge-btn"
style="background:var(--card); color:var(--err); border-color:var(--err); font-size:13px; padding:4px 10px;">
Sterge selectate
</button>
</div>
<div class="tablewrap"> <div class="tablewrap">
<table> <table>
<thead><tr> <thead><tr>
<th style="width:28px;"><span class="muted" title="Selecteaza randuri blocate">&#10003;</span></th>
<th>#</th> <th>#</th>
<th>Stare</th> <th>Stare</th>
<th>Vehicul</th> <th>Vehicul</th>
@@ -19,6 +36,12 @@
hx-swap="innerHTML" hx-swap="innerHTML"
style="cursor:pointer;" style="cursor:pointer;"
title="Click pentru detaliul complet"> title="Click pentru detaliul complet">
<td onclick="event.stopPropagation();">
{% if r.gestionabil %}
<input type="checkbox" name="submission_id" value="{{ r.id }}"
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %}
</td>
<td class="muted">{{ r.id }}</td> <td class="muted">{{ r.id }}</td>
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td> <td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
<td> <td>
@@ -37,6 +60,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</form>
{% elif filtru_activ %} {% elif filtru_activ %}
<div class="empty"> <div class="empty">
Nimic pe filtrul curent. Nimic pe filtrul curent.

View File

@@ -46,6 +46,25 @@
</details> </details>
{% endif %} {% endif %}
{# === Lifecycle (US-011): sterge / re-pune in coada — doar randuri blocate === #}
{% if gestionabil %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;">
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#trimitere-detaliu" hx-swap="innerHTML" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Re-pune in coada</button>
</form>
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#trimitere-detaliu" hx-swap="innerHTML"
hx-confirm="Stergi definitiv aceasta trimitere din coada?" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</div>
{% endif %}
{# === Corectie inline (US-010): doar randuri ne-trimise blocate === #} {# === Corectie inline (US-010): doar randuri ne-trimise blocate === #}
{% if editabil %} {% if editabil %}
{% set err_map = {} %} {% set err_map = {} %}

View File

@@ -184,6 +184,7 @@
<a role="menuitem" href="/?tab=cont">Cont</a> <a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a> <a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a> <a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
<a role="menuitem" href="/?tab=jurnal">Jurnal</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %} {% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
<hr> <hr>
<form method="post" action="/logout"> <form method="post" action="/logout">

View File

@@ -38,6 +38,7 @@ from .. import errors
from ..config import Settings, get_settings, load_test_credentials from ..config import Settings, get_settings, load_test_credentials
from ..crypto import decrypt_creds from ..crypto import decrypt_creds
from ..db import get_connection, init_db, write_heartbeat 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 ..mapping import DEFAULT_ACCOUNT_ID, upsert_nomenclator
from ..payload import build_rar_payload from ..payload import build_rar_payload
from ..reconcile import match_finalizata from ..reconcile import match_finalizata
@@ -59,6 +60,14 @@ def _iso(dt: datetime) -> str:
return dt.isoformat(timespec="seconds") 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: 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) 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 --- # --- 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: def mark(conn, submission_id: int, status: str, *, rar_status_code=None, rar_error=None, id_prezentare=None) -> None:
if status == "sent": if status == "sent":
# T16: purge_after = sent + 90 zile (GDPR/L.142 retentie maxima). # 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( conn.execute(
"UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, " "UPDATE submissions SET status=?, rar_status_code=?, rar_error=?, id_prezentare=?, "
"sending_since=NULL, updated_at=datetime('now'), " f"sending_since=NULL, updated_at=datetime('now'), purge_after={purge_expr} WHERE id=?",
"purge_after=datetime('now', '+90 days') WHERE id=?",
(status, rar_status_code, rar_error, id_prezentare, submission_id), (status, rar_status_code, rar_error, id_prezentare, submission_id),
) )
else: else:
@@ -99,19 +121,26 @@ _PURGE_INTERVAL_S = 3600
def purge_expired(conn) -> dict[str, int]: def purge_expired(conn) -> dict[str, int]:
"""Sterge randurile expirate (purge_after < now). """Sterge randurile expirate (purge_after < now).
T16/OV-5: purge_after era exportat dar setat de nimeni si niciun job nu exista. T16/OV-5 + US-013/US-008: submissions `sent` SI blocate (error/needs_data/needs_mapping)
Acum: submissions sent + expirate, import_batches expirate (import_rows via CASCADE). expirate; import_batches expirate (import_rows via CASCADE); app_events expirate (jurnal).
Intoarce {submissions_purged, batches_purged}. 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( 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( cur_batch = conn.execute(
"DELETE FROM import_batches WHERE purge_after IS NOT NULL AND purge_after < datetime('now')" "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 { return {
"submissions_purged": cur_sub.rowcount, "submissions_purged": cur_sub.rowcount,
"batches_purged": cur_batch.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: if found_id is not None:
mark(conn, submission_id, "sent", rar_status_code=200, id_prezentare=found_id, mark(conn, submission_id, "sent", rar_status_code=200, id_prezentare=found_id,
rar_error="reconciliat (raspuns pierdut)") 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 True
return False 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: def process_one(conn, settings: Settings, rar: RarClient, token: str, claimed: dict) -> str:
"""Trimite o prezentare claimed. Intoarce starea finala (pentru teste/loguri).""" """Trimite o prezentare claimed. Intoarce starea finala (pentru teste/loguri)."""
sid = claimed["id"] sid = claimed["id"]
account_id = claimed.get("account_id")
content = claimed["content"] content = claimed["content"]
payload = build_rar_payload(content) payload = build_rar_payload(content)
try: try:
data = rar.post_prezentare(token, payload) data = rar.post_prezentare(token, payload)
mark(conn, sid, "sent", rar_status_code=200, id_prezentare=data.get("id")) 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" return "sent"
except RarError as exc: except RarError as exc:
if exc.status_code == 400: 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))] enriched = [errors.eroare("RAR_VALIDARE", cauza=str(exc))]
detail = json.dumps(enriched, ensure_ascii=False) detail = json.dumps(enriched, ensure_ascii=False)
mark(conn, sid, "needs_data", rar_status_code=400, rar_error=detail) 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" return "needs_data"
if _is_transient(exc): if _is_transient(exc):
return _handle_transient(conn, settings, rar, token, sid, content, str(exc)) return _handle_transient(conn, settings, rar, token, sid, content, str(exc))
# 4xx nerecuperabil (nu 400/401/408/429) -> error. # 4xx nerecuperabil (nu 400/401/408/429) -> error.
mark(conn, sid, "error", rar_status_code=exc.status_code, rar_error=str(exc)) 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" return "error"
except (httpx.TimeoutException, httpx.TransportError) as exc: except (httpx.TimeoutException, httpx.TransportError) as exc:
return _handle_transient(conn, settings, rar, token, sid, content, f"retea: {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 `account_id` filtreaza la orfanii unui cont (login-ul e per-cont); None = toti
(compat teste / single-account). (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: if account_id is not None:
orphans = conn.execute( orphans = conn.execute(
"SELECT id, payload_json FROM submissions WHERE status='sending' " "SELECT id, payload_json FROM submissions WHERE status='sending' "
"AND (sending_since IS NULL OR sending_since <= ?) AND account_id=?", "AND (sending_since IS NULL OR sending_since <= datetime('now', ?)) AND account_id=?",
(cutoff, account_id), (lease, account_id),
).fetchall() ).fetchall()
else: else:
orphans = conn.execute( orphans = conn.execute(
"SELECT id, payload_json FROM submissions WHERE status='sending' " "SELECT id, payload_json FROM submissions WHERE status='sending' "
"AND (sending_since IS NULL OR sending_since <= ?)", "AND (sending_since IS NULL OR sending_since <= datetime('now', ?))",
(cutoff,), (lease,),
).fetchall() ).fetchall()
recovered = 0 recovered = 0
for row in orphans: for row in orphans:
@@ -308,11 +350,23 @@ class AccountSessions:
rar = RarClient(self.settings) rar = RarClient(self.settings)
try: try:
token = rar.login(creds["email"], creds["password"]) 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: except Exception:
rar.close() rar.close()
raise raise
self._sessions[account_id] = (rar, token) self._sessions[account_id] = (rar, token)
write_heartbeat(conn, rar_login_ok=True, detail=f"login RAR ok (cont {account_id})") 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. # 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. # GATE PURJARE (T1/Voce#5): sterge DOAR submissions.rar_creds_enc, NU accounts.rar_creds_enc.
# Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart). # Canal web: fallback exista in accounts -> purjarea e inofensiva (re-login dupa restart).
@@ -371,6 +425,7 @@ def run() -> int:
signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGINT, _stop)
settings = get_settings() settings = get_settings()
set_source("worker") # US-005: evenimentele worker-ului au sursa=worker (fisier app-worker.log)
init_db() init_db()
conn = get_connection() conn = get_connection()
print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True) print(f"[worker] pornit (send_enabled={settings.worker_send_enabled}, env={settings.rar_env})", flush=True)
@@ -386,10 +441,10 @@ def run() -> int:
now_ts = time.time() now_ts = time.time()
if now_ts - _last_purge_time >= _PURGE_INTERVAL_S: if now_ts - _last_purge_time >= _PURGE_INTERVAL_S:
stats = purge_expired(conn) 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( print(
f"[worker] purjare: {stats['submissions_purged']} submissions, " 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, flush=True,
) )
_last_purge_time = now_ts _last_purge_time = now_ts
@@ -408,6 +463,12 @@ def run() -> int:
sid = claimed["id"] sid = claimed["id"]
account_id = claimed["account_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 # 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. # 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) 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). # Creds gresite (login 401): NU se face retry (plan, failure registry).
mark(conn, sid, "error", rar_status_code=401, mark(conn, sid, "error", rar_status_code=401,
rar_error=json.dumps(errors.eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)) 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 continue
if token is None: if token is None:
@@ -430,6 +494,14 @@ def run() -> int:
rar = sessions.rar(account_id) rar = sessions.rar(account_id)
# Recupereaza orfanii contului inainte de trimitere (acelasi token). # Recupereaza orfanii contului inainte de trimitere (acelasi token).
recover_orphans(conn, settings, rar, token, account_id=account_id) 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: try:
process_one(conn, settings, rar, token, claimed) process_one(conn, settings, rar, token, claimed)
except RarAuthError as exc: except RarAuthError as exc:

File diff suppressed because one or more lines are too long

View File

@@ -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). - `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). - 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). Fuzzy: `rapidfuzz.token_sort_ratio` pe denumire normalizata (fara diacritice, upper).
Nomenclatorul se ia **live** din RAR (worker upsert la fiecare login); seed fallback 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. de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline.

View File

@@ -1,6 +1,7 @@
<!-- /autoplan restore point: /home/claude/.gstack/projects/romfast-rar-autopass/main-autoplan-restore-20260623-165442.md -->
# PRD 5.6 — Observabilitate, jurnal aplicatie & lifecycle trimiteri blocate # 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`. > 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`. > 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 intai (RED)**: `tests/test_api_lifecycle.py` — `test_delete_scoped_pe_cheie`,
`test_delete_sent_403`, `test_repune_error_queued`, `test_repune_inexistent_404` `test_delete_sent_403`, `test_repune_error_queued`, `test_repune_inexistent_404`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `DELETE /v1/prezentari/{id}` → 200/204 pe randuri ne-sent ale contului cheii; - [ ] `DELETE /v1/prezentari/{id}` → **200 + body JSON** `{ok, submission_id, status_anterior}`
403 pe `sent`/`sending`; 404 cross-account/inexistent (acelasi mesaj, ca B3). (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). - [ ] `POST /v1/prezentari/{id}/repune` → randul devine `queued` (peste helper US-009).
- [ ] Scoped strict pe contul cheii API (nu se poate atinge alt cont). - [ ] 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). - **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**: - **Acceptance criteria**:
- [ ] La enqueue, daca randul existent cu aceeasi `idempotency_key` e `error`: - [ ] La enqueue, daca randul existent cu aceeasi `idempotency_key` e `error`:
se RE-ACTIVEAZA acelasi rand (re-ruleaza `classify`, **actualizeaza `rar_creds_enc`** 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 cu creds-urile noi din cerere, reset `retry_count`/`next_attempt_at`, **`purge_after=NULL`**),
NU mai e `deduped: true` ci starea noua (ex. `queued`). 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` - [ ] Pentru `sent`/`queued`/`sending`: comportament neschimbat → `deduped: true`
(nu cream dubluri, nu deranjam in-flight/trimise). (nu cream dubluri, nu deranjam in-flight/trimise).
- [ ] `needs_data`/`needs_mapping`: raman `deduped` la resubmit (decizie §5) — corectia - [ ] `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=<nou>, 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<now AND status IN ('sent','error','needs_data','needs_mapping')
EXCLUDE explicit 'queued'/'sending'
```
### Failure Modes Registry (noi, din review)
```
CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES | LOGGED?
---------------------------------|-------------------------------|----------|-------|------------------|--------
create_prezentari reactivare | cursa cu claim_one / 2x POST | FIX T1 | FIX T3| queued det. | US-004
worker JWT cache dupa creds noi | trimite cu parola veche | FIX T1 | FIX T3| ramane error | US-005 <- CRITICAL
reactivare fara purge_after=NULL | purjat inainte de claim | FIX T2 | FIX T3| dispare tacit | US-005 <- CRITICAL
log_event own-conn pe hot path | WAL write-lock pana la 15s | FIX T4 | da | latenta POST | -
RotatingFileHandler 2 procese | rotatie rename race | FIX T5 | n/a | log corupt | -
500 envelope 4 chei | parser client crapa pe 5xx | FIX T7 | da | KeyError client | US-001
403 sent vs 404 cross-acct | oracol de existenta | FIX TD2 | da | leak | US-004
bulk select vs poll 15s | selectie stearsa mid-actiune | FIX T12 | da | frustrare | -
deep-link status inexistent | banner duce la lista nefiltr. | FIX T13 | da | dead-end | -
```
### Decision Audit Trail (auto-decis cu cele 6 principii)
| # | Faza | Decizie | Clasificare | Principiu | Rationament |
|---|------|---------|-------------|-----------|-------------|
| 1 | CEO | Premisa app_events table + tab | GATE (user) | - | Confirmat de utilizator: web-visibility e scop de produs (operator fara SSH) |
| 2 | Eng | US-012 = CAS guarded + invalidare sesiune worker la creds noi (T1) | Mechanical | P1 completeness | Bug central; fara el US-012 nu-si atinge scopul |
| 3 | Eng | reactivare/requeue purge_after=NULL; purge exclude queued/sending (T2) | Mechanical | P1 | Altfel randul reactivat e purjat tacit |
| 4 | Eng | teste concurenta + purge-before-claim (T3) | Mechanical | P1 well-tested | Lista de teste US-012 era single-thread |
| 5 | Eng | log_event(conn opt) reuse hot-path (T4) | Mechanical | P3 pragmatic | Evita contentie WAL |
| 6 | Eng | log-uri per-proces api.log/worker.log (T5) | Mechanical | P5 explicit | RotatingFileHandler nu e multiproces-safe |
| 7 | Eng | vin_partial() + context curat (T6) | Mechanical | P1 | scrub() nu acopera VIN (US-007) |
| 8 | DX | EROARE_INTERNA in CATALOG; 500 = 6 chei + request_id (T7) | Mechanical | P1 | Contract 6 chei (PRD 5.4) |
| 9 | DX | X-Request-ID pe TOATE raspunsurile (T8) | Mechanical | P1 | Corelare si pe 422/401/404 |
| 10 | DX | rar_error in _PREZENTARE_FIELDS (T9) | Mechanical | P6 action | Recovery API observabil fara dashboard |
| 11 | DX | update api-rar-contract.md + reconcile de-scope (T10) | Mechanical | P1 | Sursa de adevar trebuie sa includa endpointurile noi |
| 12 | DX | DELETE -> 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 ## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. > Executie completa 2026-06-23 (TDD, RED->GREEN per story). Toate cele 14 stories livrate.
> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe RAR test). Lipseste pana la VERIFY.
### 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.

121
tests/test_api_lifecycle.py Normal file
View File

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

72
tests/test_audit_api.py Normal file
View File

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

127
tests/test_dedup_error.py Normal file
View File

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

View File

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

View File

@@ -148,7 +148,10 @@ def test_fara_cheie_flag_off_vede_contul_1(env):
def test_detaliu_nu_expune_creds(env): def test_detaliu_nu_expune_creds(env):
"""B4: GET /v1/prezentari/{id} nu expune campuri sensibile (rar_creds_enc, payload_json, """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: with _client() as c:
from app.auth import create_api_key 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}) resp = c.get(f"/v1/prezentari/{sid}", headers={"X-API-Key": k1})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() 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}" assert field not in data, f"camp sensibil expus: {field}"

View File

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

View File

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

105
tests/test_observ.py Normal file
View File

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

103
tests/test_purge_blocate.py Normal file
View File

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

49
tests/test_request_id.py Normal file
View File

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

View File

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

View File

@@ -65,14 +65,18 @@ def test_mark_sent_seteaza_purge_after(conn):
assert is_future, "purge_after trebuie sa fie in viitor" assert is_future, "purge_after trebuie sa fie in viitor"
def test_mark_other_status_nu_seteaza_purge_after(conn): def test_mark_queued_nu_seteaza_purge_after(conn):
"""Alte statusuri (error, needs_data) nu seteaza purge_after.""" """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 import app.worker.__main__ as w
sid = _insert_submission(conn) 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() 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 --- # --- (b) purge_expired sterge randurile expirate ---

112
tests/test_web_jurnal.py Normal file
View File

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

173
tests/test_web_lifecycle.py Normal file
View File

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

View File

@@ -185,3 +185,64 @@ def test_status_se_reincarca_htmx(client):
assert 'id="status-bar"' in html, ( assert 'id="status-bar"' in html, (
"Fragmentul nu are id='status-bar' pe containerul radacina" "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

122
tests/test_worker_observ.py Normal file
View File

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

View File

@@ -203,6 +203,45 @@ def test_recover_orphan_neinregistrat_requeue(env):
assert _status(conn, sid)["status"] == "queued" 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): def test_claim_respecta_next_attempt_at(env):
from app.worker.__main__ import claim_one from app.worker.__main__ import claim_one
conn, _ = env conn, _ = env