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

Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:

Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
  submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
  (JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
  la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil

Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
  redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)

pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 18:45:39 +00:00
parent f48346de5c
commit c842e3352a
40 changed files with 2851 additions and 64 deletions

121
tests/test_api_lifecycle.py Normal file
View File

@@ -0,0 +1,121 @@
"""Teste US-010 (PRD 5.6): API DELETE + re-pune in coada, scoped + oracol scope/stare."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "life.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _account_with_key(name="Cont"):
from app.accounts import create_account
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
aid = create_account(conn, name, active=True)
key = create_api_key(conn, aid)
conn.commit()
return aid, key
finally:
conn.close()
def _body(**over):
prez = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}]}
prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
def _enqueue(client, key, **over):
r = client.post("/v1/prezentari", json=_body(**over), headers={"X-API-Key": key})
assert r.status_code == 200, r.text
return r.json()["results"][0]["submission_id"]
def _force_status(sid, status):
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE submissions SET status=? WHERE id=?", (status, sid))
conn.commit()
finally:
conn.close()
def test_delete_scoped_pe_cheie(client):
_aid, key = _account_with_key()
sid = _enqueue(client, key)
_force_status(sid, "error")
r = client.request("DELETE", f"/v1/prezentari/{sid}", headers={"X-API-Key": key})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["submission_id"] == sid
assert body["status_anterior"] == "error"
def test_delete_sent_409(client):
_aid, key = _account_with_key()
sid = _enqueue(client, key)
_force_status(sid, "sent")
r = client.request("DELETE", f"/v1/prezentari/{sid}", headers={"X-API-Key": key})
assert r.status_code == 409
def test_delete_inexistent_404(client):
_aid, key = _account_with_key()
r = client.request("DELETE", "/v1/prezentari/99999", headers={"X-API-Key": key})
assert r.status_code == 404
def test_delete_cross_account_sent_404(client):
"""Scope INAINTEA starii: randul `sent` al ALTUI cont -> 404 (nu 409, nu confirmam existenta)."""
aid_a, key_a = _account_with_key("A")
aid_b, key_b = _account_with_key("B")
sid = _enqueue(client, key_b)
_force_status(sid, "sent")
# contul A incearca sa stearga randul lui B (sent) -> 404, NU 409
r = client.request("DELETE", f"/v1/prezentari/{sid}", headers={"X-API-Key": key_a})
assert r.status_code == 404
def test_repune_error_queued(client):
_aid, key = _account_with_key()
sid = _enqueue(client, key)
_force_status(sid, "error")
r = client.post(f"/v1/prezentari/{sid}/repune", headers={"X-API-Key": key})
assert r.status_code == 200
assert r.json()["status_nou"] == "queued"
def test_repune_inexistent_404(client):
_aid, key = _account_with_key()
r = client.post("/v1/prezentari/99999/repune", headers={"X-API-Key": key})
assert r.status_code == 404
def test_repune_sending_409(client):
_aid, key = _account_with_key()
sid = _enqueue(client, key)
_force_status(sid, "sending")
r = client.post(f"/v1/prezentari/{sid}/repune", headers={"X-API-Key": key})
assert r.status_code == 409

72
tests/test_audit_api.py Normal file
View File

@@ -0,0 +1,72 @@
"""Teste US-004 (PRD 5.6): audit cerere API per cont in jurnal."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "audit.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _body(**over):
prez = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}]}
prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": "secretaPP"}, "prezentari": [prez]}
def _events(tip):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT * FROM app_events WHERE tip=?", (tip,)).fetchall()
finally:
conn.close()
def test_post_prezentari_logheaza_eveniment_cont(client):
r = client.post("/v1/prezentari", json=_body())
assert r.status_code == 200
rows = _events("api_prezentari")
assert len(rows) == 1
assert rows[0]["account_id"] == 1
def test_eveniment_contine_status_si_count_fara_pii(client):
client.post("/v1/prezentari", json=_body())
rows = _events("api_prezentari")
ctx = rows[0]["context_json"]
assert "distributie" in ctx
assert "queued" in ctx
assert "count" in ctx
# NICIUN PII integral (parola / VIN integral)
assert "secretaPP" not in ctx
assert "WVWZZZ1KZAW000123" not in ctx
def test_401_logat_ca_auth_esuat(client):
# cheie prezenta dar invalida -> 401 (indiferent de flag)
r = client.post("/v1/prezentari", json=_body(), headers={"X-API-Key": "rfak_invalidakey123"})
assert r.status_code == 401
rows = _events("api_auth_esuat")
assert len(rows) == 1
ctx = rows[0]["context_json"]
# prefix cheie, NU cheia intreaga
assert "rfak_inv" in ctx
assert "rfak_invalidakey123" not in ctx

127
tests/test_dedup_error.py Normal file
View File

