mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
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:
parent
041c31a613
commit
fb4038a969
59 changed files with 3531 additions and 517 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
148
api/tests/test_backfill_org_model_configuration_v2_migration.py
Normal file
148
api/tests/test_backfill_org_model_configuration_v2_migration.py
Normal 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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
99
api/tests/test_user_configuration_validation.py
Normal file
99
api/tests/test_user_configuration_validation.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue