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
@@ -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
|
||||
Reference in new issue
Block a user