@@ -0,0 +1,127 @@
"""Teste US-012 (PRD 5.6): un rand `error` nu mai blocheaza retrimiterea (dedup).
La resubmit cu aceeasi cheie de continut peste un rand `error`: se RE-ACTIVEAZA
(reactivated:true + stare noua), creds-urile se actualizeaza. Peste sent/queued/
sending/needs_* ramane `deduped:true` (neschimbat).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "dedup.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _body(password="corecta", **over):
prez = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"rar_credentials": {"email": "x@y.ro", "password": password}, "prezentari": [prez]}
def _force_status(sid, status):
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE submissions SET status=? WHERE id=?", (status, sid))
conn.commit()
finally:
conn.close()
def _creds_enc(sid):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT rar_creds_enc FROM submissions WHERE id=?", (sid,)).fetchone()["rar_creds_enc"]
finally:
conn.close()
def test_resubmit_peste_error_reactiveaza(client):
r1 = client.post("/v1/prezentari", json=_body(password="gresita"))
sid = r1.json()["results"][0]["submission_id"]
_force_status(sid, "error")
r2 = client.post("/v1/prezentari", json=_body(password="corecta"))
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["reactivated"] is True
assert res["status"] == "queued"
assert res.get("deduped", False) is False
def test_resubmit_actualizeaza_creds_pe_reactivare(client):
r1 = client.post("/v1/prezentari", json=_body(password="gresita"))
sid = r1.json()["results"][0]["submission_id"]
enc_initial = _creds_enc(sid)
_force_status(sid, "error")
client.post("/v1/prezentari", json=_body(password="parolaNoua"))
enc_dupa = _creds_enc(sid)
assert enc_dupa is not None
assert enc_dupa != enc_initial, "creds-urile trebuie actualizate la reactivare"
# propagat si in accounts.rar_creds_enc (canal durabil, decizie #17)
from app.db import get_connection
from app.crypto import decrypt_creds
conn = get_connection()
try:
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=1").fetchone()
finally:
conn.close()
assert row["rar_creds_enc"] is not None
assert decrypt_creds(row["rar_creds_enc"])["password"] == "parolaNoua"
def test_resubmit_peste_sent_ramane_deduped(client):
r1 = client.post("/v1/prezentari", json=_body())
sid = r1.json()["results"][0]["submission_id"]
_force_status(sid, "sent")
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["deduped"] is True
assert res.get("reactivated", False) is False
assert res["status"] == "sent"
def test_resubmit_peste_queued_ramane_deduped(client):
r1 = client.post("/v1/prezentari", json=_body())
sid = r1.json()["results"][0]["submission_id"]
# ramane queued
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["deduped"] is True
assert res.get("reactivated", False) is False
def test_resubmit_peste_needs_data_ramane_deduped(client):
r1 = client.post("/v1/prezentari", json=_body())
sid = r1.json()["results"][0]["submission_id"]
_force_status(sid, "needs_data")
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["deduped"] is True
assert res.get("reactivated", False) is False

View File

@@ -0,0 +1,91 @@
"""Teste US-001 (PRD 5.6): handler global de excepții -> 500 structurat (3 niveluri) + log."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "eh.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
# raise_server_exceptions=False: lasam handlerul sa produca raspunsul 500, nu sa propage
with TestClient(app, raise_server_exceptions=False) as c:
yield c
get_settings.cache_clear()
def _force_500(monkeypatch):
"""Forteaza o excepție interna pe /healthz (queue_depth arunca)."""
import app.main as m
def boom(*a, **k):
raise RuntimeError("parola=secreta123 explodeaza intern")
monkeypatch.setattr(m, "queue_depth", boom)
def test_exceptie_neasteptata_da_500_structurat(client, monkeypatch):
_force_500(monkeypatch)
r = client.get("/healthz")
assert r.status_code == 500
body = r.json()
assert body["cod"] == "EROARE_INTERNA"
# 3 niveluri (PRD 5.4): problema + fix
assert body["problema"]
assert body["fix"]
assert "request_id" in body
def test_raspuns_contine_request_id_fara_traceback(client, monkeypatch):
_force_500(monkeypatch)
r = client.get("/healthz", headers={"X-Request-ID": "rid-eroare"})
body = r.json()
assert body["request_id"] == "rid-eroare"
assert r.headers.get("X-Request-ID") == "rid-eroare"
raw = r.text
# Fara traceback / mesaj brut de excepție
assert "Traceback" not in raw
assert "RuntimeError" not in raw
assert "explodeaza intern" not in raw
def test_creds_nu_apar_in_raspuns(client, monkeypatch):
_force_500(monkeypatch)
r = client.get("/healthz")
assert "secreta123" not in r.text
def test_traceback_in_jurnal_redactat(client, monkeypatch):
_force_500(monkeypatch)
client.get("/healthz")
# Evenimentul exista in app_events, cu cod EROARE_INTERNA si fara parola in clar
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT cod, context_json FROM app_events WHERE tip='eroare_interna' ORDER BY id DESC LIMIT 1"
).fetchone()
finally:
conn.close()
assert row is not None
assert row["cod"] == "EROARE_INTERNA"
# parola redactata in traceback-ul logat
assert "secreta123" not in (row["context_json"] or "")
def test_handlerele_existente_neatinse(client):
"""422 ramane 422 structurat (nu prins de handlerul generic)."""
r = client.post("/v1/prezentari", json={"prezentari": []})
assert r.status_code == 422
# 404 ramane 404
r2 = client.get("/v1/prezentari/999999")
assert r2.status_code in (200, 404)

View File

