Compare commits
5 Commits
1c5b0cbc18
...
f149b24f96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f149b24f96 | ||
|
|
958b182e8e | ||
|
|
b92055eb01 | ||
|
|
504b490d3b | ||
|
|
748ab8b289 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,3 +75,6 @@ venv/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.gstack/
|
||||
|
||||
# --- Claude Code: config + memorie agenti (stare locala, nu artefact de proiect) ---
|
||||
.claude/
|
||||
|
||||
@@ -25,6 +25,7 @@ from ...db import get_connection
|
||||
from ...idempotency import build_key, canonicalize_row, idempotency_key
|
||||
from ...mapping import (
|
||||
account_or_default,
|
||||
account_scope_clause,
|
||||
has_no_auto_send,
|
||||
load_mapping_meta,
|
||||
pending_unmapped,
|
||||
@@ -130,36 +131,58 @@ def create_prezentari(
|
||||
|
||||
|
||||
@router.get("/prezentari")
|
||||
def list_prezentari(status: str | None = None, limit: int = 100) -> dict:
|
||||
def list_prezentari(
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
"FROM submissions WHERE status=? ORDER BY id DESC LIMIT ?",
|
||||
(status, limit),
|
||||
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
f"FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [status, limit],
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
"FROM submissions ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
f"SELECT id, status, id_prezentare, rar_status_code, retry_count, created_at, updated_at "
|
||||
f"FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?",
|
||||
scope_params + [limit],
|
||||
).fetchall()
|
||||
return {"submissions": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita (B4).
|
||||
# Exclude: rar_creds_enc, payload_json, idempotency_key, rar_error, sending_since.
|
||||
_PREZENTARE_FIELDS = frozenset({
|
||||
"id", "status", "id_prezentare", "rar_status_code", "retry_count",
|
||||
"next_attempt_at", "created_at", "updated_at", "account_id",
|
||||
"batch_id", "row_index", "purge_after",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/prezentari/{submission_id}")
|
||||
def get_prezentare(submission_id: int) -> dict:
|
||||
def get_prezentare(
|
||||
submission_id: int,
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM submissions WHERE id=?", (submission_id,)).fetchone()
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM submissions WHERE id=? AND {scope_sql}",
|
||||
[submission_id] + scope_params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
# B3: acelasi mesaj indiferent daca randul exista dar apartine altui cont
|
||||
# sau nu exista deloc — nu confirmam existenta.
|
||||
raise HTTPException(status_code=404, detail="submission inexistent")
|
||||
out = dict(row)
|
||||
out.pop("payload_json", None) # nu expunem payload-ul brut (PII) in listare
|
||||
return out
|
||||
row_dict = dict(row)
|
||||
return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -193,16 +216,20 @@ AUDIT_COLUMNS = [
|
||||
]
|
||||
|
||||
|
||||
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
||||
"""Randuri audit (sent implicit) filtrate pe data(updated_at) in [from, to].
|
||||
def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int):
|
||||
"""Randuri audit filtrate pe cont + data(updated_at) in [from, to].
|
||||
|
||||
payload_json e text in schelet (criptarea PII e P2); citim campurile-cheie
|
||||
pentru audit. b64_image NU intra in CSV (mare). Daca P2 cripteaza payload-ul,
|
||||
aici se decripteaza inainte de a construi randul.
|
||||
account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu
|
||||
account_id IS NULL apartin contului 1 (legacy/OV-2). payload_json e text in
|
||||
schelet; b64_image NU intra in CSV.
|
||||
"""
|
||||
sql = "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, created_at, updated_at, purge_after FROM submissions"
|
||||
where = []
|
||||
params: list = []
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
sql = (
|
||||
"SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, "
|
||||
"created_at, updated_at, purge_after FROM submissions"
|
||||
)
|
||||
where = [scope_sql]
|
||||
params: list = list(scope_params)
|
||||
if status != "all":
|
||||
where.append("status=?")
|
||||
params.append(status)
|
||||
@@ -212,7 +239,6 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
||||
if date_to:
|
||||
where.append("date(updated_at) <= date(?)")
|
||||
params.append(date_to)
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY id"
|
||||
|
||||
@@ -230,7 +256,8 @@ def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str):
|
||||
"submission_id": r["id"],
|
||||
"status": r["status"],
|
||||
"id_prezentare": r["id_prezentare"] or "",
|
||||
"account_id": r["account_id"] or "",
|
||||
# NULL→cont 1 (OV-2): coloana reflecta invariantul de scope, nu "" ambiguu.
|
||||
"account_id": account_or_default(r["account_id"]),
|
||||
"vin": p.get("vin") or "",
|
||||
"nr_inmatriculare": p.get("nr_inmatriculare") or "",
|
||||
"data_prestatie": p.get("data_prestatie") or "",
|
||||
@@ -248,11 +275,12 @@ def audit_export(
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
status: str = "sent",
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> StreamingResponse:
|
||||
"""CSV cu ce s-a trimis (audit). Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
|
||||
"""CSV audit scoped pe contul cheii API. Filtre optionale `date_from`/`date_to` (YYYY-MM-DD)
|
||||
|
||||
pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR);
|
||||
`status=all` exporta toata coada. Leaga re_tinerea 90 zile prin coloana
|
||||
`status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana
|
||||
`purge_after` (plan.md sect. 4 + 8). b64_image nu se exporta.
|
||||
"""
|
||||
conn = get_connection()
|
||||
@@ -260,7 +288,7 @@ def audit_export(
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS)
|
||||
writer.writeheader()
|
||||
for row in _audit_rows(conn, date_from, date_to, status):
|
||||
for row in _audit_rows(conn, date_from, date_to, status, account_id):
|
||||
writer.writerow(row)
|
||||
data = buf.getvalue()
|
||||
finally:
|
||||
@@ -275,31 +303,43 @@ def audit_export(
|
||||
|
||||
|
||||
@router.get("/mapari")
|
||||
def get_mapari(account_id: int | None = None) -> dict:
|
||||
def get_mapari(
|
||||
key_account: int = Depends(resolve_account_id),
|
||||
account_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Maparile operatie->cod ale contului curent.
|
||||
|
||||
Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul
|
||||
efectiv vine MEREU din cheia API (TD-3.2). Daca e prezent si difera -> 400.
|
||||
"""
|
||||
if account_id is not None and account_id != key_account:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="account_id din query nu corespunde contului cheii API",
|
||||
)
|
||||
conn = get_connection()
|
||||
try:
|
||||
if account_id is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service",
|
||||
(account_id,),
|
||||
(key_account,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM operations_mapping ORDER BY account_id, cod_op_service").fetchall()
|
||||
return {"mapari": [dict(r) for r in rows]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/mapari/pending")
|
||||
def get_mapari_pending() -> dict:
|
||||
def get_mapari_pending(
|
||||
account_id: int = Depends(resolve_account_id),
|
||||
) -> dict:
|
||||
"""Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy.
|
||||
|
||||
Alimenteaza editorul web. Fiecare intrare: {account_id, cod_op_service, denumire,
|
||||
blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
||||
Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service,
|
||||
denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
return {"pending": pending_unmapped(conn)}
|
||||
return {"pending": pending_unmapped(conn, account_id=account_id)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -34,6 +34,35 @@ class Settings(BaseSettings):
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
creds_key: str | None = None
|
||||
|
||||
# --- Sesiuni web (US-002, PRD 3.3) ---
|
||||
# Secret semnat cookie sesiune. None -> efemer la fiecare restart (dev ok;
|
||||
# in prod seteaza persistent ca si creds_key, altfel cookieurile se invalideaza
|
||||
# la restart). Genereaza: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
session_secret: str | None = None
|
||||
# True (IMPLICIT, sigur pentru prod): rutele web fara sesiune -> redirect /login;
|
||||
# CSRF enforce. Pentru dev rapid pe contul implicit id=1 (back-compat C12/§5 Q5),
|
||||
# seteaza explicit AUTOPASS_WEB_AUTH_REQUIRED=false.
|
||||
web_auth_required: bool = True
|
||||
# True (prod, in spatele Cloudflare Tunnel TLS): cookie cu Secure flag (C4).
|
||||
# False (dev): cookie fara Secure, functioneaza pe HTTP.
|
||||
session_https_only: bool = False
|
||||
|
||||
# --- Notificare email admin la signup (US-012, PRD 3.3b) ---
|
||||
# Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP);
|
||||
# follow-up cand exista SMTP real configurat in .env.
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from: str | None = None
|
||||
|
||||
# --- Rate-limit signup + login (US-009, PRD 3.3 C5) ---
|
||||
# Max cereri POST /signup per IP in fereastra de timp (in-proces, fara dependinta noua).
|
||||
signup_rate_max: int = 5
|
||||
signup_rate_window_s: int = 3600
|
||||
# Max incercari POST /login per IP (brute-force parole). Fereastra impartita cu signup.
|
||||
login_rate_max: int = 10
|
||||
|
||||
# --- RAR ---
|
||||
rar_env: str = "test" # "test" | "prod"
|
||||
rar_base_url_test: str = "https://apps.rarom.ro/test-rar-autopass"
|
||||
|
||||
16
app/db.py
16
app/db.py
@@ -68,6 +68,17 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"
|
||||
)
|
||||
|
||||
# Coloane users (DB cu users creata inaintea acestor coloane)
|
||||
user_tbl = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
).fetchone()
|
||||
if user_tbl:
|
||||
user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "is_admin" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||
if "email_verified" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
# Index batch_id pe submissions (poate lipsi pe DB veche)
|
||||
existing_idx = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
|
||||
@@ -77,6 +88,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_submissions_batch ON submissions(batch_id) "
|
||||
"WHERE batch_id IS NOT NULL"
|
||||
)
|
||||
if "idx_submissions_account_status" not in existing_idx:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_submissions_account_status "
|
||||
"ON submissions(account_id, status)"
|
||||
)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
|
||||
59
app/email.py
Normal file
59
app/email.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Helper notificare email admin la signup (US-012, PRD 3.3b).
|
||||
|
||||
Livrare DEGRADATA: daca smtp_host nu e configurat, functia e no-op (log doar).
|
||||
Orice eroare SMTP e prinsa si logata — signup-ul NU e blocat niciodata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import textwrap
|
||||
from email.message import EmailMessage
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
||||
"""Notifica adminii despre un cont nou in asteptare (best-effort).
|
||||
|
||||
Daca smtp_host e None SAU admin_emails e gol -> log si return (degradat).
|
||||
Daca SMTP ridica exceptie -> log eroare si return (NU se propaga).
|
||||
Timeout mic (5s) pe conexiunea SMTP.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.smtp_host or not admin_emails:
|
||||
print(
|
||||
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
|
||||
f"email={email} admins={len(admin_emails)}",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
expeditor = settings.smtp_from or settings.smtp_user or "autopass@localhost"
|
||||
msg["From"] = expeditor
|
||||
msg["To"] = ", ".join(admin_emails)
|
||||
msg["Subject"] = f"AutoPass: cont nou {account_id} in asteptare"
|
||||
msg.set_content(textwrap.dedent(f"""\
|
||||
Cont nou inregistrat si in asteptare de activare.
|
||||
|
||||
ID cont: {account_id}
|
||||
Email: {email}
|
||||
|
||||
Actioneaza din panoul admin /admin sau din CLI:
|
||||
python3 -m tools.account activate --account {account_id}
|
||||
"""))
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=5) as smtp:
|
||||
if settings.smtp_user and settings.smtp_password:
|
||||
smtp.starttls()
|
||||
smtp.login(settings.smtp_user, settings.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"SIGNUP-NOTIFY esuat cont={account_id}: {type(exc).__name__}",
|
||||
flush=True,
|
||||
)
|
||||
34
app/main.py
34
app/main.py
@@ -8,6 +8,7 @@ Pornire dev: uvicorn app.main:app --reload
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -16,6 +17,8 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from . import __version__
|
||||
from .api.v1.import_router import router as import_v1_router
|
||||
@@ -24,6 +27,10 @@ from .config import get_settings
|
||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||
from .security import install_log_redaction
|
||||
from .web.routes import router as web_router
|
||||
from .web.auth_routes import router as auth_router
|
||||
from .web.admin_routes import router as admin_router
|
||||
from .web.csrf import CsrfError
|
||||
from .web.session import AdminRequired, LoginRequired
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -35,6 +42,31 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan)
|
||||
|
||||
settings = get_settings()
|
||||
_session_secret = settings.session_secret or secrets.token_hex(32)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=_session_secret,
|
||||
session_cookie="autopass_session",
|
||||
https_only=settings.session_https_only,
|
||||
same_site="strict",
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(LoginRequired)
|
||||
async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@app.exception_handler(AdminRequired)
|
||||
async def admin_required_handler(request: Request, exc: AdminRequired) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
|
||||
|
||||
|
||||
@app.exception_handler(CsrfError)
|
||||
async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content={"detail": "CSRF invalid"})
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
@@ -59,6 +91,8 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
app.include_router(api_v1_router)
|
||||
app.include_router(import_v1_router)
|
||||
app.include_router(web_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
|
||||
@@ -129,6 +129,19 @@ def account_or_default(account_id: int | None) -> int:
|
||||
return account_id if account_id is not None else DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
def account_scope_clause(account_id: int) -> tuple[str, list]:
|
||||
"""Fragment SQL + params pentru filtrarea pe cont in tabele cu account_id nullable.
|
||||
|
||||
Aplica regula: NULL apartine contului 1 (legacy/OV-2).
|
||||
Foloseste DOAR pe submissions (account_id NULLABLE).
|
||||
NU folosi pe operations_mapping (account_id NOT NULL) — acolo WHERE account_id=? simplu.
|
||||
"""
|
||||
return (
|
||||
"(account_id = ? OR (account_id IS NULL AND ? = 1))",
|
||||
[account_id, account_id],
|
||||
)
|
||||
|
||||
|
||||
def seed_nomenclator_if_empty(conn) -> int:
|
||||
"""Seed fallback (18 coduri din contract) DOAR daca nomenclator_rar e gol.
|
||||
|
||||
@@ -217,15 +230,25 @@ def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def pending_unmapped(conn) -> list[dict]:
|
||||
def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
"""Operatii distincte nemapate, agregate din submission-urile `needs_mapping`.
|
||||
|
||||
Pentru fiecare (account_id, cod_op_service) intoarce o denumire reprezentativa,
|
||||
nr. de submission-uri blocate si sugestiile fuzzy pe nomenclator. Sursa de
|
||||
adevar = payload_json (nu o tabela separata): un item nemapat are cod_prestatie
|
||||
null + cod_op_service setat.
|
||||
account_id=None (default): global — intentionat pentru web/routes.py (back-compat).
|
||||
Apelantii noi din API TREBUIE sa paseze account_id explicit; None global e
|
||||
footgun (scurge cross-account) si e rezervat exclusiv pentru dashboard-ul intern.
|
||||
|
||||
account_id=int: filtreaza in SQL pe cont inclusiv randuri legacy (account_id IS NULL
|
||||
apartine contului 1, OV-2). Filtrarea in SQL, nu post-hoc in Python.
|
||||
"""
|
||||
nomenclator = load_nomenclator(conn)
|
||||
if account_id is not None:
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
rows = conn.execute(
|
||||
f"SELECT id, account_id, payload_json FROM submissions "
|
||||
f"WHERE status='needs_mapping' AND {scope_sql}",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, account_id, payload_json FROM submissions WHERE status='needs_mapping'"
|
||||
).fetchall()
|
||||
|
||||
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS submissions (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_account_status ON submissions(account_id, status);
|
||||
-- Nota: idx_submissions_batch se creeaza in _migrate (dupa ALTER care adauga batch_id pe DB veche).
|
||||
|
||||
-- Mapare coloane fisier -> campuri canonice (retinuta per cont, semnatura coloane).
|
||||
@@ -132,6 +133,19 @@ CREATE TABLE IF NOT EXISTS import_attestations (
|
||||
n_confirmed INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Utilizatori web (email+parola, legati de un cont). Parola stocata doar ca scrypt hash.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL, -- hex scrypt(salt, parola)
|
||||
salt TEXT NOT NULL, -- hex secrets.token_bytes(16), per-user
|
||||
scrypt_params TEXT NOT NULL, -- eticheta versiune parametri: 'n16384_r8_p1'
|
||||
email_verified INTEGER NOT NULL DEFAULT 0, -- C19: pregatire viitor
|
||||
is_admin INTEGER NOT NULL DEFAULT 0, -- pregatire 3.3b
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
|
||||
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
|
||||
167
app/users.py
Normal file
167
app/users.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Helper-e utilizatori web (email + parola scrypt). US-001 PRD 3.3.
|
||||
|
||||
Parola NICIODATA stocata in clar. Fiecare user are un salt per-user generat cu
|
||||
secrets.token_bytes(16). Parametrii scrypt stocati ca eticheta de versiune pentru
|
||||
migrare cost viitoare (C9).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
|
||||
SCRYPT_PARAMS = "n16384_r8_p1"
|
||||
_N = 2**14
|
||||
_R = 8
|
||||
_P = 1
|
||||
_DKLEN = 32
|
||||
_MAXMEM = 64 * 1024 * 1024
|
||||
|
||||
_PASSWORD_MIN = 10
|
||||
_PASSWORD_MAX = 128
|
||||
|
||||
|
||||
def _parse_scrypt_params(label: str) -> tuple[int, int, int] | None:
|
||||
"""Parseaza 'nN_rR_pP' -> (N, R, P). Returneaza None la format necunoscut/corupt."""
|
||||
try:
|
||||
parts = label.split("_")
|
||||
if len(parts) != 3 or parts[0][0] != "n" or parts[1][0] != "r" or parts[2][0] != "p":
|
||||
return None
|
||||
return (int(parts[0][1:]), int(parts[1][1:]), int(parts[2][1:]))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _scrypt_hash(password: str, salt: bytes, n: int = _N, r: int = _R, p: int = _P) -> bytes:
|
||||
return hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
maxmem=_MAXMEM,
|
||||
dklen=_DKLEN,
|
||||
)
|
||||
|
||||
|
||||
def create_user(
|
||||
conn: sqlite3.Connection,
|
||||
account_id: int,
|
||||
email: str,
|
||||
password: str,
|
||||
is_admin: bool = False,
|
||||
) -> int:
|
||||
"""Creeaza un user nou si intoarce id-ul.
|
||||
|
||||
Valideaza ca: contul exista, parola intre 10 si 128 caractere, emailul nu e duplicat.
|
||||
Stocheaza DOAR hash scrypt + salt (hex), niciodata parola in clar.
|
||||
Email duplicat (case-insensitive, via UNIQUE COLLATE NOCASE) -> ValueError.
|
||||
|
||||
is_admin: daca True, userul e marcat ca admin (is_admin=1). Apelantul decide
|
||||
logica de bootstrap (count_admins==0 -> primul cont devine admin).
|
||||
"""
|
||||
email = email.strip()
|
||||
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
|
||||
if len(password) < _PASSWORD_MIN:
|
||||
raise ValueError(f"parola prea scurta (minim {_PASSWORD_MIN} caractere)")
|
||||
if len(password) > _PASSWORD_MAX:
|
||||
raise ValueError(f"parola prea lunga (maxim {_PASSWORD_MAX} caractere, anti-DoS)")
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
pw_hash = _scrypt_hash(password, salt)
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params, is_admin) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(account_id, email, pw_hash.hex(), salt.hex(), SCRYPT_PARAMS, 1 if is_admin else 0),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
raise ValueError("email deja folosit")
|
||||
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def count_admins(conn: sqlite3.Connection) -> int:
|
||||
"""Numara userii cu is_admin=1 din intreaga baza."""
|
||||
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool = True) -> None:
|
||||
"""Seteaza/sterge rolul admin pe toti userii contului dat.
|
||||
|
||||
Ridica ValueError daca contul nu exista.
|
||||
Daca contul exista dar nu are useri, e no-op silentios (confom spec US-010).
|
||||
"""
|
||||
acct = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if not acct:
|
||||
raise ValueError(f"cont inexistent: {account_id}")
|
||||
conn.execute(
|
||||
"UPDATE users SET is_admin=? WHERE account_id=?",
|
||||
(1 if is_admin else 0, account_id),
|
||||
)
|
||||
|
||||
|
||||
def is_account_admin(conn: sqlite3.Connection, account_id: int) -> bool:
|
||||
"""Returneaza True daca cel putin un user al contului are is_admin=1."""
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM users WHERE account_id=? AND is_admin=1 LIMIT 1",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def list_admin_emails(conn: sqlite3.Connection) -> list[str]:
|
||||
"""Returneaza emailurile tuturor userilor cu is_admin=1 (folosit de US-012)."""
|
||||
rows = conn.execute(
|
||||
"SELECT email FROM users WHERE is_admin=1"
|
||||
).fetchall()
|
||||
return [row["email"] for row in rows]
|
||||
|
||||
|
||||
def verify_password(conn: sqlite3.Connection, email: str, password: str) -> int | None:
|
||||
"""Verifica parola pentru email. Intoarce account_id la potrivire, None altfel.
|
||||
|
||||
Nu distinge intre email inexistent si parola gresita (evita enumerare useri).
|
||||
Comparatie constant-time cu hmac.compare_digest.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT account_id, password_hash, salt, scrypt_params FROM users "
|
||||
"WHERE email=? COLLATE NOCASE",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
# Executa un hash dummy pentru a evita timing oracle pe email inexistent
|
||||
_scrypt_hash(password, b"\x00" * 16)
|
||||
return None
|
||||
|
||||
salt = bytes.fromhex(row["salt"])
|
||||
expected = bytes.fromhex(row["password_hash"])
|
||||
|
||||
params = _parse_scrypt_params(row["scrypt_params"] or "")
|
||||
if params is None:
|
||||
return None
|
||||
n, r, p = params
|
||||
actual = _scrypt_hash(password, salt, n=n, r=r, p=p)
|
||||
|
||||
if hmac.compare_digest(actual, expected):
|
||||
return int(row["account_id"])
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_email(conn: sqlite3.Connection, email: str) -> dict | None:
|
||||
"""Metadate user dupa email (FARA password_hash si salt)."""
|
||||
row = conn.execute(
|
||||
"SELECT id, account_id, email, is_admin, email_verified, created_at "
|
||||
"FROM users WHERE email=? COLLATE NOCASE",
|
||||
(email.strip(),),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
129
app/web/admin_routes.py
Normal file
129
app/web/admin_routes.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Panou admin web /admin. US-011 PRD 3.3b.
|
||||
|
||||
Rute:
|
||||
GET /admin — listeaza conturi in asteptare + active (require_admin)
|
||||
POST /admin/activate — activeaza un cont (require_admin + CSRF, PRG)
|
||||
POST /admin/deactivate — dezactiveaza un cont, nu permite id=1 (require_admin + CSRF, PRG)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import list_accounts, set_active
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.session import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
settings = get_settings()
|
||||
return {"rar_env": settings.rar_env, "version": __version__, **extra}
|
||||
|
||||
|
||||
def _emails_by_account(conn) -> dict[int, str | None]:
|
||||
"""Intoarce primul email per account_id, intr-un singur query (fara N+1)."""
|
||||
rows = conn.execute(
|
||||
"SELECT account_id, email FROM users ORDER BY id"
|
||||
).fetchall()
|
||||
result: dict[int, str | None] = {}
|
||||
for row in rows:
|
||||
acc_id = int(row["account_id"])
|
||||
if acc_id not in result:
|
||||
result[acc_id] = row["email"]
|
||||
return result
|
||||
|
||||
|
||||
def _render_admin(request: Request, conn, *, error: str | None = None, status_code: int = 200):
|
||||
"""Randeaza pagina admin.html cu lista de conturi si optional un mesaj de eroare."""
|
||||
accounts = list_accounts(conn)
|
||||
emails = _emails_by_account(conn)
|
||||
for acct in accounts:
|
||||
acct["email"] = emails.get(acct["id"])
|
||||
pending = [a for a in accounts if not a["active"] and a["id"] != 1]
|
||||
active = [a for a in accounts if a["active"] and a["id"] != 1]
|
||||
default = next((a for a in accounts if a["id"] == 1), None)
|
||||
return _TMPL.TemplateResponse(request, "admin.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
pending=pending,
|
||||
active=active,
|
||||
default_account=default,
|
||||
error=error,
|
||||
), status_code=status_code)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_get(request: Request):
|
||||
"""Panou admin: conturi in asteptare + active."""
|
||||
require_admin(request)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
return _render_admin(request, conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/admin/activate", response_class=HTMLResponse)
|
||||
async def admin_activate(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes."""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
set_active(conn, account_id, True)
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/deactivate", response_class=HTMLResponse)
|
||||
async def admin_deactivate(
|
||||
request: Request,
|
||||
account_id: int = Form(...),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
"""Dezactiveaza un cont. Nu permite dezactivarea contului default id=1. PRG: redirect 303."""
|
||||
require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
if account_id == 1:
|
||||
conn = get_connection()
|
||||
try:
|
||||
return _render_admin(
|
||||
request, conn,
|
||||
error="Contul default (id=1) nu poate fi dezactivat.",
|
||||
status_code=422,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
try:
|
||||
set_active(conn, account_id, False)
|
||||
except ValueError as exc:
|
||||
return _render_admin(request, conn, error=str(exc), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
170
app/web/auth_routes.py
Normal file
170
app/web/auth_routes.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Rute autentificare web: /signup (US-003), /login + /logout (US-004). PRD 3.3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..accounts import create_account
|
||||
from ..auth import create_api_key
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..email import notify_signup
|
||||
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.ratelimit import check_rate_limit
|
||||
from ..web.session import clear_session, set_session
|
||||
|
||||
router = APIRouter()
|
||||
_TMPL = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
_RATE_MSG = "Prea multe cereri. Incearca mai tarziu."
|
||||
_PASSWORD_MIN = 10
|
||||
|
||||
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
settings = get_settings()
|
||||
return {"rar_env": settings.rar_env, "version": __version__, **extra}
|
||||
|
||||
|
||||
# --- Signup ---
|
||||
|
||||
@router.get("/signup", response_class=HTMLResponse)
|
||||
async def signup_get(request: Request):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request, csrf_token=get_csrf_token(request)
|
||||
))
|
||||
|
||||
|
||||
@router.post("/signup", response_class=HTMLResponse)
|
||||
async def signup_post(
|
||||
request: Request,
|
||||
name: str = Form(default=""),
|
||||
cui: str = Form(default=""),
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit(ip, settings.signup_rate_max, settings.signup_rate_window_s):
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
name=name, cui=cui, email=email,
|
||||
), status_code=429)
|
||||
|
||||
if len(parola) < _PASSWORD_MIN:
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=f"Parola trebuie sa aiba cel putin {_PASSWORD_MIN} caractere.",
|
||||
name=name, cui=cui, email=email,
|
||||
), status_code=422)
|
||||
|
||||
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
|
||||
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
is_first = count_admins(conn) == 0
|
||||
account_id = create_account(conn, name, cui.strip() or None, active=False)
|
||||
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
|
||||
api_key = create_api_key(conn, account_id)
|
||||
conn.execute("COMMIT")
|
||||
except Exception as exc:
|
||||
conn.execute("ROLLBACK")
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=str(exc),
|
||||
name=name, cui=cui, email=email,
|
||||
), status_code=422)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
print(f"SIGNUP cont={account_id} email={email}", flush=True)
|
||||
|
||||
# Notificare email admin (best-effort, nu blocheaza signup-ul)
|
||||
try:
|
||||
conn2 = get_connection()
|
||||
try:
|
||||
admins = list_admin_emails(conn2)
|
||||
finally:
|
||||
conn2.close()
|
||||
notify_signup(admins, account_id, email)
|
||||
except Exception as exc_notify:
|
||||
print(f"SIGNUP-NOTIFY exceptie neasteptata cont={account_id}: {type(exc_notify).__name__}", flush=True)
|
||||
|
||||
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
api_key=api_key,
|
||||
account_id=account_id,
|
||||
))
|
||||
|
||||
|
||||
# --- Login / Logout ---
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_get(request: Request):
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request, csrf_token=get_csrf_token(request)
|
||||
))
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
async def login_post(
|
||||
request: Request,
|
||||
email: str = Form(default=""),
|
||||
parola: str = Form(default=""),
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
|
||||
settings = get_settings()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit("login:" + ip, settings.login_rate_max, settings.signup_rate_window_s):
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error=_RATE_MSG,
|
||||
), status_code=429)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
account_id = verify_password(conn, email, parola)
|
||||
if account_id is None:
|
||||
return _TMPL.TemplateResponse(request, "login.html", _ctx(
|
||||
request,
|
||||
csrf_token=get_csrf_token(request),
|
||||
error="Email sau parola incorecte.",
|
||||
), status_code=401)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE email=? COLLATE NOCASE", (email.strip(),)
|
||||
).fetchone()
|
||||
user_id = int(row["id"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout", response_class=HTMLResponse)
|
||||
async def logout_post(
|
||||
request: Request,
|
||||
csrf_token: str = Form(default=""),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
clear_session(request)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
51
app/web/csrf.py
Normal file
51
app/web/csrf.py
Normal 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
31
app/web/ratelimit.py
Normal 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
|
||||
@@ -22,6 +22,9 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import rotate_api_key
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
_already_sent_lookup,
|
||||
_build_idempotency_key,
|
||||
@@ -35,6 +38,7 @@ from ..crypto import decrypt_creds, encrypt_creds
|
||||
from ..db import get_connection, read_heartbeat
|
||||
from ..idempotency import build_key, canonicalize_row
|
||||
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
|
||||
from ..users import is_account_admin
|
||||
from ..mapping import (
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
account_or_default,
|
||||
@@ -55,11 +59,31 @@ templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "tem
|
||||
_BLOCKED = ("error", "needs_data", "needs_mapping")
|
||||
|
||||
|
||||
def _status_counts(conn) -> dict[str, int]:
|
||||
rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall()
|
||||
def _ctx(request: Request, **extra) -> dict:
|
||||
"""Context de baza pentru template-uri cu formulare: include mereu csrf_token.
|
||||
|
||||
Previne lock-out in prod (web_auth_required=True): orice re-randare de eroare
|
||||
trebuie sa includa csrf_token negol altfel urmatorul submit da 403 (task #8).
|
||||
"""
|
||||
return {"request": request, "csrf_token": get_csrf_token(request), **extra}
|
||||
|
||||
|
||||
def _status_counts(conn, account_id: int) -> dict[str, int]:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
||||
"GROUP BY status",
|
||||
(account_id, account_id),
|
||||
).fetchall()
|
||||
return {r["status"]: int(r["n"]) for r in rows}
|
||||
|
||||
|
||||
def _account_active(conn, account_id: int) -> bool:
|
||||
"""True daca contul e activ (sau legacy cu NULL/absent active)."""
|
||||
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return bool(row["active"]) if row else True
|
||||
|
||||
|
||||
def _worker_alive(hb) -> bool:
|
||||
if hb is None or not hb["last_beat"]:
|
||||
return False
|
||||
@@ -92,9 +116,10 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn)
|
||||
counts = _status_counts(conn, account_id)
|
||||
hb = read_heartbeat(conn)
|
||||
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
worker_alive = _worker_alive(hb)
|
||||
@@ -107,6 +132,9 @@ def dashboard(request: Request) -> HTMLResponse:
|
||||
"worker_alive": worker_alive,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"rar_state": _rar_state(hb, worker_alive),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
return templates.TemplateResponse("dashboard.html", ctx)
|
||||
finally:
|
||||
@@ -130,46 +158,62 @@ def fragment_nomenclator(request: Request) -> HTMLResponse:
|
||||
|
||||
@router.get("/_fragments/banner", response_class=HTMLResponse)
|
||||
def fragment_banner(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn)
|
||||
counts = _status_counts(conn, account_id)
|
||||
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
return templates.TemplateResponse("_banner.html", {"request": request, "blocked": blocked})
|
||||
return templates.TemplateResponse("_banner.html", {
|
||||
"request": request,
|
||||
"blocked": blocked,
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, updated_at "
|
||||
"FROM submissions ORDER BY id DESC LIMIT 100"
|
||||
"FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) "
|
||||
"ORDER BY id DESC LIMIT 100",
|
||||
(account_id, account_id),
|
||||
).fetchall()
|
||||
return templates.TemplateResponse("_submissions.html", {"request": request, "rows": rows})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_mapari(request: Request, conn, *, message: str | None = None) -> HTMLResponse:
|
||||
def _render_mapari(
|
||||
request: Request, conn, account_id: int, *, message: str | None = None
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
"_mapari.html",
|
||||
{
|
||||
"request": request,
|
||||
"pending": pending_unmapped(conn),
|
||||
"pending": pending_unmapped(conn, account_id),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": message,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/_fragments/mapari", response_class=HTMLResponse)
|
||||
def fragment_mapari(request: Request) -> HTMLResponse:
|
||||
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR."""
|
||||
"""Editor mapari: operatii ROAAUTO nemapate + sugestii fuzzy pe nomenclator RAR.
|
||||
|
||||
Scoped pe contul sesiunii (C6/task#7): pending_unmapped primeste account_id explicit.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
return _render_mapari(request, conn)
|
||||
return _render_mapari(request, conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -179,16 +223,18 @@ def post_mapare(
|
||||
request: Request,
|
||||
cod_op_service: str = Form(...),
|
||||
cod_prestatie: str = Form(...),
|
||||
account_id: int | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
auto_send: bool = Form(False),
|
||||
) -> HTMLResponse:
|
||||
"""Salveaza maparea aleasa de user, re-rezolva submission-urile blocate, re-randeaza editorul."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cod = cod_prestatie.strip().upper()
|
||||
exists = conn.execute("SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,)).fetchone()
|
||||
if not exists:
|
||||
return _render_mapari(request, conn, message=f"Cod necunoscut: {cod}")
|
||||
return _render_mapari(request, conn, account_id, message=f"Cod necunoscut: {cod}")
|
||||
save_mapping(conn, account_id, cod_op_service, cod, auto_send)
|
||||
stats = reresolve_account(conn, account_id)
|
||||
msg = (
|
||||
@@ -196,7 +242,7 @@ def post_mapare(
|
||||
f"Deblocate: {stats['requeued']} in coada, {stats['needs_data']} cu date lipsa, "
|
||||
f"{stats['still_blocked']} inca nemapate."
|
||||
)
|
||||
return _render_mapari(request, conn, message=msg)
|
||||
return _render_mapari(request, conn, account_id, message=msg)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -383,6 +429,7 @@ async def web_upload_import(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
sheet_name: str | None = Form(None),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Upload fisier xlsx/csv → staging; intoarce fragment HTML.
|
||||
|
||||
@@ -390,7 +437,8 @@ async def web_upload_import(
|
||||
Daca nu: intoarce formularul de mapare coloane.
|
||||
Nu editeaza import_router.py — apeleaza parse_file si DB direct.
|
||||
"""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
data = await file.read()
|
||||
@@ -400,30 +448,15 @@ async def web_upload_import(
|
||||
try:
|
||||
parsed = parse_file(data, filename, sheet_name=sheet_name)
|
||||
except MultipleSheets as ms:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"sheets": ms.sheet_names,
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, sheets=ms.sheet_names))
|
||||
except FileTooLarge as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": str(e),
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=str(e)))
|
||||
except HeaderError as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Antet neclar: {e}",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Antet neclar: {e}"))
|
||||
except UnicodeDecodeError as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Encoding nesuportat: {e.reason}",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Encoding nesuportat: {e.reason}"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=f"Fisier nerecunoscut (xlsx/csv): {type(e).__name__}"))
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -467,11 +500,13 @@ async def web_upload_import(
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": batch_id_int,
|
||||
"message": "Mapare retinuta aplicata automat.",
|
||||
"csrf_token": get_csrf_token(request),
|
||||
**result,
|
||||
})
|
||||
|
||||
@@ -491,6 +526,7 @@ async def web_upload_import(
|
||||
"fuzzy_suggestions": fuzzy_suggestions,
|
||||
"canonical_fields": _CANONICAL_FIELDS,
|
||||
"format_data": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -502,13 +538,14 @@ async def web_save_mapare_coloane(
|
||||
import_id: int,
|
||||
) -> HTMLResponse:
|
||||
"""Salveaza maparea de coloane si computa preview. Intoarce fragment HTML."""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
form = await request.form()
|
||||
|
||||
# Colectare perechi coloana fisier → camp canonic din form
|
||||
# form.getlist intoarce List[str | UploadFile]; filtram la str (campuri text)
|
||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||
colnames = [str(v) for v in form.getlist("colname") if isinstance(v, str)]
|
||||
canons = [str(v) for v in form.getlist("canon") if isinstance(v, str)]
|
||||
format_data_val = str(form.get("format_data") or "").strip() or None
|
||||
@@ -539,17 +576,17 @@ async def web_save_mapare_coloane(
|
||||
sugg = _fuzzy_suggest_column(col, limit=3)
|
||||
if sugg:
|
||||
fuzzy[col] = sugg
|
||||
return templates.TemplateResponse("_mapcoloane.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"columns": columns,
|
||||
"sample_rows": [],
|
||||
"fuzzy_suggestions": fuzzy,
|
||||
"canonical_fields": _CANONICAL_FIELDS,
|
||||
"format_data": format_data_val,
|
||||
"message": "Mapeaza cel putin un camp canonic inainte de a continua.",
|
||||
"error": True,
|
||||
})
|
||||
return templates.TemplateResponse("_mapcoloane.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
columns=columns,
|
||||
sample_rows=[],
|
||||
fuzzy_suggestions=fuzzy,
|
||||
canonical_fields=_CANONICAL_FIELDS,
|
||||
format_data=format_data_val,
|
||||
message="Mapeaza cel putin un camp canonic inainte de a continua.",
|
||||
error=True,
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -561,10 +598,9 @@ async def web_save_mapare_coloane(
|
||||
(import_id, acct),
|
||||
).fetchone()
|
||||
if not batch:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": "Batch de import inexistent sau expirat.",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error="Batch de import inexistent sau expirat."
|
||||
))
|
||||
|
||||
sig = _signature(list(json_mapare.keys()))
|
||||
|
||||
@@ -580,15 +616,10 @@ async def web_save_mapare_coloane(
|
||||
# Computa preview
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
**result,
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request, import_id=import_id, **result
|
||||
))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -599,7 +630,7 @@ def web_preview_import(
|
||||
import_id: int,
|
||||
) -> HTMLResponse:
|
||||
"""Preview 6 stari per rand. Tinta HTMX dupa mapare retinuta sau navigare directa."""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
@@ -607,10 +638,12 @@ def web_preview_import(
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": result,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
**result,
|
||||
})
|
||||
finally:
|
||||
@@ -620,7 +653,10 @@ def web_preview_import(
|
||||
@router.get("/_import/reset", response_class=HTMLResponse)
|
||||
def web_import_reset(request: Request) -> HTMLResponse:
|
||||
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
|
||||
return templates.TemplateResponse("_upload.html", {"request": request})
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/_import/{import_id}/confirma", response_class=HTMLResponse)
|
||||
@@ -632,11 +668,14 @@ async def web_confirma_import(
|
||||
|
||||
Replica logica din import_router.commit_import dar cu input din form HTML
|
||||
si raspuns HTML (nu JSON). INSERT per-rand ON CONFLICT DO NOTHING (TOCTOU).
|
||||
C8/OV-2: account_id din sesiune propagat consecvent la build_key si toate lookup-urile.
|
||||
C12: require_login — pe scrieri NICIODATA fallback cont 1 in prod.
|
||||
"""
|
||||
account_id = DEFAULT_ACCOUNT_ID
|
||||
account_id = require_login(request)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
form = await request.form()
|
||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||
|
||||
# Parseaza n_confirmat (form.get intoarce str | UploadFile | None → cast la str)
|
||||
try:
|
||||
@@ -662,16 +701,14 @@ async def web_confirma_import(
|
||||
(import_id, acct),
|
||||
).fetchone()
|
||||
if not batch:
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"error": "Batch de import inexistent sau expirat.",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, error="Batch de import inexistent sau expirat."
|
||||
))
|
||||
|
||||
if batch["status"] == "committed":
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"message": "Acest batch a fost deja comis.",
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request, message="Acest batch a fost deja comis."
|
||||
))
|
||||
|
||||
# Incarca randurile cu stare ok si needs_review
|
||||
ok_rows_db = conn.execute(
|
||||
@@ -684,14 +721,14 @@ async def web_confirma_import(
|
||||
# Re-arata preview cu eroare
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"message": "Niciun rand ok de confirmat in acest batch.",
|
||||
"error": True,
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
message="Niciun rand ok de confirmat in acest batch.",
|
||||
error=True,
|
||||
**result,
|
||||
})
|
||||
))
|
||||
|
||||
# Decripteaza si construieste lista de randuri de trimis
|
||||
to_enqueue: list[dict[str, Any]] = []
|
||||
@@ -726,26 +763,22 @@ async def web_confirma_import(
|
||||
f"Verifica preview-ul si retasteaza numarul corect."
|
||||
)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {"request": request, "error": msg})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"message": msg,
|
||||
"error": True,
|
||||
**result,
|
||||
})
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=msg))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request, import_id=import_id, message=msg, error=True, **result
|
||||
))
|
||||
|
||||
if n_total_ok == 0:
|
||||
result = _web_compute_preview(conn, import_id, account_id)
|
||||
if isinstance(result, str):
|
||||
return templates.TemplateResponse("_upload.html", {"request": request, "error": result})
|
||||
return templates.TemplateResponse("_preview_import.html", {
|
||||
"request": request,
|
||||
"import_id": import_id,
|
||||
"message": "Niciun rand ok de confirmat.",
|
||||
"error": True,
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
|
||||
return templates.TemplateResponse("_preview_import.html", _ctx(
|
||||
request,
|
||||
import_id=import_id,
|
||||
message="Niciun rand ok de confirmat.",
|
||||
error=True,
|
||||
**result,
|
||||
})
|
||||
))
|
||||
|
||||
# Incarca maparea de coloane pentru payload
|
||||
first_row_db = conn.execute(
|
||||
@@ -867,13 +900,129 @@ async def web_confirma_import(
|
||||
|
||||
# Succes → drop zone cu mesaj de confirmare
|
||||
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
|
||||
return templates.TemplateResponse("_upload.html", {
|
||||
"request": request,
|
||||
"message": (
|
||||
return templates.TemplateResponse("_upload.html", _ctx(
|
||||
request,
|
||||
message=(
|
||||
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
|
||||
f"Procesarea incepe in cateva secunde — urmareste coada de mai jos."
|
||||
),
|
||||
})
|
||||
))
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# US-007 — Sectiune "Contul meu": rotire cheie API + creds RAR din UI #
|
||||
# Rute web proprii scoped pe sesiune (C13: nu reutilizeaza /v1/conturi/rar-creds
|
||||
# care cere cheie API; sesiunea web e suficienta ca identitate). #
|
||||
# =========================================================================== #
|
||||
|
||||
def _render_cont(
|
||||
request: Request,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
are_creds: bool = False,
|
||||
creds_mesaj: str | None = None,
|
||||
creds_eroare: str | None = None,
|
||||
rot_eroare: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
|
||||
return templates.TemplateResponse(
|
||||
"_cont.html",
|
||||
_ctx(
|
||||
request,
|
||||
api_key=api_key,
|
||||
are_creds=are_creds,
|
||||
creds_mesaj=creds_mesaj,
|
||||
creds_eroare=creds_eroare,
|
||||
rot_eroare=rot_eroare,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/_fragments/cont", response_class=HTMLResponse)
|
||||
def fragment_cont(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
|
||||
account_id = require_login(request)
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
return _render_cont(request, are_creds=are_creds)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/cont/roteste-cheie", response_class=HTMLResponse)
|
||||
def cont_roteste_cheie(
|
||||
request: Request,
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Revoca toate cheile active si emite una noua. Afisata O SINGURA DATA."""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
new_key = rotate_api_key(conn, acct)
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
return _render_cont(request, api_key=new_key, are_creds=are_creds)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/cont/rar-creds", response_class=HTMLResponse)
|
||||
def cont_rar_creds(
|
||||
request: Request,
|
||||
rar_email: str = Form(""),
|
||||
rar_parola: str = Form(""),
|
||||
csrf_token: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Seteaza creds RAR per cont din sesiune (ruta web proprie, C13).
|
||||
|
||||
Camp parola NICIODATA re-pus in value= la re-randare.
|
||||
Validare minima: email si parola negoale.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
email = rar_email.strip()
|
||||
parola = rar_parola.strip()
|
||||
|
||||
if not email or not parola:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
finally:
|
||||
conn.close()
|
||||
return _render_cont(
|
||||
request,
|
||||
are_creds=are_creds,
|
||||
creds_eroare="Email si parola sunt obligatorii.",
|
||||
)
|
||||
|
||||
enc = encrypt_creds({"email": email, "password": parola})
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
(enc, acct),
|
||||
)
|
||||
return _render_cont(
|
||||
request,
|
||||
are_creds=True,
|
||||
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
99
app/web/session.py
Normal file
99
app/web/session.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Helper-e sesiune web. US-002 PRD 3.3.
|
||||
|
||||
Mecanism require_login (C11): NU un dependency FastAPI care intoarce RedirectResponse
|
||||
(acela nu scurtcircuiteaza handler-ul — FastAPI continua executia). In schimb:
|
||||
- require_login() RIDICA LoginRequired
|
||||
- app.main inregistreaza @app.exception_handler(LoginRequired) care intoarce
|
||||
RedirectResponse('/login', 303)
|
||||
Astfel handler-ul e intrerupt imediat la raise, independent de logica FastAPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..config import get_settings
|
||||
from ..mapping import DEFAULT_ACCOUNT_ID
|
||||
|
||||
|
||||
class LoginRequired(Exception):
|
||||
"""Ridica pentru a redirectiona la /login (prinsa de exception_handler in main.py)."""
|
||||
|
||||
|
||||
class AdminRequired(Exception):
|
||||
"""Ridica cand contul sesiunii nu are rol admin (prinsa de exception_handler in main.py)."""
|
||||
|
||||
|
||||
def current_account(request: Request) -> int | None:
|
||||
"""account_id din sesiune sau None daca nu e logat."""
|
||||
val = request.session.get("account_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
|
||||
def current_user_id(request: Request) -> int | None:
|
||||
"""user_id din sesiune sau None (C19: leaga import_attestations.confirmed_by)."""
|
||||
val = request.session.get("user_id")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
|
||||
def web_account(request: Request) -> int | None:
|
||||
"""account_id pentru rutele web de CITIRE.
|
||||
|
||||
- sesiune activa -> contul sesiunii
|
||||
- fara sesiune + web_auth_required=False (dev) -> DEFAULT_ACCOUNT_ID (cont 1, back-compat)
|
||||
- fara sesiune + web_auth_required=True (prod) -> None
|
||||
|
||||
Rutele de SCRIERE trebuie sa foloseasca require_login() direct, nu web_account(),
|
||||
ca sa nu cada niciodata tacit pe contul 1 in prod.
|
||||
"""
|
||||
aid = current_account(request)
|
||||
if aid is not None:
|
||||
return aid
|
||||
settings = get_settings()
|
||||
if not settings.web_auth_required:
|
||||
return DEFAULT_ACCOUNT_ID
|
||||
return None
|
||||
|
||||
|
||||
def require_login(request: Request) -> int:
|
||||
"""Verifica sesiunea activa; ridica LoginRequired daca nu.
|
||||
|
||||
Intoarce account_id la succes. Aruncatorul (exception_handler din main.py)
|
||||
intercepteaza LoginRequired si intoarce RedirectResponse('/login', 303).
|
||||
"""
|
||||
aid = web_account(request)
|
||||
if aid is None:
|
||||
raise LoginRequired()
|
||||
return aid
|
||||
|
||||
|
||||
def require_admin(request: Request) -> int:
|
||||
"""Verifica ca userul logat are rol admin pe contul sesiunii.
|
||||
|
||||
Intai cheama require_login (nelogat -> LoginRequired -> /login redirect).
|
||||
Daca e logat dar nu e admin -> ridica AdminRequired.
|
||||
Intoarce account_id la succes.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
from ..db import get_connection
|
||||
from ..users import is_account_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
admin = is_account_admin(conn, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if not admin:
|
||||
raise AdminRequired()
|
||||
return account_id
|
||||
|
||||
|
||||
def set_session(request: Request, account_id: int, user_id: int) -> None:
|
||||
"""Seteaza sesiunea dupa login. Curata mai intai (C3 anti-fixare sesiune)."""
|
||||
request.session.clear()
|
||||
request.session["account_id"] = account_id
|
||||
request.session["user_id"] = user_id
|
||||
|
||||
|
||||
def clear_session(request: Request) -> None:
|
||||
"""Sterge sesiunea (logout)."""
|
||||
request.session.clear()
|
||||
@@ -1,5 +1,12 @@
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
{% if not account_active %}
|
||||
<div class="card banner" style="border-color:var(--warn); background:#201c0f;"
|
||||
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
|
||||
<strong>Cont in asteptare de activare.</strong>
|
||||
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card banner {% if not blocked %}hidden{% endif %}"
|
||||
{% if account_active %}hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
|
||||
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
|
||||
Plasa de siguranta pe pene RAR > 30h. Verifica coada mai jos.
|
||||
</div>
|
||||
|
||||
76
app/web/templates/_cont.html
Normal file
76
app/web/templates/_cont.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<div class="card" id="card-cont">
|
||||
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
|
||||
|
||||
<!-- Sectiunea: Cheia mea API -->
|
||||
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
|
||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>
|
||||
|
||||
{% if api_key %}
|
||||
<div class="flash" style="margin-bottom:12px;">Cheia a fost rotita. Salveaz-o acum — nu o vei mai putea vedea.</div>
|
||||
|
||||
<div class="card" style="font-family:monospace; word-break:break-all; font-size:14px; background:#0f1115; margin:0 0 8px;">
|
||||
{{ api_key }}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-key="{{ api_key }}"
|
||||
onclick="navigator.clipboard.writeText(this.dataset.key).then(()=>this.textContent='Copiat!')">
|
||||
Copiaza cheia
|
||||
</button>
|
||||
|
||||
<p style="font-size:13px; color:var(--warn); margin:10px 0 0;">
|
||||
Atentie: la urmatoarea vizita aceasta cheie dispare. Daca o pierzi, roteste din nou.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if rot_eroare %}
|
||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ rot_eroare }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/cont/roteste-cheie"
|
||||
hx-target="#card-cont"
|
||||
hx-swap="outerHTML"
|
||||
style="margin-top:{% if api_key %}12px{% else %}0{% endif %};">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--warn); border-color:var(--warn);">
|
||||
Roteste cheia API
|
||||
</button>
|
||||
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Cheia veche se revoca imediat.</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectiunea: Credentiale RAR -->
|
||||
<div>
|
||||
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Credentiale RAR (portal AUTOPASS)</h3>
|
||||
|
||||
{% if are_creds %}
|
||||
<div class="flash" style="margin-bottom:12px;">Credentiale RAR configurate.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if creds_mesaj %}
|
||||
<div class="flash" style="margin-bottom:12px;">{{ creds_mesaj }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if creds_eroare %}
|
||||
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ creds_eroare }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/cont/rar-creds"
|
||||
hx-target="#card-cont"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p style="margin:0 0 8px;">
|
||||
<label style="font-size:13px; color:var(--muted);">Email RAR</label><br>
|
||||
<input type="email" name="rar_email" required style="width:100%; max-width:340px;"
|
||||
placeholder="email@service.ro">
|
||||
</p>
|
||||
<p style="margin:0 0 12px;">
|
||||
<label style="font-size:13px; color:var(--muted);">Parola RAR</label><br>
|
||||
<input type="password" name="rar_parola" required style="width:100%; max-width:340px;"
|
||||
autocomplete="new-password">
|
||||
</p>
|
||||
<button type="submit">Salveaza credentiale RAR</button>
|
||||
<span style="font-size:12px; color:var(--muted); margin-left:8px;">Parola stocata criptat, niciodata in clar.</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
{% set top = e.suggestions[0] if e.suggestions else None %}
|
||||
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
|
||||
<form class="maprow" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="account_id" value="{{ e.account_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
|
||||
|
||||
<div class="mapcol grow">
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
<div style="margin-bottom:8px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||
<label for="format-data" style="font-size:13px; color:var(--muted);">
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
hx-post="/_import/{{ import_id }}/confirma"
|
||||
hx-target="#import-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
|
||||
{% if sheets %}
|
||||
<div style="margin-bottom:12px;">
|
||||
|
||||
105
app/web/templates/admin.html
Normal file
105
app/web/templates/admin.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
|
||||
<h2 style="margin:0;">Panou admin</h2>
|
||||
<a href="/" class="cardlink muted">Inapoi la dashboard</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Conturi in asteptare -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3>
|
||||
{% if pending %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Companie</th>
|
||||
<th>CUI</th>
|
||||
<th>Email</th>
|
||||
<th>Inregistrat</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acct in pending %}
|
||||
<tr>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/activate" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit">Activeaza</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont in asteptare.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Conturi active -->
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3>
|
||||
{% if active %}
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Companie</th>
|
||||
<th>CUI</th>
|
||||
<th>Email</th>
|
||||
<th>Inregistrat</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for acct in active %}
|
||||
<tr>
|
||||
<td class="muted">{{ acct.id }}</td>
|
||||
<td>{{ acct.name }}</td>
|
||||
<td class="muted">{{ acct.cui or "—" }}</td>
|
||||
<td>{{ acct.email or "—" }}</td>
|
||||
<td class="muted">{{ acct.created_at or "—" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/deactivate" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="account_id" value="{{ acct.id }}">
|
||||
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Contul dev default (id=1) -->
|
||||
{% if default_account %}
|
||||
<div class="card" style="border-color:var(--muted);">
|
||||
<p class="muted" style="margin:0;font-size:13px;">
|
||||
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
|
||||
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem).
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Nav cont: link admin (doar pentru admini) + logout -->
|
||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;">
|
||||
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
|
||||
<form method="post" action="/logout" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow -->
|
||||
{% include '_upload.html' %}
|
||||
|
||||
@@ -32,6 +41,10 @@
|
||||
<div class="card"><div class="empty">se incarca mapari…</div></div>
|
||||
</div>
|
||||
|
||||
<div hx-get="/_fragments/cont" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="card"><div class="empty">se incarca cont…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
|
||||
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
|
||||
|
||||
28
app/web/templates/login.html
Normal file
28
app/web/templates/login.html
Normal 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 %}
|
||||
73
app/web/templates/signup.html
Normal file
73
app/web/templates/signup.html
Normal 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 %}
|
||||
@@ -140,8 +140,12 @@ def claim_one(conn) -> dict | None:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, account_id, payload_json, rar_creds_enc FROM submissions WHERE status='queued' "
|
||||
"AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY id LIMIT 1",
|
||||
"SELECT s.id, s.account_id, s.payload_json, s.rar_creds_enc "
|
||||
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
|
||||
"WHERE s.status='queued' "
|
||||
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
|
||||
"AND COALESCE(a.active, 1) = 1 "
|
||||
"ORDER BY s.id LIMIT 1",
|
||||
(_iso(_now()),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
|
||||
@@ -48,7 +48,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
||||
> PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata:
|
||||
> schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare".
|
||||
|
||||
**Ultima actualizare**: 2026-06-17 — 3.1 LIVRAT (CLI `tools/account.py` + `accounts.active` + index unic CUI + helper-e `app/accounts.py`; 299 teste pass). Urmeaza 3.2. Deferat din 3.1 (P3, fara SQL manual): `rename`/`set-cui` (corectie typo), `--if-not-exists` (provisioning idempotent); `set-password --account N` se implementeaza in 3.3 cu `app/users.py`.
|
||||
**Ultima actualizare**: 2026-06-18 — 3.3b LIVRAT (self-service cheie/creds + admin web + email). US-007 rute web proprii pentru rotire cheie + setare creds RAR scoped pe sesiune (C13, nu endpointul API). US-010 rol admin (`users.is_admin`) + `require_admin`→`AdminRequired`→403 + CLI `tools/account.py set-admin` + bootstrap automat (primul cont care se inregistreaza = admin, citit in `BEGIN IMMEDIATE` anti-race). US-011 panou `/admin` (conturi in asteptare/active, activare/dezactivare cu CSRF + PRG, contul dev id=1 protejat) + link "Panou admin" pe dashboard doar pentru admini + buton logout. US-012 `app/email.py notify_signup` best-effort DEGRADAT fara SMTP (no-op + log, prinde orice exceptie, nu blocheaza signup) + config `smtp_*`. Fix migrare defensiva `users.is_admin`/`email_verified` in `_migrate` (gap prins de VERIFY r1, ca C1 pe `accounts.active`). 2 runde VERIFY context curat (r2 PASS, sweep securitate toate rutele noi sub require_login/require_admin + CSRF, scoped sesiune). `/code-review` high: TOCTOU bootstrap mutat in tranzactie + `_render_admin` extras (anti-duplicare + N+1). 393 teste pass. Urmeaza Etapa 4 (4.1 mapare AI/MCP). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`. SMTP real = follow-up pe US-012.
|
||||
|
||||
> 3.3a LIVRAT (self-onboarding web core: `app/users.py` parole scrypt cu eticheta de parametri onorata la verify; `SessionMiddleware` same_site=strict + `app/web/session.py` guard `require_login`→`LoginRequired`; CSRF per-sesiune enforce in prod inclusiv pe login/signup + rate-limit signup & login in-proces; signup `active=0` tranzactie atomica + cheie-o-data + log `SIGNUP`; login/logout; dashboard & import multi-tenant scoped pe sesiune cu regula NULL→cont 1 — toate rutele web care ating date sensibile sub `require_login` + scope; gate worker `claim_one` `LEFT JOIN ... COALESCE(active,1)=1`. 2 runde VERIFY context curat — runda 1 a prins un leak cross-account pe `/_fragments/mapari`, reparat; runda 2 PASS. `/code-review` high a prins 3 findings, reparate. 361 teste pass). Urmeaza 3.3b (self-service cheie/creds + admin web + email). Deferat din 3.1 (P3): `rename`/`set-cui`, `--if-not-exists`.
|
||||
|
||||
### Etapa 1 — Canal API ROAAUTO (Treapta 1)
|
||||
|
||||
@@ -73,8 +75,9 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi
|
||||
| # | Livrabila | Status | Data | Detalii |
|
||||
|---|-----------|--------|------|---------|
|
||||
| 3.1 | Creare cont nou (CLI dedicat) | DONE | 2026-06-17 | CLI `tools/account.py` (create/list[--pending]/activate/deactivate, `--with-key` atomic) + `accounts.active` + index unic CUI + `app/accounts.py`. 20 teste noi. PRD: [prd-3.1](prd/prd-3.1-creare-cont.md) |
|
||||
| 3.2 | Filtrare pe cont a GET-urilor de listare | TODO (PRD aprobat) | | scope cheie pe `/v1/prezentari`, `/v1/mapari`, `/v1/audit/export`; nomenclator global. PRD: [prd-3.2](prd/prd-3.2-filtrare-cont-get.md) |
|
||||
| 3.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.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.3a | Self-onboarding web (core) | DONE | 2026-06-17 | `users` (scrypt) + sesiune (`SessionMiddleware`, same_site=strict) + CSRF (enforce prod, inclusiv login/signup) + rate-limit signup/login + signup/login/logout + dashboard & import scoped pe sesiune (NULL→1, anti-leak C6) + gate worker `active=0` (`COALESCE`). 2 runde VERIFY (leak `/_fragments/mapari` prins+reparat) + code-review (csrf erori, scrypt_params, login rate-limit). 361 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
|
||||
| 3.3b | Self-service cheie/creds + admin web + email | DONE | 2026-06-18 | US-007 (rute web proprii `/cont/roteste-cheie`+`/cont/rar-creds` scoped sesiune, C13), US-010 (rol admin `is_admin` + `require_admin`→403 + CLI `set-admin` + bootstrap primul cont=admin), US-011 (`/admin` activare/dezactivare cu CSRF+PRG, link doar pt admini + logout), US-012 (`app/email.py` notify best-effort degradat fara SMTP + log `SIGNUP`). Fix migrare defensiva `users.is_admin`/`email_verified`. 2 runde VERIFY context curat (r1 a prins migrarea lipsa, reparat; r2 PASS) + `/code-review` high (TOCTOU bootstrap admin mutat in tranzactie + extras `_render_admin` anti-duplicare/N+1). 393 teste. PRD: [prd-3.3](prd/prd-3.3-self-onboarding-web.md) |
|
||||
|
||||
### Etapa 4 — Viitor (Treapta 3)
|
||||
|
||||
|
||||
@@ -294,6 +294,19 @@ de 18 coduri la boot (`app/nomenclator_seed.py`) ca editorul sa mearga offline.
|
||||
Auth API-key (CORE) inca neimplementat -> `account_id` curge ca `NULL` si e atribuit
|
||||
contului default `id=1` (seed in schema); cand auth livreaza, account_id real curge natural.
|
||||
|
||||
## Regula de scope pe cont (B8, PRD 3.2)
|
||||
|
||||
Orice GET nou pe `/v1/*` care atinge `submissions` sau `operations_mapping` **PORNESTE**
|
||||
cu `account_id: int = Depends(resolve_account_id)` si clauza de scope pe cont in SQL.
|
||||
Varianta globala (fara scope) e exceptie justificata explicit — singurul exemplu actual
|
||||
este `GET /v1/nomenclator` (cache de referinta RAR fara PII, partajat intre conturi).
|
||||
|
||||
Pentru `submissions` (account_id nullable): foloseste `account_scope_clause(account_id)`
|
||||
din `app/mapping.py` care produce `(account_id = ? OR (account_id IS NULL AND ? = 1))`.
|
||||
Randurile legacy cu `account_id IS NULL` apartin contului 1 (OV-2, back-compat).
|
||||
|
||||
Pentru `operations_mapping` (account_id NOT NULL): `WHERE account_id = ?` simplu.
|
||||
|
||||
## Open questions rămase (actualizat)
|
||||
|
||||
1. ~~Sursa pozei odometrului~~ — **închis** (poză opțională).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 3.2 — Filtrare pe cont a GET-urilor de listare
|
||||
|
||||
**Stare**: aprobat
|
||||
**Stare**: inchis
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
@@ -39,11 +39,11 @@ contul meu **pentru ca** un client nu trebuie sa vada coada altui client.
|
||||
`test_detaliu_cross_account_404`, `test_legacy_null_vizibil_pentru_cont_1`,
|
||||
`test_fara_cheie_flag_off_vede_contul_1`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`.
|
||||
- [ ] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`).
|
||||
- [ ] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta).
|
||||
- [ ] Cheie A nu vede submission-uri ale contului B (lista si detaliu).
|
||||
- [ ] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev).
|
||||
- [x] Ambele rute primesc `account_id: int = Depends(resolve_account_id)`.
|
||||
- [x] `GET /v1/prezentari` adauga `WHERE` pe cont (NULL→cont 1) la ambele ramuri (cu/fara `status`).
|
||||
- [x] `GET /v1/prezentari/{id}` al altui cont → **404** (nu 403 — nu confirmam existenta).
|
||||
- [x] Cheie A nu vede submission-uri ale contului B (lista si detaliu).
|
||||
- [x] `require_api_key=false` fara cheie → vede contul 1 (back-compat dev).
|
||||
- **Verificare E2E**: doua chei (conturi distincte, via 3.1) → `POST` pe fiecare → `GET /v1/prezentari`
|
||||
cu cheia A nu contine id-urile contului B.
|
||||
|
||||
@@ -57,11 +57,11 @@ azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pen
|
||||
- **Test intai (RED)**: `tests/test_get_scope_mapari.py` — `test_mapari_ignora_query_account_id`,
|
||||
`test_mapari_doar_contul_cheii`, `test_pending_doar_contul_cheii`, `test_pending_web_global_neschimbat`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query
|
||||
- [x] `GET /v1/mapari` foloseste `Depends(resolve_account_id)`; parametrul `account_id` din query
|
||||
este **eliminat** (un cont nu poate citi maparile altuia trecand un id arbitrar).
|
||||
- [ ] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web,
|
||||
- [x] `pending_unmapped(conn, account_id=None)` capata param optional: `None` = global (web,
|
||||
back-compat), valoare = filtrare pe cont. `GET /v1/mapari/pending` paseaza contul cheii.
|
||||
- [ ] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de
|
||||
- [x] Apelul web `pending_unmapped(conn)` din `routes.py` ramane neatins (global) — confirmat de
|
||||
`test_pending_web_global_neschimbat`.
|
||||
- **Verificare E2E**: cheie A cu o mapare; cheie B → `GET /v1/mapari` (B) nu contine maparea lui A.
|
||||
|
||||
@@ -74,10 +74,10 @@ azi `/v1/mapari?account_id=` accepta `account_id` din query (spoofabil) si `/pen
|
||||
- **Test intai (RED)**: `tests/test_get_scope_audit.py` — `test_export_doar_contul_cheii`,
|
||||
`test_export_legacy_null_pentru_cont_1`, `test_export_status_all_tot_scoped`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont
|
||||
- [x] `audit_export` primeste `Depends(resolve_account_id)`; `_audit_rows` filtreaza pe cont
|
||||
(NULL→cont 1) pe langa filtrele de data/status existente.
|
||||
- [ ] `status=all` ramane scoped pe cont (nu exporta global).
|
||||
- [ ] Randurile contului B nu apar in CSV-ul cerut cu cheia A.
|
||||
- [x] `status=all` ramane scoped pe cont (nu exporta global).
|
||||
- [x] Randurile contului B nu apar in CSV-ul cerut cu cheia A.
|
||||
- **Verificare E2E**: `POST` pe doua conturi → `GET /v1/audit/export` (cheie A) → CSV fara VIN-urile B.
|
||||
|
||||
## 4. Riscuri
|
||||
@@ -171,4 +171,25 @@ cheii → **400** explicit (nu schimbare tacita). AC US-002 actualizat:
|
||||
## Raport VERIFY
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
|
||||
|
||||
**Verdict global: PASS** (verificator independent, context curat, 2026-06-17).
|
||||
|
||||
- **Suita**: `python3 -m pytest -q` → **313 passed**, 0 fail. Teste noi 3.2: 14 passed.
|
||||
- **Regresia de aur**: 313 verzi — POST `/v1/prezentari`, import, worker neatinse; calea de send nu e modificata.
|
||||
|
||||
| Criteriu | Verdict | Dovada |
|
||||
|----------|---------|--------|
|
||||
| US-001: `list_prezentari` scoped pe cont (ambele ramuri) | PASS | `router.py` + `test_lista_doar_contul_cheii` |
|
||||
| US-001: `GET /{id}` alt cont → 404 | PASS | `test_detaliu_cross_account_404` |
|
||||
| US-001: back-compat dev (fara cheie → cont 1) | PASS | `test_fara_cheie_flag_off_vede_contul_1` |
|
||||
| US-002: `GET /mapari` scoped; `?account_id` difera → 400 (TD-3.2) | PASS | `test_mapari_query_account_id_diferit_400` / `_egal_ok` |
|
||||
| US-002: web `pending_unmapped(conn)` ramane global | PASS | `routes.py:160` neatins + `test_pending_web_global_neschimbat` |
|
||||
| US-003: `audit/export` + `status=all` scoped | PASS | 3 teste `test_get_scope_audit` |
|
||||
| B1: `pending_unmapped` filtreaza IN SQL (nu Python) | PASS | `test_pending_filtreaza_in_sql_cu_regula_null` |
|
||||
| B2: `account_scope_clause` DOAR pe submissions; `get_mapari` `WHERE account_id=?` simplu | PASS | `mapping.py` + `router.py` |
|
||||
| B3: 404 cross-account byte-identic cu 404 inexistent | PASS | un singur `detail`; test explicit |
|
||||
| B4: `get_prezentare` allowlist (exclude creds/payload/idempotency/error) | PASS | `_PREZENTARE_FIELDS` + `test_detaliu_nu_expune_creds` |
|
||||
| B5: index `idx_submissions_account_status` in schema.sql + `_migrate` | PASS | `schema.sql` + `db.py` |
|
||||
| B8: regula scope documentata in `api-rar-contract.md` | PASS | sectiune "Regula de scope pe cont (B8, PRD 3.2)" |
|
||||
|
||||
**Rezerva (acceptata):** trimiterea LIVE la RAR test (FINALIZATA) nu a rulat — lipsa `.env`/credentiale RAR in mediu. Schimbarile 3.2 ating EXCLUSIV GET-uri de citire (POST/worker/send neatinse), deci regresia E2E e acoperita integral de suita automata. De re-confirmat la urmatorul deploy cu creds.
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# PRD 3.3 — Self-onboarding web (login email+parola → emite cheie)
|
||||
|
||||
**Stare**: aprobat
|
||||
**Stare**: inchis (3.3a + 3.3b livrate)
|
||||
|
||||
> **Decizii la poarta EXECUTE (2026-06-17, confirmate de utilizator):**
|
||||
> - **Livrabila sparta in doua faze** (scope 12 stories prea mare pentru un singur EXECUTE):
|
||||
> - **3.3a (in executie acum)** — self-onboarding core: US-001, US-002, US-009, US-003, US-004,
|
||||
> US-005, US-006a, US-006b, US-008. Commit + VERIFY propriu.
|
||||
> - **3.3b (urmeaza)** — admin web + email: US-010, US-011, US-012. Commit + VERIFY propriu.
|
||||
> - **Bootstrap admin (US-010, 3.3b):** primul cont creat devine automat admin (`is_admin=1`).
|
||||
> - **US-012 email:** livrare DEGRADATA fara SMTP — doar log `SIGNUP cont=N email=...` (C16) +
|
||||
> `/admin` (US-011) + `tools/account.py list --pending`. Trimiterea efectiva = follow-up cand exista SMTP.
|
||||
> - Prerechizita C1 confirmata: 3.1 livrat (`app/accounts.py`, `tools/account.py`, `active` migrat in `_migrate`).
|
||||
|
||||
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
|
||||
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
|
||||
@@ -407,7 +417,105 @@ Val 4: [US-007, US-011 admin panou, US-012 email] (US-007/011/012 peste US-010
|
||||
- **SMTP (US-012):** ce provider/expeditor? Daca nu exista SMTP la momentul executiei, US-012 se
|
||||
livreaza degradat (doar log + `/admin` + `list --pending`) si email-ul devine follow-up.
|
||||
|
||||
## Raport VERIFY
|
||||
## Progres executie 3.3a (lead)
|
||||
|
||||
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
|
||||
> PASS/FAIL per criteriu, cu dovezi. Lipseste pana la VERIFY.
|
||||
> Sub-livrabila 3.3a (self-onboarding core). Toate stories GREEN, regresie 355 pass (de la 313 baseline).
|
||||
|
||||
- [x] **US-001** — schema `users` (+ `email_verified`, `is_admin` pregatire 3.3b) + `app/users.py` (scrypt n=2^14, plafon 128 char, hmac.compare_digest). 6 teste.
|
||||
- [x] **US-002** — `SessionMiddleware` (same_site=strict, https_only config) + `app/web/session.py` (`current_account`/`current_user_id`/`web_account`/`require_login`→`LoginRequired`/`set_session` clear-inainte C3). 6 teste.
|
||||
- [x] **US-009** — `app/web/csrf.py` (token per-sesiune, `verify_csrf` gateat pe MOD: prod sau sesiune autentificata) + `app/web/ratelimit.py` (fereastra glisanta in-proces) + handler `CsrfError`→403. Gate-ul inchide login/signup CSRF in prod (C2). 8 teste.
|
||||
- [x] **US-003** — `GET/POST /signup` (`auth_routes.py` + `signup.html`): tranzactie atomica C10, log `SIGNUP` C16, cheie-o-data + gate C15, banner pozitiv C17. 4 teste.
|
||||
- [x] **US-004** — `GET/POST /login` + `POST /logout`: mesaj generic la esec, login pe `active=0` intra (C18), clear sesiune C3. 4 teste.
|
||||
- [x] **US-005** — dashboard scoped pe sesiune (`_status_counts`/`fragment_submissions`/`fragment_banner` cu regula NULL→1, C6) + banner "cont in asteptare" (US-008 AC#3, reframe C17). nomenclator ramane global. 4 teste.
|
||||
- [x] **US-006a** — citiri import (`upload`/`mapare-coloane`/`preview`) pe `web_account(request)`; batch cross-cont inaccesibil.
|
||||
- [x] **US-006b** — scrieri (`confirma` + `/mapari`) pe sesiune; propagare consecventa `account_id` la `build_key` (C8/OV-2); `verify_csrf` + camp ascuns pe toate formularele; zero atribuiri `DEFAULT_ACCOUNT_ID` in handlere (C6). 5 teste.
|
||||
- [x] **US-008** — gate worker `claim_one` cu `LEFT JOIN accounts ... COALESCE(active,1)=1` (dublu-NULL C14): cont inactiv → submission ramane `queued`; activare → eligibil fara re-enqueue. 5 teste.
|
||||
|
||||
3.3b (US-007 self-service cheie/creds + US-010/011/012 admin web + email) ramane livrabila separata.
|
||||
|
||||
## Raport VERIFY (3.3a)
|
||||
|
||||
> Doua runde de verificare independenta (subagent context curat, §5.6).
|
||||
|
||||
**Runda 1 — FAIL (1 criteriu):** suita 355 pass, dar sweep-ul anti-leak a gasit `GET /_fragments/mapari`
|
||||
nescoped: `pending_unmapped(conn)` fara `account_id` + fara `require_login` → expune `cod_op_service`/
|
||||
`denumire` cross-account (Risc #2/C6). Specul US-005 enumerase doar `_status_counts`/`fragment_submissions`/
|
||||
`fragment_banner` si omisese acest fragment. → inapoi la EXECUTE (task fix).
|
||||
|
||||
**Fix (task #7):** `fragment_mapari` → `require_login(request)`; `_render_mapari(account_id)` →
|
||||
`pending_unmapped(conn, account_id)`; `post_mapare` paseaza consecvent contul sesiunii. 2 teste noi de
|
||||
izolare pe 2 conturi (`tests/test_mapari_scope.py`).
|
||||
|
||||
**Runda 2 — PASS global (subagent NOU):**
|
||||
- Suita: **357 passed**, 0 fail.
|
||||
- Sweep anti-leak complet (toate rutele `routes.py` + `auth_routes.py`): fiecare ruta care atinge
|
||||
`submissions`/`import_batches`/`column_mappings`/`operations_mapping` e sub `require_login` SI scoped pe
|
||||
contul sesiunii. Publice intentionat: `/signup`, `/login`, `/logout`, `/_fragments/nomenclator` (global),
|
||||
`/_import/reset` (template gol, fara DB). `fragment_mapari` fix confirmat.
|
||||
- Criterii securitate critice re-verificate in cod: CSRF enforce in prod pe `/login`+`/signup` fara
|
||||
`account_id` (US-009/C2); signup tranzactie atomica cu ROLLBACK pe email duplicat, fara cont orfan
|
||||
(US-003/C10); `claim_one` `COALESCE(a.active,1)=1` cu LEFT JOIN, `account_id` NULL=activ (US-008/C14);
|
||||
parola scrypt, niciodata in clar (US-001).
|
||||
- E2E HTTP mod prod (`web_auth_required=true`): `GET /_fragments/mapari` fara cookie → 303 `/login`;
|
||||
signup → cheie `rfak_` o data + cont `active=0` + log `SIGNUP cont=N`; cu sesiune → 200 doar contul propriu.
|
||||
- Regresia de aur: `test_import_e2e.py` + `test_api.py` = 26 pass. Send live RAR neverificat (fara creds/retea
|
||||
in mediul de VERIFY), dar acoperit de teste.
|
||||
|
||||
**Verdict: PASS.** Send live la RAR test ramane de confirmat manual la deploy (canal API + import → `FINALIZATA`).
|
||||
|
||||
### Code-review (CLOSE, /code-review high) — 3 findings reparate
|
||||
|
||||
- **[HIGH] `csrf_token` lipsa pe re-randarile de eroare** (`routes.py`): ramurile de eroare din
|
||||
`web_upload_import`/`web_save_mapare_coloane`/`web_confirma_import` randau formularul fara `csrf_token`
|
||||
→ in prod (user logat, CSRF enforce) campul ascuns gol → urmatorul submit 403 (lockout dupa orice eroare).
|
||||
Fix: helper `_ctx(request, **extra)` care include mereu `csrf_token` + conversia tuturor ramurilor;
|
||||
`require_login` reordonat inaintea `verify_csrf`. Test nou de regresie in mod prod.
|
||||
- **[MEDIUM] `verify_password` ignora `scrypt_params` stocat** (`users.py`): folosea constantele curente,
|
||||
anuland migrarea de cost (C9) — un bump viitor de `n` ar fi blocat toti userii existenti. Fix:
|
||||
`_parse_scrypt_params` + verify cu parametrii din DB (eticheta corupta → `None`, fara crash). Test de migrare cost.
|
||||
- **[MEDIUM] login fara rate-limit** (`auth_routes.py`): brute-force parole + DoS CPU (scrypt/cerere).
|
||||
Fix: `check_rate_limit("login:"+ip, login_rate_max=10, ...)` → 429. (Extinde C5 dincolo de signup.)
|
||||
|
||||
Suita finala: **361 passed, 0 fail.** Findings low/by-design neactionate (documentate): dev-fallback cont 1
|
||||
cand `web_auth_required=False` (C12, intentionat — atentie ops la deploy prod), 500 rar la DB-locked in signup,
|
||||
`request.client is None` → bucket rate-limit 'unknown' partajat.
|
||||
|
||||
## Progres executie 3.3b (lead)
|
||||
|
||||
> Sub-livrabila 3.3b (self-service cheie/creds + admin web + email). Decizii confirmate la poarta:
|
||||
> primul cont care se inregistreaza devine admin (bootstrap automat); US-012 livrare DEGRADATA fara
|
||||
> SMTP (helper `app/email.py` best-effort no-op + log `SIGNUP` deja existent din 3.3a).
|
||||
|
||||
Valuri (fisiere disjuncte intre stories paralele):
|
||||
- **Val 1:** US-010 (`users.py`/`session.py`/`tools/account.py`/`main.py` handler) ‖ US-007 (`routes.py`/`_cont.html`/`dashboard.html`)
|
||||
- **Val 2:** US-011 (`admin_routes.py` nou/`admin.html`/`main.py` register) ‖ US-012 (`email.py` nou/`config.py`/`auth_routes.py`)
|
||||
|
||||
- [x] **US-010** — rol admin (`is_admin`) + helper-e (`count_admins`/`set_admin`/`is_account_admin`/`list_admin_emails`) + `require_admin`→`AdminRequired`→403 + CLI `set-admin`. 13 teste. Bootstrap (primul cont=admin) cablat in signup de US-012 (evita conflict pe auth_routes.py).
|
||||
- [x] **US-007** — sectiune "Contul meu" (`/_fragments/cont`): rotire cheie (afisata o data) + creds RAR pe ruta web proprie scoped pe sesiune (`POST /cont/roteste-cheie`, `POST /cont/rar-creds`, C13, NU endpointul API). 5 teste.
|
||||
- [x] **US-011** — panou `/admin` (`admin_routes.py`): conturi in asteptare/active + activare/dezactivare (require_admin + CSRF + PRG); contul dev id=1 fara butoane. Link "Panou admin" pe dashboard doar pentru admini + buton logout. 5 + 2 teste.
|
||||
- [x] **US-012** — `app/email.py` `notify_signup` best-effort (no-op fara `smtp_host`, prinde orice exceptie SMTP, timeout 5s) + config `smtp_*` + cablaj signup: bootstrap admin (primul cont = admin via `count_admins==0`) + notificare degradata dupa `set_session`. 5 teste.
|
||||
- [x] **Fix migrare (din VERIFY r1):** `_migrate` adauga defensiv `users.is_admin`/`email_verified` pe DB cu tabela `users` fara ele (idempotent, guard pe existenta tabelei). 2 teste.
|
||||
|
||||
## Raport VERIFY (3.3b)
|
||||
|
||||
> Doua runde de verificare independenta (subagent context curat, §5.6).
|
||||
|
||||
**Runda 1 — FAIL (1 criteriu):** suita 391 pass, toate criteriile US-007/010/011/012 confirmate + sweep
|
||||
securitate complet (toate rutele noi `/cont/*`, `/_fragments/cont`, `/admin*` sub `require_login`/`require_admin`
|
||||
+ `verify_csrf` pe POST; `/cont/*` scoped strict pe sesiune, nu accepta `account_id` din form; `/admin` nu
|
||||
expune hash/chei/creds in clar). DAR `_migrate` nu adauga defensiv `users.is_admin`/`email_verified` →
|
||||
o tabela `users` fara ele ar ceda cu `OperationalError` (acelasi tip de gap ca C1 pe `accounts.active`). → fix.
|
||||
|
||||
**Fix:** bloc `# Coloane users` in `app/db.py::_migrate` (guard pe existenta tabelei + ALTER idempotent). 2 teste.
|
||||
|
||||
**Runda 2 — PASS global (subagent NOU):**
|
||||
- Suita: **393 passed**, 0 fail.
|
||||
- Fix migrare confirmat (test pe `users` minima fara coloane → `_migrate` → coloane prezente; idempotent).
|
||||
- E2E mod prod (`web_auth_required=true`): `GET /admin` fara cookie → 303 `/login`; non-admin logat → 403;
|
||||
`POST /admin/activate` fara CSRF → 403. Rute `/cont/*` scoped pe sesiune, CSRF enforce, parola RAR niciodata in `value=`.
|
||||
- US-010 bootstrap (primul signup → `is_admin=1`, al doilea → 0), CLI `set-admin`, `require_admin`→403 confirmate.
|
||||
- US-012 `notify_signup` best-effort no-op fara SMTP + nu blocheaza signup + log `SIGNUP` pastrat.
|
||||
- Regresia de aur: `test_import_e2e` + `test_api` + `test_worker_active_gate` = 31 pass.
|
||||
|
||||
**Verdict: PASS.** Send live RAR ramane de confirmat manual la deploy (fara creds/retea in mediul VERIFY).
|
||||
La deploy prod: `AUTOPASS_session_secret` persistent, `AUTOPASS_WEB_AUTH_REQUIRED=true`, optional `AUTOPASS_smtp_*`.
|
||||
|
||||
213
tests/test_admin_panel.py
Normal file
213
tests/test_admin_panel.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Teste US-011 (PRD 3.3b): panou admin web /admin — conturi in asteptare + activare.
|
||||
|
||||
TDD strict: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Fisiere testate: app/web/admin_routes.py, app/web/templates/admin.html.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture client (web_auth_required=true -> CSRF enforce)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""TestClient pe aplicatia completa, cu DB izolata si web_auth_required=true."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_panel.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
# Ridica limita rate-limit pentru signup ca testele nu se blocheze intre ele
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
# Curata hits-urile rate-limit intre teste
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper-e
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_csrf(client: TestClient, url: str) -> str:
|
||||
"""Extrage csrf_token din pagina HTML."""
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, f"csrf_token negasit in {url}: {resp.text[:500]}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _signup(client: TestClient, name: str, email: str, password: str = "parola_test_001") -> int:
|
||||
"""Creeaza cont via POST /signup si intoarce account_id."""
|
||||
token = _get_csrf(client, "/signup")
|
||||
resp = client.post("/signup", data={
|
||||
"name": name,
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200, f"signup esuat: {resp.text[:300]}"
|
||||
# Extrage account_id din raspuns (pagina afiseaza cheia rfak_ + account_id)
|
||||
m = re.search(r"cont=(\d+)", resp.text)
|
||||
if not m:
|
||||
# fallback: citeste din DB
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT account_id FROM users WHERE email=? COLLATE NOCASE", (email,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row, f"userul {email} nu a fost creat"
|
||||
return int(row["account_id"])
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def _login(client: TestClient, email: str, password: str = "parola_test_001") -> None:
|
||||
"""Autentifica userul (seteaza sesiunea cookie)."""
|
||||
token = _get_csrf(client, "/login")
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 303, f"login esuat cu {email}: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _make_admin(account_id: int) -> None:
|
||||
"""Marcheaza contul ca admin direct in DB."""
|
||||
from app.db import get_connection
|
||||
from app.users import set_admin
|
||||
conn = get_connection()
|
||||
try:
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_account_active(account_id: int) -> bool:
|
||||
"""Citeste accounts.active din DB."""
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return bool(row["active"]) if row else False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cazuri de test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_vede_conturi_pending(client):
|
||||
"""Admin logat -> GET /admin contine numele contului pending (active=0)."""
|
||||
# Creeaza un cont pending (active=0 implicit la signup)
|
||||
_signup(client, "Service Pending SRL", "pending@test.ro")
|
||||
|
||||
# Creeaza contul admin
|
||||
admin_id = _signup(client, "Admin Corp SA", "admin@test.ro")
|
||||
_make_admin(admin_id)
|
||||
|
||||
# Login ca admin
|
||||
_login(client, "admin@test.ro")
|
||||
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200, f"GET /admin a returnat {resp.status_code}"
|
||||
assert "Service Pending SRL" in resp.text, (
|
||||
f"Contul pending nu apare in /admin. Raspuns: {resp.text[:600]}"
|
||||
)
|
||||
|
||||
|
||||
def test_activare_din_admin(client):
|
||||
"""POST /admin/activate cu CSRF -> accounts.active=1; redirect 303."""
|
||||
# Cont pending
|
||||
pending_id = _signup(client, "Firma De Activat SRL", "firma@test.ro")
|
||||
assert not _get_account_active(pending_id), "contul trebuie sa fie inactiv initial"
|
||||
|
||||
# Cont admin
|
||||
admin_id = _signup(client, "Admin Activator SA", "activator@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "activator@test.ro")
|
||||
|
||||
# Obtine CSRF din pagina /admin
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit in /admin"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp2 = client.post("/admin/activate", data={
|
||||
"account_id": str(pending_id),
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp2.status_code == 303, (
|
||||
f"POST /admin/activate trebuia redirect 303, got {resp2.status_code}: {resp2.text[:300]}"
|
||||
)
|
||||
|
||||
assert _get_account_active(pending_id), "contul trebuia sa fie activat dupa POST /admin/activate"
|
||||
|
||||
|
||||
def test_non_admin_403(client):
|
||||
"""User logat NON-admin -> GET /admin -> 403.
|
||||
|
||||
Creeaza intai un cont admin (bootstrap: primul user devine admin),
|
||||
apoi un al doilea cont (non-admin) si verifica ca al doilea primeste 403.
|
||||
"""
|
||||
# Primul signup devine automat admin (bootstrap US-010)
|
||||
_signup(client, "Admin Bootstrap SA", "bootstrap@test.ro")
|
||||
|
||||
# Al doilea user NU e admin
|
||||
_signup(client, "User Simplu SRL", "user@test.ro")
|
||||
_login(client, "user@test.ro")
|
||||
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 403, (
|
||||
f"User non-admin trebuia 403 pe /admin, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
def test_admin_nelogat_redirect(client):
|
||||
"""Fara sesiune -> GET /admin -> 303 redirect la /login."""
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 303, (
|
||||
f"Nelogat pe /admin trebuia 303, got {resp.status_code}"
|
||||
)
|
||||
loc = resp.headers.get("location", "")
|
||||
assert "/login" in loc, f"Redirect gresit: {loc}"
|
||||
|
||||
|
||||
def test_activate_fara_csrf_403(client):
|
||||
"""Admin logat, POST /admin/activate fara token CSRF -> 403."""
|
||||
pending_id = _signup(client, "Firma Fara CSRF SRL", "nocsrf@test.ro")
|
||||
|
||||
admin_id = _signup(client, "Admin CSRF Test SA", "csrfadmin@test.ro")
|
||||
_make_admin(admin_id)
|
||||
_login(client, "csrfadmin@test.ro")
|
||||
|
||||
# POST fara token (sau token gol)
|
||||
resp = client.post("/admin/activate", data={
|
||||
"account_id": str(pending_id),
|
||||
"csrf_token": "",
|
||||
})
|
||||
assert resp.status_code == 403, (
|
||||
f"POST fara CSRF trebuia 403, got {resp.status_code}"
|
||||
)
|
||||
277
tests/test_admin_role.py
Normal file
277
tests/test_admin_role.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Teste US-010 (PRD 3.3b): rol admin + bootstrap + guard require_admin.
|
||||
|
||||
Fisiere testate: app/users.py (helper-e admin), app/web/session.py (require_admin),
|
||||
tools/account.py (subcomanda set-admin).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_role.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection, init_db
|
||||
init_db()
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def account_id(conn):
|
||||
"""Cont de test (nu default id=1)."""
|
||||
from app.accounts import create_account
|
||||
return create_account(conn, "Service Test Admin")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user_id(conn, account_id):
|
||||
"""User de test pe contul de test."""
|
||||
from app.users import create_user
|
||||
return create_user(conn, account_id, "admin_test@exemplu.ro", "parola_sigura_123")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env_cli(monkeypatch):
|
||||
"""Fixture pentru CLI: DB separata + clear settings cache."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_cli.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper app minimal pentru require_admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_app_admin() -> FastAPI:
|
||||
"""App FastAPI minimal cu ruta protejata de require_admin."""
|
||||
mini = FastAPI()
|
||||
mini.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key="test-secret-admin",
|
||||
session_cookie="autopass_session",
|
||||
https_only=False,
|
||||
same_site="strict",
|
||||
)
|
||||
|
||||
from app.web.session import LoginRequired, AdminRequired
|
||||
|
||||
@mini.exception_handler(LoginRequired)
|
||||
async def login_required_handler(request: Request, exc: LoginRequired):
|
||||
return JSONResponse(status_code=401, content={"detail": "neautentificat"})
|
||||
|
||||
@mini.exception_handler(AdminRequired)
|
||||
async def admin_required_handler(request: Request, exc: AdminRequired):
|
||||
return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"})
|
||||
|
||||
@mini.get("/set-session")
|
||||
def set_sess(request: Request, account_id: int = 1, user_id: int = 1):
|
||||
from app.web.session import set_session
|
||||
set_session(request, account_id, user_id)
|
||||
return {"ok": True}
|
||||
|
||||
@mini.get("/admin-only")
|
||||
def admin_only(request: Request):
|
||||
from app.web.session import require_admin
|
||||
aid = require_admin(request)
|
||||
return {"account_id": aid}
|
||||
|
||||
return mini
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste helper-e app/users.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_count_admins_initial_zero(conn):
|
||||
"""Fara niciun user cu is_admin=1, count_admins returneaza 0."""
|
||||
from app.users import count_admins
|
||||
assert count_admins(conn) == 0
|
||||
|
||||
|
||||
def test_set_admin_marcheaza_userii_contului(conn, account_id, user_id):
|
||||
"""set_admin(conn, account_id) seteaza is_admin=1 pe toti userii contului."""
|
||||
from app.users import set_admin, count_admins
|
||||
assert count_admins(conn) == 0
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
assert count_admins(conn) == 1
|
||||
|
||||
|
||||
def test_create_user_is_admin_flag(conn, account_id):
|
||||
"""create_user cu is_admin=True seteaza coloana la 1."""
|
||||
from app.users import create_user, count_admins
|
||||
create_user(conn, account_id, "newadmin@exemplu.ro", "parola_sigura_456", is_admin=True)
|
||||
assert count_admins(conn) == 1
|
||||
|
||||
|
||||
def test_is_account_admin(conn, account_id, user_id):
|
||||
"""is_account_admin returneaza False inainte si True dupa set_admin."""
|
||||
from app.users import is_account_admin, set_admin
|
||||
assert is_account_admin(conn, account_id) is False
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
assert is_account_admin(conn, account_id) is True
|
||||
|
||||
|
||||
def test_list_admin_emails(conn, account_id):
|
||||
"""list_admin_emails returneaza emailurile userilor cu is_admin=1."""
|
||||
from app.users import create_user, set_admin, list_admin_emails
|
||||
uid = create_user(conn, account_id, "admin1@exemplu.ro", "parola_sigura_789")
|
||||
# Inainte de set_admin -> lista goala
|
||||
assert list_admin_emails(conn) == []
|
||||
set_admin(conn, account_id, is_admin=True)
|
||||
emails = list_admin_emails(conn)
|
||||
assert "admin1@exemplu.ro" in emails
|
||||
|
||||
|
||||
def test_set_admin_cont_inexistent_valueerror(conn):
|
||||
"""set_admin pe cont care nu exista ridica ValueError."""
|
||||
from app.users import set_admin
|
||||
with pytest.raises(ValueError, match="cont inexistent"):
|
||||
set_admin(conn, 9999, is_admin=True)
|
||||
|
||||
|
||||
def test_set_admin_cont_fara_users_silentios(conn):
|
||||
"""set_admin pe cont existent fara useri e no-op silentios (nu ridica exceptie)."""
|
||||
from app.accounts import create_account
|
||||
from app.users import set_admin, count_admins
|
||||
acct_fara_user = create_account(conn, "Cont Fara User")
|
||||
# Nu trebuie sa ridice
|
||||
set_admin(conn, acct_fara_user, is_admin=True)
|
||||
assert count_admins(conn) == 0 # niciun user modificat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste require_admin (app/web/session.py) prin TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client_admin(monkeypatch):
|
||||
"""TestClient pe app cu require_admin; DB cu cont admin + cont non-admin."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_require_admin.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection, init_db
|
||||
init_db()
|
||||
conn = get_connection()
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user, set_admin
|
||||
# Cont admin (id=2)
|
||||
acct_admin = create_account(conn, "Admin Corp")
|
||||
uid_admin = create_user(conn, acct_admin, "admin@corp.ro", "parola_admin_001")
|
||||
set_admin(conn, acct_admin, is_admin=True)
|
||||
# Cont non-admin (id=3)
|
||||
acct_user = create_account(conn, "User Corp")
|
||||
uid_user = create_user(conn, acct_user, "user@corp.ro", "parola_user_001")
|
||||
conn.close()
|
||||
|
||||
app = _make_app_admin()
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c, acct_admin, uid_admin, acct_user, uid_user
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_require_admin_blocheaza_non_admin(client_admin):
|
||||
"""User logat NON-admin pe ruta admin -> 403."""
|
||||
client, acct_admin, uid_admin, acct_user, uid_user = client_admin
|
||||
client.get(f"/set-session?account_id={acct_user}&user_id={uid_user}")
|
||||
resp = client.get("/admin-only")
|
||||
assert resp.status_code == 403
|
||||
assert "admin" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_require_admin_lasa_admin(client_admin):
|
||||
"""User logat ADMIN pe ruta admin -> 200 cu account_id."""
|
||||
client, acct_admin, uid_admin, acct_user, uid_user = client_admin
|
||||
client.get(f"/set-session?account_id={acct_admin}&user_id={uid_admin}")
|
||||
resp = client.get("/admin-only")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account_id"] == acct_admin
|
||||
|
||||
|
||||
def test_require_admin_nelogat_ridica_login_required(client_admin):
|
||||
"""Fara sesiune, require_admin ridica LoginRequired (-> 401 in app-ul nostru de test)."""
|
||||
client, *_ = client_admin
|
||||
resp = client.get("/admin-only")
|
||||
# In app-ul de test, LoginRequired -> 401
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste CLI tools/account.py set-admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_account(argv):
|
||||
from tools.account import main
|
||||
return main(argv)
|
||||
|
||||
|
||||
def test_cli_set_admin_marcheaza_contul(env_cli, capsys):
|
||||
"""CLI set-admin --account N seteaza is_admin=1 pe userii contului."""
|
||||
# Creeaza cont cu user
|
||||
from app.db import get_connection
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user, is_account_admin
|
||||
conn = get_connection()
|
||||
acct_id = create_account(conn, "Service CLI Admin")
|
||||
create_user(conn, acct_id, "cli_admin@corp.ro", "parola_cli_admin_1")
|
||||
conn.close()
|
||||
|
||||
rc = _run_account(["set-admin", "--account", str(acct_id)])
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert str(acct_id) in out or "admin" in out.lower()
|
||||
|
||||
conn2 = get_connection()
|
||||
assert is_account_admin(conn2, acct_id) is True
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_cli_set_admin_remove(env_cli, capsys):
|
||||
"""CLI set-admin --account N --remove scoate adminul."""
|
||||
from app.db import get_connection
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user, set_admin, is_account_admin
|
||||
conn = get_connection()
|
||||
acct_id = create_account(conn, "Service CLI Remove")
|
||||
create_user(conn, acct_id, "remove@corp.ro", "parola_remove_001")
|
||||
set_admin(conn, acct_id, is_admin=True)
|
||||
conn.close()
|
||||
|
||||
rc = _run_account(["set-admin", "--account", str(acct_id), "--remove"])
|
||||
assert rc == 0
|
||||
|
||||
conn2 = get_connection()
|
||||
assert is_account_admin(conn2, acct_id) is False
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_cli_set_admin_cont_inexistent_exit_2(env_cli, capsys):
|
||||
"""CLI set-admin pe cont inexistent -> exit code 2 + mesaj pe stderr."""
|
||||
rc = _run_account(["set-admin", "--account", "9999"])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 2
|
||||
assert "inexistent" in err or "eroare" in err.lower()
|
||||
80
tests/test_csrf_in_error_branches.py
Normal file
80
tests/test_csrf_in_error_branches.py
Normal 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
|
||||
@@ -15,6 +15,9 @@ from fastapi.testclient import TestClient
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
# Comportament in mod dev (fallback cont 1, fara login/CSRF); auth web e
|
||||
# default ON in prod — testat separat in test_web_*.
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
55
tests/test_dashboard_admin_link.py
Normal file
55
tests/test_dashboard_admin_link.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Test US-011 (discoverability): linkul 'Panou admin' apare pe dashboard doar pentru admini.
|
||||
|
||||
Completeaza intentia US-011 — adminul trebuie sa poata descoperi /admin din UI, nu doar
|
||||
prin URL direct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
yield c, conn
|
||||
conn.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _account_with_user(conn, name, *, is_admin):
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
acct = create_account(conn, name)
|
||||
email = f"{name.replace(' ', '').lower()}@test.ro"
|
||||
create_user(conn, acct, email, "parolaSuperSecreta", is_admin=is_admin)
|
||||
return acct
|
||||
|
||||
|
||||
def test_admin_vede_link_panou_admin(env, monkeypatch):
|
||||
client, conn = env
|
||||
acct = _account_with_user(conn, "Admin Co", is_admin=True)
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert 'href="/admin"' in r.text
|
||||
|
||||
|
||||
def test_non_admin_nu_vede_link(env, monkeypatch):
|
||||
client, conn = env
|
||||
acct = _account_with_user(conn, "Service Normal", is_admin=False)
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert 'href="/admin"' not in r.text
|
||||
125
tests/test_dashboard_scope.py
Normal file
125
tests/test_dashboard_scope.py
Normal 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()
|
||||
128
tests/test_get_scope_audit.py
Normal file
128
tests/test_get_scope_audit.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Teste scope cont pe GET /v1/audit/export (US-003, PRD 3.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
yield monkeypatch
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _body(**over):
|
||||
prez = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
prez.update(over)
|
||||
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
||||
|
||||
|
||||
def _csv_vins(content: bytes) -> list[str]:
|
||||
reader = csv.DictReader(io.StringIO(content.decode("utf-8")))
|
||||
return [r["vin"] for r in reader if r.get("vin")]
|
||||
|
||||
|
||||
def test_export_doar_contul_cheii(env):
|
||||
"""Exportul CSV contine doar randurile contului asociat cheii."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
|
||||
c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
|
||||
|
||||
# Marcheaza ca sent pentru ca audit/export default e status=sent
|
||||
conn2 = get_connection()
|
||||
try:
|
||||
conn2.execute("UPDATE submissions SET status='sent'")
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
resp1 = c.get("/v1/audit/export", headers={"X-API-Key": k1})
|
||||
assert resp1.status_code == 200
|
||||
vins1 = _csv_vins(resp1.content)
|
||||
assert "WVWZZZ1KZAW000123" in vins1
|
||||
assert "WVWZZZ1KZAW000456" not in vins1
|
||||
|
||||
resp2 = c.get("/v1/audit/export", headers={"X-API-Key": k2})
|
||||
vins2 = _csv_vins(resp2.content)
|
||||
assert "WVWZZZ1KZAW000456" in vins2
|
||||
assert "WVWZZZ1KZAW000123" not in vins2
|
||||
|
||||
|
||||
def test_export_legacy_null_pentru_cont_1(env):
|
||||
"""Randuri cu account_id=NULL apartin contului 1 in exportul de audit; contul 2 nu le vede."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
payload = json.dumps({"vin": "LEGACYVIN12345678", "prestatii": []})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('legacy_audit_key', NULL, 'sent', ?)", (payload,)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
resp1 = c.get("/v1/audit/export", headers={"X-API-Key": k1})
|
||||
vins1 = _csv_vins(resp1.content)
|
||||
assert "LEGACYVIN12345678" in vins1
|
||||
|
||||
resp2 = c.get("/v1/audit/export", headers={"X-API-Key": k2})
|
||||
vins2 = _csv_vins(resp2.content)
|
||||
assert "LEGACYVIN12345678" not in vins2
|
||||
|
||||
|
||||
def test_export_status_all_tot_scoped(env):
|
||||
"""status=all ramane scoped pe cont (nu exporta global)."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
|
||||
c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
|
||||
|
||||
resp1 = c.get("/v1/audit/export?status=all", headers={"X-API-Key": k1})
|
||||
vins1 = _csv_vins(resp1.content)
|
||||
assert "WVWZZZ1KZAW000123" in vins1
|
||||
assert "WVWZZZ1KZAW000456" not in vins1
|
||||
194
tests/test_get_scope_mapari.py
Normal file
194
tests/test_get_scope_mapari.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Teste scope cont pe GET /v1/mapari + /pending (US-002, PRD 3.2).
|
||||
|
||||
TD-3.2 (decis la poarta): parametrul ?account_id= din query se pastreaza DAR:
|
||||
- daca e prezent SI difera de contul cheii -> 400 explicit
|
||||
- daca e prezent si egal -> ok
|
||||
- daca lipseste -> contul cheii
|
||||
Contul efectiv vine MEREU din cheie (nespoofabil).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
yield monkeypatch
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_mapari_doar_contul_cheii(env):
|
||||
"""Cheia A vede doar maparile contului A; cheia B nu vede maparile lui A."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
|
||||
"VALUES ('OE-1', 'test')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (1, 'OP_A', 'OE-1', 1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (2, 'OP_B', 'OE-1', 1)"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
mapari1 = c.get("/v1/mapari", headers={"X-API-Key": k1}).json()["mapari"]
|
||||
ops1 = [m["cod_op_service"] for m in mapari1]
|
||||
assert "OP_A" in ops1
|
||||
assert "OP_B" not in ops1
|
||||
|
||||
mapari2 = c.get("/v1/mapari", headers={"X-API-Key": k2}).json()["mapari"]
|
||||
ops2 = [m["cod_op_service"] for m in mapari2]
|
||||
assert "OP_B" in ops2
|
||||
assert "OP_A" not in ops2
|
||||
|
||||
|
||||
def test_mapari_query_account_id_diferit_400(env):
|
||||
"""Daca ?account_id difera de contul cheii -> 400 explicit (TD-3.2)."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
resp = c.get("/v1/mapari?account_id=2", headers={"X-API-Key": k1})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_mapari_query_account_id_egal_ok(env):
|
||||
"""Daca ?account_id egal cu contul cheii -> 200 (TD-3.2)."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
k1 = create_api_key(conn, 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
resp = c.get("/v1/mapari?account_id=1", headers={"X-API-Key": k1})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_pending_doar_contul_cheii(env):
|
||||
"""GET /v1/mapari/pending cu cheia A returneaza doar operatiile contului A."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_A", "denumire": "Reparatie"}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('pm_key1', 1, 'needs_mapping', ?)", (payload1,)
|
||||
)
|
||||
payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_B", "denumire": "Vopsire"}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('pm_key2', 2, 'needs_mapping', ?)", (payload2,)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
pending1 = c.get("/v1/mapari/pending", headers={"X-API-Key": k1}).json()["pending"]
|
||||
ops1 = [p["cod_op_service"] for p in pending1]
|
||||
assert "OP_A" in ops1
|
||||
assert "OP_B" not in ops1
|
||||
|
||||
pending2 = c.get("/v1/mapari/pending", headers={"X-API-Key": k2}).json()["pending"]
|
||||
ops2 = [p["cod_op_service"] for p in pending2]
|
||||
assert "OP_B" in ops2
|
||||
assert "OP_A" not in ops2
|
||||
|
||||
|
||||
def test_pending_web_global_neschimbat(env):
|
||||
"""pending_unmapped(conn) fara argument returneaza global (back-compat pentru web/routes.py)."""
|
||||
with _client() as c:
|
||||
from app.db import get_connection
|
||||
from app.mapping import pending_unmapped
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_A", "denumire": "Reparatie"}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('pgn_key1', 1, 'needs_mapping', ?)", (payload1,)
|
||||
)
|
||||
payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_B", "denumire": "Vopsire"}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('pgn_key2', 2, 'needs_mapping', ?)", (payload2,)
|
||||
)
|
||||
# Apel fara argument -> global (ambele conturi)
|
||||
result = pending_unmapped(conn)
|
||||
ops = [p["cod_op_service"] for p in result]
|
||||
assert "OP_A" in ops
|
||||
assert "OP_B" in ops
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_pending_filtreaza_in_sql_cu_regula_null(env):
|
||||
"""B1: pending_unmapped(conn, account_id=1) include si randuri cu account_id=NULL (legacy).
|
||||
|
||||
Filtrarea trebuie sa se faca IN SQL cu:
|
||||
WHERE status='needs_mapping' AND (account_id=? OR (account_id IS NULL AND ?=1))
|
||||
Nu post-hoc in Python.
|
||||
"""
|
||||
with _client() as c:
|
||||
from app.db import get_connection
|
||||
from app.mapping import pending_unmapped
|
||||
conn = get_connection()
|
||||
try:
|
||||
payload1 = json.dumps({"prestatii": [{"cod_op_service": "OP_EXPLICIT", "denumire": "X"}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('pfn_key1', 1, 'needs_mapping', ?)", (payload1,)
|
||||
)
|
||||
payload2 = json.dumps({"prestatii": [{"cod_op_service": "OP_NULL", "denumire": "Y"}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('pfn_key2', NULL, 'needs_mapping', ?)", (payload2,)
|
||||
)
|
||||
# Cu account_id=1 -> vede si randul legacy (NULL -> cont 1)
|
||||
result = pending_unmapped(conn, account_id=1)
|
||||
ops = [p["cod_op_service"] for p in result]
|
||||
assert "OP_EXPLICIT" in ops
|
||||
assert "OP_NULL" in ops
|
||||
# Cu account_id=2 -> nu vede nimic (nu are pending submissions)
|
||||
result2 = pending_unmapped(conn, account_id=2)
|
||||
assert result2 == []
|
||||
finally:
|
||||
conn.close()
|
||||
169
tests/test_get_scope_prezentari.py
Normal file
169
tests/test_get_scope_prezentari.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Teste scope cont pe GET /v1/prezentari + /{id} (US-001, PRD 3.2).
|
||||
|
||||
Metoda TDD: testele se scriu inainte de implementare (RED) si trebuie sa ramana
|
||||
verzi dupa implementare (GREEN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
yield monkeypatch
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _body(**over):
|
||||
prez = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
prez.update(over)
|
||||
return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]}
|
||||
|
||||
|
||||
def test_lista_doar_contul_cheii(env):
|
||||
"""Cheia A vede doar submission-urile contului A; cheia B nu vede submission-urile lui A."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r1 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
|
||||
assert r1.status_code == 200
|
||||
sid1 = r1.json()["results"][0]["submission_id"]
|
||||
|
||||
r2 = c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
|
||||
assert r2.status_code == 200
|
||||
sid2 = r2.json()["results"][0]["submission_id"]
|
||||
|
||||
lista1 = c.get("/v1/prezentari", headers={"X-API-Key": k1}).json()["submissions"]
|
||||
ids1 = [s["id"] for s in lista1]
|
||||
assert sid1 in ids1
|
||||
assert sid2 not in ids1
|
||||
|
||||
lista2 = c.get("/v1/prezentari", headers={"X-API-Key": k2}).json()["submissions"]
|
||||
ids2 = [s["id"] for s in lista2]
|
||||
assert sid2 in ids2
|
||||
assert sid1 not in ids2
|
||||
|
||||
|
||||
def test_detaliu_cross_account_404(env):
|
||||
"""GET /{id} cu cheia contului B pentru submission-ul contului A -> 404.
|
||||
|
||||
B3: detail-ul 404 cross-account trebuie byte-identic cu cel al unui id inexistent
|
||||
(acelasi status + acelasi mesaj) — nu dam indicii ca randul exista.
|
||||
"""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
|
||||
sid1 = r.json()["results"][0]["submission_id"]
|
||||
|
||||
cross = c.get(f"/v1/prezentari/{sid1}", headers={"X-API-Key": k2})
|
||||
nonexist = c.get("/v1/prezentari/99999", headers={"X-API-Key": k2})
|
||||
|
||||
assert cross.status_code == 404
|
||||
assert nonexist.status_code == 404
|
||||
assert cross.json()["detail"] == nonexist.json()["detail"] == "submission inexistent"
|
||||
|
||||
|
||||
def test_legacy_null_vizibil_pentru_cont_1(env):
|
||||
"""Randuri cu account_id=NULL apartin contului 1 (legacy OV-2); contul 2 nu le vede."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
|
||||
k1 = create_api_key(conn, 1)
|
||||
k2 = create_api_key(conn, 2)
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES ('legacy_null_key', NULL, 'queued', '{}')"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
lista1 = c.get("/v1/prezentari", headers={"X-API-Key": k1}).json()["submissions"]
|
||||
lista2 = c.get("/v1/prezentari", headers={"X-API-Key": k2}).json()["submissions"]
|
||||
|
||||
assert len(lista1) >= 1
|
||||
assert len(lista2) == 0
|
||||
|
||||
|
||||
def test_fara_cheie_flag_off_vede_contul_1(env):
|
||||
"""Fara cheie cu AUTOPASS_REQUIRE_API_KEY=false -> cont implicit (id=1, back-compat dev)."""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'alt')")
|
||||
k2 = create_api_key(conn, 2)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Submission pentru contul 1 (fara cheie, flag off -> cont implicit)
|
||||
c.post("/v1/prezentari", json=_body())
|
||||
# Submission pentru contul 2
|
||||
c.post("/v1/prezentari", json=_body(vin="WVWZZZ1KZAW000456"), headers={"X-API-Key": k2})
|
||||
|
||||
# Fara cheie -> vede DOAR contul 1 (1 submission)
|
||||
lista = c.get("/v1/prezentari").json()["submissions"]
|
||||
assert len(lista) == 1
|
||||
|
||||
|
||||
def test_detaliu_nu_expune_creds(env):
|
||||
"""B4: GET /v1/prezentari/{id} nu expune campuri sensibile (rar_creds_enc, payload_json,
|
||||
idempotency_key, rar_error).
|
||||
"""
|
||||
with _client() as c:
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
k1 = create_api_key(conn, 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": k1})
|
||||
sid = r.json()["results"][0]["submission_id"]
|
||||
|
||||
resp = c.get(f"/v1/prezentari/{sid}", headers={"X-API-Key": k1})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for field in ("rar_creds_enc", "payload_json", "idempotency_key", "rar_error"):
|
||||
assert field not in data, f"camp sensibil expus: {field}"
|
||||
@@ -26,6 +26,9 @@ import pytest
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
# Comportament in mod dev (fallback cont 1, fara login/CSRF); auth web e
|
||||
# default ON in prod — testat separat in test_web_*.
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
201
tests/test_import_web_scope.py
Normal file
201
tests/test_import_web_scope.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Teste US-006a/b (PRD 3.3): scoping import web pe sesiune.
|
||||
|
||||
US-006a: citiri (upload, preview, mapare-coloane) pe contul sesiunii.
|
||||
US-006b: scrieri (confirma) pe contul sesiunii; alt cont -> inaccesibil.
|
||||
C8/OV-2: aceeasi cheie idempotenta prin API si web pe acelasi cont.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _make_xlsx(rows: list[dict]) -> bytes:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
if rows:
|
||||
ws.append(list(rows[0].keys()))
|
||||
for r in rows:
|
||||
ws.append(list(r.values()))
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
_ROWS = [
|
||||
{"vin": "WVWZZZ1KZAW111111", "nr_inmatriculare": "B111TST",
|
||||
"data_prestatie": "2026-06-01", "odometru_final": "10000",
|
||||
"cod_prestatie": "OE-1"},
|
||||
]
|
||||
|
||||
|
||||
def _csrf_from(html: str) -> str:
|
||||
"""Extrage tokenul CSRF din HTML (hidden input)."""
|
||||
m = re.search(r'name="csrf_token" value="([^"]*)"', html)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.db"))
|
||||
# Scoping testat prin monkeypatch require_login pe acct_a/acct_b; rulam in mod
|
||||
# dev (CSRF skip fara sesiune) — auth web e default ON in prod, testat in test_web_*.
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
from app.accounts import create_account
|
||||
acct_a = create_account(conn, "Cont A Scope", active=True)
|
||||
acct_b = create_account(conn, "Cont B Scope", active=True)
|
||||
yield c, conn, acct_a, acct_b
|
||||
conn.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _setup_op_mapping(conn, account_id):
|
||||
"""Configureaza maparea operatie cod_op=OE-1 -> cod_prestatie=OE-1 pt. cont."""
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
("OE-1", "Operatii electrice"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO operations_mapping "
|
||||
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (?, ?, ?, ?)",
|
||||
(account_id, "OE-1", "OE-1", 1),
|
||||
)
|
||||
|
||||
|
||||
def _mapare_coloane(c, import_id, csrf_token: str = ""):
|
||||
"""Salveaza maparea de coloane; mapeaza cod_prestatie -> operatie (canonical).
|
||||
|
||||
cod_prestatie din xlsx trebuie mapat la 'operatie' (nu la 'cod_prestatie' care nu
|
||||
e camp canonic). resolve_prestatii il rezolva din operations_mapping.
|
||||
"""
|
||||
return c.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"csrf_token": csrf_token,
|
||||
"colname": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "cod_prestatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_upload_pe_contul_sesiunii(env, monkeypatch):
|
||||
"""Upload creeaza batch pe contul din sesiune (nu DEFAULT_ACCOUNT_ID)."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
|
||||
r = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
assert r.status_code == 200
|
||||
|
||||
batch = conn.execute("SELECT id, account_id FROM import_batches").fetchone()
|
||||
assert batch is not None
|
||||
assert batch["account_id"] == acct_a
|
||||
|
||||
|
||||
def test_batch_alt_cont_inaccesibil(env, monkeypatch):
|
||||
"""Batch-ul contului A nu e accesibil din sesiunea contului B (preview -> eroare)."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
|
||||
# Upload ca A (sesiune curata, fara csrf_token anterior)
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
|
||||
# Preview ca B (GET, fara CSRF) -> trebuie eroare/inaccesibil
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
|
||||
r = client.get(f"/_import/{batch_id}/preview")
|
||||
assert r.status_code == 200
|
||||
assert "inexistent" in r.text.lower() or "inaccesibil" in r.text.lower()
|
||||
|
||||
|
||||
def test_commit_creeaza_submissions_pe_cont(env, monkeypatch):
|
||||
"""Confirma creeaza submissions cu account_id-ul sesiunii."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
_setup_op_mapping(conn, acct_a)
|
||||
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
# Upload — raspunsul contine csrf_token in form (sesiunea l-a creat)
|
||||
r_upload = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
csrf = _csrf_from(r_upload.text)
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
# Mapare coloane cu tokenul din upload
|
||||
r_map = _mapare_coloane(client, batch_id, csrf)
|
||||
csrf = _csrf_from(r_map.text) or csrf # tokenul din preview (stabil per sesiune)
|
||||
# Confirma cu tokenul sesiunii
|
||||
r = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert r.status_code == 200
|
||||
|
||||
sub = conn.execute("SELECT account_id FROM submissions").fetchone()
|
||||
assert sub is not None
|
||||
assert sub["account_id"] == acct_a
|
||||
|
||||
|
||||
def test_cheie_identica_api_vs_web_acelasi_cont(env, monkeypatch):
|
||||
"""C8/OV-2: import web si API pe acelasi cont produc aceeasi cheie idempotenta."""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
client, conn, acct_a, acct_b = env
|
||||
_setup_op_mapping(conn, acct_a)
|
||||
|
||||
row = {
|
||||
"vin": "WVWZZZ1KZAW999999",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "99999",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
canon = canonicalize_row(row)
|
||||
key_api = build_key(acct_a, canon)
|
||||
|
||||
# Upload web pe acelasi cont
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
web_row = {
|
||||
"vin": "WVWZZZ1KZAW999999",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "99999",
|
||||
"cod_prestatie": "OE-1",
|
||||
}
|
||||
r_up = client.post("/_import/upload", files={"file": ("w.xlsx", _make_xlsx([web_row]), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
csrf = _csrf_from(r_up.text)
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
r_map = _mapare_coloane(client, batch_id, csrf)
|
||||
csrf = _csrf_from(r_map.text) or csrf
|
||||
client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
|
||||
sub = conn.execute("SELECT idempotency_key FROM submissions WHERE account_id=?", (acct_a,)).fetchone()
|
||||
assert sub is not None
|
||||
assert sub["idempotency_key"] == key_api
|
||||
|
||||
|
||||
def test_confirma_alt_cont_inaccesibil(env, monkeypatch):
|
||||
"""Confirma batch-ul contului A din sesiunea B -> eroare batch inexistent."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
_setup_op_mapping(conn, acct_a)
|
||||
|
||||
# Upload + mapare ca A (cu CSRF tokens corecti)
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
r_up = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
csrf = _csrf_from(r_up.text)
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
r_map = _mapare_coloane(client, batch_id, csrf)
|
||||
csrf = _csrf_from(r_map.text) or csrf
|
||||
|
||||
# Confirma ca B cu tokenul din sesiune (acelasi cookie jar, token valid CSRF)
|
||||
# dar batch apartine lui A -> "inexistent sau expirat"
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
|
||||
r = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert r.status_code == 200
|
||||
assert "inexistent" in r.text.lower() or "inaccesibil" in r.text.lower() or "expirat" in r.text.lower()
|
||||
77
tests/test_mapari_scope.py
Normal file
77
tests/test_mapari_scope.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Teste C6: /_fragments/mapari scoped pe sesiune (task #7 fix leak cross-account).
|
||||
|
||||
TDD: testele confirma mai intai ca leak-ul exista (RED), apoi fix-ul il inchide (GREEN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
from app.accounts import create_account
|
||||
acct_a = create_account(conn, "Cont A Mapari")
|
||||
acct_b = create_account(conn, "Cont B Mapari")
|
||||
yield c, conn, acct_a, acct_b
|
||||
conn.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _insert_needs_mapping(conn, account_id, cod_op):
|
||||
payload = json.dumps({"vin": "VIN001", "nr_inmatriculare": "B01TST",
|
||||
"data_prestatie": "2026-06-01", "odometru_final": "1000",
|
||||
"prestatii": [{"cod_op_service": cod_op, "denumire": cod_op}]})
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'needs_mapping', ?)",
|
||||
(f"key_{account_id}_{cod_op}", account_id, payload),
|
||||
)
|
||||
|
||||
|
||||
def test_fragment_mapari_scoped_pe_cont(env, monkeypatch):
|
||||
"""/_fragments/mapari arata doar op-urile contului din sesiune, nu ale altuia."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
_insert_needs_mapping(conn, acct_a, "OP-DOAR-A")
|
||||
_insert_needs_mapping(conn, acct_b, "OP-DOAR-B")
|
||||
|
||||
import app.web.routes as routes
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
r = client.get("/_fragments/mapari")
|
||||
assert r.status_code == 200
|
||||
assert "OP-DOAR-A" in r.text
|
||||
assert "OP-DOAR-B" not in r.text
|
||||
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
|
||||
r = client.get("/_fragments/mapari")
|
||||
assert r.status_code == 200
|
||||
assert "OP-DOAR-B" in r.text
|
||||
assert "OP-DOAR-A" not in r.text
|
||||
|
||||
|
||||
def test_fragment_mapari_nelogat_redirect(monkeypatch):
|
||||
"""web_auth_required=True + fara sesiune -> 303 /login."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_auth.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
r = c.get("/_fragments/mapari")
|
||||
assert r.status_code == 303
|
||||
assert "/login" in r.headers.get("location", "")
|
||||
get_settings.cache_clear()
|
||||
124
tests/test_migrate_users.py
Normal file
124
tests/test_migrate_users.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Teste migrare defensiva coloane users (is_admin, email_verified).
|
||||
|
||||
TDD: RED -> implementare in _migrate -> GREEN.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import _migrate, init_db, get_connection
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: _migrate adauga is_admin si email_verified pe o tabela users minima
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_adauga_is_admin_pe_users_veche() -> None:
|
||||
"""Pe un DB cu tabela users creata fara is_admin/email_verified,
|
||||
_migrate trebuie sa adauge ambele coloane."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = Path(f.name)
|
||||
|
||||
conn = sqlite3.connect(str(db_path), isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = OFF") # evitam FK checks pe schema minima
|
||||
|
||||
# Tabela accounts minima (necesara pentru FK la users).
|
||||
# Trebuie sa aiba cui + rar_creds_enc + active ca _migrate sa nu crape
|
||||
# pe ALTER/CREATE INDEX care le refera inainte de blocul users.
|
||||
conn.execute("""
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
cui TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
rar_creds_enc TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("INSERT INTO accounts (id, name) VALUES (1, 'default')")
|
||||
|
||||
# Tabela submissions minima (necesara pentru _migrate -- coloane submissions)
|
||||
conn.execute("""
|
||||
CREATE TABLE submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
account_id INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
payload_json TEXT NOT NULL,
|
||||
rar_creds_enc TEXT,
|
||||
rar_status_code INTEGER,
|
||||
rar_error TEXT,
|
||||
id_prezentare INTEGER,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT,
|
||||
sending_since TEXT,
|
||||
purge_after TEXT,
|
||||
batch_id INTEGER,
|
||||
row_index INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# Tabela users MINIMA: fara is_admin, fara email_verified
|
||||
conn.execute("""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
scrypt_params TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
try:
|
||||
# Rulam migrarea
|
||||
_migrate(conn)
|
||||
|
||||
# Verificam ca ambele coloane au fost adaugate
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
assert "is_admin" in cols, f"is_admin lipseste dupa migrare; coloane prezente: {cols}"
|
||||
assert "email_verified" in cols, f"email_verified lipseste dupa migrare; coloane prezente: {cols}"
|
||||
finally:
|
||||
conn.close()
|
||||
db_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: _migrate este idempotent pe un DB initializat normal cu init_db()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_idempotent_pe_users_curenta(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pe un DB initializat normal, re-apelarea _migrate nu ridica exceptie
|
||||
si coloanele is_admin/email_verified raman prezente."""
|
||||
db_file = tmp_path / "test_idem.db"
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", str(db_file))
|
||||
|
||||
# Resetam settings-ul cached ca sa preia noul AUTOPASS_DB_PATH
|
||||
get_settings.cache_clear() # type: ignore[attr-defined]
|
||||
|
||||
try:
|
||||
# Initializare normala
|
||||
init_db()
|
||||
|
||||
# Re-apelam _migrate direct (idempotenta)
|
||||
conn = get_connection()
|
||||
try:
|
||||
_migrate(conn) # nu trebuie sa ridice
|
||||
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
assert "is_admin" in cols
|
||||
assert "email_verified" in cols
|
||||
finally:
|
||||
conn.close()
|
||||
finally:
|
||||
get_settings.cache_clear() # type: ignore[attr-defined]
|
||||
181
tests/test_signup_notify.py
Normal file
181
tests/test_signup_notify.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Teste US-012 (PRD 3.3b): notificare email admin la signup (degradat) + bootstrap admin.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture client (pattern identic cu test_web_signup.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csrf(c: TestClient) -> str:
|
||||
"""Obtine un token CSRF proaspat de la GET /signup."""
|
||||
import re
|
||||
resp = c.get("/signup")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit in HTML"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecreta") -> object:
|
||||
token = _csrf(c)
|
||||
return c.post("/signup", data={
|
||||
"name": name,
|
||||
"email": email,
|
||||
"parola": parola,
|
||||
"csrf_token": token,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste notify_signup (unitare, fara TestClient)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_notify_noop_fara_smtp(monkeypatch):
|
||||
"""smtp_host None -> notify_signup nu ridica si nu incearca sa trimita."""
|
||||
import smtplib
|
||||
|
||||
# Daca smtplib.SMTP e apelat, testul pica
|
||||
def fake_smtp(*a, **kw):
|
||||
raise AssertionError("smtplib.SMTP nu trebuia apelat fara smtp_host configurat")
|
||||
|
||||
monkeypatch.setattr(smtplib, "SMTP", fake_smtp)
|
||||
|
||||
# Asigura smtp_host = None
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setenv("AUTOPASS_SMTP_HOST", "") # string gol -> None in Settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.email import notify_signup
|
||||
# Nu trebuie sa ridice
|
||||
notify_signup(["admin@test.com"], account_id=1, email="nou@test.com")
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_notify_nu_blocheaza_la_eroare(monkeypatch):
|
||||
"""smtp_host setat, SMTP ridica exceptie -> notify_signup returneaza normal (best-effort)."""
|
||||
import smtplib
|
||||
|
||||
class FakeSMTP:
|
||||
def __init__(self, *a, **kw):
|
||||
raise ConnectionRefusedError("simulam eroare retea")
|
||||
|
||||
monkeypatch.setattr(smtplib, "SMTP", FakeSMTP)
|
||||
|
||||
monkeypatch.setenv("AUTOPASS_SMTP_HOST", "smtp.test.local")
|
||||
monkeypatch.setenv("AUTOPASS_SMTP_PORT", "587")
|
||||
monkeypatch.setenv("AUTOPASS_SMTP_FROM", "autopass@test.local")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.email import notify_signup
|
||||
# Nu trebuie sa ridice, chiar daca SMTP esueaza
|
||||
notify_signup(["admin@test.com"], account_id=5, email="nou@test.com")
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste bootstrap admin (prin TestClient)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_primul_signup_devine_admin(client):
|
||||
"""Primul signup -> userul are is_admin=1 (count_admins==1).
|
||||
Al doilea signup cu alt email -> is_admin=0 (count_admins ramane 1)."""
|
||||
# Primul signup
|
||||
resp = _do_signup(client, "Primul Service", "primul@test.com")
|
||||
assert resp.status_code == 200
|
||||
assert "rfak_" in resp.text
|
||||
|
||||
from app.db import get_connection
|
||||
from app.users import count_admins
|
||||
conn = get_connection()
|
||||
try:
|
||||
n_admins = count_admins(conn)
|
||||
assert n_admins == 1, f"Dupa primul signup, count_admins trebuie sa fie 1, nu {n_admins}"
|
||||
user = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE email='primul@test.com'"
|
||||
).fetchone()
|
||||
assert user is not None
|
||||
assert user["is_admin"] == 1, "Primul user trebuie sa fie admin"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Al doilea signup
|
||||
resp2 = _do_signup(client, "Al Doilea Service", "aldoilea@test.com")
|
||||
assert resp2.status_code == 200
|
||||
assert "rfak_" in resp2.text
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
n_admins = count_admins(conn)
|
||||
assert n_admins == 1, f"Dupa al doilea signup, count_admins trebuie sa ramana 1, nu {n_admins}"
|
||||
user2 = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE email='aldoilea@test.com'"
|
||||
).fetchone()
|
||||
assert user2 is not None
|
||||
assert user2["is_admin"] == 0, "Al doilea user NU trebuie sa fie admin"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste C16 (log SIGNUP pastrat) si best-effort E2E
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_signup_inca_logheaza_si_notifica(client, capsys):
|
||||
"""Signup reusit -> stdout contine 'SIGNUP cont=' (C16 pastrat)."""
|
||||
resp = _do_signup(client, "Service Log Test", "log@test.com")
|
||||
assert resp.status_code == 200
|
||||
assert "rfak_" in resp.text
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "SIGNUP cont=" in captured.out, (
|
||||
f"Linia de log C16 'SIGNUP cont=' lipseste din stdout. Capturat: {captured.out!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_signup_neblocat_de_notify(monkeypatch, client):
|
||||
"""notify_signup ridica -> signup returneaza totusi 200 cu cheia (best-effort E2E)."""
|
||||
# Monkeypatch notify_signup sa ridice
|
||||
def notify_always_raises(*a, **kw):
|
||||
raise RuntimeError("simulam eroare fatala in notify")
|
||||
|
||||
# Importam modulul inainte de monkeypatch
|
||||
import app.email as email_mod
|
||||
monkeypatch.setattr(email_mod, "notify_signup", notify_always_raises)
|
||||
|
||||
resp = _do_signup(client, "Service Robust", "robust@test.com")
|
||||
assert resp.status_code == 200, (
|
||||
f"Signup trebuia sa reuseasca indiferent de eroarea din notify. status={resp.status_code}"
|
||||
)
|
||||
assert "rfak_" in resp.text, "Cheia API trebuia afisata chiar daca notify a esuat"
|
||||
193
tests/test_users.py
Normal file
193
tests/test_users.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Teste US-001 (PRD 3.3): tabela users + helper-e parole scrypt in app/users.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_users.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.db import get_connection, init_db
|
||||
init_db()
|
||||
c = get_connection()
|
||||
yield c
|
||||
c.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def account_id(conn):
|
||||
"""Cont de test (nu default id=1)."""
|
||||
from app.accounts import create_account
|
||||
return create_account(conn, "Service Test")
|
||||
|
||||
|
||||
def test_create_user_hash_nu_e_plaintext(conn, account_id):
|
||||
"""password_hash din DB nu contine parola in clar si nu e egal cu ea."""
|
||||
from app.users import create_user
|
||||
parola = "parola_sigura_123"
|
||||
user_id = create_user(conn, account_id, "test@exemplu.ro", parola)
|
||||
assert isinstance(user_id, int)
|
||||
row = conn.execute(
|
||||
"SELECT password_hash, salt FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["password_hash"] != parola
|
||||
assert parola not in row["password_hash"]
|
||||
assert row["salt"] != parola
|
||||
|
||||
|
||||
def test_verify_parola_corecta_si_gresita(conn, account_id):
|
||||
"""verify_password intoarce account_id la parola corecta, None la cea gresita."""
|
||||
from app.users import create_user, verify_password
|
||||
create_user(conn, account_id, "user@exemplu.ro", "parola_corecta_99")
|
||||
result_ok = verify_password(conn, "user@exemplu.ro", "parola_corecta_99")
|
||||
assert result_ok == account_id
|
||||
result_gresit = verify_password(conn, "user@exemplu.ro", "parola_gresita_00")
|
||||
assert result_gresit is None
|
||||
result_inexistent = verify_password(conn, "inexistent@exemplu.ro", "parola_corecta_99")
|
||||
assert result_inexistent is None
|
||||
|
||||
|
||||
def test_email_unic_global(conn, account_id):
|
||||
"""Al doilea create_user cu acelasi email (diferit doar in case) ridica ValueError."""
|
||||
from app.users import create_user
|
||||
create_user(conn, account_id, "Unic@exemplu.ro", "parola_unica_001")
|
||||
with pytest.raises(ValueError, match="email deja folosit"):
|
||||
create_user(conn, account_id, "unic@exemplu.ro", "alta_parola_002")
|
||||
|
||||
|
||||
def test_get_user_by_email(conn, account_id):
|
||||
"""get_user_by_email intoarce metadate fara password_hash si salt."""
|
||||
from app.users import create_user, get_user_by_email
|
||||
create_user(conn, account_id, "meta@exemplu.ro", "parola_meta_xyz")
|
||||
user = get_user_by_email(conn, "meta@exemplu.ro")
|
||||
assert user is not None
|
||||
assert user["email"].lower() == "meta@exemplu.ro"
|
||||
assert user["account_id"] == account_id
|
||||
assert "id" in user
|
||||
assert "is_admin" in user
|
||||
assert "email_verified" in user
|
||||
assert "created_at" in user
|
||||
assert "password_hash" not in user
|
||||
assert "salt" not in user
|
||||
assert get_user_by_email(conn, "inexistent@exemplu.ro") is None
|
||||
|
||||
|
||||
def test_parola_scurta_si_lunga_eroare(conn, account_id):
|
||||
"""Parola < 10 caractere sau > 128 ridica ValueError (C9 anti-DoS)."""
|
||||
from app.users import create_user
|
||||
with pytest.raises(ValueError):
|
||||
create_user(conn, account_id, "scurta@ex.ro", "scurt")
|
||||
with pytest.raises(ValueError):
|
||||
create_user(conn, account_id, "lunga@ex.ro", "x" * 129)
|
||||
# exact 10 caractere — trebuie sa mearga
|
||||
uid = create_user(conn, account_id, "exact10@ex.ro", "a" * 10)
|
||||
assert uid > 0
|
||||
# exact 128 caractere — trebuie sa mearga
|
||||
uid2 = create_user(conn, account_id, "exact128@ex.ro", "b" * 128)
|
||||
assert uid2 > 0
|
||||
|
||||
|
||||
def test_verify_honoreaza_scrypt_params(conn, account_id, monkeypatch):
|
||||
"""verify_password foloseste parametrii din DB (scrypt_params), nu constantele globale.
|
||||
|
||||
Simuleaza migrare cost: hash creat cu n=4 (vechi), constanta _N ridicata la 2**15 (nou).
|
||||
verify_password trebuie sa returneze account_id folosind n=4 din DB, nu _N global.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets as _secrets
|
||||
|
||||
import app.users as users_mod
|
||||
|
||||
email = "legacy@test.com"
|
||||
password = "parolasecreta"
|
||||
|
||||
# Hash cu parametri "vechi" (n=4, rapid pentru teste)
|
||||
n_old, r_old, p_old = 4, 8, 1
|
||||
salt = _secrets.token_bytes(16)
|
||||
pw_hash = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n_old, r=r_old, p=p_old,
|
||||
maxmem=64 * 1024 * 1024,
|
||||
dklen=32,
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(account_id, email, pw_hash.hex(), salt.hex(), "n4_r8_p1"),
|
||||
)
|
||||
|
||||
# Simuleaza cresterea costului: _N e acum mai mare
|
||||
monkeypatch.setattr(users_mod, "_N", 2**15)
|
||||
|
||||
# verify_password trebuie sa onoreze n=4 din DB, nu sa foloseasca _N=2**15
|
||||
result = users_mod.verify_password(conn, email, password)
|
||||
assert result == account_id, "verify_password trebuia sa onoreze scrypt_params din DB"
|
||||
assert users_mod.verify_password(conn, email, "gresita123456") is None
|
||||
|
||||
|
||||
def test_verify_params_corupti_return_none(conn, account_id):
|
||||
"""scrypt_params corupt/necunoscut -> verify returneaza None (no crash)."""
|
||||
import hashlib
|
||||
import secrets as _secrets
|
||||
|
||||
email = "corupt@test.com"
|
||||
password = "parolasecreta"
|
||||
salt = _secrets.token_bytes(16)
|
||||
pw_hash = hashlib.scrypt(password.encode(), salt=salt, n=4, r=8, p=1,
|
||||
maxmem=64 * 1024 * 1024, dklen=32)
|
||||
conn.execute(
|
||||
"INSERT INTO users (account_id, email, password_hash, salt, scrypt_params) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(account_id, email, pw_hash.hex(), salt.hex(), "FORMAT_NECUNOSCUT"),
|
||||
)
|
||||
|
||||
from app.users import verify_password
|
||||
result = verify_password(conn, email, password)
|
||||
assert result is None, "Eticheta corupta trebuia sa returneze None, nu crash"
|
||||
|
||||
|
||||
def test_init_db_pe_db_fara_users_creeaza_tabela(monkeypatch, tmp_path):
|
||||
"""init_db pe o DB existenta fara tabela users o creeaza fara eroare (migrare idempotenta)."""
|
||||
db_path = tmp_path / "veche.db"
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", str(db_path))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
# Creeaza DB fara tabela users (simuleaza DB veche)
|
||||
import sqlite3 as _sq
|
||||
conn_raw = _sq.connect(str(db_path))
|
||||
conn_raw.execute("PRAGMA journal_mode = WAL")
|
||||
conn_raw.execute("PRAGMA foreign_keys = ON")
|
||||
conn_raw.execute(
|
||||
"CREATE TABLE IF NOT EXISTS accounts "
|
||||
"(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
|
||||
"cui TEXT, active INTEGER NOT NULL DEFAULT 1, "
|
||||
"rar_creds_enc TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||
)
|
||||
conn_raw.execute("INSERT OR IGNORE INTO accounts (id, name) VALUES (1, 'default')")
|
||||
conn_raw.commit()
|
||||
conn_raw.close()
|
||||
|
||||
# init_db trebuie sa creeze tabela users fara eroare
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
|
||||
from app.db import get_connection
|
||||
c = get_connection()
|
||||
tables = {r[0] for r in c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
c.close()
|
||||
assert "users" in tables
|
||||
get_settings.cache_clear()
|
||||
232
tests/test_web_cont.py
Normal file
232
tests/test_web_cont.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Teste US-007 (PRD 3.3b): sectiunea 'Contul meu' — rotire cheie API + creds RAR din UI.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET /_fragments/cont -> card "Contul meu"
|
||||
- POST /cont/roteste-cheie -> cheie noua afisata o singura data
|
||||
- POST /cont/rar-creds -> seteaza rar_creds_enc per cont din sesiune
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client fara web_auth_required (dev mode) — sesiunea se seteaza manual."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_prod(monkeypatch):
|
||||
"""Client cu web_auth_required=True (mod prod) — CSRF enforce."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _create_account_user(email: str = "user@test.com", password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user + cheie API initiala. Intoarce (acct_id, user_id, api_key)."""
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Test", active=True)
|
||||
user_id = create_user(conn, acct_id, email, password)
|
||||
api_key = create_api_key(conn, acct_id)
|
||||
return acct_id, user_id, api_key
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> None:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
# Obtine CSRF token de pe pagina de login
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, "csrf_token negasit pe /login"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
# 303 redirect la / inseamna login reusit
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _get_csrf_from_fragment(client) -> str:
|
||||
"""Obtine CSRF token din fragmentul /_fragments/cont."""
|
||||
resp = client.get("/_fragments/cont")
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, f"csrf_token negasit in /_fragments/cont: {resp.text[:500]}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_roteste_cheie_afisata_o_data
|
||||
# ============================================================
|
||||
|
||||
def test_roteste_cheie_afisata_o_data(client):
|
||||
"""User logat roteste cheia: raspunsul contine 'rfak_'; cheia veche revocata."""
|
||||
acct_id, user_id, api_key_initiala = _create_account_user("roteste@test.com")
|
||||
_login(client, "roteste@test.com", "parolasecreta10")
|
||||
|
||||
csrf = _get_csrf_from_fragment(client)
|
||||
resp = client.post("/cont/roteste-cheie", data={"csrf_token": csrf})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "rfak_" in resp.text, f"Cheia noua nu apare in raspuns: {resp.text[:500]}"
|
||||
|
||||
# Verifica in DB: cheia veche revocata, una noua activa
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, active FROM api_keys WHERE account_id=? ORDER BY id",
|
||||
(acct_id,),
|
||||
).fetchall()
|
||||
# Trebuie sa avem minim 2 chei: cea initiala (active=0) si cea noua (active=1)
|
||||
active_keys = [r for r in rows if r["active"] == 1]
|
||||
inactive_keys = [r for r in rows if r["active"] == 0]
|
||||
assert len(active_keys) == 1, f"Trebuia exact 1 cheie activa, gasit: {len(active_keys)}"
|
||||
assert len(inactive_keys) >= 1, "Cheia veche trebuia revocata (active=0)"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_set_creds_rar_din_sesiune
|
||||
# ============================================================
|
||||
|
||||
def test_set_creds_rar_din_sesiune(client):
|
||||
"""User logat seteaza creds RAR: accounts.rar_creds_enc != NULL, decriptabil."""
|
||||
acct_id, user_id, _ = _create_account_user("creds@test.com")
|
||||
_login(client, "creds@test.com", "parolasecreta10")
|
||||
|
||||
csrf = _get_csrf_from_fragment(client)
|
||||
resp = client.post("/cont/rar-creds", data={
|
||||
"csrf_token": csrf,
|
||||
"rar_email": "user@rar.ro",
|
||||
"rar_parola": "parolaRAR123",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Mesaj de succes in raspuns
|
||||
assert "succes" in resp.text.lower() or "salvat" in resp.text.lower() or "configurat" in resp.text.lower(), \
|
||||
f"Mesaj de succes lipsa: {resp.text[:500]}"
|
||||
|
||||
# Verifica in DB: rar_creds_enc setat si decriptabil
|
||||
from app.db import get_connection
|
||||
from app.crypto import decrypt_creds
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_id,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["rar_creds_enc"] is not None, "rar_creds_enc trebuia setat"
|
||||
creds = decrypt_creds(row["rar_creds_enc"])
|
||||
assert creds is not None
|
||||
assert creds.get("email") == "user@rar.ro"
|
||||
assert creds.get("password") == "parolaRAR123"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_creds_alt_cont_neafectat
|
||||
# ============================================================
|
||||
|
||||
def test_creds_alt_cont_neafectat(client):
|
||||
"""User A seteaza creds -> contul B ramane cu rar_creds_enc NULL."""
|
||||
acct_a, user_a, _ = _create_account_user("userA@test.com")
|
||||
acct_b, user_b, _ = _create_account_user("userB@test.com")
|
||||
|
||||
# Logam user A si setam creds
|
||||
_login(client, "userA@test.com", "parolasecreta10")
|
||||
csrf = _get_csrf_from_fragment(client)
|
||||
resp = client.post("/cont/rar-creds", data={
|
||||
"csrf_token": csrf,
|
||||
"rar_email": "a@rar.ro",
|
||||
"rar_parola": "parolaA123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verifica: contul A are creds, contul B ramane NULL
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row_a = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_a,)).fetchone()
|
||||
row_b = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct_b,)).fetchone()
|
||||
assert row_a["rar_creds_enc"] is not None, "Contul A trebuia sa aiba creds"
|
||||
assert row_b["rar_creds_enc"] is None, "Contul B nu trebuia atins"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_roteste_fara_csrf_403_in_prod
|
||||
# ============================================================
|
||||
|
||||
def test_roteste_fara_csrf_403_in_prod(client_prod):
|
||||
"""Prod + sesiune autentificata + CSRF lipsa -> 403."""
|
||||
# Cream cont + user
|
||||
acct_id, user_id, _ = _create_account_user("csrf_test@test.com")
|
||||
|
||||
# Login real
|
||||
_login(client_prod, "csrf_test@test.com", "parolasecreta10")
|
||||
|
||||
# POST fara csrf_token (sau cu token gresit)
|
||||
resp = client_prod.post("/cont/roteste-cheie", data={"csrf_token": "token_gresit"})
|
||||
assert resp.status_code == 403, f"Trebuia 403, got {resp.status_code}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_fragment_cont_nelogat_redirect
|
||||
# ============================================================
|
||||
|
||||
def test_fragment_cont_nelogat_redirect(monkeypatch):
|
||||
"""Fara sesiune + web_auth_required=True -> 303 redirect /login."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t_nl.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
resp = c.get("/_fragments/cont")
|
||||
assert resp.status_code == 303
|
||||
assert "/login" in resp.headers.get("location", "")
|
||||
get_settings.cache_clear()
|
||||
177
tests/test_web_csrf.py
Normal file
177
tests/test_web_csrf.py
Normal 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
155
tests/test_web_login.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Teste US-004 (PRD 3.3): GET/POST /login, POST /logout.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementarea auth_routes.py; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_rl(monkeypatch):
|
||||
"""Client cu login_rate_max=2 pentru testul de rate-limit."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
monkeypatch.setenv("AUTOPASS_LOGIN_RATE_MAX", "2")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _get_csrf(client, url: str) -> str:
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 200
|
||||
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
||||
if not m:
|
||||
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
||||
assert m, f"csrf_token negasit in {url}"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _create_user(email: str = "test@test.com", password: str = "parolasecreta", active: bool = True):
|
||||
"""Creeaza direct un cont + user in DB (fara HTTP)."""
|
||||
from app.accounts import create_account
|
||||
from app.users import create_user
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
acct_id = create_account(conn, "Service Test", active=active)
|
||||
user_id = create_user(conn, acct_id, email, password)
|
||||
return acct_id, user_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_login_corect_seteaza_sesiune(client):
|
||||
"""Credentiale corecte -> 303 redirect la /; sesiunea are account_id."""
|
||||
_create_user("valid@test.com", "parolasecreta", active=True)
|
||||
|
||||
token = _get_csrf(client, "/login")
|
||||
resp = client.post("/login", data={
|
||||
"email": "valid@test.com",
|
||||
"parola": "parolasecreta",
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
|
||||
assert resp.status_code == 303
|
||||
loc = resp.headers.get("location", "")
|
||||
assert loc in ("/", "http://testserver/"), f"Redirect gresit: {loc}"
|
||||
|
||||
|
||||
def test_login_gresit_401_fara_leak(client):
|
||||
"""Parola gresita -> 401; mesaj generic fara a dezvalui daca emailul exista."""
|
||||
_create_user("real@test.com", "parolasecreta", active=True)
|
||||
|
||||
token = _get_csrf(client, "/login")
|
||||
resp = client.post("/login", data={
|
||||
"email": "real@test.com",
|
||||
"parola": "gresita",
|
||||
"csrf_token": token,
|
||||
})
|
||||
|
||||
assert resp.status_code == 401
|
||||
text = resp.text.lower()
|
||||
assert "inexistent" not in text, "Raspunsul dezvaluie ca emailul nu exista"
|
||||
assert "nu exista" not in text, "Raspunsul dezvaluie ca emailul nu exista"
|
||||
|
||||
|
||||
def test_logout_redirect_login(client):
|
||||
"""POST /logout -> 303 redirect la /login."""
|
||||
_create_user("logout@test.com", "parolasecreta", active=True)
|
||||
|
||||
token = _get_csrf(client, "/login")
|
||||
client.post("/login", data={
|
||||
"email": "logout@test.com",
|
||||
"parola": "parolasecreta",
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
|
||||
# Dupa login, sesiunea e reset -> obtine un token CSRF nou
|
||||
token = _get_csrf(client, "/login")
|
||||
resp = client.post("/logout", data={"csrf_token": token}, follow_redirects=False)
|
||||
|
||||
assert resp.status_code == 303
|
||||
assert "/login" in resp.headers.get("location", "")
|
||||
|
||||
|
||||
def test_login_cont_inactiv_intra(client):
|
||||
"""C18: Login pe cont active=0 trebuie sa functioneze (gate-ul e doar pe trimitere)."""
|
||||
_create_user("inactiv@test.com", "parolasecreta", active=False)
|
||||
|
||||
token = _get_csrf(client, "/login")
|
||||
resp = client.post("/login", data={
|
||||
"email": "inactiv@test.com",
|
||||
"parola": "parolasecreta",
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
|
||||
assert resp.status_code == 303, (
|
||||
"Login pe cont inactiv trebuia sa reuseasca (gate-ul e doar la trimitere, nu la login)"
|
||||
)
|
||||
|
||||
|
||||
def test_login_rate_limit_429(client_rl):
|
||||
"""Peste login_rate_max incercari (login_rate_max=2) -> 429 la urmatoarea cerere."""
|
||||
# Doua incercari (permise)
|
||||
for _ in range(2):
|
||||
token = _get_csrf(client_rl, "/login")
|
||||
client_rl.post("/login", data={
|
||||
"email": "nimeni@test.com",
|
||||
"parola": "parola_gresita",
|
||||
"csrf_token": token,
|
||||
})
|
||||
|
||||
# A treia — trebuie 429
|
||||
token = _get_csrf(client_rl, "/login")
|
||||
resp = client_rl.post("/login", data={
|
||||
"email": "nimeni@test.com",
|
||||
"parola": "parola_gresita",
|
||||
"csrf_token": token,
|
||||
})
|
||||
assert resp.status_code == 429, "Peste login_rate_max trebuia 429"
|
||||
161
tests/test_web_session.py
Normal file
161
tests/test_web_session.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Teste US-002 (PRD 3.3): SessionMiddleware + helper-e sesiune in app/web/session.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ---- App minimal pentru teste (fara init_db, fara DB) ----
|
||||
|
||||
def _make_app(web_auth_required: bool = True, session_secret: str = "test-secret-dev") -> FastAPI:
|
||||
"""Construieste un app FastAPI minimal cu SessionMiddleware + rute de test."""
|
||||
mini = FastAPI()
|
||||
mini.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=session_secret,
|
||||
session_cookie="autopass_session",
|
||||
https_only=False,
|
||||
same_site="strict",
|
||||
)
|
||||
|
||||
@mini.get("/set-session")
|
||||
def set_sess(request: Request, account_id: int = 1, user_id: int = 1):
|
||||
from app.web.session import set_session
|
||||
set_session(request, account_id, user_id)
|
||||
return {"ok": True}
|
||||
|
||||
@mini.get("/get-session")
|
||||
def get_sess(request: Request):
|
||||
from app.web.session import current_account, current_user_id
|
||||
return {
|
||||
"account_id": current_account(request),
|
||||
"user_id": current_user_id(request),
|
||||
}
|
||||
|
||||
@mini.get("/protected")
|
||||
def protected(request: Request):
|
||||
from app.web.session import require_login
|
||||
aid = require_login(request)
|
||||
return {"account_id": aid}
|
||||
|
||||
@mini.get("/logout")
|
||||
def logout_ep(request: Request):
|
||||
from app.web.session import clear_session
|
||||
clear_session(request)
|
||||
return {"ok": True}
|
||||
|
||||
@mini.get("/web-account")
|
||||
def web_account_ep(request: Request):
|
||||
from app.web.session import web_account
|
||||
return {"account_id": web_account(request)}
|
||||
|
||||
# Handler pentru LoginRequired
|
||||
from app.web.session import LoginRequired
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
@mini.exception_handler(LoginRequired)
|
||||
async def login_required_handler(request: Request, exc: LoginRequired):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
return mini
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_auth(monkeypatch):
|
||||
"""Client cu web_auth_required=True."""
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
app = _make_app(web_auth_required=True)
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_dev(monkeypatch):
|
||||
"""Client cu web_auth_required=False (dev bypass)."""
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
app = _make_app(web_auth_required=False)
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_ruta_protejata_redirect_login(client_auth):
|
||||
"""Fara sesiune si web_auth_required=True -> 303 redirect catre /login."""
|
||||
resp = client_auth.get("/protected")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_sesiune_seteaza_si_citeste_cont(client_auth):
|
||||
"""set_session stocheaza account_id si user_id; current_account/current_user_id le citesc."""
|
||||
client_auth.get("/set-session?account_id=5&user_id=7")
|
||||
resp = client_auth.get("/get-session")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["account_id"] == 5
|
||||
assert data["user_id"] == 7
|
||||
|
||||
|
||||
def test_logout_curata_sesiunea(client_auth):
|
||||
"""clear_session sterge account_id si user_id din sesiune."""
|
||||
client_auth.get("/set-session?account_id=3&user_id=4")
|
||||
# Verifica ca sesiunea e setata
|
||||
data_before = client_auth.get("/get-session").json()
|
||||
assert data_before["account_id"] == 3
|
||||
# Logout
|
||||
client_auth.get("/logout")
|
||||
data_after = client_auth.get("/get-session").json()
|
||||
assert data_after["account_id"] is None
|
||||
assert data_after["user_id"] is None
|
||||
|
||||
|
||||
def test_dev_bypass_cont_1(client_dev, monkeypatch):
|
||||
"""web_auth_required=False -> web_account() returneaza 1 (DEFAULT_ACCOUNT_ID) fara sesiune."""
|
||||
resp = client_dev.get("/web-account")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account_id"] == 1
|
||||
|
||||
|
||||
def test_set_session_curata_sesiunea_anterioara(client_auth):
|
||||
"""set_session face clear() inainte de a seta (C3 anti-fixare sesiune)."""
|
||||
# Seteaza sesiune initiala cu cont 10
|
||||
client_auth.get("/set-session?account_id=10&user_id=10")
|
||||
data_initial = client_auth.get("/get-session").json()
|
||||
assert data_initial["account_id"] == 10
|
||||
# Re-login cu cont nou 20 — sesiunea veche trebuie stearsa inainte
|
||||
client_auth.get("/set-session?account_id=20&user_id=20")
|
||||
data_nou = client_auth.get("/get-session").json()
|
||||
assert data_nou["account_id"] == 20
|
||||
assert data_nou["user_id"] == 20
|
||||
|
||||
|
||||
def test_ruta_protejata_cu_sesiune_trece(client_auth):
|
||||
"""Cu sesiune setata si web_auth_required=True -> ruta protejata raspunde 200."""
|
||||
client_auth.get("/set-session?account_id=5&user_id=5")
|
||||
resp = client_auth.get("/protected")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account_id"] == 5
|
||||
|
||||
|
||||
def test_web_auth_required_default_true(monkeypatch):
|
||||
"""Default-ul de productie: auth web e ON daca AUTOPASS_WEB_AUTH_REQUIRED nu e setat.
|
||||
|
||||
Dev rapid pe cont 1 = opt-out explicit (AUTOPASS_WEB_AUTH_REQUIRED=false).
|
||||
"""
|
||||
monkeypatch.delenv("AUTOPASS_WEB_AUTH_REQUIRED", raising=False)
|
||||
from app.config import Settings, get_settings
|
||||
get_settings.cache_clear()
|
||||
assert Settings().web_auth_required is True
|
||||
get_settings.cache_clear()
|
||||
154
tests/test_web_signup.py
Normal file
154
tests/test_web_signup.py
Normal 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)"
|
||||
145
tests/test_worker_active_gate.py
Normal file
145
tests/test_worker_active_gate.py
Normal 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
|
||||
@@ -25,6 +25,7 @@ import sys
|
||||
from app.accounts import create_account, list_accounts, set_active
|
||||
from app.auth import create_api_key
|
||||
from app.db import get_connection, init_db
|
||||
from app.users import set_admin
|
||||
|
||||
|
||||
def _create(conn: sqlite3.Connection, args: argparse.Namespace) -> int:
|
||||
@@ -67,6 +68,17 @@ def _set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _set_admin(conn: sqlite3.Connection, account_id: int, is_admin: bool) -> int:
|
||||
try:
|
||||
set_admin(conn, account_id, is_admin=is_admin)
|
||||
except ValueError as exc:
|
||||
print(f"eroare: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
actiune = "admin" if is_admin else "non-admin"
|
||||
print(f"Cont {account_id}: marcat ca {actiune}")
|
||||
return 0
|
||||
|
||||
|
||||
def _list(conn: sqlite3.Connection, pending_only: bool) -> int:
|
||||
rows = list_accounts(conn)
|
||||
if pending_only:
|
||||
@@ -102,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
p_deact = sub.add_parser("deactivate", help="dezactiveaza un cont")
|
||||
p_deact.add_argument("--account", type=int, required=True, help="account_id")
|
||||
|
||||
p_sadmin = sub.add_parser("set-admin", help="seteaza/sterge rol admin pe un cont")
|
||||
p_sadmin.add_argument("--account", type=int, required=True, help="account_id")
|
||||
p_sadmin.add_argument("--remove", action="store_true", help="sterge rolul admin (implicit: adauga)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
init_db() # asigura schema (accounts.active + index CUI) + cont default
|
||||
@@ -115,6 +131,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return _set_active(conn, args.account, True)
|
||||
if args.cmd == "deactivate":
|
||||
return _set_active(conn, args.account, False)
|
||||
if args.cmd == "set-admin":
|
||||
return _set_admin(conn, args.account, is_admin=not args.remove)
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user