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())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
creds_key: str | None = None creds_key: str | None = None
# --- Sesiuni web (US-002, PRD 3.3) ---
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
session_secret: str | None = None
# True (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 ---
rar_env: str = "test" # "test" | "prod" rar_env: str = "test" # "test" | "prod"
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass" rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"

View File

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

View File

@@ -133,6 +133,19 @@ CREATE TABLE IF NOT EXISTS import_attestations (
n_confirmed INTEGER NOT NULL n_confirmed INTEGER NOT NULL
); );
-- Utilizatori web (email+parola, legati de un cont). Parola stocata doar ca scrypt hash.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, -- hex scrypt(salt, parola)
salt TEXT NOT NULL, -- hex secrets.token_bytes(16), per-user
scrypt_params TEXT NOT NULL, -- eticheta versiune parametri: 'n16384_r8_p1'
email_verified INTEGER NOT NULL DEFAULT 0, -- C19: pregatire viitor
is_admin INTEGER NOT NULL DEFAULT 0, -- pregatire 3.3b
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici. -- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
CREATE TABLE IF NOT EXISTS worker_heartbeat ( CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),

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 fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from ..web.csrf import get_csrf_token, verify_csrf
from ..web.session import require_login
from ..api.v1.import_router import ( from ..api.v1.import_router import (
_already_sent_lookup, _already_sent_lookup,
_build_idempotency_key, _build_idempotency_key,
@@ -55,11 +57,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
_BLOCKED = ("error", "needs_data", "needs_mapping") _BLOCKED = ("error", "needs_data", "needs_mapping")
def _status_counts(conn) -> dict[str, int]: def _ctx(request: Request, **extra) -> dict:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() """Context de baza pentru template-uri cu formulare: include mereu csrf_token.
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
"""
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
def _status_counts(conn, account_id: int) -> dict[str, int]:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"GROUP BY status",
(account_id, account_id),
).fetchall()
return {r["status"]: int(r["n"]) for r in rows} return {r["status"]: int(r["n"]) for r in rows}
def _account_active(conn, account_id: int) -> bool:
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
return bool(row["active"]) if row else True
def _worker_alive(hb) -> bool: def _worker_alive(hb) -> bool:
if hb is None or not hb["last_beat"]: if hb is None or not hb["last_beat"]:
return False return False
@@ -92,9 +114,10 @@ def _rar_state(hb, worker_alive: bool) -> str:
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard(request: Request) -> HTMLResponse: def dashboard(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
counts = _status_counts(conn) counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn) hb = read_heartbeat(conn)
blocked = sum(counts.get(s, 0) for s in _BLOCKED) blocked = sum(counts.get(s, 0) for s in _BLOCKED)
worker_alive = _worker_alive(hb) worker_alive = _worker_alive(hb)
@@ -107,6 +130,8 @@ def dashboard(request: Request) -> HTMLResponse:
"worker_alive": worker_alive, "worker_alive": worker_alive,
"last_login": hb["last_rar_login_ok"] if hb else None, "last_login": hb["last_rar_login_ok"] if hb else None,
"rar_state": _rar_state(hb, worker_alive), "rar_state": _rar_state(hb, worker_alive),
"account_active": _account_active(conn, account_id),
"csrf_token": get_csrf_token(request),
} }
return templates.TemplateResponse("dashboard.html", ctx) return templates.TemplateResponse("dashboard.html", ctx)
finally: finally:
@@ -130,46 +155,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse:
@router.get("/_fragments/banner", response_class=HTMLResponse) @router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse: def fragment_banner(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
counts = _status_counts(conn) counts = _status_counts(conn, account_id)
blocked = sum(counts.get(s, 0) for s in _BLOCKED) blocked = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked}) return templates.TemplateResponse("_banner.html", {
"request": request,
"blocked": blocked,
"account_active": _account_active(conn, account_id),
})
finally: finally:
conn.close() conn.close()
@router.get("/_fragments/submissions", response_class=HTMLResponse) @router.get("/_fragments/submissions", response_class=HTMLResponse)
def fragment_submissions(request: Request) -> HTMLResponse: def fragment_submissions(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
rows = conn.execute( rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at " "SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
"FROM submissions ORDER BY id DESC LIMIT 100" "FROM submissions "
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
"ORDER BY id DESC LIMIT 100",
(account_id, account_id),
).fetchall() ).fetchall()
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows}) return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
finally: finally:
conn.close() conn.close()
def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse: def _render_mapari(
request: Request, conn, account_id: int, *, message: str | None = None
) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
"_mapari.html", "_mapari.html",
{ {
"request": request, "request": request,
"pending": pending_unmapped(conn), "pending": pending_unmapped(conn, account_id),
"nomenclator": load_nomenclator(conn), "nomenclator": load_nomenclator(conn),
"message": message, "message": message,
"csrf_token": get_csrf_token(request),
}, },
) )
@router.get("/_fragments/mapari", response_class=HTMLResponse) @router.get("/_fragments/mapari", response_class=HTMLResponse)
def fragment_mapari(request: Request) -> HTMLResponse: def fragment_mapari(request: Request) -> HTMLResponse:
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.""" """Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
"""
account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
return _render_mapari(request, conn) return _render_mapari(request, conn, account_id)
finally: finally:
conn.close() conn.close()
@@ -179,16 +220,18 @@ def post_mapare(
request: Request, request: Request,
cod_op_service: str = Form(...), cod_op_service: str = Form(...),
cod_prestatie: str = Form(...), cod_prestatie: str = Form(...),
account_id: int | None = Form(None), csrf_token: str | None = Form(None),
auto_send: bool = Form(False), auto_send: bool = Form(False),
) -> HTMLResponse: ) -> HTMLResponse:
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul.""" """Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
account_id = require_login(request)
verify_csrf(request, csrf_token)
conn = get_connection() conn = get_connection()
try: try:
cod = cod_prestatie.strip().upper() cod = cod_prestatie.strip().upper()
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone() exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
if not exists: if not exists:
return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}") return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
save_mapping(conn, account_id, cod_op_service, cod, auto_send) save_mapping(conn, account_id, cod_op_service, cod, auto_send)
stats = reresolve_account(conn, account_id) stats = reresolve_account(conn, account_id)
msg = ( msg = (
@@ -196,7 +239,7 @@ def post_mapare(
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, " f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
f"{stats['still_blocked']} inca nemapate." f"{stats['still_blocked']} inca nemapate."
) )
return _render_mapari(request, conn, message=msg) return _render_mapari(request, conn, account_id, message=msg)
finally: finally:
conn.close() conn.close()
@@ -383,6 +426,7 @@ async def web_upload_import(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
sheet_name: str | None = Form(None), sheet_name: str | None = Form(None),
csrf_token: str | None = Form(None),
) -> HTMLResponse: ) -> HTMLResponse:
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML. """Upload fisier xlsx/csv → staging; intoarce fragment HTML.
@@ -390,7 +434,8 @@ async def web_upload_import(
Daca nu: intoarce formularul de mapare coloane. Daca nu: intoarce formularul de mapare coloane.
Nu editeaza import_router.py — apeleaza parse_file si DB direct. Nu editeaza import_router.py — apeleaza parse_file si DB direct.
""" """
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
verify_csrf(request, csrf_token)
acct = account_or_default(account_id) acct = account_or_default(account_id)
data = await file.read() data = await file.read()
@@ -400,30 +445,15 @@ async def web_upload_import(
try: try:
parsed = parse_file(data, filename, sheet_name=sheet_name) parsed = parse_file(data, filename, sheet_name=sheet_name)
except MultipleSheets as ms: except MultipleSheets as ms:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
"request": request,
"sheets": ms.sheet_names,
})
except FileTooLarge as e: except FileTooLarge as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
"request": request,
"error": str(e),
})
except HeaderError as e: except HeaderError as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
"request": request,
"error": f"Antet neclar: {e}",
})
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
"request": request,
"error": f"Encoding nesuportat: {e.reason}",
})
except Exception as e: except Exception as e:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
"request": request,
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
})
conn = get_connection() conn = get_connection()
try: try:
@@ -467,11 +497,13 @@ async def web_upload_import(
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", {
"request": request, "request": request,
"error": result, "error": result,
"csrf_token": get_csrf_token(request),
}) })
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", {
"request": request, "request": request,
"import_id": batch_id_int, "import_id": batch_id_int,
"message": "Mapare retinuta aplicata automat.", "message": "Mapare retinuta aplicata automat.",
"csrf_token": get_csrf_token(request),
**result, **result,
}) })
@@ -491,6 +523,7 @@ async def web_upload_import(
"fuzzy_suggestions": fuzzy_suggestions, "fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS, "canonical_fields": _CANONICAL_FIELDS,
"format_data": None, "format_data": None,
"csrf_token": get_csrf_token(request),
}) })
finally: finally:
conn.close() conn.close()
@@ -502,13 +535,14 @@ async def web_save_mapare_coloane(
import_id: int, import_id: int,
) -> HTMLResponse: ) -> HTMLResponse:
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML.""" """Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
acct = account_or_default(account_id) acct = account_or_default(account_id)
form = await request.form() form = await request.form()
# Colectare perechi coloana fisier → camp canonic din form # Colectare perechi coloana fisier → camp canonic din form
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text) # form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
verify_csrf(request, str(form.get("csrf_token") or ""))
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)] colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)] canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
format_data_val = str(form.get("format_data") or "").strip() or None format_data_val = str(form.get("format_data") or "").strip() or None
@@ -539,17 +573,17 @@ async def web_save_mapare_coloane(
sugg = _fuzzy_suggest_column(col, limit=3) sugg = _fuzzy_suggest_column(col, limit=3)
if sugg: if sugg:
fuzzy[col] = sugg fuzzy[col] = sugg
return templates.TemplateResponse("_mapcoloane.html", { return templates.TemplateResponse("_mapcoloane.html", _ctx(
"request": request, request,
"import_id": import_id, import_id=import_id,
"columns": columns, columns=columns,
"sample_rows": [], sample_rows=[],
"fuzzy_suggestions": fuzzy, fuzzy_suggestions=fuzzy,
"canonical_fields": _CANONICAL_FIELDS, canonical_fields=_CANONICAL_FIELDS,
"format_data": format_data_val, format_data=format_data_val,
"message": "Mapeaza cel putin un camp canonic inainte de a continua.", message="Mapeaza cel putin un camp canonic inainte de a continua.",
"error": True, error=True,
}) ))
finally: finally:
conn.close() conn.close()
@@ -561,10 +595,9 @@ async def web_save_mapare_coloane(
(import_id, acct), (import_id, acct),
).fetchone() ).fetchone()
if not batch: if not batch:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request, error="Batch de import inexistent sau expirat."
"error": "Batch de import inexistent sau expirat.", ))
})
sig = _signature(list(json_mapare.keys())) sig = _signature(list(json_mapare.keys()))
@@ -580,15 +613,10 @@ async def web_save_mapare_coloane(
# Computa preview # Computa preview
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
"request": request, return templates.TemplateResponse("_preview_import.html", _ctx(
"error": result, request, import_id=import_id, **result
}) ))
return templates.TemplateResponse("_preview_import.html", {
"request": request,
"import_id": import_id,
**result,
})
finally: finally:
conn.close() conn.close()
@@ -599,7 +627,7 @@ def web_preview_import(
import_id: int, import_id: int,
) -> HTMLResponse: ) -> HTMLResponse:
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa.""" """Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
conn = get_connection() conn = get_connection()
try: try:
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
@@ -607,10 +635,12 @@ def web_preview_import(
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", {
"request": request, "request": request,
"error": result, "error": result,
"csrf_token": get_csrf_token(request),
}) })
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", {
"request": request, "request": request,
"import_id": import_id, "import_id": import_id,
"csrf_token": get_csrf_token(request),
**result, **result,
}) })
finally: finally:
@@ -620,7 +650,10 @@ def web_preview_import(
@router.get("/_import/reset", response_class=HTMLResponse) @router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse: def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol).""" """Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
return templates.TemplateResponse("_upload.html", {"request": request}) return templates.TemplateResponse("_upload.html", {
"request": request,
"csrf_token": get_csrf_token(request),
})
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse) @router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
@@ -632,11 +665,14 @@ async def web_confirma_import(
Replica logica din import_router.commit_import dar cu input din form HTML Replica logica din import_router.commit_import dar cu input din form HTML
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU). si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
""" """
account_id = DEFAULT_ACCOUNT_ID account_id = require_login(request)
acct = account_or_default(account_id) acct = account_or_default(account_id)
form = await request.form() form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str) # Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
try: try:
@@ -662,16 +698,14 @@ async def web_confirma_import(
(import_id, acct), (import_id, acct),
).fetchone() ).fetchone()
if not batch: if not batch:
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request, error="Batch de import inexistent sau expirat."
"error": "Batch de import inexistent sau expirat.", ))
})
if batch["status"] == "committed": if batch["status"] == "committed":
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request, message="Acest batch a fost deja comis."
"message": "Acest batch a fost deja comis.", ))
})
# Incarca randurile cu stare ok si needs_review # Incarca randurile cu stare ok si needs_review
ok_rows_db = conn.execute( ok_rows_db = conn.execute(
@@ -684,14 +718,14 @@ async def web_confirma_import(
# Re-arata preview cu eroare # Re-arata preview cu eroare
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result}) return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", _ctx(
"request": request, request,
"import_id": import_id, import_id=import_id,
"message": "Niciun rand ok de confirmat in acest batch.", message="Niciun rand ok de confirmat in acest batch.",
"error": True, error=True,
**result, **result,
}) ))
# Decripteaza si construieste lista de randuri de trimis # Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = [] to_enqueue: list[dict[str, Any]] = []
@@ -726,26 +760,22 @@ async def web_confirma_import(
f"Verifica preview-ul si retasteaza numarul corect." f"Verifica preview-ul si retasteaza numarul corect."
) )
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": msg}) return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", _ctx(
"request": request, request, import_id=import_id, message=msg, error=True, **result
"import_id": import_id, ))
"message": msg,
"error": True,
**result,
})
if n_total_ok == 0: if n_total_ok == 0:
result = _web_compute_preview(conn, import_id, account_id) result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str): if isinstance(result, str):
return templates.TemplateResponse("_upload.html", {"request": request, "error": result}) return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", { return templates.TemplateResponse("_preview_import.html", _ctx(
"request": request, request,
"import_id": import_id, import_id=import_id,
"message": "Niciun rand ok de confirmat.", message="Niciun rand ok de confirmat.",
"error": True, error=True,
**result, **result,
}) ))
# Incarca maparea de coloane pentru payload # Incarca maparea de coloane pentru payload
first_row_db = conn.execute( first_row_db = conn.execute(
@@ -867,13 +897,13 @@ async def web_confirma_import(
# Succes → drop zone cu mesaj de confirmare # Succes → drop zone cu mesaj de confirmare
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else "" toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", { return templates.TemplateResponse("_upload.html", _ctx(
"request": request, request,
"message": ( message=(
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. " f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos." f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
), ),
}) ))
finally: finally:
conn.close() conn.close()

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"> hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Cont in asteptare de activare.</strong>
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.
</div>
{% endif %}
<div class="card banner {% if not blocked %}hidden{% endif %}"
{% if account_active %}hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping). <strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos. Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,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: > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
**Ultima actualizare**: 2026-06-17 — 3.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) ### 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.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.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) ### Etapa 4 — Viitor (Treapta 3)

