fix: improve maintenance and synchronize live conversations

This commit is contained in:
WangShen007 committed 2026-10-29 09:46:21 +08:00
1 parent bb541bd212
commit ef86a5d3af
297 files changed
+2983 -1274

No files matched your search

-1
View File
@@ -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
+3 -10
View File
@@ -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:
-1
View File
@@ -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
+2 -4
View File
@@ -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
+2 -5
View File
@@ -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
-1
View File
@@ -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 = {
+1 -1
View File
@@ -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
-1
View File
@@ -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
+1 -3
View File
@@ -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
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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",
+2 -3
View File
@@ -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 = {
-2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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()
+2 -2
View File
@@ -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
+4 -12
View File
@@ -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
+1 -6
View File
@@ -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
+2 -2
View File
@@ -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()
+3 -7
View File
@@ -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.
+1 -3
View File
@@ -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
+33 -6
View File
@@ -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
+274 -2
View File
@@ -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
-4
View File
@@ -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