mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-10 11:12:13 +02:00
* 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>
397 lines
14 KiB
Python
397 lines
14 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import ANY, AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from api.enums import WorkflowRunMode, WorkflowRunState
|
|
from api.errors.telephony_errors import TelephonyError
|
|
from api.routes.telephony import _handle_telephony_websocket, handle_inbound_run, router
|
|
from api.services.auth.depends import get_user
|
|
from api.services.call_concurrency import CallConcurrencyLimitError
|
|
|
|
|
|
def _make_test_app() -> FastAPI:
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
|
|
id=7,
|
|
selected_organization_id=11,
|
|
)
|
|
return app
|
|
|
|
|
|
def _workflow(*, workflow_id: int = 33, user_id: int = 99):
|
|
return SimpleNamespace(
|
|
id=workflow_id,
|
|
user_id=user_id,
|
|
organization_id=11,
|
|
template_context_variables={"template_key": "template-value"},
|
|
)
|
|
|
|
|
|
def _provider():
|
|
return SimpleNamespace(
|
|
PROVIDER_NAME="twilio",
|
|
WEBHOOK_ENDPOINT="twilio/voice",
|
|
validate_config=Mock(return_value=True),
|
|
initiate_call=AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
caller_number="+15550001111",
|
|
provider_metadata={"call_id": "call-123"},
|
|
)
|
|
),
|
|
)
|
|
|
|
|
|
def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
|
|
app = _make_test_app()
|
|
client = TestClient(app)
|
|
|
|
workflow = _workflow()
|
|
provider = _provider()
|
|
quota_mock = AsyncMock(
|
|
return_value=SimpleNamespace(has_quota=True, error_message="")
|
|
)
|
|
|
|
with (
|
|
patch("api.routes.telephony.db_client") as mock_db,
|
|
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
|
|
patch(
|
|
"api.routes.telephony.authorize_workflow_run_start",
|
|
new=quota_mock,
|
|
),
|
|
patch(
|
|
"api.routes.telephony.get_default_telephony_provider",
|
|
new=AsyncMock(return_value=provider),
|
|
),
|
|
patch(
|
|
"api.routes.telephony.get_backend_endpoints",
|
|
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
|
|
),
|
|
):
|
|
slot = object()
|
|
mock_concurrency.acquire_org_slot = AsyncMock(return_value=slot)
|
|
mock_concurrency.bind_workflow_run = AsyncMock()
|
|
mock_concurrency.release_slot = AsyncMock()
|
|
mock_concurrency.release_workflow_run_slot = AsyncMock()
|
|
|
|
mock_db.get_user_configurations = AsyncMock(
|
|
return_value=SimpleNamespace(test_phone_number=None)
|
|
)
|
|
mock_db.get_default_telephony_configuration = AsyncMock(
|
|
return_value=SimpleNamespace(id=55)
|
|
)
|
|
mock_db.get_workflow = AsyncMock(return_value=workflow)
|
|
mock_db.create_workflow_run = AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
id=501,
|
|
name="WR-TEL-OUT-00000001",
|
|
initial_context={"template_key": "template-value"},
|
|
)
|
|
)
|
|
mock_db.update_workflow_run = AsyncMock()
|
|
|
|
response = client.post(
|
|
"/telephony/initiate-call",
|
|
json={"workflow_id": workflow.id, "phone_number": "+15551234567"},
|
|
)
|
|
|
|
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,
|
|
)
|
|
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
|
|
|
|
create_call = mock_db.create_workflow_run.await_args
|
|
create_args = create_call.args
|
|
create_kwargs = create_call.kwargs
|
|
assert create_args[1] == workflow.id
|
|
assert create_kwargs["user_id"] == workflow.user_id
|
|
assert create_kwargs["organization_id"] == workflow.organization_id
|
|
assert create_kwargs["initial_context"]["template_key"] == "template-value"
|
|
mock_concurrency.acquire_org_slot.assert_awaited_once_with(
|
|
workflow.organization_id,
|
|
source="telephony_outbound",
|
|
timeout=0,
|
|
)
|
|
mock_concurrency.bind_workflow_run.assert_awaited_once_with(slot, 501)
|
|
|
|
initiate_kwargs = provider.initiate_call.await_args.kwargs
|
|
assert initiate_kwargs["workflow_id"] == workflow.id
|
|
assert initiate_kwargs["user_id"] == workflow.user_id
|
|
assert "user_id=99" in initiate_kwargs["webhook_url"]
|
|
mock_db.get_user_configurations.assert_not_called()
|
|
|
|
|
|
def test_initiate_call_uses_organization_preference_phone_number():
|
|
app = _make_test_app()
|
|
client = TestClient(app)
|
|
|
|
workflow = _workflow()
|
|
provider = _provider()
|
|
quota_mock = AsyncMock(
|
|
return_value=SimpleNamespace(has_quota=True, error_message="")
|
|
)
|
|
|
|
with (
|
|
patch("api.routes.telephony.db_client") as mock_db,
|
|
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
|
|
patch(
|
|
"api.routes.telephony.authorize_workflow_run_start",
|
|
new=quota_mock,
|
|
),
|
|
patch(
|
|
"api.routes.telephony.get_default_telephony_provider",
|
|
new=AsyncMock(return_value=provider),
|
|
),
|
|
patch(
|
|
"api.routes.telephony.get_backend_endpoints",
|
|
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
|
|
),
|
|
):
|
|
mock_concurrency.acquire_org_slot = AsyncMock(return_value=object())
|
|
mock_concurrency.bind_workflow_run = AsyncMock()
|
|
mock_concurrency.release_slot = AsyncMock()
|
|
mock_concurrency.release_workflow_run_slot = AsyncMock()
|
|
|
|
mock_db.get_user_configurations = AsyncMock(
|
|
return_value=SimpleNamespace(test_phone_number="+15550000000")
|
|
)
|
|
mock_db.get_configuration = Mock(
|
|
return_value=SimpleNamespace(value={"test_phone_number": "+15557654321"})
|
|
)
|
|
mock_db.get_default_telephony_configuration = AsyncMock(
|
|
return_value=SimpleNamespace(id=55)
|
|
)
|
|
mock_db.get_workflow = AsyncMock(return_value=workflow)
|
|
mock_db.create_workflow_run = AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
id=501,
|
|
name="WR-TEL-OUT-00000001",
|
|
initial_context={},
|
|
)
|
|
)
|
|
mock_db.update_workflow_run = AsyncMock()
|
|
|
|
response = client.post(
|
|
"/telephony/initiate-call",
|
|
json={"workflow_id": workflow.id},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert provider.initiate_call.await_args.kwargs["to_number"] == "+15557654321"
|
|
mock_db.get_user_configurations.assert_not_called()
|
|
|
|
|
|
def test_initiate_call_rejects_existing_run_for_different_workflow():
|
|
app = _make_test_app()
|
|
client = TestClient(app)
|
|
|
|
workflow = _workflow()
|
|
provider = _provider()
|
|
quota_mock = AsyncMock(
|
|
return_value=SimpleNamespace(has_quota=True, error_message="")
|
|
)
|
|
|
|
with (
|
|
patch("api.routes.telephony.db_client") as mock_db,
|
|
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
|
|
patch(
|
|
"api.routes.telephony.authorize_workflow_run_start",
|
|
new=quota_mock,
|
|
),
|
|
patch(
|
|
"api.routes.telephony.get_default_telephony_provider",
|
|
new=AsyncMock(return_value=provider),
|
|
),
|
|
):
|
|
mock_concurrency.acquire_org_slot = AsyncMock(return_value=object())
|
|
mock_concurrency.bind_workflow_run = AsyncMock()
|
|
mock_concurrency.release_slot = AsyncMock()
|
|
mock_concurrency.release_workflow_run_slot = AsyncMock()
|
|
|
|
mock_db.get_user_configurations = AsyncMock(
|
|
return_value=SimpleNamespace(test_phone_number=None)
|
|
)
|
|
mock_db.get_default_telephony_configuration = AsyncMock(
|
|
return_value=SimpleNamespace(id=55)
|
|
)
|
|
mock_db.get_workflow = AsyncMock(return_value=workflow)
|
|
mock_db.get_workflow_run = AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
id=501,
|
|
workflow_id=44,
|
|
name="WR-TEL-OUT-00000044",
|
|
initial_context={},
|
|
)
|
|
)
|
|
|
|
response = client.post(
|
|
"/telephony/initiate-call",
|
|
json={
|
|
"workflow_id": workflow.id,
|
|
"workflow_run_id": 501,
|
|
"phone_number": "+15551234567",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "workflow_run_workflow_mismatch"
|
|
mock_db.get_workflow_run.assert_awaited_once_with(501, organization_id=11)
|
|
mock_concurrency.release_slot.assert_awaited_once()
|
|
assert not mock_db.create_workflow_run.called
|
|
assert provider.initiate_call.await_count == 0
|
|
|
|
|
|
def test_initiate_call_rejects_when_concurrency_limit_reached():
|
|
app = _make_test_app()
|
|
client = TestClient(app)
|
|
|
|
workflow = _workflow()
|
|
provider = _provider()
|
|
|
|
with (
|
|
patch("api.routes.telephony.db_client") as mock_db,
|
|
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
|
|
patch(
|
|
"api.routes.telephony.get_default_telephony_provider",
|
|
new=AsyncMock(return_value=provider),
|
|
),
|
|
):
|
|
mock_concurrency.acquire_org_slot = AsyncMock(
|
|
side_effect=CallConcurrencyLimitError(
|
|
organization_id=workflow.organization_id,
|
|
source="telephony_outbound",
|
|
wait_time=0,
|
|
max_concurrent=1,
|
|
)
|
|
)
|
|
mock_db.get_default_telephony_configuration = AsyncMock(
|
|
return_value=SimpleNamespace(id=55)
|
|
)
|
|
mock_db.get_workflow = AsyncMock(return_value=workflow)
|
|
mock_db.create_workflow_run = AsyncMock()
|
|
|
|
response = client.post(
|
|
"/telephony/initiate-call",
|
|
json={"workflow_id": workflow.id, "phone_number": "+15551234567"},
|
|
)
|
|
|
|
assert response.status_code == 429
|
|
assert response.json()["detail"] == "Concurrent call limit reached"
|
|
mock_db.create_workflow_run.assert_not_called()
|
|
provider.initiate_call.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_run_rejects_when_concurrency_limit_reached():
|
|
request = SimpleNamespace(headers={}, url="https://api.example.com/inbound/run")
|
|
provider_class = SimpleNamespace(
|
|
PROVIDER_NAME="twilio",
|
|
generate_validation_error_response=Mock(return_value="limit-response"),
|
|
)
|
|
normalized_data = SimpleNamespace(
|
|
provider="twilio",
|
|
direction="inbound",
|
|
to_number="+15551230000",
|
|
from_number="+15557650000",
|
|
to_country="US",
|
|
from_country="US",
|
|
account_id="acct-1",
|
|
call_id="call-1",
|
|
raw_data={},
|
|
)
|
|
config = SimpleNamespace(id=55, organization_id=11)
|
|
phone_row = SimpleNamespace(id=77, inbound_workflow_id=33)
|
|
workflow = SimpleNamespace(id=33, user_id=99)
|
|
provider_instance = SimpleNamespace(
|
|
verify_inbound_signature=AsyncMock(return_value=True)
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"api.routes.telephony.parse_webhook_request",
|
|
new=AsyncMock(return_value=({}, "raw-body")),
|
|
),
|
|
patch(
|
|
"api.routes.telephony._detect_provider",
|
|
new=AsyncMock(return_value=provider_class),
|
|
),
|
|
patch(
|
|
"api.routes.telephony.normalize_webhook_data",
|
|
return_value=normalized_data,
|
|
),
|
|
patch("api.routes.telephony.db_client") as mock_db,
|
|
patch(
|
|
"api.routes.telephony.get_telephony_provider_by_id",
|
|
new=AsyncMock(return_value=provider_instance),
|
|
),
|
|
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
|
|
):
|
|
mock_db.find_inbound_route_by_account = AsyncMock(
|
|
return_value=(config, phone_row)
|
|
)
|
|
mock_db.get_workflow = AsyncMock(return_value=workflow)
|
|
mock_db.create_workflow_run = AsyncMock()
|
|
mock_concurrency.acquire_org_slot = AsyncMock(
|
|
side_effect=CallConcurrencyLimitError(
|
|
organization_id=config.organization_id,
|
|
source="inbound:twilio",
|
|
wait_time=0,
|
|
max_concurrent=1,
|
|
)
|
|
)
|
|
|
|
response = await handle_inbound_run(request)
|
|
|
|
assert response == "limit-response"
|
|
provider_class.generate_validation_error_response.assert_called_once_with(
|
|
TelephonyError.CONCURRENT_CALL_LIMIT
|
|
)
|
|
mock_db.create_workflow_run.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_running():
|
|
websocket = AsyncMock()
|
|
workflow_run = SimpleNamespace(
|
|
id=501,
|
|
workflow_id=33,
|
|
mode=WorkflowRunMode.SMALLWEBRTC.value,
|
|
state=WorkflowRunState.INITIALIZED.value,
|
|
initial_context={},
|
|
gathered_context={},
|
|
)
|
|
workflow = SimpleNamespace(id=33, organization_id=11, user_id=99)
|
|
provider_lookup = AsyncMock()
|
|
|
|
with (
|
|
patch("api.routes.telephony.db_client") as mock_db,
|
|
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
|
|
patch(
|
|
"api.routes.telephony.get_telephony_provider_for_run",
|
|
new=provider_lookup,
|
|
),
|
|
):
|
|
mock_concurrency.unregister_active_call = AsyncMock()
|
|
mock_db.get_workflow_run = AsyncMock(return_value=workflow_run)
|
|
mock_db.get_workflow_by_id = AsyncMock(return_value=workflow)
|
|
mock_db.update_workflow_run = AsyncMock()
|
|
|
|
await _handle_telephony_websocket(websocket, 33, 99, 501)
|
|
|
|
websocket.close.assert_awaited_once_with(
|
|
code=4400,
|
|
reason=(
|
|
"smallwebrtc runs connect through the WebRTC signaling endpoint, "
|
|
"not the telephony websocket"
|
|
),
|
|
)
|
|
assert mock_db.update_workflow_run.await_count == 0
|
|
assert provider_lookup.await_count == 0
|
|
mock_concurrency.unregister_active_call.assert_not_awaited()
|