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>
This commit is contained in:
Claude Agent
2026-06-18 16:43:21 +00:00
parent 748ab8b289
commit 504b490d3b
29 changed files with 2264 additions and 106 deletions

View File

@@ -34,6 +34,25 @@ class Settings(BaseSettings):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
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 (prod): rutele web fara sesiune -> redirect /login. False (dev): fara
# sesiune -> cont implicit id=1, back-compat (C12/§5 Q5).
web_auth_required: bool = False
# 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
# --- 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_env: str = "test" # "test" | "prod"
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"

View File

@@ -8,6 +8,7 @@ Pornire dev: uvicorn app.main:app --reload
from __future__ import annotations
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
@@ -16,6 +17,8 @@ from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import RedirectResponse
from . import __version__
from .api.v1.import_router import router as import_v1_router
@@ -24,6 +27,9 @@ from .config import get_settings
from .db import get_connection, init_db, queue_depth, read_heartbeat
from .security import install_log_redaction
from .web.routes import router as web_router
from .web.auth_routes import router as auth_router
from .web.csrf import CsrfError
from .web.session import LoginRequired
@asynccontextmanager
@@ -35,6 +41,26 @@ async def lifespan(app: FastAPI):
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(CsrfError)
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
@@ -59,6 +85,7 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
app.include_router(api_v1_router)
app.include_router(import_v1_router)
app.include_router(web_router)
app.include_router(auth_router)
@app.get("/healthz")

View File

@@ -133,6 +133,19 @@ CREATE TABLE IF NOT EXISTS import_attestations (
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.
CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1),

120
app/users.py Normal file
View File

@@ -0,0 +1,120 @@
"""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) -> 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.
"""
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) "
"VALUES (?, ?, ?, ?, ?)",
(account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS),
)
except sqlite3.IntegrityError:
raise ValueError("email deja folosit")
return int(cur.lastrowid or 0)
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

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

