gendesign/tradein-mvp/backend/tests/test_admin_ssrf.py

115 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Unit tests for SSRF allowlist guard (_assert_allowed_url) — issue #756.
Approach: unit-tests on the helper directly (no DB/scraper fixtures needed).
The guard is a pure synchronous function; TestClient integration is not required
because the guard raises before any async scraper is called.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_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 HTTPException # noqa: E402
from app.api.v1.admin import _assert_allowed_url # noqa: E402
from app.core.config import settings # noqa: E402
# ---------------------------------------------------------------------------
# Absolute URL — host NOT in allowlist → 400
# ---------------------------------------------------------------------------
def test_absolute_ssrf_metadata_endpoint_blocked() -> None:
"""http://169.254.169.254/... (AWS metadata) должен быть отклонён с 400."""
with pytest.raises(HTTPException) as exc_info:
_assert_allowed_url("http://169.254.169.254/latest/meta-data/")
assert exc_info.value.status_code == 400
assert "host not allowed" in exc_info.value.detail
def test_absolute_localhost_blocked() -> None:
with pytest.raises(HTTPException) as exc_info:
_assert_allowed_url("http://localhost/internal/admin")
assert exc_info.value.status_code == 400
def test_absolute_arbitrary_host_blocked() -> None:
with pytest.raises(HTTPException) as exc_info:
_assert_allowed_url("https://evil.example.com/scrape")
assert exc_info.value.status_code == 400
# ---------------------------------------------------------------------------
# Absolute URL — host IN allowlist → passes
# ---------------------------------------------------------------------------
def test_absolute_avito_allowed() -> None:
_assert_allowed_url("https://www.avito.ru/catalog/houses/some-slug/12345")
def test_absolute_cian_allowed() -> None:
_assert_allowed_url("https://ekb.cian.ru/sale/flat/327830237/")
def test_absolute_yandex_realty_allowed() -> None:
_assert_allowed_url("https://realty.yandex.ru/ekaterinburg/kupit/kvartira/123/")
def test_absolute_n1_blocked() -> None:
"""n1 удалён из allowlist (#2204, источник мёртв) → абсолютный n1-URL блокируется."""
with pytest.raises(HTTPException) as exc_info:
_assert_allowed_url("https://ekaterinburg.n1.ru/kupit/kvartiry/vtorichka/")
assert exc_info.value.status_code == 400
# ---------------------------------------------------------------------------
# Relative path — no netloc → always passes
# ---------------------------------------------------------------------------
def test_relative_path_passes_the_guard() -> None:
"""Путь без схемы/хоста (авито-стиль /ekaterinburg/...) разрешается."""
_assert_allowed_url("/ekaterinburg/kvartiry/prodam-123")
def test_relative_path_house_catalog_passes() -> None:
_assert_allowed_url("/catalog/houses/ekb-slug/99999")
def test_empty_string_passes() -> None:
"""Пустая строка — нет netloc → guard не блокирует (upstream validation задача scraper'а)."""
_assert_allowed_url("")
# ---------------------------------------------------------------------------
# Allowlist integrity: verify expected hosts present
# ---------------------------------------------------------------------------
def test_allowlist_contains_required_scraper_hosts() -> None:
required = {
"www.avito.ru",
"ekb.cian.ru",
"www.cian.ru",
"realty.yandex.ru",
}
missing = required - settings.scrape_allowed_hosts
assert not missing, f"Missing hosts in scrape_allowed_hosts: {missing}"
def test_allowlist_excludes_removed_n1_hosts() -> None:
"""n1 полностью удалён из allowlist (#2204) — ни один n1-хост не должен остаться."""
n1_hosts = {"n1.ru", "ekaterinburg.n1.ru"}
leftover = n1_hosts & settings.scrape_allowed_hosts
assert not leftover, f"n1 hosts still in scrape_allowed_hosts: {leftover}"