fix: improve maintenance and synchronize live conversations
This commit is contained in:
1 parent
bb541bd212
commit
ef86a5d3af
297 files changed
+2983
-1274
No files matched your search
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ from app.models.user import User, UserGroup
|
||||
from app.models.agent import Agent, AgentCustomization, AgentType
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime
|
||||
import os
|
||||
from typing import Dict, Generator
|
||||
from uuid import uuid4
|
||||
from app.api import agent as agent_router
|
||||
from app.core.auth import get_current_user, require_permissions, get_unified_auth
|
||||
from app.models.schemas.agent_customization import CustomizationCreate
|
||||
@@ -705,7 +702,7 @@ def test_customization_response_signs_photo_url_without_persisting_it(
|
||||
instance, which SQLAlchemy then flushed, so stored URLs carried a signature
|
||||
that eventually expired.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
stored_url = "https://bucket.s3.us-east-1.amazonaws.com/agents/photo.png"
|
||||
signed_url = f"{stored_url}?AWSAccessKeyId=A&Signature=S&Expires=1"
|
||||
@@ -1261,7 +1258,7 @@ def test_generate_instructions_ai_error(
|
||||
"existing_instructions": []
|
||||
}
|
||||
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Create valid AI config mock
|
||||
mock_config = MagicMock()
|
||||
@@ -1289,10 +1286,6 @@ def test_generate_instructions_rate_limiting(
|
||||
"""Test rate limiting on instruction generation endpoint"""
|
||||
from unittest.mock import patch
|
||||
|
||||
prompt_data = {
|
||||
"prompt": "Create helpful instructions",
|
||||
"existing_instructions": []
|
||||
}
|
||||
|
||||
# Mock rate limiting decorator to raise exception
|
||||
with patch('app.utils.rate_limit.limit_instruction_generation') as mock_limit:
|
||||
|
||||
@@ -21,7 +21,6 @@ from app.models.user import User
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from app.models.organization import Organization
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from uuid import uuid4
|
||||
from app.api import agent as agent_router
|
||||
|
||||
@@ -16,18 +16,16 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.ai_config import AIConfig, AIModelType
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from uuid import uuid4
|
||||
from app.api import ai_setup as ai_setup_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.models.schemas.ai_config import AIConfigCreate, AIConfigUpdate
|
||||
from pydantic import SecretStr
|
||||
from app.database import get_db
|
||||
|
||||
# Create a test FastAPI app
|
||||
|
||||
@@ -16,10 +16,7 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
from app.database import Base, get_db
|
||||
@@ -29,14 +26,14 @@ from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.rating import Rating
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from app.models.role import Role
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
from app.api import analytics as analytics_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.repositories.rating import RatingRepository
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
|
||||
@@ -504,7 +504,6 @@ def test_get_chat_detail_with_shopify_output(
|
||||
test_customer
|
||||
):
|
||||
"""Test getting chat detail with Shopify output in attributes"""
|
||||
import json
|
||||
|
||||
# Create message with shopify_output in attributes
|
||||
shopify_data = {
|
||||
|
||||
@@ -29,7 +29,7 @@ from sqlalchemy.orm import Session
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api import users as users_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.database import Base, get_db
|
||||
|
||||
@@ -33,7 +33,6 @@ from app.core.auth import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.crm.base import OAuthTokens
|
||||
from app.crm.hubspot import HubSpotAdapter
|
||||
from app.crm.pipedrive import PipedriveAdapter
|
||||
from app.database import get_db
|
||||
from app.models.crm import CrmConnectionStatus, CrmSyncJobStatus
|
||||
from app.models.lead_capture import LeadCaptureResponse
|
||||
|
||||
@@ -15,11 +15,9 @@ limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch, Mock
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI, status
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.file_upload import router, get_current_user_or_widget
|
||||
from app.models.user import User
|
||||
|
||||
@@ -26,9 +26,9 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import app.main # noqa: F401 — ensures routers are registered on the FastAPI app
|
||||
from app.core.application import app
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.core.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.faq import FAQ, FAQStatus
|
||||
from app.models.faq import FAQ
|
||||
from app.repositories.faq import FAQRepository
|
||||
|
||||
BASE = "/api/v1/help-center"
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from app.database import get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
@@ -25,7 +25,7 @@ from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from app.models.jira import JiraToken, AgentJiraConfig
|
||||
from app.models.agent import Agent, AgentType
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timedelta
|
||||
from app.api import jira as jira_router
|
||||
import app.core.oauth_state as oauth_state
|
||||
|
||||
@@ -31,7 +31,7 @@ from app.api import knowledge as knowledge_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||||
from tests.conftest import engine, TestingSessionLocal
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
import os
|
||||
@@ -924,7 +924,7 @@ def test_add_subpage_with_enterprise_limits_mocked():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
# Call the endpoint function directly
|
||||
import asyncio
|
||||
result = asyncio.run(add_subpage(
|
||||
asyncio.run(add_subpage(
|
||||
knowledge_id=1,
|
||||
subpage_name="test",
|
||||
content="test content",
|
||||
|
||||
@@ -25,7 +25,7 @@ from fastapi import FastAPI
|
||||
|
||||
from app.api import mcp_tool as mcp_api
|
||||
from app.core.config import settings
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.core.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
@@ -34,7 +34,6 @@ from app.models.agent import Agent, AgentType
|
||||
from app.models.mcp_tool import MCPTransportType
|
||||
from app.repositories.mcp_tool import MCPToolRepository
|
||||
from app.models.schemas.mcp_tool import (
|
||||
MCPToolCreate, MCPToolUpdate, MCPToolToAgentCreate,
|
||||
MAX_USAGE_GUIDANCE_CHARS, SECRET_MASK,
|
||||
)
|
||||
from app.models.ticket_settings import OrganizationTicketSettings
|
||||
@@ -108,7 +107,7 @@ def test_create_and_get_mcp_tool(client: TestClient, db):
|
||||
# Disable enterprise gating for tests
|
||||
mcp_api.HAS_ENTERPRISE = False
|
||||
# Fetch the user created in fixture
|
||||
user = db.query(User).first()
|
||||
db.query(User).first()
|
||||
|
||||
# Create an MCP tool
|
||||
payload = {
|
||||
|
||||
@@ -29,7 +29,6 @@ from app.models.permission import (
|
||||
DEFAULT_AGENT_ROLE_PERMISSIONS,
|
||||
)
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime, timezone
|
||||
from app.api import organizations as organizations_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
@@ -185,7 +184,6 @@ def test_create_organization(client, db, monkeypatch):
|
||||
|
||||
# Mock the agent repository to avoid SQLite issues with JSON
|
||||
from app.repositories.agent import AgentRepository
|
||||
original_create_agent = AgentRepository.create_agent
|
||||
|
||||
def mock_create_agent(self, **kwargs):
|
||||
# Skip agent creation in tests
|
||||
|
||||
@@ -24,7 +24,7 @@ import app.main # noqa: F401 — ensures routers are registered
|
||||
import app.api.people as people_api
|
||||
from app.core.application import app
|
||||
from app.core.auth import get_current_user
|
||||
from app.crm.base import CrmPushResult, OAuthTokens
|
||||
from app.crm.base import CrmPushResult
|
||||
from app.crm.hubspot import HubSpotAdapter
|
||||
from app.database import get_db
|
||||
from app.models.customer import Customer
|
||||
|
||||
@@ -22,12 +22,12 @@ from fastapi import FastAPI
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
from app.api import roles as roles_router
|
||||
from app.core.auth import get_current_user
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||||
from tests.conftest import engine, TestingSessionLocal
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
@@ -25,9 +25,9 @@ from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.customer import Customer
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
from app.api import session_to_agent as session_to_agent_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.core.auth import get_current_user
|
||||
from typing import Generator
|
||||
from datetime import datetime
|
||||
from app.models.schemas.chat import ChatDetailResponse, CustomerInfo, AgentInfo, Message
|
||||
|
||||
@@ -15,16 +15,11 @@ limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
|
||||
from app.models.shopify.shopify_shop import ShopifyShop
|
||||
import uuid
|
||||
from fastapi.testclient import TestClient
|
||||
from app.core.application import app
|
||||
from app.database import get_db
|
||||
from app.core.auth import get_current_user, require_permissions, get_current_organization
|
||||
from app.services.shopify_session import require_shopify_session
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, ANY, AsyncMock
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -24,20 +24,12 @@ import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
from urllib.parse import urlencode, quote
|
||||
from urllib.parse import quote
|
||||
from datetime import datetime, timezone
|
||||
from app.models.schemas.shopify.agent_shopify_config import AgentShopifyConfig
|
||||
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
|
||||
|
||||
from app.main import app
|
||||
from app.api.shopify import router
|
||||
from app.services.shopify_helper_service import ShopifyHelperService
|
||||
from app.repositories.shopify_shop_repository import ShopifyShopRepository
|
||||
from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository
|
||||
from app.models.shopify import ShopifyShop
|
||||
from app.models.schemas.shopify import ShopifyShopCreate, ShopifyShopUpdate
|
||||
from app.models.schemas.shopify import AgentShopifyConfigBase, AgentShopifyConfigCreate, AgentShopifyConfigUpdate
|
||||
from app.services.shopify import ShopifyService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -334,7 +326,7 @@ async def test_delete_shop(mock_shop_repo, mock_config_repo, mock_db, mock_organ
|
||||
async def test_check_connection_connected(mock_shop_repo, mock_db, mock_organization, mock_user):
|
||||
"""Test the check_connection endpoint when connected"""
|
||||
# Arrange
|
||||
org_id = str(mock_organization.id)
|
||||
str(mock_organization.id)
|
||||
|
||||
mock_shop_repo_instance = MagicMock()
|
||||
mock_shop_repo.return_value = mock_shop_repo_instance
|
||||
@@ -362,7 +354,7 @@ async def test_check_connection_connected(mock_shop_repo, mock_db, mock_organiza
|
||||
async def test_check_connection_not_connected(mock_shop_repo, mock_db, mock_organization, mock_user):
|
||||
"""Test the check_connection endpoint when not connected"""
|
||||
# Arrange
|
||||
org_id = str(mock_organization.id)
|
||||
str(mock_organization.id)
|
||||
|
||||
mock_shop_repo_instance = MagicMock()
|
||||
mock_shop_repo.return_value = mock_shop_repo_instance
|
||||
|
||||
@@ -17,17 +17,12 @@ limitations under the License.
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
import jwt
|
||||
|
||||
from app.api import token as token_router
|
||||
from app.api.token import (
|
||||
validate_api_key,
|
||||
GenerateTokenRequest,
|
||||
RevokeTokenRequest,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.core.security import CONVERSATION_SECRET_KEY, ALGORITHM
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ from app.models.role import Role
|
||||
from app.models.permission import Permission
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import user_groups as user_groups_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.core.auth import get_current_user
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||||
|
||||
# Create a test FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
@@ -17,25 +17,21 @@ limitations under the License.
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.database import Base, get_db
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from app.models.user import User, UserGroup
|
||||
from app.models.user import User
|
||||
from app.models.fcm_token import FCMToken
|
||||
from app.models.organization import Organization
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
from app.api import users as users_router
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
from urllib.parse import unquote
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables
|
||||
|
||||
# The disposable-address gate is enterprise-only; a community checkout has no
|
||||
# blocklist and accepts everything.
|
||||
|
||||
@@ -17,13 +17,11 @@ limitations under the License.
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import Base, get_db
|
||||
from fastapi import FastAPI
|
||||
from app.models.widget import Widget
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.agent import AgentCustomization
|
||||
from app.models.agent import Agent
|
||||
from uuid import UUID, uuid4
|
||||
from app.api import widget as widget_router
|
||||
from app.core.auth import get_current_user
|
||||
|
||||
@@ -27,13 +27,10 @@ from app.repositories.widget import WidgetRepository
|
||||
from app.models.schemas.widget import WidgetCreate
|
||||
from app.core.security import encrypt_api_key
|
||||
from uuid import UUID, uuid4
|
||||
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.workflow_node import WorkflowNode, NodeType
|
||||
from app.models.workflow_connection import WorkflowConnection
|
||||
from app.models.rating import Rating
|
||||
import datetime
|
||||
from socketio.exceptions import ConnectionRefusedError as SocketRefused
|
||||
|
||||
@@ -563,7 +560,7 @@ async def test_agent_message(db, test_widget, test_customer, test_user, mock_sio
|
||||
# Create a test session
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
session = session_repo.create_session(
|
||||
session_repo.create_session(
|
||||
session_id=session_id,
|
||||
agent_id=test_widget.agent_id,
|
||||
customer_id=test_customer.id,
|
||||
@@ -706,6 +703,36 @@ class TestUtilityFunctions:
|
||||
assert validate_form_field(number_config, "101") == "Age must not exceed 100"
|
||||
assert validate_form_field(number_config, "invalid") == "Age must be a valid number"
|
||||
assert validate_form_field(number_config, "25") is None
|
||||
|
||||
# The client shares these accepted values: trim text/email input, reject
|
||||
# parseFloat-only suffixes, and preserve numeric zero bounds and values.
|
||||
assert validate_form_field(text_config, " A ") == "Name must be at least 2 characters"
|
||||
assert validate_form_field(text_config, " AB ") is None
|
||||
assert validate_form_field(
|
||||
{"name": "name", "label": "Name", "type": "text", "minLength": 2}, "🙂"
|
||||
) == "Name must be at least 2 characters"
|
||||
assert validate_form_field(
|
||||
{"name": "name", "label": "Name", "type": "text", "maxLength": 1}, "🙂"
|
||||
) is None
|
||||
assert validate_form_field(number_config, "12abc") == "Age must be a valid number"
|
||||
assert validate_form_field(
|
||||
{"name": "amount", "label": "Amount", "type": "number", "maxLength": 0}, "1"
|
||||
) == "Amount must not exceed 0"
|
||||
assert validate_form_field(
|
||||
{"name": "amount", "label": "Amount", "type": "number", "maxLength": 0}, 0
|
||||
) is None
|
||||
assert validate_form_field(
|
||||
{"name": "amount", "label": "Amount", "type": "number"}, "3."
|
||||
) is None
|
||||
assert validate_form_field(
|
||||
{"name": "amount", "label": "Amount", "type": "number"}, "1.e3"
|
||||
) is None
|
||||
assert validate_form_field(
|
||||
{
|
||||
"name": "amount", "label": "Amount", "type": "number",
|
||||
"minLength": None, "maxLength": None,
|
||||
}, "-1"
|
||||
) is None
|
||||
|
||||
def test_validate_form_data(self):
|
||||
"""Test validate_form_data function"""
|
||||
@@ -1563,7 +1590,7 @@ class TestComplexScenarios:
|
||||
session_id = uuid4()
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
session = session_repo.create_session(
|
||||
session_repo.create_session(
|
||||
session_id=session_id,
|
||||
agent_id=test_widget.agent_id,
|
||||
customer_id=test_customer.id,
|
||||
@@ -1621,7 +1648,7 @@ class TestComplexScenarios:
|
||||
session_id = uuid4()
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
session = session_repo.create_session(
|
||||
session_repo.create_session(
|
||||
session_id=session_id,
|
||||
agent_id=test_widget.agent_id,
|
||||
customer_id=test_customer.id,
|
||||
|
||||
@@ -20,7 +20,6 @@ from app.models.widget import Widget
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.ai_config import AIConfig, AIModelType
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.models.workflow import Workflow, WorkflowStatus
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
|
||||
@@ -30,7 +29,7 @@ from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.models.schemas.widget import WidgetCreate
|
||||
from app.core.security import encrypt_api_key
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class TestWidgetChatWorkflow:
|
||||
"""Test that WorkflowExecutionService can be mocked properly"""
|
||||
from app.services.workflow_execution import WorkflowExecutionService
|
||||
|
||||
with patch.object(WorkflowExecutionService, '__init__', return_value=None) as mock_init, \
|
||||
with patch.object(WorkflowExecutionService, '__init__', return_value=None), \
|
||||
patch.object(WorkflowExecutionService, 'execute_workflow', new_callable=AsyncMock) as mock_execute:
|
||||
|
||||
mock_execute.return_value = {
|
||||
@@ -252,4 +252,276 @@ class TestOneMessageWorkflowIsSentOnce:
|
||||
await widget_chat.handle_widget_chat("test_sid", {"message": "hi"})
|
||||
|
||||
assert _chat_responses(sio) == ["Hello from the workflow"]
|
||||
assert _stored_bot_messages(db, session_id) == ["Hello from the workflow"]
|
||||
assert _stored_bot_messages(db, session_id) == ["Hello from the workflow"]
|
||||
|
||||
# Synthetic values are deliberately recognizable in a rendered log message.
|
||||
_SYNTHETIC_KEY = "sk-f01-synthetic-key-do-not-log"
|
||||
_SYNTHETIC_NOTE = "f01-private-form-answer"
|
||||
_SYNTHETIC_EMAIL = "f01-private@example.test"
|
||||
_SYNTHETIC_STATE = "f01-private-previous-state"
|
||||
|
||||
|
||||
def _rendered_log(caplog):
|
||||
import logging
|
||||
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
return "\n".join(formatter.format(record) for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def form_logging_session(db, test_workflow, test_agent, test_customer, test_ai_config, monkeypatch):
|
||||
"""A real SQLite session: FORM -> CONDITION -> GUARDRAILS -> MESSAGE -> LLM."""
|
||||
from app.api import widget_chat
|
||||
from app.core.security import encrypt_api_key
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.workflow_connection import WorkflowConnection
|
||||
from app.services import socket_rate_limit
|
||||
|
||||
form = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.FORM,
|
||||
name="Contact Form", config={"form_fields": [
|
||||
{"name": "email", "type": "email", "label": "Email"},
|
||||
{"name": "note", "type": "text", "label": "Note"},
|
||||
]})
|
||||
condition = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.CONDITION,
|
||||
name="Condition", config={"condition_expression": "{{contact_form_note}} > 1"})
|
||||
guard = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.GUARDRAILS,
|
||||
name="Guard", config={"enabled_guardrails": ["pii"], "pii_action": "redact",
|
||||
"text_source": "contact_form_note"})
|
||||
message = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.MESSAGE,
|
||||
name="Message", config={"message_text": "Noted {{contact_form_note}}"})
|
||||
model = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.LLM,
|
||||
name="Model", config={"system_prompt": "Use {{contact_form_email}}",
|
||||
"exit_condition": "continuous_execution"})
|
||||
db.add_all([form, condition, guard, message, model])
|
||||
for source, target, label in [(form, condition, None), (condition, guard, "false"),
|
||||
(guard, message, "pass"), (message, model, None)]:
|
||||
db.add(WorkflowConnection(id=uuid4(), workflow_id=test_workflow.id,
|
||||
source_node_id=source.id, target_node_id=target.id, label=label))
|
||||
test_customer.email = "anonymous@noemail.com"
|
||||
test_ai_config.encrypted_api_key = encrypt_api_key(_SYNTHETIC_KEY)
|
||||
test_ai_config.settings = {"base_url": "https://model.example.test/v1"}
|
||||
active = SessionToAgent(session_id=uuid4(), organization_id=test_agent.organization_id,
|
||||
customer_id=test_customer.id, agent_id=test_agent.id,
|
||||
workflow_id=test_workflow.id, current_node_id=form.id,
|
||||
workflow_state={"form_state": "waiting", "variables": {
|
||||
"earlier": {"private": _SYNTHETIC_STATE}}},
|
||||
workflow_history=[], status=SessionStatus.OPEN)
|
||||
db.add(active)
|
||||
db.commit()
|
||||
sio = MagicMock()
|
||||
sio.emit = AsyncMock()
|
||||
sio.get_session = AsyncMock(return_value={
|
||||
"widget_id": str(uuid4()), "org_id": str(test_agent.organization_id),
|
||||
"agent_id": str(test_agent.id), "customer_id": str(test_customer.id),
|
||||
"session_id": str(active.session_id), "ai_config": test_ai_config,
|
||||
"conversation_scope": VERIFIED_SCOPE, "source": "widget",
|
||||
})
|
||||
monkeypatch.setattr(widget_chat, "sio", sio)
|
||||
monkeypatch.setattr(socket_rate_limit, "sio", sio)
|
||||
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
|
||||
return active, form, model, sio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("duplicate_email", [False, True])
|
||||
async def test_form_submission_keeps_secrets_out_of_rendered_logs(
|
||||
db, test_organization, test_customer, test_ai_config, form_logging_session, duplicate_email,
|
||||
caplog, monkeypatch,
|
||||
):
|
||||
import logging
|
||||
from app.api import widget_chat
|
||||
from app.models.customer import Customer
|
||||
from app.models.schemas.chat import ChatResponse
|
||||
from app.services import workflow_execution
|
||||
|
||||
active, form, model, sio = form_logging_session
|
||||
if duplicate_email:
|
||||
db.add(Customer(id=uuid4(), organization_id=test_organization.id,
|
||||
email=_SYNTHETIC_EMAIL, full_name="Other"))
|
||||
db.commit()
|
||||
agent = MagicMock()
|
||||
agent._get_llm_response_only = AsyncMock(return_value=ChatResponse(
|
||||
message=f"Reply {_SYNTHETIC_NOTE}", transfer_to_human=False, end_chat=False,
|
||||
request_rating=False, create_ticket=False,
|
||||
))
|
||||
agent.safe_cleanup_mcp_tools = AsyncMock()
|
||||
create_agent = AsyncMock(return_value=agent)
|
||||
monkeypatch.setattr(workflow_execution.ChatAgent, "create_async", create_agent)
|
||||
session_id = str(active.session_id)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
|
||||
await widget_chat.handle_form_submission("sid-f01", {"form_data": {
|
||||
"email": _SYNTHETIC_EMAIL, "note": _SYNTHETIC_NOTE,
|
||||
}})
|
||||
|
||||
assert _chat_responses(sio) == [f"Noted {_SYNTHETIC_NOTE}", f"Reply {_SYNTHETIC_NOTE}"]
|
||||
assert sorted(_stored_bot_messages(db, session_id)) == sorted(_chat_responses(sio))
|
||||
assert any(c.args[0] == "form_submitted" and c.args[1]["success"] for c in sio.emit.call_args_list)
|
||||
db.refresh(active)
|
||||
db.refresh(test_customer)
|
||||
assert test_customer.email == ("anonymous@noemail.com" if duplicate_email else _SYNTHETIC_EMAIL)
|
||||
assert active.current_node_id == model.id
|
||||
assert active.workflow_state["variables"][str(form.id)] == {
|
||||
"contact_form_email": _SYNTHETIC_EMAIL, "contact_form_note": _SYNTHETIC_NOTE,
|
||||
}
|
||||
assert active.workflow_history[-1]["data"] == {"email": _SYNTHETIC_EMAIL, "note": _SYNTHETIC_NOTE}
|
||||
kwargs = create_agent.await_args.kwargs
|
||||
assert kwargs["api_key"] == _SYNTHETIC_KEY
|
||||
assert kwargs["model_name"] == test_ai_config.model_name
|
||||
assert kwargs["model_type"] == test_ai_config.model_type
|
||||
assert kwargs["custom_system_prompt"] == f"Use {_SYNTHETIC_EMAIL}"
|
||||
assert kwargs["session_id"] == session_id
|
||||
assert kwargs["base_url"] == "https://model.example.test/v1"
|
||||
assert kwargs["org_id"] == str(test_organization.id)
|
||||
assert kwargs["customer_id"] == str(test_customer.id)
|
||||
assert kwargs["agent_id"] == str(active.agent_id)
|
||||
assert kwargs["source"] == "widget"
|
||||
assert _SYNTHETIC_NOTE in agent._get_llm_response_only.await_args.kwargs["message"]
|
||||
agent.safe_cleanup_mcp_tools.assert_awaited_once()
|
||||
|
||||
logs = _rendered_log(caplog)
|
||||
for secret in (_SYNTHETIC_KEY, _SYNTHETIC_NOTE, _SYNTHETIC_EMAIL, _SYNTHETIC_STATE):
|
||||
assert secret not in logs
|
||||
assert session_id in logs and str(form.id) in logs and str(model.id) in logs
|
||||
assert "form" in logs.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("case", ["missing", "unexpected", "exception"])
|
||||
async def test_form_submission_failure_logs_identifiers_not_sensitive_inputs(case, caplog):
|
||||
import logging
|
||||
from app.services.workflow_execution import WorkflowExecutionService
|
||||
|
||||
service = WorkflowExecutionService(Mock())
|
||||
node_id = uuid4()
|
||||
session_id = "f01-failure-session"
|
||||
workflow_id = uuid4()
|
||||
error = f"database failed while handling {_SYNTHETIC_NOTE} and {_SYNTHETIC_KEY}"
|
||||
service.session_repo.get_session = Mock(
|
||||
side_effect=RuntimeError(error) if case == "exception" else None,
|
||||
return_value=Mock(workflow_state={"form_state": "not-waiting", "private": _SYNTHETIC_STATE},
|
||||
current_node_id=node_id) if case == "unexpected" else None,
|
||||
)
|
||||
service.execute_workflow = AsyncMock()
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
result = await service.submit_form(session_id, {"note": _SYNTHETIC_NOTE}, workflow_id,
|
||||
api_key=_SYNTHETIC_KEY)
|
||||
expected = {"missing": "Session not found", "unexpected": "No form submission expected",
|
||||
"exception": error}[case]
|
||||
assert result.success is False and result.error == expected
|
||||
service.execute_workflow.assert_not_awaited()
|
||||
logs = _rendered_log(caplog)
|
||||
for secret in (_SYNTHETIC_KEY, _SYNTHETIC_NOTE, _SYNTHETIC_STATE):
|
||||
assert secret not in logs
|
||||
assert session_id in logs and str(workflow_id) in logs
|
||||
assert any(record.levelno >= logging.WARNING for record in caplog.records)
|
||||
if case == "unexpected":
|
||||
assert str(node_id) in logs
|
||||
if case == "exception":
|
||||
assert "RuntimeError" in logs
|
||||
|
||||
|
||||
def test_landing_page_interpolation_does_not_log_submitted_values(caplog):
|
||||
import logging
|
||||
from app.services.workflow_execution import WorkflowExecutionService
|
||||
|
||||
service = WorkflowExecutionService(Mock())
|
||||
node = Mock(spec=WorkflowNode)
|
||||
node.id = uuid4()
|
||||
node.config = {"landing_page_heading": "Hello", "landing_page_content": "Saved {{note}}"}
|
||||
state = {"variables": {"form": {"note": _SYNTHETIC_NOTE}}}
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
result = service._execute_landing_page_node(node, state)
|
||||
assert result.landing_page_data == {"heading": "Hello", "content": f"Saved {_SYNTHETIC_NOTE}"}
|
||||
logs = _rendered_log(caplog)
|
||||
assert _SYNTHETIC_NOTE not in logs
|
||||
assert str(node.id) in logs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["update", "history"])
|
||||
def test_form_state_persistence_failure_logs_no_database_parameters(
|
||||
db, form_logging_session, caplog, monkeypatch, operation,
|
||||
):
|
||||
import logging
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
|
||||
active, form, _, _ = form_logging_session
|
||||
session_id = str(active.session_id)
|
||||
repository = SessionToAgentRepository(db)
|
||||
monkeypatch.setattr(db, "commit", Mock(side_effect=RuntimeError(f"database error: {_SYNTHETIC_NOTE}")))
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
if operation == "update":
|
||||
result = repository.update_workflow_state(session_id, form.id, {"secret": _SYNTHETIC_NOTE})
|
||||
else:
|
||||
result = repository.add_workflow_history_entry(session_id, form.id, "form_submission", {"note": _SYNTHETIC_NOTE})
|
||||
assert result is False
|
||||
logs = _rendered_log(caplog)
|
||||
assert _SYNTHETIC_NOTE not in logs
|
||||
assert session_id in logs and "RuntimeError" in logs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_failure_does_not_log_form_data_or_api_key(
|
||||
db, form_logging_session, caplog, monkeypatch,
|
||||
):
|
||||
import logging
|
||||
from app.api import widget_chat
|
||||
from app.services import workflow_execution
|
||||
|
||||
active, form, model, sio = form_logging_session
|
||||
error = f"model refused {_SYNTHETIC_NOTE} with {_SYNTHETIC_KEY}"
|
||||
monkeypatch.setattr(workflow_execution.ChatAgent, "create_async", AsyncMock(side_effect=RuntimeError(error)))
|
||||
session_id = str(active.session_id)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
await widget_chat.handle_form_submission("sid-f01", {"form_data": {
|
||||
"email": _SYNTHETIC_EMAIL, "note": _SYNTHETIC_NOTE,
|
||||
}})
|
||||
errors = [c.args[1] for c in sio.emit.call_args_list if c.args[0] == "error"]
|
||||
assert errors == [{"error": error, "type": "form_error"}]
|
||||
assert not any(c.args[0] == "form_submitted" for c in sio.emit.call_args_list)
|
||||
logs = _rendered_log(caplog)
|
||||
for secret in (_SYNTHETIC_KEY, _SYNTHETIC_NOTE, _SYNTHETIC_EMAIL, _SYNTHETIC_STATE):
|
||||
assert secret not in logs
|
||||
assert session_id in logs and str(form.id) in logs and str(model.id) in logs
|
||||
assert "RuntimeError" in logs
|
||||
|
||||
|
||||
def test_form_email_lookup_database_failure_does_not_log_parameters(caplog):
|
||||
import logging
|
||||
from app.repositories.customer import CustomerRepository
|
||||
|
||||
db = Mock()
|
||||
db.query.side_effect = RuntimeError(f"database lookup failed: {_SYNTHETIC_EMAIL}")
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
assert CustomerRepository(db).get_customer_by_email(_SYNTHETIC_EMAIL, uuid4()) is None
|
||||
logs = _rendered_log(caplog)
|
||||
assert _SYNTHETIC_EMAIL not in logs and "RuntimeError" in logs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("error_type", [ValueError, RuntimeError])
|
||||
async def test_widget_form_exception_logs_type_without_exception_contents(
|
||||
form_logging_session, caplog, monkeypatch, error_type,
|
||||
):
|
||||
import logging
|
||||
from app.api import widget_chat
|
||||
|
||||
_, _, _, sio = form_logging_session
|
||||
detail = f"failed with {_SYNTHETIC_KEY} and {_SYNTHETIC_NOTE}"
|
||||
monkeypatch.setattr(widget_chat, "_own_session", Mock(side_effect=error_type(detail)))
|
||||
caplog.set_level(logging.DEBUG)
|
||||
caplog.clear()
|
||||
await widget_chat.handle_form_submission("sid-f01", {"form_data": {"note": _SYNTHETIC_NOTE}})
|
||||
|
||||
emitted = [call.args[1] for call in sio.emit.call_args_list if call.args[0] == "error"]
|
||||
assert emitted == [{"error": detail if error_type is ValueError else "Failed to submit form",
|
||||
"type": "form_error"}]
|
||||
logs = _rendered_log(caplog)
|
||||
assert _SYNTHETIC_KEY not in logs and _SYNTHETIC_NOTE not in logs
|
||||
assert "sid-f01" in logs and error_type.__name__ in logs
|
||||
@@ -16,8 +16,6 @@ limitations under the License.
|
||||
|
||||
import pytest
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -28,8 +26,6 @@ from app.models.agent import Agent
|
||||
from app.models.organization import Organization
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission
|
||||
from app.models.schemas.workflow import WorkflowCreate, WorkflowUpdate
|
||||
from app.services.workflow import WorkflowService
|
||||
from app.core.config import settings
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.database import get_db
|
||||
|
||||
@@ -19,13 +19,9 @@ from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI
|
||||
from uuid import uuid4
|
||||
from app.models import (
|
||||
Agent,
|
||||
User,
|
||||
Organization,
|
||||
Workflow,
|
||||
WorkflowNode
|
||||
)
|
||||
from app.models.schemas.workflow import WorkflowNodeCreate, WorkflowNodeUpdate
|
||||
from app.models.workflow import WorkflowStatus
|
||||
from app.models.workflow_node import NodeType
|
||||
from app.api import workflow_node as workflow_node_router
|
||||
|
||||
Reference in new issue
Block a user