Compare commits

..

5 Commits

Author SHA1 Message Date
Claude Agent
f149b24f96 chore: ignora .claude/ (config + memorie agenti, stare locala)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:27:59 +00:00
Claude Agent
958b182e8e feat(config): web_auth_required ON implicit (login obligatoriu pe rutele web)
Inverseaza default-ul C12: rutele web cer sesiune + CSRF implicit (sigur pentru
prod). Dev rapid pe contul 1 = opt-out explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
Testele de comportament import/dashboard marcate explicit dev-mode; test nou
blocheaza default-ul. 394 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:27:09 +00:00
Claude Agent
b92055eb01 feat(web): self-service cheie/creds + admin web + email signup (PRD 3.3b)
US-007: rute web proprii /cont/roteste-cheie + /cont/rar-creds scoped pe
sesiune (C13), sectiune "Contul meu" cu cheie afisata o data.
US-010: rol admin (users.is_admin) + require_admin->403 + CLI set-admin +
bootstrap primul cont=admin (count_admins in BEGIN IMMEDIATE, anti-race).
US-011: panou /admin (activare/dezactivare conturi, CSRF + PRG), link admin
+ logout pe dashboard.
US-012: app/email.py notify_signup best-effort degradat fara SMTP + config smtp_*.
Fix: migrare defensiva users.is_admin/email_verified in _migrate.

VERIFY x2 context curat (PASS) + /code-review high. 393 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:19:06 +00:00
Claude Agent
504b490d3b feat(web): self-onboarding multi-tenant + auth sesiune (PRD 3.3a)
Canalul web trece de la 100% deschis (hardcodat cont 1) la autentificat si
multi-tenant. Un service nou se inregistreaza din browser, primeste o cheie API
(o singura data) si o sesiune; contul se creeaza "in asteptare" (active=0) si nu
trimite la RAR pana la activarea de catre admin (tools/account.py activate).

- users + app/users.py: parole scrypt (salt per-user, eticheta parametri onorata
  la verify pentru migrare cost), email unic case-insensitive
- sesiune: SessionMiddleware (same_site=strict, https_only config) + app/web/session.py
  (current_account/web_account/require_login->LoginRequired, set_session clear-inainte)
- CSRF (app/web/csrf.py) enforce in prod inclusiv pe login/signup + rate-limit
  in-proces (app/web/ratelimit.py) pe signup si login
- signup/login/logout (app/web/auth_routes.py): signup tranzactie atomica,
  cheie-o-data, log SIGNUP pentru descoperire admin
- dashboard + import scoped pe contul sesiunii (regula NULL->cont 1); toate rutele
  web care ating date sensibile sub require_login; nomenclator ramane global
- banner "cont in asteptare" pentru conturi active=0
- gate worker: claim_one LEFT JOIN accounts COALESCE(active,1)=1 (account_id NULL=activ)