@@ -148,7 +148,10 @@ def test_fara_cheie_flag_off_vede_contul_1(env):
def test_detaliu_nu_expune_creds(env):
"""B4: GET /v1/prezentari/{id} nu expune campuri sensibile (rar_creds_enc, payload_json,
idempotency_key, rar_error).
idempotency_key).
NOTA T9 (PRD 5.6): `rar_error` e ACUM expus intentionat (recovery API observabil) —
contine doar coduri/mesaje de validare RAR, niciodata creds.
"""
with _client() as c:
from app.auth import create_api_key
@@ -165,5 +168,5 @@ def test_detaliu_nu_expune_creds(env):
resp = c.get(f"/v1/prezentari/{sid}", headers={"X-API-Key": k1})
assert resp.status_code == 200
data = resp.json()
for field in ("rar_creds_enc", "payload_json", "idempotency_key", "rar_error"):
for field in ("rar_creds_enc", "payload_json", "idempotency_key"):
assert field not in data, f"camp sensibil expus: {field}"

View File

@@ -0,0 +1,77 @@
"""Teste US-007 (PRD 5.6): gard de redactare PII/parole in jurnal (L.142/GDPR)."""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "red.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield tmp
get_settings.cache_clear()
def _all_events_text():
from app.db import get_connection
conn = get_connection()
try:
rows = conn.execute("SELECT mesaj, context_json FROM app_events").fetchall()
finally:
conn.close()
return "\n".join((r["mesaj"] or "") + " " + (r["context_json"] or "") for r in rows)
def test_vin_logat_partial():
from app.security import vin_partial
assert vin_partial("WVWZZZ1KZAW000123") == "WVW…0123"
assert "WVWZZZ1KZAW000123" not in vin_partial("WVWZZZ1KZAW000123")
assert vin_partial("") == ""
assert vin_partial("AB") == ""
def test_parola_niciodata_in_app_events(env):
from app import observ
observ.log_event(
"test_creds",
account_id=1,
mesaj='login cu password="parolaABC" si token=eyJsecret',
context={"rar_credentials": {"email": "a@b.ro", "password": "parolaABC"},
"password": "parolaABC", "token": "eyJsecret"},
)
blob = _all_events_text()
assert "parolaABC" not in blob
assert "eyJsecret" not in blob
assert "***REDACTED***" in blob
def test_payload_integral_nu_se_logheaza(env):
"""Un VIN integral pus in context se reduce la partial (nu se logheaza intreg)."""
from app import observ
observ.log_event("test_vin", context={"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B123ABC"})
blob = _all_events_text()
assert "WVWZZZ1KZAW000123" not in blob
assert "0123" in blob # partial pastrat
def test_fuzz_chei_sensibile_mascate(env):
"""Orice cheie sensibila in context -> mascata, oricat de adanc."""
from app import observ
observ.log_event("fuzz", context={
"nivel1": {"secret": "AAA", "pwd": "BBB", "ok": "vizibil"},
"lista": [{"jwt": "CCC"}, {"apikey": "DDD"}],
"authorization": "Bearer eyJxyz",
})
blob = _all_events_text()
for leak in ("AAA", "BBB", "CCC", "DDD", "eyJxyz"):
assert leak not in blob, f"scurgere: {leak}"
assert "vizibil" in blob # campurile benigne raman

View File

@@ -0,0 +1,94 @@
"""Teste US-008 (PRD 5.6): retentie / purjare jurnal + RotatingFileHandler."""
from __future__ import annotations
import logging.handlers
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "jr.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield tmp
get_settings.cache_clear()
def _conn():
from app.db import get_connection
return get_connection()
def test_app_events_primesc_purge_after(env):
from app import observ
observ.log_event("test", account_id=1, mesaj="x")
conn = _conn()
try:
row = conn.execute("SELECT purge_after FROM app_events ORDER BY id DESC LIMIT 1").fetchone()
assert row["purge_after"] is not None
is_future = conn.execute(
"SELECT purge_after > datetime('now') AS ok FROM app_events ORDER BY id DESC LIMIT 1"
).fetchone()["ok"]
assert is_future
finally:
conn.close()
def test_retentie_configurabila(env, monkeypatch):
monkeypatch.setenv("AUTOPASS_LOG_RETENTION_DAYS", "10")
from app.config import get_settings
get_settings.cache_clear()
from app import observ
observ.log_event("test", mesaj="x")
conn = _conn()
try:
ok = conn.execute(
"SELECT purge_after < datetime('now','+11 days') AS ok FROM app_events ORDER BY id DESC LIMIT 1"
).fetchone()["ok"]
assert ok
finally:
conn.close()
def test_purjare_sterge_evenimente_expirate(env):
from app import observ
import app.worker.__main__ as w
observ.log_event("vechi", mesaj="x")
conn = _conn()
try:
conn.execute("UPDATE app_events SET purge_after=datetime('now','-1 day')")
conn.commit()
stats = w.purge_expired(conn)
assert stats["events_purged"] >= 1
assert conn.execute("SELECT COUNT(*) AS n FROM app_events").fetchone()["n"] == 0
finally:
conn.close()
def test_purjare_pastreaza_neexpirate(env):
from app import observ
import app.worker.__main__ as w
observ.log_event("nou", mesaj="x") # purge_after in viitor (90z)
conn = _conn()
try:
stats = w.purge_expired(conn)
assert stats["events_purged"] == 0
assert conn.execute("SELECT COUNT(*) AS n FROM app_events").fetchone()["n"] == 1
finally:
conn.close()
def test_log_text_foloseste_rotating_file_handler(env):
from app import observ
observ.log_event("rotativ", mesaj="x")
lg = observ._text_logger("api")
assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in lg.handlers), \
"logul text trebuie sa foloseasca RotatingFileHandler (rotatie in aplicatie)"

