"""Offline-тесты POST /api/v1/trade-in/lead (#2376, sub-issue родителя #1971). Покрытие (db мокается, NO live network/DB): - happy path: consent=True + валидный телефон -> 200, INSERT + commit - consent=False -> 422 (Literal[True] guard) - невалидный формат телефона -> 422 - телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening) - source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы) - estimate_id, которого нет в trade_in_estimates -> 404 - IDOR guard (security-audit, зеркалит #690/test_estimate_idor.py): estimate_id чужого пользователя -> 404; admin может привязать любой; нет X-Authenticated-User -> 401; неизвестная роль -> 403 """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock from uuid import uuid4 import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @pytest.fixture def db() -> MagicMock: return MagicMock() @pytest.fixture def client(db: MagicMock) -> TestClient: from app.api.v1 import lead as lead_module from app.core.db import get_db app = FastAPI() app.include_router(lead_module.router, prefix="/api/v1/trade-in") def fake_db() -> Any: yield db app.dependency_overrides[get_db] = fake_db return TestClient(app) @pytest.fixture(autouse=True) def _restore_get_role(): """Restore app.core.auth.get_role after each test (mirror test_estimate_idor.py).""" from app.core import auth as auth_mod original = auth_mod.get_role yield auth_mod.get_role = original def _insert_result(lead_id: str) -> MagicMock: result = MagicMock() result.mappings.return_value.one.return_value = { "id": lead_id, "created_at": datetime(2026, 7, 4, 12, 0, tzinfo=UTC), } return result def test_lead_happy_path(client: TestClient, db: MagicMock) -> None: from app.api.v1 import lead as lead_module lead_id = str(uuid4()) db.execute.return_value = _insert_result(lead_id) r = client.post( "/api/v1/trade-in/lead", json={"phone": "+7 (912) 345-67-89", "consent": True}, headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"}, ) assert r.status_code == 200, r.text body = r.json() assert body["id"] == lead_id assert body["status"] == "received" assert db.commit.called params = db.execute.call_args.args[1] assert params["phone"] == "+7 (912) 345-67-89" assert params["consent"] is True assert params["source"] == "result" assert params["estimate_id"] is None # 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text # snapshot must reach the INSERT params, not just the audit log (#2497 TODO). assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None: # No X-Forwarded-For header -> falls back to request.client.host (TestClient # reports "testclient"), still persisted rather than silently None. lead_id = str(uuid4()) db.execute.return_value = _insert_result(lead_id) r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True}, ) assert r.status_code == 200, r.text params = db.execute.call_args.args[1] assert params["client_ip"] == "testclient" def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None: r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": False}, ) assert r.status_code == 422 assert not db.execute.called def test_lead_consent_missing_422(client: TestClient) -> None: r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789"}) assert r.status_code == 422 def test_lead_invalid_phone_422(client: TestClient) -> None: r = client.post( "/api/v1/trade-in/lead", json={"phone": "not-a-phone!!", "consent": True}, ) assert r.status_code == 422 def test_lead_with_unknown_estimate_id_404(client: TestClient, db: MagicMock) -> None: result = MagicMock() result.fetchone.return_value = None db.execute.return_value = result r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, ) assert r.status_code == 404 assert not db.commit.called def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> None: """Owner привязывает лид к своей же оценке -> 200.""" from app.core import auth as auth_mod auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment] lead_id = str(uuid4()) exists_result = MagicMock() exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov") db.execute.side_effect = [exists_result, _insert_result(lead_id)] estimate_id = str(uuid4()) r = client.post( "/api/v1/trade-in/lead", json={ "phone": "+79123456789", "consent": True, "estimate_id": estimate_id, }, headers={"X-Authenticated-User": "kopylov"}, ) assert r.status_code == 200, r.text params = db.execute.call_args.args[1] assert params["estimate_id"] == estimate_id assert params["source"] == "result" # ── IDOR guard (security-audit): estimate_id ownership ───────────────────────── def test_lead_estimate_id_owned_by_other_user_gets_404(client: TestClient, db: MagicMock) -> None: """Чужой estimate_id -> 404 (существование не подтверждаем), лид НЕ создаётся.""" from app.core import auth as auth_mod auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment] exists_result = MagicMock() exists_result.fetchone.return_value = SimpleNamespace(created_by="victim") db.execute.return_value = exists_result r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, headers={"X-Authenticated-User": "attacker"}, ) assert r.status_code == 404, r.text assert not db.commit.called def test_lead_estimate_id_admin_can_attach_any_200(client: TestClient, db: MagicMock) -> None: """Admin может привязать лид к чужой оценке (owner-or-admin, зеркалит #690).""" from app.core import auth as auth_mod auth_mod.get_role = lambda _u: "admin" # type: ignore[assignment] lead_id = str(uuid4()) exists_result = MagicMock() exists_result.fetchone.return_value = SimpleNamespace(created_by="someone_else") db.execute.side_effect = [exists_result, _insert_result(lead_id)] r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, headers={"X-Authenticated-User": "admin"}, ) assert r.status_code == 200, r.text def test_lead_estimate_id_requires_authenticated_user_401( client: TestClient, db: MagicMock ) -> None: """estimate_id задан, но нет X-Authenticated-User -> 401 (defense-in-depth: в проде rbac_guard уже требует заголовок раньше, см. app/main.py).""" exists_result = MagicMock() exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov") db.execute.return_value = exists_result r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, ) assert r.status_code == 401, r.text assert not db.commit.called def test_lead_estimate_id_unknown_role_403(client: TestClient, db: MagicMock) -> None: """Аутентифицирован через Caddy, но роль отсутствует в roles.yaml -> 403.""" from app.core import auth as auth_mod def _raise_keyerror(_u: str): raise KeyError(_u) auth_mod.get_role = _raise_keyerror # type: ignore[assignment] exists_result = MagicMock() exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov") db.execute.return_value = exists_result r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, headers={"X-Authenticated-User": "ghost"}, ) assert r.status_code == 403, r.text assert not db.commit.called def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None: # "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы), # но содержит 0 цифр -> должно отклоняться digit-валидатором. r = client.post( "/api/v1/trade-in/lead", json={"phone": "(()) -- ..", "consent": True}, ) assert r.status_code == 422, r.text assert not db.execute.called def test_lead_too_few_digits_phone_422(client: TestClient, db: MagicMock) -> None: # 6 цифр < минимума 10 -> 422 (маску проходит по длине, digit-guard режет). r = client.post( "/api/v1/trade-in/lead", json={"phone": "123-456", "consent": True}, ) assert r.status_code == 422, r.text assert not db.execute.called def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> None: # "landing"-воронка недостижима (rbac_guard закрывает /lead) — литерал убран # из схемы, поэтому явный source="landing" теперь 422. r = client.post( "/api/v1/trade-in/lead", json={"phone": "+79123456789", "consent": True, "source": "landing"}, ) assert r.status_code == 422, r.text assert not db.execute.called