"""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'])