105
tests/test_observ.py Normal file
View File

@@ -0,0 +1,105 @@
"""Teste US-003 (PRD 5.6): logger structurat central observ.log_event.
TDD: scrie in app_events SI in log text, redacteaza creds/PII, filtreaza pe nivel.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "observ.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield tmp
get_settings.cache_clear()
def _events(tip=None):
from app.db import get_connection
conn = get_connection()
try:
if tip:
return conn.execute("SELECT * FROM app_events WHERE tip=?", (tip,)).fetchall()
return conn.execute("SELECT * FROM app_events").fetchall()
finally:
conn.close()
def test_log_event_scrie_in_db_si_fisier(env):
from app import observ
observ.log_event("test_eveniment", nivel="INFO", account_id=2, cod="X", mesaj="salut")
rows = _events("test_eveniment")
assert len(rows) == 1
assert rows[0]["account_id"] == 2
assert rows[0]["cod"] == "X"
assert rows[0]["mesaj"] == "salut"
assert rows[0]["sursa"] == "api"
# Log text scris
log_path = os.path.join(env, "logs", "app-api.log")
assert os.path.exists(log_path)
with open(log_path, encoding="utf-8") as f:
content = f.read()
assert "test_eveniment" in content
def test_log_event_redacteaza_pii_si_creds(env):
from app import observ
observ.log_event(
"cu_secrete",
context={
"password": "parolaSuperSecreta",
"rar_credentials": {"email": "a@b.ro", "password": "x"},
"vin": "WVWZZZ1KZAW000123",
"token": "eyJhbGciOi",
"count": 3,
},
)
rows = _events("cu_secrete")
assert len(rows) == 1
ctx = rows[0]["context_json"]
assert "parolaSuperSecreta" not in ctx
assert "***REDACTED***" in ctx
# VIN doar partial
assert "WVWZZZ1KZAW000123" not in ctx
assert "0123" in ctx
# campuri benigne raman
assert "count" in ctx
# fisierul text nu contine parola
log_path = os.path.join(env, "logs", "app-api.log")
with open(log_path, encoding="utf-8") as f:
content = f.read()
assert "parolaSuperSecreta" not in content
def test_nivel_filtrat_din_env(env, monkeypatch):
monkeypatch.setenv("AUTOPASS_LOG_LEVEL", "WARNING")
from app.config import get_settings
get_settings.cache_clear()
from app import observ
observ.log_event("sub_nivel", nivel="INFO")
observ.log_event("peste_nivel", nivel="ERROR")
assert len(_events("sub_nivel")) == 0
assert len(_events("peste_nivel")) == 1
def test_log_event_best_effort_nu_propaga(env, monkeypatch):
"""O eroare interna de scriere nu propaga (jurnal best-effort)."""
from app import observ
# Forteaza o eroare in insert pasand o conexiune invalida
class Boom:
def execute(self, *a, **k):
raise RuntimeError("boom")
# nu trebuie sa ridice
observ.log_event("nepasator", conn=Boom())

103
tests/test_purge_blocate.py Normal file
View File

@@ -0,0 +1,103 @@
"""Teste US-013 (PRD 5.6): retentie / purjare randuri ne-sent blocate."""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pb.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _ins(conn, status="queued"):
content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B1",
"data_prestatie": "2026-06-15", "odometru_final": "1",
"prestatii": [{"cod_prestatie": "OE-1"}]}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(4).hex()}", 1, status, json.dumps(content)),
)
return int(cur.lastrowid)
def test_error_primeste_purge_after(conn):
import app.worker.__main__ as w
sid = _ins(conn)
w.mark(conn, sid, "error", rar_error="creds gresite")
row = conn.execute("SELECT purge_after FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["purge_after"] is not None
# In viitor, dar mai aproape de 30z decat de 90z
is_future = conn.execute(
"SELECT purge_after > datetime('now') AS ok FROM submissions WHERE id=?", (sid,)
).fetchone()["ok"]
assert is_future
def test_needs_data_si_needs_mapping_primesc_purge_after(conn):
import app.worker.__main__ as w
for st in ("needs_data", "needs_mapping"):
sid = _ins(conn)
w.mark(conn, sid, st)
row = conn.execute("SELECT purge_after FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["purge_after"] is not None, f"{st} trebuie sa primeasca purge_after"
def test_purjare_sterge_error_expirat(conn):
import app.worker.__main__ as w
sid = _ins(conn)
conn.execute(
"UPDATE submissions SET status='error', purge_after=datetime('now','-1 day') WHERE id=?",
(sid,),
)
stats = w.purge_expired(conn)
assert stats["submissions_purged"] == 1
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is None
def test_purjare_nu_atinge_queued_sau_sending(conn):
import app.worker.__main__ as w
# chiar daca au un purge_after rezidual in trecut, queued/sending NU se purjeaza
for st in ("queued", "sending"):
sid = _ins(conn)
conn.execute(
"UPDATE submissions SET status=?, purge_after=datetime('now','-1 day') WHERE id=?",
(st, sid),
)
stats = w.purge_expired(conn)
assert stats["submissions_purged"] == 0
def test_retentie_blocate_configurabila(conn, monkeypatch):
monkeypatch.setenv("AUTOPASS_BLOCKED_RETENTION_DAYS", "7")
from app.config import get_settings
get_settings.cache_clear()
import app.worker.__main__ as w
sid = _ins(conn)
w.mark(conn, sid, "error")
# purge_after ~ now + 7 zile: e inainte de now + 8 zile
row = conn.execute(
"SELECT purge_after < datetime('now','+8 days') AS ok FROM submissions WHERE id=?",
(sid,),
).fetchone()
assert row["ok"]

49
tests/test_request_id.py Normal file
View File

@@ -0,0 +1,49 @@
"""Teste US-002 (PRD 5.6): request_id per cerere + header X-Request-ID."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rid.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def test_raspuns_are_header_x_request_id(client):
r = client.get("/healthz")
assert r.status_code == 200
assert r.headers.get("X-Request-ID"), "lipseste header X-Request-ID"
def test_request_id_distinct_pe_cereri(client):
a = client.get("/healthz").headers.get("X-Request-ID")
b = client.get("/healthz").headers.get("X-Request-ID")
assert a and b and a != b
def test_request_id_pastrat_daca_clientul_trimite(client):
r = client.get("/healthz", headers={"X-Request-ID": "corelare-abc"})
assert r.headers.get("X-Request-ID") == "corelare-abc"
def test_request_id_propagat_in_log(client):
"""request_id e disponibil in log_event pe durata cererii (contextvar)."""
from app import observ
r = client.get("/healthz", headers={"X-Request-ID": "rid-xyz"})
assert r.headers["X-Request-ID"] == "rid-xyz"
# In afara cererii, contextvar revine la None (reset in middleware)
assert observ.request_id_var.get() is None