View File

@@ -1,6 +1,16 @@
# PRD 3.3 — Self-onboarding web (login email+parola → emite cheie) # PRD 3.3 — Self-onboarding web (login email+parola → emite cheie)
**Stare**: aprobat **Stare**: 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`. > Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead). > Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
@@ -407,7 +417,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 - **SMTP (US-012):** ce provider/expeditor? Daca nu exista SMTP la momentul executiei, US-012 se
livreaza degradat (doar log + `/admin` + `list --pending`) si email-ul devine follow-up. livreaza degradat (doar log + `/admin` + `list --pending`) si email-ul devine follow-up.
## Raport VERIFY ## Progres executie 3.3a (lead)
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6. > Sub-livrabila 3.3a (self-onboarding core). Toate stories GREEN, regresie 355 pass (de la 313 baseline).
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
- [x] **US-001** — schema `users` (+ `email_verified`, `is_admin` pregatire 3.3b) + `app/users.py` (scrypt n=2^14, plafon 128 char, hmac.compare_digest). 6 teste.
- [x] **US-002**`SessionMiddleware` (same_site=strict, https_only config) + `app/web/session.py` (`current_account`/`current_user_id`/`web_account`/`require_login``LoginRequired`/`set_session` clear-inainte C3). 6 teste.
- [x] **US-009**`app/web/csrf.py` (token per-sesiune, `verify_csrf` gateat pe MOD: prod sau sesiune autentificata) + `app/web/ratelimit.py` (fereastra glisanta in-proces) + handler `CsrfError`→403. Gate-ul inchide login/signup CSRF in prod (C2). 8 teste.
- [x] **US-003**`GET/POST /signup` (`auth_routes.py` + `signup.html`): tranzactie atomica C10, log `SIGNUP` C16, cheie-o-data + gate C15, banner pozitiv C17. 4 teste.
- [x] **US-004**`GET/POST /login` + `POST /logout`: mesaj generic la esec, login pe `active=0` intra (C18), clear sesiune C3. 4 teste.
- [x] **US-005** — dashboard scoped pe sesiune (`_status_counts`/`fragment_submissions`/`fragment_banner` cu regula NULL→1, C6) + banner "cont in asteptare" (US-008 AC#3, reframe C17). nomenclator ramane global. 4 teste.
- [x] **US-006a** — citiri import (`upload`/`mapare-coloane`/`preview`) pe `web_account(request)`; batch cross-cont inaccesibil.
- [x] **US-006b** — scrieri (`confirma` + `/mapari`) pe sesiune; propagare consecventa `account_id` la `build_key` (C8/OV-2); `verify_csrf` + camp ascuns pe toate formularele; zero atribuiri `DEFAULT_ACCOUNT_ID` in handlere (C6). 5 teste.
- [x] **US-008** — gate worker `claim_one` cu `LEFT JOIN accounts ... COALESCE(active,1)=1` (dublu-NULL C14): cont inactiv → submission ramane `queued`; activare → eligibil fara re-enqueue. 5 teste.
3.3b (US-007 self-service cheie/creds + US-010/011/012 admin web + email) ramane livrabila separata.
## Raport VERIFY (3.3a)
> Doua runde de verificare independenta (subagent context curat, §5.6).
**Runda 1 — FAIL (1 criteriu):** suita 355 pass, dar sweep-ul anti-leak a gasit `GET /_fragments/mapari`
nescoped: `pending_unmapped(conn)` fara `account_id` + fara `require_login` → expune `cod_op_service`/
`denumire` cross-account (Risc #2/C6). Specul US-005 enumerase doar `_status_counts`/`fragment_submissions`/
`fragment_banner` si omisese acest fragment. → inapoi la EXECUTE (task fix).
**Fix (task #7):** `fragment_mapari``require_login(request)`; `_render_mapari(account_id)`
`pending_unmapped(conn, account_id)`; `post_mapare` paseaza consecvent contul sesiunii. 2 teste noi de
izolare pe 2 conturi (`tests/test_mapari_scope.py`).
**Runda 2 — PASS global (subagent NOU):**
- Suita: **357 passed**, 0 fail.
- Sweep anti-leak complet (toate rutele `routes.py` + `auth_routes.py`): fiecare ruta care atinge
`submissions`/`import_batches`/`column_mappings`/`operations_mapping` e sub `require_login` SI scoped pe
contul sesiunii. Publice intentionat: `/signup`, `/login`, `/logout`, `/_fragments/nomenclator` (global),
`/_import/reset` (template gol, fara DB). `fragment_mapari` fix confirmat.
- Criterii securitate critice re-verificate in cod: CSRF enforce in prod pe `/login`+`/signup` fara
`account_id` (US-009/C2); signup tranzactie atomica cu ROLLBACK pe email duplicat, fara cont orfan
(US-003/C10); `claim_one` `COALESCE(a.active,1)=1` cu LEFT JOIN, `account_id` NULL=activ (US-008/C14);
parola scrypt, niciodata in clar (US-001).
- E2E HTTP mod prod (`web_auth_required=true`): `GET /_fragments/mapari` fara cookie → 303 `/login`;
signup → cheie `rfak_` o data + cont `active=0` + log `SIGNUP cont=N`; cu sesiune → 200 doar contul propriu.
- Regresia de aur: `test_import_e2e.py` + `test_api.py` = 26 pass. Send live RAR neverificat (fara creds/retea
in mediul de VERIFY), dar acoperit de teste.
**Verdict: PASS.** Send live la RAR test ramane de confirmat manual la deploy (canal API + import → `FINALIZATA`).
### Code-review (CLOSE, /code-review high) — 3 findings reparate
- **[HIGH] `csrf_token` lipsa pe re-randarile de eroare** (`routes.py`): ramurile de eroare din
`web_upload_import`/`web_save_mapare_coloane`/`web_confirma_import` randau formularul fara `csrf_token`
→ in prod (user logat, CSRF enforce) campul ascuns gol → urmatorul submit 403 (lockout dupa orice eroare).
Fix: helper `_ctx(request, **extra)` care include mereu `csrf_token` + conversia tuturor ramurilor;
`require_login` reordonat inaintea `verify_csrf`. Test nou de regresie in mod prod.
- **[MEDIUM] `verify_password` ignora `scrypt_params` stocat** (`users.py`): folosea constantele curente,
anuland migrarea de cost (C9) — un bump viitor de `n` ar fi blocat toti userii existenti. Fix:
`_parse_scrypt_params` + verify cu parametrii din DB (eticheta corupta → `None`, fara crash). Test de migrare cost.
- **[MEDIUM] login fara rate-limit** (`auth_routes.py`): brute-force parole + DoS CPU (scrypt/cerere).
Fix: `check_rate_limit("login:"+ip, login_rate_max=10, ...)` → 429. (Extinde C5 dincolo de signup.)
Suita finala: **361 passed, 0 fail.** Findings low/by-design neactionate (documentate): dev-fallback cont 1
cand `web_auth_required=False` (C12, intentionat — atentie ops la deploy prod), 500 rar la DB-locked in signup,
`request.client is None` → bucket rate-limit 'unknown' partajat.

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