fix: fix org scoped access for resources (#517)

* fix: fix org scoped access for resources

* Fix auth and config validation regressions

* fix: track org config validation timestamp

* fix: backfill org model configuration v2 from legacy user rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align config tests with org-level v2 resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: helm example values tweaks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-09 23:04:33 +05:30 committed by GitHub
parent 041c31a613
commit fb4038a969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3531 additions and 517 deletions

View file

@ -195,7 +195,11 @@ async def create_workflow_run_rows(
Returns:
Tuple of (workflow_run, user, workflow).
"""
from api.enums import OrganizationConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
)
org = OrganizationModel(provider_id=f"test-org-{provider_id_suffix}")
async_session.add(org)
@ -208,9 +212,16 @@ async def create_workflow_run_rows(
async_session.add(user)
await async_session.flush()
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(

View file

@ -15,9 +15,11 @@ from api.services.configuration.ai_model_configuration import (
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY,
check_for_masked_keys_in_ai_model_configuration_v2,
convert_legacy_ai_model_configuration_to_v2,
get_resolved_ai_model_configuration,
mask_ai_model_configuration_v2,
merge_ai_model_configuration_v2_secrets,
migrate_workflow_configuration_model_override_to_v2,
upsert_organization_ai_model_configuration_v2,
)
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.masking import mask_key
@ -148,6 +150,83 @@ async def test_byok_realtime_validator_does_not_require_stt_or_tts():
}
@pytest.mark.asyncio
async def test_resolved_org_v2_uses_last_validated_at_as_validation_cache(
monkeypatch,
):
from datetime import UTC, datetime
from api.services.configuration import ai_model_configuration
last_validated_at = datetime.now(UTC)
config = OrganizationAIModelConfigurationV2(
mode="dograh",
dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"),
)
row = SimpleNamespace(
value=config.model_dump(mode="json", exclude_none=True),
last_validated_at=last_validated_at,
)
monkeypatch.setattr(
ai_model_configuration.db_client,
"get_configuration",
AsyncMock(return_value=row),
)
resolved = await get_resolved_ai_model_configuration(organization_id=42)
assert resolved.source == "organization_v2"
assert resolved.effective.last_validated_at == last_validated_at
@pytest.mark.asyncio
async def test_upsert_org_v2_marks_configuration_validated(monkeypatch):
from api.services.configuration import ai_model_configuration
config = OrganizationAIModelConfigurationV2(
mode="dograh",
dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"),
)
upsert = AsyncMock()
monkeypatch.setattr(
ai_model_configuration.db_client,
"upsert_configuration",
upsert,
)
result = await upsert_organization_ai_model_configuration_v2(42, config)
assert result is config
upsert.assert_awaited_once()
args = upsert.await_args.args
kwargs = upsert.await_args.kwargs
assert args == (
42,
"MODEL_CONFIGURATION_V2",
config.model_dump(mode="json", exclude_none=True),
)
assert kwargs["last_validated_at"].tzinfo is not None
def test_org_config_validation_timestamp_update_does_not_touch_updated_at():
from datetime import UTC, datetime
from sqlalchemy import update
from sqlalchemy.dialects import postgresql
from api.db.models import OrganizationConfigurationModel
stmt = (
update(OrganizationConfigurationModel)
.where(OrganizationConfigurationModel.id == 1)
.values(last_validated_at=datetime.now(UTC))
)
compiled = str(stmt.compile(dialect=postgresql.dialect()))
assert "last_validated_at" in compiled
assert "updated_at" not in compiled
@pytest.mark.asyncio
async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime():
effective = EffectiveAIModelConfiguration(

View file

@ -0,0 +1,148 @@
"""Tests for the 00b0201ad918 backfill migration's frozen conversion.
The migration writes MODEL_CONFIGURATION_V2 JSON directly, so its output must
keep parsing with OrganizationAIModelConfigurationV2 these tests fail if the
schema drifts away from what the migration produces.
"""
import importlib.util
from pathlib import Path
from api.schemas.ai_model_configuration import (
OrganizationAIModelConfigurationV2,
compile_ai_model_configuration_v2,
)
_MIGRATION_PATH = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "00b0201ad918_backfill_org_model_configuration_v2.py"
)
_spec = importlib.util.spec_from_file_location(
"backfill_org_model_configuration_v2", _MIGRATION_PATH
)
migration = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(migration)
def _parse_and_compile(value: dict) -> None:
compile_ai_model_configuration_v2(
OrganizationAIModelConfigurationV2.model_validate(value)
)
def test_dograh_legacy_converts_to_managed_mode():
value = migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "dograh", "api_key": "mps-key", "model": "default"},
"tts": {
"provider": "dograh",
"api_key": "mps-key",
"voice": "aura",
"speed": 1.2,
},
"stt": {"provider": "dograh", "api_key": "mps-key", "language": "en"},
}
)
assert value["mode"] == "dograh"
assert value["dograh"] == {
"api_key": "mps-key",
"voice": "aura",
"speed": 1.2,
"language": "en",
}
_parse_and_compile(value)
def test_dograh_mode_wins_over_byok_sections_and_sanitizes_speed():
value = migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "dograh", "api_key": ["mps-key"]},
"tts": {"provider": "elevenlabs", "api_key": "el-key", "speed": 9.0},
"stt": {"provider": "deepgram", "api_key": "dg-key"},
}
)
assert value["mode"] == "dograh"
assert value["dograh"]["api_key"] == "mps-key"
assert value["dograh"]["speed"] == 1.0
_parse_and_compile(value)
def test_byok_pipeline_legacy_converts_and_validates():
value = migration.convert_legacy_configuration_to_v2(
{
"llm": {
"provider": "openai",
"api_key": "sk-test",
"model": "gpt-4.1-mini",
"temperature": 0.5,
},
"tts": {
"provider": "elevenlabs",
"api_key": "el-key",
"model": "eleven_flash_v2_5",
"voice": "voice-id",
},
"stt": {
"provider": "deepgram",
"api_key": "dg-key",
"model": "nova-2-phonecall",
},
}
)
assert value["mode"] == "byok"
assert value["byok"]["mode"] == "pipeline"
assert "embeddings" not in value["byok"]["pipeline"]
_parse_and_compile(value)
def test_realtime_legacy_converts_to_byok_realtime():
value = migration.convert_legacy_configuration_to_v2(
{
"is_realtime": True,
"realtime": {
"provider": "google_realtime",
"api_key": "google-key",
"model": "gemini-3.1-flash-live-preview",
"voice": "Puck",
"language": "en",
},
"llm": {
"provider": "google",
"api_key": "google-key",
"model": "gemini-2.5-flash",
},
}
)
assert value["mode"] == "byok"
assert value["byok"]["mode"] == "realtime"
_parse_and_compile(value)
def test_incomplete_pipeline_legacy_is_skipped():
assert (
migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "openai", "api_key": "sk-test"},
"tts": {"provider": "elevenlabs", "api_key": "el-key"},
}
)
is None
)
assert migration.convert_legacy_configuration_to_v2({}) is None
def test_dograh_provider_without_single_key_cannot_become_byok():
# Multiple dograh keys can't map to managed mode, and BYOK rejects the
# dograh provider — the org must be skipped rather than written broken.
assert (
migration.convert_legacy_configuration_to_v2(
{
"llm": {"provider": "dograh", "api_key": ["key-a", "key-b"]},
"tts": {"provider": "elevenlabs", "api_key": "el-key"},
"stt": {"provider": "deepgram", "api_key": "dg-key"},
}
)
is None
)

View file

@ -1,3 +1,4 @@
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@ -7,20 +8,25 @@ from fastapi.testclient import TestClient
from api.routes.user import router
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
ResolvedAIModelConfiguration,
)
from api.services.configuration.masking import mask_key
from api.services.configuration.registry import (
GoogleLLMService,
DeepgramSTTConfiguration,
GoogleVertexLLMConfiguration,
OpenAILLMService,
OpenAITTSService,
)
def _make_test_app(selected_organization_id=None):
def _make_test_app(selected_organization_id=11):
app = FastAPI()
app.include_router(router)
mock_user = MagicMock()
mock_user.id = 1
mock_user.provider_id = "provider-1"
mock_user.is_superuser = False
mock_user.selected_organization_id = selected_organization_id
@ -38,28 +44,63 @@ def _existing_openai_config():
provider="openai",
api_key=REAL_KEY,
model="gpt-4.1",
)
),
tts=OpenAITTSService(
provider="openai",
api_key=REAL_KEY,
model="gpt-4o-mini-tts",
voice="alloy",
),
stt=DeepgramSTTConfiguration(
provider="deepgram",
api_key=REAL_KEY,
model="nova-3-general",
),
)
@contextmanager
def _patch_config_update(existing_config=None):
existing_config = existing_config or _existing_openai_config()
preferences = SimpleNamespace(test_phone_number=None, timezone=None)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
patch(
"api.routes.user.get_resolved_ai_model_configuration",
new=AsyncMock(
return_value=ResolvedAIModelConfiguration(
effective=existing_config,
source="organization_v2",
)
),
),
patch(
"api.routes.user.upsert_organization_ai_model_configuration_v2",
new=AsyncMock(),
) as upsert_config,
patch(
"api.routes.user.get_organization_preferences",
new=AsyncMock(return_value=preferences),
),
):
mock_db.get_organization_by_id = AsyncMock(return_value=None)
mock_validator.return_value.validate = AsyncMock()
yield SimpleNamespace(
db=mock_db,
validator=mock_validator,
upsert_config=upsert_config,
)
class TestMaskedKeyRejection:
def test_rejects_masked_api_key_on_provider_change(self):
"""Changing provider with a masked API key should return 400."""
app = _make_test_app()
client = TestClient(app)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(
return_value=_existing_openai_config()
)
mock_db.update_user_configuration = AsyncMock(
side_effect=lambda uid, cfg: cfg
)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update():
response = client.put(
"/user/configurations/user",
json={
@ -79,18 +120,7 @@ class TestMaskedKeyRejection:
app = _make_test_app()
client = TestClient(app)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(
return_value=_existing_openai_config()
)
mock_db.update_user_configuration = AsyncMock(
side_effect=lambda uid, cfg: cfg
)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update():
response = client.put(
"/user/configurations/user",
json={
@ -111,24 +141,7 @@ class TestMaskedKeyRejection:
client = TestClient(app)
new_key = "AIzaSyNewRealKey12345678"
updated = EffectiveAIModelConfiguration(
llm=GoogleLLMService(
provider="google",
api_key=new_key,
model="gemini-2.5-flash",
)
)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(
return_value=_existing_openai_config()
)
mock_db.update_user_configuration = AsyncMock(return_value=updated)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update() as patched:
response = client.put(
"/user/configurations/user",
json={
@ -141,21 +154,14 @@ class TestMaskedKeyRejection:
)
assert response.status_code == 200
patched.upsert_config.assert_awaited_once()
def test_allows_same_provider_with_masked_key(self):
"""Same provider with masked key should succeed (merge resolves it)."""
app = _make_test_app()
client = TestClient(app)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
existing = _existing_openai_config()
mock_db.get_user_configurations = AsyncMock(return_value=existing)
mock_db.update_user_configuration = AsyncMock(return_value=existing)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update() as patched:
response = client.put(
"/user/configurations/user",
json={
@ -170,6 +176,7 @@ class TestMaskedKeyRejection:
# Merge resolves the masked key back to the real one,
# so check_for_masked_keys should NOT raise.
assert response.status_code == 200
patched.upsert_config.assert_awaited_once()
def test_allows_same_provider_with_masked_vertex_credentials(self):
"""Same provider with masked credentials should succeed."""
@ -186,17 +193,21 @@ class TestMaskedKeyRejection:
project_id="demo-project",
location="us-east4",
credentials=real_credentials,
)
),
tts=OpenAITTSService(
provider="openai",
api_key=REAL_KEY,
model="gpt-4o-mini-tts",
voice="alloy",
),
stt=DeepgramSTTConfiguration(
provider="deepgram",
api_key=REAL_KEY,
model="nova-3-general",
),
)
with (
patch("api.routes.user.db_client") as mock_db,
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
):
mock_db.get_user_configurations = AsyncMock(return_value=existing)
mock_db.update_user_configuration = AsyncMock(return_value=existing)
mock_validator.return_value.validate = AsyncMock()
with _patch_config_update(existing) as patched:
response = client.put(
"/user/configurations/user",
json={
@ -211,6 +222,7 @@ class TestMaskedKeyRejection:
)
assert response.status_code == 200
patched.upsert_config.assert_awaited_once()
def test_preference_only_update_does_not_validate_or_save_model_config(self):
"""Saving a test phone number through the legacy endpoint must not touch models."""
@ -229,6 +241,15 @@ class TestMaskedKeyRejection:
"api.routes.user.upsert_organization_preferences",
new=AsyncMock(return_value=preferences),
) as upsert_preferences,
patch(
"api.routes.user.get_resolved_ai_model_configuration",
new=AsyncMock(
return_value=ResolvedAIModelConfiguration(
effective=_existing_openai_config(),
source="organization_v2",
)
),
),
):
existing = _existing_openai_config()
mock_db.get_user_configurations = AsyncMock(return_value=existing)

View file

@ -102,6 +102,7 @@ def test_trigger_route_executes_as_workflow_owner():
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
)
mock_concurrency.acquire_org_slot.assert_awaited_once_with(

View file

@ -26,7 +26,12 @@ def _embed_session():
def _embed_token(allowed_domains):
return SimpleNamespace(allowed_domains=allowed_domains, created_by=7, workflow_id=3)
return SimpleNamespace(
allowed_domains=allowed_domains,
created_by=7,
workflow_id=3,
organization_id=11,
)
def _patch_deps():
@ -35,6 +40,7 @@ def _patch_deps():
mgr = patch("api.routes.webrtc_signaling.signaling_manager").start()
db.get_embed_session_by_token = AsyncMock(return_value=_embed_session())
db.get_embed_token_by_id = AsyncMock(return_value=_embed_token(["example.com"]))
db.get_workflow_run = AsyncMock(return_value=SimpleNamespace(workflow_id=3))
db.get_user_by_id = AsyncMock(return_value=SimpleNamespace(id=7))
mgr.handle_websocket = AsyncMock()
return db, mgr

View file

@ -9,6 +9,8 @@ from api.services.configuration.registry import ServiceProviders
from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY
from api.services.quota_service import QuotaCheckResult
_UNSET = object()
def _dograh_config(
api_key: str = "mps_sk_12345678",
@ -58,11 +60,17 @@ def _actor():
)
def _patch_workflow_context(monkeypatch, *, workflow=None, owner=None):
def _patch_workflow_context(monkeypatch, *, workflow=_UNSET, owner=None):
workflow_value = _workflow() if workflow is _UNSET else workflow
monkeypatch.setattr(
quota_service.db_client,
"get_workflow",
AsyncMock(return_value=workflow_value),
)
monkeypatch.setattr(
quota_service.db_client,
"get_workflow_by_id",
AsyncMock(return_value=workflow or _workflow()),
AsyncMock(side_effect=AssertionError("quota must not use unscoped workflow")),
)
monkeypatch.setattr(
quota_service.db_client,
@ -103,11 +111,14 @@ async def test_authorize_workflow_run_uses_workflow_org_for_hosted_v2(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is True
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=42)
get_config.assert_awaited_once_with(
user_id=123,
organization_id=42,
workflow_configurations={"model_overrides": {}},
)
@ -156,7 +167,10 @@ async def test_authorize_workflow_run_v2_insufficient_credits_prompts_billing(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is False
assert result.error_code == "insufficient_credits"
@ -189,11 +203,14 @@ async def test_authorize_workflow_run_oss_exhausted_key_blocks_run(
check_usage,
)
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
)
assert result.has_quota is False
assert result.error_code == "quota_exceeded"
assert "founders@dograh.com" in result.error_message
assert "app.dograh.com" in result.error_message
assert "/billing" not in result.error_message
check_usage.assert_awaited_once_with(api_key)
@ -247,6 +264,7 @@ async def test_authorize_workflow_run_managed_v2_stores_hosted_correlation(
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -314,6 +332,7 @@ async def test_authorize_workflow_run_service_token_from_wrong_org_prompts_new_t
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -383,6 +402,7 @@ async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
workflow_run_id=88,
)
@ -411,6 +431,7 @@ async def test_authorize_workflow_run_rejects_actor_not_a_member(monkeypatch):
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
)
@ -430,6 +451,7 @@ async def test_authorize_workflow_run_membership_lookup_error_fails_closed(monke
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=42),
)
@ -464,6 +486,7 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
# but is_user_member_of_organization returns True so the run should be allowed.
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=42,
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
)
@ -471,16 +494,9 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
@pytest.mark.asyncio
async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkeypatch):
"""Personal/legacy workflows (organization_id=None) bypass membership check."""
personal_workflow = SimpleNamespace(
id=7,
user_id=123,
organization_id=None,
workflow_configurations={"model_overrides": {}},
)
async def test_authorize_workflow_run_requires_organization_scope(monkeypatch):
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch, workflow=personal_workflow)
_patch_workflow_context(monkeypatch)
is_member_mock = AsyncMock()
monkeypatch.setattr(
quota_service.db_client,
@ -500,8 +516,35 @@ async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkey
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=None,
actor_user=SimpleNamespace(id=456),
)
assert result.has_quota is True
assert result.has_quota is False
assert result.error_code == "workflow_not_found"
quota_service.db_client.get_workflow.assert_not_awaited()
is_member_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_authorize_workflow_run_rejects_workflow_outside_org(monkeypatch):
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch, workflow=None)
is_member_mock = AsyncMock()
monkeypatch.setattr(
quota_service.db_client,
"is_user_member_of_organization",
is_member_mock,
)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
organization_id=99,
actor_user=SimpleNamespace(id=456),
)
assert result.has_quota is False
assert result.error_code == "workflow_not_found"
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=99)
is_member_mock.assert_not_awaited()
quota_service.db_client.get_user_by_id.assert_not_awaited()

View file

@ -1,8 +1,8 @@
from api.services.pipecat.realtime_feedback_events import (
build_bot_text_event,
build_function_call_end_event,
build_user_transcription_event,
build_node_transition_event,
build_user_transcription_event,
realtime_feedback_event_sort_key,
stamp_realtime_feedback_event,
)

View file

@ -1,5 +1,5 @@
from types import SimpleNamespace
import re
from types import SimpleNamespace
import pytest
from pipecat.frames.frames import (

View file

@ -101,6 +101,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
actor_user=ANY,
)
@ -366,7 +367,7 @@ async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_runni
initial_context={},
gathered_context={},
)
workflow = SimpleNamespace(id=33, organization_id=11)
workflow = SimpleNamespace(id=33, organization_id=11, user_id=99)
provider_lookup = AsyncMock()
with (

View file

@ -0,0 +1,99 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.routes import user as user_routes
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.ai_model_configuration import (
ResolvedAIModelConfiguration,
)
@pytest.mark.asyncio
async def test_validate_user_configurations_marks_stale_org_v2_config_validated(
monkeypatch,
):
stale_config = EffectiveAIModelConfiguration(
last_validated_at=datetime.now(UTC) - timedelta(seconds=120)
)
resolved = ResolvedAIModelConfiguration(
effective=stale_config,
source="organization_v2",
)
validate = AsyncMock(return_value={"status": [{"model": "all", "message": "ok"}]})
touch_validation_cache = AsyncMock()
class FakeValidator:
def __init__(self):
self.validate = validate
monkeypatch.setattr(
user_routes,
"get_resolved_ai_model_configuration",
AsyncMock(return_value=resolved),
)
monkeypatch.setattr(user_routes, "UserConfigurationValidator", FakeValidator)
monkeypatch.setattr(
user_routes,
"update_organization_ai_model_configuration_last_validated_at",
touch_validation_cache,
)
response = await user_routes.validate_user_configurations(
validity_ttl_seconds=60,
user=SimpleNamespace(
provider_id="provider-123",
selected_organization_id=42,
),
)
assert response == {"status": [{"model": "all", "message": "ok"}]}
validate.assert_awaited_once_with(
stale_config,
organization_id=42,
created_by="provider-123",
)
touch_validation_cache.assert_awaited_once_with(42)
@pytest.mark.asyncio
async def test_validate_user_configurations_uses_fresh_org_v2_validation_cache(
monkeypatch,
):
fresh_config = EffectiveAIModelConfiguration(last_validated_at=datetime.now(UTC))
resolved = ResolvedAIModelConfiguration(
effective=fresh_config,
source="organization_v2",
)
validate = AsyncMock()
touch_validation_cache = AsyncMock()
class FakeValidator:
def __init__(self):
self.validate = validate
monkeypatch.setattr(
user_routes,
"get_resolved_ai_model_configuration",
AsyncMock(return_value=resolved),
)
monkeypatch.setattr(user_routes, "UserConfigurationValidator", FakeValidator)
monkeypatch.setattr(
user_routes,
"update_organization_ai_model_configuration_last_validated_at",
touch_validation_cache,
)
response = await user_routes.validate_user_configurations(
validity_ttl_seconds=60,
user=SimpleNamespace(
provider_id="provider-123",
selected_organization_id=42,
),
)
assert response == {"status": []}
validate.assert_not_awaited()
touch_validation_cache.assert_not_awaited()

View file

@ -61,6 +61,7 @@ async def test_public_embed_offer_rejects_when_org_concurrency_limit_reached():
workflow_id=33,
workflow_run_id=501,
user=user,
organization_id=11,
connection_key="conn-1",
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
@ -107,6 +108,7 @@ async def test_public_embed_renegotiation_does_not_acquire_another_slot():
workflow_id=33,
workflow_run_id=501,
user=user,
organization_id=11,
connection_key=connection_key,
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
@ -126,7 +128,7 @@ async def test_signaling_websocket_rejects_run_not_owned_by_workflow():
from api.routes.webrtc_signaling import signaling_websocket
ws = _FakeWebSocket()
user = SimpleNamespace(id=7)
user = SimpleNamespace(id=7, selected_organization_id=11)
with (
patch("api.routes.webrtc_signaling.db_client") as mock_db,
@ -151,7 +153,7 @@ async def test_signaling_websocket_accepts_matching_workflow_and_run():
from api.routes.webrtc_signaling import signaling_websocket
ws = _FakeWebSocket()
user = SimpleNamespace(id=7)
user = SimpleNamespace(id=7, selected_organization_id=11)
with (
patch("api.routes.webrtc_signaling.db_client") as mock_db,

View file

@ -5,8 +5,12 @@ from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMSpecificMessage
from api.db.models import OrganizationModel, UserModel
from api.db.models import OrganizationModel, UserModel, organization_users_association
from api.enums import OrganizationConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
)
from api.services.workflow.text_chat_runner import (
_deserialize_text_chat_checkpoint_messages,
_serialize_text_chat_checkpoint_messages,
@ -84,10 +88,23 @@ async def _create_user_and_workflow(
)
async_session.add(user)
await async_session.flush()
await async_session.execute(
organization_users_association.insert().values(
user_id=user.id,
organization_id=org.id,
)
)
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(
@ -1079,10 +1096,23 @@ async def test_text_chat_session_creation_requires_selected_org_scope(
)
async_session.add(user)
await async_session.flush()
await async_session.execute(
organization_users_association.insert().values(
user_id=user.id,
organization_id=org_a.id,
)
)
await db_session.update_user_configuration(
user_id=user.id,
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
user_configuration = EffectiveAIModelConfiguration.model_validate(
USER_CONFIGURATION
)
await db_session.upsert_configuration(
org_a.id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
mode="json",
exclude_none=True,
),
)
workflow = await db_session.create_workflow(

View file

@ -42,7 +42,9 @@ def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
@ -69,7 +71,9 @@ def test_create_xai_tts_service_converts_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -91,7 +95,9 @@ def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -113,7 +119,9 @@ def test_create_xai_tts_service_preserves_auto_language():
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
with patch(
"api.services.pipecat.service_factory.XAIHttpTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
@ -127,25 +135,20 @@ def test_xai_is_registered_for_key_validation():
def test_xai_key_validation_accepts_valid_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 200
assert validator._check_xai_api_key("xai", "xai-valid-key") is True
# Validates against the TTS-scoped voices endpoint, not /v1/models.
called_url = mock_get.call_args.args[0]
assert called_url == "https://api.x.ai/v1/tts/voices"
assert (
mock_get.call_args.kwargs["headers"]["Authorization"]
== "Bearer xai-valid-key"
mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer xai-valid-key"
)
def test_xai_key_validation_rejects_bad_key():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 401
with pytest.raises(ValueError):
validator._check_xai_api_key("xai", "bad-key")
@ -153,8 +156,6 @@ def test_xai_key_validation_rejects_bad_key():
def test_xai_key_validation_allows_scoped_key_without_voice_list_access():
validator = UserConfigurationValidator()
with patch(
"api.services.configuration.check_validity.httpx.get"
) as mock_get:
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
mock_get.return_value.status_code = 403
assert validator._check_xai_api_key("xai", "tts-scoped-key") is True