Merge remote-tracking branch 'forgejo/main' into feat/tradein-651-652-accuracy

# Conflicts:
#	tradein-mvp/backend/app/core/config.py
This commit is contained in:
lekss361 2026-05-29 19:11:51 +03:00
commit 1d74410832
19 changed files with 503 additions and 26 deletions

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
async def ping() -> dict[str, bool]:
return {"pong": True}

View file

@ -30,6 +30,7 @@ from app.api.v1 import (
parcels, parcels,
photos, photos,
pilot, pilot,
ping,
trade_in, trade_in,
users, users,
) )
@ -86,7 +87,7 @@ app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan)
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки — # Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
# X-Authenticated-User там просто не приходит из Caddy. # X-Authenticated-User там просто не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) _PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
@app.middleware("http") @app.middleware("http")
@ -164,6 +165,7 @@ app.include_router(landing.router, prefix="/api/v1", tags=["landing"])
app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"]) app.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"])
app.include_router(users.router, prefix="/api/v1", tags=["users"]) app.include_router(users.router, prefix="/api/v1", tags=["users"])
app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(ping.router, prefix="/api/v1", tags=["ping"])
@app.get("/health") @app.get("/health")

View file

@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
def test_ping() -> None:
client = TestClient(app)
response = client.get("/api/v1/ping")
assert response.status_code == 200
assert response.json() == {"pong": True}

View file

