Files
baiyue-logistics-chat/tests/test_model_turn_security.py

178 lines
8.8 KiB
Python

"""Authorization expiry and quote-state conflicts must not publish private replies."""
from hashlib import sha256
import json
import unittest
from unittest.mock import patch
from flask import g
import test_model_chat as fixtures
from test_model_tool_loop import evidence, final_facts, quote_arguments, tool_plan
from test_quotes import frames
from logistics_chat import db
from logistics_chat.conversation_state import commit_state, load_state
from logistics_chat.model_turn_store import latest_message_id, save_exchange
from logistics_chat.quote_data import load_book
from logistics_chat.quote_tool import QuoteTurn
class ModelTurnSecurityTests(unittest.TestCase):
def setUp(self):
fixtures.ModelChatTests.setUp(self)
def messages(self):
with self.app.app_context():
connection = db.connect()
try:
return connection.execute('SELECT conv_id,role,content FROM messages ORDER BY id').fetchall()
finally:
connection.close()
def test_stateless_private_results_are_not_sent_after_login_revoked_during_final_model_call(self):
fixtures.ModelChatTests.login(self)
before = self.messages()
requests = []
def provider(body, **kwargs):
requests.append(body)
if len(requests) == 1:
return fixtures.plan(('query_orders', '我的异常订单有多少个'))
self.assertEqual(len(requests), 2)
facts = evidence(body)
self.assertTrue(facts)
self.assertIn('等待资料', facts[0]['result'])
connection = db.connect()
connection.execute('DELETE FROM customer_sessions')
connection.commit()
connection.close()
return final_facts('fact_0')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
events = frames(self.client.post('/stream_chat', json={
'msg': '我有多少异常单,帮我看看原因', 'conv_id': None}))
self.assertEqual(len(requests), 2)
self.assertTrue(events[-1].get('d'), events)
self.assertTrue(events[0].get('e') or events[0].get('login_required'), events)
serialized = json.dumps(events, ensure_ascii=False)
self.assertNotIn('等待资料', serialized)
self.assertNotIn('VISIBLE20260001', serialized)
self.assertNotIn('INBOUND20260001', serialized)
self.assertFalse(any('card' in event for event in events))
self.assertEqual(self.messages(), before)
def test_bound_private_results_are_not_saved_after_login_revoked_during_final_model_call(self):
fixtures.ModelChatTests.login(self)
before = self.messages()
calls = 0
def provider(body, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
return fixtures.plan(('query_orders', '我的异常订单有多少个'))
self.assertIn('等待资料', evidence(body)[0]['result'])
connection = db.connect()
connection.execute('DELETE FROM customer_sessions')
connection.commit()
connection.close()
return final_facts('fact_0')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
events = frames(self.client.post('/stream_chat', json={
'msg': '我有多少异常单', 'conv_id': self.cid}))
self.assertEqual(calls, 2)
self.assertIn('e', events[0])
self.assertNotIn('等待资料', json.dumps(events, ensure_ascii=False))
self.assertEqual(self.messages(), before)
def test_greeting_tool_only_returns_customer_facing_text(self):
responses = [tool_plan('read_service_info', {'question': '你好'}, 'greeting'), final_facts('fact_0')]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses):
events = frames(self.client.post('/stream_chat', json={'msg': '你好', 'conv_id': self.cid}))
self.assertTrue(events[-1].get('d'), events)
self.assertIn('t', events[0])
self.assertTrue(events[0]['t'].strip())
for internal in ('可以回应问候', '针对原问题澄清', '本次没有查得其他业务事实', '报价工具', 'query_orders', 'fact_0'):
self.assertNotIn(internal, events[0]['t'])
self.assertIn(('assistant', events[0]['t']), [(role, text) for _, role, text in self.messages()])
def test_revocation_immediately_before_transaction_saves_neither_reply_nor_quote_state(self):
fixtures.ModelChatTests.login(self)
before = self.messages()
order_call = fixtures.plan(('query_orders', '我的异常订单有多少个')).message['tool_calls'][0]
quote_call = tool_plan('quote_shipping', quote_arguments(shipment={
'country': 'US', 'total_weight_kg': '1000', 'origin': '义乌', 'zone': '美西'}),
'quote_candidate').message['tool_calls'][0]
plan = fixtures.Completion({'role': 'assistant', 'content': '',
'tool_calls': [order_call, quote_call]}, 'tool_calls')
save_attempts = []
def revoke_then_save(*args, **kwargs):
turn = kwargs['quote_turn']
self.assertIsNotNone(turn.candidate)
self.assertEqual(turn.candidate['intent']['weight'], '1000')
connection = db.connect()
connection.execute('DELETE FROM customer_sessions')
connection.commit()
connection.close()
result = save_exchange(*args, **kwargs)
save_attempts.append(result)
return result
with patch('logistics_chat.ai_client.post_completion', side_effect=[plan, final_facts('fact_0', 'fact_1')]), \
patch('logistics_chat.model_chat_service.save_exchange', side_effect=revoke_then_save):
events = frames(self.client.post('/stream_chat', json={
'msg': '查异常单,同时核算一吨货美国美西的运费', 'conv_id': self.cid}))
self.assertEqual(save_attempts, ['deleted'])
self.assertIn('e', events[0])
self.assertNotIn('等待资料', json.dumps(events, ensure_ascii=False))
self.assertEqual(self.messages(), before)
with self.app.app_context():
connection = db.connect()
stored = connection.execute('SELECT conversation_state,order_list_state FROM chat_history WHERE id=?',
(self.cid,)).fetchone()
connection.close()
self.assertEqual(stored, (None, None))
def test_state_cas_failure_rolls_back_inserted_exchange(self):
with self.app.test_request_context():
g.visitor_id = sha256(('6' * 64).encode()).hexdigest()
book = load_book()
stale, competing = (QuoteTurn(self.cid, '查询报价', book) for _ in range(2))
stale.run(quote_arguments(shipment={'country': 'US', 'total_weight_kg': '1000'}))
competing.run(quote_arguments(shipment={'country': 'US', 'total_weight_kg': '2000'}))
previous_message_id = latest_message_id(self.cid)
connection = db.connect()
self.assertTrue(commit_state(connection, self.cid, competing.candidate, competing.expected_version))
connection.commit()
connection.close()
before = self.messages()
result = save_exchange(self.cid, '旧的一吨报价', '旧报价事实',
previous_message_id=previous_message_id, quote_turn=stale)
self.assertEqual(result, 'stale')
self.assertEqual(self.messages(), before)
self.assertEqual(load_state(self.cid)['intent']['weight'], '2000')
self.assertEqual(load_state(self.cid)['version'], 1)
def test_deleted_conversation_is_not_recreated_by_saving_exchange(self):
with self.app.test_request_context():
g.visitor_id = sha256(('6' * 64).encode()).hexdigest()
turn = QuoteTurn(self.cid, '查询报价', load_book())
turn.run(quote_arguments(shipment={'country': 'US', 'total_weight_kg': '1000'}))
previous_message_id = latest_message_id(self.cid)
connection = db.connect()
connection.execute('DELETE FROM messages WHERE conv_id=?', (self.cid,))
connection.execute('DELETE FROM chat_history WHERE id=?', (self.cid,))
connection.commit()
connection.close()
result = save_exchange(self.cid, '迟到请求', '迟到报价',
previous_message_id=previous_message_id, quote_turn=turn)
self.assertEqual(result, 'deleted')
self.assertIsNone(load_state(self.cid))
self.assertFalse(any(cid == self.cid for cid, _, _ in self.messages()))
if __name__ == '__main__':
unittest.main()