Law/tests/test_public_requests.py
2026-03-03 14:13:59 +03:00

654 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from uuid import UUID, uuid4
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Ensure settings can be initialized in test environments
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.main import app
from app.core.config import settings
from app.core.security import create_jwt, decode_jwt
from app.db.session import get_db
from app.models.client import Client
from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.otp_session import OtpSession
from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest
from app.models.topic_required_field import TopicRequiredField
class PublicRequestCreateTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
Client.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
RequestServiceRequest.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
OtpSession.__table__.create(bind=cls.engine)
TopicRequiredField.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
RequestServiceRequest.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
OtpSession.__table__.drop(bind=cls.engine)
TopicRequiredField.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AuditLog.__table__.drop(bind=cls.engine)
Client.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(RequestServiceRequest))
db.execute(delete(Notification))
db.execute(delete(OtpSession))
db.execute(delete(TopicRequiredField))
db.execute(delete(Request))
db.execute(delete(AuditLog))
db.execute(delete(Client))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
self._otp_limits_backup = (
settings.OTP_SEND_RATE_LIMIT,
settings.OTP_VERIFY_RATE_LIMIT,
settings.OTP_RATE_LIMIT_WINDOW_SECONDS,
)
settings.OTP_SEND_RATE_LIMIT = 10_000
settings.OTP_VERIFY_RATE_LIMIT = 10_000
settings.OTP_RATE_LIMIT_WINDOW_SECONDS = 1
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
settings.OTP_SEND_RATE_LIMIT, settings.OTP_VERIFY_RATE_LIMIT, settings.OTP_RATE_LIMIT_WINDOW_SECONDS = self._otp_limits_backup
@staticmethod
def _unique_phone() -> str:
suffix = f"{uuid4().int % 10_000_000_000:010d}"
return f"+79{suffix}"
def _send_and_verify_create_otp(self, phone: str) -> None:
with patch("app.api.public.otp._generate_code", return_value="123456"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
body = sent.json()
self.assertEqual(body["status"], "sent")
self.assertEqual(body["sms_response"]["provider"], "mock_sms")
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "123456"},
)
self.assertEqual(verified.status_code, 200)
self.assertEqual(verified.json()["status"], "verified")
def test_create_request_requires_verified_otp_cookie(self):
payload = {
"client_name": "ООО Ромашка",
"client_phone": "+79990000001",
"topic_code": "consulting",
"description": "Тестируем создание заявки",
"extra_fields": {"referral_name": "Партнер"},
"pdn_consent": True,
}
response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 401)
self._send_and_verify_create_otp(payload["client_phone"])
response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertTrue(body["track_number"].startswith("TRK-"))
self.assertFalse(body["otp_required"])
request_id = UUID(body["request_id"])
with self.SessionLocal() as db:
created = db.get(Request, request_id)
self.assertIsNotNone(created)
self.assertEqual(created.client_name, payload["client_name"])
self.assertEqual(created.client_phone, payload["client_phone"])
self.assertIsNotNone(created.client_id)
self.assertEqual(created.topic_code, payload["topic_code"])
self.assertEqual(created.description, payload["description"])
self.assertEqual(created.extra_fields, payload["extra_fields"])
self.assertEqual(created.status_code, "NEW")
self.assertIsNotNone(created.important_date_at)
self.assertEqual(created.track_number, body["track_number"])
self.assertEqual(created.responsible, "Клиент")
self.assertTrue(created.pdn_consent)
self.assertIsNotNone(created.pdn_consent_at)
self.assertIsNotNone(created.pdn_consent_ip)
created_at = created.created_at or datetime.now(timezone.utc)
important_at = created.important_date_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if important_at.tzinfo is None:
important_at = important_at.replace(tzinfo=timezone.utc)
initial_deadline_seconds = (important_at - created_at).total_seconds()
self.assertGreaterEqual(initial_deadline_seconds, 23 * 3600)
self.assertLessEqual(initial_deadline_seconds, 25 * 3600)
client = db.get(Client, created.client_id)
self.assertIsNotNone(client)
self.assertEqual(client.phone, payload["client_phone"])
# After creation, cookie is switched to VIEW_REQUEST for this track.
read = self.client.get(f"/api/public/requests/{body['track_number']}")
self.assertEqual(read.status_code, 200)
self.assertEqual(read.json()["track_number"], body["track_number"])
def test_view_request_requires_view_otp_and_uses_track_cookie(self):
track_number = f"TRK-VIEW-{uuid4().hex[:8].upper()}"
with self.SessionLocal() as db:
row = Request(
track_number=track_number,
client_name="Клиент",
client_phone=self._unique_phone(),
topic_code="consulting",
status_code="NEW",
description="Проверка просмотра",
extra_fields={},
)
db.add(row)
db.commit()
no_session = self.client.get(f"/api/public/requests/{track_number}")
self.assertEqual(no_session.status_code, 401)
with patch("app.api.public.otp._generate_code", return_value="654321"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "track_number": track_number},
)
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.json()["status"], "sent")
wrong_code = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "track_number": track_number, "code": "000000"},
)
self.assertEqual(wrong_code.status_code, 400)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "track_number": track_number, "code": "654321"},
)
self.assertEqual(verified.status_code, 200)
ok = self.client.get(f"/api/public/requests/{track_number}")
self.assertEqual(ok.status_code, 200)
self.assertEqual(ok.json()["track_number"], track_number)
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
self.assertEqual(denied_other_track.status_code, 404)
def test_otp_send_rejects_honeypot_field(self):
response = self.client.post(
"/api/public/otp/send",
json={
"purpose": "CREATE_REQUEST",
"client_phone": self._unique_phone(),
"hp_field": "https://spam.example",
},
)
self.assertEqual(response.status_code, 400)
def test_create_request_rejects_honeypot_field(self):
phone = self._unique_phone()
self._send_and_verify_create_otp(phone)
payload = {
"client_name": "Клиент honeypot",
"client_phone": phone,
"topic_code": "consulting",
"description": "Проверка honeypot",
"extra_fields": {},
"pdn_consent": True,
"hp_field": "https://spam.example",
}
response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 400)
def test_create_request_requires_pdn_consent(self):
phone = self._unique_phone()
self._send_and_verify_create_otp(phone)
payload = {
"client_name": "Клиент без согласия",
"client_phone": phone,
"topic_code": "consulting",
"description": "Проверка согласия ПДн",
"extra_fields": {},
"pdn_consent": False,
}
response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 400)
self.assertIn("согласие", str(response.json().get("detail", "")).lower())
def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self):
phone = "+79996660077"
with self.SessionLocal() as db:
client = Client(full_name="Клиент Мульти", phone=phone, responsible="seed")
db.add(client)
db.flush()
db.add_all(
[
Request(
track_number="TRK-MULTI-1",
client_id=client.id,
client_name=client.full_name,
client_phone=client.phone,
topic_code="consulting",
status_code="NEW",
description="Первая",
extra_fields={},
),
Request(
track_number="TRK-MULTI-2",
client_id=client.id,
client_name=client.full_name,
client_phone=client.phone,
topic_code="consulting",
status_code="IN_PROGRESS",
description="Вторая",
extra_fields={},
),
Request(
track_number="TRK-FOREIGN-1",
client_name="Другой клиент",
client_phone="+79990009999",
topic_code="consulting",
status_code="NEW",
description="Чужая",
extra_fields={},
),
]
)
db.commit()
with patch("app.api.public.otp._generate_code", return_value="111111"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "client_phone": phone, "code": "111111"},
)
self.assertEqual(verified.status_code, 200)
list_resp = self.client.get("/api/public/requests/my")
self.assertEqual(list_resp.status_code, 200)
rows = list_resp.json().get("rows") or []
tracks = {row["track_number"] for row in rows}
self.assertEqual(tracks, {"TRK-MULTI-1", "TRK-MULTI-2"})
opened = self.client.get("/api/public/requests/TRK-MULTI-2")
self.assertEqual(opened.status_code, 200)
self.assertEqual(opened.json()["track_number"], "TRK-MULTI-2")
denied = self.client.get("/api/public/requests/TRK-FOREIGN-1")
self.assertEqual(denied.status_code, 404)
def test_email_auth_mode_allows_create_flow_via_email_otp(self):
phone = self._unique_phone()
email = "client.email.mode@example.com"
with (
patch("app.api.public.otp.settings.PUBLIC_AUTH_MODE", "email"),
patch("app.api.public.otp.settings.EMAIL_PROVIDER", "dummy"),
patch("app.api.public.otp._generate_code", return_value="112233"),
):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_email": email},
)
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.json()["channel"], "EMAIL")
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_email": email, "code": "112233"},
)
self.assertEqual(verified.status_code, 200)
self.assertEqual(verified.json()["channel"], "EMAIL")
create = self.client.post(
"/api/public/requests",
json={
"client_name": "Email Client",
"client_phone": phone,
"client_email": email,
"topic_code": "consulting",
"description": "Email auth mode create",
"extra_fields": {},
"pdn_consent": True,
},
)
self.assertEqual(create.status_code, 201)
body = create.json()
with self.SessionLocal() as db:
req = db.query(Request).filter(Request.track_number == body["track_number"]).first()
self.assertIsNotNone(req)
self.assertEqual(req.client_email, email)
def test_view_otp_email_channel_by_track(self):
track_number = f"TRK-EMAIL-{uuid4().hex[:8].upper()}"
email = "view.track.email@example.com"
with self.SessionLocal() as db:
row = Request(
track_number=track_number,
client_name="Клиент Email",
client_phone=self._unique_phone(),
client_email=email,
topic_code="consulting",
status_code="NEW",
description="Проверка просмотра по email",
extra_fields={},
)
db.add(row)
db.commit()
with (
patch("app.api.public.otp.settings.PUBLIC_AUTH_MODE", "sms_or_email"),
patch("app.api.public.otp.settings.EMAIL_PROVIDER", "dummy"),
patch("app.api.public.otp._generate_code", return_value="445566"),
):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "track_number": track_number, "channel": "email"},
)
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.json()["channel"], "EMAIL")
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "track_number": track_number, "channel": "email", "code": "445566"},
)
self.assertEqual(verified.status_code, 200)
self.assertEqual(verified.json()["channel"], "EMAIL")
ok = self.client.get(f"/api/public/requests/{track_number}")
self.assertEqual(ok.status_code, 200)
def test_send_otp_falls_back_to_email_when_sms_balance_low(self):
with (
patch("app.api.public.otp.settings.PUBLIC_AUTH_MODE", "sms_or_email"),
patch("app.api.public.otp.settings.OTP_EMAIL_FALLBACK_ENABLED", True),
patch("app.api.public.otp.settings.OTP_SMS_MIN_BALANCE", 100.0),
patch("app.api.public.otp.sms_provider_health", return_value={"mode": "real", "balance_amount": 0.0}),
patch("app.api.public.otp.send_otp_email_message", return_value={"provider": "mock_email", "debug_code": "778899"}),
patch("app.api.public.otp._generate_code", return_value="778899"),
):
sent = self.client.post(
"/api/public/otp/send",
json={
"purpose": "CREATE_REQUEST",
"client_phone": "+79991112233",
"client_email": "fallback@example.com",
"channel": "sms",
},
)
self.assertEqual(sent.status_code, 200)
body = sent.json()
self.assertEqual(body.get("channel"), "EMAIL")
self.assertEqual(body.get("fallback_reason"), "low_sms_balance")
def test_open_request_marks_client_updates_as_read(self):
with self.SessionLocal() as db:
row = Request(
track_number="TRK-READ-1",
client_name="Клиент",
client_phone="+79995550011",
topic_code="consulting",
status_code="IN_PROGRESS",
description="Проверка чтения",
extra_fields={},
client_has_unread_updates=True,
client_unread_event_type="STATUS",
)
db.add(row)
db.commit()
request_id = row.id
public_token = create_jwt({"sub": "TRK-READ-1", "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
opened = self.client.get("/api/public/requests/TRK-READ-1", cookies=cookies)
self.assertEqual(opened.status_code, 200)
body = opened.json()
self.assertFalse(body["client_has_unread_updates"])
self.assertIsNone(body["client_unread_event_type"])
with self.SessionLocal() as db:
refreshed = db.get(Request, request_id)
self.assertIsNotNone(refreshed)
self.assertFalse(refreshed.client_has_unread_updates)
self.assertIsNone(refreshed.client_unread_event_type)
def test_create_request_checks_required_topic_fields(self):
phone = "+79990000005"
self._send_and_verify_create_otp(phone)
with self.SessionLocal() as db:
db.add(
TopicRequiredField(
topic_code="consulting",
field_key="passport_series",
required=True,
enabled=True,
sort_order=1,
responsible="root@example.com",
)
)
db.commit()
missing = self.client.post(
"/api/public/requests",
json={
"client_name": "ООО Поле",
"client_phone": phone,
"topic_code": "consulting",
"description": "Проверка обязательного поля",
"extra_fields": {},
"pdn_consent": True,
},
)
self.assertEqual(missing.status_code, 400)
self.assertIn("passport_series", missing.json().get("detail", ""))
created = self.client.post(
"/api/public/requests",
json={
"client_name": "ООО Поле",
"client_phone": phone,
"topic_code": "consulting",
"description": "Проверка обязательного поля",
"extra_fields": {"passport_series": "1234"},
"pdn_consent": True,
},
)
self.assertEqual(created.status_code, 201)
self.assertTrue(created.json()["track_number"].startswith("TRK-"))
def test_verify_otp_sets_public_cookie_for_configured_ttl(self):
phone = self._unique_phone()
with patch("app.api.public.otp._generate_code", return_value="777777"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "777777"},
)
self.assertEqual(verified.status_code, 200)
token = verified.cookies.get(settings.PUBLIC_COOKIE_NAME)
self.assertTrue(token)
payload = decode_jwt(token, settings.PUBLIC_JWT_SECRET)
self.assertEqual(payload.get("sub"), phone)
self.assertEqual(payload.get("purpose"), "CREATE_REQUEST")
self.assertEqual(
int(payload.get("exp") or 0) - int(payload.get("iat") or 0),
settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
)
cookie_header = str(verified.headers.get("set-cookie") or "")
self.assertIn(f"{settings.PUBLIC_COOKIE_NAME}=", cookie_header)
self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
self.assertIn("httponly", cookie_header.lower())
def test_verify_otp_respects_cookie_security_flags_from_settings(self):
phone = self._unique_phone()
secure_backup = settings.PUBLIC_COOKIE_SECURE
samesite_backup = settings.PUBLIC_COOKIE_SAMESITE
try:
settings.PUBLIC_COOKIE_SECURE = True
settings.PUBLIC_COOKIE_SAMESITE = "strict"
with patch("app.api.public.otp._generate_code", return_value="313131"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "313131"},
)
self.assertEqual(verified.status_code, 200)
cookie_header = str(verified.headers.get("set-cookie") or "")
self.assertIn("Secure", cookie_header)
self.assertIn("SameSite=strict", cookie_header)
finally:
settings.PUBLIC_COOKIE_SECURE = secure_backup
settings.PUBLIC_COOKIE_SAMESITE = samesite_backup
def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self):
phone = "+79998887766"
with self.SessionLocal() as db:
db.add(
Request(
track_number="TRK-VIEW-PHONE-1",
client_name="Телефонный клиент",
client_phone=phone,
topic_code="consulting",
status_code="NEW",
description="Проверка",
extra_fields={},
)
)
db.commit()
with patch("app.api.public.otp._generate_code", return_value="222222"):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "VIEW_REQUEST", "client_phone": phone},
)
self.assertEqual(sent.status_code, 200)
verified = self.client.post(
"/api/public/otp/verify",
json={"purpose": "VIEW_REQUEST", "client_phone": phone, "code": "222222"},
)
self.assertEqual(verified.status_code, 200)
token = verified.cookies.get(settings.PUBLIC_COOKIE_NAME)
self.assertTrue(token)
payload = decode_jwt(token, settings.PUBLIC_JWT_SECRET)
self.assertEqual(payload.get("sub"), phone)
self.assertEqual(payload.get("purpose"), "VIEW_REQUEST")
def test_client_can_create_both_service_request_types_and_audit_is_written(self):
phone = "+79997776655"
lawyer_id = UUID("11111111-1111-1111-1111-111111111111")
with self.SessionLocal() as db:
client = Client(full_name="Запросный клиент", phone=phone, responsible="seed")
db.add(client)
db.flush()
req = Request(
track_number="TRK-SVC-1",
client_id=client.id,
client_name=client.full_name,
client_phone=client.phone,
topic_code="consulting",
status_code="IN_PROGRESS",
description="Проверка сервисных запросов",
extra_fields={},
assigned_lawyer_id=str(lawyer_id),
)
db.add(req)
db.commit()
view_token = create_jwt({"sub": phone, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: view_token}
curator = self.client.post(
"/api/public/requests/TRK-SVC-1/service-requests",
cookies=cookies,
json={"type": "CURATOR_CONTACT", "body": "Прошу консультацию администратора"},
)
self.assertEqual(curator.status_code, 201)
self.assertEqual(curator.json()["type"], "CURATOR_CONTACT")
change = self.client.post(
"/api/public/requests/TRK-SVC-1/service-requests",
cookies=cookies,
json={"type": "LAWYER_CHANGE_REQUEST", "body": "Прошу сменить юриста"},
)
self.assertEqual(change.status_code, 201)
self.assertEqual(change.json()["type"], "LAWYER_CHANGE_REQUEST")
listed = self.client.get("/api/public/requests/TRK-SVC-1/service-requests", cookies=cookies)
self.assertEqual(listed.status_code, 200)
self.assertEqual(len(listed.json()), 2)
with self.SessionLocal() as db:
rows = db.query(RequestServiceRequest).order_by(RequestServiceRequest.created_at.asc()).all()
self.assertEqual(len(rows), 2)
self.assertTrue(rows[0].admin_unread)
self.assertTrue(rows[0].lawyer_unread) # curator-contact visible to assigned lawyer
self.assertTrue(rows[1].admin_unread)
self.assertFalse(rows[1].lawyer_unread) # lawyer-change hidden from assigned lawyer
audits = (
db.query(AuditLog)
.filter(AuditLog.entity == "request_service_requests", AuditLog.action == "CREATE_CLIENT_REQUEST")
.all()
)
self.assertEqual(len(audits), 2)