支持免登录游客聊天并隔离历史与附件
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user