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:
@@ -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()
|
||||||
|
|||||||
26
app/auth.py
26
app/auth.py
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
66
app/db.py
66
app/db.py
@@ -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()
|
||||||
|
|||||||
@@ -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."
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
41
app/main.py
41
app/main.py
@@ -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
|
||||||
|
|||||||
@@ -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
147
app/observ.py
Normal 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
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
117
app/submissions_admin.py
Normal 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
33
app/web/middleware.py
Normal 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
|
||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
106
app/web/templates/_jurnal.html
Normal file
106
app/web/templates/_jurnal.html
Normal 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">‹ 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 ›</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">Niciun eveniment pe filtrul curent.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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 }}) ›
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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">✓</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.
|
||||||
|
|||||||
@@ -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 = {} %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
121
tests/test_api_lifecycle.py
Normal 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
72
tests/test_audit_api.py
Normal 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
127
tests/test_dedup_error.py
Normal 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
|
||||||
91
tests/test_error_handler.py
Normal file
91
tests/test_error_handler.py
Normal 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)
|
||||||
@@ -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}"
|
||||||
|
|||||||
77
tests/test_jurnal_redactare.py
Normal file
77
tests/test_jurnal_redactare.py
Normal 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
|
||||||
94
tests/test_jurnal_retentie.py
Normal file
94
tests/test_jurnal_retentie.py
Normal 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
105
tests/test_observ.py
Normal 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
103
tests/test_purge_blocate.py
Normal 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
49
tests/test_request_id.py
Normal 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
|
||||||
116
tests/test_submissions_admin.py
Normal file
116
tests/test_submissions_admin.py
Normal 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
|
||||||
@@ -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
112
tests/test_web_jurnal.py
Normal 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
173
tests/test_web_lifecycle.py
Normal 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
|
||||||
@@ -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
122
tests/test_worker_observ.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user