test_oidc_icon_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. """Unit tests for backend.app.services.oidc_icon.fetch_icon (#1333).
  2. Uses ``patch("backend.app.services.oidc_icon.httpx.AsyncClient", ...)`` —
  3. the same mocking pattern the project uses in ``test_mfa_api.py`` for OIDC
  4. discovery/JWKS calls. Streaming-mock helper lives in
  5. ``backend/tests/_fixtures/oidc_icon.py``.
  6. """
  7. import hashlib
  8. from types import SimpleNamespace
  9. from unittest.mock import patch
  10. import httpx
  11. import pytest
  12. from backend.app.services.oidc_icon import (
  13. OIDCIconUnavailableError,
  14. OIDCIconUrlError,
  15. _resolve_content_type,
  16. fetch_icon,
  17. )
  18. from backend.tests._fixtures.oidc_icon import (
  19. PNG_BYTES,
  20. PNG_ETAG,
  21. build_streaming_icon_mock,
  22. )
  23. # ─── _resolve_content_type — pure helper, tested directly ────────────────
  24. class TestResolveContentType:
  25. @pytest.mark.parametrize(
  26. "mime",
  27. ["image/png", "image/jpeg", "image/webp", "image/gif"],
  28. )
  29. def test_accepts_whitelisted_mime(self, mime):
  30. assert _resolve_content_type(mime, "/icon") == mime
  31. def test_octet_stream_with_png_extension(self):
  32. assert _resolve_content_type("application/octet-stream", "/path/icon.png") == "image/png"
  33. def test_octet_stream_with_jpeg_extension(self):
  34. assert _resolve_content_type("application/octet-stream", "/icon.jpeg") == "image/jpeg"
  35. def test_octet_stream_without_extension_raises(self):
  36. with pytest.raises(OIDCIconUnavailableError, match="no image extension"):
  37. _resolve_content_type("application/octet-stream", "/icon")
  38. def test_missing_content_type_distinct_message(self):
  39. # N6: empty string → distinct "missing Content-Type" message,
  40. # not user-hostile "unsupported content-type: ''".
  41. with pytest.raises(OIDCIconUnavailableError, match="missing a Content-Type header"):
  42. _resolve_content_type("", "/icon.png")
  43. @pytest.mark.parametrize(
  44. "mime",
  45. ["image/svg+xml", "text/html", "application/json", "application/pdf", "text/plain"],
  46. )
  47. def test_disallowed_mime_raises_with_value(self, mime):
  48. with pytest.raises(OIDCIconUnavailableError, match="content-type"):
  49. _resolve_content_type(mime, "/icon.png")
  50. # ─── fetch_icon — happy paths (streaming) ─────────────────────────────────
  51. @pytest.mark.asyncio
  52. @pytest.mark.parametrize(
  53. "mime",
  54. ["image/png", "image/jpeg", "image/webp", "image/gif"],
  55. )
  56. async def test_accepts_whitelisted_mime(mime):
  57. mock_cls, _ = build_streaming_icon_mock(content_type=mime)
  58. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  59. payload, ct, etag = await fetch_icon("https://example.com/icon")
  60. assert payload == PNG_BYTES
  61. assert ct == mime
  62. assert etag == PNG_ETAG
  63. @pytest.mark.asyncio
  64. async def test_etag_is_deterministic_sha256():
  65. mock_cls, _ = build_streaming_icon_mock()
  66. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  67. _, _, etag_a = await fetch_icon("https://example.com/a.png")
  68. _, _, etag_b = await fetch_icon("https://example.com/b.png")
  69. assert etag_a == etag_b # same bytes → same etag
  70. assert etag_a == hashlib.sha256(PNG_BYTES).hexdigest()
  71. @pytest.mark.asyncio
  72. async def test_octet_stream_with_png_extension_accepted():
  73. mock_cls, _ = build_streaming_icon_mock(content_type="application/octet-stream")
  74. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  75. _, ct, _ = await fetch_icon("https://cdn.example.com/path/icon.png")
  76. assert ct == "image/png"
  77. # ─── fetch_icon — rejects: scheme ────────────────────────────────────────
  78. @pytest.mark.asyncio
  79. async def test_rejects_non_https():
  80. with pytest.raises(OIDCIconUrlError, match="https"):
  81. await fetch_icon("http://example.com/icon.png")
  82. # ─── fetch_icon — rejects: HTTP status codes ─────────────────────────────
  83. @pytest.mark.asyncio
  84. @pytest.mark.parametrize("status_code", [301, 302, 307, 308, 404, 500, 502])
  85. async def test_rejects_non_200(status_code):
  86. mock_cls, _ = build_streaming_icon_mock(status_code=status_code)
  87. with (
  88. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  89. pytest.raises(OIDCIconUnavailableError, match=f"HTTP {status_code}"),
  90. ):
  91. await fetch_icon("https://example.com/icon")
  92. # ─── fetch_icon — rejects: content types ─────────────────────────────────
  93. @pytest.mark.asyncio
  94. @pytest.mark.parametrize(
  95. "mime",
  96. [
  97. "image/svg+xml", # SVG explicitly excluded in v1
  98. "text/html",
  99. "application/json",
  100. "application/pdf",
  101. "text/plain",
  102. ],
  103. )
  104. async def test_rejects_disallowed_mime(mime):
  105. mock_cls, _ = build_streaming_icon_mock(content_type=mime)
  106. with (
  107. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  108. pytest.raises(OIDCIconUnavailableError, match="content-type"),
  109. ):
  110. await fetch_icon("https://example.com/icon")
  111. @pytest.mark.asyncio
  112. async def test_rejects_octet_stream_without_image_extension():
  113. mock_cls, _ = build_streaming_icon_mock(content_type="application/octet-stream")
  114. with (
  115. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  116. pytest.raises(OIDCIconUnavailableError, match="no image extension"),
  117. ):
  118. await fetch_icon("https://example.com/icon")
  119. @pytest.mark.asyncio
  120. async def test_rejects_missing_content_type_header():
  121. # N6: distinct message when upstream omits Content-Type entirely.
  122. mock_cls, _ = build_streaming_icon_mock(content_type=None)
  123. with (
  124. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  125. pytest.raises(OIDCIconUnavailableError, match="missing a Content-Type"),
  126. ):
  127. await fetch_icon("https://example.com/icon.png")
  128. # ─── fetch_icon — rejects: payload size (streaming early-exit) ───────────
  129. @pytest.mark.asyncio
  130. async def test_rejects_oversized_payload_via_streaming_early_exit():
  131. """I4: size-cap fires DURING streaming, not after full buffer.
  132. The 2 MB payload is emitted in 4 KB chunks. The cap (1 MB) is crossed
  133. around chunk 256; fetch_icon must raise BEFORE the remaining ~256
  134. chunks are buffered. We don't observe the early-exit timing
  135. directly — we just confirm the right exception with the right
  136. message is raised; the streaming-mock structure guarantees the
  137. code path went through aiter_bytes().
  138. """
  139. too_big = b"\x89PNG" + b"\x00" * (2 * 1024 * 1024) # 2 MB > 1 MB cap
  140. mock_cls, _ = build_streaming_icon_mock(body=too_big, chunk_size=4096)
  141. with (
  142. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  143. pytest.raises(OIDCIconUnavailableError, match="cap"),
  144. ):
  145. await fetch_icon("https://example.com/icon.png")
  146. @pytest.mark.asyncio
  147. async def test_streaming_size_cap_aborts_at_first_chunk_past_limit():
  148. """Stronger guarantee: when the very first chunk exceeds the cap,
  149. we abort on that chunk — no further iteration."""
  150. chunks_seen = 0
  151. async def _hostile_aiter_bytes():
  152. nonlocal chunks_seen
  153. # First chunk: 2 MB in one go — already over the 1 MB cap.
  154. chunks_seen += 1
  155. yield b"\x00" * (2 * 1024 * 1024)
  156. # This second chunk must NEVER be reached.
  157. chunks_seen += 1
  158. yield b"\x00" * 100
  159. response = SimpleNamespace(
  160. status_code=200,
  161. headers={"content-type": "image/png"},
  162. aiter_bytes=_hostile_aiter_bytes,
  163. )
  164. class _StreamCtx:
  165. async def __aenter__(self):
  166. return response
  167. async def __aexit__(self, *_exc):
  168. return False
  169. class _MockHttpxClient:
  170. def __init__(self, *_a, **_kw):
  171. pass
  172. async def __aenter__(self):
  173. return self
  174. async def __aexit__(self, *_exc):
  175. return False
  176. def stream(self, *_a, **_kw):
  177. return _StreamCtx()
  178. with (
  179. patch("backend.app.services.oidc_icon.httpx.AsyncClient", _MockHttpxClient),
  180. pytest.raises(OIDCIconUnavailableError, match="cap"),
  181. ):
  182. await fetch_icon("https://example.com/icon.png")
  183. assert chunks_seen == 1, "size-cap must abort on first oversized chunk"
  184. @pytest.mark.asyncio
  185. async def test_rejects_empty_body():
  186. mock_cls, _ = build_streaming_icon_mock(body=b"")
  187. with (
  188. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  189. pytest.raises(OIDCIconUnavailableError, match="empty"),
  190. ):
  191. await fetch_icon("https://example.com/icon.png")
  192. # ─── fetch_icon — network errors ─────────────────────────────────────────
  193. @pytest.mark.asyncio
  194. async def test_timeout_raises_unavailable():
  195. class _TimingOutClient:
  196. def __init__(self, *_a, **_kw):
  197. pass
  198. async def __aenter__(self):
  199. return self
  200. async def __aexit__(self, *_exc):
  201. return False
  202. def stream(self, *_a, **_kw):
  203. class _Ctx:
  204. async def __aenter__(_self):
  205. raise httpx.TimeoutException("timed out")
  206. async def __aexit__(_self, *_exc):
  207. return False
  208. return _Ctx()
  209. with (
  210. patch("backend.app.services.oidc_icon.httpx.AsyncClient", _TimingOutClient),
  211. pytest.raises(OIDCIconUnavailableError, match="timed out"),
  212. ):
  213. await fetch_icon("https://example.com/icon")
  214. @pytest.mark.asyncio
  215. async def test_connection_error_raises_unavailable():
  216. class _ErrClient:
  217. def __init__(self, *_a, **_kw):
  218. pass
  219. async def __aenter__(self):
  220. return self
  221. async def __aexit__(self, *_exc):
  222. return False
  223. def stream(self, *_a, **_kw):
  224. class _Ctx:
  225. async def __aenter__(_self):
  226. raise httpx.ConnectError("connection refused")
  227. async def __aexit__(_self, *_exc):
  228. return False
  229. return _Ctx()
  230. with (
  231. patch("backend.app.services.oidc_icon.httpx.AsyncClient", _ErrClient),
  232. pytest.raises(OIDCIconUnavailableError, match="failed"),
  233. ):
  234. await fetch_icon("https://example.com/icon")
  235. # ─── C1: httpx.InvalidURL → OIDCIconUrlError (not a 500) ─────────────────
  236. @pytest.mark.asyncio
  237. async def test_invalid_url_raises_url_error():
  238. """C1: httpx.InvalidURL is NOT a subclass of httpx.HTTPError. Must be
  239. caught explicitly and mapped to OIDCIconUrlError → 400, not 500."""
  240. class _InvalidUrlClient:
  241. def __init__(self, *_a, **_kw):
  242. pass
  243. async def __aenter__(self):
  244. return self
  245. async def __aexit__(self, *_exc):
  246. return False
  247. def stream(self, *_a, **_kw):
  248. class _Ctx:
  249. async def __aenter__(_self):
  250. raise httpx.InvalidURL("Invalid non-printable ASCII character in URL")
  251. async def __aexit__(_self, *_exc):
  252. return False
  253. return _Ctx()
  254. with (
  255. patch("backend.app.services.oidc_icon.httpx.AsyncClient", _InvalidUrlClient),
  256. pytest.raises(OIDCIconUrlError, match="Invalid icon URL"),
  257. ):
  258. await fetch_icon("https://example.com/icon")
  259. # ─── follow_redirects=False is non-negotiable ────────────────────────────
  260. @pytest.mark.asyncio
  261. async def test_passes_follow_redirects_false():
  262. """Defence-in-depth: verify we explicitly pass follow_redirects=False so
  263. an upstream 302 cannot bypass the SSRF host check on the initial URL."""
  264. mock_cls, stream_recorder = build_streaming_icon_mock()
  265. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  266. await fetch_icon("https://example.com/icon.png")
  267. stream_recorder.assert_called_once()
  268. _args, kwargs = stream_recorder.call_args
  269. assert kwargs.get("follow_redirects") is False