feat(web): self-onboarding multi-tenant + auth sesiune (PRD 3.3a)
Canalul web trece de la 100% deschis (hardcodat cont 1) la autentificat si multi-tenant. Un service nou se inregistreaza din browser, primeste o cheie API (o singura data) si o sesiune; contul se creeaza "in asteptare" (active=0) si nu trimite la RAR pana la activarea de catre admin (tools/account.py activate). - users + app/users.py: parole scrypt (salt per-user, eticheta parametri onorata la verify pentru migrare cost), email unic case-insensitive - sesiune: SessionMiddleware (same_site=strict, https_only config) + app/web/session.py (current_account/web_account/require_login->LoginRequired, set_session clear-inainte) - CSRF (app/web/csrf.py) enforce in prod inclusiv pe login/signup + rate-limit in-proces (app/web/ratelimit.py) pe signup si login - signup/login/logout (app/web/auth_routes.py): signup tranzactie atomica, cheie-o-data, log SIGNUP pentru descoperire admin - dashboard + import scoped pe contul sesiunii (regula NULL->cont 1); toate rutele web care ating date sensibile sub require_login; nomenclator ramane global - banner "cont in asteptare" pentru conturi active=0 - gate worker: claim_one LEFT JOIN accounts COALESCE(active,1)=1 (account_id NULL=activ) VERIFY context curat (2 runde): leak cross-account /_fragments/mapari prins+reparat. /code-review high: csrf_token lipsa pe re-randari de eroare, scrypt_params ignorat, login fara rate-limit -- toate reparate. 361 teste pass (de la 313). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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
|
||||
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()
|
||||
198
tests/test_import_web_scope.py
Normal file
198
tests/test_import_web_scope.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Teste US-006a/b (PRD 3.3): scoping import web pe sesiune.
|
||||
|
||||
US-006a: citiri (upload, preview, mapare-coloane) pe contul sesiunii.
|
||||
US-006b: scrieri (confirma) pe contul sesiunii; alt cont -> inaccesibil.
|
||||
C8/OV-2: aceeasi cheie idempotenta prin API si web pe acelasi cont.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _make_xlsx(rows: list[dict]) -> bytes:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
if rows:
|
||||
ws.append(list(rows[0].keys()))
|
||||
for r in rows:
|
||||
ws.append(list(r.values()))
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
_ROWS = [
|
||||
{"vin": "WVWZZZ1KZAW111111", "nr_inmatriculare": "B111TST",
|
||||
"data_prestatie": "2026-06-01", "odometru_final": "10000",
|
||||
"cod_prestatie": "OE-1"},
|
||||
]
|
||||
|
||||
|
||||
def _csrf_from(html: str) -> str:
|
||||
"""Extrage tokenul CSRF din HTML (hidden input)."""
|
||||
m = re.search(r'name="csrf_token" value="([^"]*)"', html)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.db"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
from app.accounts import create_account
|
||||
acct_a = create_account(conn, "Cont A Scope", active=True)
|
||||
acct_b = create_account(conn, "Cont B Scope", active=True)
|
||||
yield c, conn, acct_a, acct_b
|
||||
conn.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _setup_op_mapping(conn, account_id):
|
||||
"""Configureaza maparea operatie cod_op=OE-1 -> cod_prestatie=OE-1 pt. cont."""
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
||||
("OE-1", "Operatii electrice"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO operations_mapping "
|
||||
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (?, ?, ?, ?)",
|
||||
(account_id, "OE-1", "OE-1", 1),
|
||||
)
|
||||
|
||||
|
||||
def _mapare_coloane(c, import_id, csrf_token: str = ""):
|
||||
"""Salveaza maparea de coloane; mapeaza cod_prestatie -> operatie (canonical).
|
||||
|
||||
cod_prestatie din xlsx trebuie mapat la 'operatie' (nu la 'cod_prestatie' care nu
|
||||
e camp canonic). resolve_prestatii il rezolva din operations_mapping.
|
||||
"""
|
||||
return c.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
data={
|
||||
"csrf_token": csrf_token,
|
||||
"colname": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "cod_prestatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_upload_pe_contul_sesiunii(env, monkeypatch):
|
||||
"""Upload creeaza batch pe contul din sesiune (nu DEFAULT_ACCOUNT_ID)."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
|
||||
r = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
assert r.status_code == 200
|
||||
|
||||
batch = conn.execute("SELECT id, account_id FROM import_batches").fetchone()
|
||||
assert batch is not None
|
||||
assert batch["account_id"] == acct_a
|
||||
|
||||
|
||||
def test_batch_alt_cont_inaccesibil(env, monkeypatch):
|
||||
"""Batch-ul contului A nu e accesibil din sesiunea contului B (preview -> eroare)."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
|
||||
# Upload ca A (sesiune curata, fara csrf_token anterior)
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
|
||||
# Preview ca B (GET, fara CSRF) -> trebuie eroare/inaccesibil
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
|
||||
r = client.get(f"/_import/{batch_id}/preview")
|
||||
assert r.status_code == 200
|
||||
assert "inexistent" in r.text.lower() or "inaccesibil" in r.text.lower()
|
||||
|
||||
|
||||
def test_commit_creeaza_submissions_pe_cont(env, monkeypatch):
|
||||
"""Confirma creeaza submissions cu account_id-ul sesiunii."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
_setup_op_mapping(conn, acct_a)
|
||||
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
# Upload — raspunsul contine csrf_token in form (sesiunea l-a creat)
|
||||
r_upload = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
csrf = _csrf_from(r_upload.text)
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
# Mapare coloane cu tokenul din upload
|
||||
r_map = _mapare_coloane(client, batch_id, csrf)
|
||||
csrf = _csrf_from(r_map.text) or csrf # tokenul din preview (stabil per sesiune)
|
||||
# Confirma cu tokenul sesiunii
|
||||
r = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert r.status_code == 200
|
||||
|
||||
sub = conn.execute("SELECT account_id FROM submissions").fetchone()
|
||||
assert sub is not None
|
||||
assert sub["account_id"] == acct_a
|
||||
|
||||
|
||||
def test_cheie_identica_api_vs_web_acelasi_cont(env, monkeypatch):
|
||||
"""C8/OV-2: import web si API pe acelasi cont produc aceeasi cheie idempotenta."""
|
||||
from app.idempotency import build_key, canonicalize_row
|
||||
client, conn, acct_a, acct_b = env
|
||||
_setup_op_mapping(conn, acct_a)
|
||||
|
||||
row = {
|
||||
"vin": "WVWZZZ1KZAW999999",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "99999",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
canon = canonicalize_row(row)
|
||||
key_api = build_key(acct_a, canon)
|
||||
|
||||
# Upload web pe acelasi cont
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
web_row = {
|
||||
"vin": "WVWZZZ1KZAW999999",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "99999",
|
||||
"cod_prestatie": "OE-1",
|
||||
}
|
||||
r_up = client.post("/_import/upload", files={"file": ("w.xlsx", _make_xlsx([web_row]), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
csrf = _csrf_from(r_up.text)
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
r_map = _mapare_coloane(client, batch_id, csrf)
|
||||
csrf = _csrf_from(r_map.text) or csrf
|
||||
client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
|
||||
sub = conn.execute("SELECT idempotency_key FROM submissions WHERE account_id=?", (acct_a,)).fetchone()
|
||||
assert sub is not None
|
||||
assert sub["idempotency_key"] == key_api
|
||||
|
||||
|
||||
def test_confirma_alt_cont_inaccesibil(env, monkeypatch):
|
||||
"""Confirma batch-ul contului A din sesiunea B -> eroare batch inexistent."""
|
||||
client, conn, acct_a, acct_b = env
|
||||
_setup_op_mapping(conn, acct_a)
|
||||
|
||||
# Upload + mapare ca A (cu CSRF tokens corecti)
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
|
||||
r_up = client.post("/_import/upload", files={"file": ("a.xlsx", _make_xlsx(_ROWS), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")})
|
||||
csrf = _csrf_from(r_up.text)
|
||||
batch_id = conn.execute("SELECT id FROM import_batches WHERE account_id=?", (acct_a,)).fetchone()["id"]
|
||||
r_map = _mapare_coloane(client, batch_id, csrf)
|
||||
csrf = _csrf_from(r_map.text) or csrf
|
||||
|
||||
# Confirma ca B cu tokenul din sesiune (acelasi cookie jar, token valid CSRF)
|
||||
# dar batch apartine lui A -> "inexistent sau expirat"
|
||||
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
|
||||
r = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
|
||||
assert r.status_code == 200
|
||||
assert "inexistent" in r.text.lower() or "inaccesibil" in r.text.lower() or "expirat" in r.text.lower()
|
||||
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()
|
||||
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()
|
||||
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"
|
||||
149
tests/test_web_session.py
Normal file
149
tests/test_web_session.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Teste US-002 (PRD 3.3): SessionMiddleware + helper-e sesiune in app/web/session.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ---- App minimal pentru teste (fara init_db, fara DB) ----
|
||||
|
||||
def _make_app(web_auth_required: bool = True, session_secret: str = "test-secret-dev") -> FastAPI:
|
||||
"""Construieste un app FastAPI minimal cu SessionMiddleware + rute de test."""
|
||||
mini = FastAPI()
|
||||
mini.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=session_secret,
|
||||
session_cookie="autopass_session",
|
||||
https_only=False,
|
||||
same_site="strict",
|
||||
)
|
||||
|
||||
@mini.get("/set-session")
|
||||
def set_sess(request: Request, account_id: int = 1, user_id: int = 1):
|
||||
from app.web.session import set_session
|
||||
set_session(request, account_id, user_id)
|
||||
return {"ok": True}
|
||||
|
||||
@mini.get("/get-session")
|
||||
def get_sess(request: Request):
|
||||
from app.web.session import current_account, current_user_id
|
||||
return {
|
||||
"account_id": current_account(request),
|
||||
"user_id": current_user_id(request),
|
||||
}
|
||||
|
||||
@mini.get("/protected")
|
||||
def protected(request: Request):
|
||||
from app.web.session import require_login
|
||||
aid = require_login(request)
|
||||
return {"account_id": aid}
|
||||
|
||||
@mini.get("/logout")
|
||||
def logout_ep(request: Request):
|
||||
from app.web.session import clear_session
|
||||
clear_session(request)
|
||||
return {"ok": True}
|
||||
|
||||
@mini.get("/web-account")
|
||||
def web_account_ep(request: Request):
|
||||
from app.web.session import web_account
|
||||
return {"account_id": web_account(request)}
|
||||
|
||||
# Handler pentru LoginRequired
|
||||
from app.web.session import LoginRequired
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
@mini.exception_handler(LoginRequired)
|
||||
async def login_required_handler(request: Request, exc: LoginRequired):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
return mini
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_auth(monkeypatch):
|
||||
"""Client cu web_auth_required=True."""
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
app = _make_app(web_auth_required=True)
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_dev(monkeypatch):
|
||||
"""Client cu web_auth_required=False (dev bypass)."""
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
app = _make_app(web_auth_required=False)
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_ruta_protejata_redirect_login(client_auth):
|
||||
"""Fara sesiune si web_auth_required=True -> 303 redirect catre /login."""
|
||||
resp = client_auth.get("/protected")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_sesiune_seteaza_si_citeste_cont(client_auth):
|
||||
"""set_session stocheaza account_id si user_id; current_account/current_user_id le citesc."""
|
||||
client_auth.get("/set-session?account_id=5&user_id=7")
|
||||
resp = client_auth.get("/get-session")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["account_id"] == 5
|
||||
assert data["user_id"] == 7
|
||||
|
||||
|
||||
def test_logout_curata_sesiunea(client_auth):
|
||||
"""clear_session sterge account_id si user_id din sesiune."""
|
||||
client_auth.get("/set-session?account_id=3&user_id=4")
|
||||
# Verifica ca sesiunea e setata
|
||||
data_before = client_auth.get("/get-session").json()
|
||||
assert data_before["account_id"] == 3
|
||||
# Logout
|
||||
client_auth.get("/logout")
|
||||
data_after = client_auth.get("/get-session").json()
|
||||
assert data_after["account_id"] is None
|
||||
assert data_after["user_id"] is None
|
||||
|
||||
|
||||
def test_dev_bypass_cont_1(client_dev, monkeypatch):
|
||||
"""web_auth_required=False -> web_account() returneaza 1 (DEFAULT_ACCOUNT_ID) fara sesiune."""
|
||||
resp = client_dev.get("/web-account")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account_id"] == 1
|
||||
|
||||
|
||||
def test_set_session_curata_sesiunea_anterioara(client_auth):
|
||||
"""set_session face clear() inainte de a seta (C3 anti-fixare sesiune)."""
|
||||
# Seteaza sesiune initiala cu cont 10
|
||||
client_auth.get("/set-session?account_id=10&user_id=10")
|
||||
data_initial = client_auth.get("/get-session").json()
|
||||
assert data_initial["account_id"] == 10
|
||||
# Re-login cu cont nou 20 — sesiunea veche trebuie stearsa inainte
|
||||
client_auth.get("/set-session?account_id=20&user_id=20")
|
||||
data_nou = client_auth.get("/get-session").json()
|
||||
assert data_nou["account_id"] == 20
|
||||
assert data_nou["user_id"] == 20
|
||||
|
||||
|
||||
def test_ruta_protejata_cu_sesiune_trece(client_auth):
|
||||
"""Cu sesiune setata si web_auth_required=True -> ruta protejata raspunde 200."""
|
||||
client_auth.get("/set-session?account_id=5&user_id=5")
|
||||
resp = client_auth.get("/protected")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["account_id"] == 5
|
||||
154
tests/test_web_signup.py
Normal file
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
|
||||
Reference in New Issue
Block a user