import json import os import sqlite3 import tempfile import unittest from copy import deepcopy 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 from services.business_demo_database import build_business_demo_database, database_summary 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_multitable_business_database_and_configured_cards(self): with tempfile.TemporaryDirectory() as root: database = build_business_demo_database(Path(root) / "business.sqlite3") summary = database_summary(database) self.assertEqual(len(summary["tables"]), 11) self.assertEqual(summary["tables"]["lease_project"], 3) connection = deepcopy(next(item for item in engine.SEED["connections"] if item["id"] == "conn-business-demo")) connection["database"] = str(database) cards = {item["id"]: item for item in engine.SEED["cards"] if item["id"].startswith("card-business-")} with patch.dict(os.environ, {"XINGBANG_SQLITE_ROOT": root}): project = engine.query_card(cards["card-business-project"], connection, "DEMO-B") finance = engine.query_card(cards["card-business-finance"], connection, "DEMO-B") credit = engine.query_card(cards["card-business-credit"], connection, "DEMO-B") lease = engine.query_card(cards["card-business-lease"], connection, "DEMO-B") repayment = engine.query_card(cards["card-business-repayment"], connection, "DEMO-B") self.assertEqual(project[0]["facility_amount"], 5000) self.assertEqual(len(finance), 2) self.assertIsNone(finance[-1]["operating_cashflow"]) self.assertEqual(credit[0]["legal_case_count"], 1) self.assertEqual(lease[0]["lease_total_value"], 5400) self.assertEqual(lease[0]["duplicate_asset_count"], 1) self.assertEqual(repayment[0]["evidence_status"], "缺少最新流水") 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()