VERIFY context curat (2 runde): leak cross-account /_fragments/mapari prins+reparat.
/code-review high: csrf_token lipsa pe re-randari de eroare, scrypt_params ignorat,
login fara rate-limit -- toate reparate. 361 teste pass (de la 313).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:43:21 +00:00
Claude Agent
748ab8b289 feat(api): scope pe cont la GET-urile de listare /v1/* (PRD 3.2)
Inchide scurgerea cross-account pe GET /v1/prezentari(/{id}),
/v1/mapari(/pending) si /v1/audit/export. Toate primesc
Depends(resolve_account_id) + account_scope_clause (regula NULL->cont 1,
OV-2). Nomenclatorul ramane global (referinta partajata, fara PII).

- B3: 404 cross-account byte-identic cu 404 inexistent (fara oracol enumerare)
- B4: get_prezentare cu allowlist de campuri (nu mai expune rar_creds_enc/
  payload_json/idempotency_key/rar_error)
- B1: pending_unmapped filtreaza in SQL; default None = global doar pentru web
- B2: helper account_scope_clause (DRY, doar pe submissions nullable)
- B5: index idx_submissions_account_status
- B8: regula de scope documentata in api-rar-contract.md
- TD-3.2: ?account_id != contul cheii -> 400

14 teste noi (cross-account, legacy NULL, B3, B4); suita 313 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:35:50 +00:00
52 changed files with 4699 additions and 166 deletions

3
.gitignore vendored
View File

@@ -75,3 +75,6 @@ venv/
*.db-wal *.db-wal
*.db-shm *.db-shm
.gstack/ .gstack/
# --- Claude Code: config + memorie agenti (stare locala, nu artefact de proiect) ---
.claude/

View File

@@ -25,6 +25,7 @@ from ...db import get_connection
from ...idempotency import build_key, canonicalize_row, idempotency_key from ...idempotency import build_key, canonicalize_row, idempotency_key
from ...mapping import ( from ...mapping import (
account_or_default, account_or_default,
account_scope_clause,
has_no_auto_send, has_no_auto_send,
load_mapping_meta, load_mapping_meta,
pending_unmapped, pending_unmapped,
@@ -130,36 +131,58 @@ def create_prezentari(
@router.get("/prezentari") @router.get("/prezentari")
def list_prezentari(status: str | None = None, limit: int = 100) -> dict: def list_prezentari(
status: str | None = None,
limit: int = 100,
account_id: int = Depends(resolve_account_id),
) -> dict:
conn = get_connection() conn = get_connection()
try: try:
scope_sql, scope_params = account_scope_clause(account_id)
if status: if status:
rows = conn.execute( rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?", f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?",
(status, limit), scope_params + [status, limit],
).fetchall() ).fetchall()
else: else:
rows = conn.execute( rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at " f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
"FROM submissions ORDER BY id DESC LIMIT ?", f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
(limit,), scope_params + [limit],
).fetchall() ).fetchall()
return {"submissions": [dict(r) for r in rows]} return {"submissions": [dict(r) for r in rows]}
finally: finally:
conn.close() conn.close()
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4).
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since.
_PREZENTARE_FIELDS = frozenset({
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
"next_attempt_at", "created_at", "updated_at", "account_id",
"batch_id", "row_index", "purge_after",
})
@router.get("/prezentari/{submission_id}") @router.get("/prezentari/{submission_id}")
def get_prezentare(submission_id: int) -> dict: def get_prezentare(
submission_id: int,
account_id: int = Depends(resolve_account_id),
) -> dict:
conn = get_connection() conn = get_connection()
try: try:
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone() scope_sql, scope_params = account_scope_clause(account_id)
row = conn.execute(
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
[submission_id] + scope_params,
).fetchone()
if not row: if not row:
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont
# sau nu exista deloc — nu confirmam existenta.
raise HTTPException(status_code=404, detail="submission inexistent") raise HTTPException(status_code=404, detail="submission inexistent")
out = dict(row) row_dict = dict(row)
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS}
return out
finally: finally:
conn.close() conn.close()
@@ -193,16 +216,20 @@ AUDIT_COLUMNS = [
] ]
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str): def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int):
"""Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to]. """Randuri audit filtrate pe cont + data(updated_at) in [from, to].
payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul, account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
aici se decripteaza inainte de a construi randul. schelet; b64_image NU intra in CSV.
""" """
sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions" scope_sql, scope_params = account_scope_clause(account_id)
where = [] sql = (
params: list = [] "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, "
"created_at, updated_at, purge_after FROM submissions"
)
where = [scope_sql]
params: list = list(scope_params)
if status != "all": if status != "all":
where.append("status=?") where.append("status=?")
params.append(status) params.append(status)
@@ -212,8 +239,7 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
if date_to: if date_to:
where.append("date(updated_at) <= date(?)") where.append("date(updated_at) <= date(?)")
params.append(date_to) params.append(date_to)
if where: sql += " WHERE " + " AND ".join(where)
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY id" sql += " ORDER BY id"
for r in conn.execute(sql, params).fetchall(): for r in conn.execute(sql, params).fetchall():
@@ -230,7 +256,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
"submission_id": r["id"], "submission_id": r["id"],
"status": r["status"], "status": r["status"],
"id_prezentare": r["id_prezentare"] or "", "id_prezentare": r["id_prezentare"] or "",
"account_id": r["account_id"] or "", # NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu.
"account_id": account_or_default(r["account_id"]),
"vin": p.get("vin") or "", "vin": p.get("vin") or "",
"nr_inmatriculare": p.get("nr_inmatriculare") or "", "nr_inmatriculare": p.get("nr_inmatriculare") or "",
"data_prestatie": p.get("data_prestatie") or "", "data_prestatie": p.get("data_prestatie") or "",
@@ -248,11 +275,12 @@ def audit_export(
date_from: str | None = None, date_from: str | None = None,
date_to: str | None = None, date_to: str | None = None,
status: str = "sent", status: str = "sent",
account_id: int = Depends(resolve_account_id),
) -> StreamingResponse: ) -> StreamingResponse:
"""CSV cu ce s-a trimis (audit). Filtre optionale `date_from`/`date_to` (YYYY-MM-DD) """CSV audit scoped pe contul cheii API. Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR); pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
`status=all` exporta toata coada. Leaga re_tinerea 90 zile prin coloana `status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta. `purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
""" """
conn = get_connection() conn = get_connection()
@@ -260,7 +288,7 @@ def audit_export(
buf = io.StringIO() buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS) writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
writer.writeheader() writer.writeheader()
for row in _audit_rows(conn, date_from, date_to, status): for row in _audit_rows(conn, date_from, date_to, status, account_id):
writer.writerow(row) writer.writerow(row)
data = buf.getvalue() data = buf.getvalue()
finally: finally:
@@ -275,31 +303,43 @@ def audit_export(
@router.get("/mapari") @router.get("/mapari")
def get_mapari(account_id: int | None = None) -> dict: def get_mapari(
key_account: int = Depends(resolve_account_id),
account_id: int | None = None,
) -> dict:
"""Maparile operatie->cod ale contului curent.
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400.
"""
if account_id is not None and account_id != key_account:
raise HTTPException(
status_code=400,
detail="account_id din query nu corespunde contului cheii API",
)
conn = get_connection() conn = get_connection()
try: try:
if account_id is not None: rows = conn.execute(
rows = conn.execute( "SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service", (key_account,),
(account_id,), ).fetchall()
).fetchall()
else:
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
return {"mapari": [dict(r) for r in rows]} return {"mapari": [dict(r) for r in rows]}
finally: finally:
conn.close() conn.close()
@router.get("/mapari/pending") @router.get("/mapari/pending")
def get_mapari_pending() -> dict: def get_mapari_pending(
account_id: int = Depends(resolve_account_id),
) -> dict:
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy. """Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire, Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service,
blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
""" """
conn = get_connection() conn = get_connection()
try: try:
return {"pending": pending_unmapped(conn)} return {"pending": pending_unmapped(conn, account_id=account_id)}
finally: finally:
conn.close() conn.close()

View File

@@ -34,6 +34,35 @@ class Settings(BaseSettings):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
creds_key: str | None = None creds_key: str | None = None
# --- Sesiuni web (US-002, PRD 3.3) ---
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
session_secret: str | None = None
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
# CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5),
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
web_auth_required: bool = True
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4).
# False (dev): cookie fara Secure, functioneaza pe HTTP.
session_https_only: bool = False
# --- Notificare email admin la signup (US-012, PRD 3.3b) ---
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP);
# follow-up cand exista SMTP real configurat in .env.
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) ---
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
signup_rate_max: int = 5
signup_rate_window_s: int = 3600
# Max incercari POST /login per IP (brute-force parole). Fereastra impartita cu signup.
login_rate_max: int = 10
# --- RAR --- # --- RAR ---
rar_env: str = "test" # "test" | "prod" rar_env: str = "test" # "test" | "prod"
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"

View File

@@ -68,6 +68,17 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
) )
# Coloane users (DB cu users creata inaintea acestor coloane)
user_tbl = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
).fetchone()
if user_tbl:
user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "is_admin" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
if "email_verified" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
# Index batch_id pe submissions (poate lipsi pe DB veche) # Index batch_id pe submissions (poate lipsi pe DB veche)
existing_idx = {r["name"] for r in conn.execute( existing_idx = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'" "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
@@ -77,6 +88,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) " "CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) "
"WHERE batch_id IS NOT NULL" "WHERE batch_id IS NOT NULL"
) )
if "idx_submissions_account_status" not in existing_idx:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
"ON submissions(account_id, status)"
)
def _now_iso() -> str: def _now_iso() -> str:

59
app/email.py Normal file
View File

@@ -0,0 +1,59 @@
"""Helper notificare email admin la signup (US-012, PRD 3.3b).
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.
"""
from __future__ import annotations
import smtplib
import textwrap
from email.message import EmailMessage
from .config import get_settings
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
"""Notifica adminii despre un cont nou in asteptare (best-effort).
Daca smtp_host e None SAU admin_emails e gol -> log si return (degradat).
Daca SMTP ridica exceptie -> log eroare si return (NU se propaga).
Timeout mic (5s) pe conexiunea SMTP.
"""
settings = get_settings()
if not settings.smtp_host or not admin_emails:
print(
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
f"email={email} admins={len(admin_emails)}",
flush=True,
)
return
try:
msg = EmailMessage()
expeditor = settings.smtp_from or settings.smtp_user or "autopass@localhost"
msg["From"] = expeditor
msg["To"] = ", ".join(admin_emails)
msg["Subject"] = f"AutoPass: cont nou {account_id} in asteptare"
msg.set_content(textwrap.dedent(f"""\
Cont nou inregistrat si in asteptare de activare.
ID cont: {account_id}
Email: {email}
Actioneaza din panoul admin /admin sau din CLI:
python3 -m tools.account activate --account {account_id}
"""))
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=5) as smtp:
if settings.smtp_user and settings.smtp_password:
smtp.starttls()
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(msg)
except Exception as exc:
print(
f"SIGNUP-NOTIFY esuat cont={account_id}: {type(exc).__name__}",
flush=True,
)

View File

@@ -8,6 +8,7 @@ Pornire dev: uvicorn app.main:app --reload
from __future__ import annotations from __future__ import annotations
import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -16,6 +17,8 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse
from . import __version__ from . import __version__
from .api.v1.import_router import router as import_v1_router from .api.v1.import_router import router as import_v1_router
@@ -24,6 +27,10 @@ from .config import get_settings
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 .security import install_log_redaction
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.admin_routes import router as admin_router
from .web.csrf import CsrfError
from .web.session import AdminRequired, LoginRequired
@asynccontextmanager @asynccontextmanager
@@ -35,6 +42,31 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan) app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
settings = get_settings()
_session_secret = settings.session_secret or secrets.token_hex(32)
app.add_middleware(
SessionMiddleware,
secret_key=_session_secret,
session_cookie="autopass_session",
https_only=settings.session_https_only,
same_site="strict",
)
@app.exception_handler(LoginRequired)
async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse:
return RedirectResponse("/login", status_code=303)
@app.exception_handler(AdminRequired)
async def admin_required_handler(request: Request, exc: AdminRequired) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
@app.exception_handler(CsrfError)
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
@@ -59,6 +91,8 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
app.include_router(api_v1_router) app.include_router(api_v1_router)
app.include_router(import_v1_router) app.include_router(import_v1_router)
app.include_router(web_router) app.include_router(web_router)
app.include_router(auth_router)
app.include_router(admin_router)
@app.get("/healthz") @app.get("/healthz")

View File

@@ -129,6 +129,19 @@ def account_or_default(account_id: int | None) -> int:
return account_id if account_id is not None else DEFAULT_ACCOUNT_ID return account_id if account_id is not None else DEFAULT_ACCOUNT_ID
def account_scope_clause(account_id: int) -> tuple[str, list]:
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
Aplica regula: NULL apartine contului 1 (legacy/OV-2).
Foloseste DOAR pe submissions (account_id NULLABLE).
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
"""
return (
"(account_id = ? OR (account_id IS NULL AND ? = 1))",
[account_id, account_id],
)
def seed_nomenclator_if_empty(conn) -> int: def seed_nomenclator_if_empty(conn) -> int:
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol. """Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
@@ -217,18 +230,28 @@ def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> boo
return False return False
def pending_unmapped(conn) -> list[dict]: def pending_unmapped(conn, account_id=None) -> list[dict]:
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`. """Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa, account_id=None (default): global — intentionat pentru web/routes.py (back-compat).
nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de Apelantii noi din API TREBUIE sa paseze account_id explicit; None global e
adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
null + cod_op_service setat.
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python.
""" """
nomenclator = load_nomenclator(conn) nomenclator = load_nomenclator(conn)
rows = conn.execute( if account_id is not None:
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'" scope_sql, scope_params = account_scope_clause(account_id)
).fetchall() rows = conn.execute(
f"SELECT id, account_id, payload_json FROM submissions "
f"WHERE status='needs_mapping' AND {scope_sql}",
scope_params,
).fetchall()
else:
rows = conn.execute(
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
).fetchall()
agg: dict[tuple[int, str], dict[str, Any]] = {} agg: dict[tuple[int, str], dict[str, Any]] = {}
for r in rows: for r in rows:

View File

@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS submissions (
); );
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status); CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status);
-- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche). -- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche).
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane). -- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
@@ -132,6 +133,19 @@ CREATE TABLE IF NOT EXISTS import_attestations (
n_confirmed INTEGER NOT NULL n_confirmed INTEGER NOT NULL
); );
-- Utilizatori web (email+parola, legati de un cont). Parola stocata doar ca scrypt hash.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, -- hex scrypt(salt, parola)
salt TEXT NOT NULL, -- hex secrets.token_bytes(16), per-user
scrypt_params TEXT NOT NULL, -- eticheta versiune parametri: 'n16384_r8_p1'
email_verified INTEGER NOT NULL DEFAULT 0, -- C19: pregatire viitor
is_admin INTEGER NOT NULL DEFAULT 0, -- pregatire 3.3b
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 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),

167
app/users.py Normal file
View File

@@ -0,0 +1,167 @@
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3.
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
migrare cost viitoare (C9).
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import sqlite3
SCRYPT_PARAMS = "n16384_r8_p1"
_N = 2**14
_R = 8
_P = 1
_DKLEN = 32
_MAXMEM = 64 * 1024 * 1024
_PASSWORD_MIN = 10
_PASSWORD_MAX = 128
def _parse_scrypt_params(label: str) -> tuple[int, int, int] | None:
"""Parseaza 'nN_rR_pP' -> (N, R, P). Returneaza None la format necunoscut/corupt."""
try:
parts = label.split("_")
if len(parts) != 3 or parts[0][0] != "n" or parts[1][0] != "r" or parts[2][0] != "p":
return None
return (int(parts[0][1:]), int(parts[1][1:]), int(parts[2][1:]))
except (ValueError, IndexError):
return None
def _scrypt_hash(password: str, salt: bytes, n: int = _N, r: int = _R, p: int = _P) -> bytes:
return hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
maxmem=_MAXMEM,
dklen=_DKLEN,
)
def create_user(
conn: sqlite3.Connection,
account_id: int,
email: str,
password: str,
is_admin: bool = False,
) -> int:
"""Creeaza un user nou si intoarce id-ul.
Valideaza ca: contul exista, parola intre 10 si 128 caractere, emailul nu e duplicat.
Stocheaza DOAR hash scrypt + salt (hex), niciodata parola in clar.
Email duplicat (case-insensitive, via UNIQUE COLLATE NOCASE) -> ValueError.
is_admin: daca True, userul e marcat ca admin (is_admin=1). Apelantul decide
logica de bootstrap (count_admins==0 -> primul cont devine admin).
"""
email = email.strip()
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct:
raise ValueError(f"cont inexistent: {account_id}")
if len(password) < _PASSWORD_MIN:
raise ValueError(f"parola prea scurta (minim {_PASSWORD_MIN} caractere)")
if len(password) > _PASSWORD_MAX:
raise ValueError(f"parola prea lunga (maxim {_PASSWORD_MAX} caractere, anti-DoS)")
salt = secrets.token_bytes(16)
pw_hash = _scrypt_hash(password, salt)
try:
cur = conn.execute(
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params, is_admin) "
"VALUES (?, ?, ?, ?, ?, ?)",
(account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS, 1 if is_admin else 0),
)
except sqlite3.IntegrityError:
raise ValueError("email deja folosit")
return int(cur.lastrowid or 0)
def count_admins(conn: sqlite3.Connection) -> int:
"""Numara userii cu is_admin=1 din intreaga baza."""
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()
return int(row["n"]) if row else 0
def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True) -> None:
"""Seteaza/sterge rolul admin pe toti userii contului dat.
Ridica ValueError daca contul nu exista.
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010).
"""
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not acct:
raise ValueError(f"cont inexistent: {account_id}")
conn.execute(
"UPDATE users SET is_admin=? WHERE account_id=?",
(1 if is_admin else 0, account_id),
)
def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
"""Returneaza True daca cel putin un user al contului are is_admin=1."""
row = conn.execute(
"SELECT 1 FROM users WHERE account_id=? AND is_admin=1 LIMIT 1",
(account_id,),
).fetchone()
return row is not None
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012)."""
rows = conn.execute(
"SELECT email FROM users WHERE is_admin=1"
).fetchall()
return [row["email"] for row in rows]
def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int | None:
"""Verifica parola pentru email. Intoarce account_id la potrivire, None altfel.
Nu distinge intre email inexistent si parola gresita (evita enumerare useri).
Comparatie constant-time cu hmac.compare_digest.
"""
row = conn.execute(
"SELECT account_id, password_hash, salt, scrypt_params FROM users "
"WHERE email=? COLLATE NOCASE",
(email.strip(),),
).fetchone()
if row is None:
# Executa un hash dummy pentru a evita timing oracle pe email inexistent
_scrypt_hash(password, b"\x00" * 16)
return None
salt = bytes.fromhex(row["salt"])
expected = bytes.fromhex(row["password_hash"])
params = _parse_scrypt_params(row["scrypt_params"] or "")
if params is None:
return None
n, r, p = params
actual = _scrypt_hash(password, salt, n=n, r=r, p=p)
if hmac.compare_digest(actual, expected):
return int(row["account_id"])
return None
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
"""Metadate user dupa email (FARA password_hash si salt)."""
row = conn.execute(
"SELECT id, account_id, email, is_admin, email_verified, created_at "
"FROM users WHERE email=? COLLATE NOCASE",
(email.strip(),),
).fetchone()
return dict(row) if row else None

129
app/web/admin_routes.py Normal file
View File

@@ -0,0 +1,129 @@
"""Panou admin web /admin. US-011 PRD 3.3b.
Rute:
GET /admin — listeaza conturi in asteptare + active (require_admin)
POST /admin/activate — activeaza un cont (require_admin + CSRF, PRG)
POST /admin/deactivate — dezactiveaza un cont, nu permite id=1 (require_admin + CSRF, PRG)
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..accounts import list_accounts, set_active
from ..config import get_settings
from ..db import get_connection
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_admin
router = APIRouter()
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
def _ctx(request: Request, **extra) -> dict:
settings = get_settings()
return {"rar_env": settings.rar_env, "version": __version__, **extra}
def _emails_by_account(conn) -> dict[int, str | None]:
"""Intoarce primul email per account_id, intr-un singur query (fara N+1)."""
rows = conn.execute(
"SELECT account_id, email FROM users ORDER BY id"
).fetchall()
result: dict[int, str | None] = {}
for row in rows:
acc_id = int(row["account_id"])
if acc_id not in result:
result[acc_id] = row["email"]
return result
def _render_admin(request: Request, conn, *, error: str | None = None, status_code: int = 200):
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
accounts = list_accounts(conn)
emails = _emails_by_account(conn)
for acct in accounts:
acct["email"] = emails.get(acct["id"])
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
active = [a for a in accounts if a["active"] and a["id"] != 1]
default = next((a for a in accounts if a["id"] == 1), None)
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
request,
csrf_token=get_csrf_token(request),
pending=pending,
active=active,
default_account=default,
error=error,
), status_code=status_code)
@router.get("/admin", response_class=HTMLResponse)
async def admin_get(request: Request):
"""Panou admin: conturi in asteptare + active."""
require_admin(request)
conn = get_connection()
try:
return _render_admin(request, conn)
finally:
conn.close()
@router.post("/admin/activate", response_class=HTMLResponse)
async def admin_activate(
request: Request,
account_id: int = Form(...),
csrf_token: str = Form(default=""),
):
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
require_admin(request)
verify_csrf(request, csrf_token)
conn = get_connection()
try:
try:
set_active(conn, account_id, True)
except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422)
finally:
conn.close()
return RedirectResponse("/admin", status_code=303)
@router.post("/admin/deactivate", response_class=HTMLResponse)
async def admin_deactivate(
request: Request,
account_id: int = Form(...),
csrf_token: str = Form(default=""),
):
"""Dezactiveaza un cont. Nu permite dezactivarea contului default id=1. PRG: redirect 303."""
require_admin(request)
verify_csrf(request, csrf_token)
if account_id == 1:
conn = get_connection()
try:
return _render_admin(
request, conn,
error="Contul default (id=1) nu poate fi dezactivat.",
status_code=422,
)
finally:
conn.close()
conn = get_connection()
try:
try:
set_active(conn, account_id, False)
except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422)
finally:
conn.close()
return RedirectResponse("/admin", status_code=303)

170
app/web/auth_routes.py Normal file
View File

@@ -0,0 +1,170 @@
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..accounts import create_account
from ..auth import create_api_key
from ..config import get_settings
from ..db import get_connection
from ..email import notify_signup
from ..users import count_admins, create_user, list_admin_emails, verify_password
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.ratelimit import check_rate_limit
from ..web.session import clear_session, set_session
router = APIRouter()
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
_RATE_MSG = "Prea multe cereri. Incearca mai tarziu."
_PASSWORD_MIN = 10
def _ctx(request: Request, **extra) -> dict:
settings = get_settings()
return {"rar_env": settings.rar_env, "version": __version__, **extra}
# --- Signup ---
@router.get("/signup", response_class=HTMLResponse)
async def signup_get(request: Request):
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request, csrf_token=get_csrf_token(request)
))
@router.post("/signup", response_class=HTMLResponse)
async def signup_post(
request: Request,
name: str = Form(default=""),
cui: str = Form(default=""),
email: str = Form(default=""),
parola: str = Form(default=""),
csrf_token: str = Form(default=""),
):
verify_csrf(request, csrf_token)
settings = get_settings()
ip = request.client.host if request.client else "unknown"
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=_RATE_MSG,
name=name, cui=cui, email=email,
), status_code=429)
if len(parola) < _PASSWORD_MIN:
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
name=name, cui=cui, email=email,
), status_code=422)
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
try:
is_first = count_admins(conn) == 0
account_id = create_account(conn, name, cui.strip() or None, active=False)
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
api_key = create_api_key(conn, account_id)
conn.execute("COMMIT")
except Exception as exc:
conn.execute("ROLLBACK")
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=str(exc),
name=name, cui=cui, email=email,
), status_code=422)
finally:
conn.close()
set_session(request, account_id, user_id)
print(f"SIGNUP cont={account_id} email={email}", flush=True)
# Notificare email admin (best-effort, nu blocheaza signup-ul)
try:
conn2 = get_connection()
try:
admins = list_admin_emails(conn2)
finally:
conn2.close()
notify_signup(admins, account_id, email)
except Exception as exc_notify:
print(f"SIGNUP-NOTIFY exceptie neasteptata cont={account_id}: {type(exc_notify).__name__}", flush=True)
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
api_key=api_key,
account_id=account_id,
))
# --- Login / Logout ---
@router.get("/login", response_class=HTMLResponse)
async def login_get(request: Request):
return _TMPL.TemplateResponse(request, "login.html", _ctx(
request, csrf_token=get_csrf_token(request)
))
@router.post("/login", response_class=HTMLResponse)
async def login_post(
request: Request,
email: str = Form(default=""),
parola: str = Form(default=""),
csrf_token: str = Form(default=""),
):
verify_csrf(request, csrf_token)
settings = get_settings()
ip = request.client.host if request.client else "unknown"
if not check_rate_limit("login:" + ip, settings.login_rate_max, settings.signup_rate_window_s):
return _TMPL.TemplateResponse(request, "login.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=_RATE_MSG,
), status_code=429)
conn = get_connection()
try:
account_id = verify_password(conn, email, parola)
if account_id is None:
return _TMPL.TemplateResponse(request, "login.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error="Email sau parola incorecte.",
), status_code=401)
row = conn.execute(
"SELECT id FROM users WHERE email=? COLLATE NOCASE", (email.strip(),)
).fetchone()
user_id = int(row["id"]) if row else 0
finally:
conn.close()
set_session(request, account_id, user_id)
return RedirectResponse("/", status_code=303)
@router.post("/logout", response_class=HTMLResponse)
async def logout_post(
request: Request,
csrf_token: str = Form(default=""),
):
verify_csrf(request, csrf_token)
clear_session(request)
return RedirectResponse("/login", status_code=303)

51
app/web/csrf.py Normal file
View File

@@ -0,0 +1,51 @@
"""CSRF token per-sesiune + validare. US-009 PRD 3.3.
Contract pentru rutele POST web:
- Formulare HTML includ: <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
- Handler-ul POST apeleaza: verify_csrf(request, form.get("csrf_token"))
- La nepotrivire/lipsa: CsrfError -> @app.exception_handler(CsrfError) -> 403
Token e per-sesiune (stabil pana la logout), generat lazy la primul acces.
"""
from __future__ import annotations
import hmac
import secrets
from starlette.requests import Request
from ..config import get_settings
class CsrfError(Exception):
"""Token CSRF lipsa sau invalid. Prins de exception_handler in main.py -> 403."""
def get_csrf_token(request: Request) -> str:
"""Intoarce tokenul CSRF al sesiunii, generandu-l daca lipseste."""
token = request.session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
request.session["csrf_token"] = token
return token
def verify_csrf(request: Request, submitted: str | None) -> None:
"""Verifica tokenul CSRF trimis in formular.
Gateaza pe MOD, nu pe account_id:
- prod (web_auth_required=True): enforce pe TOATE rutele POST, inclusiv /login si
/signup unde atacatorul ar putea forta victima sa se logheze in contul sau
(login CSRF). GET-urile de formular genereaza token in sesiune via get_csrf_token.
- dev/test (web_auth_required=False, fara account_id): skip transparent, testele
existente raman verzi fara sa fie nevoie de token.
- sesiune autentificata (account_id in sesiune): enforce indiferent de mod.
"""
settings = get_settings()
enforce = settings.web_auth_required or request.session.get("account_id") is not None
if not enforce:
return # dev fara auth: CSRF neaplicabil
expected = request.session.get("csrf_token")
if not expected or not submitted or not hmac.compare_digest(expected.encode(), submitted.encode()):
raise CsrfError("token CSRF invalid")

31
app/web/ratelimit.py Normal file
View File

@@ -0,0 +1,31 @@
"""Rate-limit in-proces cu fereastra glisanta. US-009 PRD 3.3 C5.
Fara dependinta externa. Folosit de POST /signup (US-003) cu cheia = IP client.
Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (config.py).
"""
from __future__ import annotations
import time
from collections import defaultdict
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
_hits: dict[str, list[float]] = defaultdict(list)
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
"""Fereastra glisanta: returneaza True daca cererea e permisa, False la depasire.
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
suficient pentru un singur proces uvicorn.
"""
now = time.monotonic()
cutoff = now - window_s
timestamps = _hits[key]
# Sterge intrari expirate
_hits[key] = [t for t in timestamps if t > cutoff]
if len(_hits[key]) >= max_hits:
return False
_hits[key].append(now)
return True

View File

@@ -22,6 +22,9 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from ..auth import rotate_api_key
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_login
from ..api.v1.import_router import ( from ..api.v1.import_router import (
_already_sent_lookup, _already_sent_lookup,
_build_idempotency_key, _build_idempotency_key,
@@ -35,6 +38,7 @@ from ..crypto import decrypt_creds, encrypt_creds
from ..db import get_connection, read_heartbeat from ..db import get_connection, read_heartbeat
from ..idempotency import build_key, canonicalize_row from ..idempotency import build_key, canonicalize_row
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 ..mapping import ( from ..mapping import (
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
account_or_default, account_or_default,
@@ -55,11 +59,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
_BLOCKED = ("error", "needs_data", "needs_mapping") _BLOCKED = ("error", "needs_data", "needs_mapping")
def _status_counts(conn) -> dict[str, int]: def _ctx(request: Request, **extra) -> dict:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() """Context de baza pentru template-uri cu formulare: include mereu csrf_token.
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
"""
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
def _status_counts(conn, account_id: int) -> dict[str, int]:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"GROUP BY status",
(account_id, account_id),
).fetchall()
return {r["status"]: int(r["n"]) for r in rows} return {r["status"]: int(r["n"]) for r in rows}
def _account_active(conn, account_id: int) -> bool:
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
return bool(row["active"]) if row else True
def _worker_alive(hb) -> bool: def _worker_alive(hb) -> bool:
if hb is None or not hb["last_beat"]: if hb is None or not hb["last_beat"]:
return False return False
@@ -92,9 +116,10 @@ def _rar_state(hb, worker_alive: bool) -> str:
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request) -> HTMLResponse: def dashboard(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
counts = _status_counts(conn) counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn) hb = read_heartbeat(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED) blocked = sum(counts.get(s, 0) for s in _BLOCKED)
worker_alive = _worker_alive(hb) worker_alive = _worker_alive(hb)
@@ -107,6 +132,9 @@ def dashboard(request: Request) -> HTMLResponse:
"worker_alive": worker_alive, "worker_alive": worker_alive,
"last_login": hb["last_rar_login_ok"] if hb else None, "last_login": hb["last_rar_login_ok"] if hb else None,
"rar_state": _rar_state(hb, worker_alive), "rar_state": _rar_state(hb, worker_alive),
"account_active": _account_active(conn, account_id),
"is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request),
} }
return templates.TemplateResponse("dashboard.html", ctx) return templates.TemplateResponse("dashboard.html", ctx)
finally: finally:
@@ -130,46 +158,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse:
@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)
conn = get_connection() conn = get_connection()
try: try:
counts = _status_counts(conn) counts = _status_counts(conn, account_id)
blocked = sum(counts.get(s, 0) for s in _BLOCKED) blocked = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked}) return templates.TemplateResponse("_banner.html", {
"request": request,
"blocked": blocked,
"account_active": _account_active(conn, account_id),
})
finally: finally:
conn.close() conn.close()
@router.get("/_fragments/submissions", response_class=HTMLResponse) @router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse: def fragment_submissions(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
rows = conn.execute( rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at " "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
"FROM submissions ORDER BY id DESC LIMIT 100" "FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"ORDER BY id DESC LIMIT 100",
(account_id, account_id),
).fetchall() ).fetchall()
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows}) return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
finally: finally:
conn.close() conn.close()
def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse: def _render_mapari(
request: Request, conn, account_id: int, *, message: str | None = None
) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
"_mapari.html", "_mapari.html",
{ {
"request": request, "request": request,
"pending": pending_unmapped(conn), "pending": pending_unmapped(conn, account_id),
"nomenclator": load_nomenclator(conn), "nomenclator": load_nomenclator(conn),
"message": message, "message": message,
"csrf_token": get_csrf_token(request),
}, },
) )
@router.get("/_fragments/mapari", response_class=HTMLResponse) @router.get("/_fragments/mapari", response_class=HTMLResponse)
def fragment_mapari(request: Request) -> HTMLResponse: def fragment_mapari(request: Request) -> HTMLResponse:
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.""" """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
"""
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
return _render_mapari(request, conn) return _render_mapari(request, conn, account_id)
finally: finally:
conn.close() conn.close()
@@ -179,16 +223,18 @@ def post_mapare(
request: Request, request: Request,
cod_op_service: str = Form(...), cod_op_service: str = Form(...),
cod_prestatie: str = Form(...), cod_prestatie: str = Form(...),
account_id: int | None = Form(None), csrf_token: str | None = Form(None),
auto_send: bool = Form(False), auto_send: bool = Form(False),
) -> HTMLResponse: ) -> HTMLResponse:
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul.""" """Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection() conn = get_connection()
try: try:
cod = cod_prestatie.strip().upper() cod = cod_prestatie.strip().upper()
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone() exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
if not exists: if not exists:
return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}") return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send) save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id) stats = reresolve_account(conn, account_id)
msg = ( msg = (
@@ -196,7 +242,7 @@ def post_mapare(
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, " f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate." f"{stats['still_blocked']} inca nemapate."
) )
return _render_mapari(request, conn, message=msg) return _render_mapari(request, conn, account_id, message=msg)
finally: finally:
conn.close() conn.close()
@@ -383,6 +429,7 @@ async def web_upload_import(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
sheet_name: str | None = Form(None), sheet_name: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse: ) -> HTMLResponse:
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML. """Upload fisier xlsx/csv → staging; intoarce fragment HTML.
@@ -390,7 +437,8 @@ async def web_upload_import(
Daca nu: intoarce formularul de mapare coloane. Daca nu: intoarce formularul de mapare coloane.
Nu editeaza import_router.py — apeleaza parse_file si DB direct. Nu editeaza import_router.py — apeleaza parse_file si DB direct.
""" """
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id) acct = account_or_default(account_id)
data = await file.read() data = await file.read()
@@ -400,30 +448,15 @@ async def web_upload_import(
try: try:
parsed = parse_file(data, filename, sheet_name=sheet_name) parsed = parse_file(data, filename, sheet_name=sheet_name)
except MultipleSheets as ms: except MultipleSheets as ms:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
"request": request,
"sheets": ms.sheet_names,
})
except FileTooLarge as e: except FileTooLarge as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
"request": request,
"error": str(e),
})
except HeaderError as e: except HeaderError as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
"request": request,
"error": f"Antet neclar: {e}",
})
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
"request": request,
"error": f"Encoding nesuportat: {e.reason}",
})
except Exception as e: except Exception as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
"request": request,
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
})
conn = get_connection() conn = get_connection()
try: try:
@@ -467,11 +500,13 @@ async def web_upload_import(
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", {
"request": request, "request": request,
"error": result, "error": result,
"csrf_token": get_csrf_token(request),
}) })
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", {
"request": request, "request": request,
"import_id": batch_id_int, "import_id": batch_id_int,
"message": "Mapare retinuta aplicata automat.", "message": "Mapare retinuta aplicata automat.",
"csrf_token": get_csrf_token(request),
**result, **result,
}) })
@@ -491,6 +526,7 @@ async def web_upload_import(
"fuzzy_suggestions": fuzzy_suggestions, "fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS, "canonical_fields": _CANONICAL_FIELDS,
"format_data": None, "format_data": None,
"csrf_token": get_csrf_token(request),
}) })
finally: finally:
conn.close() conn.close()
@@ -502,13 +538,14 @@ async def web_save_mapare_coloane(
import_id: int, import_id: int,
) -> HTMLResponse: ) -> HTMLResponse:
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML.""" """Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
acct = account_or_default(account_id) acct = account_or_default(account_id)
form = await request.form() form = await request.form()
# Colectare perechi coloana fisier → camp canonic din form # Colectare perechi coloana fisier → camp canonic din form
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text) # form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
verify_csrf(request, str(form.get("csrf_token") or ""))
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)] colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)] canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
format_data_val = str(form.get("format_data") or "").strip() or None format_data_val = str(form.get("format_data") or "").strip() or None
@@ -539,17 +576,17 @@ async def web_save_mapare_coloane(
sugg = _fuzzy_suggest_column(col, limit=3) sugg = _fuzzy_suggest_column(col, limit=3)
if sugg: if sugg:
fuzzy[col] = sugg fuzzy[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", { return templates.TemplateResponse("_mapcoloane.html", _ctx(
"request": request, request,
"import_id": import_id, import_id=import_id,
"columns": columns, columns=columns,
"sample_rows": [], sample_rows=[],
"fuzzy_suggestions": fuzzy, fuzzy_suggestions=fuzzy,
"canonical_fields": _CANONICAL_FIELDS, canonical_fields=_CANONICAL_FIELDS,
"format_data": format_data_val, format_data=format_data_val,
"message": "Mapeaza cel putin un camp canonic inainte de a continua.", message="Mapeaza cel putin un camp canonic inainte de a continua.",
"error": True, error=True,
}) ))
finally: finally:
conn.close() conn.close()
@@ -561,10 +598,9 @@ async def web_save_mapare_coloane(
(import_id, acct), (import_id, acct),
).fetchone() ).fetchone()
if not batch: if not batch:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request, error="Batch de import inexistent sau expirat."
"error": "Batch de import inexistent sau expirat.", ))
})
sig = _signature(list(json_mapare.keys())) sig = _signature(list(json_mapare.keys()))
@@ -580,15 +616,10 @@ async def web_save_mapare_coloane(
# Computa preview # Computa preview
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
"request": request, return templates.TemplateResponse("_preview_import.html", _ctx(
"error": result, request, import_id=import_id, **result
}) ))
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
**result,
})
finally: finally:
conn.close() conn.close()
@@ -599,7 +630,7 @@ def web_preview_import(
import_id: int, import_id: int,
) -> HTMLResponse: ) -> HTMLResponse:
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa.""" """Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
@@ -607,10 +638,12 @@ def web_preview_import(
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", {
"request": request, "request": request,
"error": result, "error": result,
"csrf_token": get_csrf_token(request),
}) })
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
"csrf_token": get_csrf_token(request),
**result, **result,
}) })
finally: finally:
@@ -620,7 +653,10 @@ def web_preview_import(
@router.get("/_import/reset", response_class=HTMLResponse) @router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse: def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol).""" """Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
return templates.TemplateResponse("_upload.html", {"request": request}) return templates.TemplateResponse("_upload.html", {
"request": request,
"csrf_token": get_csrf_token(request),
})
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse) @router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
@@ -632,11 +668,14 @@ async def web_confirma_import(
Replica logica din import_router.commit_import dar cu input din form HTML Replica logica din import_router.commit_import dar cu input din form HTML
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU). si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
""" """
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
acct = account_or_default(account_id) acct = account_or_default(account_id)
form = await request.form() form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str) # Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
try: try:
@@ -662,16 +701,14 @@ async def web_confirma_import(
(import_id, acct), (import_id, acct),
).fetchone() ).fetchone()
if not batch: if not batch:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request, error="Batch de import inexistent sau expirat."
"error": "Batch de import inexistent sau expirat.", ))
})
if batch["status"] == "committed": if batch["status"] == "committed":
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request, message="Acest batch a fost deja comis."
"message": "Acest batch a fost deja comis.", ))
})
# Incarca randurile cu stare ok si needs_review # Incarca randurile cu stare ok si needs_review
ok_rows_db = conn.execute( ok_rows_db = conn.execute(
@@ -684,14 +721,14 @@ async def web_confirma_import(
# Re-arata preview cu eroare # Re-arata preview cu eroare
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result}) return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", _ctx(
"request": request, request,
"import_id": import_id, import_id=import_id,
"message": "Niciun rand ok de confirmat in acest batch.", message="Niciun rand ok de confirmat in acest batch.",
"error": True, error=True,
**result, **result,
}) ))
# Decripteaza si construieste lista de randuri de trimis # Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = [] to_enqueue: list[dict[str, Any]] = []
@@ -726,26 +763,22 @@ async def web_confirma_import(
f"Verifica preview-ul si retasteaza numarul corect." f"Verifica preview-ul si retasteaza numarul corect."
) )
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": msg}) return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", _ctx(
"request": request, request, import_id=import_id, message=msg, error=True, **result
"import_id": import_id, ))
"message": msg,
"error": True,
**result,
})
if n_total_ok == 0: if n_total_ok == 0:
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result}) return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", _ctx(
"request": request, request,
"import_id": import_id, import_id=import_id,
"message": "Niciun rand ok de confirmat.", message="Niciun rand ok de confirmat.",
"error": True, error=True,
**result, **result,
}) ))
# Incarca maparea de coloane pentru payload # Incarca maparea de coloane pentru payload
first_row_db = conn.execute( first_row_db = conn.execute(
@@ -867,13 +900,129 @@ async def web_confirma_import(
# Succes → drop zone cu mesaj de confirmare # Succes → drop zone cu mesaj de confirmare
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request,
"message": ( message=(
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. " f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos." f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
), ),
}) ))
finally: finally:
conn.close() conn.close()
# =========================================================================== #
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
# care cere cheie API; sesiunea web e suficienta ca identitate). #
# =========================================================================== #
def _render_cont(
request: Request,
*,
api_key: str | None = None,
are_creds: bool = False,
creds_mesaj: str | None = None,
creds_eroare: str | None = None,
rot_eroare: str | None = None,
) -> HTMLResponse:
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
return templates.TemplateResponse(
"_cont.html",
_ctx(
request,
api_key=api_key,
are_creds=are_creds,
creds_mesaj=creds_mesaj,
creds_eroare=creds_eroare,
rot_eroare=rot_eroare,
),
)
@router.get("/_fragments/cont", response_class=HTMLResponse)
def fragment_cont(request: Request) -> HTMLResponse:
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
account_id = require_login(request)
acct = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, are_creds=are_creds)
finally:
conn.close()
@router.post("/cont/roteste-cheie", response_class=HTMLResponse)
def cont_roteste_cheie(
request: Request,
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Revoca toate cheile active si emite una noua. Afisata O SINGURA DATA."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
conn = get_connection()
try:
new_key = rotate_api_key(conn, acct)
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, api_key=new_key, are_creds=are_creds)
finally:
conn.close()
@router.post("/cont/rar-creds", response_class=HTMLResponse)
def cont_rar_creds(
request: Request,
rar_email: str = Form(""),
rar_parola: str = Form(""),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13).
Camp parola NICIODATA re-pus in value= la re-randare.
Validare minima: email si parola negoale.
"""
account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id)
email = rar_email.strip()
parola = rar_parola.strip()
if not email or not parola:
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
creds_eroare="Email si parola sunt obligatorii.",
)
enc = encrypt_creds({"email": email, "password": parola})
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(enc, acct),
)
return _render_cont(
request,
are_creds=True,
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
)
finally:
conn.close()

99
app/web/session.py Normal file
View File

@@ -0,0 +1,99 @@
"""Helper-e sesiune web. US-002 PRD 3.3.
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
- require_login() RIDICA LoginRequired
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
RedirectResponse('/login', 303)
Astfel handler-ul e intrerupt imediat la raise, independent de logica FastAPI.
"""
from __future__ import annotations
from starlette.requests import Request
from ..config import get_settings
from ..mapping import DEFAULT_ACCOUNT_ID
class LoginRequired(Exception):
"""Ridica pentru a redirectiona la /login (prinsa de exception_handler in main.py)."""
class AdminRequired(Exception):
"""Ridica cand contul sesiunii nu are rol admin (prinsa de exception_handler in main.py)."""
def current_account(request: Request) -> int | None:
"""account_id din sesiune sau None daca nu e logat."""
val = request.session.get("account_id")
return int(val) if val is not None else None
def current_user_id(request: Request) -> int | None:
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
val = request.session.get("user_id")
return int(val) if val is not None else None
def web_account(request: Request) -> int | None:
"""account_id pentru rutele web de CITIRE.
- sesiune activa -> contul sesiunii
- fara sesiune + web_auth_required=False (dev) -> DEFAULT_ACCOUNT_ID (cont 1, back-compat)
- fara sesiune + web_auth_required=True (prod) -> None
Rutele de SCRIERE trebuie sa foloseasca require_login() direct, nu web_account(),
ca sa nu cada niciodata tacit pe contul 1 in prod.
"""
aid = current_account(request)
if aid is not None:
return aid
settings = get_settings()
if not settings.web_auth_required:
return DEFAULT_ACCOUNT_ID
return None
def require_login(request: Request) -> int:
"""Verifica sesiunea activa; ridica LoginRequired daca nu.
Intoarce account_id la succes. Aruncatorul (exception_handler din main.py)
intercepteaza LoginRequired si intoarce RedirectResponse('/login', 303).
"""
aid = web_account(request)
if aid is None:
raise LoginRequired()
return aid
def require_admin(request: Request) -> int:
"""Verifica ca userul logat are rol admin pe contul sesiunii.
Intai cheama require_login (nelogat -> LoginRequired -> /login redirect).
Daca e logat dar nu e admin -> ridica AdminRequired.
Intoarce account_id la succes.
"""
account_id = require_login(request)
from ..db import get_connection
from ..users import is_account_admin
conn = get_connection()
try:
admin = is_account_admin(conn, account_id)
finally:
conn.close()
if not admin:
raise AdminRequired()
return account_id
def set_session(request: Request, account_id: int, user_id: int) -> None:
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
request.session.clear()
request.session["account_id"] = account_id
request.session["user_id"] = user_id
def clear_session(request: Request) -> None:
"""Sterge sesiunea (logout)."""
request.session.clear()

View File

@@ -1,5 +1,12 @@
<div class="card banner {% if not blocked %}hidden{% endif %}" {% if not account_active %}
<div class="card banner" style="border-color:var(--warn); background:#201c0f;"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"> hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Cont in asteptare de activare.</strong>
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.
</div>
{% endif %}
<div class="card banner {% if not blocked %}hidden{% endif %}"
{% if account_active %}hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping). <strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos. Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
</div> </div>

View File

@@ -0,0 +1,76 @@
<div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
<!-- Sectiunea: Cheia mea API -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>
{% if api_key %}
<div class="flash" style="margin-bottom:12px;">Cheia a fost rotita. Salveaz-o acum — nu o vei mai putea vedea.</div>
<div class="card" style="font-family:monospace; word-break:break-all; font-size:14px; background:#0f1115; margin:0 0 8px;">
{{ api_key }}
</div>
<button type="button"
data-key="{{ api_key }}"
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
Copiaza cheia
</button>
<p style="font-size:13px; color:var(--warn); margin:10px 0 0;">
Atentie: la urmatoarea vizita aceasta cheie dispare. Daca o pierzi, roteste din nou.
</p>
{% endif %}
{% if rot_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ rot_eroare }}</div>
{% endif %}
<form hx-post="/cont/roteste-cheie"
hx-target="#card-cont"
hx-swap="outerHTML"
style="margin-top:{% if api_key %}12px{% else %}0{% endif %};">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--warn); border-color:var(--warn);">
Roteste cheia API
</button>
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Cheia veche se revoca imediat.</span>
</form>
</div>
<!-- Sectiunea: Credentiale RAR -->
<div>
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
{% if are_creds %}
<div class="flash" style="margin-bottom:12px;">Credentiale RAR configurate.</div>
{% endif %}
{% if creds_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
{% endif %}
{% if creds_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
{% endif %}
<form hx-post="/cont/rar-creds"
hx-target="#card-cont"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email RAR</label><br>
<input type="email" name="rar_email" required style="width:100%; max-width:340px;"
placeholder="email@service.ro">
</p>
<p style="margin:0 0 12px;">
<label style="font-size:13px; color:var(--muted);">Parola RAR</label><br>
<input type="password" name="rar_parola" required style="width:100%; max-width:340px;"
autocomplete="new-password">
</p>
<button type="submit">Salveaza credentiale RAR</button>
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parola stocata criptat, niciodata in clar.</span>
</form>
</div>
</div>

View File

@@ -17,7 +17,7 @@
{% set top = e.suggestions[0] if e.suggestions else None %} {% set top = e.suggestions[0] if e.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %} {% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML"> <form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
<input type="hidden" name="account_id" value="{{ e.account_id }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}"> <input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow"> <div class="mapcol grow">

View File

@@ -20,6 +20,7 @@
<form hx-post="/_import/{{ import_id }}/mapare-coloane" <form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section" hx-target="#import-section"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;"> <div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
<label for="format-data" style="font-size:13px; color:var(--muted);"> <label for="format-data" style="font-size:13px; color:var(--muted);">

View File

@@ -57,6 +57,7 @@
hx-post="/_import/{{ import_id }}/confirma" hx-post="/_import/{{ import_id }}/confirma"
hx-target="#import-section" hx-target="#import-section"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="tablewrap"> <div class="tablewrap">
<table> <table>

View File

@@ -23,6 +23,7 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-encoding="multipart/form-data" hx-encoding="multipart/form-data"
hx-indicator="#upload-spinner"> hx-indicator="#upload-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
{% if sheets %} {% if sheets %}
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
<h2 style="margin:0;">Panou admin</h2>
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
</div>
{% if error %}
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
{% endif %}
<!-- Conturi in asteptare -->
<div class="card">
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
{% if pending %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in pending %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/activate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit">Activeaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont in asteptare.</p>
{% endif %}
</div>
<!-- Conturi active -->
<div class="card">
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
{% if active %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in active %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/deactivate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
{% endif %}
</div>
<!-- Contul dev default (id=1) -->
{% if default_account %}
<div class="card" style="border-color:var(--muted);">
<p class="muted" style="margin:0;font-size:13px;">
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<!-- Nav cont: link admin (doar pentru admini) + logout -->
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
<form method="post" action="/logout" style="display:inline; margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
</form>
</div>
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow --> <!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow -->
{% include '_upload.html' %} {% include '_upload.html' %}
@@ -32,6 +41,10 @@
<div class="card"><div class="empty">se incarca mapari…</div></div> <div class="card"><div class="empty">se incarca mapari…</div></div>
</div> </div>
<div hx-get="/_fragments/cont" hx-trigger="load" hx-swap="outerHTML">
<div class="card"><div class="empty">se incarca cont…</div></div>
</div>
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2> <h2 style="font-size:15px; margin:0;">Coada submissions</h2>

View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Autentificare — Gateway RAR AUTOPASS{% endblock %}
{% block content %}
<div class="card" style="max-width:400px;margin:40px auto;">
<h2 style="margin-top:0;">Autentificare</h2>
{% if error %}
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>
<label>Email</label><br>
<input type="email" name="email" required style="width:100%;">
</p>
<p>
<label>Parola</label><br>
<input type="password" name="parola" required style="width:100%;">
</p>
<button type="submit" style="width:100%;margin-top:8px;">Intra in cont</button>
</form>
<p style="text-align:center;font-size:13px;margin-top:16px;">
Cont nou? <a href="/signup">Inregistrare</a>
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Inregistrare — Gateway RAR AUTOPASS{% endblock %}
{% block content %}
<div class="card" style="max-width:480px;margin:40px auto;">
{% if api_key %}
<div class="flash">Contul a fost creat. Salveaza cheia API acum — nu o vei mai putea vedea.</div>
<div class="card" style="font-family:monospace;word-break:break-all;font-size:14px;background:#0f1115;margin:12px 0;">
{{ api_key }}
</div>
<button type="button"
data-key="{{ api_key }}"
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
Copiaza cheia
</button>
<p style="font-size:13px;color:var(--warn);margin-top:12px;">
Atentie: la refresh sau la urmatoarea vizita aceasta cheie dispare.
Recuperare posibila doar prin rotire cheie (CLI admin).
</p>
<div class="banner warn" style="margin-top:16px;">
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">
<input type="checkbox" id="saved-check" style="margin-top:3px;">
Am salvat cheia in siguranta
</label>
</div>
<p id="cta-dashboard" style="display:none;margin-top:16px;">
<a href="/">Mergi la dashboard</a> — configureaza creds RAR si pregateste importul.
Trimiterea catre RAR porneste automat dupa activarea contului de catre admin.
</p>
<script>
document.getElementById('saved-check').addEventListener('change', function() {
document.getElementById('cta-dashboard').style.display = this.checked ? 'block' : 'none';
});
</script>
{% else %}
<h2 style="margin-top:0;">Inregistrare cont nou</h2>
{% if error %}
<div class="banner" style="margin-bottom:12px;padding:8px 12px;">{{ error }}</div>
{% endif %}
<form method="post" action="/signup">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>
<label>Companie <span style="color:var(--err)">*</span></label><br>
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
</p>
<p>
<label>CUI <span style="color:var(--muted);font-size:12px;">(optional)</span></label><br>
<input type="text" name="cui" value="{{ cui or '' }}" style="width:100%;">
</p>
<p>
<label>Email <span style="color:var(--err)">*</span></label><br>
<input type="email" name="email" value="{{ email or '' }}" required style="width:100%;">
</p>
<p>
<label>Parola <span style="color:var(--err)">*</span>
<span style="color:var(--muted);font-size:12px;">(minim 10 caractere)</span>
</label><br>
<input type="password" name="parola" required style="width:100%;">
</p>
<button type="submit" style="width:100%;margin-top:8px;">Creeaza cont</button>
</form>
<p style="text-align:center;font-size:13px;margin-top:16px;">
Ai deja cont? <a href="/login">Autentificare</a>
</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -140,8 +140,12 @@ def claim_one(conn) -> dict | None:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
row = conn.execute( row = conn.execute(
"SELECT id, account_id, payload_json, rar_creds_enc FROM submissions WHERE status='queued' " "SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc "
"AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY id LIMIT 1", "FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
"AND COALESCE(a.active, 1) = 1 "
"ORDER BY s.id LIMIT 1",
(_iso(_now()),), (_iso(_now()),),
).fetchone() ).fetchone()
if not row: if not row:

View File

@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-06-17 — 3.1 LIVRAT (CLI `tools/account.py` + `accounts.active` + index unic CUI + helper-e `app/accounts.py`; 299 teste pass). Urmeaza 3.2. Deferat din 3.1 (P3, fara SQL manual): `rename`/`set-cui` (corectie typo), `--if-not-exists` (provisioning idempotent); `set-password --account N` se implementeaza in 3.3 cu `app/users.py`. **Ultima actualizare**: 2026-06-18 — 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin``AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012.
> 3.3a LIVRAT (self-onboarding web core: `app/users.py` parole scrypt cu eticheta de parametri onorata la verify; `SessionMiddleware` same_site=strict + `app/web/session.py` guard `require_login`→`LoginRequired`; CSRF per-sesiune enforce in prod inclusiv pe login/signup + rate-limit signup & login in-proces; signup `active=0` tranzactie atomica + cheie-o-data + log `SIGNUP`; login/logout; dashboard & import multi-tenant scoped pe sesiune cu regula NULL→cont 1 — toate rutele web care ating date sensibile sub `require_login` + scope; gate worker `claim_one` `LEFT JOIN ... COALESCE(active,1)=1`. 2 runde VERIFY context curat — runda 1 a prins un leak cross-account pe `/_fragments/mapari`, reparat; runda 2 PASS. `/code-review` high a prins 3 findings, reparate. 361 teste pass). Urmeaza 3.3b (self-service cheie/creds + admin web + email). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`.
### Etapa 1 — Canal API ROAAUTO (Treapta 1) ### Etapa 1 — Canal API ROAAUTO (Treapta 1)
@@ -73,8 +75,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
| # | Livrabila | Status | Data | Detalii | | # | Livrabila | Status | Data | Detalii |
|---|-----------|--------|------|---------| |---|-----------|--------|------|---------|
| 3.1 | Creare cont nou (CLI dedicat) | DONE | 2026-06-17 | CLI `tools/account.py` (create/list[--pending]/activate/deactivate, `--with-key` atomic) + `accounts.active` + index unic CUI + `app/accounts.py`. 20 teste noi. PRD: [prd-3.1](prd/prd-3.1-creare-cont.md) | | 3.1 | Creare cont nou (CLI dedicat) | DONE | 2026-06-17 | CLI `tools/account.py` (create/list[--pending]/activate/deactivate, `--with-key` atomic) + `accounts.active` + index unic CUI + `app/accounts.py`. 20 teste noi. PRD: [prd-3.1](prd/prd-3.1-creare-cont.md) |
| 3.2 | Filtrare pe cont a GET-urilor de listare | TODO (PRD aprobat) | | scope cheie pe `/v1/prezentari`, `/v1/mapari`, `/v1/audit/export`; nomenclator global. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) | | 3.2 | Filtrare pe cont a GET-urilor de listare | DONE | 2026-06-17 | scope cheie pe `/v1/prezentari(/{id})`, `/v1/mapari(/pending)`, `/v1/audit/export` (NULL→cont 1); nomenclator global; 404 cross-account identic (B3) + allowlist campuri detaliu (B4) + helper `account_scope_clause` (B2) + index (B5). 14 teste noi, 313 pass. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) |
| 3.3 | Self-onboarding web + interfata admin | TODO (PRD aprobat) | | signup/login/sesiuni + cont "in asteptare" + gate worker + CSRF + panou admin web + email. 12 stories. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) | | 3.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
| 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
### Etapa 4 — Viitor (Treapta 3) ### Etapa 4 — Viitor (Treapta 3)

View File

@@ -294,6 +294,19 @@ de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline.
Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit
contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural. contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural.
## Regula de scope pe cont (B8, PRD 3.2)
Orice GET nou pe `/v1/*` care atinge `submissions` sau `operations_mapping` **PORNESTE**
cu `account_id: int = Depends(resolve_account_id)` si clauza de scope pe cont in SQL.
Varianta globala (fara scope) e exceptie justificata explicit — singurul exemplu actual
este `GET /v1/nomenclator` (cache de referinta RAR fara PII, partajat intre conturi).
Pentru `submissions` (account_id nullable): foloseste `account_scope_clause(account_id)`
din `app/mapping.py` care produce `(account_id = ? OR (account_id IS NULL AND ? = 1))`.
Randurile legacy cu `account_id IS NULL` apartin contului 1 (OV-2, back-compat).
Pentru `operations_mapping` (account_id NOT NULL): `WHERE account_id = ?` simplu.
## Open questions rămase (actualizat) ## Open questions rămase (actualizat)
1. ~~Sursa pozei odometrului~~**închis** (poză opțională). 1. ~~Sursa pozei odometrului~~**închis** (poză opțională).

View File

@@ -1,6 +1,6 @@
# PRD 3.2 — Filtrare pe cont a GET-urilor de listare # PRD 3.2 — Filtrare pe cont a GET-urilor de listare
**Stare**: aprobat **Stare**: inchis
> 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`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). > Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
@@ -39,11 +39,11 @@ contul meu **pentru ca** un client nu trebuie sa vada coada altui client.
`test_detaliu_cross_account_404`, `test_legacy_null_vizibil_pentru_cont_1`, `test_detaliu_cross_account_404`, `test_legacy_null_vizibil_pentru_cont_1`,
`test_fara_cheie_flag_off_vede_contul_1` `test_fara_cheie_flag_off_vede_contul_1`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`. - [x] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`.
- [ ] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`). - [x] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`).
- [ ] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta). - [x] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta).
- [ ] Cheie A nu vede submission-uri ale contului B (lista si detaliu). - [x] Cheie A nu vede submission-uri ale contului B (lista si detaliu).
- [ ] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev). - [x] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev).
- **Verificare E2E**: doua chei (conturi distincte, via 3.1) → `POST` pe fiecare → `GET /v1/prezentari` - **Verificare E2E**: doua chei (conturi distincte, via 3.1) → `POST` pe fiecare → `GET /v1/prezentari`
cu cheia A nu contine id-urile contului B. cu cheia A nu contine id-urile contului B.
@@ -57,11 +57,11 @@ azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pen
- **Test intai (RED)**: `tests/test_get_scope_mapari.py``test_mapari_ignora_query_account_id`, - **Test intai (RED)**: `tests/test_get_scope_mapari.py``test_mapari_ignora_query_account_id`,
`test_mapari_doar_contul_cheii`, `test_pending_doar_contul_cheii`, `test_pending_web_global_neschimbat` `test_mapari_doar_contul_cheii`, `test_pending_doar_contul_cheii`, `test_pending_web_global_neschimbat`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query - [x] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query
este **eliminat** (un cont nu poate citi maparile altuia trecand un id arbitrar). este **eliminat** (un cont nu poate citi maparile altuia trecand un id arbitrar).
- [ ] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web, - [x] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web,
back-compat), valoare = filtrare pe cont. `GET /v1/mapari/pending` paseaza contul cheii. back-compat), valoare = filtrare pe cont. `GET /v1/mapari/pending` paseaza contul cheii.
- [ ] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de - [x] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de
`test_pending_web_global_neschimbat`. `test_pending_web_global_neschimbat`.
- **Verificare E2E**: cheie A cu o mapare; cheie B → `GET /v1/mapari` (B) nu contine maparea lui A. - **Verificare E2E**: cheie A cu o mapare; cheie B → `GET /v1/mapari` (B) nu contine maparea lui A.
@@ -74,10 +74,10 @@ azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pen
- **Test intai (RED)**: `tests/test_get_scope_audit.py``test_export_doar_contul_cheii`, - **Test intai (RED)**: `tests/test_get_scope_audit.py``test_export_doar_contul_cheii`,
`test_export_legacy_null_pentru_cont_1`, `test_export_status_all_tot_scoped` `test_export_legacy_null_pentru_cont_1`, `test_export_status_all_tot_scoped`
- **Acceptance criteria**: - **Acceptance criteria**:
- [ ] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont - [x] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont
(NULL→cont 1) pe langa filtrele de data/status existente. (NULL→cont 1) pe langa filtrele de data/status existente.
- [ ] `status=all` ramane scoped pe cont (nu exporta global). - [x] `status=all` ramane scoped pe cont (nu exporta global).
- [ ] Randurile contului B nu apar in CSV-ul cerut cu cheia A. - [x] Randurile contului B nu apar in CSV-ul cerut cu cheia A.
- **Verificare E2E**: `POST` pe doua conturi → `GET /v1/audit/export` (cheie A) → CSV fara VIN-urile B. - **Verificare E2E**: `POST` pe doua conturi → `GET /v1/audit/export` (cheie A) → CSV fara VIN-urile B.
## 4. Riscuri ## 4. Riscuri
@@ -171,4 +171,25 @@ cheii → **400** explicit (nu schimbare tacita). AC US-002 actualizat:
## Raport VERIFY ## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. > Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
**Verdict global: PASS** (verificator independent, context curat, 2026-06-17).
- **Suita**: `python3 -m pytest -q`**313 passed**, 0 fail. Teste noi 3.2: 14 passed.
- **Regresia de aur**: 313 verzi — POST `/v1/prezentari`, import, worker neatinse; calea de send nu e modificata.
| Criteriu | Verdict | Dovada |
|----------|---------|--------|
| US-001: `list_prezentari` scoped pe cont (ambele ramuri) | PASS | `router.py` + `test_lista_doar_contul_cheii` |
| US-001: `GET /{id}` alt cont → 404 | PASS | `test_detaliu_cross_account_404` |
| US-001: back-compat dev (fara cheie → cont 1) | PASS | `test_fara_cheie_flag_off_vede_contul_1` |
| US-002: `GET /mapari` scoped; `?account_id` difera → 400 (TD-3.2) | PASS | `test_mapari_query_account_id_diferit_400` / `_egal_ok` |
| US-002: web `pending_unmapped(conn)` ramane global | PASS | `routes.py:160` neatins + `test_pending_web_global_neschimbat` |
| US-003: `audit/export` + `status=all` scoped | PASS | 3 teste `test_get_scope_audit` |
| B1: `pending_unmapped` filtreaza IN SQL (nu Python) | PASS | `test_pending_filtreaza_in_sql_cu_regula_null` |
| B2: `account_scope_clause` DOAR pe submissions; `get_mapari` `WHERE account_id=?` simplu | PASS | `mapping.py` + `router.py` |
| B3: 404 cross-account byte-identic cu 404 inexistent | PASS | un singur `detail`; test explicit |
| B4: `get_prezentare` allowlist (exclude creds/payload/idempotency/error) | PASS | `_PREZENTARE_FIELDS` + `test_detaliu_nu_expune_creds` |
| B5: index `idx_submissions_account_status` in schema.sql + `_migrate` | PASS | `schema.sql` + `db.py` |
| B8: regula scope documentata in `api-rar-contract.md` | PASS | sectiune "Regula de scope pe cont (B8, PRD 3.2)" |
**Rezerva (acceptata):** trimiterea LIVE la RAR test (FINALIZATA) nu a rulat — lipsa `.env`/credentiale RAR in mediu. Schimbarile 3.2 ating EXCLUSIV GET-uri de citire (POST/worker/send neatinse), deci regresia E2E e acoperita integral de suita automata. De re-confirmat la urmatorul deploy cu creds.

View File

@@ -1,6 +1,16 @@
# PRD 3.3 — Self-onboarding web (login email+parola → emite cheie) # PRD 3.3 — Self-onboarding web (login email+parola → emite cheie)
**Stare**: aprobat **Stare**: inchis (3.3a + 3.3b livrate)
> **Decizii la poarta EXECUTE (2026-06-17, confirmate de utilizator):**
> - **Livrabila sparta in doua faze** (scope 12 stories prea mare pentru un singur EXECUTE):
> - **3.3a (in executie acum)** — self-onboarding core: US-001, US-002, US-009, US-003, US-004,
> US-005, US-006a, US-006b, US-008. Commit + VERIFY propriu.
> - **3.3b (urmeaza)** — admin web + email: US-010, US-011, US-012. Commit + VERIFY propriu.
> - **Bootstrap admin (US-010, 3.3b):** primul cont creat devine automat admin (`is_admin=1`).
> - **US-012 email:** livrare DEGRADATA fara SMTP — doar log `SIGNUP cont=N email=...` (C16) +
> `/admin` (US-011) + `tools/account.py list --pending`. Trimiterea efectiva = follow-up cand exista SMTP.
> - Prerechizita C1 confirmata: 3.1 livrat (`app/accounts.py`, `tools/account.py`, `active` migrat in `_migrate`).
> 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`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). > Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
@@ -407,7 +417,105 @@ Val 4: [US-007, US-011 admin panou, US-012 email] (US-007/011/012 peste US-010
- **SMTP (US-012):** ce provider/expeditor? Daca nu exista SMTP la momentul executiei, US-012 se - **SMTP (US-012):** ce provider/expeditor? Daca nu exista SMTP la momentul executiei, US-012 se
livreaza degradat (doar log + `/admin` + `list --pending`) si email-ul devine follow-up. livreaza degradat (doar log + `/admin` + `list --pending`) si email-ul devine follow-up.
## Raport VERIFY ## Progres executie 3.3a (lead)
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. > Sub-livrabila 3.3a (self-onboarding core). Toate stories GREEN, regresie 355 pass (de la 313 baseline).
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
- [x] **US-001** — schema `users` (+ `email_verified`, `is_admin` pregatire 3.3b) + `app/users.py` (scrypt n=2^14, plafon 128 char, hmac.compare_digest). 6 teste.
- [x] **US-002**`SessionMiddleware` (same_site=strict, https_only config) + `app/web/session.py` (`current_account`/`current_user_id`/`web_account`/`require_login``LoginRequired`/`set_session` clear-inainte C3). 6 teste.
- [x] **US-009**`app/web/csrf.py` (token per-sesiune, `verify_csrf` gateat pe MOD: prod sau sesiune autentificata) + `app/web/ratelimit.py` (fereastra glisanta in-proces) + handler `CsrfError`→403. Gate-ul inchide login/signup CSRF in prod (C2). 8 teste.
- [x] **US-003**`GET/POST /signup` (`auth_routes.py` + `signup.html`): tranzactie atomica C10, log `SIGNUP` C16, cheie-o-data + gate C15, banner pozitiv C17. 4 teste.
- [x] **US-004**`GET/POST /login` + `POST /logout`: mesaj generic la esec, login pe `active=0` intra (C18), clear sesiune C3. 4 teste.
- [x] **US-005** — dashboard scoped pe sesiune (`_status_counts`/`fragment_submissions`/`fragment_banner` cu regula NULL→1, C6) + banner "cont in asteptare" (US-008 AC#3, reframe C17). nomenclator ramane global. 4 teste.
- [x] **US-006a** — citiri import (`upload`/`mapare-coloane`/`preview`) pe `web_account(request)`; batch cross-cont inaccesibil.
- [x] **US-006b** — scrieri (`confirma` + `/mapari`) pe sesiune; propagare consecventa `account_id` la `build_key` (C8/OV-2); `verify_csrf` + camp ascuns pe toate formularele; zero atribuiri `DEFAULT_ACCOUNT_ID` in handlere (C6). 5 teste.
- [x] **US-008** — gate worker `claim_one` cu `LEFT JOIN accounts ... COALESCE(active,1)=1` (dublu-NULL C14): cont inactiv → submission ramane `queued`; activare → eligibil fara re-enqueue. 5 teste.
3.3b (US-007 self-service cheie/creds + US-010/011/012 admin web + email) ramane livrabila separata.
## Raport VERIFY (3.3a)
> Doua runde de verificare independenta (subagent context curat, §5.6).
**Runda 1 — FAIL (1 criteriu):** suita 355 pass, dar sweep-ul anti-leak a gasit `GET /_fragments/mapari`
nescoped: `pending_unmapped(conn)` fara `account_id` + fara `require_login` → expune `cod_op_service`/
`denumire` cross-account (Risc #2/C6). Specul US-005 enumerase doar `_status_counts`/`fragment_submissions`/
`fragment_banner` si omisese acest fragment. → inapoi la EXECUTE (task fix).
**Fix (task #7):** `fragment_mapari``require_login(request)`; `_render_mapari(account_id)`
`pending_unmapped(conn, account_id)`; `post_mapare` paseaza consecvent contul sesiunii. 2 teste noi de
izolare pe 2 conturi (`tests/test_mapari_scope.py`).
**Runda 2 — PASS global (subagent NOU):**
- Suita: **357 passed**, 0 fail.
- Sweep anti-leak complet (toate rutele `routes.py` + `auth_routes.py`): fiecare ruta care atinge
`submissions`/`import_batches`/`column_mappings`/`operations_mapping` e sub `require_login` SI scoped pe
contul sesiunii. Publice intentionat: `/signup`, `/login`, `/logout`, `/_fragments/nomenclator` (global),
`/_import/reset` (template gol, fara DB). `fragment_mapari` fix confirmat.
- Criterii securitate critice re-verificate in cod: CSRF enforce in prod pe `/login`+`/signup` fara
`account_id` (US-009/C2); signup tranzactie atomica cu ROLLBACK pe email duplicat, fara cont orfan
(US-003/C10); `claim_one` `COALESCE(a.active,1)=1` cu LEFT JOIN, `account_id` NULL=activ (US-008/C14);
parola scrypt, niciodata in clar (US-001).
- E2E HTTP mod prod (`web_auth_required=true`): `GET /_fragments/mapari` fara cookie → 303 `/login`;
signup → cheie `rfak_` o data + cont `active=0` + log `SIGNUP cont=N`; cu sesiune → 200 doar contul propriu.
- Regresia de aur: `test_import_e2e.py` + `test_api.py` = 26 pass. Send live RAR neverificat (fara creds/retea
in mediul de VERIFY), dar acoperit de teste.
**Verdict: PASS.** Send live la RAR test ramane de confirmat manual la deploy (canal API + import → `FINALIZATA`).
### Code-review (CLOSE, /code-review high) — 3 findings reparate
- **[HIGH] `csrf_token` lipsa pe re-randarile de eroare** (`routes.py`): ramurile de eroare din
`web_upload_import`/`web_save_mapare_coloane`/`web_confirma_import` randau formularul fara `csrf_token`
→ in prod (user logat, CSRF enforce) campul ascuns gol → urmatorul submit 403 (lockout dupa orice eroare).
Fix: helper `_ctx(request, **extra)` care include mereu `csrf_token` + conversia tuturor ramurilor;
`require_login` reordonat inaintea `verify_csrf`. Test nou de regresie in mod prod.
- **[MEDIUM] `verify_password` ignora `scrypt_params` stocat** (`users.py`): folosea constantele curente,
anuland migrarea de cost (C9) — un bump viitor de `n` ar fi blocat toti userii existenti. Fix:
`_parse_scrypt_params` + verify cu parametrii din DB (eticheta corupta → `None`, fara crash). Test de migrare cost.
- **[MEDIUM] login fara rate-limit** (`auth_routes.py`): brute-force parole + DoS CPU (scrypt/cerere).
Fix: `check_rate_limit("login:"+ip, login_rate_max=10, ...)` → 429. (Extinde C5 dincolo de signup.)
Suita finala: **361 passed, 0 fail.** Findings low/by-design neactionate (documentate): dev-fallback cont 1
cand `web_auth_required=False` (C12, intentionat — atentie ops la deploy prod), 500 rar la DB-locked in signup,
`request.client is None` → bucket rate-limit 'unknown' partajat.
## Progres executie 3.3b (lead)
> Sub-livrabila 3.3b (self-service cheie/creds + admin web + email). Decizii confirmate la poarta:
> primul cont care se inregistreaza devine admin (bootstrap automat); US-012 livrare DEGRADATA fara
> SMTP (helper `app/email.py` best-effort no-op + log `SIGNUP` deja existent din 3.3a).
Valuri (fisiere disjuncte intre stories paralele):
- **Val 1:** US-010 (`users.py`/`session.py`/`tools/account.py`/`main.py` handler) ‖ US-007 (`routes.py`/`_cont.html`/`dashboard.html`)
- **Val 2:** US-011 (`admin_routes.py` nou/`admin.html`/`main.py` register) ‖ US-012 (`email.py` nou/`config.py`/`auth_routes.py`)
- [x] **US-010** — rol admin (`is_admin`) + helper-e (`count_admins`/`set_admin`/`is_account_admin`/`list_admin_emails`) + `require_admin``AdminRequired`→403 + CLI `set-admin`. 13 teste. Bootstrap (primul cont=admin) cablat in signup de US-012 (evita conflict pe auth_routes.py).
- [x] **US-007** — sectiune "Contul meu" (`/_fragments/cont`): rotire cheie (afisata o data) + creds RAR pe ruta web proprie scoped pe sesiune (`POST /cont/roteste-cheie`, `POST /cont/rar-creds`, C13, NU endpointul API). 5 teste.
- [x] **US-011** — panou `/admin` (`admin_routes.py`): conturi in asteptare/active + activare/dezactivare (require_admin + CSRF + PRG); contul dev id=1 fara butoane. Link "Panou admin" pe dashboard doar pentru admini + buton logout. 5 + 2 teste.
- [x] **US-012**`app/email.py` `notify_signup` best-effort (no-op fara `smtp_host`, prinde orice exceptie SMTP, timeout 5s) + config `smtp_*` + cablaj signup: bootstrap admin (primul cont = admin via `count_admins==0`) + notificare degradata dupa `set_session`. 5 teste.
- [x] **Fix migrare (din VERIFY r1):** `_migrate` adauga defensiv `users.is_admin`/`email_verified` pe DB cu tabela `users` fara ele (idempotent, guard pe existenta tabelei). 2 teste.
## Raport VERIFY (3.3b)
> Doua runde de verificare independenta (subagent context curat, §5.6).
**Runda 1 — FAIL (1 criteriu):** suita 391 pass, toate criteriile US-007/010/011/012 confirmate + sweep
securitate complet (toate rutele noi `/cont/*`, `/_fragments/cont`, `/admin*` sub `require_login`/`require_admin`
+ `verify_csrf` pe POST; `/cont/*` scoped strict pe sesiune, nu accepta `account_id` din form; `/admin` nu
expune hash/chei/creds in clar). DAR `_migrate` nu adauga defensiv `users.is_admin`/`email_verified`
o tabela `users` fara ele ar ceda cu `OperationalError` (acelasi tip de gap ca C1 pe `accounts.active`). → fix.
**Fix:** bloc `# Coloane users` in `app/db.py::_migrate` (guard pe existenta tabelei + ALTER idempotent). 2 teste.
**Runda 2 — PASS global (subagent NOU):**
- Suita: **393 passed**, 0 fail.
- Fix migrare confirmat (test pe `users` minima fara coloane → `_migrate` → coloane prezente; idempotent).
- E2E mod prod (`web_auth_required=true`): `GET /admin` fara cookie → 303 `/login`; non-admin logat → 403;
`POST /admin/activate` fara CSRF → 403. Rute `/cont/*` scoped pe sesiune, CSRF enforce, parola RAR niciodata in `value=`.
- US-010 bootstrap (primul signup → `is_admin=1`, al doilea → 0), CLI `set-admin`, `require_admin`→403 confirmate.
- US-012 `notify_signup` best-effort no-op fara SMTP + nu blocheaza signup + log `SIGNUP` pastrat.
- Regresia de aur: `test_import_e2e` + `test_api` + `test_worker_active_gate` = 31 pass.
**Verdict: PASS.** Send live RAR ramane de confirmat manual la deploy (fara creds/retea in mediul VERIFY).
La deploy prod: `AUTOPASS_session_secret` persistent, `AUTOPASS_WEB_AUTH_REQUIRED=true`, optional `AUTOPASS_smtp_*`.

213
tests/test_admin_panel.py Normal file
View File

@@ -0,0 +1,213 @@
"""Teste US-011 (PRD 3.3b): panou admin web /admin — conturi in asteptare + activare.
TDD strict: testele se scriu INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
Fisiere testate: app/web/admin_routes.py, app/web/templates/admin.html.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture client (web_auth_required=true -> CSRF enforce)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
"""TestClient pe aplicatia completa, cu DB izolata si web_auth_required=true."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_panel.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
# Ridica limita rate-limit pentru signup ca testele nu se blocheze intre ele
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
# Curata hits-urile rate-limit intre teste
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Helper-e
# ---------------------------------------------------------------------------
def _get_csrf(client: TestClient, url: str) -> str:
"""Extrage csrf_token din pagina HTML."""
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, f"csrf_token negasit in {url}: {resp.text[:500]}"
return m.group(1)
def _signup(client: TestClient, name: str, email: str, password: str = "parola_test_001") -> int:
"""Creeaza cont via POST /signup si intoarce account_id."""
token = _get_csrf(client, "/signup")
resp = client.post("/signup", data={
"name": name,
"email": email,
"parola": password,
"csrf_token": token,
}, follow_redirects=True)
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
# Extrage account_id din raspuns (pagina afiseaza cheia rfak_ + account_id)
m = re.search(r"cont=(\d+)", resp.text)
if not m:
# fallback: citeste din DB
from app.db import get_connection
conn = get_connection()
row = conn.execute(
"SELECT account_id FROM users WHERE email=? COLLATE NOCASE", (email,)
).fetchone()
conn.close()
assert row, f"userul {email} nu a fost creat"
return int(row["account_id"])
return int(m.group(1))
def _login(client: TestClient, email: str, password: str = "parola_test_001") -> None:
"""Autentifica userul (seteaza sesiunea cookie)."""
token = _get_csrf(client, "/login")
resp = client.post("/login", data={
"email": email,
"parola": password,
"csrf_token": token,
}, follow_redirects=False)
assert resp.status_code == 303, f"login esuat cu {email}: {resp.status_code} {resp.text[:200]}"
def _make_admin(account_id: int) -> None:
"""Marcheaza contul ca admin direct in DB."""
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, account_id, is_admin=True)
conn.commit()
finally:
conn.close()
def _get_account_active(account_id: int) -> bool:
"""Citeste accounts.active din DB."""
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
return bool(row["active"]) if row else False
finally:
conn.close()
# ---------------------------------------------------------------------------
# Cazuri de test
# ---------------------------------------------------------------------------
def test_admin_vede_conturi_pending(client):
"""Admin logat -> GET /admin contine numele contului pending (active=0)."""
# Creeaza un cont pending (active=0 implicit la signup)
_signup(client, "Service Pending SRL", "pending@test.ro")
# Creeaza contul admin
admin_id = _signup(client, "Admin Corp SA", "admin@test.ro")
_make_admin(admin_id)
# Login ca admin
_login(client, "admin@test.ro")
resp = client.get("/admin")
assert resp.status_code == 200, f"GET /admin a returnat {resp.status_code}"
assert "Service Pending SRL" in resp.text, (
f"Contul pending nu apare in /admin. Raspuns: {resp.text[:600]}"
)
def test_activare_din_admin(client):
"""POST /admin/activate cu CSRF -> accounts.active=1; redirect 303."""
# Cont pending
pending_id = _signup(client, "Firma De Activat SRL", "firma@test.ro")
assert not _get_account_active(pending_id), "contul trebuie sa fie inactiv initial"
# Cont admin
admin_id = _signup(client, "Admin Activator SA", "activator@test.ro")
_make_admin(admin_id)
_login(client, "activator@test.ro")
# Obtine CSRF din pagina /admin
resp = client.get("/admin")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in /admin"
csrf = m.group(1)
resp2 = client.post("/admin/activate", data={
"account_id": str(pending_id),
"csrf_token": csrf,
})
assert resp2.status_code == 303, (
f"POST /admin/activate trebuia redirect 303, got {resp2.status_code}: {resp2.text[:300]}"
)
assert _get_account_active(pending_id), "contul trebuia sa fie activat dupa POST /admin/activate"
def test_non_admin_403(client):
"""User logat NON-admin -> GET /admin -> 403.
Creeaza intai un cont admin (bootstrap: primul user devine admin),
apoi un al doilea cont (non-admin) si verifica ca al doilea primeste 403.
"""
# Primul signup devine automat admin (bootstrap US-010)
_signup(client, "Admin Bootstrap SA", "bootstrap@test.ro")
# Al doilea user NU e admin
_signup(client, "User Simplu SRL", "user@test.ro")
_login(client, "user@test.ro")
resp = client.get("/admin")
assert resp.status_code == 403, (
f"User non-admin trebuia 403 pe /admin, got {resp.status_code}"
)
def test_admin_nelogat_redirect(client):
"""Fara sesiune -> GET /admin -> 303 redirect la /login."""
resp = client.get("/admin")
assert resp.status_code == 303, (
f"Nelogat pe /admin trebuia 303, got {resp.status_code}"
)
loc = resp.headers.get("location", "")
assert "/login" in loc, f"Redirect gresit: {loc}"
def test_activate_fara_csrf_403(client):
"""Admin logat, POST /admin/activate fara token CSRF -> 403."""
pending_id = _signup(client, "Firma Fara CSRF SRL", "nocsrf@test.ro")
admin_id = _signup(client, "Admin CSRF Test SA", "csrfadmin@test.ro")
_make_admin(admin_id)
_login(client, "csrfadmin@test.ro")
# POST fara token (sau token gol)
resp = client.post("/admin/activate", data={
"account_id": str(pending_id),
"csrf_token": "",
})
assert resp.status_code == 403, (
f"POST fara CSRF trebuia 403, got {resp.status_code}"
)

277
tests/test_admin_role.py Normal file
View File

@@ -0,0 +1,277 @@
"""Teste US-010 (PRD 3.3b): rol admin + bootstrap + guard require_admin.
Fisiere testate: app/users.py (helper-e admin), app/web/session.py (require_admin),
tools/account.py (subcomanda set-admin).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture DB
# ---------------------------------------------------------------------------
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_role.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
@pytest.fixture()
def account_id(conn):
"""Cont de test (nu default id=1)."""
from app.accounts import create_account
return create_account(conn, "Service Test Admin")
@pytest.fixture()
def user_id(conn, account_id):
"""User de test pe contul de test."""
from app.users import create_user
return create_user(conn, account_id, "admin_test@exemplu.ro", "parola_sigura_123")
@pytest.fixture()
def env_cli(monkeypatch):
"""Fixture pentru CLI: DB separata + clear settings cache."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_cli.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Helper app minimal pentru require_admin
# ---------------------------------------------------------------------------
def _make_app_admin() -> FastAPI:
"""App FastAPI minimal cu ruta protejata de require_admin."""
mini = FastAPI()
mini.add_middleware(
SessionMiddleware,
secret_key="test-secret-admin",
session_cookie="autopass_session",
https_only=False,
same_site="strict",
)
from app.web.session import LoginRequired, AdminRequired
@mini.exception_handler(LoginRequired)
async def login_required_handler(request: Request, exc: LoginRequired):
return JSONResponse(status_code=401, content={"detail": "neautentificat"})
@mini.exception_handler(AdminRequired)
async def admin_required_handler(request: Request, exc: AdminRequired):
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
@mini.get("/set-session")
def set_sess(request: Request, account_id: int = 1, user_id: int = 1):
from app.web.session import set_session
set_session(request, account_id, user_id)
return {"ok": True}
@mini.get("/admin-only")
def admin_only(request: Request):
from app.web.session import require_admin
aid = require_admin(request)
return {"account_id": aid}
return mini
# ---------------------------------------------------------------------------
# Teste helper-e app/users.py
# ---------------------------------------------------------------------------
def test_count_admins_initial_zero(conn):
"""Fara niciun user cu is_admin=1, count_admins returneaza 0."""
from app.users import count_admins
assert count_admins(conn) == 0
def test_set_admin_marcheaza_userii_contului(conn, account_id, user_id):
"""set_admin(conn, account_id) seteaza is_admin=1 pe toti userii contului."""
from app.users import set_admin, count_admins
assert count_admins(conn) == 0
set_admin(conn, account_id, is_admin=True)
assert count_admins(conn) == 1
def test_create_user_is_admin_flag(conn, account_id):
"""create_user cu is_admin=True seteaza coloana la 1."""
from app.users import create_user, count_admins
create_user(conn, account_id, "newadmin@exemplu.ro", "parola_sigura_456", is_admin=True)
assert count_admins(conn) == 1
def test_is_account_admin(conn, account_id, user_id):
"""is_account_admin returneaza False inainte si True dupa set_admin."""
from app.users import is_account_admin, set_admin
assert is_account_admin(conn, account_id) is False
set_admin(conn, account_id, is_admin=True)
assert is_account_admin(conn, account_id) is True
def test_list_admin_emails(conn, account_id):
"""list_admin_emails returneaza emailurile userilor cu is_admin=1."""
from app.users import create_user, set_admin, list_admin_emails
uid = create_user(conn, account_id, "admin1@exemplu.ro", "parola_sigura_789")
# Inainte de set_admin -> lista goala
assert list_admin_emails(conn) == []
set_admin(conn, account_id, is_admin=True)
emails = list_admin_emails(conn)
assert "admin1@exemplu.ro" in emails
def test_set_admin_cont_inexistent_valueerror(conn):
"""set_admin pe cont care nu exista ridica ValueError."""
from app.users import set_admin
with pytest.raises(ValueError, match="cont inexistent"):
set_admin(conn, 9999, is_admin=True)
def test_set_admin_cont_fara_users_silentios(conn):
"""set_admin pe cont existent fara useri e no-op silentios (nu ridica exceptie)."""
from app.accounts import create_account
from app.users import set_admin, count_admins
acct_fara_user = create_account(conn, "Cont Fara User")
# Nu trebuie sa ridice
set_admin(conn, acct_fara_user, is_admin=True)
assert count_admins(conn) == 0 # niciun user modificat
# ---------------------------------------------------------------------------
# Teste require_admin (app/web/session.py) prin TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def client_admin(monkeypatch):
"""TestClient pe app cu require_admin; DB cu cont admin + cont non-admin."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_require_admin.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
from app.accounts import create_account
from app.users import create_user, set_admin
# Cont admin (id=2)
acct_admin = create_account(conn, "Admin Corp")
uid_admin = create_user(conn, acct_admin, "admin@corp.ro", "parola_admin_001")
set_admin(conn, acct_admin, is_admin=True)
# Cont non-admin (id=3)
acct_user = create_account(conn, "User Corp")
uid_user = create_user(conn, acct_user, "user@corp.ro", "parola_user_001")
conn.close()
app = _make_app_admin()
with TestClient(app, follow_redirects=False) as c:
yield c, acct_admin, uid_admin, acct_user, uid_user
get_settings.cache_clear()
def test_require_admin_blocheaza_non_admin(client_admin):
"""User logat NON-admin pe ruta admin -> 403."""
client, acct_admin, uid_admin, acct_user, uid_user = client_admin
client.get(f"/set-session?account_id={acct_user}&user_id={uid_user}")
resp = client.get("/admin-only")
assert resp.status_code == 403
assert "admin" in resp.json()["detail"]
def test_require_admin_lasa_admin(client_admin):
"""User logat ADMIN pe ruta admin -> 200 cu account_id."""
client, acct_admin, uid_admin, acct_user, uid_user = client_admin
client.get(f"/set-session?account_id={acct_admin}&user_id={uid_admin}")
resp = client.get("/admin-only")
assert resp.status_code == 200
assert resp.json()["account_id"] == acct_admin
def test_require_admin_nelogat_ridica_login_required(client_admin):
"""Fara sesiune, require_admin ridica LoginRequired (-> 401 in app-ul nostru de test)."""
client, *_ = client_admin
resp = client.get("/admin-only")
# In app-ul de test, LoginRequired -> 401
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Teste CLI tools/account.py set-admin
# ---------------------------------------------------------------------------
def _run_account(argv):
from tools.account import main
return main(argv)
def test_cli_set_admin_marcheaza_contul(env_cli, capsys):
"""CLI set-admin --account N seteaza is_admin=1 pe userii contului."""
# Creeaza cont cu user
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user, is_account_admin
conn = get_connection()
acct_id = create_account(conn, "Service CLI Admin")
create_user(conn, acct_id, "cli_admin@corp.ro", "parola_cli_admin_1")
conn.close()
rc = _run_account(["set-admin", "--account", str(acct_id)])
out = capsys.readouterr().out
assert rc == 0
assert str(acct_id) in out or "admin" in out.lower()
conn2 = get_connection()
assert is_account_admin(conn2, acct_id) is True
conn2.close()
def test_cli_set_admin_remove(env_cli, capsys):
"""CLI set-admin --account N --remove scoate adminul."""
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user, set_admin, is_account_admin
conn = get_connection()
acct_id = create_account(conn, "Service CLI Remove")
create_user(conn, acct_id, "remove@corp.ro", "parola_remove_001")
set_admin(conn, acct_id, is_admin=True)
conn.close()
rc = _run_account(["set-admin", "--account", str(acct_id), "--remove"])
assert rc == 0
conn2 = get_connection()
assert is_account_admin(conn2, acct_id) is False
conn2.close()
def test_cli_set_admin_cont_inexistent_exit_2(env_cli, capsys):
"""CLI set-admin pe cont inexistent -> exit code 2 + mesaj pe stderr."""
rc = _run_account(["set-admin", "--account", "9999"])
err = capsys.readouterr().err
assert rc == 2
assert "inexistent" in err or "eroare" in err.lower()

View File

@@ -0,0 +1,80 @@
"""Teste task #8: csrf_token prezent in TOATE raspunsurile de formular (inclusiv ramuri eroare).
Lockout real in prod (web_auth_required=True, sesiune logata):
eroare la confirma/upload re-randeaza template FARA csrf_token in context →
campul {{ csrf_token }} devine gol → urmatorul submit trimite token gol → CsrfError 403.
TDD: RED pe codul actual (token gol in eroare), GREEN dupa fix.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
def _extract_csrf(html: str) -> str:
m = re.search(r'name="csrf_token" value="([^"]*)"', html)
return m.group(1) if m else ""
@pytest.fixture()
def prod_client(monkeypatch):
"""Client cu web_auth_required=True + require_login monkeypatched."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "csrf_err.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
from app.accounts import create_account
conn = get_connection()
acct = create_account(conn, "Cont Test CSRF Erori")
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
yield c, conn, acct
conn.close()
get_settings.cache_clear()
def test_confirma_batch_inexistent_contine_csrf(prod_client):
"""POST confirma cu batch inexistent re-randeaza _upload.html cu csrf_token negol.
Flux:
1. GET / -> initializeaza csrf_token in sesiune, extragem token
2. POST /_import/99999/confirma cu token valid -> batch nu exista -> _upload.html
3. Verificam ca _upload.html din eroare contine csrf_token negol
4. POST cu tokenul din eroare -> NU 403
"""
client, conn, acct = prod_client
# 1. GET dashboard: initializeaza CSRF token in sesiune
r_get = client.get("/")
assert r_get.status_code == 200
csrf = _extract_csrf(r_get.text)
assert csrf, "Dashboard nu a returnat csrf_token — problema de setup"
# 2. POST confirma cu batch inexistent (ID 99999 cu siguranta nu exista)
r_err = client.post(
"/_import/99999/confirma",
data={"n_confirmat": "1", "csrf_token": csrf},
)
assert r_err.status_code == 200
assert "inexistent" in r_err.text.lower() or "expirat" in r_err.text.lower()
# 3. csrf_token in raspunsul de eroare (_upload.html) trebuie sa fie NEGOL
csrf_in_error = _extract_csrf(r_err.text)
assert csrf_in_error, "csrf_token gol in _upload.html de eroare — lockout garantat in prod!"
# 4. Urmatorul POST cu token din eroare -> NU 403
r_retry = client.post(
"/_import/99999/confirma",
data={"n_confirmat": "1", "csrf_token": csrf_in_error},
)
assert r_retry.status_code != 403, f"CsrfError 403 dupa eroare — lockout confirmat! Status: {r_retry.status_code}"
assert r_retry.status_code == 200

View File

@@ -15,6 +15,9 @@ from fastapi.testclient import TestClient
def client(monkeypatch): def client(monkeypatch):
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
# Comportament in mod dev (fallback cont 1, fara login/CSRF); auth web e
# default ON in prod — testat separat in test_web_*.
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings from app.config import get_settings
get_settings.cache_clear() get_settings.cache_clear()

View File

@@ -0,0 +1,55 @@
"""Test US-011 (discoverability): linkul 'Panou admin' apare pe dashboard doar pentru admini.
Completeaza intentia US-011 — adminul trebuie sa poata descoperi /admin din UI, nu doar
prin URL direct.
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
conn = get_connection()
yield c, conn
conn.close()
get_settings.cache_clear()
def _account_with_user(conn, name, *, is_admin):
from app.accounts import create_account
from app.users import create_user
acct = create_account(conn, name)
email = f"{name.replace(' ', '').lower()}@test.ro"
create_user(conn, acct, email, "parolaSuperSecreta", is_admin=is_admin)
return acct
def test_admin_vede_link_panou_admin(env, monkeypatch):
client, conn = env
acct = _account_with_user(conn, "Admin Co", is_admin=True)
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
r = client.get("/")
assert r.status_code == 200
assert 'href="/admin"' in r.text
def test_non_admin_nu_vede_link(env, monkeypatch):
client, conn = env
acct = _account_with_user(conn, "Service Normal", is_admin=False)
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
r = client.get("/")
assert r.status_code == 200
assert 'href="/admin"' not in r.text

View File

@@ -0,0 +1,125 @@
"""Teste US-005 (PRD 3.3): scoping dashboard pe sesiune (2 conturi, citiri).
Comportamental (C6): nu grep, ci verificare reala cu 2 conturi + date distincte.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
"""DB temporar + app principal."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
conn = get_connection()
yield c, conn
conn.close()
get_settings.cache_clear()
def _make_account(conn, name, active=True):
from app.accounts import create_account
return create_account(conn, name, active=active)
def _insert_submission(conn, account_id, vin="WVWZZZ1KZAW000001", status="queued"):
key = f"key_{account_id}_{vin}_{status}"
payload = json.dumps({"vin": vin, "nr_inmatriculare": "B001TST",
"data_prestatie": "2026-06-01", "odometru_final": "100",
"prestatii": [{"cod_prestatie": "OE-1"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(key, account_id, status, payload),
)
def test_counts_doar_contul_sesiunii(env, monkeypatch):
"""_status_counts scoped: contul A vede doar ale lui, nu ale lui B."""
client, conn = env
acct_a = _make_account(conn, "Cont A")
acct_b = _make_account(conn, "Cont B")
_insert_submission(conn, acct_a, vin="AAAAAAAAAAAA00001")
_insert_submission(conn, acct_a, vin="AAAAAAAAAAAA00002")
_insert_submission(conn, acct_b, vin="BBBBBBBBBBBB00001")
# Contul A vede 2 submissions
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
r = client.get("/")
assert r.status_code == 200
assert "2" in r.text # 2 queued pentru A
# Contul B vede 1 submission
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
r = client.get("/")
assert r.status_code == 200
def test_submissions_fragment_scoped(env, monkeypatch):
"""/_fragments/submissions arata doar submission-urile contului din sesiune.
VIN-ul e in payload_json (nu in HTML), asa ca testam dupa r.id din template.
"""
client, conn = env
acct_a = _make_account(conn, "Cont A2")
acct_b = _make_account(conn, "Cont B2")
_insert_submission(conn, acct_a, vin="AAONLY000000000VIN")
_insert_submission(conn, acct_b, vin="BBONLY000000000VIN")
sub_a = conn.execute("SELECT id FROM submissions WHERE account_id=?", (acct_a,)).fetchone()["id"]
sub_b = conn.execute("SELECT id FROM submissions WHERE account_id=?", (acct_b,)).fetchone()["id"]
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
r = client.get("/_fragments/submissions")
assert r.status_code == 200
assert f"<td>{sub_a}</td>" in r.text
assert f"<td>{sub_b}</td>" not in r.text
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
r = client.get("/_fragments/submissions")
assert r.status_code == 200
assert f"<td>{sub_b}</td>" in r.text
assert f"<td>{sub_a}</td>" not in r.text
def test_nelogat_redirect(monkeypatch):
"""web_auth_required=True + fara sesiune -> 303 redirect /login."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_auth.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
r = c.get("/")
assert r.status_code == 303
assert "/login" in r.headers.get("location", "")
get_settings.cache_clear()
def test_banner_cont_in_asteptare(env, monkeypatch):
"""Contul cu active=0 vede banner 'in asteptare'; contul activ nu il vede."""
client, conn = env
acct_inactiv = _make_account(conn, "Cont Inactiv", active=False)
acct_activ = _make_account(conn, "Cont Activ", active=True)
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_inactiv)
r = client.get("/_fragments/banner")
assert r.status_code == 200
assert "asteptare" in r.text.lower() or "activare" in r.text.lower()
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_activ)
r = client.get("/_fragments/banner")
assert r.status_code == 200
assert "asteptare" not in r.text.lower() or "activare" not in r.text.lower()

View File

@@ -0,0 +1,128 @@
"""Teste scope cont pe GET /v1/audit/export (US-003, PRD 3.2)."""
from __future__ import annotations
import csv
import io
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
yield monkeypatch
get_settings.cache_clear()
def _client():
from app.main import app
return TestClient(app)
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 _csv_vins(content: bytes) -> list[str]:
reader = csv.DictReader(io.StringIO(content.decode("utf-8")))
return [r["vin"] for r in reader if r.get("vin")]
def test_export_doar_contul_cheii(env):
"""Exportul CSV contine doar randurile contului asociat cheii."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:
conn.close()
c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
# Marcheaza ca sent pentru ca audit/export default e status=sent
conn2 = get_connection()
try:
conn2.execute("UPDATE submissions SET status='sent'")
finally:
conn2.close()
resp1 = c.get("/v1/audit/export", headers={"X-API-Key": k1})
assert resp1.status_code == 200
vins1 = _csv_vins(resp1.content)
assert "WVWZZZ1KZAW000123" in vins1
assert "WVWZZZ1KZAW000456" not in vins1
resp2 = c.get("/v1/audit/export", headers={"X-API-Key": k2})
vins2 = _csv_vins(resp2.content)
assert "WVWZZZ1KZAW000456" in vins2
assert "WVWZZZ1KZAW000123" not in vins2
def test_export_legacy_null_pentru_cont_1(env):
"""Randuri cu account_id=NULL apartin contului 1 in exportul de audit; contul 2 nu le vede."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
payload = json.dumps({"vin": "LEGACYVIN12345678", "prestatii": []})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('legacy_audit_key', NULL, 'sent', ?)", (payload,)
)
finally:
conn.close()
resp1 = c.get("/v1/audit/export", headers={"X-API-Key": k1})
vins1 = _csv_vins(resp1.content)
assert "LEGACYVIN12345678" in vins1
resp2 = c.get("/v1/audit/export", headers={"X-API-Key": k2})
vins2 = _csv_vins(resp2.content)
assert "LEGACYVIN12345678" not in vins2
def test_export_status_all_tot_scoped(env):
"""status=all ramane scoped pe cont (nu exporta global)."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:
conn.close()
c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
resp1 = c.get("/v1/audit/export?status=all", headers={"X-API-Key": k1})
vins1 = _csv_vins(resp1.content)
assert "WVWZZZ1KZAW000123" in vins1
assert "WVWZZZ1KZAW000456" not in vins1

View File

@@ -0,0 +1,194 @@
"""Teste scope cont pe GET /v1/mapari + /pending (US-002, PRD 3.2).
TD-3.2 (decis la poarta): parametrul ?account_id= din query se pastreaza DAR:
- daca e prezent SI difera de contul cheii -> 400 explicit
- daca e prezent si egal -> ok
- daca lipseste -> contul cheii
Contul efectiv vine MEREU din cheie (nespoofabil).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
yield monkeypatch
get_settings.cache_clear()
def _client():
from app.main import app
return TestClient(app)
def test_mapari_doar_contul_cheii(env):
"""Cheia A vede doar maparile contului A; cheia B nu vede maparile lui A."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('OE-1', 'test')"
)
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, 'OP_A', 'OE-1', 1)"
)
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (2, 'OP_B', 'OE-1', 1)"
)
finally:
conn.close()
mapari1 = c.get("/v1/mapari", headers={"X-API-Key": k1}).json()["mapari"]
ops1 = [m["cod_op_service"] for m in mapari1]
assert "OP_A" in ops1
assert "OP_B" not in ops1
mapari2 = c.get("/v1/mapari", headers={"X-API-Key": k2}).json()["mapari"]
ops2 = [m["cod_op_service"] for m in mapari2]
assert "OP_B" in ops2
assert "OP_A" not in ops2
def test_mapari_query_account_id_diferit_400(env):
"""Daca ?account_id difera de contul cheii -> 400 explicit (TD-3.2)."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
finally:
conn.close()
resp = c.get("/v1/mapari?account_id=2", headers={"X-API-Key": k1})
assert resp.status_code == 400
def test_mapari_query_account_id_egal_ok(env):
"""Daca ?account_id egal cu contul cheii -> 200 (TD-3.2)."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
k1 = create_api_key(conn, 1)
finally:
conn.close()
resp = c.get("/v1/mapari?account_id=1", headers={"X-API-Key": k1})
assert resp.status_code == 200
def test_pending_doar_contul_cheii(env):
"""GET /v1/mapari/pending cu cheia A returneaza doar operatiile contului A."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_A", "denumire": "Reparatie"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('pm_key1', 1, 'needs_mapping', ?)", (payload1,)
)
payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_B", "denumire": "Vopsire"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('pm_key2', 2, 'needs_mapping', ?)", (payload2,)
)
finally:
conn.close()
pending1 = c.get("/v1/mapari/pending", headers={"X-API-Key": k1}).json()["pending"]
ops1 = [p["cod_op_service"] for p in pending1]
assert "OP_A" in ops1
assert "OP_B" not in ops1
pending2 = c.get("/v1/mapari/pending", headers={"X-API-Key": k2}).json()["pending"]
ops2 = [p["cod_op_service"] for p in pending2]
assert "OP_B" in ops2
assert "OP_A" not in ops2
def test_pending_web_global_neschimbat(env):
"""pending_unmapped(conn) fara argument returneaza global (back-compat pentru web/routes.py)."""
with _client() as c:
from app.db import get_connection
from app.mapping import pending_unmapped
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_A", "denumire": "Reparatie"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('pgn_key1', 1, 'needs_mapping', ?)", (payload1,)
)
payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_B", "denumire": "Vopsire"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('pgn_key2', 2, 'needs_mapping', ?)", (payload2,)
)
# Apel fara argument -> global (ambele conturi)
result = pending_unmapped(conn)
ops = [p["cod_op_service"] for p in result]
assert "OP_A" in ops
assert "OP_B" in ops
finally:
conn.close()
def test_pending_filtreaza_in_sql_cu_regula_null(env):
"""B1: pending_unmapped(conn, account_id=1) include si randuri cu account_id=NULL (legacy).
Filtrarea trebuie sa se faca IN SQL cu:
WHERE status='needs_mapping' AND (account_id=? OR (account_id IS NULL AND ?=1))
Nu post-hoc in Python.
"""
with _client() as c:
from app.db import get_connection
from app.mapping import pending_unmapped
conn = get_connection()
try:
payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_EXPLICIT", "denumire": "X"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('pfn_key1', 1, 'needs_mapping', ?)", (payload1,)
)
payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_NULL", "denumire": "Y"}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('pfn_key2', NULL, 'needs_mapping', ?)", (payload2,)
)
# Cu account_id=1 -> vede si randul legacy (NULL -> cont 1)
result = pending_unmapped(conn, account_id=1)
ops = [p["cod_op_service"] for p in result]
assert "OP_EXPLICIT" in ops
assert "OP_NULL" in ops
# Cu account_id=2 -> nu vede nimic (nu are pending submissions)
result2 = pending_unmapped(conn, account_id=2)
assert result2 == []
finally:
conn.close()

View File

@@ -0,0 +1,169 @@
"""Teste scope cont pe GET /v1/prezentari + /{id} (US-001, PRD 3.2).
Metoda TDD: testele se scriu inainte de implementare (RED) si trebuie sa ramana
verzi dupa implementare (GREEN).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
yield monkeypatch
get_settings.cache_clear()
def _client():
from app.main import app
return TestClient(app)
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 test_lista_doar_contul_cheii(env):
"""Cheia A vede doar submission-urile contului A; cheia B nu vede submission-urile lui A."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:
conn.close()
r1 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
assert r1.status_code == 200
sid1 = r1.json()["results"][0]["submission_id"]
r2 = c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
assert r2.status_code == 200
sid2 = r2.json()["results"][0]["submission_id"]
lista1 = c.get("/v1/prezentari", headers={"X-API-Key": k1}).json()["submissions"]
ids1 = [s["id"] for s in lista1]
assert sid1 in ids1
assert sid2 not in ids1
lista2 = c.get("/v1/prezentari", headers={"X-API-Key": k2}).json()["submissions"]
ids2 = [s["id"] for s in lista2]
assert sid2 in ids2
assert sid1 not in ids2
def test_detaliu_cross_account_404(env):
"""GET /{id} cu cheia contului B pentru submission-ul contului A -> 404.
B3: detail-ul 404 cross-account trebuie byte-identic cu cel al unui id inexistent
(acelasi status + acelasi mesaj) — nu dam indicii ca randul exista.
"""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:
conn.close()
r = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
sid1 = r.json()["results"][0]["submission_id"]
cross = c.get(f"/v1/prezentari/{sid1}", headers={"X-API-Key": k2})
nonexist = c.get("/v1/prezentari/99999", headers={"X-API-Key": k2})
assert cross.status_code == 404
assert nonexist.status_code == 404
assert cross.json()["detail"] == nonexist.json()["detail"] == "submission inexistent"
def test_legacy_null_vizibil_pentru_cont_1(env):
"""Randuri cu account_id=NULL apartin contului 1 (legacy OV-2); contul 2 nu le vede."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES ('legacy_null_key', NULL, 'queued', '{}')"
)
finally:
conn.close()
lista1 = c.get("/v1/prezentari", headers={"X-API-Key": k1}).json()["submissions"]
lista2 = c.get("/v1/prezentari", headers={"X-API-Key": k2}).json()["submissions"]
assert len(lista1) >= 1
assert len(lista2) == 0
def test_fara_cheie_flag_off_vede_contul_1(env):
"""Fara cheie cu AUTOPASS_REQUIRE_API_KEY=false -> cont implicit (id=1, back-compat dev)."""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'alt')")
k2 = create_api_key(conn, 2)
finally:
conn.close()
# Submission pentru contul 1 (fara cheie, flag off -> cont implicit)
c.post("/v1/prezentari", json=_body())
# Submission pentru contul 2
c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
# Fara cheie -> vede DOAR contul 1 (1 submission)
lista = c.get("/v1/prezentari").json()["submissions"]
assert len(lista) == 1
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).
"""
with _client() as c:
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
k1 = create_api_key(conn, 1)
finally:
conn.close()
r = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
sid = r.json()["results"][0]["submission_id"]
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"):
assert field not in data, f"camp sensibil expus: {field}"

View File

@@ -26,6 +26,9 @@ import pytest
def client(monkeypatch): def client(monkeypatch):
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
# Comportament in mod dev (fallback cont 1, fara login/CSRF); auth web e
# default ON in prod — testat separat in test_web_*.
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings from app.config import get_settings
get_settings.cache_clear() get_settings.cache_clear()

View File

@@ -0,0 +1,201 @@
"""Teste US-006a/b (PRD 3.3): scoping import web pe sesiune.
US-006a: citiri (upload, preview, mapare-coloane) pe contul sesiunii.
US-006b: scrieri (confirma) pe contul sesiunii; alt cont -> inaccesibil.
C8/OV-2: aceeasi cheie idempotenta prin API si web pe acelasi cont.
"""
from __future__ import annotations
import io
import re
import os
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
def _make_xlsx(rows: list[dict]) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
if rows:
ws.append(list(rows[0].keys()))
for r in rows:
ws.append(list(r.values()))
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
_ROWS = [
{"vin": "WVWZZZ1KZAW111111", "nr_inmatriculare": "B111TST",
"data_prestatie": "2026-06-01", "odometru_final": "10000",
"cod_prestatie": "OE-1"},
]
def _csrf_from(html: str) -> str:
"""Extrage tokenul CSRF din HTML (hidden input)."""
m = re.search(r'name="csrf_token" value="([^"]*)"', html)
return m.group(1) if m else ""
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.db"))
# Scoping testat prin monkeypatch require_login pe acct_a/acct_b; rulam in mod
# dev (CSRF skip fara sesiune) — auth web e default ON in prod, testat in test_web_*.
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
conn = get_connection()
from app.accounts import create_account
acct_a = create_account(conn, "Cont A Scope", active=True)
acct_b = create_account(conn, "Cont B Scope", active=True)
yield c, conn, acct_a, acct_b
conn.close()
get_settings.cache_clear()
def _setup_op_mapping(conn, account_id):
"""Configureaza maparea operatie cod_op=OE-1 -> cod_prestatie=OE-1 pt. cont."""
conn.execute(
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
("OE-1", "Operatii electrice"),
)
conn.execute(
"INSERT OR REPLACE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (?, ?, ?, ?)",
(account_id, "OE-1", "OE-1", 1),
)
def _mapare_coloane(c, import_id, csrf_token: str = ""):
"""Salveaza maparea de coloane; mapeaza cod_prestatie -> operatie (canonical).
cod_prestatie din xlsx trebuie mapat la 'operatie' (nu la 'cod_prestatie' care nu
e camp canonic). resolve_prestatii il rezolva din operations_mapping.
"""
return c.post(
f"/_import/{import_id}/mapare-coloane",
data={
"csrf_token": csrf_token,
"colname": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "cod_prestatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
},
)
def test_upload_pe_contul_sesiunii(env, monkeypatch):
"""Upload creeaza batch pe contul din sesiune (nu DEFAULT_ACCOUNT_ID)."""
client, conn, acct_a, acct_b = env
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
r = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
assert r.status_code == 200
batch = conn.execute("SELECT id, account_id FROM import_batches").fetchone()
assert batch is not None
assert batch["account_id"] == acct_a
def test_batch_alt_cont_inaccesibil(env, monkeypatch):
"""Batch-ul contului A nu e accesibil din sesiunea contului B (preview -> eroare)."""
client, conn, acct_a, acct_b = env
# Upload ca A (sesiune curata, fara csrf_token anterior)
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
# Preview ca B (GET, fara CSRF) -> trebuie eroare/inaccesibil
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
r = client.get(f"/_import/{batch_id}/preview")
assert r.status_code == 200
assert "inexistent" in r.text.lower() or "inaccesibil" in r.text.lower()
def test_commit_creeaza_submissions_pe_cont(env, monkeypatch):
"""Confirma creeaza submissions cu account_id-ul sesiunii."""
client, conn, acct_a, acct_b = env
_setup_op_mapping(conn, acct_a)
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
# Upload — raspunsul contine csrf_token in form (sesiunea l-a creat)
r_upload = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
csrf = _csrf_from(r_upload.text)
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
# Mapare coloane cu tokenul din upload
r_map = _mapare_coloane(client, batch_id, csrf)
csrf = _csrf_from(r_map.text) or csrf # tokenul din preview (stabil per sesiune)
# Confirma cu tokenul sesiunii
r = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert r.status_code == 200
sub = conn.execute("SELECT account_id FROM submissions").fetchone()
assert sub is not None
assert sub["account_id"] == acct_a
def test_cheie_identica_api_vs_web_acelasi_cont(env, monkeypatch):
"""C8/OV-2: import web si API pe acelasi cont produc aceeasi cheie idempotenta."""
from app.idempotency import build_key, canonicalize_row
client, conn, acct_a, acct_b = env
_setup_op_mapping(conn, acct_a)
row = {
"vin": "WVWZZZ1KZAW999999",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "99999",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
canon = canonicalize_row(row)
key_api = build_key(acct_a, canon)
# Upload web pe acelasi cont
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
web_row = {
"vin": "WVWZZZ1KZAW999999",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "99999",
"cod_prestatie": "OE-1",
}
r_up = client.post("/_import/upload", files={"file": ("w.xlsx", _make_xlsx([web_row]), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
csrf = _csrf_from(r_up.text)
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
r_map = _mapare_coloane(client, batch_id, csrf)
csrf = _csrf_from(r_map.text) or csrf
client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
sub = conn.execute("SELECT idempotency_key FROM submissions WHERE account_id=?", (acct_a,)).fetchone()
assert sub is not None
assert sub["idempotency_key"] == key_api
def test_confirma_alt_cont_inaccesibil(env, monkeypatch):
"""Confirma batch-ul contului A din sesiunea B -> eroare batch inexistent."""
client, conn, acct_a, acct_b = env
_setup_op_mapping(conn, acct_a)
# Upload + mapare ca A (cu CSRF tokens corecti)
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
r_up = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
csrf = _csrf_from(r_up.text)
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
r_map = _mapare_coloane(client, batch_id, csrf)
csrf = _csrf_from(r_map.text) or csrf
# Confirma ca B cu tokenul din sesiune (acelasi cookie jar, token valid CSRF)
# dar batch apartine lui A -> "inexistent sau expirat"
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
r = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert r.status_code == 200
assert "inexistent" in r.text.lower() or "inaccesibil" in r.text.lower() or "expirat" in r.text.lower()

View File

@@ -0,0 +1,77 @@
"""Teste C6: /_fragments/mapari scoped pe sesiune (task #7 fix leak cross-account).
TDD: testele confirma mai intai ca leak-ul exista (RED), apoi fix-ul il inchide (GREEN).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
conn = get_connection()
from app.accounts import create_account
acct_a = create_account(conn, "Cont A Mapari")
acct_b = create_account(conn, "Cont B Mapari")
yield c, conn, acct_a, acct_b
conn.close()
get_settings.cache_clear()
def _insert_needs_mapping(conn, account_id, cod_op):
payload = json.dumps({"vin": "VIN001", "nr_inmatriculare": "B01TST",
"data_prestatie": "2026-06-01", "odometru_final": "1000",
"prestatii": [{"cod_op_service": cod_op, "denumire": cod_op}]})
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'needs_mapping', ?)",
(f"key_{account_id}_{cod_op}", account_id, payload),
)
def test_fragment_mapari_scoped_pe_cont(env, monkeypatch):
"""/_fragments/mapari arata doar op-urile contului din sesiune, nu ale altuia."""
client, conn, acct_a, acct_b = env
_insert_needs_mapping(conn, acct_a, "OP-DOAR-A")
_insert_needs_mapping(conn, acct_b, "OP-DOAR-B")
import app.web.routes as routes
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
r = client.get("/_fragments/mapari")
assert r.status_code == 200
assert "OP-DOAR-A" in r.text
assert "OP-DOAR-B" not in r.text
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
r = client.get("/_fragments/mapari")
assert r.status_code == 200
assert "OP-DOAR-B" in r.text
assert "OP-DOAR-A" not in r.text
def test_fragment_mapari_nelogat_redirect(monkeypatch):
"""web_auth_required=True + fara sesiune -> 303 /login."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_auth.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
r = c.get("/_fragments/mapari")
assert r.status_code == 303
assert "/login" in r.headers.get("location", "")
get_settings.cache_clear()

124
tests/test_migrate_users.py Normal file
View File

@@ -0,0 +1,124 @@
"""Teste migrare defensiva coloane users (is_admin, email_verified).
TDD: RED -> implementare in _migrate -> GREEN.
"""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app.db import _migrate, init_db, get_connection
from app.config import get_settings
# ---------------------------------------------------------------------------
# Test 1: _migrate adauga is_admin si email_verified pe o tabela users minima
# ---------------------------------------------------------------------------
def test_migrate_adauga_is_admin_pe_users_veche() -> None:
"""Pe un DB cu tabela users creata fara is_admin/email_verified,
_migrate trebuie sa adauge ambele coloane."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = Path(f.name)
conn = sqlite3.connect(str(db_path), isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = OFF") # evitam FK checks pe schema minima
# Tabela accounts minima (necesara pentru FK la users).
# Trebuie sa aiba cui + rar_creds_enc + active ca _migrate sa nu crape
# pe ALTER/CREATE INDEX care le refera inainte de blocul users.
conn.execute("""
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cui TEXT,
active INTEGER NOT NULL DEFAULT 1,
rar_creds_enc TEXT
)
""")
conn.execute("INSERT INTO accounts (id, name) VALUES (1, 'default')")
# Tabela submissions minima (necesara pentru _migrate -- coloane submissions)
conn.execute("""
CREATE TABLE submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
payload_json TEXT NOT NULL,
rar_creds_enc TEXT,
rar_status_code INTEGER,
rar_error TEXT,
id_prezentare INTEGER,
retry_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
sending_since TEXT,
purge_after TEXT,
batch_id INTEGER,
row_index INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# Tabela users MINIMA: fara is_admin, fara email_verified
conn.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
scrypt_params TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
try:
# Rulam migrarea
_migrate(conn)
# Verificam ca ambele coloane au fost adaugate
cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
assert "is_admin" in cols, f"is_admin lipseste dupa migrare; coloane prezente: {cols}"
assert "email_verified" in cols, f"email_verified lipseste dupa migrare; coloane prezente: {cols}"
finally:
conn.close()
db_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Test 2: _migrate este idempotent pe un DB initializat normal cu init_db()
# ---------------------------------------------------------------------------
def test_migrate_idempotent_pe_users_curenta(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
"""Pe un DB initializat normal, re-apelarea _migrate nu ridica exceptie
si coloanele is_admin/email_verified raman prezente."""
db_file = tmp_path / "test_idem.db"
monkeypatch.setenv("AUTOPASS_DB_PATH", str(db_file))
# Resetam settings-ul cached ca sa preia noul AUTOPASS_DB_PATH
get_settings.cache_clear() # type: ignore[attr-defined]
try:
# Initializare normala
init_db()
# Re-apelam _migrate direct (idempotenta)
conn = get_connection()
try:
_migrate(conn) # nu trebuie sa ridice
cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
assert "is_admin" in cols
assert "email_verified" in cols
finally:
conn.close()
finally:
get_settings.cache_clear() # type: ignore[attr-defined]

181
tests/test_signup_notify.py Normal file
View File

@@ -0,0 +1,181 @@
"""Teste US-012 (PRD 3.3b): notificare email admin la signup (degradat) + bootstrap admin.
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture client (pattern identic cu test_web_signup.py)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
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) as c:
yield c
get_settings.cache_clear()
def _csrf(c: TestClient) -> str:
"""Obtine un token CSRF proaspat de la GET /signup."""
import re
resp = c.get("/signup")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in HTML"
return m.group(1)
def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecreta") -> object:
token = _csrf(c)
return c.post("/signup", data={
"name": name,
"email": email,
"parola": parola,
"csrf_token": token,
})
# ---------------------------------------------------------------------------
# Teste notify_signup (unitare, fara TestClient)
# ---------------------------------------------------------------------------
def test_notify_noop_fara_smtp(monkeypatch):
"""smtp_host None -> notify_signup nu ridica si nu incearca sa trimita."""
import smtplib
# Daca smtplib.SMTP e apelat, testul pica
def fake_smtp(*a, **kw):
raise AssertionError("smtplib.SMTP nu trebuia apelat fara smtp_host configurat")
monkeypatch.setattr(smtplib, "SMTP", fake_smtp)
# Asigura smtp_host = None
from app.config import get_settings
get_settings.cache_clear()
monkeypatch.setenv("AUTOPASS_SMTP_HOST", "") # string gol -> None in Settings
get_settings.cache_clear()
from app.email import notify_signup
# Nu trebuie sa ridice
notify_signup(["admin@test.com"], account_id=1, email="nou@test.com")
get_settings.cache_clear()
def test_notify_nu_blocheaza_la_eroare(monkeypatch):
"""smtp_host setat, SMTP ridica exceptie -> notify_signup returneaza normal (best-effort)."""
import smtplib
class FakeSMTP:
def __init__(self, *a, **kw):
raise ConnectionRefusedError("simulam eroare retea")
monkeypatch.setattr(smtplib, "SMTP", FakeSMTP)
monkeypatch.setenv("AUTOPASS_SMTP_HOST", "smtp.test.local")
monkeypatch.setenv("AUTOPASS_SMTP_PORT", "587")
monkeypatch.setenv("AUTOPASS_SMTP_FROM", "autopass@test.local")
from app.config import get_settings
get_settings.cache_clear()
from app.email import notify_signup
# Nu trebuie sa ridice, chiar daca SMTP esueaza
notify_signup(["admin@test.com"], account_id=5, email="nou@test.com")
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Teste bootstrap admin (prin TestClient)
# ---------------------------------------------------------------------------
def test_primul_signup_devine_admin(client):
"""Primul signup -> userul are is_admin=1 (count_admins==1).
Al doilea signup cu alt email -> is_admin=0 (count_admins ramane 1)."""
# Primul signup
resp = _do_signup(client, "Primul Service", "primul@test.com")
assert resp.status_code == 200
assert "rfak_" in resp.text
from app.db import get_connection
from app.users import count_admins
conn = get_connection()
try:
n_admins = count_admins(conn)
assert n_admins == 1, f"Dupa primul signup, count_admins trebuie sa fie 1, nu {n_admins}"
user = conn.execute(
"SELECT is_admin FROM users WHERE email='primul@test.com'"
).fetchone()
assert user is not None
assert user["is_admin"] == 1, "Primul user trebuie sa fie admin"
finally:
conn.close()
# Al doilea signup
resp2 = _do_signup(client, "Al Doilea Service", "aldoilea@test.com")
assert resp2.status_code == 200
assert "rfak_" in resp2.text
conn = get_connection()
try:
n_admins = count_admins(conn)
assert n_admins == 1, f"Dupa al doilea signup, count_admins trebuie sa ramana 1, nu {n_admins}"
user2 = conn.execute(
"SELECT is_admin FROM users WHERE email='aldoilea@test.com'"
).fetchone()
assert user2 is not None
assert user2["is_admin"] == 0, "Al doilea user NU trebuie sa fie admin"
finally:
conn.close()
# ---------------------------------------------------------------------------
# Teste C16 (log SIGNUP pastrat) si best-effort E2E
# ---------------------------------------------------------------------------
def test_signup_inca_logheaza_si_notifica(client, capsys):
"""Signup reusit -> stdout contine 'SIGNUP cont=' (C16 pastrat)."""
resp = _do_signup(client, "Service Log Test", "log@test.com")
assert resp.status_code == 200
assert "rfak_" in resp.text
captured = capsys.readouterr()
assert "SIGNUP cont=" in captured.out, (
f"Linia de log C16 'SIGNUP cont=' lipseste din stdout. Capturat: {captured.out!r}"
)
def test_signup_neblocat_de_notify(monkeypatch, client):
"""notify_signup ridica -> signup returneaza totusi 200 cu cheia (best-effort E2E)."""
# Monkeypatch notify_signup sa ridice
def notify_always_raises(*a, **kw):
raise RuntimeError("simulam eroare fatala in notify")
# Importam modulul inainte de monkeypatch
import app.email as email_mod
monkeypatch.setattr(email_mod, "notify_signup", notify_always_raises)
resp = _do_signup(client, "Service Robust", "robust@test.com")
assert resp.status_code == 200, (
f"Signup trebuia sa reuseasca indiferent de eroarea din notify. status={resp.status_code}"
)
assert "rfak_" in resp.text, "Cheia API trebuia afisata chiar daca notify a esuat"

193
tests/test_users.py Normal file
View File

@@ -0,0 +1,193 @@
"""Teste US-001 (PRD 3.3): tabela users + helper-e parole scrypt in app/users.py."""
from __future__ import annotations
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_users.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
@pytest.fixture()
def account_id(conn):
"""Cont de test (nu default id=1)."""
from app.accounts import create_account
return create_account(conn, "Service Test")
def test_create_user_hash_nu_e_plaintext(conn, account_id):
"""password_hash din DB nu contine parola in clar si nu e egal cu ea."""
from app.users import create_user
parola = "parola_sigura_123"
user_id = create_user(conn, account_id, "test@exemplu.ro", parola)
assert isinstance(user_id, int)
row = conn.execute(
"SELECT password_hash, salt FROM users WHERE id=?", (user_id,)
).fetchone()
assert row is not None
assert row["password_hash"] != parola
assert parola not in row["password_hash"]
assert row["salt"] != parola
def test_verify_parola_corecta_si_gresita(conn, account_id):
"""verify_password intoarce account_id la parola corecta, None la cea gresita."""
from app.users import create_user, verify_password
create_user(conn, account_id, "user@exemplu.ro", "parola_corecta_99")
result_ok = verify_password(conn, "user@exemplu.ro", "parola_corecta_99")
assert result_ok == account_id
result_gresit = verify_password(conn, "user@exemplu.ro", "parola_gresita_00")
assert result_gresit is None
result_inexistent = verify_password(conn, "inexistent@exemplu.ro", "parola_corecta_99")
assert result_inexistent is None
def test_email_unic_global(conn, account_id):
"""Al doilea create_user cu acelasi email (diferit doar in case) ridica ValueError."""
from app.users import create_user
create_user(conn, account_id, "Unic@exemplu.ro", "parola_unica_001")
with pytest.raises(ValueError, match="email deja folosit"):
create_user(conn, account_id, "unic@exemplu.ro", "alta_parola_002")
def test_get_user_by_email(conn, account_id):
"""get_user_by_email intoarce metadate fara password_hash si salt."""
from app.users import create_user, get_user_by_email
create_user(conn, account_id, "meta@exemplu.ro", "parola_meta_xyz")
user = get_user_by_email(conn, "meta@exemplu.ro")
assert user is not None
assert user["email"].lower() == "meta@exemplu.ro"
assert user["account_id"] == account_id
assert "id" in user
assert "is_admin" in user
assert "email_verified" in user
assert "created_at" in user
assert "password_hash" not in user
assert "salt" not in user
assert get_user_by_email(conn, "inexistent@exemplu.ro") is None
def test_parola_scurta_si_lunga_eroare(conn, account_id):
"""Parola < 10 caractere sau > 128 ridica ValueError (C9 anti-DoS)."""
from app.users import create_user
with pytest.raises(ValueError):
create_user(conn, account_id, "scurta@ex.ro", "scurt")
with pytest.raises(ValueError):
create_user(conn, account_id, "lunga@ex.ro", "x" * 129)
# exact 10 caractere — trebuie sa mearga
uid = create_user(conn, account_id, "exact10@ex.ro", "a" * 10)
assert uid > 0
# exact 128 caractere — trebuie sa mearga
uid2 = create_user(conn, account_id, "exact128@ex.ro", "b" * 128)
assert uid2 > 0
def test_verify_honoreaza_scrypt_params(conn, account_id, monkeypatch):
"""verify_password foloseste parametrii din DB (scrypt_params), nu constantele globale.
Simuleaza migrare cost: hash creat cu n=4 (vechi), constanta _N ridicata la 2**15 (nou).
verify_password trebuie sa returneze account_id folosind n=4 din DB, nu _N global.
"""
import hashlib
import secrets as _secrets
import app.users as users_mod
email = "legacy@test.com"
password = "parolasecreta"
# Hash cu parametri "vechi" (n=4, rapid pentru teste)
n_old, r_old, p_old = 4, 8, 1
salt = _secrets.token_bytes(16)
pw_hash = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=n_old, r=r_old, p=p_old,
maxmem=64 * 1024 * 1024,
dklen=32,
)
conn.execute(
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params) "
"VALUES (?, ?, ?, ?, ?)",
(account_id, email, pw_hash.hex(), salt.hex(), "n4_r8_p1"),
)
# Simuleaza cresterea costului: _N e acum mai mare
monkeypatch.setattr(users_mod, "_N", 2**15)
# verify_password trebuie sa onoreze n=4 din DB, nu sa foloseasca _N=2**15
result = users_mod.verify_password(conn, email, password)
assert result == account_id, "verify_password trebuia sa onoreze scrypt_params din DB"
assert users_mod.verify_password(conn, email, "gresita123456") is None
def test_verify_params_corupti_return_none(conn, account_id):
"""scrypt_params corupt/necunoscut -> verify returneaza None (no crash)."""
import hashlib
import secrets as _secrets
email = "corupt@test.com"
password = "parolasecreta"
salt = _secrets.token_bytes(16)
pw_hash = hashlib.scrypt(password.encode(), salt=salt, n=4, r=8, p=1,
maxmem=64 * 1024 * 1024, dklen=32)
conn.execute(
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params) "
"VALUES (?, ?, ?, ?, ?)",
(account_id, email, pw_hash.hex(), salt.hex(), "FORMAT_NECUNOSCUT"),
)
from app.users import verify_password
result = verify_password(conn, email, password)
assert result is None, "Eticheta corupta trebuia sa returneze None, nu crash"
def test_init_db_pe_db_fara_users_creeaza_tabela(monkeypatch, tmp_path):
"""init_db pe o DB existenta fara tabela users o creeaza fara eroare (migrare idempotenta)."""
db_path = tmp_path / "veche.db"
monkeypatch.setenv("AUTOPASS_DB_PATH", str(db_path))
from app.config import get_settings
get_settings.cache_clear()
# Creeaza DB fara tabela users (simuleaza DB veche)
import sqlite3 as _sq
conn_raw = _sq.connect(str(db_path))
conn_raw.execute("PRAGMA journal_mode = WAL")
conn_raw.execute("PRAGMA foreign_keys = ON")
conn_raw.execute(
"CREATE TABLE IF NOT EXISTS accounts "
"(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
"cui TEXT, active INTEGER NOT NULL DEFAULT 1, "
"rar_creds_enc TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))"
)
conn_raw.execute("INSERT OR IGNORE INTO accounts (id, name) VALUES (1, 'default')")
conn_raw.commit()
conn_raw.close()
# init_db trebuie sa creeze tabela users fara eroare
from app.db import init_db
init_db()
from app.db import get_connection
c = get_connection()
tables = {r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
c.close()
assert "users" in tables
get_settings.cache_clear()

232
tests/test_web_cont.py Normal file
View File

@@ -0,0 +1,232 @@
"""Teste US-007 (PRD 3.3b): sectiunea 'Contul meu' — rotire cheie API + creds RAR din UI.
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
dupa implementare trec (GREEN).
Rute testate:
- GET /_fragments/cont -> card "Contul meu"
- POST /cont/roteste-cheie -> cheie noua afisata o singura data
- POST /cont/rar-creds -> seteaza rar_creds_enc per cont din sesiune
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
"""Client fara web_auth_required (dev mode) — sesiunea se seteaza manual."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
@pytest.fixture()
def client_prod(monkeypatch):
"""Client cu web_auth_required=True (mod prod) — CSRF enforce."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
def _create_account_user(email: str = "user@test.com", password: str = "parolasecreta10"):
"""Creeaza cont + user + cheie API initiala. Intoarce (acct_id, user_id, api_key)."""
from app.accounts import create_account
from app.users import create_user
from app.auth import create_api_key
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test", active=True)
user_id = create_user(conn, acct_id, email, password)
api_key = create_api_key(conn, acct_id)
return acct_id, user_id, api_key
finally:
conn.close()
def _login(client, email: str, password: str) -> None:
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
# Obtine CSRF token de pe pagina de login
resp = client.get("/login")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit pe /login"
csrf = m.group(1)
resp = client.post("/login", data={
"email": email,
"parola": password,
"csrf_token": csrf,
})
# 303 redirect la / inseamna login reusit
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
def _get_csrf_from_fragment(client) -> str:
"""Obtine CSRF token din fragmentul /_fragments/cont."""
resp = client.get("/_fragments/cont")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, f"csrf_token negasit in /_fragments/cont: {resp.text[:500]}"
return m.group(1)
# ============================================================
# test_roteste_cheie_afisata_o_data
# ============================================================
def test_roteste_cheie_afisata_o_data(client):
"""User logat roteste cheia: raspunsul contine 'rfak_'; cheia veche revocata."""
acct_id, user_id, api_key_initiala = _create_account_user("roteste@test.com")
_login(client, "roteste@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/roteste-cheie", data={"csrf_token": csrf})
assert resp.status_code == 200
assert "rfak_" in resp.text, f"Cheia noua nu apare in raspuns: {resp.text[:500]}"
# Verifica in DB: cheia veche revocata, una noua activa
from app.db import get_connection
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, active FROM api_keys WHERE account_id=? ORDER BY id",
(acct_id,),
).fetchall()
# Trebuie sa avem minim 2 chei: cea initiala (active=0) si cea noua (active=1)
active_keys = [r for r in rows if r["active"] == 1]
inactive_keys = [r for r in rows if r["active"] == 0]
assert len(active_keys) == 1, f"Trebuia exact 1 cheie activa, gasit: {len(active_keys)}"
assert len(inactive_keys) >= 1, "Cheia veche trebuia revocata (active=0)"
finally:
conn.close()
# ============================================================
# test_set_creds_rar_din_sesiune
# ============================================================
def test_set_creds_rar_din_sesiune(client):
"""User logat seteaza creds RAR: accounts.rar_creds_enc != NULL, decriptabil."""
acct_id, user_id, _ = _create_account_user("creds@test.com")
_login(client, "creds@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/rar-creds", data={
"csrf_token": csrf,
"rar_email": "user@rar.ro",
"rar_parola": "parolaRAR123",
})
assert resp.status_code == 200
# Mesaj de succes in raspuns
assert "succes" in resp.text.lower() or "salvat" in resp.text.lower() or "configurat" in resp.text.lower(), \
f"Mesaj de succes lipsa: {resp.text[:500]}"
# Verifica in DB: rar_creds_enc setat si decriptabil
from app.db import get_connection
from app.crypto import decrypt_creds
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_id,)
).fetchone()
assert row is not None
assert row["rar_creds_enc"] is not None, "rar_creds_enc trebuia setat"
creds = decrypt_creds(row["rar_creds_enc"])
assert creds is not None
assert creds.get("email") == "user@rar.ro"
assert creds.get("password") == "parolaRAR123"
finally:
conn.close()
# ============================================================
# test_creds_alt_cont_neafectat
# ============================================================
def test_creds_alt_cont_neafectat(client):
"""User A seteaza creds -> contul B ramane cu rar_creds_enc NULL."""
acct_a, user_a, _ = _create_account_user("userA@test.com")
acct_b, user_b, _ = _create_account_user("userB@test.com")
# Logam user A si setam creds
_login(client, "userA@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/rar-creds", data={
"csrf_token": csrf,
"rar_email": "a@rar.ro",
"rar_parola": "parolaA123",
})
assert resp.status_code == 200
# Verifica: contul A are creds, contul B ramane NULL
from app.db import get_connection
conn = get_connection()
try:
row_a = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_a,)).fetchone()
row_b = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_b,)).fetchone()
assert row_a["rar_creds_enc"] is not None, "Contul A trebuia sa aiba creds"
assert row_b["rar_creds_enc"] is None, "Contul B nu trebuia atins"
finally:
conn.close()
# ============================================================
# test_roteste_fara_csrf_403_in_prod
# ============================================================
def test_roteste_fara_csrf_403_in_prod(client_prod):
"""Prod + sesiune autentificata + CSRF lipsa -> 403."""
# Cream cont + user
acct_id, user_id, _ = _create_account_user("csrf_test@test.com")
# Login real
_login(client_prod, "csrf_test@test.com", "parolasecreta10")
# POST fara csrf_token (sau cu token gresit)
resp = client_prod.post("/cont/roteste-cheie", data={"csrf_token": "token_gresit"})
assert resp.status_code == 403, f"Trebuia 403, got {resp.status_code}"
# ============================================================
# test_fragment_cont_nelogat_redirect
# ============================================================
def test_fragment_cont_nelogat_redirect(monkeypatch):
"""Fara sesiune + web_auth_required=True -> 303 redirect /login."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_nl.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
resp = c.get("/_fragments/cont")
assert resp.status_code == 303
assert "/login" in resp.headers.get("location", "")
get_settings.cache_clear()

177
tests/test_web_csrf.py Normal file
View File

@@ -0,0 +1,177 @@
"""Teste US-009 (PRD 3.3): CSRF token per-sesiune + rate-limit signup."""
from __future__ import annotations
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.testclient import TestClient
# ---- App minimal pentru teste CSRF ----
def _make_csrf_app() -> FastAPI:
mini = FastAPI()
mini.add_middleware(
SessionMiddleware,
secret_key="csrf-test-secret",
session_cookie="autopass_session",
https_only=False,
same_site="strict",
)
@mini.get("/login-sim")
def login_sim(request: Request):
"""Simuleaza login: seteaza account_id in sesiune (ca set_session din US-003)."""
from app.web.csrf import get_csrf_token
request.session["account_id"] = 1
return {"token": get_csrf_token(request)}
@mini.get("/csrf-token")
def get_token(request: Request):
from app.web.csrf import get_csrf_token
return {"token": get_csrf_token(request)}
@mini.post("/verify-csrf")
async def verify(request: Request):
from app.web.csrf import verify_csrf
form = await request.form()
submitted = form.get("csrf_token")
verify_csrf(request, submitted)
return {"ok": True}
from app.web.csrf import CsrfError
@mini.exception_handler(CsrfError)
async def csrf_handler(request: Request, exc: CsrfError):
return HTMLResponse("CSRF invalid", status_code=403)
return mini
@pytest.fixture()
def csrf_client():
app = _make_csrf_app()
with TestClient(app, follow_redirects=False) as c:
yield c
def test_get_csrf_token_stabil_per_sesiune(csrf_client):
"""get_csrf_token intoarce acelasi token in cadrul aceleiasi sesiuni."""
r1 = csrf_client.get("/csrf-token")
token1 = r1.json()["token"]
r2 = csrf_client.get("/csrf-token")
token2 = r2.json()["token"]
assert token1 == token2
assert len(token1) > 16
def test_verify_csrf_corect(csrf_client):
"""Token corect -> verify_csrf trece, raspuns 200."""
token = csrf_client.get("/csrf-token").json()["token"]
resp = csrf_client.post("/verify-csrf", data={"csrf_token": token})
assert resp.status_code == 200
def test_verify_csrf_gresit_ridica(csrf_client):
"""Token gresit -> CsrfError -> 403.
CSRF se enforce doar cand exista sesiune autentificata (account_id in sesiune).
login-sim seteaza account_id + csrf_token, ca login-ul real din US-003.
"""
csrf_client.get("/login-sim") # initializeaza sesiunea autentificata (account_id + csrf_token)
resp = csrf_client.post("/verify-csrf", data={"csrf_token": "token-fals-xxxx"})
assert resp.status_code == 403
def test_verify_csrf_lipsa_ridica(csrf_client):
"""Token lipsa (None) -> CsrfError -> 403."""
csrf_client.get("/login-sim") # initializeaza sesiunea autentificata
resp = csrf_client.post("/verify-csrf", data={})
assert resp.status_code == 403
def test_verify_csrf_enforce_in_prod_fara_login(monkeypatch):
"""web_auth_required=True -> CSRF enforce chiar fara account_id (login CSRF fix).
In prod, POST /login si /signup NU au account_id in sesiune, dar CSRF trebuie
enforced (login CSRF: atacatorul forteaza victima sa se logheze in contul lui).
Verifica ca verify_csrf ridica CsrfError pe POST fara token, si trece cu token valid.
"""
import tempfile, os
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "csrf_prod.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.testclient import TestClient
from app.web.csrf import CsrfError, get_csrf_token, verify_csrf
mini = FastAPI()
mini.add_middleware(SessionMiddleware, secret_key="prod-test-key",
session_cookie="autopass_session", https_only=False, same_site="strict")
@mini.get("/form")
def form(request: Request):
return {"token": get_csrf_token(request)}
@mini.post("/form")
async def form_post(request: Request):
form = await request.form()
verify_csrf(request, form.get("csrf_token"))
return {"ok": True}
@mini.exception_handler(CsrfError)
async def csrf_handler(request: Request, exc: CsrfError):
return HTMLResponse("CSRF invalid", status_code=403)
with TestClient(mini, follow_redirects=False) as c:
# GET initializeaza token in sesiune (fara account_id, ca /login GET)
tok = c.get("/form").json()["token"]
# POST fara token -> 403 in prod (login CSRF protectie)
r_bad = c.post("/form", data={})
assert r_bad.status_code == 403, f"asteptat 403, primit {r_bad.status_code}"
# POST cu token valid -> 200
r_ok = c.post("/form", data={"csrf_token": tok})
assert r_ok.status_code == 200, f"asteptat 200, primit {r_ok.status_code}"
get_settings.cache_clear()
# ---- Teste rate-limit ----
def test_rate_limit_permite_sub_prag():
"""check_rate_limit permite N cereri sub prag."""
from app.web.ratelimit import check_rate_limit
key = "ip_test_sub_prag_unic"
for _ in range(3):
assert check_rate_limit(key, max_hits=5, window_s=3600) is True
def test_rate_limit_blocheaza_peste_prag():
"""check_rate_limit blocheaza la a (max_hits+1)-a cerere."""
from app.web.ratelimit import check_rate_limit
key = "ip_test_bloc_unic"
for _ in range(3):
check_rate_limit(key, max_hits=3, window_s=3600)
# A 4-a cerere -> False
assert check_rate_limit(key, max_hits=3, window_s=3600) is False
def test_rate_limit_fereastra_glisanta():
"""Dupa expirarea ferestrei, limiterul permite din nou."""
import time
from app.web.ratelimit import check_rate_limit
key = "ip_test_fereastra_unic"
# Umple fereastra cu window_s=0 (toate timestamp-urile expira imediat)
check_rate_limit(key, max_hits=1, window_s=0)
check_rate_limit(key, max_hits=1, window_s=0)
# Cu window_s=0 toate sunt expirate -> urmatoarea e permisa
assert check_rate_limit(key, max_hits=1, window_s=0) is True

155
tests/test_web_login.py Normal file
View File

@@ -0,0 +1,155 @@
"""Teste US-004 (PRD 3.3): GET/POST /login, POST /logout.
TDD: testele se scriu INAINTE de implementarea auth_routes.py; la inceput pica (RED),
dupa implementare trec (GREEN).
"""
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, "t.db"))
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()
@pytest.fixture()
def client_rl(monkeypatch):
"""Client cu login_rate_max=2 pentru testul de rate-limit."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_LOGIN_RATE_MAX", "2")
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) as c:
yield c
get_settings.cache_clear()
def _get_csrf(client, url: str) -> str:
resp = client.get(url)
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, f"csrf_token negasit in {url}"
return m.group(1)
def _create_user(email: str = "test@test.com", password: str = "parolasecreta", active: bool = True):
"""Creeaza direct un cont + user in DB (fara HTTP)."""
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test", active=active)
user_id = create_user(conn, acct_id, email, password)
return acct_id, user_id
finally:
conn.close()
def test_login_corect_seteaza_sesiune(client):
"""Credentiale corecte -> 303 redirect la /; sesiunea are account_id."""
_create_user("valid@test.com", "parolasecreta", active=True)
token = _get_csrf(client, "/login")
resp = client.post("/login", data={
"email": "valid@test.com",
"parola": "parolasecreta",
"csrf_token": token,
}, follow_redirects=False)
assert resp.status_code == 303
loc = resp.headers.get("location", "")
assert loc in ("/", "http://testserver/"), f"Redirect gresit: {loc}"
def test_login_gresit_401_fara_leak(client):
"""Parola gresita -> 401; mesaj generic fara a dezvalui daca emailul exista."""
_create_user("real@test.com", "parolasecreta", active=True)
token = _get_csrf(client, "/login")
resp = client.post("/login", data={
"email": "real@test.com",
"parola": "gresita",
"csrf_token": token,
})
assert resp.status_code == 401
text = resp.text.lower()
assert "inexistent" not in text, "Raspunsul dezvaluie ca emailul nu exista"
assert "nu exista" not in text, "Raspunsul dezvaluie ca emailul nu exista"
def test_logout_redirect_login(client):
"""POST /logout -> 303 redirect la /login."""
_create_user("logout@test.com", "parolasecreta", active=True)
token = _get_csrf(client, "/login")
client.post("/login", data={
"email": "logout@test.com",
"parola": "parolasecreta",
"csrf_token": token,
}, follow_redirects=False)
# Dupa login, sesiunea e reset -> obtine un token CSRF nou
token = _get_csrf(client, "/login")
resp = client.post("/logout", data={"csrf_token": token}, follow_redirects=False)
assert resp.status_code == 303
assert "/login" in resp.headers.get("location", "")
def test_login_cont_inactiv_intra(client):
"""C18: Login pe cont active=0 trebuie sa functioneze (gate-ul e doar pe trimitere)."""
_create_user("inactiv@test.com", "parolasecreta", active=False)
token = _get_csrf(client, "/login")
resp = client.post("/login", data={
"email": "inactiv@test.com",
"parola": "parolasecreta",
"csrf_token": token,
}, follow_redirects=False)
assert resp.status_code == 303, (
"Login pe cont inactiv trebuia sa reuseasca (gate-ul e doar la trimitere, nu la login)"
)
def test_login_rate_limit_429(client_rl):
"""Peste login_rate_max incercari (login_rate_max=2) -> 429 la urmatoarea cerere."""
# Doua incercari (permise)
for _ in range(2):
token = _get_csrf(client_rl, "/login")
client_rl.post("/login", data={
"email": "nimeni@test.com",
"parola": "parola_gresita",
"csrf_token": token,
})
# A treia — trebuie 429
token = _get_csrf(client_rl, "/login")
resp = client_rl.post("/login", data={
"email": "nimeni@test.com",
"parola": "parola_gresita",
"csrf_token": token,
})
assert resp.status_code == 429, "Peste login_rate_max trebuia 429"

161
tests/test_web_session.py Normal file
View File

@@ -0,0 +1,161 @@
"""Teste US-002 (PRD 3.3): SessionMiddleware + helper-e sesiune in app/web/session.py."""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.testclient import TestClient
# ---- App minimal pentru teste (fara init_db, fara DB) ----
def _make_app(web_auth_required: bool = True, session_secret: str = "test-secret-dev") -> FastAPI:
"""Construieste un app FastAPI minimal cu SessionMiddleware + rute de test."""
mini = FastAPI()
mini.add_middleware(
SessionMiddleware,
secret_key=session_secret,
session_cookie="autopass_session",
https_only=False,
same_site="strict",
)
@mini.get("/set-session")
def set_sess(request: Request, account_id: int = 1, user_id: int = 1):
from app.web.session import set_session
set_session(request, account_id, user_id)
return {"ok": True}
@mini.get("/get-session")
def get_sess(request: Request):
from app.web.session import current_account, current_user_id
return {
"account_id": current_account(request),
"user_id": current_user_id(request),
}
@mini.get("/protected")
def protected(request: Request):
from app.web.session import require_login
aid = require_login(request)
return {"account_id": aid}
@mini.get("/logout")
def logout_ep(request: Request):
from app.web.session import clear_session
clear_session(request)
return {"ok": True}
@mini.get("/web-account")
def web_account_ep(request: Request):
from app.web.session import web_account
return {"account_id": web_account(request)}
# Handler pentru LoginRequired
from app.web.session import LoginRequired
from starlette.responses import RedirectResponse
@mini.exception_handler(LoginRequired)
async def login_required_handler(request: Request, exc: LoginRequired):
return RedirectResponse("/login", status_code=303)
return mini
@pytest.fixture()
def client_auth(monkeypatch):
"""Client cu web_auth_required=True."""
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
app = _make_app(web_auth_required=True)
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
@pytest.fixture()
def client_dev(monkeypatch):
"""Client cu web_auth_required=False (dev bypass)."""
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
app = _make_app(web_auth_required=False)
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
def test_ruta_protejata_redirect_login(client_auth):
"""Fara sesiune si web_auth_required=True -> 303 redirect catre /login."""
resp = client_auth.get("/protected")
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_sesiune_seteaza_si_citeste_cont(client_auth):
"""set_session stocheaza account_id si user_id; current_account/current_user_id le citesc."""
client_auth.get("/set-session?account_id=5&user_id=7")
resp = client_auth.get("/get-session")
assert resp.status_code == 200
data = resp.json()
assert data["account_id"] == 5
assert data["user_id"] == 7
def test_logout_curata_sesiunea(client_auth):
"""clear_session sterge account_id si user_id din sesiune."""
client_auth.get("/set-session?account_id=3&user_id=4")
# Verifica ca sesiunea e setata
data_before = client_auth.get("/get-session").json()
assert data_before["account_id"] == 3
# Logout
client_auth.get("/logout")
data_after = client_auth.get("/get-session").json()
assert data_after["account_id"] is None
assert data_after["user_id"] is None
def test_dev_bypass_cont_1(client_dev, monkeypatch):
"""web_auth_required=False -> web_account() returneaza 1 (DEFAULT_ACCOUNT_ID) fara sesiune."""
resp = client_dev.get("/web-account")
assert resp.status_code == 200
assert resp.json()["account_id"] == 1
def test_set_session_curata_sesiunea_anterioara(client_auth):
"""set_session face clear() inainte de a seta (C3 anti-fixare sesiune)."""
# Seteaza sesiune initiala cu cont 10
client_auth.get("/set-session?account_id=10&user_id=10")
data_initial = client_auth.get("/get-session").json()
assert data_initial["account_id"] == 10
# Re-login cu cont nou 20 — sesiunea veche trebuie stearsa inainte
client_auth.get("/set-session?account_id=20&user_id=20")
data_nou = client_auth.get("/get-session").json()
assert data_nou["account_id"] == 20
assert data_nou["user_id"] == 20
def test_ruta_protejata_cu_sesiune_trece(client_auth):
"""Cu sesiune setata si web_auth_required=True -> ruta protejata raspunde 200."""
client_auth.get("/set-session?account_id=5&user_id=5")
resp = client_auth.get("/protected")
assert resp.status_code == 200
assert resp.json()["account_id"] == 5
def test_web_auth_required_default_true(monkeypatch):
"""Default-ul de productie: auth web e ON daca AUTOPASS_WEB_AUTH_REQUIRED nu e setat.
Dev rapid pe cont 1 = opt-out explicit (AUTOPASS_WEB_AUTH_REQUIRED=false).
"""
monkeypatch.delenv("AUTOPASS_WEB_AUTH_REQUIRED", raising=False)
from app.config import Settings, get_settings
get_settings.cache_clear()
assert Settings().web_auth_required is True
get_settings.cache_clear()

154
tests/test_web_signup.py Normal file
View File

@@ -0,0 +1,154 @@
"""Teste US-003 (PRD 3.3): GET/POST /signup.
TDD: testele se scriu INAINTE de implementarea auth_routes.py; la inceput pica (RED),
dupa implementare trec (GREEN).
"""
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, "t.db"))
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
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) as c:
yield c
get_settings.cache_clear()
def _csrf(html: str) -> str:
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', html)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', html)
assert m, "csrf_token negasit in HTML"
return m.group(1)
def test_signup_creeaza_cont_user_si_cheie(client):
"""POST /signup valid -> cont active=0, user, api_key create in DB; cheie rfak_ in raspuns."""
resp = client.get("/signup")
assert resp.status_code == 200
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Auto Test",
"cui": "RO12345678",
"email": "test@example.com",
"parola": "parolasecreta",
"csrf_token": token,
})
assert resp.status_code == 200
assert "rfak_" in resp.text
from app.db import get_connection
conn = get_connection()
try:
acct = conn.execute(
"SELECT * FROM accounts WHERE name='Service Auto Test'"
).fetchone()
assert acct is not None
assert acct["active"] == 0, "Contul trebuie creat inactive (in asteptare)"
user = conn.execute(
"SELECT * FROM users WHERE email='test@example.com'"
).fetchone()
assert user is not None
assert user["account_id"] == acct["id"]
key = conn.execute(
"SELECT * FROM api_keys WHERE account_id=?", (acct["id"],)
).fetchone()
assert key is not None
assert key["active"] == 1
finally:
conn.close()
def test_signup_email_duplicat_eroare(client):
"""Email duplicat -> ROLLBACK; COUNT(accounts) neschimbat (fara cont orfan)."""
resp = client.get("/signup")
token = _csrf(resp.text)
client.post("/signup", data={
"name": "Service A",
"email": "dup@example.com",
"parola": "parolasecreta",
"csrf_token": token,
})
from app.db import get_connection
conn = get_connection()
count_before = conn.execute("SELECT COUNT(*) AS n FROM accounts").fetchone()["n"]
conn.close()
resp = client.get("/signup")
token = _csrf(resp.text)
resp2 = client.post("/signup", data={
"name": "Service B",
"email": "dup@example.com",
"parola": "altaparola123",
"csrf_token": token,
})
assert resp2.status_code in (200, 422)
assert "rfak_" not in resp2.text
conn = get_connection()
count_after = conn.execute("SELECT COUNT(*) AS n FROM accounts").fetchone()["n"]
conn.close()
assert count_after == count_before, "Cont orfan creat la email duplicat (ROLLBACK a esuat)"
def test_signup_parola_scurta_eroare(client):
"""Parola sub 10 caractere -> eroare, fara creare cont/user."""
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Test",
"email": "scurta@test.com",
"parola": "scurt",
"csrf_token": token,
})
assert resp.status_code in (200, 422)
assert "rfak_" not in resp.text
from app.db import get_connection
conn = get_connection()
try:
acct = conn.execute(
"SELECT * FROM accounts WHERE name='Service Test'"
).fetchone()
assert acct is None, "Cont creat desi parola era prea scurta"
finally:
conn.close()
def test_cheie_afisata_o_data(client):
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
resp = client.get("/signup")
token = _csrf(resp.text)
resp_post = client.post("/signup", data={
"name": "Service Cheie",
"email": "cheie@test.com",
"parola": "parolasecreta",
"csrf_token": token,
})
assert resp_post.status_code == 200
assert "rfak_" in resp_post.text, "Cheia trebuia afisata in raspunsul POST /signup"
resp_get = client.get("/signup")
assert "rfak_" not in resp_get.text, "GET /signup nu trebuie sa contina cheia (afisata o singura data)"

View File

@@ -0,0 +1,145 @@
"""Teste US-008 — gate worker: claim_one sare submission-urile conturilor inactive.
TDD: testele se scriu INAINTE de modificarea claim_one; la inceput pica (RED),
dupa modificare trec (GREEN).
C14: LEFT JOIN accounts + COALESCE(a.active, 1) = 1
(a) cont legacy fara active -> COALESCE(NULL,1)=1 -> tratat ca activ
(b) submissions.account_id IS NULL (ON DELETE SET NULL) -> LEFT JOIN lasa
a.active NULL -> COALESCE(NULL,1)=1 -> tratat ca activ/default
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
# --- Fixture DB (pattern din test_worker_reconcile.py) ---
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
yield conn, get_settings()
conn.close()
get_settings.cache_clear()
# --- Helpers ---
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
}
def _insert(conn, account_id=None, status="queued", content=None):
content = content or _CONTENT
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id) "
"VALUES (?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), account_id),
)
return int(cur.lastrowid)
def _row_status(conn, sid):
return conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"]
# --- Teste ---
def test_claim_sare_cont_inactiv(env):
"""Cont inactiv (active=0) -> claim_one nu ridica submission-ul; ramane queued."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Inactiv", active=False)
sid = _insert(conn, account_id=acct_id)
result = claim_one(conn)
assert result is None, "claim_one trebuia sa returneze None pentru cont inactiv"
assert _row_status(conn, sid) == "queued", "submission-ul trebuia sa ramana queued"
def test_claim_ia_cont_activ(env):
"""Cont activ (active=1) -> claim_one ridica submission-ul si il marcheaza sending."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Activ", active=True)
sid = _insert(conn, account_id=acct_id)
result = claim_one(conn)
assert result is not None, "claim_one trebuia sa returneze submission-ul pentru cont activ"
assert result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_activare_deblocheaza_trimiterea(env):
"""Cont initial inactiv -> claim_one None; dupa set_active(True) -> claim_one ridica randul."""
from app.accounts import create_account, set_active
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Provizoriu", active=False)
sid = _insert(conn, account_id=acct_id)
assert claim_one(conn) is None, "inainte de activare claim_one trebuia sa returneze None"
assert _row_status(conn, sid) == "queued"
set_active(conn, acct_id, True)
result = claim_one(conn)
assert result is not None, "dupa activare claim_one trebuia sa returneze submission-ul"
assert result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_claim_account_null_tratat_activ(env):
"""submission.account_id IS NULL (ON DELETE SET NULL) -> LEFT JOIN lasa a.active NULL
-> COALESCE(NULL,1)=1 -> tratat ca activ; claim_one il ridica."""
from app.worker.__main__ import claim_one
conn, _ = env
sid = _insert(conn, account_id=None)
result = claim_one(conn)
assert result is not None, "submission cu account_id NULL trebuia sa fie ridicat (tratat ca activ)"
assert result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_claim_cont_legacy_fara_active(env):
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.
Nota: schema curenta are active NOT NULL, deci NULL pe coloana `active` e imposibil;
COALESCE acopera NULL-ul de pe a.active produs de LEFT JOIN fara match, nu din coloana.
Simulam prin setarea directa a account_id la NULL (ca dupa ON DELETE SET NULL).
"""
from app.worker.__main__ import claim_one
conn, _ = env
sid = _insert(conn, account_id=None)
conn.execute("UPDATE submissions SET account_id=NULL WHERE id=?", (sid,))
result = claim_one(conn)
assert result is not None, "submission fara cont (account_id NULL) trebuia tratat ca activ"
assert result["id"] == sid

View File

@@ -25,6 +25,7 @@ import sys
from app.accounts import create_account, list_accounts, set_active from app.accounts import create_account, list_accounts, set_active
from app.auth import create_api_key from app.auth import create_api_key
from app.db import get_connection, init_db from app.db import get_connection, init_db
from app.users import set_admin
def _create(conn: sqlite3.Connection, args: argparse.Namespace) -> int: def _create(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
@@ -67,6 +68,17 @@ def _set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> int:
return 0 return 0
def _set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool) -> int:
try:
set_admin(conn, account_id, is_admin=is_admin)
except ValueError as exc:
print(f"eroare: {exc}", file=sys.stderr)
return 2
actiune = "admin" if is_admin else "non-admin"
print(f"Cont {account_id}: marcat ca {actiune}")
return 0
def _list(conn: sqlite3.Connection, pending_only: bool) -> int: def _list(conn: sqlite3.Connection, pending_only: bool) -> int:
rows = list_accounts(conn) rows = list_accounts(conn)
if pending_only: if pending_only:
@@ -102,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
p_deact = sub.add_parser("deactivate", help="dezactiveaza un cont") p_deact = sub.add_parser("deactivate", help="dezactiveaza un cont")
p_deact.add_argument("--account", type=int, required=True, help="account_id") p_deact.add_argument("--account", type=int, required=True, help="account_id")
p_sadmin = sub.add_parser("set-admin", help="seteaza/sterge rol admin pe un cont")
p_sadmin.add_argument("--account", type=int, required=True, help="account_id")
p_sadmin.add_argument("--remove", action="store_true", help="sterge rolul admin (implicit: adauga)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
init_db() # asigura schema (accounts.active + index CUI) + cont default init_db() # asigura schema (accounts.active + index CUI) + cont default
@@ -115,6 +131,8 @@ def main(argv: list[str] | None = None) -> int:
return _set_active(conn, args.account, True) return _set_active(conn, args.account, True)
if args.cmd == "deactivate": if args.cmd == "deactivate":
return _set_active(conn, args.account, False) return _set_active(conn, args.account, False)
if args.cmd == "set-admin":
return _set_admin(conn, args.account, is_admin=not args.remove)
finally: finally:
conn.close() conn.close()
return 0 return 0