import json import os import sqlite3 import tempfile import unittest from pathlib import Path from unittest.mock import patch from fastapi import FastAPI from fastapi.testclient import TestClient from api import risk_agent_config as engine from api.risk_agent_alignment import align from api.risk_agent_connectors import execute, validate_query class AlignmentTests(unittest.TestCase): def setUp(self): self.card={"mapping":{"financial.total_assets":"assets"},"unit":"元","alignment":{"project_field":"pid","period_field":"year","scope_field":"scope","selection":"latest","target_scope":"合并"}} self.rows=[{"pid":"P1","year":"2024","scope":"合并","assets":10000},{"pid":"P1","year":"2025","scope":"合并","assets":20000}] def test_latest_keeps_history_and_converts(self): records,selected=align(self.card,self.rows,"P1") self.assertEqual(len(records),2) self.assertEqual(selected["values"]["financial.total_assets"],2) self.assertEqual(selected["period"],"2025") def test_wrong_project_rejected(self): with self.assertRaises(ValueError): align(self.card,self.rows,"P2") def test_duplicates_rejected(self): with self.assertRaises(ValueError): align(self.card,self.rows+self.rows,"P1") def test_period_selection_and_all(self): _,selected=align(self.card,self.rows,"P1","2024") self.assertEqual(selected["values"]["financial.total_assets"],1) self.card["alignment"]["selection"]="all" records,selected=align(self.card,self.rows,"P1") self.assertIsNone(selected) self.assertEqual(len(records),2) def test_mixed_scopes_rejected(self): self.card["alignment"].pop("target_scope") self.rows[1]["scope"]="母公司" with self.assertRaises(ValueError): align(self.card,self.rows,"P1") class DatabaseTests(unittest.TestCase): def test_real_sqlite_read_only_and_allowlist(self): with tempfile.TemporaryDirectory() as root: file=Path(root)/"erp.sqlite" db=sqlite3.connect(file) db.execute("CREATE TABLE finance(project_id TEXT, assets REAL)") db.execute("INSERT INTO finance VALUES ('P1',100)");db.commit();db.close() conn={"type":"SQLite","database":str(file),"allowed_tables":["finance"]} with patch.dict(os.environ,{"XINGBANG_SQLITE_ROOT":root}): self.assertEqual(execute(conn,"SELECT * FROM finance WHERE project_id=:project_id","P1")[0]["assets"],100) self.assertEqual(execute(conn,"SELECT * FROM finance WHERE project_id=:project_id","' OR 1=1 --"),[]) with self.assertRaises(ValueError):execute(conn,"DELETE FROM finance","P1") with self.assertRaises(ValueError):execute(conn,"SELECT * FROM sqlite_master","P1") def test_sql_parser_rejects_multi_statement_and_writes(self): for sql in ("SELECT * FROM finance; DELETE FROM finance", "SELECT * INTO backup FROM finance", "SELECT pg_sleep(5) FROM finance"): with self.assertRaises(ValueError):validate_query(sql,["finance"],"postgres") ACCOUNTS=[{"id":"editor","token":"editor-token","role":"admin"},{"id":"approver","token":"review-token","role":"reviewer"},{"id":"manager","token":"manager-token","role":"manager","projects":["XB-2026-001"]}] class WorkflowTests(unittest.TestCase): def setUp(self): self.temp=tempfile.TemporaryDirectory() self.file_patch=patch.object(engine,"DB_PATH",Path(self.temp.name)/"catalog.sqlite");self.file_patch.start() self.auth_patch=patch.dict(os.environ,{"XINGBANG_AUTH_USERS":json.dumps(ACCOUNTS)});self.auth_patch.start() app=FastAPI();app.include_router(engine.router) self.client=TestClient(app);self.base="/api/xingbang/risk-agent" def tearDown(self): self.client.close();self.auth_patch.stop();self.file_patch.stop();self.temp.cleanup() def call(self,path,method="GET",data=None,token="editor-token"): return self.client.request(method,self.base+path,json=data,headers={"Authorization":"Bearer "+token}) def test_auth_and_project_scope(self): self.assertEqual(self.call("/catalog",token="bad").status_code,401) self.assertEqual(self.call("/cards","POST",{"name":"unauthorized"},"manager-token").status_code,403) catalog=self.call("/catalog",token="manager-token").json() self.assertEqual(catalog["cards"],[]) self.assertEqual(self.call("/plans/plan-default/run","POST",{"project_id":"P2"},"manager-token").status_code,403) result=self.call("/plans/plan-default/run","POST",{"project_id":"XB-2026-001"},"manager-token").json() self.assertNotIn("configuration",result) def test_demo_role_dropdown_is_gated_and_preserves_role_permissions(self): header=lambda role:{"X-Xingbang-Demo-Role":role} self.assertEqual(self.client.get(self.base+"/access/context",headers=header("manager")).status_code,401) with patch.dict(os.environ,{"XINGBANG_DEMO_ROLE_LOGIN":"1"}): for role in ("admin","reviewer","manager"): response=self.client.get(self.base+"/access/context",headers=header(role)) self.assertEqual(response.status_code,200,response.text) identity=response.json() self.assertEqual(identity["role"],role) self.assertTrue(identity["demo"]) self.assertNotIn("*",identity["projects"]) self.assertEqual(self.client.get(self.base+"/access/context",headers=header("owner")).status_code,401) self.assertEqual(self.client.post(self.base+"/cards",headers=header("manager"),json={"name":"x"}).status_code,403) self.assertEqual(self.client.post(self.base+"/cards",headers=header("reviewer"),json={"name":"x"}).status_code,403) def test_review_publishes_exact_version(self): item=self.call("/skills","POST",{"name":"新增检查","data":{"engine":"quality","inputs":["*"]}}).json() item["status"]="published" self.assertEqual(self.call("/skills/"+item["id"],"PUT",{"name":item["name"],"data":item}).status_code,403) pending=self.call("/skills/"+item["id"]+"/submit","POST").json() path="/reviews/"+pending["id"]+"/review" self.assertEqual(self.call(path,"POST",{"approve":True,"note":"同意"}).status_code,403) self.assertEqual(self.call(path,"POST",{"approve":True,"note":"确认输入契约"},"review-token").status_code,200) saved=next(x for x in self.call("/catalog").json()["skills"] if x["id"]==item["id"]) self.assertEqual(saved["status"],"published") self.assertEqual(saved["approved_by"],"approver") def test_changed_submission_cannot_be_approved(self): item=self.call("/skills","POST",{"name":"检查","data":{"engine":"quality","inputs":["*"]}}).json() pending=self.call("/skills/"+item["id"]+"/submit","POST").json() self.call("/skills/"+item["id"],"PUT",{"name":"修改后","data":item}) response=self.call("/reviews/"+pending["id"]+"/review","POST",{"approve":True,"note":"同意"},"review-token") self.assertEqual(response.status_code,409) def test_natural_rules_reach_model_without_explicit_model_skill(self): item=self.call("/catalog").json()["rules"][0] item.update(status="draft",natural_language="重点关注现金流,不能下审批结论") item=self.call("/rules/"+item["id"],"PUT",{"name":item["name"],"data":item}).json() pending=self.call("/rules/"+item["id"]+"/submit","POST").json() self.call("/reviews/"+pending["id"]+"/review","POST",{"approve":True,"note":"内容已核对"},"review-token") with patch.object(engine,"local_model",return_value="请补充现金流材料") as model: response=self.call("/plans/plan-default/run","POST",{"project_id":"XB-2026-001"},"manager-token") self.assertEqual(response.status_code,200,response.text) context=model.call_args.args[1] self.assertEqual(context["human_rules"][0]["instructions"],item["natural_language"]) self.assertTrue(context["peer_datasets"]) self.assertIn("advisories",response.json()) if __name__=="__main__":unittest.main()