View File

@@ -0,0 +1,116 @@
"""Teste US-009 (PRD 5.6): helper sterge / re-pune in coada randuri blocate."""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "sa.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
def _ins(conn, *, account_id=1, status="error", valid=True, key=None):
if valid:
content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}]}
else:
content = {"vin": "BAD", "nr_inmatriculare": "B1",
"data_prestatie": "2026-06-15", "odometru_final": "1",
"prestatii": [{"cod_prestatie": "OE-1"}]}
sfx = key or os.urandom(4).hex()
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{sfx}", account_id, status, json.dumps(content)),
)
return int(cur.lastrowid)
def test_sterge_rand_error_scoped(conn):
from app.submissions_admin import delete_submission
sid = _ins(conn, status="error")
res = delete_submission(conn, 1, sid)
assert res["status_anterior"] == "error"
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is None
def test_nu_sterge_sent_sau_sending(conn):
from app.submissions_admin import delete_submission, SubmissionStateConflict
for st in ("sent", "sending"):
sid = _ins(conn, status=st)
with pytest.raises(SubmissionStateConflict):
delete_submission(conn, 1, sid)
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is not None
def test_scope_cross_account_404(conn):
from app.accounts import create_account
from app.submissions_admin import delete_submission, requeue_submission, SubmissionNotFound
other = create_account(conn, "Alt cont", active=True)
sid = _ins(conn, account_id=other, status="error")
with pytest.raises(SubmissionNotFound):
delete_submission(conn, 1, sid)
with pytest.raises(SubmissionNotFound):
requeue_submission(conn, 1, sid)
# randul altui cont ramane intact
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is not None
def test_repune_error_devine_queued_reset_retry(conn):
from app.submissions_admin import requeue_submission
sid = _ins(conn, status="error", valid=True)
conn.execute(
"UPDATE submissions SET retry_count=5, next_attempt_at=datetime('now','+1 day'), "
"purge_after=datetime('now','+30 days') WHERE id=?",
(sid,),
)
res = requeue_submission(conn, 1, sid)
assert res["status_nou"] == "queued"
row = conn.execute(
"SELECT status, retry_count, next_attempt_at, purge_after FROM submissions WHERE id=?",
(sid,),
).fetchone()
assert row["status"] == "queued"
assert row["retry_count"] == 0
assert row["next_attempt_at"] is None
assert row["purge_after"] is None
def test_repune_re_ruleaza_classify(conn):
"""Continut invalid la re-pune -> ramane needs_data (classify re-ruleaza)."""
from app.submissions_admin import requeue_submission
sid = _ins(conn, status="error", valid=False)
res = requeue_submission(conn, 1, sid)
assert res["status_nou"] == "needs_data"
def test_actiunile_emit_eveniment(conn):
from app.submissions_admin import delete_submission, requeue_submission
sid1 = _ins(conn, status="error", valid=True)
requeue_submission(conn, 1, sid1)
sid2 = _ins(conn, status="error")
delete_submission(conn, 1, sid2)
tipuri = {r["tip"] for r in conn.execute("SELECT tip FROM app_events").fetchall()}
assert "submission_repus" in tipuri
assert "submission_sters" in tipuri

View File

