支持免登录游客聊天并隔离历史与附件

This commit is contained in:
2026-10-30 12:17:43 +08:00
parent e58dd86d5b
commit 147edd042f
36 changed files with 663 additions and 129 deletions
+19 -18
View File
@@ -80,7 +80,7 @@ async function customerPage(pageOptions = {}){
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
@@ -91,14 +91,15 @@ async function customerPage(pageOptions = {}){
}})}};
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/human/poll') ? state
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -362,7 +363,7 @@ test('all three footer questions leave the human queue and send to separate AI c
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
@@ -377,7 +378,7 @@ test('welcome and hot question buttons use the same AI routing as the footer', a
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
@@ -386,7 +387,7 @@ test('the welcome contact button opens the human queue and does not send an AI q
button.setAttribute('data-human-support', '');
app.element('chatDom').listeners.click({target:button});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
@@ -397,7 +398,7 @@ test('new chat clears human routing and the next quote is sent to AI', async ()
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.__busy, false);
});
@@ -454,7 +455,7 @@ test('a late history response cannot overwrite the latest quick conversation', a
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
app.hooks['/api/visitor/human/request'] = () => new Promise(done => { resolve=done; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
@@ -468,7 +469,7 @@ test('a delayed human connection cannot capture a newly selected AI topic', asyn
test('repeated contact clicks create one human request and show a visible queue state', async () => {
const app = await customerPage({human:false});
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.element('human-session').hidden, false);
assert.match(app.element('human-session-label').textContent, /正在排队/);
});
@@ -476,7 +477,7 @@ test('repeated contact clicks create one human request and show a visible queue
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0);
@@ -484,7 +485,7 @@ test('the contact shortcut outside service hours submits a ticket without enteri
test('a failed human send preserves the text and makes retry possible', async () => {
const app = await customerPage();
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
app.hooks['/api/visitor/human/send'] = () => response({ok:false}, 503);
await app.context.window.fillInput('请确认这批货的价格');
assert.equal(app.element('input-text').value, '请确认这批货的价格');
assert.match(app.element('composer-status').textContent, /内容已保留/);
@@ -494,10 +495,10 @@ test('a failed human send preserves the text and makes retry possible', async ()
test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage();
let resolve;
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
app.hooks['/api/visitor/human/send'] = () => new Promise(done => { resolve=done; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
@@ -508,7 +509,7 @@ test('returning to AI explicitly closes the human session and preserves the conv
await app.click(app.element('return-ai'));
await app.context.window.fillInput('请继续解答');
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/close').length, 1);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
@@ -639,7 +640,7 @@ test('recognized voice uses the normal AI and human send routes with a visible u
const app=await customerPage({human,width:390});
await app.voiceOptions.onText('语音识别的物流问题');
await flush();
const call=app.calls.find(call=>call.url===(human ? '/api/human/send' : '/stream_chat'));
const call=app.calls.find(call=>call.url===(human ? '/api/visitor/human/send' : '/stream_chat'));
assert.ok(call);
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
@@ -682,7 +683,7 @@ test('an AI regeneration is not forwarded to a human session', async () => {
await app.context.window.fillInput('之前的问题');await flush();
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
await app.context.window.showHuman();await app.click(retry);
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,0);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/human/send').length,0);
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
});
+79
View File
@@ -0,0 +1,79 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
const {webcrypto}=require('node:crypto');
const flush=()=>new Promise(setImmediate);
function sessionPage(options={}){
const storage=options.storage || new Map(),calls=[],timers=new Map();let timerId=0;
const response=(data,status=200)=>({ok:status>=200&&status<300,status,json:async()=>data});
const context=vm.createContext({console,AbortController,Uint8Array,
window:{crypto:webcrypto},
localStorage:{getItem:key=>{if(options.storageBlocked) throw Error();return storage.get(key);},
setItem:(key,value)=>{if(options.storageBlocked) throw Error();storage.set(key,value);}},
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},clearTimeout:id=>timers.delete(id),
fetch:async(url,init)=>{calls.push({url,init});
if(options.fetch) return options.fetch(url,init,response);
return url==='/api/visitor/session' ? response({ok:true,key:JSON.parse(init.body).key || 'f'.repeat(64)}) : response([]);
},
});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/visitor.js'),'utf8'),context);
return {fetch:context.VisitorSession.fetch,calls,storage,
expire:async()=>{for(const timer of [...timers.values()])timer.callback();await flush();}};
}
test('a local random key is restored across reloads and differs in another browser',async()=>{
const a=sessionPage();await a.fetch('/api/get_history');
const key=JSON.parse(a.calls[0].init.body).key;
assert.match(key,/^[0-9a-f]{64}$/);
const restored=sessionPage({storage:a.storage});await restored.fetch('/api/get_history');
assert.equal(JSON.parse(restored.calls[0].init.body).key,key);
const b=sessionPage();await b.fetch('/api/get_history');
assert.notEqual(JSON.parse(b.calls[0].init.body).key,key);
assert.equal(a.calls[1].init.credentials,'same-origin');
assert.equal(a.calls.some(call=>call.url.includes(key)),false);
});
test('concurrent requests wait for one bootstrap before sending customer data',async()=>{
let release;
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
? new Promise(resolve=>{release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));}) : response([])});
const a=app.fetch('/api/get_history'),b=app.fetch('/api/new_chat',{method:'POST'});
assert.equal(app.calls.length,1);
release();await Promise.all([a,b]);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,1);
assert.equal(app.calls.length,3);
});
test('blocked local storage uses a server cookie without falling back to global history',async()=>{
const app=sessionPage({storageBlocked:true});await app.fetch('/api/get_history');
assert.equal(JSON.parse(app.calls[0].init.body).key,null);
assert.equal(app.calls[1].init.credentials,'same-origin');
const failed=sessionPage({fetch:(url,init,response)=>response({ok:false},503)});
await assert.rejects(failed.fetch('/api/get_history'));
assert.equal(failed.calls.length,1);
});
test('expired sessions rebootstrap once and retry the original request once',async()=>{
let attempts=0;
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
? response({ok:true,key:JSON.parse(init.body).key}) : response([],++attempts===1 ? 401 : 200)});
const result=await app.fetch('/api/get_history');
assert.equal(result.status,200);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,2);
assert.equal(attempts,2);
});
test('a cancelled request waiting for bootstrap does not upload its body',async()=>{
let release;
const app=sessionPage({fetch:(url,init,response)=>new Promise(resolve=>{
release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));
})});
const controller=new AbortController();
const pending=app.fetch('/api/upload',{method:'POST',body:'private file',signal:controller.signal});
controller.abort();release();
await assert.rejects(pending,{name:'AbortError'});
assert.equal(app.calls.length,1);
});
+8 -1
View File
@@ -45,6 +45,8 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.app.extensions['rate_hits'].clear()
from logistics_chat.order_data import import_records
with self.app.app_context():
connection = db.connect()
@@ -55,9 +57,14 @@ class ApiTests(unittest.TestCase):
connection.close()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
app = create_app({'TESTING': True, 'DATABASE': root/'history.db',
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
@app.before_request
def fixture_customer():
from flask import g
g.customer_tenant_id = 'fixture'
return app
def conversation(self):
return self.client.post('/api/new_chat').json['id']
+1
View File
@@ -29,6 +29,7 @@ class DeploymentTests(unittest.TestCase):
self.addCleanup(close_handlers)
client = app.test_client()
client.post('/api/visitor/session', json={'key':'1'*64})
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
self.assertEqual(client.get(path).status_code, 200, path)
self.assertEqual(client.post('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
+1
View File
@@ -21,6 +21,7 @@ class DevIntegrationTests(unittest.TestCase):
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key': '1' * 64})
def session(self):
cid = self.client.post('/api/new_chat').json['id']
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+21
View File
@@ -4,6 +4,7 @@ from dataclasses import replace
from pathlib import Path
import tempfile
import unittest
import re
from dotenv import dotenv_values
@@ -57,6 +58,26 @@ class GrayConfigTests(unittest.TestCase):
self.assertNotIn(key, (path / name).read_text(encoding='utf-8'))
self.assertIn('auth_basic_user_file', (path / 'site.conf').read_text(encoding='utf-8'))
def test_public_allowlist_keeps_staff_search_exports_and_order_endpoints_protected(self):
config = GrayConfig.from_environment(self.settings()).nginx_config()
self.assertTrue(config.startswith('# Managed for baiyue-logistics-chat-gray;'))
match = re.search(r'location ~ (\S+) \{([^}]+)\}', config)
self.assertIsNotNone(match)
pattern, body = match.groups()
self.assertIn('auth_basic off;', body)
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
with self.subTest(public_path=path):self.assertRegex(path,pattern)
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
'/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'):
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
self.assertIn('location = / {\n auth_basic off;',config)
def test_invalid_model_credential_does_not_create_a_release_directory(self):
config = GrayConfig.from_environment(self.settings())
with tempfile.TemporaryDirectory() as directory:
+5 -1
View File
@@ -19,8 +19,11 @@ class OrderDataTests(unittest.TestCase):
'UPLOAD_DIR':root/'uploads', 'BACKUP_DIR':root/'backups', 'LOG_DIR':root/'logs',
'DEEPSEEK_API_KEY':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
self.context = self.app.app_context()
self.context.push()
from flask import g
g.customer_tenant_id = 'tenant-a'
self.addCleanup(self.context.pop)
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
@@ -88,7 +91,8 @@ class OrderDataTests(unittest.TestCase):
def test_server_tenant_scope_and_ambiguous_identifiers(self):
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
self.assertIn('多个订单', self.events(self.record['order_no'])[0]['t'])
fields = self.events(self.record['order_no'])[0]['card']['fields']
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '30.000 kg')
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
rows = order_data.lookup_order(self.record['order_no'])
self.assertEqual(len(rows), 1)
+1
View File
@@ -37,6 +37,7 @@ class SpeechTests(unittest.TestCase):
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.client.post('/api/visitor/session', json={'key':'1'*64})
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
+146
View File
@@ -0,0 +1,146 @@
"""Independent guests cannot read, mutate or claim each other's chat data."""
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from datetime import datetime
from logistics_chat import create_app, db
class VisitorTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.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':'','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)):
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
def conversation(self, client, text):
cid = client.post('/api/new_chat').json['id']
with self.app.app_context(): db.save_message(cid,'user',text)
return cid
def human(self, client, cid):
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026,10,30,12)
return client.post('/api/visitor/human/request',json={'conv_id':cid}).json['session_id']
def test_entry_is_public_but_customer_apis_need_a_guest_session_without_basic_login(self):
fresh = self.app.test_client()
self.assertEqual(fresh.get('/').status_code,200)
response = fresh.get('/api/get_history')
self.assertEqual(response.status_code,401)
self.assertNotIn('WWW-Authenticate',response.headers)
self.assertEqual(fresh.post('/api/new_chat').status_code,401)
self.assertEqual(self.a.get('/api/get_history').json,[])
def test_guest_key_is_restorable_but_only_its_hash_is_stored(self):
cid = self.conversation(self.a,'A_PRIVATE')
restored = self.app.test_client()
response = restored.post('/api/visitor/session',json={'key':'1'*64},base_url='https://localhost')
for flag in ('HttpOnly','Secure','SameSite=Lax'):
self.assertIn(flag,response.headers['Set-Cookie'])
self.assertEqual(restored.get('/api/get_history').json[0]['id'],cid)
with self.app.app_context():
connection=db.connect()
owner=connection.execute('SELECT visitor_id FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
connection.close()
self.assertEqual(len(owner),64)
self.assertNotEqual(owner,'1'*64)
def test_listing_loading_and_streaming_never_expose_another_guest(self):
a = self.conversation(self.a,'A_PRIVATE')
b = self.conversation(self.b,'B_PRIVATE')
self.assertEqual([row['id'] for row in self.a.get('/api/get_history').json],[a])
self.assertEqual([row['id'] for row in self.b.get('/api/get_history').json],[b])
self.assertEqual(self.a.get(f'/api/get_messages?conv_id={b}').json,[])
self.assertEqual(self.b.get(f'/api/get_messages?conv_id={a}').json,[])
with patch('logistics_chat.ai_client.post_completion') as model:
response=self.a.post('/stream_chat',json={'msg':'继续上一段对话','conv_id':b})
self.assertIn('新建对话',response.get_data(as_text=True))
self.assertNotIn('B_PRIVATE',response.get_data(as_text=True))
self.assertEqual(len(self.b.get(f'/api/get_messages?conv_id={b}').json),2)
model.assert_not_called()
def test_delete_and_clear_only_affect_the_current_guest_and_keep_unowned_legacy_data(self):
a = self.conversation(self.a,'A_PRIVATE')
b = self.conversation(self.b,'B_PRIVATE')
sid_a,sid_b=self.human(self.a,a),self.human(self.b,b)
self.a.post('/api/visitor/human/send',json={'sid':sid_a,'content':'A_SUPPORT'})
self.b.post('/api/visitor/human/send',json={'sid':sid_b,'content':'B_SUPPORT'})
with self.app.app_context():
connection=db.connect()
legacy=connection.execute("INSERT INTO chat_history(title,ctime) VALUES('LEGACY_PRIVATE','now')").lastrowid
connection.commit();connection.close()
self.assertEqual(self.a.delete(f'/api/del_history?id={b}').status_code,404)
self.assertTrue(self.a.delete('/api/clear_all').json['ok'])
self.assertEqual(self.a.get('/api/get_history').json,[])
self.assertEqual(self.b.get('/api/get_history').json[0]['id'],b)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid_b}').json['messages'][0]['content'],'B_SUPPORT')
with self.app.app_context():
connection=db.connect()
self.assertEqual(connection.execute('SELECT id FROM chat_history ORDER BY id').fetchall(),[(b,),(legacy,)])
connection.close()
def test_customer_human_routes_enforce_owner_and_role_and_hide_internal_notes(self):
a=self.conversation(self.a,'A_PRIVATE')
sid=self.human(self.a,a)
self.assertEqual(self.b.post('/api/visitor/human/request',json={'conv_id':a}).status_code,404)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
for action in ('send','close'):
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
self.assertNotIn('note',response.json)
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
self.assertEqual(self.b.post('/api/rate',json={'sid':sid,'stars':5}).status_code,404)
self.assertEqual(self.a.post('/api/rate',json=[1]).status_code,400)
self.assertEqual(self.a.post('/api/rate',json={'sid':sid,'stars':5}).status_code,200)
def test_uploads_are_private_and_legacy_files_are_not_assigned_to_new_visitors(self):
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'A_PRIVATE'),'note.txt')})
url=response.json['url']
with self.a.get(url) as downloaded:
self.assertEqual(downloaded.data,b'A_PRIVATE')
self.assertIn('no-store',downloaded.headers['Cache-Control'])
self.assertEqual(self.b.get(url).status_code,404)
self.assertEqual(self.app.test_client().get(url).status_code,404)
root=Path(self.app.config['UPLOAD_DIR'])
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
with self.a.get('/api/staff'+url) as staff:
self.assertEqual(staff.data,b'A_PRIVATE')
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'):
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
lookup.assert_not_called();model.assert_not_called()
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
cid=self.conversation(self.a,'A_PRIVATE')
for key in ('short','x'*64,'1'*65,[],{},123):
self.assertEqual(self.a.post('/api/visitor/session',json={'key':key}).status_code,400)
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'2'*64},headers={'Origin':'https://attacker.example'}).status_code,403)
self.assertEqual(self.a.post('/api/visitor/session',data={'key':'2'*64}).status_code,400)
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'1'*64,'padding':'x'*2000}).status_code,413)
self.assertEqual(self.a.get('/api/get_history').json[0]['id'],cid)
def test_owned_support_polling_is_not_counted_as_chat_generation_and_bounds_its_cursor(self):
cid=self.conversation(self.a,'A_PRIVATE')
sid=self.human(self.a,cid)
self.app.config['RATE_LIMIT_PER_MIN']=1
self.app.extensions['rate_hits'].clear()
self.assertEqual(self.a.get('/api/get_history').status_code,200)
self.assertEqual(self.a.get('/api/get_history').status_code,429)
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}').status_code,200)
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}&after=9999999999999999999999999').status_code,400)