"""Favicon сайта проекта: SSRF-барьер, снафф типа, кэш на диске, эндпоинт."""
import asyncio
import time
from pathlib import Path
import httpx
import pytest
from fastapi.testclient import TestClient
from app.services import favicon
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 32
@pytest.fixture(autouse=True)
def clean_cache() -> None:
"""Кэш-каталог между тестами чистим: путь один на процесс."""
for entry in favicon._cache_dir().glob("*"):
entry.unlink()
@pytest.fixture
def public_dns(monkeypatch: pytest.MonkeyPatch) -> None:
"""Резолв «всё публичное»: сеть в тестах не трогаем."""
def fake_getaddrinfo(*args: object, **kwargs: object) -> list[tuple]:
return [(2, 1, 6, "", ("93.184.216.34", 0))]
monkeypatch.setattr(favicon.socket, "getaddrinfo", fake_getaddrinfo)
def test_private_hosts_rejected() -> None:
# приватные/loopback/link-local адреса — отказ (в т.ч. метаданные облака)
for host in ("127.0.0.1", "10.0.0.1", "192.168.1.5", "169.254.169.254", "::1"):
assert favicon.is_public_host(host) is False, host
def test_public_host_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
favicon.socket, "getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 0))]
)
assert favicon.is_public_host("example.com") is True
def test_unresolvable_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(*args: object, **kwargs: object) -> list[tuple]:
raise favicon.socket.gaierror("no such host")
monkeypatch.setattr(favicon.socket, "getaddrinfo", boom)
assert favicon.is_public_host("nope.invalid") is False
def test_validated_urls(public_dns: None) -> None:
assert favicon._validated("https://example.com") is True
assert favicon._validated("ftp://example.com") is False
assert favicon._validated("https://") is False
def test_sniff_supported_formats() -> None:
assert favicon._sniff_ext(PNG) == ".png"
assert favicon._sniff_ext(b"\x00\x00\x01\x00" + b"x" * 8) == ".ico"
assert favicon._sniff_ext(b"GIF89a" + b"x" * 8) == ".gif"
assert favicon._sniff_ext(b"\xff\xd8\xff" + b"x" * 8) == ".jpg"
assert favicon._sniff_ext(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == ".webp"
# SVG не принимаем (скриптуемый формат), произвольный мусор — тоже
assert favicon._sniff_ext(b"<svg xmlns=\"http://www.w3.org/2000/svg\">") is None
assert favicon._sniff_ext(b"<html>") is None
def _client(handler: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=5.0) # type: ignore[arg-type]
def test_download_rejects_non_image(public_dns: None) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/html"}, content=b"<html>")
async def run() -> tuple[bytes, str] | None:
async with _client(handler) as client:
return await favicon._download(client, "https://example.com/favicon.ico")
assert asyncio.run(run()) is None
def test_download_rejects_svg(public_dns: None) -> None:
svg = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "image/svg+xml"}, content=svg)
async def run() -> tuple[bytes, str] | None:
async with _client(handler) as client:
return await favicon._download(client, "https://example.com/icon.svg")
# content-type картинки, но magic-bytes чужие — иконкой не считаем
assert asyncio.run(run()) is None
def test_download_stops_on_oversize(public_dns: None) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "image/png"},
content=PNG + b"0" * favicon.MAX_BYTES,
)
async def run() -> tuple[bytes, str] | None:
async with _client(handler) as client:
return await favicon._download(client, "https://example.com/favicon.png")
assert asyncio.run(run()) is None
def test_download_follows_redirect_and_checks_host(public_dns: None) -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.url.path)
if request.url.path == "/favicon.ico":
return httpx.Response(302, headers={"location": "/icon.png"})
return httpx.Response(200, headers={"content-type": "image/png"}, content=PNG)
async def run() -> tuple[bytes, str] | None:
async with _client(handler) as client:
return await favicon._download(client, "https://example.com/favicon.ico")
assert asyncio.run(run()) == (PNG, ".png")
assert seen == ["/favicon.ico", "/icon.png"]
def test_download_redirect_to_private_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_getaddrinfo(*args: object, **kwargs: object) -> list[tuple]:
host = str(args[0]) if args else ""
ip = "127.0.0.1" if host == "internal.corp" else "93.184.216.34"
return [(2, 1, 6, "", (ip, 0))]
monkeypatch.setattr(favicon.socket, "getaddrinfo", fake_getaddrinfo)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(302, headers={"location": "http://internal.corp/secret"})
async def run() -> tuple[bytes, str] | None:
async with _client(handler) as client:
return await favicon._download(client, "https://example.com/favicon.ico")
# первый хоп публичный, второй — приватный: редирект не проходим
assert asyncio.run(run()) is None
def test_fetch_caches_and_reuses(public_dns: None, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
async def fake_download(client: object, url: str) -> tuple[bytes, str] | None:
calls.append(url)
return (PNG, ".png")
monkeypatch.setattr(favicon, "_download", fake_download)
async def run() -> tuple[bytes, str] | None:
return await favicon.fetch_favicon("https://example.com")
first = asyncio.run(run())
assert first == (PNG, "image/png")
fetched = len(calls)
# второй запрос отдаётся с диска — сеть не трогаем
assert asyncio.run(run()) == (PNG, "image/png")
assert len(calls) == fetched
# кэш переживает рестарт процесса (файл на диске)
files = [p for p in favicon._cache_dir().glob("*") if p.suffix == ".png"]
assert files and files[0].read_bytes() == PNG
def test_fetch_negative_cached(public_dns: None, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
async def fake_download(client: object, url: str) -> tuple[bytes, str] | None:
calls.append(url)
return None
monkeypatch.setattr(favicon, "_download", fake_download)
async def run() -> tuple[bytes, str] | None:
return await favicon.fetch_favicon("https://no-icon.example")
assert asyncio.run(run()) is None
tried = len(calls)
assert tried > 0
# негативный маркер: повторно сайт не долбим
assert asyncio.run(run()) is None
assert len(calls) == tried
# маркер протух — перепроверяем
real_time = time.time
monkeypatch.setattr(favicon.time, "time", lambda: real_time() + favicon.NEGATIVE_TTL + 1)
assert asyncio.run(run()) is None
assert len(calls) > tried
def test_cache_dir_created(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
target = tmp_path / "favicons"
settings = favicon.get_settings()
monkeypatch.setattr(settings, "favicons_path", str(target))
assert favicon._cache_dir() == target
assert target.is_dir()
def test_endpoint_404_without_site_url(client: TestClient) -> None:
project_id = client.post("/api/projects", json={"name": "Без сайта"}).json()["id"]
assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404
def test_endpoint_404_when_not_found(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
project_id = client.post(
"/api/projects", json={"name": "Сайт", "site_url": "https://example.com"}
).json()["id"]
async def none_found(url: str) -> tuple[bytes, str] | None:
return None
monkeypatch.setattr(favicon, "fetch_favicon", none_found)
assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404
def test_endpoint_serves_favicon(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
project_id = client.post(
"/api/projects", json={"name": "Сайт", "site_url": "https://example.com"}
).json()["id"]
async def found(url: str) -> tuple[bytes, str] | None:
return PNG, "image/png"
monkeypatch.setattr(favicon, "fetch_favicon", found)
resp = client.get(f"/api/projects/{project_id}/favicon")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert resp.content == PNG
assert resp.headers["cache-control"] == "private, max-age=86400"
# иконки чужого сайта не должны исполняться как документ
assert resp.headers["x-content-type-options"] == "nosniff"
def test_endpoint_scoped_to_user(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
project_id = client.post(
"/api/projects", json={"name": "Сайт", "site_url": "https://example.com"}
).json()["id"]
async def found(url: str) -> tuple[bytes, str] | None:
return PNG, "image/png"
monkeypatch.setattr(favicon, "fetch_favicon", found)
# чужой пользователь не должен получить иконку чужого проекта
from app.dependencies import require_user
from app.main import app as fastapi_app
original = fastapi_app.dependency_overrides[require_user]
fastapi_app.dependency_overrides[require_user] = lambda: {
"user_id": "999",
"email": "other@example.com",
"locale": "ru",
}
try:
assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404
finally:
# возвращаем тот же объект: conftest-овский lambda закрыт на общий
# AUTH_USER, его подменяют тесты мультиюзера
fastapi_app.dependency_overrides[require_user] = original