@@ -65,14 +65,18 @@ def test_mark_sent_seteaza_purge_after(conn):
assert is_future, "purge_after trebuie sa fie in viitor"
def test_mark_other_status_nu_seteaza_purge_after(conn):
"""Alte statusuri (error, needs_data) nu seteaza purge_after."""
def test_mark_queued_nu_seteaza_purge_after(conn):
"""Starile active (queued) nu primesc purge_after.
NOTA US-013: error/needs_data/needs_mapping primesc ACUM purge_after (retentie
blocate, 30z) — vezi tests/test_purge_blocate.py. Doar queued/sending raman fara.
"""
import app.worker.__main__ as w
sid = _insert_submission(conn)
w.mark(conn, sid, "error", rar_error="test")
w.mark(conn, sid, "queued")
row = conn.execute("SELECT purge_after FROM submissions WHERE id=?", (sid,)).fetchone()
assert row["purge_after"] is None, "purge_after nu trebuie setat la 'error'"
assert row["purge_after"] is None, "purge_after nu trebuie setat la 'queued'"
# --- (b) purge_expired sterge randurile expirate ---

112
tests/test_web_jurnal.py Normal file
View File

@@ -0,0 +1,112 @@
"""Teste US-006 (PRD 5.6): tab Jurnal in dashboard (scoped + filtre)."""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "jrnl.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _account_user(email, name="Service", admin=False):
from app.accounts import create_account
from app.users import create_user, set_admin
from app.db import get_connection
conn = get_connection()
try:
aid = create_account(conn, name, active=True)
create_user(conn, aid, email, "parolasecreta10")
if admin:
set_admin(conn, aid, True)
conn.commit()
return aid
finally:
conn.close()
def _login(client, email):
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
resp = client.post("/login", data={"email": email, "parola": "parolasecreta10", "csrf_token": m.group(1)})
assert resp.status_code == 303
def _event(account_id, tip, nivel="INFO", mesaj="x"):
from app import observ
observ.log_event(tip, nivel=nivel, account_id=account_id, mesaj=mesaj)
def test_non_admin_vede_doar_evenimentele_contului_sau(client):
aid = _account_user("u@test.com")
other = _account_user("o@test.com", name="Alt")
_event(aid, "api_prezentari", mesaj="al meu MARKER_A")
_event(other, "api_prezentari", mesaj="al altuia MARKER_B")
_login(client, "u@test.com")
html = client.get("/_fragments/jurnal").text
assert "MARKER_A" in html
assert "MARKER_B" not in html
def test_admin_vede_toate_si_filtru_cont(client):
admin = _account_user("admin@test.com", name="Admin", admin=True)
other = _account_user("client@test.com", name="Client")
_event(admin, "rar_login", mesaj="eveniment ADMINEV")
_event(other, "api_prezentari", mesaj="eveniment CLIENTEV")
_login(client, "admin@test.com")
# admin vede tot
html = client.get("/_fragments/jurnal").text
assert "ADMINEV" in html
assert "CLIENTEV" in html
# filtru pe cont
html2 = client.get(f"/_fragments/jurnal?cont={other}").text
assert "CLIENTEV" in html2
assert "ADMINEV" not in html2
def test_filtru_pe_tip_si_nivel(client):
aid = _account_user("f@test.com")
_event(aid, "api_prezentari", nivel="INFO", mesaj="EV_INFO")
_event(aid, "submission_error", nivel="ERROR", mesaj="EV_ERR")
_login(client, "f@test.com")
html = client.get("/_fragments/jurnal?tip=submission_error").text
assert "EV_ERR" in html
assert "EV_INFO" not in html
html2 = client.get("/_fragments/jurnal?nivel=INFO").text
assert "EV_INFO" in html2
assert "EV_ERR" not in html2
def test_jurnal_necesita_login(client):
r = client.get("/_fragments/jurnal")
assert r.status_code in (303, 401)
def test_deep_link_tab_jurnal(client):
_account_user("d@test.com")
_login(client, "d@test.com")
r = client.get("/?tab=jurnal")
assert r.status_code == 200
assert "Jurnal de aplicatie" in r.text

173
tests/test_web_lifecycle.py Normal file
View File

