Files
mini-openclaw-v2/backend/test_workbench_configuration.py

115 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AC84–90: permissions, hidden dependencies, immutable quality and mock provenance."""
import json,os,tempfile,unittest
from pathlib import Path
from unittest.mock import patch,MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api import workbench_configuration as api,lease_library as library_api
from services import assistant_store as store,component_registry as registry,catalog_configuration as catalog,trajectory_quality as quality,review_plan,lease_case_library as library
from services.project_assistant import ProjectAssistantService
from services.component_evaluation import evaluate
from services.prescreen_explanation import explain
BASE='/api/xingbang/risk-agent/project-assistant/library'
ADMIN={'X-Xingbang-Demo-Role':'admin'};MANAGER={'X-Xingbang-Demo-Role':'manager'}
class ConfigurationTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(ignore_cleanup_errors=True);self.root=Path(self.temp.name);self.svc=ProjectAssistantService(self.root/'db.sqlite3');library.initialize(self.svc.db_path)
with self.svc.connect() as db:catalog.seed_fact_source(db)
self.patches=[patch.object(api,'service',lambda:self.svc),patch.object(library_api,'service',lambda:self.svc),patch.object(quality,'PATH',self.root/'quality.private.json'),patch.dict(os.environ,{'XINGBANG_AUTH_USERS':'','XINGBANG_REQUIRE_AUTH':'0','XINGBANG_DEMO_ROLE_LOGIN':'1','TYPESAFE_API_KEY':''})]
for p in self.patches:p.start()
app=FastAPI();app.include_router(library_api.router);app.include_router(api.router);self.client=TestClient(app)
def tearDown(self):
self.client.close()
for p in reversed(self.patches):p.stop()
self.temp.cleanup()
def test_hidden_new_assembly_old_version_preserved(self):
with self.svc.connect() as db:
old=registry.resolve(db,'corporate.cashflow');original=store.digest(old);catalog.set_visibility(db,old,'admin',True)
with self.assertRaisesRegex(ValueError,'COMPONENT_HIDDEN'):review_plan.assemble(db,'corporate.prescreen')
self.assertEqual(store.digest(registry.resolve(db,'corporate.cashflow')),original)
with self.assertRaisesRegex(ValueError,'COMPONENT_HIDDEN'):review_plan.propose_plan(db,dict({k:v for k,v in registry.resolve(db,'corporate.prescreen').items() if k!='allowed_tools'},id='new.plan'),'admin','DEMO-A')
catalog.set_visibility(db,old,'admin',False);self.assertEqual(review_plan.assemble(db,'corporate.prescreen')['plan']['id'],'corporate.prescreen')
def test_inventory_scope_and_role(self):
with self.svc.connect() as db:
for project_id,key in [('DEMO-A','private.skill'),('DEMO-B','other.skill')]:registry.draft(db,{'id':key,'name':'现金流草稿','kind':'skill','engine':'required','inputs':['facility.amount'],'version':'1.0.0','project_id':project_id},'admin')
catalog.set_visibility(db,registry.resolve(db,'corporate.cashflow'),'admin',True)
def query(headers):return self.client.post(BASE+'/inventory',headers=headers,json={'project_id':'DEMO-A','message':'我有几个skills?'}).json()['items']
manager=query(MANAGER);admin=query(ADMIN)
self.assertNotIn('private.skill@1.0.0',[r['id'] for r in manager]);self.assertNotIn('corporate.cashflow',[r['id'] for r in manager])
self.assertIn('private.skill@1.0.0',[r['id'] for r in admin]);self.assertNotIn('other.skill@1.0.0',[r['id'] for r in admin])
self.assertEqual(self.client.post(BASE+'/inventory',headers=MANAGER,json={'project_id':'NOT-ALLOWED','message':'skills'}).status_code,403)
def test_template_create_evaluate_independent_publish(self):
body={'project_id':'DEMO-A','kind':'template','name':'精简预审','content':{'sections':[{'id':'overview','title':'项目概况','dependencies':['*']}]}}
self.assertEqual(self.client.post(BASE+'/configuration/cards',headers=MANAGER,json=body).status_code,403)
result=self.client.post(BASE+'/configuration/cards',headers=ADMIN,json=body);self.assertEqual(result.status_code,201,result.text);draft=result.json()
with self.svc.connect() as db:
self.assertTrue(evaluate(db,draft['id'],'test')['passed'])
with self.assertRaises(PermissionError):registry.release(db,draft['id'],'demo-admin')
self.assertEqual(registry.release(db,draft['id'],'other-admin')['sections'][0]['title'],'项目概况')
path=BASE+'/cards/'+draft['component_id']+'/visibility'
self.assertEqual(self.client.patch(path,headers=MANAGER,json={'hidden':True}).status_code,403)
self.assertEqual(self.client.patch(path,headers=ADMIN,json={'hidden':True}).status_code,200)
def test_data_card_reject_write(self):
body={'project_id':'DEMO-A','kind':'data_card','name':'财务事实','content':{'source_id':'source.project-facts','sql':'SELECT * FROM finance WHERE project_id=:project_id','tables':['finance'],'mapping':{'facility.amount':'amount'}}}
result=self.client.post(BASE+'/configuration/cards',headers=ADMIN,json=body);self.assertEqual(result.status_code,201,result.text)
with self.svc.connect() as db:self.assertTrue(evaluate(db,result.json()['id'],'test')['passed'])
body['content']['sql']='DELETE FROM finance';self.assertEqual(self.client.post(BASE+'/configuration/cards',headers=ADMIN,json=body).status_code,422)
def test_natural_case_migration_idempotent(self):
with self.svc.connect() as db:before=library.read_query(db,'SELECT * FROM lease_cases ORDER BY id')
library.initialize(self.svc.db_path)
with self.svc.connect() as db:after=library.read_query(db,'SELECT * FROM lease_cases ORDER BY id')
self.assertEqual(before,after);self.assertEqual(len({r['lessee'] for r in after}),240);self.assertTrue(all('模拟' not in r['lessee'] and r['data_domain']=='demo' for r in after))
self.assertTrue(all('补充' in r['supplements'] and r['total_assets']>r['total_liabilities'] for r in after))
def test_private_settings_and_address(self):
payload={'provider':'local','endpoint':'http://127.0.0.1:11434/v1','model':'qwen3:8b','api_key':'secret-test'}
self.assertEqual(self.client.put(BASE+'/quality/settings',headers=MANAGER,json=payload).status_code,403)
result=self.client.put(BASE+'/quality/settings',headers=ADMIN,json=payload);self.assertEqual(result.status_code,200);self.assertNotIn('secret-test',result.text);self.assertTrue(result.json()['key_configured'])
payload['endpoint']='https://evil.example/v1';result=self.client.put(BASE+'/quality/settings',headers=ADMIN,json=payload);self.assertEqual(result.status_code,422);self.assertEqual(result.json()['error']['code'],'CONFIGURATION_INVALID')
self.assertNotIn('api_key',self.client.get(BASE+'/quality/settings',headers=ADMIN).json())
def test_quality_disabled_immutable_and_cross_project(self):
run={'id':'run-test','project_id':'DEMO-A','status':'partial','steps':[{'step_id':'s1','status':'partial','skill':{'id':'corporate.cashflow'},'findings':[{'code':'c1','result':'pass','evidence_ids':[]}]}],'component_versions':[{'id':'corporate.cashflow','version':'1.0.0'}],'probability':None,'components':{'plan':{'scope':'demo'}}}
with self.svc.connect() as db:store.put(db,'run',run,immutable=True);baseline=store.digest(store.get(db,'run',run['id']))
result=self.client.post(BASE+'/projects/DEMO-A/quality',headers=MANAGER,json={'run_id':run['id']});self.assertEqual(result.status_code,201,result.text);body=result.json()
self.assertEqual(body['judgment']['status'],'skipped');self.assertEqual({c['code'] for c in body['checks']},{'CHECK_INCOMPLETE','PASS_WITHOUT_REFERENCE'})
with self.svc.connect() as db:self.assertEqual(store.digest(store.get(db,'run',run['id'])),baseline)
self.assertEqual(self.client.post(BASE+'/projects/DEMO-B/quality',headers=MANAGER,json={'run_id':run['id']}).status_code,404)
def test_jev_no_key_no_fallback(self):
config={'provider':'jev','revision':2,'model':'jev-latest','allow_external':True};run={'id':'r','project_id':'DEMO-A','status':'completed','components':{'plan':{'scope':'demo'}}}
with patch('services.trajectory_quality.httpx.Client') as transport:result=quality.inspect(run,config);transport.assert_not_called()
self.assertEqual(result['judgment']['status'],'failed');self.assertEqual(result['judgment']['answers'],{});self.assertIsNone(result['approval_probability'])
def test_local_and_jev_contract_validation(self):
state={'steps':[],'components':[],'status':'completed'};config={'provider':'local','model':'qwen3:8b','endpoint':'http://127.0.0.1:11434/v1'};response=MagicMock();response.json.return_value={'choices':[{'message':{'content':json.dumps({'missing':False,'unsupported':False,'deviation':True})}}]}
with patch('services.trajectory_quality.httpx.Client') as factory:
client=factory.return_value.__enter__.return_value;client.post.return_value=response
self.assertTrue(quality.invoke(config,state)['answers']['deviation']['value']);self.assertNotIn('tools',client.post.call_args.kwargs['json'])
response.json.return_value={'model':'jev-1.13.0','answers':{k:{'type':'noul','noul':0.1} for k in quality.QUESTIONS}};config={'provider':'jev','model':'jev-latest','api_key':'secret','allow_external':True}
self.assertEqual(quality.invoke(config,state)['model'],'jev-1.13.0');self.assertEqual(client.post.call_args.args[0],'https://api.typesafe.ai/v1/systemone')
response.json.return_value['answers']['missing']['noul']=float('nan')
with self.assertRaises(ValueError):quality.invoke(config,state)
def test_explanation_frozen_without_fake_contributions(self):
card={'id':'skill.one','name':'现金流','version':'2.0.0','code':'cash.missing'};run={'components':{'skills':[card]},'steps':[{'skill':card,'findings':[{'code':'cash.missing','result':'unknown','title':'缺少流水','evidence_ids':[]}]}],'input_snapshot_id':'snapshot-original'}
result=explain(run,{'dimension_results':[{'id':'skill.one','stage':0,'state':'unknown'}]})
self.assertIsNone(result['probability_contributions']);self.assertEqual(result['items'][0]['version'],'2.0.0');self.assertEqual(result['items'][0]['checks'][0]['result'],'unknown')
def test_malformed_card_content_is_422(self):
for content in ({'sections':['wrong']},{'sections':[]},{'sections':[{'id':'overview','title':None}]}):
result=self.client.post(BASE+'/configuration/cards',headers=ADMIN,json={'project_id':'DEMO-A','kind':'template','name':'bad','content':content})
self.assertEqual(result.status_code,422,result.text)
def test_frozen_report_template_survives_later_release(self):
from copy import deepcopy
with self.svc.connect() as db:
template=registry.resolve(db,'template.ai-pre-review');project=store.get(db,'project','DEMO-A')
snapshot=dict(deepcopy(project),id='snapshot-template');store.put(db,'snapshot',snapshot,immutable=True)
run={'id':'run-template','project_id':'DEMO-A','status':'completed','snapshot_id':snapshot['id'],'components':{'template':deepcopy(template)},'steps':[],'findings':[],'summary':{},'evidence':[]}
store.put(db,'run',run,immutable=True)
new=deepcopy({k:v for k,v in template.items() if k not in ('revision','content_hash')});new['version']='1.0.1';new['sections'][0]['title']='新版章节'
draft=registry.draft(db,new,'editor');self.assertTrue(evaluate(db,draft['id'],'independent')['passed']);registry.release(db,draft['id'],'publisher')
report=self.svc.create_report('DEMO-A',run['id'],'manager','template.ai-pre-review')
self.assertEqual(report['template']['version'],template['version']);self.assertEqual(report['sections'][0]['title'],template['sections'][0]['title'])
def test_builtin_data_card_reads_only_current_confirmed_facts(self):
from services.assistant_data import read_cards
with self.svc.connect() as db:
project=store.get(db,'project','DEMO-A');source=registry.resolve(db,'source.project-facts');card=registry.resolve(db,'data.initial-finance')
values,sources,datasets,errors=read_cards(project,{'legacy_cards':[dict(card,connection=source['id'])],'legacy_connections':[dict(source,data_domain='demo')]})
self.assertFalse(errors,errors);self.assertEqual(values['facility.amount'],project['values']['facility.amount']);self.assertEqual(len(datasets),1);self.assertEqual(values['financial.total_assets'],project['values']['financial.total_assets'])
if __name__=='__main__':unittest.main()