|
|
@@ -0,0 +1,137 @@
|
|
|
+"""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)
|