Files
bambuddy/backend/tests/integration/test_oidc_env_lock.py
T
Marian c9ee259807 feat(oidc): refuse API writes to the env-managed provider
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so an edit
through the UI would be accepted and then silently reverted at the next
restart -- the operator would watch their change vanish with nothing
explaining why. A 409 says so instead.

Covers all four mutating routes, including the two icon ones: the icon comes
from BAMBUDDY_OIDC_ICON_URL and would be restored the same way. Extracted as
one helper rather than four copies of the same check, so a fifth route cannot
be added with the guard silently missing.

Locking it is safe because BAMBUDDY_LOCAL_LOGIN (#1589) remains the documented
recovery path if the provider itself becomes unusable. A test pins that
UI-created providers stay editable -- the lock must not leak onto them.

Refs #2593
2026-07-28 21:05:29 +00:00

138 lines
4.6 KiB
Python

"""The env-managed provider is read-only through the API (#2593).
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
would silently disappear at the next restart -- the operator would see their
change accepted and then reverted, with nothing explaining why. Refusing the
write is the honest answer.
Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
recovery path if the provider itself becomes unusable.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from backend.app.models.oidc_provider import OIDCProvider
from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
async def _env_managed_provider(db_session) -> int:
provider = OIDCProvider(
name="Env Keycloak",
issuer_url="https://sso.example.com/realms/main",
client_id="bambuddy",
icon_url="https://sso.example.com/logo.png",
is_env_managed=True,
)
provider.client_secret = "s3cr3t"
db_session.add(provider)
await db_session.commit()
await db_session.refresh(provider)
return provider.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_put_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockput", "envlockput123")
response = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"name": "hijacked"},
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
response = await async_client.delete(
f"/api/v1/auth/oidc/providers/{provider_id}",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
"""The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
response = await async_client.delete(
f"/api/v1/auth/oidc/providers/{provider_id}/icon",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
response = await async_client.post(
f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
"""The lock must not leak onto providers the operator created themselves --
they coexist with the env one and stay fully editable."""
token = await _setup_and_login(async_client, "envlockui", "envlockui123")
created = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "UI provider",
"issuer_url": "https://other.example.com",
"client_id": "ui",
"client_secret": "ui-secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
},
headers=_auth_header(token),
)
provider_id = created.json()["id"]
response = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"name": "Renamed"},
headers=_auth_header(token),
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
"""The frontend needs this to render the lock; without it the UI would show
editable fields whose writes the API then refuses."""
await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
assert response.status_code == 200
providers = response.json()
assert any(p["is_env_managed"] for p in providers)