| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- """Unit tests for backend.app.services.oidc_icon.fetch_icon (#1333).
- Uses ``patch("backend.app.services.oidc_icon.httpx.AsyncClient", ...)`` —
- the same mocking pattern the project uses in ``test_mfa_api.py`` for OIDC
- discovery/JWKS calls. Streaming-mock helper lives in
- ``backend/tests/_fixtures/oidc_icon.py``.
- """
- import hashlib
- from types import SimpleNamespace
- from unittest.mock import patch
- import httpx
- import pytest
- from backend.app.services.oidc_icon import (
- OIDCIconUnavailableError,
- OIDCIconUrlError,
- _resolve_content_type,
- fetch_icon,
- )
- from backend.tests._fixtures.oidc_icon import (
- PNG_BYTES,
- PNG_ETAG,
- build_streaming_icon_mock,
- )
- # ─── _resolve_content_type — pure helper, tested directly ────────────────
- class TestResolveContentType:
- @pytest.mark.parametrize(
- "mime",
- ["image/png", "image/jpeg", "image/webp", "image/gif"],
- )
- def test_accepts_whitelisted_mime(self, mime):
- assert _resolve_content_type(mime, "/icon") == mime
- def test_octet_stream_with_png_extension(self):
- assert _resolve_content_type("application/octet-stream", "/path/icon.png") == "image/png"
- def test_octet_stream_with_jpeg_extension(self):
- assert _resolve_content_type("application/octet-stream", "/icon.jpeg") == "image/jpeg"
- def test_octet_stream_without_extension_raises(self):
- with pytest.raises(OIDCIconUnavailableError, match="no image extension"):
- _resolve_content_type("application/octet-stream", "/icon")
- def test_missing_content_type_distinct_message(self):
- # N6: empty string → distinct "missing Content-Type" message,
- # not user-hostile "unsupported content-type: ''".
- with pytest.raises(OIDCIconUnavailableError, match="missing a Content-Type header"):
- _resolve_content_type("", "/icon.png")
- @pytest.mark.parametrize(
- "mime",
- ["image/svg+xml", "text/html", "application/json", "application/pdf", "text/plain"],
- )
- def test_disallowed_mime_raises_with_value(self, mime):
- with pytest.raises(OIDCIconUnavailableError, match="content-type"):
- _resolve_content_type(mime, "/icon.png")
- # ─── fetch_icon — happy paths (streaming) ─────────────────────────────────
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "mime",
- ["image/png", "image/jpeg", "image/webp", "image/gif"],
- )
- async def test_accepts_whitelisted_mime(mime):
- mock_cls, _ = build_streaming_icon_mock(content_type=mime)
- with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
- payload, ct, etag = await fetch_icon("https://example.com/icon")
- assert payload == PNG_BYTES
- assert ct == mime
- assert etag == PNG_ETAG
- @pytest.mark.asyncio
- async def test_etag_is_deterministic_sha256():
- mock_cls, _ = build_streaming_icon_mock()
- with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
- _, _, etag_a = await fetch_icon("https://example.com/a.png")
- _, _, etag_b = await fetch_icon("https://example.com/b.png")
- assert etag_a == etag_b # same bytes → same etag
- assert etag_a == hashlib.sha256(PNG_BYTES).hexdigest()
- @pytest.mark.asyncio
- async def test_octet_stream_with_png_extension_accepted():
- mock_cls, _ = build_streaming_icon_mock(content_type="application/octet-stream")
- with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
- _, ct, _ = await fetch_icon("https://cdn.example.com/path/icon.png")
- assert ct == "image/png"
- # ─── fetch_icon — rejects: scheme ────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_rejects_non_https():
- with pytest.raises(OIDCIconUrlError, match="https"):
- await fetch_icon("http://example.com/icon.png")
- # ─── fetch_icon — rejects: HTTP status codes ─────────────────────────────
- @pytest.mark.asyncio
- @pytest.mark.parametrize("status_code", [301, 302, 307, 308, 404, 500, 502])
- async def test_rejects_non_200(status_code):
- mock_cls, _ = build_streaming_icon_mock(status_code=status_code)
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
- pytest.raises(OIDCIconUnavailableError, match=f"HTTP {status_code}"),
- ):
- await fetch_icon("https://example.com/icon")
- # ─── fetch_icon — rejects: content types ─────────────────────────────────
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "mime",
- [
- "image/svg+xml", # SVG explicitly excluded in v1
- "text/html",
- "application/json",
- "application/pdf",
- "text/plain",
- ],
- )
- async def test_rejects_disallowed_mime(mime):
- mock_cls, _ = build_streaming_icon_mock(content_type=mime)
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
- pytest.raises(OIDCIconUnavailableError, match="content-type"),
- ):
- await fetch_icon("https://example.com/icon")
- @pytest.mark.asyncio
- async def test_rejects_octet_stream_without_image_extension():
- mock_cls, _ = build_streaming_icon_mock(content_type="application/octet-stream")
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
- pytest.raises(OIDCIconUnavailableError, match="no image extension"),
- ):
- await fetch_icon("https://example.com/icon")
- @pytest.mark.asyncio
- async def test_rejects_missing_content_type_header():
- # N6: distinct message when upstream omits Content-Type entirely.
- mock_cls, _ = build_streaming_icon_mock(content_type=None)
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
- pytest.raises(OIDCIconUnavailableError, match="missing a Content-Type"),
- ):
- await fetch_icon("https://example.com/icon.png")
- # ─── fetch_icon — rejects: payload size (streaming early-exit) ───────────
- @pytest.mark.asyncio
- async def test_rejects_oversized_payload_via_streaming_early_exit():
- """I4: size-cap fires DURING streaming, not after full buffer.
- The 2 MB payload is emitted in 4 KB chunks. The cap (1 MB) is crossed
- around chunk 256; fetch_icon must raise BEFORE the remaining ~256
- chunks are buffered. We don't observe the early-exit timing
- directly — we just confirm the right exception with the right
- message is raised; the streaming-mock structure guarantees the
- code path went through aiter_bytes().
- """
- too_big = b"\x89PNG" + b"\x00" * (2 * 1024 * 1024) # 2 MB > 1 MB cap
- mock_cls, _ = build_streaming_icon_mock(body=too_big, chunk_size=4096)
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
- pytest.raises(OIDCIconUnavailableError, match="cap"),
- ):
- await fetch_icon("https://example.com/icon.png")
- @pytest.mark.asyncio
- async def test_streaming_size_cap_aborts_at_first_chunk_past_limit():
- """Stronger guarantee: when the very first chunk exceeds the cap,
- we abort on that chunk — no further iteration."""
- chunks_seen = 0
- async def _hostile_aiter_bytes():
- nonlocal chunks_seen
- # First chunk: 2 MB in one go — already over the 1 MB cap.
- chunks_seen += 1
- yield b"\x00" * (2 * 1024 * 1024)
- # This second chunk must NEVER be reached.
- chunks_seen += 1
- yield b"\x00" * 100
- response = SimpleNamespace(
- status_code=200,
- headers={"content-type": "image/png"},
- aiter_bytes=_hostile_aiter_bytes,
- )
- class _StreamCtx:
- async def __aenter__(self):
- return response
- async def __aexit__(self, *_exc):
- return False
- class _MockHttpxClient:
- def __init__(self, *_a, **_kw):
- pass
- async def __aenter__(self):
- return self
- async def __aexit__(self, *_exc):
- return False
- def stream(self, *_a, **_kw):
- return _StreamCtx()
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", _MockHttpxClient),
- pytest.raises(OIDCIconUnavailableError, match="cap"),
- ):
- await fetch_icon("https://example.com/icon.png")
- assert chunks_seen == 1, "size-cap must abort on first oversized chunk"
- @pytest.mark.asyncio
- async def test_rejects_empty_body():
- mock_cls, _ = build_streaming_icon_mock(body=b"")
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
- pytest.raises(OIDCIconUnavailableError, match="empty"),
- ):
- await fetch_icon("https://example.com/icon.png")
- # ─── fetch_icon — network errors ─────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_timeout_raises_unavailable():
- class _TimingOutClient:
- def __init__(self, *_a, **_kw):
- pass
- async def __aenter__(self):
- return self
- async def __aexit__(self, *_exc):
- return False
- def stream(self, *_a, **_kw):
- class _Ctx:
- async def __aenter__(_self):
- raise httpx.TimeoutException("timed out")
- async def __aexit__(_self, *_exc):
- return False
- return _Ctx()
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", _TimingOutClient),
- pytest.raises(OIDCIconUnavailableError, match="timed out"),
- ):
- await fetch_icon("https://example.com/icon")
- @pytest.mark.asyncio
- async def test_connection_error_raises_unavailable():
- class _ErrClient:
- def __init__(self, *_a, **_kw):
- pass
- async def __aenter__(self):
- return self
- async def __aexit__(self, *_exc):
- return False
- def stream(self, *_a, **_kw):
- class _Ctx:
- async def __aenter__(_self):
- raise httpx.ConnectError("connection refused")
- async def __aexit__(_self, *_exc):
- return False
- return _Ctx()
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", _ErrClient),
- pytest.raises(OIDCIconUnavailableError, match="failed"),
- ):
- await fetch_icon("https://example.com/icon")
- # ─── C1: httpx.InvalidURL → OIDCIconUrlError (not a 500) ─────────────────
- @pytest.mark.asyncio
- async def test_invalid_url_raises_url_error():
- """C1: httpx.InvalidURL is NOT a subclass of httpx.HTTPError. Must be
- caught explicitly and mapped to OIDCIconUrlError → 400, not 500."""
- class _InvalidUrlClient:
- def __init__(self, *_a, **_kw):
- pass
- async def __aenter__(self):
- return self
- async def __aexit__(self, *_exc):
- return False
- def stream(self, *_a, **_kw):
- class _Ctx:
- async def __aenter__(_self):
- raise httpx.InvalidURL("Invalid non-printable ASCII character in URL")
- async def __aexit__(_self, *_exc):
- return False
- return _Ctx()
- with (
- patch("backend.app.services.oidc_icon.httpx.AsyncClient", _InvalidUrlClient),
- pytest.raises(OIDCIconUrlError, match="Invalid icon URL"),
- ):
- await fetch_icon("https://example.com/icon")
- # ─── follow_redirects=False is non-negotiable ────────────────────────────
- @pytest.mark.asyncio
- async def test_passes_follow_redirects_false():
- """Defence-in-depth: verify we explicitly pass follow_redirects=False so
- an upstream 302 cannot bypass the SSRF host check on the initial URL."""
- mock_cls, stream_recorder = build_streaming_icon_mock()
- with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
- await fetch_icon("https://example.com/icon.png")
- stream_recorder.assert_called_once()
- _args, kwargs = stream_recorder.call_args
- assert kwargs.get("follow_redirects") is False
|