mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
* feat(auth): proxy OIDC provider icons server-side (#1333) Strict img-src CSP blocked external OIDC icon hosts on the login page. Loosening CSP was rejected via the MakerWorld precedent, so icons are proxied: admin sets icon_url, backend fetches and caches the bytes in a deferred BLOB column, the SPA renders from a same-origin /api/v1/auth/oidc/providers/{id}/icon endpoint.
96 lines
3.5 KiB
Python
96 lines
3.5 KiB
Python
"""Verify icon_data stays deferred on list queries (#1333).
|
|
|
|
Regression guard: if ``deferred=True`` ever gets dropped from
|
|
``OIDCProvider.icon_data``, every login-page hit pulls the full BLOB on
|
|
the listing query, adding ~MB of bandwidth per anonymous request. These
|
|
tests assert via SQLAlchemy's instance inspector that the column is
|
|
**not** loaded by default and **is** loaded after an explicit
|
|
``undefer()``.
|
|
"""
|
|
|
|
import hashlib
|
|
|
|
import pytest
|
|
from sqlalchemy import inspect, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import undefer
|
|
|
|
_PNG_BYTES = bytes.fromhex(
|
|
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
|
|
"890000000d49444154789c63000100000005000100"
|
|
"0d0a2db40000000049454e44ae426082"
|
|
)
|
|
|
|
|
|
async def _seed_provider(db_session: AsyncSession, name: str = "DeferredProv"):
|
|
from backend.app.models.oidc_provider import OIDCProvider
|
|
|
|
provider = OIDCProvider(
|
|
name=name,
|
|
issuer_url="https://idp.example.com",
|
|
client_id="c",
|
|
scopes="openid",
|
|
is_enabled=True,
|
|
)
|
|
provider.client_secret = "secret"
|
|
provider.icon_url = "https://example.com/icon.png"
|
|
provider.icon_data = _PNG_BYTES
|
|
provider.icon_content_type = "image/png"
|
|
provider.icon_etag = hashlib.sha256(_PNG_BYTES).hexdigest()
|
|
db_session.add(provider)
|
|
await db_session.commit()
|
|
db_session.expire_all() # force the next read to come from DB, not identity-map
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_default_list_query_does_not_load_icon_data(db_session: AsyncSession):
|
|
"""`select(OIDCProvider)` without options keeps icon_data unloaded."""
|
|
from backend.app.models.oidc_provider import OIDCProvider
|
|
|
|
await _seed_provider(db_session)
|
|
result = await db_session.execute(select(OIDCProvider))
|
|
provider = result.scalar_one()
|
|
|
|
state = inspect(provider)
|
|
assert "icon_data" in state.unloaded, (
|
|
"icon_data should be deferred on the default list query — "
|
|
"without this guard every login page hit pulls the full BLOB."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_undefer_loads_icon_data(db_session: AsyncSession):
|
|
"""`select(...).options(undefer(...))` loads icon_data eagerly."""
|
|
from backend.app.models.oidc_provider import OIDCProvider
|
|
|
|
await _seed_provider(db_session, name="UndeferProv")
|
|
result = await db_session.execute(
|
|
select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.name == "UndeferProv")
|
|
)
|
|
provider = result.scalar_one()
|
|
|
|
state = inspect(provider)
|
|
assert "icon_data" not in state.unloaded, "undefer() must eagerly load icon_data"
|
|
# And the bytes are accessible without raising MissingGreenlet.
|
|
assert provider.icon_data == _PNG_BYTES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_icon_content_type_is_eager_indicator(db_session: AsyncSession):
|
|
"""icon_content_type must NOT be deferred — it's the eager has-icon
|
|
indicator that route handlers consult instead of icon_data, so it must
|
|
be loaded on every default query."""
|
|
from backend.app.models.oidc_provider import OIDCProvider
|
|
|
|
await _seed_provider(db_session, name="IndicatorProv")
|
|
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "IndicatorProv"))
|
|
provider = result.scalar_one()
|
|
|
|
state = inspect(provider)
|
|
assert "icon_content_type" not in state.unloaded
|
|
# Direct access does not raise MissingGreenlet (it was already loaded).
|
|
assert provider.icon_content_type == "image/png"
|