205 lines
13 KiB
Python
205 lines
13 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_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'])
|