@@ -0,0 +1,173 @@
"""Teste US-011 (PRD 5.6): butoane web sterge / re-pune in coada + bulk, scoped + CSRF."""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "wl.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _account_user(email, name="Service", password="parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
aid = create_account(conn, name, active=True)
create_user(conn, aid, email, password)
return aid
finally:
conn.close()
def _login(client, email, password="parolasecreta10"):
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, resp.text[:200]
# set_session goleste sesiunea la login -> token CSRF nou, obtinut DUPA login.
return _csrf(client)
def _csrf(client):
resp = client.get("/?tab=acasa")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token negasit dupa login"
return m.group(1)
def _ins(account_id, status="error"):
from app.db import get_connection
conn = get_connection()
try:
content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}]}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(5).hex()}", account_id, status, json.dumps(content)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def test_buton_sterge_doar_pe_blocate(client):
aid = _account_user("b@test.com")
_login(client, "b@test.com")
sid_err = _ins(aid, "error")
sid_sent = _ins(aid, "sent")
html_err = client.get(f"/_fragments/trimitere/{sid_err}").text
assert "Re-pune in coada" in html_err
assert "/trimitere/%d/sterge" % sid_err in html_err or f"/trimitere/{sid_err}/sterge" in html_err
html_sent = client.get(f"/_fragments/trimitere/{sid_sent}").text
assert "Re-pune in coada" not in html_sent
assert f"/trimitere/{sid_sent}/sterge" not in html_sent
def test_repune_din_ui_scoped_sesiune(client):
aid = _account_user("r@test.com")
csrf = _login(client, "r@test.com")
sid = _ins(aid, "error")
r = client.post(f"/trimitere/{sid}/repune", data={"csrf_token": csrf})
assert r.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
st = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"]
finally:
conn.close()
assert st == "queued"
def test_sterge_din_ui(client):
aid = _account_user("s@test.com")
csrf = _login(client, "s@test.com")
sid = _ins(aid, "error")
r = client.post(f"/trimitere/{sid}/sterge", data={"csrf_token": csrf})
assert r.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is None
finally:
conn.close()
def test_sterge_sent_409(client):
aid = _account_user("se@test.com")
csrf = _login(client, "se@test.com")
sid = _ins(aid, "sent")
r = client.post(f"/trimitere/{sid}/sterge", data={"csrf_token": csrf})
assert r.status_code == 409
def test_csrf_enforce(client):
aid = _account_user("c@test.com")
_login(client, "c@test.com")
sid = _ins(aid, "error")
r = client.post(f"/trimitere/{sid}/sterge", data={"csrf_token": "gresit"})
assert r.status_code == 403
# randul ramane
from app.db import get_connection
conn = get_connection()
try:
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid,)).fetchone() is not None
finally:
conn.close()
def test_bulk_sterge_doar_blocate_scoped(client):
aid = _account_user("bk@test.com")
csrf = _login(client, "bk@test.com")
sid_err = _ins(aid, "error")
sid_nd = _ins(aid, "needs_data")
sid_sent = _ins(aid, "sent")
r = client.post(
"/trimiteri/sterge-bulk",
data={"submission_id": [str(sid_err), str(sid_nd), str(sid_sent)], "csrf_token": csrf},
)
assert r.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid_err,)).fetchone() is None
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid_nd,)).fetchone() is None
# sent NU se sterge
assert conn.execute("SELECT 1 FROM submissions WHERE id=?", (sid_sent,)).fetchone() is not None
finally:
conn.close()
def test_repune_cross_account_404(client):
aid = _account_user("x1@test.com", name="X1")
other = _account_user("x2@test.com", name="X2")
csrf = _login(client, "x1@test.com")
sid_other = _ins(other, "error")
r = client.post(f"/trimitere/{sid_other}/repune", data={"csrf_token": csrf})
assert r.status_code == 404

View File

@@ -185,3 +185,64 @@ def test_status_se_reincarca_htmx(client):
assert 'id="status-bar"' in html, (
"Fragmentul nu are id='status-bar' pe containerul radacina"
)
# ============================================================
# US-014: banner "Necesita atentia ta" actionabil
# ============================================================
def _insert_submission_vehicul(status, account_id, vin, nr):
from app.db import get_connection
import json
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(5).hex()}", account_id, status,
json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": "2026-06-15",
"odometru_final": "1", "prestatii": [{"cod_prestatie": "OE-1"}]})),
)
conn.commit()
finally:
conn.close()
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client):
acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
_login(client, "link@test.com", "parolasecreta10")
_insert_submission("error", acct_id)
html = client.get("/_fragments/status").text
# Link HTMX catre lista filtrata pe error + deep-link server-side
assert "/_fragments/submissions?status=error" in html
assert "tab=acasa&status=error" in html
def test_status_arata_identificator_rand_blocat(client):
acct_id, _ = _create_account_user("ident@test.com", "parolasecreta10")
_login(client, "ident@test.com", "parolasecreta10")
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
html = client.get("/_fragments/status").text
# VIN partial (ultimele 4) + nr inmatriculare + #id
assert "0123" in html, "lipseste VIN partial"
assert "B123ABC" in html, "lipseste nr inmatriculare"
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus"
def test_scoped_pe_cont(client):
from app.accounts import create_account
from app.db import get_connection
acct_id, _ = _create_account_user("own@test.com", "parolasecreta10")
conn = get_connection()
try:
other = create_account(conn, "Alt", active=True)
finally:
conn.close()
_login(client, "own@test.com", "parolasecreta10")
_insert_submission_vehicul("error", other, "OTHERVIN000009999", "X999ZZZ")
html = client.get("/_fragments/status").text
# randul altui cont NU apare in banner-ul meu
assert "X999ZZZ" not in html
assert "9999" not in html

122
tests/test_worker_observ.py Normal file
View File