@ -61,7 +61,7 @@ async def estimate(
""" """
account_quota.check_and_raise(db, x_authenticated_user) account_quota.check_and_raise(db, x_authenticated_user)
from app.services.estimator import estimate_quality from app.services.estimator import estimate_quality
result = await estimate_quality(payload, db) result = await estimate_quality(payload, db, created_by=x_authenticated_user)
account_quota.increment(db, x_authenticated_user) account_quota.increment(db, x_authenticated_user)
return result return result
@ -397,19 +397,57 @@ def get_photo(
def estimate_history( def estimate_history(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
limit: int = 50, limit: int = 50,
account: str | None = None,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""История оценок (#399) — последние N записей trade_in_estimates.""" """История оценок (#399) — последние N записей trade_in_estimates.
Скоупинг (#656 — закрывает cross-pilot data-leak): non-admin видит ТОЛЬКО
свои оценки (created_by = X-Authenticated-User); legacy NULL-строки без
владельца не попадают. Admin видит все строки, либо фильтрует по ?account=<user>.
401 если заголовок отсутствует (mirror /me Caddy basic_auth обязателен).
"""
if not x_authenticated_user:
raise HTTPException(
status_code=401,
detail="no authenticated user (Caddy basic_auth required)",
)
from app.core.auth import get_role
try:
role = get_role(x_authenticated_user)
except KeyError:
logger.warning(
"user %r authenticated via Caddy but missing from roles.yaml",
x_authenticated_user,
)
raise HTTPException(status_code=403, detail="user not in roles config") from None
params: dict[str, object] = {"limit": min(max(limit, 1), 200)}
where = ""
if role == "admin":
# Admin: все строки, либо фильтр по конкретному аккаунту.
if account:
where = "WHERE created_by = :account"
params["account"] = account
else:
# Non-admin: жёстко скоупим на свои строки; ?account игнорируется.
where = "WHERE created_by = :owner"
params["owner"] = x_authenticated_user
rows = db.execute( rows = db.execute(
text( text(
""" f"""
SELECT id, address, rooms, area_m2, median_price, SELECT id, address, rooms, area_m2, median_price,
confidence, n_analogs, created_at confidence, n_analogs, created_at
FROM trade_in_estimates FROM trade_in_estimates
{where}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT :limit LIMIT :limit
""" """
), ),
{"limit": min(max(limit, 1), 200)}, params,
).mappings().all() ).mappings().all()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View file

@ -34,6 +34,13 @@ class Settings(BaseSettings):
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL. # Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
# Rate-limit публичного /api/* (per-IP sliding window). ENV: RATE_LIMIT,
# RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s
# секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от
# Caddy basic_auth) не лимитируется — см. ratelimit.py (#655).
rate_limit: int = 300
rate_limit_window_s: float = 60.0
# Password for tradein_fdw_reader role — used by backend startup to create/refresh # Password for tradein_fdw_reader role — used by backend startup to create/refresh
# USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server). # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev). # Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
@ -60,5 +67,9 @@ class Settings(BaseSettings):
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15 estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658).
# Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15.
estimate_quota_limit: int = 15
settings = Settings() settings = Settings()

View file

@ -16,9 +16,11 @@ from fastapi import Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP. from app.core.config import settings
RATE_WINDOW = 60.0
RATE_LIMIT = 90 # Окно и лимит читаются из settings (env RATE_LIMIT / RATE_LIMIT_WINDOW_S):
# не более settings.rate_limit запросов за settings.rate_limit_window_s секунд
# с одного IP.
class RateLimitMiddleware(BaseHTTPMiddleware): class RateLimitMiddleware(BaseHTTPMiddleware):
@ -34,17 +36,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
if not path.startswith("/api/"): if not path.startswith("/api/"):
return await call_next(request) return await call_next(request)
# Аутентифицированный трафик не лимитируем: Caddy basic_auth ставит
# X-Authenticated-User на каждый запрос пилота. Лимит — только для
# анонимного трафика (заголовок отсутствует/пуст). #655.
if request.headers.get("x-authenticated-user"):
return await call_next(request)
ip = _client_ip(request) ip = _client_ip(request)
now = time.monotonic() now = time.monotonic()
bucket = self._hits[ip] bucket = self._hits[ip]
# Выкидываем устаревшие отметки за пределами окна. # Выкидываем устаревшие отметки за пределами окна.
cutoff = now - RATE_WINDOW cutoff = now - settings.rate_limit_window_s
while bucket and bucket[0] < cutoff: while bucket and bucket[0] < cutoff:
bucket.popleft() bucket.popleft()
if len(bucket) >= RATE_LIMIT: if len(bucket) >= settings.rate_limit:
retry = int(RATE_WINDOW - (now - bucket[0])) + 1 retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1
return JSONResponse( return JSONResponse(
status_code=429, status_code=429,
content={"detail": "Слишком много запросов. Попробуйте позже."}, content={"detail": "Слишком много запросов. Попробуйте позже."},

View file

@ -1,7 +1,8 @@
"""Сервис квоты оценок trade-in — 15 успешных оценок в месяц на аккаунт. """Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
Правила: Правила:
- Лимит = 15 успешных оценок за календарный месяц (UTC, период 'YYYY-MM'). - Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15.
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'. - Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). - Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited, - Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited,
@ -19,12 +20,16 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth import get_role from app.core.auth import get_role
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MONTHLY_LIMIT = 15 # Лимит успешных оценок за календарный месяц — конфигурируется через
# env ESTIMATE_QUOTA_LIMIT (core.config.Settings), default 15 (#658).
MONTHLY_LIMIT = settings.estimate_quota_limit
LIMIT_EXHAUSTED_MESSAGE = ( LIMIT_EXHAUSTED_MESSAGE = (
"Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
) )

View file

@ -829,7 +829,9 @@ def _fetch_dkp_corridor(
# ── Public ─────────────────────────────────────────────────────────────────── # ── Public ───────────────────────────────────────────────────────────────────
async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate: async def estimate_quality(
payload: TradeInEstimateInput, db: Session, created_by: str | None = None
) -> AggregatedEstimate:
"""Главная функция — оценка квартиры по реальным данным. """Главная функция — оценка квартиры по реальным данным.
PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output. PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output.
@ -850,7 +852,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
if geo is None: if geo is None:
# Без координат не можем искать через PostGIS. Возвращаем low confidence. # Без координат не можем искать через PostGIS. Возвращаем low confidence.
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
return _empty_estimate(payload, db, reason="address_not_geocoded") return _empty_estimate(payload, db, reason="address_not_geocoded", created_by=created_by)
# 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса. # 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса.
# Best-effort: graceful None при отсутствии credentials / quota / fail. # Best-effort: graceful None при отсутствии credentials / quota / fail.
@ -1292,6 +1294,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
expected_sold_price, expected_sold_range_low, expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2, expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, asking_to_sold_ratio, ratio_basis,
created_by,
expires_at expires_at
) VALUES ( ) VALUES (
CAST(:id AS uuid), CAST(:id AS uuid),
@ -1311,6 +1314,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
:expected_sold_price, :expected_sold_range_low, :expected_sold_price, :expected_sold_range_low,
:expected_sold_range_high, :expected_sold_per_m2, :expected_sold_range_high, :expected_sold_per_m2,
:asking_to_sold_ratio, :ratio_basis, :asking_to_sold_ratio, :ratio_basis,
:created_by,
:expires_at :expires_at
) )
""" """
@ -1359,6 +1363,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
"expected_sold_per_m2": expected_sold_per_m2, "expected_sold_per_m2": expected_sold_per_m2,
"asking_to_sold_ratio": asking_to_sold_ratio, "asking_to_sold_ratio": asking_to_sold_ratio,
"ratio_basis": ratio_basis, "ratio_basis": ratio_basis,
"created_by": created_by,
"expires_at": expires_at, "expires_at": expires_at,
}, },
) )
@ -2295,7 +2300,7 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot:
def _empty_estimate( def _empty_estimate(
payload: TradeInEstimateInput, db: Session, *, reason: str payload: TradeInEstimateInput, db: Session, *, reason: str, created_by: str | None = None
) -> AggregatedEstimate: ) -> AggregatedEstimate:
"""Fallback когда нет данных для оценки. """Fallback когда нет данных для оценки.
@ -2318,6 +2323,7 @@ def _empty_estimate(
confidence, confidence_explanation, n_analogs, confidence, confidence_explanation, n_analogs,
analogs, actual_deals, analogs, actual_deals,
sources_used, sources_used,
created_by,
expires_at expires_at
) VALUES ( ) VALUES (
CAST(:id AS uuid), :address, CAST(:id AS uuid), :address,
@ -2328,6 +2334,7 @@ def _empty_estimate(
'low', :explanation, 0, 'low', :explanation, 0,
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb,
'[]'::jsonb, '[]'::jsonb,
:created_by,
:expires_at :expires_at
) )
""" """
@ -2348,6 +2355,7 @@ def _empty_estimate(
"client_name": payload.client_name, "client_name": payload.client_name,
"client_phone": payload.client_phone, "client_phone": payload.client_phone,
"explanation": reason, "explanation": reason,
"created_by": created_by,
"expires_at": expires_at, "expires_at": expires_at,
}, },
) )

View file

@ -0,0 +1,34 @@
-- 083_trade_in_estimates_created_by.sql
-- #656 [P1, security/data-leak] — добавляем владельца оценки для скоупинга GET /history.
--
-- ПРОБЛЕМА: GET /history SELECT'ил последние оценки ВСЕХ пользователей (без фильтра)
-- → cross-pilot data leak (один пилот видел адреса/клиентов другого). trade_in_estimates
-- не имела колонки владельца. Caddy basic_auth прокидывает X-Authenticated-User в каждый
-- аутентифицированный запрос; RBAC middleware уже 401'ит неизвестных юзеров, так что
-- заголовок гарантированно присутствует для реальных вызовов.
--
-- ФИКС: created_by text (username из X-Authenticated-User). Estimator пишет его на INSERT.
-- GET /history скоупит: non-admin → WHERE created_by = :user; admin → все строки
-- (или ?account=<user> фильтр). Legacy-строки остаются created_by=NULL — они без
-- владельца, поэтому non-admin их НЕ видит (корректно: нет owner = не показываем).
--
-- Индекс (created_by, created_at DESC) обслуживает основной паттерн запроса /history
-- (ORDER BY created_at DESC LIMIT N, опционально WHERE created_by = :user).
-- Idempotent: ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS.
-- Apply after: 082_scrape_schedules_seed_ratio_refresh.sql
BEGIN;
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS created_by text;
CREATE INDEX IF NOT EXISTS idx_trade_in_estimates_created_by_created_at
ON trade_in_estimates (created_by, created_at DESC);
COMMENT ON COLUMN trade_in_estimates.created_by IS
'Username (X-Authenticated-User из Caddy basic_auth), создавший оценку. '
'Используется для скоупинга GET /history (#656): non-admin видит только свои '
'строки. NULL для legacy-строк, созданных до миграции 083 — они без владельца '
'и в non-admin историю не попадают.';
COMMIT;

View file

@ -0,0 +1,25 @@
-- 084_brand_praktika_fill.sql
-- #657 White-label для клиента «Практика» (gk-praktika.ru).
--
-- ПРОБЛЕМА: seed бренда 'praktika' в 002_core_tables.sql задан заглушкой —
-- primary_color='#e87722' (orange, НЕ из брендбука клиента), logo_url/accent NULL.
-- Реальные фирменные цвета ГК «Практика» (с gk-praktika.ru):
-- primary #235F49 — тёмно-зелёный (основной),
-- accent #1FCECB — циан (акцент).
-- Логотип хотлинкается с сайта клиента (magenta-метка logo-pink.svg).
--
-- Идемпотентно: UPDATE по PK slug='praktika'. Безопасно ре-применять.
--
-- NUMBERING: highest на main = 082; PR #656 занимает 083 → используем 084,
-- чтобы избежать коллизии нумерации (см. PR body).
BEGIN;
UPDATE brands
SET primary_color = '#235F49', -- тёмно-зелёный (бренд)
accent_color = '#1FCECB', -- циан (акцент)
logo_url = 'https://gk-praktika.ru/images/logo/logo-pink.svg', -- hotlink с сайта клиента
footer_text = 'ГК «Практика» · Екатеринбург'
WHERE slug = 'praktika';
COMMIT;

View file

@ -147,7 +147,8 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user3") check_and_raise(db, "user3")
assert exc_info.value.detail == ( assert exc_info.value.detail == (
"Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым." f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
) )

View file

@ -0,0 +1,155 @@
"""Tests for GET /api/v1/trade-in/history scoping (#656).
Закрывает cross-pilot data-leak: non-admin видит только свои оценки
(created_by = X-Authenticated-User), admin видит все либо фильтрует по ?account.
Проверяем САМ скоупинг через перехват SQL/params, переданных в db.execute
реальная БД не нужна (DB + get_role мокируются).
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
# psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
@pytest.fixture()
def trade_in_app() -> FastAPI:
"""Minimal FastAPI app mounting only the trade-in router."""
from app.api.v1 import trade_in as trade_in_module
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
return application
def _make_db_mock(rows: list[dict]) -> MagicMock:
"""DB session mock returning *rows* from .mappings().all()."""
db = MagicMock()
mapping_result = MagicMock()
mapping_result.all.return_value = rows
execute_result = MagicMock()
execute_result.mappings.return_value = mapping_result
db.execute.return_value = execute_result
return db
def _captured_sql_and_params(db_mock: MagicMock) -> tuple[str, dict]:
"""Extract the SQL text + params dict from the single db.execute call."""
args, _ = db_mock.execute.call_args
sql = str(args[0])
params = args[1]
return sql, params
def _client_with(app: FastAPI, db_mock: MagicMock, role: str) -> TestClient:
"""Override get_db with *db_mock* and patch get_role to return *role*."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
def _override_db():
yield db_mock
app.dependency_overrides[get_db] = _override_db
# estimate_history imports get_role from app.core.auth at call time → patch source.
trade_in_module_auth = sys.modules["app.core.auth"]
trade_in_module_auth.get_role = lambda _u: role # type: ignore[assignment]
_ = trade_in_module # keep router import side-effect explicit
return TestClient(app)
def test_history_requires_authenticated_user(trade_in_app: FastAPI) -> None:
"""No X-Authenticated-User header → 401 (mirror /me)."""
db_mock = _make_db_mock([])
from app.core.db import get_db
def _override_db():
yield db_mock
trade_in_app.dependency_overrides[get_db] = _override_db
client = TestClient(trade_in_app)
resp = client.get("/api/v1/trade-in/history")
assert resp.status_code == 401
# DB must not be touched when unauthenticated.
db_mock.execute.assert_not_called()
def test_non_admin_sees_only_own_rows(trade_in_app: FastAPI) -> None:
"""Pilot user → WHERE created_by = :owner with owner == header value."""
db_mock = _make_db_mock([{"id": "1", "address": "A", "rooms": 2,
"area_m2": 50, "median_price": 5_000_000,
"confidence": "medium", "n_analogs": 7,
"created_at": "2026-05-29T00:00:00"}])
client = _client_with(trade_in_app, db_mock, role="pilot")
resp = client.get(
"/api/v1/trade-in/history",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
sql, params = _captured_sql_and_params(db_mock)
assert "created_by = :owner" in sql
assert params["owner"] == "kopylov"
assert "account" not in params
def test_non_admin_account_param_is_ignored(trade_in_app: FastAPI) -> None:
"""Pilot passing ?account=victim must still be scoped to themselves."""
db_mock = _make_db_mock([])
client = _client_with(trade_in_app, db_mock, role="pilot")
resp = client.get(
"/api/v1/trade-in/history",
params={"account": "victim"},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
sql, params = _captured_sql_and_params(db_mock)
assert "created_by = :owner" in sql
assert params["owner"] == "kopylov"
# The attacker-supplied account must NOT reach the query.
assert "account" not in params
assert "victim" not in params.values()
def test_admin_sees_all_rows(trade_in_app: FastAPI) -> None:
"""Admin without ?account → no WHERE filter (all rows)."""
db_mock = _make_db_mock([])
client = _client_with(trade_in_app, db_mock, role="admin")
resp = client.get(
"/api/v1/trade-in/history",
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
sql, params = _captured_sql_and_params(db_mock)
assert "WHERE" not in sql.upper()
assert "owner" not in params
assert "account" not in params
def test_admin_filters_by_account(trade_in_app: FastAPI) -> None:
"""Admin with ?account=kopylov → WHERE created_by = :account."""
db_mock = _make_db_mock([])
client = _client_with(trade_in_app, db_mock, role="admin")
resp = client.get(
"/api/v1/trade-in/history",
params={"account": "kopylov"},
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
sql, params = _captured_sql_and_params(db_mock)
assert "created_by = :account" in sql
assert params["account"] == "kopylov"
assert "owner" not in params

View file

@ -0,0 +1,58 @@
"""Tests for RateLimitMiddleware — per-IP sliding window + auth bypass (#655).
Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не
тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно читаются из
settings на каждый dispatch monkeypatch'им их в маленькие значения.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import config
from app.core.ratelimit import RateLimitMiddleware
@pytest.fixture
def client(monkeypatch):
"""Минимальное приложение с лимитом 3 запроса / 60 с (для быстрого 429)."""
monkeypatch.setattr(config.settings, "rate_limit", 3)
monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0)
app = FastAPI()
app.add_middleware(RateLimitMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
return TestClient(app)
def test_anonymous_throttled_past_limit(client):
# Первые rate_limit запросов проходят, следующий — 429.
for _ in range(3):
assert client.get("/api/v1/ping").status_code == 200
resp = client.get("/api/v1/ping")
assert resp.status_code == 429
assert resp.json() == {"detail": "Слишком много запросов. Попробуйте позже."}
assert "Retry-After" in resp.headers
def test_authenticated_user_bypasses_limit(client):
# X-Authenticated-User (Caddy basic_auth) → лимит не применяется.
headers = {"X-Authenticated-User": "pilot@example.com"}
for _ in range(10): # сильно больше rate_limit=3
assert client.get("/api/v1/ping", headers=headers).status_code == 200
def test_empty_auth_header_does_not_bypass(client):
# Пустой заголовок не должен no-op'ить лимит (header present но falsy).
headers = {"X-Authenticated-User": ""}
for _ in range(3):
assert client.get("/api/v1/ping", headers=headers).status_code == 200
assert client.get("/api/v1/ping", headers=headers).status_code == 429

View file

@ -5,7 +5,7 @@
* С basePath=/trade-in user видит её на gendsgn.ru/trade-in/. * С basePath=/trade-in user видит её на gendsgn.ru/trade-in/.
* Использует CSS из /components/trade-in/trade-in.css. * Использует CSS из /components/trade-in/trade-in.css.
*/ */
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@ -29,6 +29,7 @@ import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets"; import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api"; import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota"; import { useQuota } from "@/lib/useQuota";
import { useBrand, getActiveBrandSlug } from "@/lib/useBrand";
function useEstimateId() { function useEstimateId() {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
@ -41,6 +42,27 @@ export default function TradeInPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const quota = useQuota(); const quota = useQuota();
// #657 white-label: «отдельная страничка под Практику» = re-skin того же
// UI при ?brand=praktika. Бренд резолвится из ?brand= slug (см. useBrand.ts).
const { data: brand } = useBrand();
const activeBrandSlug = getActiveBrandSlug();
// Переопределяем CSS-переменные палитры в рантайме + вешаем .brand-active
// (переключает --font-sans на Manrope, см. trade-in.css). Cleanup снимает
// overrides → без ?brand= дефолтный UI полностью нетронут (no-op).
useEffect(() => {
const root = document.documentElement;
if (!brand) return;
root.style.setProperty("--accent", brand.primary_color); // зелёный (бренд)
root.style.setProperty("--accent-2", brand.accent_color); // циан (акцент)
root.classList.add("brand-active");
return () => {
root.style.removeProperty("--accent");
root.style.removeProperty("--accent-2");
root.classList.remove("brand-active");
};
}, [brand]);
const blocked = quota.data const blocked = quota.data
? !quota.data.unlimited && quota.data.remaining <= 0 ? !quota.data.unlimited && quota.data.remaining <= 0
: false; : false;
@ -205,7 +227,7 @@ export default function TradeInPage() {
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} /> <ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
<DealsCard estimate={resultData.estimate} /> <DealsCard estimate={resultData.estimate} />
<StreetDealsCard estimate={resultData.estimate} /> <StreetDealsCard estimate={resultData.estimate} />
<OfferCard estimate={resultData.estimate} /> <OfferCard estimate={resultData.estimate} brandSlug={activeBrandSlug} />
</> </>
) : ( ) : (
<article className="card"> <article className="card">

View file

@ -429,7 +429,8 @@ export function EstimateForm({
marginBottom: 8, marginBottom: 8,
}} }}
> >
Лимит из 15 оценок в этом месяце исчерпан. Свяжитесь с Артёмом Копыловым. Лимит из {limit ?? 15} оценок в этом месяце исчерпан. За полной версией
обращайтесь к Копылову.
</div> </div>
) : ( ) : (
<> <>

View file

@ -9,13 +9,18 @@ import { API_BASE_URL } from "@/lib/api";
interface Props { interface Props {
estimate: AggregatedEstimate; estimate: AggregatedEstimate;
/** #657 white-label: активный ?brand= slug — прокидывается в PDF endpoint. */
brandSlug?: string | null;
} }
function fmtRub(v: number): string { function fmtRub(v: number): string {
return Math.round(v).toLocaleString("ru-RU"); return Math.round(v).toLocaleString("ru-RU");
} }
export function OfferCard({ estimate }: Props) { export function OfferCard({ estimate, brandSlug }: Props) {
// PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label.
// Без бренда query пустой → стандартный PDF.
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
const basePrice = estimate.median_price_rub; const basePrice = estimate.median_price_rub;
const baseMln = basePrice / 1_000_000; const baseMln = basePrice / 1_000_000;
// Расчёт издержек самостоятельной продажи (от рыночной цены) // Расчёт издержек самостоятельной продажи (от рыночной цены)
@ -175,7 +180,7 @@ export function OfferCard({ estimate }: Props) {
</div> </div>
<div className="offer-cta-buttons"> <div className="offer-cta-buttons">
<a <a
href={`${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf`} href={`${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf${pdfQuery}`}
download={`trade-in-${estimate.estimate_id}.pdf`} download={`trade-in-${estimate.estimate_id}.pdf`}
className="btn btn-ghost" className="btn btn-ghost"
> >

View file

@ -1,8 +1,11 @@
"use client"; "use client";
/* eslint-disable @next/next/no-img-element -- white-label логотип хотлинком с сайта клиента, next/image не нужен */
import { UserMenu } from "@/components/auth/UserMenu"; import { UserMenu } from "@/components/auth/UserMenu";
import { API_BASE_URL, HTTPError } from "@/lib/api"; import { API_BASE_URL, HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed"; import { isPathAllowed } from "@/lib/isPathAllowed";
import { useBrand } from "@/lib/useBrand";
import { useMe } from "@/lib/useMe"; import { useMe } from "@/lib/useMe";
type ActiveTab = type ActiveTab =
@ -72,6 +75,9 @@ const NAV_ITEMS: Array<{
export function Topbar({ active }: TopbarProps) { export function Topbar({ active }: TopbarProps) {
const { data, error } = useMe(); const { data, error } = useMe();
// #657 white-label: при активном ?brand= с логотипом рендерим логотип
// клиента вместо «TI»-метки. Без бренда brand === null → стандартный wordmark.
const { data: brand } = useBrand();
// 401 (dev без Caddy basic_auth) — показываем все айтемы. // 401 (dev без Caddy basic_auth) — показываем все айтемы.
// Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы // Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы
@ -88,8 +94,17 @@ export function Topbar({ active }: TopbarProps) {
<header className="topbar"> <header className="topbar">
<div className="topbar-inner"> <div className="topbar-inner">
<div className="brand"> <div className="brand">
<span className="brand-mark">TI</span> {brand?.logo_url ? (
<span className="brand-product">Trade-In</span> <>
<img src={brand.logo_url} alt={brand.name} className="brand-logo" />
<span className="brand-product">{brand.name}</span>
</>
) : (
<>
<span className="brand-mark">TI</span>
<span className="brand-product">Trade-In</span>
</>
)}
</div> </div>
<nav className="top-nav"> <nav className="top-nav">
{items.map((item) => ( {items.map((item) => (

View file

@ -1,3 +1,8 @@
/* #657 white-label: Manrope OPEN geometric grotesk, визуальная замена
платного HalvarMittel из брендбука «Практики». Подключается всегда, но
применяется только при активном бренде (см. --font-sans override ниже). */
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap');
:root { :root {
/* surface */ /* surface */
--bg: oklch(99% 0.002 240); --bg: oklch(99% 0.002 240);
@ -32,6 +37,8 @@
/* type */ /* type */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
/* #657 white-label font (Manrope) — применяется через .brand-active ниже */
--font-brand: 'Manrope', var(--font-sans);
/* layout */ /* layout */
--radius: 10px; --radius: 10px;
--radius-sm: 6px; --radius-sm: 6px;
@ -107,6 +114,13 @@
color: var(--muted); color: var(--muted);
text-transform: uppercase; text-transform: uppercase;
} }
/* #657 white-label: логотип клиента вместо «TI»-метки (см. Topbar.tsx) */
.brand-logo { height: 24px; width: auto; display: block; }
/* Активный бренд: переопределяем шрифт всего trade-in scope на Manrope.
--accent / --accent-2 переопределяются в рантайме из page.tsx
(document.documentElement.style.setProperty). Без ?brand= класс не
вешается стандартный Inter + OKLCH-палитра нетронуты. */
.brand-active { --font-sans: var(--font-brand); }
.top-nav { .top-nav {
display: flex; display: flex;
gap: 22px; gap: 22px;

View file

@ -0,0 +1,57 @@
"use client";
/**
* useBrand white-label resolver для #657 («Практика» и пр.).
*
* Активный бренд определяется query-параметром `?brand=<slug>` в URL
* (НЕ из /me у юзера нет brand/account-поля). Default нет бренда
* (стандартный UI prinzip/generic), хук возвращает {data: null}.
*
* Mirror паттерна useMe.ts: TanStack Query, staleTime/gcTime Infinity
* (бренд статичен на сессию). Backend: GET /api/v1/brand/{slug}
* (см. tradein-mvp/backend/app/api/v1/brand.py) возвращает поля ниже,
* fallback на generic если slug не найден.
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
export interface Brand {
slug: string;
name: string;
logo_url: string | null;
primary_color: string;
accent_color: string;
footer_text: string | null;
pdf_disclaimer: string | null;
}
/** Читает `?brand=` slug из URL. SSR-safe (null на сервере). */
export function getActiveBrandSlug(): string | null {
if (typeof window === "undefined") return null;
const slug = new URLSearchParams(window.location.search).get("brand");
return slug && slug.trim() ? slug.trim().toLowerCase() : null;
}
async function fetchBrand(slug: string): Promise<Brand> {
return apiFetch<Brand>(`/api/v1/brand/${encodeURIComponent(slug)}`);
}
/**
* Возвращает активный бренд или {data: null}, если `?brand=` отсутствует.
* Запрос включён только при наличии slug без бренда хук no-op.
*/
export function useBrand() {
const slug = getActiveBrandSlug();
return useQuery<Brand | null, Error>({
queryKey: ["brand", slug],
queryFn: () => (slug ? fetchBrand(slug) : Promise.resolve(null)),
enabled: slug !== null,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
}