@@ -0,0 +1,155 @@
"""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 ..users import create_user, 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)
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
try:
account_id = create_account(conn, name, cui.strip() or None, active=False)
user_id = create_user(conn, account_id, email, parola)
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)
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,8 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_login
from ..api.v1.import_router import (
_already_sent_lookup,
_build_idempotency_key,
@@ -55,11 +57,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
_BLOCKED = ("error", "needs_data", "needs_mapping")
def _status_counts(conn) -> dict[str, int]:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
def _ctx(request: Request, **extra) -> dict:
"""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}
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:
if hb is None or not hb["last_beat"]:
return False
@@ -92,9 +114,10 @@ def _rar_state(hb, worker_alive: bool) -> str:
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
counts = _status_counts(conn)
counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
worker_alive = _worker_alive(hb)
@@ -107,6 +130,8 @@ def dashboard(request: Request) -> HTMLResponse:
"worker_alive": worker_alive,
"last_login": hb["last_rar_login_ok"] if hb else None,
"rar_state": _rar_state(hb, worker_alive),
"account_active": _account_active(conn, account_id),
"csrf_token": get_csrf_token(request),
}
return templates.TemplateResponse("dashboard.html", ctx)
finally:
@@ -130,46 +155,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse:
@router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
counts = _status_counts(conn)
counts = _status_counts(conn, account_id)
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:
conn.close()
@router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
rows = conn.execute(
"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()
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
finally:
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(
"_mapari.html",
{
"request": request,
"pending": pending_unmapped(conn),
"pending": pending_unmapped(conn, account_id),
"nomenclator": load_nomenclator(conn),
"message": message,
"csrf_token": get_csrf_token(request),
},
)
@router.get("/_fragments/mapari", response_class=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()
try:
return _render_mapari(request, conn)
return _render_mapari(request, conn, account_id)
finally:
conn.close()
@@ -179,16 +220,18 @@ def post_mapare(
request: Request,
cod_op_service: str = Form(...),
cod_prestatie: str = Form(...),
account_id: int | None = Form(None),
csrf_token: str | None = Form(None),
auto_send: bool = Form(False),
) -> HTMLResponse:
"""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()
try:
cod = cod_prestatie.strip().upper()
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
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)
stats = reresolve_account(conn, account_id)
msg = (
@@ -196,7 +239,7 @@ def post_mapare(
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate."
)
return _render_mapari(request, conn, message=msg)
return _render_mapari(request, conn, account_id, message=msg)
finally:
conn.close()
@@ -383,6 +426,7 @@ async def web_upload_import(
request: Request,
file: UploadFile = File(...),
sheet_name: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse:
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
@@ -390,7 +434,8 @@ async def web_upload_import(
Daca nu: intoarce formularul de mapare coloane.
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)
data = await file.read()
@@ -400,30 +445,15 @@ async def web_upload_import(
try:
parsed = parse_file(data, filename, sheet_name=sheet_name)
except MultipleSheets as ms:
return templates.TemplateResponse("_upload.html", {
"request": request,
"sheets": ms.sheet_names,
})
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
except FileTooLarge as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": str(e),
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
except HeaderError as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": f"Antet neclar: {e}",
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
except UnicodeDecodeError as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": f"Encoding nesuportat: {e.reason}",
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
except Exception as e:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
conn = get_connection()
try:
@@ -467,11 +497,13 @@ async def web_upload_import(
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
"csrf_token": get_csrf_token(request),
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": batch_id_int,
"message": "Mapare retinuta aplicata automat.",
"csrf_token": get_csrf_token(request),
**result,
})
@@ -491,6 +523,7 @@ async def web_upload_import(
"fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": None,
"csrf_token": get_csrf_token(request),
})
finally:
conn.close()
@@ -502,13 +535,14 @@ async def web_save_mapare_coloane(
import_id: int,
) -> HTMLResponse:
"""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)
form = await request.form()
# Colectare perechi coloana fisier → camp canonic din form
# 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)]
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
@@ -539,17 +573,17 @@ async def web_save_mapare_coloane(
sugg = _fuzzy_suggest_column(col, limit=3)
if sugg:
fuzzy[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", {
"request": request,
"import_id": import_id,
"columns": columns,
"sample_rows": [],
"fuzzy_suggestions": fuzzy,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": format_data_val,
"message": "Mapeaza cel putin un camp canonic inainte de a continua.",
"error": True,
})
return templates.TemplateResponse("_mapcoloane.html", _ctx(
request,
import_id=import_id,
columns=columns,
sample_rows=[],
fuzzy_suggestions=fuzzy,
canonical_fields=_CANONICAL_FIELDS,
format_data=format_data_val,
message="Mapeaza cel putin un camp canonic inainte de a continua.",
error=True,
))
finally:
conn.close()
@@ -561,10 +595,9 @@ async def web_save_mapare_coloane(
(import_id, acct),
).fetchone()
if not batch:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": "Batch de import inexistent sau expirat.",
})
return templates.TemplateResponse("_upload.html", _ctx(
request, error="Batch de import inexistent sau expirat."
))
sig = _signature(list(json_mapare.keys()))
@@ -580,15 +613,10 @@ async def web_save_mapare_coloane(
# Computa preview
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
**result,
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, **result
))
finally:
conn.close()
@@ -599,7 +627,7 @@ def web_preview_import(
import_id: int,
) -> HTMLResponse:
"""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()
try:
result = _web_compute_preview(conn, import_id, account_id)
@@ -607,10 +635,12 @@ def web_preview_import(
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": result,
"csrf_token": get_csrf_token(request),
})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"csrf_token": get_csrf_token(request),
**result,
})
finally:
@@ -620,7 +650,10 @@ def web_preview_import(
@router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse:
"""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)
@@ -632,11 +665,14 @@ async def web_confirma_import(
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).
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)
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)
try:
@@ -662,16 +698,14 @@ async def web_confirma_import(
(import_id, acct),
).fetchone()
if not batch:
return templates.TemplateResponse("_upload.html", {
"request": request,
"error": "Batch de import inexistent sau expirat.",
})
return templates.TemplateResponse("_upload.html", _ctx(
request, error="Batch de import inexistent sau expirat."
))
if batch["status"] == "committed":
return templates.TemplateResponse("_upload.html", {
"request": request,
"message": "Acest batch a fost deja comis.",
})
return templates.TemplateResponse("_upload.html", _ctx(
request, message="Acest batch a fost deja comis."
))
# Incarca randurile cu stare ok si needs_review
ok_rows_db = conn.execute(
@@ -684,14 +718,14 @@ async def web_confirma_import(
# Re-arata preview cu eroare
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"message": "Niciun rand ok de confirmat in acest batch.",
"error": True,
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request,
import_id=import_id,
message="Niciun rand ok de confirmat in acest batch.",
error=True,
**result,
})
))
# Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = []
@@ -726,26 +760,22 @@ async def web_confirma_import(
f"Verifica preview-ul si retasteaza numarul corect."
)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": msg})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"message": msg,
"error": True,
**result,
})
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=msg, error=True, **result
))
if n_total_ok == 0:
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
"message": "Niciun rand ok de confirmat.",
"error": True,
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request,
import_id=import_id,
message="Niciun rand ok de confirmat.",
error=True,
**result,
})
))
# Incarca maparea de coloane pentru payload
first_row_db = conn.execute(
@@ -867,13 +897,13 @@ async def web_confirma_import(
# Succes → drop zone cu mesaj de confirmare
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", {
"request": request,
"message": (
return templates.TemplateResponse("_upload.html", _ctx(
request,
message=(
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
),
})
))
finally:
conn.close()

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

@@ -0,0 +1,75 @@
"""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)."""
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 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">
<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).
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
</div>

