mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
Merge remote-tracking branch 'dograh-main' into fix/378-disable-duplicate-trigger-nodes
This commit is contained in:
commit
af5818d63a
26 changed files with 1022 additions and 311 deletions
|
|
@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, patch
|
|||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from api.services.telephony.providers.vobiz.provider import VobizProvider
|
||||
from api.services.telephony.providers.vobiz.routes import (
|
||||
handle_vobiz_hangup_callback,
|
||||
handle_vobiz_hangup_callback_by_workflow,
|
||||
handle_vobiz_ring_callback,
|
||||
)
|
||||
|
||||
|
|
@ -225,3 +227,154 @@ async def test_vobiz_verify_inbound_signature_rejects_missing_signature():
|
|||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vobiz_hangup_callback_rejects_missing_signature():
|
||||
"""An unsigned hangup callback must be rejected before status processing."""
|
||||
provider = _provider()
|
||||
form_data = {
|
||||
"CallUUID": "call-123",
|
||||
"CallStatus": "completed",
|
||||
"From": "15551230001",
|
||||
"To": "15551230002",
|
||||
"Direction": "outbound",
|
||||
"Duration": "12",
|
||||
}
|
||||
# No x-vobiz-signature-* headers — the callback is unsigned.
|
||||
request = _request(
|
||||
path="/api/v1/telephony/vobiz/hangup-callback/123",
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("https://example.test", "wss://example.test"),
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes._process_status_update",
|
||||
new_callable=AsyncMock,
|
||||
) as process_status,
|
||||
):
|
||||
db_client.get_workflow_run_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(workflow_id=7)
|
||||
)
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await handle_vobiz_hangup_callback(
|
||||
workflow_run_id=123,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
process_status.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vobiz_ring_callback_rejects_missing_signature():
|
||||
"""An unsigned ring callback must be rejected before it is logged."""
|
||||
provider = _provider()
|
||||
form_data = {
|
||||
"CallUUID": "call-123",
|
||||
"CallStatus": "ringing",
|
||||
"From": "15551230001",
|
||||
"To": "15551230002",
|
||||
}
|
||||
# No x-vobiz-signature-* headers — the callback is unsigned.
|
||||
request = _request(
|
||||
path="/api/v1/telephony/vobiz/ring-callback/123",
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
workflow_run = SimpleNamespace(workflow_id=7, logs={})
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("https://example.test", "wss://example.test"),
|
||||
),
|
||||
):
|
||||
db_client.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
db_client.update_workflow_run = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await handle_vobiz_ring_callback(
|
||||
workflow_run_id=123,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
db_client.update_workflow_run.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vobiz_hangup_callback_by_workflow_rejects_missing_signature():
|
||||
"""An unsigned by-workflow hangup callback must be rejected before processing."""
|
||||
provider = _provider()
|
||||
form_data = {
|
||||
"CallUUID": "call-123",
|
||||
"CallStatus": "completed",
|
||||
"From": "15551230001",
|
||||
"To": "15551230002",
|
||||
"Direction": "outbound",
|
||||
"Duration": "12",
|
||||
}
|
||||
# No x-vobiz-signature-* headers — the callback is unsigned.
|
||||
request = _request(
|
||||
path="/api/v1/telephony/vobiz/hangup-callback/workflow/7",
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes.get_backend_endpoints",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("https://example.test", "wss://example.test"),
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.vobiz.routes._process_status_update",
|
||||
new_callable=AsyncMock,
|
||||
) as process_status,
|
||||
):
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
db_client.get_workflow_run_by_call_id = AsyncMock(
|
||||
return_value=SimpleNamespace(id=123, workflow_id=7)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await handle_vobiz_hangup_callback_by_workflow(
|
||||
workflow_id=7,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
process_status.assert_not_awaited()
|
||||
|
|
|
|||
66
api/tests/test_pre_call_fetch.py
Normal file
66
api/tests/test_pre_call_fetch.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from api.services.pipecat.pre_call_fetch import _extract_initial_context
|
||||
|
||||
|
||||
class TestExtractInitialContext:
|
||||
"""Tests for _extract_initial_context, the pre-call fetch response parser."""
|
||||
|
||||
def test_initial_context_nested_under_call_inbound(self):
|
||||
"""The canonical `initial_context` key nested under `call_inbound`."""
|
||||
response = {"call_inbound": {"initial_context": {"customer_name": "Jane"}}}
|
||||
assert _extract_initial_context(response) == {"customer_name": "Jane"}
|
||||
|
||||
def test_initial_context_at_top_level(self):
|
||||
"""The canonical `initial_context` key at the top level."""
|
||||
response = {"initial_context": {"customer_name": "Jane"}}
|
||||
assert _extract_initial_context(response) == {"customer_name": "Jane"}
|
||||
|
||||
def test_legacy_dynamic_variables_nested(self):
|
||||
"""The legacy `dynamic_variables` key still works nested under `call_inbound`."""
|
||||
response = {"call_inbound": {"dynamic_variables": {"customer_name": "Jane"}}}
|
||||
assert _extract_initial_context(response) == {"customer_name": "Jane"}
|
||||
|
||||
def test_legacy_dynamic_variables_at_top_level(self):
|
||||
"""The legacy `dynamic_variables` key still works at the top level."""
|
||||
response = {"dynamic_variables": {"customer_name": "Jane"}}
|
||||
assert _extract_initial_context(response) == {"customer_name": "Jane"}
|
||||
|
||||
def test_initial_context_takes_precedence_over_legacy(self):
|
||||
"""When both keys are present, `initial_context` wins."""
|
||||
response = {
|
||||
"call_inbound": {
|
||||
"initial_context": {"source": "new"},
|
||||
"dynamic_variables": {"source": "legacy"},
|
||||
}
|
||||
}
|
||||
assert _extract_initial_context(response) == {"source": "new"}
|
||||
|
||||
def test_falls_back_to_legacy_when_initial_context_not_a_dict(self):
|
||||
"""A non-dict `initial_context` falls back to `dynamic_variables`."""
|
||||
response = {
|
||||
"initial_context": None,
|
||||
"dynamic_variables": {"customer_name": "Jane"},
|
||||
}
|
||||
assert _extract_initial_context(response) == {"customer_name": "Jane"}
|
||||
|
||||
def test_nested_values_preserved(self):
|
||||
"""Nested objects pass through untouched for dot-notation access."""
|
||||
response = {
|
||||
"call_inbound": {
|
||||
"initial_context": {"customer": {"address": {"city": "LA"}}}
|
||||
}
|
||||
}
|
||||
assert _extract_initial_context(response) == {
|
||||
"customer": {"address": {"city": "LA"}}
|
||||
}
|
||||
|
||||
def test_empty_when_no_known_keys(self):
|
||||
"""A response with neither key yields an empty dict."""
|
||||
assert _extract_initial_context({"call_inbound": {"agent_id": 1}}) == {}
|
||||
|
||||
def test_empty_when_call_inbound_missing(self):
|
||||
"""No `call_inbound` and no top-level keys yields an empty dict."""
|
||||
assert _extract_initial_context({}) == {}
|
||||
|
||||
def test_non_dict_vars_yield_empty(self):
|
||||
"""A non-dict value under a known key yields an empty dict."""
|
||||
assert _extract_initial_context({"initial_context": "nope"}) == {}
|
||||
274
api/tests/test_public_embed_cors.py
Normal file
274
api/tests/test_public_embed_cors.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routes.public_embed import PublicEmbedCORSMiddleware, router
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://app.dograh.com"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(PublicEmbedCORSMiddleware, api_prefix="/api/v1")
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
_ACTIVE_TOKEN = SimpleNamespace(
|
||||
id=10,
|
||||
is_active=True,
|
||||
expires_at=None,
|
||||
allowed_domains=[],
|
||||
workflow_id=1,
|
||||
created_by=7,
|
||||
usage_limit=None,
|
||||
usage_count=0,
|
||||
settings={},
|
||||
)
|
||||
|
||||
_RESTRICTED_TOKEN = SimpleNamespace(
|
||||
id=20,
|
||||
is_active=True,
|
||||
expires_at=None,
|
||||
allowed_domains=["allowed.example.com"],
|
||||
workflow_id=2,
|
||||
created_by=7,
|
||||
usage_limit=None,
|
||||
usage_count=0,
|
||||
settings={},
|
||||
)
|
||||
|
||||
_LOCALHOST_TOKEN = SimpleNamespace(
|
||||
id=30,
|
||||
is_active=True,
|
||||
expires_at=None,
|
||||
allowed_domains=["localhost:3000", "localhost:3020"],
|
||||
workflow_id=3,
|
||||
created_by=7,
|
||||
usage_limit=None,
|
||||
usage_count=0,
|
||||
settings={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_db(monkeypatch):
|
||||
async def _get_token(token):
|
||||
if token == "valid":
|
||||
return _ACTIVE_TOKEN
|
||||
if token == "restricted":
|
||||
return _RESTRICTED_TOKEN
|
||||
if token == "localhost":
|
||||
return _LOCALHOST_TOKEN
|
||||
return None
|
||||
|
||||
async def _get_token_by_id(token_id):
|
||||
if token_id == _ACTIVE_TOKEN.id:
|
||||
return _ACTIVE_TOKEN
|
||||
if token_id == _RESTRICTED_TOKEN.id:
|
||||
return _RESTRICTED_TOKEN
|
||||
if token_id == _LOCALHOST_TOKEN.id:
|
||||
return _LOCALHOST_TOKEN
|
||||
return None
|
||||
|
||||
async def _get_session(session_token):
|
||||
if session_token == "session-valid":
|
||||
return SimpleNamespace(embed_token_id=_ACTIVE_TOKEN.id, expires_at=None)
|
||||
if session_token == "session-restricted":
|
||||
return SimpleNamespace(embed_token_id=_RESTRICTED_TOKEN.id, expires_at=None)
|
||||
return None
|
||||
|
||||
async def _create_workflow_run(**_kwargs):
|
||||
return SimpleNamespace(id=123)
|
||||
|
||||
async def _noop(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.db_client.get_embed_token_by_token",
|
||||
_get_token,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.db_client.get_embed_token_by_id",
|
||||
_get_token_by_id,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.db_client.get_embed_session_by_token",
|
||||
_get_session,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.db_client.create_workflow_run",
|
||||
_create_workflow_run,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.db_client.create_embed_session",
|
||||
_noop,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.db_client.increment_embed_token_usage",
|
||||
_noop,
|
||||
)
|
||||
monkeypatch.setattr("api.routes.public_embed.TURN_SECRET", "test-secret")
|
||||
monkeypatch.setattr(
|
||||
"api.routes.public_embed.generate_turn_credentials",
|
||||
lambda _user_id: {
|
||||
"username": "turn-user",
|
||||
"password": "turn-password",
|
||||
"ttl": 3600,
|
||||
"uris": ["turn:example.com:3478"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _assert_embed_cors(resp, origin: str):
|
||||
assert resp.headers.get("access-control-allow-origin") == origin
|
||||
assert "origin" in {
|
||||
value.strip().lower() for value in resp.headers.get("vary", "").split(",")
|
||||
}
|
||||
|
||||
|
||||
def test_options_config_returns_acao_for_allowed_origin():
|
||||
origin = "https://mysite.vercel.app"
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/config/valid",
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_options_config_accepts_allowed_localhost_port():
|
||||
origin = "http://localhost:3020"
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/config/localhost",
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_options_config_rejects_unknown_token():
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/config/unknown",
|
||||
headers={
|
||||
"Origin": "https://mysite.vercel.app",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_options_config_rejects_disallowed_origin():
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/config/restricted",
|
||||
headers={
|
||||
"Origin": "https://notallowed.example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_get_config_includes_acao_header():
|
||||
origin = "https://mysite.vercel.app"
|
||||
resp = client.get(
|
||||
"/api/v1/public/embed/config/valid",
|
||||
headers={"Origin": origin},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_get_config_accepts_allowed_localhost_port():
|
||||
origin = "http://localhost:3020"
|
||||
resp = client.get(
|
||||
"/api/v1/public/embed/config/localhost",
|
||||
headers={"Origin": origin},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_get_config_rejects_unlisted_localhost_port():
|
||||
resp = client.get(
|
||||
"/api/v1/public/embed/config/localhost",
|
||||
headers={"Origin": "http://localhost:3021"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_get_config_rejects_disallowed_origin():
|
||||
resp = client.get(
|
||||
"/api/v1/public/embed/config/restricted",
|
||||
headers={"Origin": "https://notallowed.example.com"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_init_includes_acao_header():
|
||||
origin = "https://mysite.vercel.app"
|
||||
resp = client.post(
|
||||
"/api/v1/public/embed/init",
|
||||
headers={"Origin": origin},
|
||||
json={"token": "valid"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_turn_credentials_includes_acao_header():
|
||||
origin = "https://mysite.vercel.app"
|
||||
resp = client.get(
|
||||
"/api/v1/public/embed/turn-credentials/session-valid",
|
||||
headers={"Origin": origin},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_options_init_returns_acao_for_allowed_origin():
|
||||
origin = "https://mysite.vercel.app"
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/init",
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_options_turn_credentials_returns_acao_for_allowed_origin():
|
||||
origin = "https://mysite.vercel.app"
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/turn-credentials/session-valid",
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_assert_embed_cors(resp, origin)
|
||||
|
||||
|
||||
def test_options_turn_credentials_rejects_disallowed_origin():
|
||||
resp = client.options(
|
||||
"/api/v1/public/embed/turn-credentials/session-restricted",
|
||||
headers={
|
||||
"Origin": "https://notallowed.example.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
Loading…
Add table
Add a link
Reference in a new issue