Files
baiyue-logistics-chat/tests/test_customer.py
T

219 lines
14 KiB
Python

"""Customer login, tenant scope and sensitive-history lifecycle."""
import hashlib
import json
from pathlib import Path
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
from logistics_chat import create_app, db, order_data
from logistics_chat.customer_service import COOKIE_NAME
class CustomerTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','RATE_LIMIT_PER_MIN':1000})
self.a,self.b=self.app.test_client(),self.app.test_client()
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
client.post('/api/visitor/session',json={'key':key})
with self.app.app_context():
connection=db.connect()
order_data.import_records(connection,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30},
{'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}])
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
connection.commit();connection.close()
def login(self, client=None, username='admin'):
return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'})
def events(self,message,conv=None,client=None):
response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv})
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
def test_guest_must_login_then_customer_reads_only_the_linked_scope(self):
with patch('logistics_chat.order_data.lookup_order') as lookup:
self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called()
self.assertTrue(self.login().json['ok'])
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001')
self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t'])
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
self.assertNotIn('99 kg',forged.get_data(as_text=True))
def test_common_tracking_requests_require_login_without_calling_the_model(self):
questions = ('查物流', '帮我查一下物流', '物流查询', '查货', '帮我查下轨迹',
'我的货到哪了', '我的包裹到哪里了?', '这票货什么时候到?',
'Where is my parcel?', 'Track my package', 'What is my shipment status?')
with patch('logistics_chat.ai_client.post_completion') as model:
for question in questions:
with self.subTest(question=question):
self.assertTrue(self.events(question)[0].get('login_required'))
self.login()
for question in questions:
with self.subTest(authenticated_question=question):
self.assertIn('请提供入仓码', self.events(question)[0]['t'])
model.assert_not_called()
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
secure=self.app.test_client()
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost')
for flag in ('Secure','HttpOnly','SameSite=Lax'):
self.assertIn(flag,response.headers['Set-Cookie'])
self.login();token=self.a.get_cookie(COOKIE_NAME).value
with self.app.app_context():
connection=db.connect()
self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456')
self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?',
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close()
with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600):
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token)
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self):
self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value)
self.assertFalse(self.b.get('/api/customer/session').json['authenticated'])
self.b.post('/api/customer/logout')
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
for index in range(5):
response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'})
self.assertEqual(response.status_code,403)
self.assertEqual(self.login(self.b).status_code,429)
def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self):
self.login();cid=self.a.post('/api/new_chat').json['id']
self.events('ORDER20260001',cid)
self.a.post('/api/customer/logout')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertEqual(self.a.get('/api/get_history').json,[])
self.assertTrue(self.events('继续查询',cid)[0]['login_required'])
self.a.delete('/api/clear_all')
self.login(username='other')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login()
self.assertTrue(any('__CARD__' in row['content'] for row in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
self.login(self.b)
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self):
for path in ('login','logout'):
self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'},
headers={'Origin':'https://foreign.example'}).status_code,403)
self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400)
self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400)
self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413)
def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self):
self.login()
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.assertEqual(self.login().status_code,403)
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close()
self.login()
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self):
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False
self.assertTrue(self.login().json['ok'])
reply=self.events('ORDER20260001')[0]
self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply)
with self.app.app_context():
connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close()
self.login()
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self):
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid)
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.login()
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self):
self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.assertEqual(self.login().status_code,403)
self.assertTrue(self.login(username='other').json['authenticated'])
def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self):
barrier=Barrier(6)
def attempt(index):
with self.app.test_client() as client:
client.set_cookie('baiyue_visitor','1'*64)
barrier.wait(timeout=5)
return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code
with ThreadPoolExecutor(max_workers=6) as executor:
statuses=list(executor.map(attempt,range(6)))
self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1)
def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self):
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.assertTrue(self.login().json['authenticated'])
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
with self.app.app_context():
connection=db.connect()
order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close()
self.assertIn('未找到',self.events('ORDER20260003')[0]['t'])
self.login(username='other')
self.assertIn('未找到',self.events('ORDER20260001')[0]['t'])
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self):
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
with self.app.app_context():
connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");connection.commit();connection.close()
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.login()
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self):
from logistics_chat.customer_service import init_customer_tables
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.login()
with self.app.app_context():
connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit()
init_customer_tables(connection);connection.commit();connection.close()
self.login()
self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t'])
with self.app.app_context():
connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close()
def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self):
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
with self.app.app_context():
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
self.app.config['ORDER_DATA_TENANT_ID']='tenant-a'
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
self.login()
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])