View File

@@ -17,7 +17,7 @@
{% set top = e.suggestions[0] if e.suggestions else None %}
{% 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">
<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 }}">
<div class="mapcol grow">

View File

@@ -20,6 +20,7 @@
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section"
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;">
<label for="format-data" style="font-size:13px; color:var(--muted);">

View File

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

View File

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

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")
try:
row = conn.execute(
"SELECT id, account_id, payload_json, rar_creds_enc FROM submissions WHERE status='queued' "
"AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY id LIMIT 1",
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc "
"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()),),
).fetchone()
if not row:

View File

@@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-06-17 — 3.2 LIVRAT (scope pe cont la toate GET-urile API `/v1/*` care ating `submissions`/`operations_mapping`: `account_scope_clause` cu regula NULL→cont 1, 404 cross-account byte-identic, allowlist campuri pe detaliu, index `(account_id,status)`, regula B8 in contract; nomenclator ramane global. 14 teste noi, 313 pass. VERIFY=PASS context curat). Urmeaza 3.3 (self-onboarding web). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`; `set-password --account N` in 3.3 cu `app/users.py`.
**Ultima actualizare**: 2026-06-17 — 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)
@@ -74,7 +74,8 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|---|-----------|--------|------|---------|
| 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 | 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 | TODO (PRD aprobat) | | US-007 (rotire cheie + creds RAR pe ruta web), US-010 rol admin (primul cont=admin), US-011 panou `/admin` activare, US-012 email signup (degradat: log+`/admin`+`list --pending`, SMTP follow-up). PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
### Etapa 4 — Viitor (Treapta 3)

View File

@@ -1,6 +1,16 @@
# PRD 3.3 — Self-onboarding web (login email+parola → emite cheie)
**Stare**: aprobat
**Stare**: verify-pass (3.3a) — 3.3b deschis
> **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`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
@@ -407,7 +417,65 @@ 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
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.
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
> Sub-livrabila 3.3a (self-onboarding core). Toate stories GREEN, regresie 355 pass (de la 313 baseline).
- [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.

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

@@ -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,198 @@
"""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"))
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()

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

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"

149
tests/test_web_session.py Normal file
View File

@@ -0,0 +1,149 @@
"""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

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