test_oidc_icon_api.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  1. """Integration tests for the OIDC icon proxy endpoints (#1333).
  2. Covers create/update/delete/refresh round-trips, the public GET /icon
  3. endpoint with ETag/304 behaviour, and the strict "disabled provider → 404"
  4. rule that protects against existence-leak on disabled providers.
  5. httpx mocking follows the project convention:
  6. ``patch("backend.app.services.oidc_icon.httpx.AsyncClient", ...)``.
  7. """
  8. from unittest.mock import patch
  9. import pytest
  10. from httpx import AsyncClient
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from sqlalchemy.orm import undefer
  14. from backend.tests._fixtures.oidc_icon import (
  15. PNG_BYTES as _PNG_BYTES,
  16. PNG_ETAG as _PNG_ETAG,
  17. build_streaming_icon_mock,
  18. )
  19. def _build_icon_mock(*, body: bytes = _PNG_BYTES, content_type: str = "image/png", status_code: int = 200):
  20. """Adapter to the shared streaming-mock fixture.
  21. Kept as a thin wrapper so individual test bodies (lots of them) stay
  22. readable. Returns ``(MockHttpxClient, stream_recorder)`` — same shape
  23. as the pre-streaming ``(MockHttpxClient, mock_get)`` so the per-test
  24. ``mock_get.assert_called_once()`` patterns continue to mean
  25. ``the fetcher hit the upstream exactly once``.
  26. """
  27. return build_streaming_icon_mock(body=body, content_type=content_type, status_code=status_code)
  28. @pytest.fixture
  29. async def admin_token(async_client: AsyncClient):
  30. """Setup auth + return an admin token."""
  31. await async_client.post(
  32. "/api/v1/auth/setup",
  33. json={
  34. "auth_enabled": True,
  35. "admin_username": "iconadmin",
  36. "admin_password": "AdminPass1!",
  37. },
  38. )
  39. login = await async_client.post(
  40. "/api/v1/auth/login",
  41. json={"username": "iconadmin", "password": "AdminPass1!"},
  42. )
  43. return login.json()["access_token"]
  44. def _auth_h(token: str) -> dict:
  45. return {"Authorization": f"Bearer {token}"}
  46. def _create_payload(**overrides) -> dict:
  47. """Minimal valid OIDC provider create payload; overrides shadow fields."""
  48. base = {
  49. "name": "Test",
  50. "issuer_url": "https://idp.example.com",
  51. "client_id": "client",
  52. "client_secret": "secret",
  53. }
  54. base.update(overrides)
  55. return base
  56. # ───────────────────────────────────────────────────────────────────────────
  57. # CREATE
  58. # ───────────────────────────────────────────────────────────────────────────
  59. class TestCreateProviderWithIcon:
  60. @pytest.mark.asyncio
  61. @pytest.mark.integration
  62. async def test_create_with_valid_icon_url_fetches_and_caches(
  63. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  64. ):
  65. from backend.app.models.oidc_provider import OIDCProvider
  66. mock_cls, mock_get = _build_icon_mock()
  67. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  68. resp = await async_client.post(
  69. "/api/v1/auth/oidc/providers",
  70. headers=_auth_h(admin_token),
  71. json=_create_payload(name="GoogleProv", icon_url="https://google.com/icon.png"),
  72. )
  73. assert resp.status_code == 201, resp.text
  74. body = resp.json()
  75. assert body["has_icon"] is True
  76. # DB row has all three icon columns populated — undefer() is required
  77. # because icon_data is deferred (deferred BLOBs raise MissingGreenlet
  78. # on direct attribute access inside an async session).
  79. result = await db_session.execute(
  80. select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.name == "GoogleProv")
  81. )
  82. provider = result.scalar_one()
  83. assert provider.icon_content_type == "image/png"
  84. assert provider.icon_etag == _PNG_ETAG
  85. assert provider.icon_data == _PNG_BYTES
  86. mock_get.assert_called_once()
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_create_with_unreachable_icon_url_returns_400_no_row(
  90. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  91. ):
  92. """Atomicity: failed icon-fetch leaves no row in the DB."""
  93. from backend.app.models.oidc_provider import OIDCProvider
  94. mock_cls, _ = _build_icon_mock(status_code=404)
  95. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  96. resp = await async_client.post(
  97. "/api/v1/auth/oidc/providers",
  98. headers=_auth_h(admin_token),
  99. json=_create_payload(name="BrokenIconProv", icon_url="https://google.com/missing.png"),
  100. )
  101. assert resp.status_code == 400
  102. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "BrokenIconProv"))
  103. assert result.scalar_one_or_none() is None
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_create_without_icon_url_has_icon_false(self, async_client: AsyncClient, admin_token: str):
  107. resp = await async_client.post(
  108. "/api/v1/auth/oidc/providers",
  109. headers=_auth_h(admin_token),
  110. json=_create_payload(name="NoIconProv"),
  111. )
  112. assert resp.status_code == 201
  113. assert resp.json()["has_icon"] is False
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_fetch_failure_logs_warning(self, async_client: AsyncClient, admin_token: str, caplog):
  117. """I2: every fetch failure writes a WARNING log so operators have
  118. a forensic trail beyond the admin's transient toast."""
  119. import logging
  120. mock_cls, _ = _build_icon_mock(status_code=500)
  121. # The Pydantic + SSRF validators must pass for the fetcher branch
  122. # to be reached; we use a public, safe URL and let the upstream
  123. # mock fail with a 500.
  124. with (
  125. caplog.at_level(logging.WARNING, logger="backend.app.api.routes.mfa"),
  126. patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
  127. ):
  128. resp = await async_client.post(
  129. "/api/v1/auth/oidc/providers",
  130. headers=_auth_h(admin_token),
  131. json=_create_payload(name="LogProv", icon_url="https://google.com/icon.png"),
  132. )
  133. assert resp.status_code == 400
  134. warnings = [r for r in caplog.records if "fetch failed" in r.getMessage()]
  135. assert warnings, "expected a WARNING log for the failed icon fetch"
  136. assert "https://google.com/icon.png" in warnings[0].getMessage()
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_ssrf_rejection_logs_warning(
  140. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str, caplog
  141. ):
  142. """I2: SSRF rejection path also logs WARNING (separate branch from
  143. fetch failure — same forensic-trail requirement).
  144. The Pydantic validator now (after I1) catches private IPs at
  145. 422-time, so the route-level _fetch_icon_or_400 SSRF branch is
  146. only reachable via /refresh on a row whose icon_url was inserted
  147. directly (bypassing Pydantic). Use the test DB session to seed
  148. that row, then trigger /refresh.
  149. """
  150. import logging
  151. from backend.app.models.oidc_provider import OIDCProvider
  152. prov = OIDCProvider(
  153. name="SsrfLogProv",
  154. issuer_url="https://idp.example.com",
  155. client_id="c",
  156. scopes="openid",
  157. is_enabled=True,
  158. icon_url="https://192.168.1.1/icon.png", # private — must be rejected
  159. )
  160. prov.client_secret = "secret"
  161. db_session.add(prov)
  162. await db_session.commit()
  163. pid = prov.id
  164. with caplog.at_level(logging.WARNING, logger="backend.app.api.routes.mfa"):
  165. resp = await async_client.post(
  166. f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
  167. headers=_auth_h(admin_token),
  168. )
  169. assert resp.status_code == 400
  170. warnings = [r for r in caplog.records if "SSRF guard" in r.getMessage()]
  171. assert warnings, "expected a WARNING log for the SSRF rejection"
  172. assert "192.168.1.1" in warnings[0].getMessage()
  173. # ───────────────────────────────────────────────────────────────────────────
  174. # UPDATE
  175. # ───────────────────────────────────────────────────────────────────────────
  176. class TestUpdateProviderIcon:
  177. async def _create_with_icon(self, async_client, admin_token, name="UpdProv") -> int:
  178. mock_cls, _ = _build_icon_mock()
  179. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  180. resp = await async_client.post(
  181. "/api/v1/auth/oidc/providers",
  182. headers=_auth_h(admin_token),
  183. json=_create_payload(name=name, icon_url="https://example.com/a.png"),
  184. )
  185. assert resp.status_code == 201, resp.text
  186. return resp.json()["id"]
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_put_without_icon_url_field_does_not_refetch(self, async_client: AsyncClient, admin_token: str):
  190. pid = await self._create_with_icon(async_client, admin_token)
  191. mock_cls, mock_get = _build_icon_mock()
  192. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  193. resp = await async_client.put(
  194. f"/api/v1/auth/oidc/providers/{pid}",
  195. headers=_auth_h(admin_token),
  196. json={"name": "Renamed"},
  197. )
  198. assert resp.status_code == 200
  199. mock_get.assert_not_called()
  200. @pytest.mark.asyncio
  201. @pytest.mark.integration
  202. async def test_put_with_unchanged_icon_url_and_data_present_skips_fetch(
  203. self, async_client: AsyncClient, admin_token: str
  204. ):
  205. pid = await self._create_with_icon(async_client, admin_token)
  206. mock_cls, mock_get = _build_icon_mock()
  207. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  208. resp = await async_client.put(
  209. f"/api/v1/auth/oidc/providers/{pid}",
  210. headers=_auth_h(admin_token),
  211. json={"icon_url": "https://example.com/a.png"},
  212. )
  213. assert resp.status_code == 200
  214. mock_get.assert_not_called()
  215. @pytest.mark.asyncio
  216. @pytest.mark.integration
  217. async def test_put_with_unchanged_url_but_missing_cached_bytes_refetches(
  218. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  219. ):
  220. """Upgrade-path edge case: existing row with icon_url but no cached bytes
  221. (e.g. row predates the proxy migration). Saving must trigger a fetch."""
  222. from backend.app.models.oidc_provider import OIDCProvider
  223. pid = await self._create_with_icon(async_client, admin_token, name="UpgrTest")
  224. # Simulate the upgrade scenario: clear the cached bytes directly in DB.
  225. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
  226. prov = result.scalar_one()
  227. prov.icon_data = None
  228. prov.icon_content_type = None
  229. prov.icon_etag = None
  230. await db_session.commit()
  231. mock_cls, mock_get = _build_icon_mock()
  232. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  233. resp = await async_client.put(
  234. f"/api/v1/auth/oidc/providers/{pid}",
  235. headers=_auth_h(admin_token),
  236. json={"icon_url": "https://example.com/a.png"}, # unchanged URL
  237. )
  238. assert resp.status_code == 200
  239. mock_get.assert_called_once()
  240. assert resp.json()["has_icon"] is True
  241. @pytest.mark.asyncio
  242. @pytest.mark.integration
  243. async def test_put_with_changed_icon_url_refetches(self, async_client: AsyncClient, admin_token: str):
  244. pid = await self._create_with_icon(async_client, admin_token, name="ChangedUrlProv")
  245. mock_cls, mock_get = _build_icon_mock()
  246. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  247. resp = await async_client.put(
  248. f"/api/v1/auth/oidc/providers/{pid}",
  249. headers=_auth_h(admin_token),
  250. json={"icon_url": "https://example.com/b.png"},
  251. )
  252. assert resp.status_code == 200
  253. mock_get.assert_called_once()
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_put_with_icon_url_null_clears_icon(
  257. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  258. ):
  259. """Explicit ``icon_url: null`` in the PUT body clears icon_url AND
  260. all three cached-bytes columns. Distinct from "field absent" which
  261. leaves the icon untouched.
  262. """
  263. from backend.app.models.oidc_provider import OIDCProvider
  264. from sqlalchemy.orm import undefer
  265. pid = await self._create_with_icon(async_client, admin_token, name="ClearViaPutProv")
  266. # Sanity: icon is present before the clear.
  267. pre_resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
  268. assert pre_resp.status_code == 200
  269. # PUT with explicit null clears icon_url + cached bytes.
  270. resp = await async_client.put(
  271. f"/api/v1/auth/oidc/providers/{pid}",
  272. headers=_auth_h(admin_token),
  273. json={"icon_url": None},
  274. )
  275. assert resp.status_code == 200, resp.text
  276. body = resp.json()
  277. assert body["icon_url"] is None
  278. assert body["has_icon"] is False
  279. # DB state: all four icon columns NULL.
  280. db_session.expire_all()
  281. result = await db_session.execute(
  282. select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.id == pid)
  283. )
  284. prov = result.scalar_one()
  285. assert prov.icon_url is None
  286. assert prov.icon_data is None
  287. assert prov.icon_content_type is None
  288. assert prov.icon_etag is None
  289. # GET /icon now 404s.
  290. post_resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
  291. assert post_resp.status_code == 404
  292. @pytest.mark.asyncio
  293. @pytest.mark.integration
  294. async def test_put_with_broken_new_icon_url_preserves_old_bytes(
  295. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  296. ):
  297. """Atomicity: failed icon-fetch on PUT must not clear old cached bytes."""
  298. from backend.app.models.oidc_provider import OIDCProvider
  299. pid = await self._create_with_icon(async_client, admin_token, name="AtomicProv")
  300. # Failed fetch (404).
  301. mock_cls, _ = _build_icon_mock(status_code=404)
  302. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  303. resp = await async_client.put(
  304. f"/api/v1/auth/oidc/providers/{pid}",
  305. headers=_auth_h(admin_token),
  306. json={"icon_url": "https://example.com/broken.png"},
  307. )
  308. assert resp.status_code == 400
  309. # Re-read state: row still has the original icon bytes.
  310. db_session.expire_all()
  311. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
  312. prov = result.scalar_one()
  313. assert prov.icon_content_type == "image/png"
  314. assert prov.icon_etag == _PNG_ETAG
  315. # icon_url also unchanged (rollback works) — admin sees no partial state.
  316. assert prov.icon_url == "https://example.com/a.png"
  317. # ───────────────────────────────────────────────────────────────────────────
  318. # DELETE /icon
  319. # ───────────────────────────────────────────────────────────────────────────
  320. class TestDeleteIcon:
  321. @pytest.mark.asyncio
  322. @pytest.mark.integration
  323. async def test_delete_icon_clears_columns(
  324. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  325. ):
  326. from backend.app.models.oidc_provider import OIDCProvider
  327. mock_cls, _ = _build_icon_mock()
  328. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  329. create_resp = await async_client.post(
  330. "/api/v1/auth/oidc/providers",
  331. headers=_auth_h(admin_token),
  332. json=_create_payload(name="DelIconProv", icon_url="https://example.com/icon.png"),
  333. )
  334. pid = create_resp.json()["id"]
  335. resp = await async_client.delete(
  336. f"/api/v1/auth/oidc/providers/{pid}/icon",
  337. headers=_auth_h(admin_token),
  338. )
  339. assert resp.status_code == 204
  340. db_session.expire_all()
  341. # undefer icon_data so we can assert it's None without triggering
  342. # an async lazy-load (which would raise MissingGreenlet).
  343. result = await db_session.execute(
  344. select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.id == pid)
  345. )
  346. prov = result.scalar_one()
  347. assert prov.icon_data is None
  348. assert prov.icon_content_type is None
  349. assert prov.icon_etag is None
  350. # DELETE /icon clears the URL too — "Remove icon" means the whole
  351. # record is gone, not just the cache. Without this the admin form
  352. # would still show a stale URL while the login page rendered the
  353. # Shield fallback (confusing half-state).
  354. assert prov.icon_url is None
  355. @pytest.mark.asyncio
  356. @pytest.mark.integration
  357. async def test_delete_icon_without_auth_rejected(self, async_client: AsyncClient, admin_token: str):
  358. # Create with admin auth, then try to DELETE icon anonymously.
  359. mock_cls, _ = _build_icon_mock()
  360. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  361. create_resp = await async_client.post(
  362. "/api/v1/auth/oidc/providers",
  363. headers=_auth_h(admin_token),
  364. json=_create_payload(name="AuthGuardProv", icon_url="https://example.com/i.png"),
  365. )
  366. pid = create_resp.json()["id"]
  367. resp = await async_client.delete(f"/api/v1/auth/oidc/providers/{pid}/icon")
  368. assert resp.status_code in (401, 403)
  369. # ───────────────────────────────────────────────────────────────────────────
  370. # REFRESH /icon
  371. # ───────────────────────────────────────────────────────────────────────────
  372. class TestRefreshIcon:
  373. @pytest.mark.asyncio
  374. @pytest.mark.integration
  375. async def test_refresh_fetches_from_stored_url(
  376. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  377. ):
  378. from backend.app.models.oidc_provider import OIDCProvider
  379. mock_cls, _ = _build_icon_mock()
  380. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  381. create_resp = await async_client.post(
  382. "/api/v1/auth/oidc/providers",
  383. headers=_auth_h(admin_token),
  384. json=_create_payload(name="RefProv", icon_url="https://example.com/i.png"),
  385. )
  386. pid = create_resp.json()["id"]
  387. # Now clear in DB (simulate icon corruption / IdP change)
  388. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
  389. prov = result.scalar_one()
  390. prov.icon_data = None
  391. prov.icon_content_type = None
  392. prov.icon_etag = None
  393. await db_session.commit()
  394. new_png = _PNG_BYTES + b"\x00\x01"
  395. mock_cls2, mock_get2 = _build_icon_mock(body=new_png)
  396. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls2):
  397. resp = await async_client.post(
  398. f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
  399. headers=_auth_h(admin_token),
  400. )
  401. assert resp.status_code == 200, resp.text
  402. assert resp.json()["has_icon"] is True
  403. mock_get2.assert_called_once()
  404. @pytest.mark.asyncio
  405. @pytest.mark.integration
  406. async def test_refresh_without_icon_url_returns_400(self, async_client: AsyncClient, admin_token: str):
  407. # Create provider without an icon_url
  408. create_resp = await async_client.post(
  409. "/api/v1/auth/oidc/providers",
  410. headers=_auth_h(admin_token),
  411. json=_create_payload(name="NoUrlRef"),
  412. )
  413. pid = create_resp.json()["id"]
  414. resp = await async_client.post(
  415. f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
  416. headers=_auth_h(admin_token),
  417. )
  418. assert resp.status_code == 400
  419. assert "no icon_url" in resp.json()["detail"].lower()
  420. @pytest.mark.asyncio
  421. @pytest.mark.integration
  422. async def test_refresh_failure_preserves_old_bytes(
  423. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  424. ):
  425. from backend.app.models.oidc_provider import OIDCProvider
  426. mock_cls, _ = _build_icon_mock()
  427. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  428. create_resp = await async_client.post(
  429. "/api/v1/auth/oidc/providers",
  430. headers=_auth_h(admin_token),
  431. json=_create_payload(name="RefAtomicProv", icon_url="https://example.com/i.png"),
  432. )
  433. pid = create_resp.json()["id"]
  434. mock_cls_fail, _ = _build_icon_mock(status_code=500)
  435. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls_fail):
  436. resp = await async_client.post(
  437. f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
  438. headers=_auth_h(admin_token),
  439. )
  440. assert resp.status_code == 400
  441. db_session.expire_all()
  442. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
  443. prov = result.scalar_one()
  444. assert prov.icon_etag == _PNG_ETAG # original bytes intact
  445. # ───────────────────────────────────────────────────────────────────────────
  446. # GET /icon — the public icon-proxy endpoint
  447. # ───────────────────────────────────────────────────────────────────────────
  448. class TestGetProviderIcon:
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_anonymous_get_returns_bytes(self, async_client: AsyncClient, admin_token: str):
  452. mock_cls, _ = _build_icon_mock()
  453. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  454. create_resp = await async_client.post(
  455. "/api/v1/auth/oidc/providers",
  456. headers=_auth_h(admin_token),
  457. json=_create_payload(name="PubIconProv", icon_url="https://example.com/i.png"),
  458. )
  459. pid = create_resp.json()["id"]
  460. # Anonymous request — no Authorization header at all.
  461. resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
  462. assert resp.status_code == 200
  463. assert resp.content == _PNG_BYTES
  464. assert resp.headers["content-type"] == "image/png"
  465. assert resp.headers["etag"] == f'"{_PNG_ETAG}"'
  466. assert resp.headers["cache-control"] == "public, max-age=3600"
  467. @pytest.mark.asyncio
  468. @pytest.mark.integration
  469. async def test_get_without_cached_data_returns_404(self, async_client: AsyncClient, admin_token: str):
  470. create_resp = await async_client.post(
  471. "/api/v1/auth/oidc/providers",
  472. headers=_auth_h(admin_token),
  473. json=_create_payload(name="EmptyIconProv"), # no icon_url → no bytes
  474. )
  475. pid = create_resp.json()["id"]
  476. resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
  477. assert resp.status_code == 404
  478. @pytest.mark.asyncio
  479. @pytest.mark.integration
  480. async def test_get_for_disabled_provider_returns_404(
  481. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  482. ):
  483. """Disabled providers must not leak existence via the icon endpoint."""
  484. from backend.app.models.oidc_provider import OIDCProvider
  485. mock_cls, _ = _build_icon_mock()
  486. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  487. create_resp = await async_client.post(
  488. "/api/v1/auth/oidc/providers",
  489. headers=_auth_h(admin_token),
  490. json=_create_payload(name="DisabledProv", icon_url="https://example.com/d.png"),
  491. )
  492. pid = create_resp.json()["id"]
  493. # Disable directly in DB.
  494. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
  495. prov = result.scalar_one()
  496. prov.is_enabled = False
  497. await db_session.commit()
  498. resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
  499. assert resp.status_code == 404
  500. @pytest.mark.asyncio
  501. @pytest.mark.integration
  502. async def test_if_none_match_exact_returns_304(self, async_client: AsyncClient, admin_token: str):
  503. mock_cls, _ = _build_icon_mock()
  504. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  505. create_resp = await async_client.post(
  506. "/api/v1/auth/oidc/providers",
  507. headers=_auth_h(admin_token),
  508. json=_create_payload(name="EtagProv", icon_url="https://example.com/e.png"),
  509. )
  510. pid = create_resp.json()["id"]
  511. resp = await async_client.get(
  512. f"/api/v1/auth/oidc/providers/{pid}/icon",
  513. headers={"If-None-Match": f'"{_PNG_ETAG}"'},
  514. )
  515. assert resp.status_code == 304
  516. assert resp.content == b""
  517. assert resp.headers["etag"] == f'"{_PNG_ETAG}"'
  518. @pytest.mark.asyncio
  519. @pytest.mark.integration
  520. async def test_if_none_match_mismatch_returns_200(self, async_client: AsyncClient, admin_token: str):
  521. mock_cls, _ = _build_icon_mock()
  522. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  523. create_resp = await async_client.post(
  524. "/api/v1/auth/oidc/providers",
  525. headers=_auth_h(admin_token),
  526. json=_create_payload(name="EtagMismatchProv", icon_url="https://example.com/m.png"),
  527. )
  528. pid = create_resp.json()["id"]
  529. resp = await async_client.get(
  530. f"/api/v1/auth/oidc/providers/{pid}/icon",
  531. headers={"If-None-Match": '"stale-etag-value"'},
  532. )
  533. assert resp.status_code == 200
  534. assert resp.content == _PNG_BYTES
  535. @pytest.mark.asyncio
  536. @pytest.mark.integration
  537. async def test_if_none_match_weak_prefix_returns_304(self, async_client: AsyncClient, admin_token: str):
  538. """N5 — RFC 7232 §2.3 weak validator prefix ``W/"…"`` must match.
  539. CDN intermediaries and some browsers send weak validators on GET
  540. even though we issue strong ones; without the W/ strip a 200 was
  541. returned needlessly.
  542. """
  543. mock_cls, _ = _build_icon_mock()
  544. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  545. create_resp = await async_client.post(
  546. "/api/v1/auth/oidc/providers",
  547. headers=_auth_h(admin_token),
  548. json=_create_payload(name="EtagWeakProv", icon_url="https://example.com/w.png"),
  549. )
  550. pid = create_resp.json()["id"]
  551. resp = await async_client.get(
  552. f"/api/v1/auth/oidc/providers/{pid}/icon",
  553. headers={"If-None-Match": f'W/"{_PNG_ETAG}"'},
  554. )
  555. assert resp.status_code == 304
  556. assert resp.content == b""
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_if_none_match_wildcard_returns_304(self, async_client: AsyncClient, admin_token: str):
  560. """N5 — RFC 7232 §3.2 ``*`` wildcard matches any current
  561. representation when the resource exists. We always have an icon
  562. here (resource existence verified above by the 404 path) so ``*``
  563. always means "I have something; tell me if it's stale" → 304."""
  564. mock_cls, _ = _build_icon_mock()
  565. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  566. create_resp = await async_client.post(
  567. "/api/v1/auth/oidc/providers",
  568. headers=_auth_h(admin_token),
  569. json=_create_payload(name="EtagWildcardProv", icon_url="https://example.com/wc.png"),
  570. )
  571. pid = create_resp.json()["id"]
  572. resp = await async_client.get(
  573. f"/api/v1/auth/oidc/providers/{pid}/icon",
  574. headers={"If-None-Match": "*"},
  575. )
  576. assert resp.status_code == 304
  577. @pytest.mark.asyncio
  578. @pytest.mark.integration
  579. async def test_if_none_match_multiple_tokens_one_match(self, async_client: AsyncClient, admin_token: str):
  580. """N5 — comma-separated list with one matching token returns 304."""
  581. mock_cls, _ = _build_icon_mock()
  582. with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
  583. create_resp = await async_client.post(
  584. "/api/v1/auth/oidc/providers",
  585. headers=_auth_h(admin_token),
  586. json=_create_payload(name="EtagMultiProv", icon_url="https://example.com/m2.png"),
  587. )
  588. pid = create_resp.json()["id"]
  589. resp = await async_client.get(
  590. f"/api/v1/auth/oidc/providers/{pid}/icon",
  591. headers={"If-None-Match": f'"stale", "{_PNG_ETAG}"'},
  592. )
  593. assert resp.status_code == 304
  594. # ───────────────────────────────────────────────────────────────────────────
  595. # N12 — Edge cases (404 paths, inconsistent triplet via raw SQL)
  596. # ───────────────────────────────────────────────────────────────────────────
  597. class TestEdgeCases:
  598. @pytest.mark.asyncio
  599. @pytest.mark.integration
  600. async def test_delete_icon_on_nonexistent_provider_returns_404(self, async_client: AsyncClient, admin_token: str):
  601. """N12 — DELETE /icon on a missing provider_id must 404, not 500."""
  602. resp = await async_client.delete(
  603. "/api/v1/auth/oidc/providers/99999/icon",
  604. headers=_auth_h(admin_token),
  605. )
  606. assert resp.status_code == 404
  607. @pytest.mark.asyncio
  608. @pytest.mark.integration
  609. async def test_refresh_icon_on_nonexistent_provider_returns_404(self, async_client: AsyncClient, admin_token: str):
  610. """N12 — POST /icon/refresh on a missing provider_id must 404, not 500."""
  611. resp = await async_client.post(
  612. "/api/v1/auth/oidc/providers/99999/icon/refresh",
  613. headers=_auth_h(admin_token),
  614. )
  615. assert resp.status_code == 404
  616. @pytest.mark.asyncio
  617. @pytest.mark.integration
  618. async def test_get_icon_with_inconsistent_triplet_returns_404(
  619. self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
  620. ):
  621. """N12 — defensive double-check on the GET /icon endpoint.
  622. The CHECK constraint (#1333 / N10) prevents this state from being
  623. reachable via the application, but the defensive
  624. ``provider.icon_data is None`` guard at the route layer is what
  625. protects against a manual SQL hotfix that bypassed the constraint
  626. (e.g. operator-run UPDATE during incident recovery on stale
  627. SQLite where the CHECK isn't present). We can't write such a row
  628. via SQLAlchemy here (the CHECK fires), so we verify the
  629. equivalent path: a provider with NO icon at all returns 404.
  630. """
  631. from backend.app.models.oidc_provider import OIDCProvider
  632. prov = OIDCProvider(
  633. name="EmptyTripletProv",
  634. issuer_url="https://idp.example.com",
  635. client_id="c",
  636. scopes="openid",
  637. is_enabled=True,
  638. )
  639. prov.client_secret = "secret"
  640. db_session.add(prov)
  641. await db_session.commit()
  642. pid = prov.id
  643. resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
  644. assert resp.status_code == 404