diff --git a/.gitignore b/.gitignore index 022d0ea..fe9caca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ /tmp/ *.idea .env - +/reports +node_modules/ +e2e/node_modules/ +e2e/playwright-report/ +e2e/test-results/ diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index 8aaefbe..d87d7a2 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -9,6 +9,7 @@ from functools import lru_cache from typing import Any from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.inspection import inspect as sa_inspect from sqlalchemy.orm import Session @@ -146,6 +147,31 @@ def _is_lawyer(admin: dict) -> bool: return str(admin.get("role") or "").upper() == "LAWYER" +def _lawyer_actor_id_or_401(admin: dict) -> str: + actor_id = str(admin.get("sub") or "").strip() + if not actor_id: + raise HTTPException(status_code=401, detail="Некорректный токен") + return actor_id + + +def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None: + if not _is_lawyer(admin): + return + actor_id = _lawyer_actor_id_or_401(admin) + assigned = str(req.assigned_lawyer_id or "").strip() + if assigned and assigned != actor_id: + raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки") + + +def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: + if not _is_lawyer(admin): + return + actor_id = _lawyer_actor_id_or_401(admin) + assigned = str(req.assigned_lawyer_id or "").strip() + if not assigned or assigned != actor_id: + raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") + + def _serialize_value(value: Any) -> Any: if isinstance(value, dict): return {key: _serialize_value(val) for key, val in value.items()} @@ -364,7 +390,6 @@ def _column_label(table_name: str, column_name: str) -> str: "phone": "Телефон", "verified_at": "Подтверждено", "expires_at": "Истекает", - "jwt_token": "JWT-токен", "action": "Действие", "entity": "Сущность", "entity_id": "ID сущности", @@ -969,7 +994,16 @@ def query_table( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "query") - query = apply_universal_query(db.query(model), model, uq) + base_query = db.query(model) + if normalized == "requests" and _is_lawyer(admin): + actor_id = _lawyer_actor_id_or_401(admin) + base_query = base_query.filter( + or_( + Request.assigned_lawyer_id == actor_id, + Request.assigned_lawyer_id.is_(None), + ) + ) + query = apply_universal_query(base_query, model, uq) total = query.count() rows = query.offset(uq.page.offset).limit(uq.page.limit).all() return {"rows": [_strip_hidden_fields(normalized, _row_to_dict(row)) for row in rows], "total": total} @@ -988,6 +1022,7 @@ def get_row( if normalized == "requests": req = row if isinstance(row, Request) else None if req is not None: + _ensure_lawyer_can_view_request_or_403(admin, req) changed = False if _is_lawyer(admin) and clear_unread_for_lawyer(req): changed = True @@ -1093,6 +1128,8 @@ def update_row( if forbidden_fields: raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки") row = _load_row_or_404(db, model, row_id) + if normalized == "requests" and isinstance(row, Request): + _ensure_lawyer_can_manage_request_or_403(admin, row) if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования") prepared = dict(payload) @@ -1197,6 +1234,8 @@ def delete_row( if normalized == "admin_users" and str(admin.get("sub") or "") == str(row_id): raise HTTPException(status_code=400, detail="Нельзя удалить собственную учетную запись") row = _load_row_or_404(db, model, row_id) + if normalized == "requests" and isinstance(row, Request): + _ensure_lawyer_can_manage_request_or_403(admin, row) if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для удаления") diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index 5ea4acd..951e40e 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -4,7 +4,7 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from sqlalchemy import case, update +from sqlalchemy import case, or_, update from app.db.session import get_db from app.core.deps import require_role @@ -60,11 +60,25 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: if role != "LAWYER": return actor = str(admin.get("sub") or "").strip() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") assigned = str(req.assigned_lawyer_id or "").strip() if not actor or not assigned or actor != assigned: raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") +def _ensure_lawyer_can_view_request_or_403(admin: dict, req: Request) -> None: + role = str(admin.get("role") or "").upper() + if role != "LAWYER": + return + actor = str(admin.get("sub") or "").strip() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + assigned = str(req.assigned_lawyer_id or "").strip() + if assigned and actor != assigned: + raise HTTPException(status_code=403, detail="Юрист может видеть только свои и неназначенные заявки") + + def _request_data_requirement_row(row: RequestDataRequirement) -> dict: return { "id": str(row.id), @@ -81,7 +95,20 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict: @router.post("/query") def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): - q = apply_universal_query(db.query(Request), Request, uq) + base_query = db.query(Request) + role = str(admin.get("role") or "").upper() + if role == "LAWYER": + actor = str(admin.get("sub") or "").strip() + if not actor: + raise HTTPException(status_code=401, detail="Некорректный токен") + base_query = base_query.filter( + or_( + Request.assigned_lawyer_id == actor, + Request.assigned_lawyer_id.is_(None), + ) + ) + + q = apply_universal_query(base_query, Request, uq) total = q.count() rows = q.offset(uq.page.offset).limit(uq.page.limit).all() return { @@ -166,6 +193,7 @@ def update_request( row = db.get(Request, request_uuid) if not row: raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_can_manage_request_or_403(admin, row) changes = payload.model_dump(exclude_unset=True) actor_role = str(admin.get("role") or "").upper() if actor_role == "LAWYER": @@ -238,6 +266,7 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends row = db.get(Request, request_uuid) if not row: raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_can_manage_request_or_403(admin, row) db.delete(row) db.commit() return {"status": "удалено"} @@ -248,6 +277,7 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re req = db.get(Request, request_uuid) if not req: raise HTTPException(status_code=404, detail="Заявка не найдена") + _ensure_lawyer_can_view_request_or_403(admin, req) changed = False if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req): changed = True diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py index ad916cb..7f45274 100644 --- a/app/core/http_hardening.py +++ b/app/core/http_hardening.py @@ -17,6 +17,10 @@ SECURITY_HEADERS = { "Referrer-Policy": "no-referrer", "X-Permitted-Cross-Domain-Policies": "none", "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Cross-Origin-Resource-Policy": "same-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()", + "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", } @@ -40,6 +44,11 @@ def install_http_hardening(app: FastAPI) -> None: for key, value in SECURITY_HEADERS.items(): response.headers[key] = value + # Backend serves application data and operational endpoints only. + # Keep responses non-cacheable to avoid stale or sensitive data reuse. + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" response.headers[REQUEST_ID_HEADER] = request_id duration_ms = (perf_counter() - started_at) * 1000.0 diff --git a/app/core/security.py b/app/core/security.py index a78ae4b..51d8018 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from jose import jwt +import jwt from passlib.context import CryptContext pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") @@ -14,7 +14,8 @@ def create_jwt(payload: dict, secret: str, expires_delta: timedelta) -> str: now = datetime.now(timezone.utc) data = payload.copy() data.update({"iat": int(now.timestamp()), "exp": int((now + expires_delta).timestamp())}) - return jwt.encode(data, secret, algorithm="HS256") + token = jwt.encode(data, secret, algorithm="HS256") + return str(token) def decode_jwt(token: str, secret: str) -> dict: - return jwt.decode(token, secret, algorithms=["HS256"]) + return jwt.decode(token, secret, algorithms=["HS256"], options={"require": ["iat", "exp"]}) diff --git a/app/main.py b/app/main.py index 16d671b..d1fc58e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,12 @@ -from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import JSONResponse from app.core.config import settings from app.core.http_hardening import install_http_hardening from app.api.public.router import router as public_router from app.api.admin.router import router as admin_router app = FastAPI(title=settings.APP_NAME, version="0.1.0") -BASE_DIR = Path(__file__).resolve().parent -LANDING_FILE = BASE_DIR / "web" / "landing.html" - app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, @@ -25,7 +21,7 @@ app.include_router(admin_router, prefix="/api/admin") @app.get("/", include_in_schema=False) def landing(): - return FileResponse(LANDING_FILE) + return JSONResponse({"service": settings.APP_NAME, "status": "ok"}) @app.get("/health") def health(): diff --git a/app/services/invoice_crypto.py b/app/services/invoice_crypto.py index 803dbe6..2f05801 100644 --- a/app/services/invoice_crypto.py +++ b/app/services/invoice_crypto.py @@ -14,8 +14,12 @@ _VERSION = b"v1" def _key() -> bytes: secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip() - if not secret or secret == "change_me_data_encryption": - secret = str(settings.ADMIN_JWT_SECRET or "change_me_admin") + if not secret: + secret = str(settings.ADMIN_JWT_SECRET or "").strip() + if not secret: + secret = str(settings.PUBLIC_JWT_SECRET or "").strip() + if not secret: + raise ValueError("Не задан секрет шифрования") return hashlib.sha256(secret.encode("utf-8")).digest() diff --git a/app/services/security_audit.py b/app/services/security_audit.py index f6706d0..7f2682a 100644 --- a/app/services/security_audit.py +++ b/app/services/security_audit.py @@ -126,4 +126,4 @@ def record_file_security_event( try: db.rollback() except Exception: - pass + logger.debug("security_audit_rollback_failed", exc_info=True) diff --git a/app/services/telegram_notify.py b/app/services/telegram_notify.py index 2659aeb..c64f186 100644 --- a/app/services/telegram_notify.py +++ b/app/services/telegram_notify.py @@ -10,7 +10,8 @@ from app.core.config import settings def _telegram_enabled() -> bool: token = str(settings.TELEGRAM_BOT_TOKEN or "").strip() chat_id = str(settings.TELEGRAM_CHAT_ID or "").strip() - if not token or token == "change_me": + # Telegram bot token is expected in ":" format. + if not token or ":" not in token or len(token) < 20: return False if not chat_id or chat_id == "0": return False diff --git a/app/web/admin.css b/app/web/admin.css new file mode 100644 index 0000000..7370a47 --- /dev/null +++ b/app/web/admin.css @@ -0,0 +1,717 @@ + :root { + --bg: #0b1015; + --bg-soft: #111922; + --surface: #15202b; + --surface-2: #1d2a37; + --line: rgba(196, 210, 228, 0.2); + --text: #f3f7fc; + --muted: #9db0c5; + --brand: #d4a86a; + --brand-soft: rgba(212, 168, 106, 0.14); + --ok: #4dbe93; + --danger: #ff7f7f; + --radius: 16px; + --shadow: 0 24px 58px rgba(0, 0, 0, 0.34); + } + + * { box-sizing: border-box; } + + html, body { + margin: 0; + padding: 0; + background: radial-gradient(circle at 12% 2%, #1a2532, var(--bg) 50%), var(--bg); + color: var(--text); + font-family: "Manrope", sans-serif; + } + + body.modal-open { overflow: hidden; } + + .layout { + display: grid; + grid-template-columns: 272px 1fr; + min-height: 100vh; + } + + .sidebar { + border-right: 1px solid var(--line); + background: linear-gradient(180deg, rgba(19, 29, 39, 0.94), rgba(13, 20, 27, 0.94)); + padding: 1rem; + position: sticky; + top: 0; + height: 100vh; + } + + .logo { + font-weight: 800; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #ebf2fb; + margin-bottom: 1.2rem; + } + + .logo a { + color: inherit; + text-decoration: none; + } + + .menu { + display: flex; + flex-direction: column; + gap: 0.45rem; + } + + .menu button { + width: 100%; + text-align: left; + border: 1px solid transparent; + background: transparent; + color: #d4deec; + padding: 0.72rem 0.78rem; + border-radius: 10px; + cursor: pointer; + font-weight: 600; + font-size: 0.92rem; + } + + .menu button:hover { + border-color: var(--line); + background: rgba(255, 255, 255, 0.03); + } + + .menu button.active { + border-color: rgba(212, 168, 106, 0.45); + background: var(--brand-soft); + color: #fde5c2; + } + + .menu-tree { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding-left: 0.6rem; + border-left: 1px dashed rgba(212, 168, 106, 0.3); + margin: 0.2rem 0 0.1rem 0.2rem; + } + + .menu-tree button { + font-size: 0.85rem; + padding: 0.52rem 0.62rem; + color: #c8d8ea; + } + + .auth-box { + margin-top: 1.2rem; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + padding: 0.75rem; + font-size: 0.86rem; + color: var(--muted); + line-height: 1.45; + } + + .auth-box b { + color: #f4f7fc; + font-weight: 700; + } + + .main { + padding: 1.2rem; + } + + .topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(130deg, rgba(22, 33, 45, 0.94), rgba(15, 23, 31, 0.97)); + padding: 0.85rem 1rem; + box-shadow: var(--shadow); + } + + .topbar h1 { + margin: 0; + font-family: "Prata", serif; + font-size: 1.5rem; + letter-spacing: 0.01em; + } + + .badge { + border: 1px solid rgba(212, 168, 106, 0.35); + border-radius: 999px; + background: var(--brand-soft); + color: #fedfb1; + font-size: 0.76rem; + font-weight: 700; + padding: 0.32rem 0.55rem; + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .btn { + border: 1px solid transparent; + border-radius: 999px; + background: linear-gradient(120deg, #d9b57f, #c59048); + color: #1b2430; + padding: 0.64rem 1rem; + font-weight: 700; + font-size: 0.88rem; + cursor: pointer; + } + + .btn:hover { filter: brightness(1.05); } + + .btn.secondary { + border-color: var(--line); + background: rgba(255, 255, 255, 0.04); + color: #dbe6f5; + } + + .btn.danger { + border-color: rgba(255, 127, 127, 0.3); + background: rgba(255, 127, 127, 0.13); + color: #ffd3d3; + } + + .section { + display: none; + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(160deg, rgba(20, 30, 40, 0.93), rgba(14, 22, 30, 0.96)); + box-shadow: var(--shadow); + padding: 1rem; + } + + .section.active { display: block; } + + .section h2 { + margin: 0; + font-family: "Prata", serif; + font-size: 1.35rem; + } + + .section-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.9rem; + flex-wrap: wrap; + } + + .muted { + color: var(--muted); + margin: 0.45rem 0 0; + line-height: 1.55; + font-size: 0.94rem; + } + + .cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + } + + .card { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.025); + padding: 0.8rem; + } + + .card p { + margin: 0; + color: var(--muted); + font-size: 0.83rem; + } + + .card b { + display: block; + margin-top: 0.3rem; + font-size: 1.2rem; + color: #f6dab0; + } + + .filters { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; + margin-bottom: 0.75rem; + } + + .filter-toolbar { + display: flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 0.75rem; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + } + + .filter-chips { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + flex: 1 1 auto; + min-height: 34px; + } + + .filter-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.5rem; + border: 1px solid rgba(212, 168, 106, 0.3); + border-radius: 999px; + background: var(--brand-soft); + color: #f9dfb5; + font-size: 0.76rem; + line-height: 1.2; + cursor: pointer; + } + + .filter-chip button { + border: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: #fce3bd; + cursor: pointer; + font-size: 0.82rem; + line-height: 1; + padding: 0; + } + + .chip-placeholder { + color: var(--muted); + font-size: 0.84rem; + } + + .filter-action { + margin-left: auto; + flex-shrink: 0; + } + + .field { + display: flex; + flex-direction: column; + gap: 0.3rem; + } + + .field label { + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9fb3c8; + font-size: 0.73rem; + font-weight: 700; + } + + .field-inline { + display: flex; + align-items: center; + gap: 0.45rem; + } + + .btn-sm { + min-height: 38px; + padding: 0.5rem 0.68rem; + font-size: 0.82rem; + } + + input, textarea, select { + width: 100%; + border: 1px solid #3c4d62; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + font: inherit; + padding: 0.58rem 0.68rem; + min-height: 38px; + } + + textarea { + min-height: 108px; + resize: vertical; + } + + .table-wrap { + border: 1px solid var(--line); + border-radius: 12px; + overflow: auto; + background: rgba(255, 255, 255, 0.015); + } + + table { + width: 100%; + border-collapse: collapse; + min-width: 840px; + } + + th, td { + padding: 0.63rem 0.65rem; + border-bottom: 1px solid var(--line); + vertical-align: top; + font-size: 0.89rem; + } + + th { + text-align: left; + color: #9fb3c8; + font-size: 0.77rem; + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .sortable-th { + cursor: pointer; + user-select: none; + } + + .sortable-head { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + + .sort-indicator { + color: #6d7f96; + font-weight: 700; + font-size: 0.8rem; + line-height: 1; + } + + .sort-indicator.active { + color: #f1d3a3; + } + + td code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: #f7dfb8; + } + + .avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #f5f8ff; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.24); + text-transform: uppercase; + letter-spacing: 0.02em; + } + + .avatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .user-identity { + display: inline-flex; + align-items: center; + gap: 0.52rem; + min-width: 0; + } + + .user-identity-text { + display: flex; + flex-direction: column; + min-width: 0; + } + + .user-identity-text b { + font-size: 0.88rem; + font-weight: 700; + color: #eaf2fd; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 230px; + } + + .table-actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + } + + .request-updates-stack { + display: inline-flex; + flex-direction: column; + gap: 0.24rem; + align-items: flex-start; + } + + .request-update-chip { + display: inline-flex; + align-items: center; + gap: 0.32rem; + border: 1px solid rgba(95, 182, 145, 0.34); + border-radius: 999px; + background: rgba(77, 190, 147, 0.14); + color: #bef5df; + font-size: 0.74rem; + line-height: 1.1; + padding: 0.18rem 0.45rem; + white-space: nowrap; + } + + .request-update-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #3ed692; + box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18); + flex-shrink: 0; + } + + .request-update-empty { + color: #8ea1b8; + font-size: 0.8rem; + } + + .icon-btn { + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(255, 255, 255, 0.03); + color: #d8e4f4; + cursor: pointer; + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + font-size: 0.95rem; + line-height: 1; + position: relative; + } + + .icon-btn:hover { + border-color: rgba(212, 168, 106, 0.42); + background: rgba(212, 168, 106, 0.16); + color: #fce0b6; + } + + .icon-btn.danger:hover { + border-color: rgba(255, 127, 127, 0.45); + background: rgba(255, 127, 127, 0.16); + color: #ffd9d9; + } + + .icon-btn::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 7px); + transform: translate(-50%, 2px); + background: #081018; + border: 1px solid var(--line); + color: #dce6f5; + font-size: 0.72rem; + white-space: nowrap; + border-radius: 7px; + padding: 0.24rem 0.4rem; + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; + z-index: 3; + } + + .icon-btn:hover::after, + .icon-btn:focus-visible::after { + opacity: 1; + transform: translate(-50%, 0); + } + + .pager { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + margin-top: 0.72rem; + color: var(--muted); + font-size: 0.86rem; + flex-wrap: wrap; + } + + .status { + margin: 0.6rem 0 0; + min-height: 1.1rem; + color: var(--muted); + font-size: 0.87rem; + } + + .status.ok { color: var(--ok); } + .status.error { color: var(--danger); } + + .stack { + display: grid; + gap: 0.9rem; + } + + .triple { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + } + + .config-layout { + display: grid; + grid-template-columns: 1fr; + gap: 0.85rem; + } + + .config-panel { + min-width: 0; + } + + .block { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.02); + } + + .block h3 { + margin: 0 0 0.5rem; + font-size: 1rem; + } + + .block .table-wrap table { + min-width: 640px; + } + + .json { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.7rem; + background: #0e151d; + color: #ccdef4; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.83rem; + overflow: auto; + max-height: 380px; + white-space: pre-wrap; + word-break: break-word; + } + + .overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 40; + background: rgba(6, 10, 14, 0.78); + backdrop-filter: blur(4px); + } + + .overlay.open { display: flex; } + + .modal { + width: min(680px, 100%); + max-height: 90vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(160deg, rgba(21, 31, 42, 0.95), rgba(14, 21, 28, 0.98)); + padding: 0.9rem; + box-shadow: var(--shadow); + } + + .modal-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.8rem; + margin-bottom: 0.65rem; + } + + .modal-head h3 { + margin: 0; + font-family: "Prata", serif; + font-size: 1.2rem; + } + + .close { + border: 1px solid var(--line); + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.04); + color: #d7e4f5; + cursor: pointer; + font-size: 1.05rem; + } + + .login-screen { + position: fixed; + inset: 0; + z-index: 60; + background: rgba(7, 11, 15, 0.86); + backdrop-filter: blur(5px); + display: grid; + place-items: center; + padding: 1rem; + } + + .login-card { + width: min(420px, 100%); + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(160deg, rgba(24, 36, 48, 0.95), rgba(15, 24, 32, 0.98)); + box-shadow: var(--shadow); + padding: 1rem; + } + + .login-card h2 { + margin: 0; + font-family: "Prata", serif; + font-size: 1.4rem; + } + + @media (max-width: 1160px) { + .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .triple { grid-template-columns: 1fr; } + .config-layout { grid-template-columns: 1fr; } + } + + @media (max-width: 920px) { + .layout { grid-template-columns: 1fr; } + .sidebar { + position: static; + height: auto; + } + } + + @media (max-width: 620px) { + .cards, + .filters { + grid-template-columns: 1fr; + } + .filter-toolbar { + flex-direction: column; + align-items: stretch; + } + .filter-action { + margin-left: 0; + } + .topbar { + flex-direction: column; + align-items: flex-start; + } + } diff --git a/app/web/admin.html b/app/web/admin.html index 43f7cab..5ebb06e 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -4,734 +4,13 @@ Административная панель • Правовой трекер - - - - +
- - - + + + diff --git a/app/web/landing.css b/app/web/landing.css new file mode 100644 index 0000000..9c381d3 --- /dev/null +++ b/app/web/landing.css @@ -0,0 +1,757 @@ + :root { + --bg: #0d1217; + --bg-soft: #121a22; + --surface: #171f29; + --surface-2: #1f2a37; + --text: #f4f7fb; + --muted: #a8b2c2; + --accent: #d4a968; + --accent-soft: rgba(212, 169, 104, 0.15); + --line: rgba(207, 217, 231, 0.18); + --ok: #49b68e; + --danger: #ff7b7b; + --radius: 18px; + --shadow: 0 30px 70px rgba(0, 0, 0, 0.32); + --maxw: 1180px; + } + + * { box-sizing: border-box; } + + html, body { + margin: 0; + padding: 0; + background: radial-gradient(circle at 12% 0%, #1a2430 0, var(--bg) 48%), var(--bg); + color: var(--text); + font-family: "Manrope", sans-serif; + scroll-behavior: smooth; + overflow-x: hidden; + } + + body.modal-open { overflow: hidden; } + + body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(600px 320px at 90% 8%, rgba(212, 169, 104, 0.1), transparent 70%), + radial-gradient(600px 360px at 10% 76%, rgba(94, 147, 227, 0.1), transparent 72%); + z-index: -1; + } + + .wrap { + width: min(var(--maxw), calc(100% - 1.5rem)); + margin: 0 auto; + } + + section { scroll-margin-top: 84px; } + + .topbar { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(10px); + background: rgba(13, 18, 23, 0.78); + border-bottom: 1px solid var(--line); + } + + .topbar-inner { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + .brand { + font-size: 0.84rem; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 800; + max-width: 390px; + color: #eef4ff; + } + + .nav { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + + .nav a { + text-decoration: none; + color: #d6deea; + font-size: 0.93rem; + font-weight: 600; + opacity: 0.92; + } + + .btn { + border: 1px solid transparent; + border-radius: 999px; + padding: 0.82rem 1.25rem; + font-family: inherit; + font-size: 0.93rem; + font-weight: 700; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + } + + .btn:hover { transform: translateY(-1px); } + .btn:active { transform: translateY(0); } + + .btn-primary { + background: linear-gradient(120deg, #d8b27b, #c6914a); + color: #17212d; + box-shadow: 0 16px 28px rgba(198, 145, 74, 0.3); + } + + .btn-ghost { + border-color: var(--line); + color: #dde6f2; + background: rgba(255, 255, 255, 0.04); + } + + .hero { + padding: 5.2rem 0 3rem; + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 1.1rem; + align-items: stretch; + } + + .hero h1 { + margin: 0; + font-family: "Prata", serif; + font-size: clamp(2.05rem, 5.6vw, 4.2rem); + line-height: 1.08; + max-width: 13ch; + letter-spacing: 0.01em; + } + + .hero p { + margin: 1.1rem 0 0; + color: var(--muted); + line-height: 1.66; + font-size: 1.05rem; + max-width: 66ch; + } + + .hero-actions { + margin-top: 1.6rem; + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + } + + .panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(160deg, rgba(35, 48, 63, 0.92), rgba(21, 29, 39, 0.95)); + box-shadow: var(--shadow); + padding: 1.3rem; + position: relative; + overflow: hidden; + } + + .panel::before { + content: ""; + position: absolute; + right: -40px; + top: -40px; + width: 140px; + height: 140px; + border-radius: 50%; + background: radial-gradient(circle, rgba(212, 169, 104, 0.2), transparent 70%); + } + + .panel small { + display: block; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.72rem; + color: #90a2b7; + font-weight: 800; + margin-bottom: 0.6rem; + } + + .panel strong { + display: block; + font-size: 1.06rem; + line-height: 1.45; + margin-bottom: 0.9rem; + } + + .stats { + margin-top: 0.95rem; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.6rem; + } + + .stat { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + padding: 0.7rem; + } + + .stat b { + display: block; + font-size: 1.15rem; + color: #f7dbb1; + margin-bottom: 0.15rem; + } + + .stat span { + font-size: 0.78rem; + color: #a7b3c5; + line-height: 1.35; + } + + section { padding: 1.3rem 0 2.2rem; } + + .section-head { + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; + } + + h2 { + margin: 0; + font-family: "Prata", serif; + font-size: clamp(1.65rem, 4vw, 2.7rem); + letter-spacing: 0.01em; + } + + .subtitle { + margin: 0.65rem 0 0; + color: var(--muted); + line-height: 1.6; + max-width: 70ch; + } + + .grid { display: grid; gap: 0.9rem; } + + .practices { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .card { + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(160deg, rgba(25, 34, 45, 0.88), rgba(19, 26, 34, 0.95)); + padding: 1.05rem; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25); + opacity: 0; + transform: translateY(12px); + animation: rise 0.6s ease forwards; + } + + .card:nth-child(2) { animation-delay: 0.06s; } + .card:nth-child(3) { animation-delay: 0.12s; } + .card:nth-child(4) { animation-delay: 0.18s; } + .card:nth-child(5) { animation-delay: 0.24s; } + .card:nth-child(6) { animation-delay: 0.3s; } + + .card h3 { + margin: 0 0 0.52rem; + font-size: 1.03rem; + color: #f1f5fb; + } + + .card p { + margin: 0; + color: #aab5c4; + line-height: 1.57; + font-size: 0.95rem; + } + + .approach { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 0.9rem; + } + + .timeline { + border: 1px solid var(--line); + border-radius: 16px; + padding: 1rem; + background: linear-gradient(160deg, rgba(22, 30, 40, 0.88), rgba(16, 23, 30, 0.95)); + } + + .timeline .step { + position: relative; + padding-left: 2.2rem; + margin-bottom: 1rem; + } + + .timeline .step:last-child { margin-bottom: 0; } + + .timeline .step::before { + content: attr(data-step); + position: absolute; + left: 0; + top: 0; + width: 1.55rem; + height: 1.55rem; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 0.74rem; + font-weight: 800; + color: #1b2634; + background: linear-gradient(130deg, #e3c08f, #c5914b); + } + + .timeline h3 { margin: 0 0 0.35rem; font-size: 1rem; } + .timeline p { margin: 0; color: var(--muted); line-height: 1.55; } + + .quote { + border: 1px solid #4b5b71; + border-radius: 16px; + background: linear-gradient(160deg, #1e2b3c, #1a2432); + padding: 1rem; + } + + .quote p { + margin: 0; + min-height: 5.3rem; + line-height: 1.6; + color: #dbe6f5; + } + + .quote-meta { + margin-top: 0.7rem; + color: #98adc7; + font-size: 0.86rem; + } + + .expert { + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(160deg, rgba(26, 36, 48, 0.92), rgba(20, 27, 36, 0.95)); + padding: 1.1rem; + } + + .expert strong { + display: block; + margin-bottom: 0.5rem; + font-size: 1.08rem; + } + + .expert p { + margin: 0; + color: var(--muted); + line-height: 1.62; + } + + .tags { + margin-top: 0.9rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .tag { + padding: 0.38rem 0.62rem; + border-radius: 999px; + border: 1px solid rgba(212, 169, 104, 0.3); + background: var(--accent-soft); + color: #f6d7a8; + font-size: 0.8rem; + font-weight: 700; + } + + .cta-band { + margin: 1.4rem 0 2.4rem; + border: 1px solid rgba(212, 169, 104, 0.35); + border-radius: 18px; + background: linear-gradient(120deg, rgba(212, 169, 104, 0.14), rgba(66, 99, 145, 0.18)); + padding: 1.15rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + } + + .cta-band p { + margin: 0; + color: #d8e2f2; + line-height: 1.52; + max-width: 60ch; + } + + footer { + border-top: 1px solid var(--line); + margin-top: 1.1rem; + padding: 1.8rem 0; + color: #94a6bc; + text-align: center; + font-size: 0.9rem; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(7, 10, 14, 0.72); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 40; + } + + .modal-backdrop.open { display: flex; } + + .modal { + width: min(620px, 100%); + max-height: 92vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(160deg, #18222e, #121a23); + box-shadow: var(--shadow); + padding: 1.15rem; + } + + .modal-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 0.7rem; + margin-bottom: 0.9rem; + } + + .modal-head h3 { + margin: 0; + font-size: 1.28rem; + font-family: "Prata", serif; + line-height: 1.2; + } + + .modal-head p { + margin: 0.5rem 0 0; + color: var(--muted); + line-height: 1.54; + font-size: 0.93rem; + } + + .close { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.04); + color: #dce5f2; + width: 34px; + height: 34px; + border-radius: 50%; + font-size: 1.1rem; + cursor: pointer; + } + + .form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .field { + display: flex; + flex-direction: column; + gap: 0.34rem; + } + + .field.full { grid-column: 1 / -1; } + + label { + font-size: 0.76rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9fb0c6; + font-weight: 700; + } + + input, textarea { + width: 100%; + border-radius: 12px; + border: 1px solid #3b4b5f; + background: rgba(255, 255, 255, 0.03); + color: #ecf2fb; + font: inherit; + font-size: 16px; + padding: 0.72rem 0.8rem; + } + + textarea { + min-height: 108px; + resize: vertical; + } + + .form-foot { + margin-top: 0.9rem; + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; + } + + .status { + margin: 0; + color: #9bafc8; + font-size: 0.9rem; + min-height: 1.2rem; + } + + .status.ok { color: var(--ok); } + .status.error { color: var(--danger); } + + .cabinet-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.9rem; + } + + .cabinet-card { + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95)); + padding: 1rem; + } + + .cabinet-card h3 { + margin: 0 0 0.65rem; + font-size: 1.03rem; + } + + .cabinet-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin-top: 0.7rem; + } + + .meta-row { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.58rem 0.65rem; + background: rgba(255, 255, 255, 0.02); + } + + .meta-row small { + display: block; + color: #9fb0c6; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.2rem; + } + + .meta-row b { + display: block; + color: #eaf2ff; + font-size: 0.9rem; + font-weight: 700; + line-height: 1.4; + } + + .simple-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.5rem; + max-height: 280px; + overflow: auto; + } + + .simple-item { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.58rem 0.65rem; + background: rgba(255, 255, 255, 0.02); + } + + .simple-item p { + margin: 0.24rem 0 0; + color: #d8e3f3; + line-height: 1.5; + font-size: 0.92rem; + overflow-wrap: anywhere; + } + + .simple-item time { + color: #9eb1ca; + font-size: 0.78rem; + } + + .chat-form { + margin-top: 0.7rem; + display: grid; + gap: 0.55rem; + } + + .chat-form textarea { + min-height: 84px; + } + + .file-row { + margin-top: 0.7rem; + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + } + + .file-row input[type="file"] { + max-width: 100%; + } + + .brand, + .meta-row b { + overflow-wrap: anywhere; + } + + @keyframes rise { + to { + opacity: 1; + transform: translateY(0); + } + } + + @media (max-width: 980px) { + .hero, + .approach, + .practices { + grid-template-columns: 1fr; + } + } + + @media (max-width: 740px) { + .topbar-inner { + flex-direction: column; + align-items: flex-start; + padding: 0.72rem 0; + } + + .nav { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + } + + .nav a, + .nav .btn { + width: 100%; + text-align: center; + } + + .hero { + padding-top: 2.7rem; + } + + .stats { + grid-template-columns: 1fr; + } + + .form { + grid-template-columns: 1fr; + } + + .cabinet-layout { + grid-template-columns: 1fr; + } + + .cabinet-meta { + grid-template-columns: 1fr; + } + + .hero-actions .btn { + width: 100%; + } + + .simple-list { + max-height: 220px; + } + } + + @media (max-width: 520px) { + .wrap { + width: calc(100% - 1rem); + } + + .topbar { + position: static; + } + + section { + scroll-margin-top: 0; + } + + .brand { + font-size: 0.78rem; + max-width: none; + } + + .nav { + grid-template-columns: 1fr; + } + + .hero { + padding-top: 1.4rem; + } + + .panel, + .card, + .expert, + .cabinet-card { + padding: 0.85rem; + } + + .file-row { + flex-direction: column; + align-items: stretch; + } + + .file-row .btn { + width: 100%; + } + + .modal-backdrop { + padding: 0; + } + + .modal { + width: 100%; + max-height: 100vh; + min-height: 100vh; + border-radius: 0; + border: none; + padding: 0.95rem; + } + + .modal-head { + position: sticky; + top: 0; + z-index: 2; + background: linear-gradient(160deg, #18222e, #121a23); + padding-bottom: 0.5rem; + margin-bottom: 0.7rem; + } + + .close { + width: 38px; + height: 38px; + } + + .form-foot .btn { + width: 100%; + } + } diff --git a/app/web/landing.html b/app/web/landing.html index e83421b..94ac782 100644 --- a/app/web/landing.html +++ b/app/web/landing.html @@ -5,768 +5,7 @@ Аудиторы корпоративной безопасности - - - - +
@@ -1022,510 +261,6 @@ - + diff --git a/app/web/landing.js b/app/web/landing.js new file mode 100644 index 0000000..a3eff59 --- /dev/null +++ b/app/web/landing.js @@ -0,0 +1,503 @@ + (function () { + const modal = document.getElementById("request-modal"); + const openButtons = document.querySelectorAll("[data-open-modal]"); + const closeButtons = document.querySelectorAll("[data-close-modal]"); + const form = document.getElementById("request-form"); + const status = document.getElementById("form-status"); + const quoteText = document.getElementById("quote-text"); + const quoteMeta = document.getElementById("quote-meta"); + const cabinetTrackInput = document.getElementById("cabinet-track"); + const cabinetOpenButton = document.getElementById("cabinet-open"); + const cabinetStatus = document.getElementById("cabinet-status"); + const cabinetSummary = document.getElementById("cabinet-summary"); + const cabinetRequestStatus = document.getElementById("cabinet-request-status"); + const cabinetRequestTopic = document.getElementById("cabinet-request-topic"); + const cabinetRequestCreated = document.getElementById("cabinet-request-created"); + const cabinetRequestUpdated = document.getElementById("cabinet-request-updated"); + const cabinetMessages = document.getElementById("cabinet-messages"); + const cabinetFiles = document.getElementById("cabinet-files"); + const cabinetInvoices = document.getElementById("cabinet-invoices"); + const cabinetTimeline = document.getElementById("cabinet-timeline"); + const cabinetChatForm = document.getElementById("cabinet-chat-form"); + const cabinetChatBody = document.getElementById("cabinet-chat-body"); + const cabinetChatSend = document.getElementById("cabinet-chat-send"); + const cabinetFileInput = document.getElementById("cabinet-file-input"); + const cabinetFileUpload = document.getElementById("cabinet-file-upload"); + + let activeTrack = ""; + let activeRequestId = ""; + + function openModal() { + modal.classList.add("open"); + modal.setAttribute("aria-hidden", "false"); + document.body.classList.add("modal-open"); + } + + function closeModal() { + modal.classList.remove("open"); + modal.setAttribute("aria-hidden", "true"); + document.body.classList.remove("modal-open"); + } + + openButtons.forEach((button) => button.addEventListener("click", openModal)); + closeButtons.forEach((button) => button.addEventListener("click", closeModal)); + + modal.addEventListener("click", (event) => { + if (event.target === modal) closeModal(); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && modal.classList.contains("open")) closeModal(); + }); + + function formatDate(value) { + if (!value) return "-"; + try { + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return value; + return dt.toLocaleString("ru-RU"); + } catch (_) { + return value; + } + } + + function setStatus(el, message, kind) { + el.className = "status"; + if (kind === "ok") el.classList.add("ok"); + if (kind === "error") el.classList.add("error"); + el.textContent = message; + } + + async function parseJsonSafe(response) { + try { + return await response.json(); + } catch (_) { + return null; + } + } + + function apiErrorDetail(data, fallbackMessage) { + if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail; + return fallbackMessage; + } + + function setCabinetEnabled(enabled) { + cabinetChatBody.disabled = !enabled; + cabinetChatSend.disabled = !enabled; + cabinetFileInput.disabled = !enabled; + cabinetFileUpload.disabled = !enabled; + } + + function clearList(node, emptyMessage) { + node.innerHTML = ""; + const li = document.createElement("li"); + li.className = "simple-item"; + const p = document.createElement("p"); + p.textContent = emptyMessage; + li.appendChild(p); + node.appendChild(li); + } + + function renderMessages(items) { + cabinetMessages.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetMessages, "Сообщений пока нет."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = formatDate(item.created_at); + li.appendChild(time); + + const p = document.createElement("p"); + const author = item.author_name || item.author_type || "Участник"; + p.textContent = author + ": " + (item.body || ""); + li.appendChild(p); + cabinetMessages.appendChild(li); + }); + } + + function renderFiles(items) { + cabinetFiles.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetFiles, "Файлы пока не загружены."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = formatDate(item.created_at); + li.appendChild(time); + + const p = document.createElement("p"); + const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024)); + p.textContent = item.file_name + " (" + sizeKb + " КБ)"; + li.appendChild(p); + + const link = document.createElement("a"); + link.href = item.download_url; + link.textContent = "Открыть / скачать"; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.style.color = "#f6d7a8"; + li.appendChild(link); + cabinetFiles.appendChild(li); + }); + } + + function renderInvoices(items) { + cabinetInvoices.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetInvoices, "Счета пока не выставлены."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = "Сформирован: " + formatDate(item.issued_at); + li.appendChild(time); + + const p = document.createElement("p"); + const amount = Number(item.amount || 0).toLocaleString("ru-RU"); + p.textContent = + (item.invoice_number || "Счет") + + " • " + + (item.status_label || item.status || "-") + + " • " + + amount + + " " + + (item.currency || "RUB"); + li.appendChild(p); + + const link = document.createElement("a"); + link.href = item.download_url; + link.textContent = "Открыть / скачать PDF"; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.style.color = "#f6d7a8"; + li.appendChild(link); + + cabinetInvoices.appendChild(li); + }); + } + + function renderTimeline(items) { + cabinetTimeline.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetTimeline, "История пока пуста."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = formatDate(item.created_at); + li.appendChild(time); + + const p = document.createElement("p"); + if (item.type === "status_change") { + p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-"); + } else if (item.type === "message") { + const author = item.payload?.author_name || item.payload?.author_type || "Участник"; + p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || ""); + } else if (item.type === "attachment") { + p.textContent = "Файл: " + (item.payload?.file_name || "вложение"); + } else { + p.textContent = "Событие"; + } + li.appendChild(p); + cabinetTimeline.appendChild(li); + }); + } + + async function loadQuotes() { + try { + const response = await fetch("/api/public/quotes?limit=8&order=random"); + if (!response.ok) throw new Error("quotes fetch failed"); + const items = await response.json(); + if (!Array.isArray(items) || items.length === 0) throw new Error("quotes empty"); + let index = 0; + const render = () => { + const quote = items[index % items.length]; + quoteText.textContent = quote.text; + quoteMeta.textContent = [quote.author, quote.source].filter(Boolean).join(" • "); + index += 1; + }; + render(); + if (items.length > 1) setInterval(render, 5500); + } catch (error) { + quoteText.textContent = "С вами работает дружный коллектив профессионалов. Мы уверены в вашем успехе."; + quoteMeta.textContent = "Команда компании"; + } + } + + async function fetchRequestByTrack(trackNumber) { + const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber)); + const data = await parseJsonSafe(response); + return { response, data }; + } + + async function ensureViewAccess(trackNumber) { + let { response, data } = await fetchRequestByTrack(trackNumber); + if (response.ok) return data; + + if (response.status !== 401 && response.status !== 403) { + throw new Error(apiErrorDetail(data, "Не удалось открыть заявку")); + } + + setStatus(cabinetStatus, "Отправляем OTP-код...", null); + const sendResponse = await fetch("/api/public/otp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "VIEW_REQUEST", + track_number: trackNumber + }) + }); + const sendData = await parseJsonSafe(sendResponse); + if (!sendResponse.ok) { + throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP")); + } + + const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):"); + if (!code) { + throw new Error("Код OTP не введен"); + } + + setStatus(cabinetStatus, "Проверяем OTP...", null); + const verifyResponse = await fetch("/api/public/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "VIEW_REQUEST", + track_number: trackNumber, + code: String(code).trim() + }) + }); + const verifyData = await parseJsonSafe(verifyResponse); + if (!verifyResponse.ok) { + throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден")); + } + + ({ response, data } = await fetchRequestByTrack(trackNumber)); + if (!response.ok) { + throw new Error(apiErrorDetail(data, "Нет доступа к заявке")); + } + return data; + } + + async function refreshCabinetData() { + if (!activeTrack) return; + + const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([ + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"), + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"), + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"), + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline") + ]); + + const messagesData = await parseJsonSafe(messagesRes); + const filesData = await parseJsonSafe(filesRes); + const invoicesData = await parseJsonSafe(invoicesRes); + const timelineData = await parseJsonSafe(timelineRes); + + if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения")); + if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы")); + if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета")); + if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю")); + + renderMessages(messagesData); + renderFiles(filesData); + renderInvoices(invoicesData); + renderTimeline(timelineData); + } + + async function openCabinetByTrack() { + const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase(); + if (!trackNumber) { + setStatus(cabinetStatus, "Введите номер заявки.", "error"); + return; + } + + try { + setStatus(cabinetStatus, "Открываем кабинет...", null); + const requestData = await ensureViewAccess(trackNumber); + activeTrack = trackNumber; + activeRequestId = requestData.id; + + cabinetRequestStatus.textContent = requestData.status_code || "-"; + cabinetRequestTopic.textContent = requestData.topic_code || "Не указана"; + cabinetRequestCreated.textContent = formatDate(requestData.created_at); + cabinetRequestUpdated.textContent = formatDate(requestData.updated_at); + cabinetSummary.hidden = false; + setCabinetEnabled(true); + + await refreshCabinetData(); + setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok"); + } catch (error) { + setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error"); + } + } + + cabinetOpenButton.addEventListener("click", () => { + openCabinetByTrack(); + }); + + cabinetChatForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!activeTrack) { + setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error"); + return; + } + + const body = String(cabinetChatBody.value || "").trim(); + if (!body) return; + + try { + setStatus(cabinetStatus, "Отправляем сообщение...", null); + const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }) + }); + const data = await parseJsonSafe(response); + if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение")); + cabinetChatBody.value = ""; + await refreshCabinetData(); + setStatus(cabinetStatus, "Сообщение отправлено.", "ok"); + } catch (error) { + setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error"); + } + }); + + cabinetFileUpload.addEventListener("click", async () => { + if (!activeTrack || !activeRequestId) { + setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error"); + return; + } + const file = cabinetFileInput.files && cabinetFileInput.files[0]; + if (!file) { + setStatus(cabinetStatus, "Выберите файл для загрузки.", "error"); + return; + } + + try { + setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null); + const initResponse = await fetch("/api/public/uploads/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_name: file.name, + mime_type: file.type || "application/octet-stream", + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: activeRequestId + }) + }); + const initData = await parseJsonSafe(initResponse); + if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку")); + + const putResponse = await fetch(initData.presigned_url, { + method: "PUT", + headers: { "Content-Type": file.type || "application/octet-stream" }, + body: file + }); + if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище"); + + const completeResponse = await fetch("/api/public/uploads/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: initData.key, + file_name: file.name, + mime_type: file.type || "application/octet-stream", + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: activeRequestId + }) + }); + const completeData = await parseJsonSafe(completeResponse); + if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку")); + + cabinetFileInput.value = ""; + await refreshCabinetData(); + setStatus(cabinetStatus, "Файл загружен.", "ok"); + } catch (error) { + setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error"); + } + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + setStatus(status, "Отправляем заявку...", null); + + const payload = { + client_name: document.getElementById("name").value.trim(), + client_phone: document.getElementById("phone").value.trim(), + topic_code: "consulting", + description: document.getElementById("description").value.trim(), + extra_fields: { + referral_name: document.getElementById("referral").value.trim() + } + }; + + try { + setStatus(status, "Отправляем OTP-код...", null); + const otpSend = await fetch("/api/public/otp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "CREATE_REQUEST", + client_phone: payload.client_phone + }) + }); + if (!otpSend.ok) throw new Error("otp send failed"); + + const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):"); + if (!code) throw new Error("otp code required"); + + setStatus(status, "Проверяем OTP...", null); + const otpVerify = await fetch("/api/public/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "CREATE_REQUEST", + client_phone: payload.client_phone, + code: String(code).trim() + }) + }); + if (!otpVerify.ok) throw new Error("otp verify failed"); + + setStatus(status, "Создаем заявку...", null); + const response = await fetch("/api/public/requests", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!response.ok) throw new Error("create request failed"); + const data = await response.json(); + setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok"); + cabinetTrackInput.value = data.track_number; + form.reset(); + setTimeout(closeModal, 1200); + } catch (error) { + setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error"); + } + }); + + loadQuotes(); + setCabinetEnabled(false); + clearList(cabinetMessages, "Сообщений пока нет."); + clearList(cabinetFiles, "Файлы пока не загружены."); + clearList(cabinetInvoices, "Счета пока не выставлены."); + clearList(cabinetTimeline, "История пока пуста."); + })(); diff --git a/celerybeat-schedule b/celerybeat-schedule index 4ddb96e..952570d 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index d5d676e..7868683 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -54,6 +54,32 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac | P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` | | P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон | +## Ролевое покрытие (PUBLIC / LAWYER / ADMIN) +### PUBLIC (клиент) +- Лендинг и клиентский контур (ручной e2e через `http://localhost:8081`): открыть лендинг, создать заявку, открыть кабинет. +- OTP create/view + 7-day cookie + rate-limit: `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py`. +- Просмотр статуса/истории/чата/файлов/таймлайна по `track_number`: `tests/test_public_cabinet.py`. +- Переписка клиент -> юрист и маркеры непрочитанного: `tests/test_public_cabinet.py`, `tests/test_notifications.py`. +- Загрузка и скачивание файлов + лимиты 25MB/250MB + контроль доступа: `tests/test_uploads_s3.py`, `tests/test_public_cabinet.py`. +- Публичные счета и PDF в кабинете: `tests/test_invoices.py`. + +### LAWYER (юрист) +- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`. +- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`. +- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`. +- Смена статуса и завершение только своих заявок: `tests/test_admin_universal_crud.py`. +- Оповещения (алерты): список/прочтение и генерация по событиям: `tests/test_notifications.py`. +- Сообщения/файлы по заявке и непрочитанные маркеры: `tests/test_notifications.py`, `tests/test_uploads_s3.py`, `tests/test_admin_universal_crud.py`. +- Счета: видимость только своих, запрет ставить `PAID`: `tests/test_invoices.py`, `tests/test_billing_flow.py`. + +### ADMIN (администратор) +- CRUD пользователей/юристов (пароли, роли, профильная тема, аватар): `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py`. +- Темы и флоу статусов (включая ветвление), SLA-переходы: `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`. +- Шаблоны обязательных/дозапрашиваемых данных: `tests/test_admin_universal_crud.py`, `tests/test_public_requests.py`. +- Счета и оплаты (создание, статусы, подтверждение оплаты, multiple cycles): `tests/test_invoices.py`, `tests/test_billing_flow.py`. +- Дашборд портала и загрузка юристов + финансовые метрики: `tests/test_dashboard_finance.py`, `tests/test_admin_universal_crud.py`. +- Безопасность: security-audit по S3 доступам, RBAC и шифрование реквизитов: `tests/test_security_audit.py`, `tests/test_uploads_s3.py`, `tests/test_invoices.py`. + ## Минимальный чеклист закрытия пункта 1. Выполнить миграции (если были изменения схемы). 2. Выполнить целевые тесты пункта по матрице выше. diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..ee14c48 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "law-e2e", + "private": true, + "version": "1.0.0", + "description": "Playwright E2E tests for Law project", + "scripts": { + "test": "playwright test --config=playwright.config.js" + }, + "devDependencies": { + "@playwright/test": "^1.51.0", + "dotenv": "^16.4.5", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index df91ede..f3b9a5f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,20 +1,33 @@ server { listen 80; server_name _; + server_tokens off; root /usr/share/nginx/html; index index.html; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "credentialless" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + location = /admin { + expires 10m; return 302 /admin.html; } location ~* \.jsx$ { + expires 10m; default_type application/javascript; try_files $uri =404; } location / { + expires 10m; try_files $uri /index.html; } diff --git a/requirements.txt b/requirements.txt index a89d374..83a2307 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ -fastapi==0.115.6 +fastapi==0.121.0 +starlette==0.49.1 uvicorn[standard]==0.32.1 pydantic==2.10.3 pydantic-settings==2.6.1 SQLAlchemy==2.0.36 alembic==1.14.0 psycopg[binary]==3.2.3 -python-jose==3.3.0 +PyJWT==2.10.1 passlib[bcrypt]==1.7.4 redis==5.2.0 celery==5.4.0 boto3==1.35.70 httpx==0.27.2 -python-multipart==0.0.12 +python-multipart==0.0.22 diff --git a/tests/test_admin_universal_crud.py b/tests/test_admin_universal_crud.py index 0bceb43..4233ec9 100644 --- a/tests/test_admin_universal_crud.py +++ b/tests/test_admin_universal_crud.py @@ -243,6 +243,157 @@ class AdminUniversalCrudTests(unittest.TestCase): ) self.assertEqual(status_forbidden.status_code, 403) + def test_lawyer_can_see_own_and_unassigned_requests_and_close_only_own(self): + with self.SessionLocal() as db: + lawyer_self = AdminUser( + role="LAWYER", + name="Юрист Свой", + email="lawyer.self@example.com", + password_hash="hash", + is_active=True, + ) + lawyer_other = AdminUser( + role="LAWYER", + name="Юрист Чужой", + email="lawyer.other@example.com", + password_hash="hash", + is_active=True, + ) + db.add_all([lawyer_self, lawyer_other]) + db.flush() + self_id = str(lawyer_self.id) + other_id = str(lawyer_other.id) + + own = Request( + track_number="TRK-LAWYER-OWN", + client_name="Клиент Свой", + client_phone="+79990001011", + status_code="NEW", + description="own", + extra_fields={}, + assigned_lawyer_id=self_id, + ) + foreign = Request( + track_number="TRK-LAWYER-FOREIGN", + client_name="Клиент Чужой", + client_phone="+79990001012", + status_code="NEW", + description="foreign", + extra_fields={}, + assigned_lawyer_id=other_id, + ) + unassigned = Request( + track_number="TRK-LAWYER-UNASSIGNED", + client_name="Клиент Без назначения", + client_phone="+79990001013", + status_code="NEW", + description="unassigned", + extra_fields={}, + assigned_lawyer_id=None, + ) + db.add_all([own, foreign, unassigned]) + db.commit() + own_id = str(own.id) + foreign_id = str(foreign.id) + unassigned_id = str(unassigned.id) + + headers = self._auth_headers("LAWYER", email="lawyer.self@example.com", sub=self_id) + + crud_query = self.client.post( + "/api/admin/crud/requests/query", + headers=headers, + json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}}, + ) + self.assertEqual(crud_query.status_code, 200) + crud_ids = {str(row["id"]) for row in (crud_query.json().get("rows") or [])} + self.assertEqual(crud_ids, {own_id, unassigned_id}) + + legacy_query = self.client.post( + "/api/admin/requests/query", + headers=headers, + json={"filters": [], "sort": [{"field": "created_at", "dir": "asc"}], "page": {"limit": 50, "offset": 0}}, + ) + self.assertEqual(legacy_query.status_code, 200) + legacy_ids = {str(row["id"]) for row in (legacy_query.json().get("rows") or [])} + self.assertEqual(legacy_ids, {own_id, unassigned_id}) + + crud_get_foreign = self.client.get(f"/api/admin/crud/requests/{foreign_id}", headers=headers) + self.assertEqual(crud_get_foreign.status_code, 403) + legacy_get_foreign = self.client.get(f"/api/admin/requests/{foreign_id}", headers=headers) + self.assertEqual(legacy_get_foreign.status_code, 403) + + crud_update_unassigned = self.client.patch( + f"/api/admin/crud/requests/{unassigned_id}", + headers=headers, + json={"status_code": "CLOSED"}, + ) + self.assertEqual(crud_update_unassigned.status_code, 403) + legacy_update_unassigned = self.client.patch( + f"/api/admin/requests/{unassigned_id}", + headers=headers, + json={"status_code": "CLOSED"}, + ) + self.assertEqual(legacy_update_unassigned.status_code, 403) + + close_own = self.client.patch( + f"/api/admin/requests/{own_id}", + headers=headers, + json={"status_code": "CLOSED"}, + ) + self.assertEqual(close_own.status_code, 200) + + with self.SessionLocal() as db: + refreshed = db.get(Request, UUID(own_id)) + self.assertIsNotNone(refreshed) + self.assertEqual(refreshed.status_code, "CLOSED") + + def test_topic_status_flow_supports_branching_transitions(self): + headers = self._auth_headers("ADMIN", email="root@example.com") + with self.SessionLocal() as db: + db.add_all( + [ + Topic(code="civil-branch", name="Гражданское (ветвление)", enabled=True, sort_order=1), + TopicStatusTransition(topic_code="civil-branch", from_status="NEW", to_status="IN_PROGRESS", enabled=True, sort_order=1), + TopicStatusTransition(topic_code="civil-branch", from_status="NEW", to_status="WAITING_CLIENT", enabled=True, sort_order=2), + ] + ) + req_in_progress = Request( + track_number="TRK-BRANCH-1", + client_name="Клиент 1", + client_phone="+79991110021", + topic_code="civil-branch", + status_code="NEW", + description="branch 1", + extra_fields={}, + ) + req_waiting = Request( + track_number="TRK-BRANCH-2", + client_name="Клиент 2", + client_phone="+79991110022", + topic_code="civil-branch", + status_code="NEW", + description="branch 2", + extra_fields={}, + ) + db.add_all([req_in_progress, req_waiting]) + db.commit() + req_in_progress_id = str(req_in_progress.id) + req_waiting_id = str(req_waiting.id) + + first_branch = self.client.patch( + f"/api/admin/crud/requests/{req_in_progress_id}", + headers=headers, + json={"status_code": "IN_PROGRESS"}, + ) + self.assertEqual(first_branch.status_code, 200) + + second_branch = self.client.patch( + f"/api/admin/crud/requests/{req_waiting_id}", + headers=headers, + json={"status_code": "WAITING_CLIENT"}, + ) + self.assertEqual(second_branch.status_code, 200) + def test_request_read_markers_status_update_and_lawyer_open_reset(self): with self.SessionLocal() as db: lawyer = AdminUser( diff --git a/tests/test_public_requests.py b/tests/test_public_requests.py index 333c95e..57d6b6c 100644 --- a/tests/test_public_requests.py +++ b/tests/test_public_requests.py @@ -19,7 +19,7 @@ os.environ.setdefault("S3_BUCKET", "test") from app.main import app from app.core.config import settings -from app.core.security import create_jwt +from app.core.security import create_jwt, decode_jwt from app.db.session import get_db from app.models.notification import Notification from app.models.otp_session import OtpSession @@ -244,3 +244,33 @@ class PublicRequestCreateTests(unittest.TestCase): ) 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 = "+79990001234" + 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())