@@ -0,0 +1,122 @@
"""Teste US-005 (PRD 5.6): audit login RAR + ciclu de viata trimiteri (worker)."""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from app.rar_client import RarAuthError
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "wo.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
settings = get_settings()
yield conn, settings
conn.close()
get_settings.cache_clear()
_CONTENT = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null"}
def _events(conn, tip=None):
if tip:
return conn.execute("SELECT * FROM app_events WHERE tip=?", (tip,)).fetchall()
return conn.execute("SELECT * FROM app_events").fetchall()
class FakeRar:
def __init__(self, *, token="JWT-TEST", login_exc=None, post_result=None):
self.token = token
self.login_exc = login_exc
self.post_result = post_result if post_result is not None else {"id": 1000}
self.closed = False
def login(self, email, password):
if self.login_exc:
raise self.login_exc
return self.token
def get_nomenclator(self, token):
return []
def post_prezentare(self, token, payload):
return self.post_result
def close(self):
self.closed = True
def test_login_reusit_logat(env, monkeypatch):
conn, settings = env
import app.worker.__main__ as w
monkeypatch.setattr(w, "RarClient", lambda s: FakeRar())
sessions = w.AccountSessions(settings)
tok = sessions.get_token(conn, 2, {"email": "a@b.ro", "password": "secretaXY"})
assert tok == "JWT-TEST"
rows = _events(conn, "rar_login")
assert len(rows) == 1
assert rows[0]["account_id"] == 2
ctx = rows[0]["context_json"]
assert "ok" in ctx
# fara parola in clar nicaieri
assert "secretaXY" not in (rows[0]["mesaj"] or "")
assert "secretaXY" not in (ctx or "")
def test_login_401_logat_fara_parola(env, monkeypatch):
conn, settings = env
import app.worker.__main__ as w
monkeypatch.setattr(w, "RarClient", lambda s: FakeRar(login_exc=RarAuthError("401", status_code=401)))
sessions = w.AccountSessions(settings)
with pytest.raises(RarAuthError):
sessions.get_token(conn, 3, {"email": "a@b.ro", "password": "parolaGRESITA"})
rows = _events(conn, "rar_login")
assert len(rows) == 1
assert rows[0]["nivel"] == "WARNING"
assert "esuat" in rows[0]["context_json"]
assert "parolaGRESITA" not in (rows[0]["context_json"] or "")
assert "parolaGRESITA" not in (rows[0]["mesaj"] or "")
def test_tranzitie_sent_si_error_logate(env, monkeypatch):
conn, settings = env
import app.worker.__main__ as w
from app.accounts import create_account
acct = create_account(conn, "Service Worker Obs", active=True)
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, 'sending', ?)",
(f"k-{os.urandom(4).hex()}", acct, json.dumps(_CONTENT)),
)
sid = int(cur.lastrowid)
claimed = {"id": sid, "account_id": acct, "content": _CONTENT}
rar = FakeRar()
res = w.process_one(conn, settings, rar, "tok", claimed)
assert res == "sent"
assert len(_events(conn, "submission_sent")) == 1
assert _events(conn, "submission_sent")[0]["account_id"] == 2
# eroare 4xx nerecuperabila -> submission_error
from app.rar_client import RarError
cur2 = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, 2, 'sending', ?)",
(f"k-{os.urandom(4).hex()}", json.dumps(_CONTENT)),
)
sid2 = int(cur2.lastrowid)
rar_err = FakeRar()
rar_err.post_prezentare = lambda t, p: (_ for _ in ()).throw(RarError("403", status_code=403))
w.process_one(conn, settings, rar_err, "tok", {"id": sid2, "account_id": 2, "content": _CONTENT})
assert len(_events(conn, "submission_error")) == 1

View File

@@ -203,6 +203,45 @@ def test_recover_orphan_neinregistrat_requeue(env):
assert _status(conn, sid)["status"] == "queued"
def test_recover_orphan_ignora_randul_proaspat_claim(env):
"""Regresie format data: un rand PROASPAT revendicat NU trebuie tratat ca orfan.
claim_one scrie sending_since cu datetime('now') -> 'YYYY-MM-DD HH:MM:SS' (spatiu).
Bug-ul: cutoff calculat cu _iso() era in format ISO ('T'), iar la comparatie de string
spatiul (0x20) < 'T' (0x54) facea ca ORICE rand 'sending' sa para <= cutoff -> lease-ul
de 120s era ignorat si fiecare rand revendicat era reconciliat/requeue-uit instant.
"""
from app.worker.__main__ import claim_one, recover_orphans
conn, settings = env
_insert(conn) # queued
claimed = claim_one(conn) # -> sending, sending_since=datetime('now')
assert claimed is not None
# Chiar daca recordul ar exista la RAR, randul proaspat nu trebuie atins.
rar = FakeRar(finalizate=[{"id": 999, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}])
n = recover_orphans(conn, settings, rar, "tok")
assert n == 0 # nu l-a recuperat
assert rar.finalizate_calls == 0 # nici macar nu a interogat RAR
assert _status(conn, claimed["id"])["status"] == "sending" # neatins
def test_recover_orphan_lease_depasit_format_sqlite(env):
"""Complementar: un rand 'sending' mai vechi decat lease-ul (format SQLite, spatiu) ESTE orfan."""
from app.worker.__main__ import recover_orphans
conn, settings = env
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, sending_since) "
"VALUES (?, 'sending', ?, datetime('now', '-1 hour'))",
(f"key-{os.urandom(4).hex()}", json.dumps(_CONTENT)),
)
sid = int(cur.lastrowid)
rar = FakeRar(finalizate=[{"id": 888, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}])
n = recover_orphans(conn, settings, rar, "tok")
assert n == 1
assert _status(conn, sid)["status"] == "sent"
def test_claim_respecta_next_attempt_at(env):
from app.worker.__main__ import claim_one
conn, _ = env