"""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()