"""HTTP integration checks against an isolated relational test database. SQLite runs the real persistence/ON CONFLICT paths. Its advisory-lock stub does not simulate PostgreSQL worker locking; deployment still needs PostgreSQL migration. """ import asyncio import base64 import hashlib import hmac import json import time import uuid from datetime import UTC, date, datetime, timedelta from types import SimpleNamespace import httpx import pytest from app.api.v1 import integration_risk from app.database.base import Base from app.database.session import get_db_session from app.main import create_app from app.models.entities import ( AnomalyCase, IntegrationAssessment, IntegrationNonce, RolePermission, UserAccount, ) from app.repositories.integration_risk import IntegrationRiskRepository from app.services import integration_auth from app.services.llm_client import LLMClient from pydantic import SecretStr from sqlalchemy import JSON, BigInteger, Integer, MetaData, event, select, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine PATH = "/api/v1/integrations/risk-assessments/report" SECRET = "test-only-integration-secret-32-characters" def payload(): return { "requestId": "PLM-D-FT-003-0001", "sourceSystem": "dieya-plm", "operator": {"dingtalkUnionId": "union-1", "dingtalkUserId": "ding-1", "name": "伪造姓名"}, "assessment": { "subjectId": "D-FT-003", "productName": "洗发水", "productCategory": "洗发水/洗护类", "materialType": "PET 瓶身 + PP 泵头", "stageNew": "产品开发", "asOfDate": "2026-01-15", "supplier": None, "factory": None, }, } def headers(body=b"", *, method="POST", query="", client="dieya-plm", nonce=None, timestamp=None): timestamp = timestamp or str(int(time.time())) nonce = nonce or uuid.uuid4().hex message = timestamp.encode() + b"\n" + nonce.encode() + b"\n" if method == "GET": message = b"GET\n" + PATH.encode() + b"\n" + query.encode() + b"\n" + message else: message += body return { "Content-Type": "application/json", "X-Client-Id": client, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.b64encode(hmac.new(SECRET.encode(), message, hashlib.sha256).digest()).decode(), "Idempotency-Key": "PLM-D-FT-003-0001", } async def post(http_client, data=None, **options): body = json.dumps(data if data is not None else payload(), ensure_ascii=False).encode() return await http_client.post(PATH, content=body, headers=headers(body, **options)) @pytest.fixture async def integration_db(tmp_path, monkeypatch): engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'integration.db'}") @event.listens_for(engine.sync_engine, "connect") def sqlite_advisory_stub(connection, _): connection.create_function("pg_advisory_xact_lock", 1, lambda key: None) schema = MetaData() for table in Base.metadata.sorted_tables: copied = table.to_metadata(schema) for column in copied.columns: if isinstance(column.type, JSONB): column.type = JSON() elif isinstance(column.type, BigInteger): column.type = Integer() async with engine.begin() as connection: await connection.run_sync(schema.create_all) sessions = async_sessionmaker(engine, expire_on_commit=False) async with sessions() as session: session.add( UserAccount( id=1, emp_no="real-employee", name="真实评估人", role="user", status="启用", dingtalk_union_id="union-1", dingtalk_id="ding-1", ) ) session.add(RolePermission(role="user", func_key="risk.assess", can_operate=True, can_view=True)) session.add_all( [ AnomalyCase( id=1, date=date(2026, 1, 10), product_name="洗发水", product_category="洗发露", material_type="泵头", stage_new="产品开发", defect_level="C", description="泵头漏液", solution="密封验证", import_fingerprint="case1", ), AnomalyCase( id=2, date=date(2026, 1, 20), product_name="洗发水", product_category="洗发露", material_type="泵头", stage_new="产品开发", defect_level="A", description="未来记录", import_fingerprint="case2", ), ] ) await session.commit() settings = SimpleNamespace( integration_clients=SecretStr(json.dumps({"dieya-plm": SECRET, "other-system": SECRET})), integration_operator_mappings=SecretStr("{}"), integration_requests_per_minute=60, integration_report_timeout_seconds=110, ) monkeypatch.setattr(integration_auth, "get_settings", lambda: settings) monkeypatch.setattr(integration_risk, "get_settings", lambda: settings) async def suggestions(self, context): return ["完成密封性验证。"] monkeypatch.setattr(LLMClient, "generate_prevention_suggestions", suggestions) app = create_app() async def database(): async with sessions() as session: try: yield session await session.commit() except Exception: await session.rollback() raise app.dependency_overrides[get_db_session] = database async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: yield client, sessions, settings await engine.dispose() async def test_success_uses_cutoff_actual_user_and_seven_dimensions(integration_db): client, sessions, _ = integration_db response = await post(client) assert response.status_code == 200, response.text assert response.headers["content-type"] == "text/markdown; charset=utf-8" assert response.headers["x-risk-level"] in {"low", "moderately_low", "medium", "high", "very_high"} assert "真实评估人" in response.text and "伪造姓名" not in response.text assert "泵头" in response.text and "未来记录" not in response.text assert response.headers["x-model-version"] == "database-seven-dimension-v1" async with sessions() as session: record = await session.scalar(select(IntegrationAssessment)) assert record.status == "completed" assert len(record.result_data["dimensionScores"]) == 7 assert record.result_data["categoryWarning"]["anomalyCount"] == 1 assert record.result_data["similarCases"][0]["caseId"] == 1 assert record.result_data["score"] == float(response.headers["x-risk-score"]) async def test_replay_and_bad_signature_do_not_generate_reports(integration_db): client, sessions, _ = integration_db response = await post(client, nonce="same-nonce-123") assert response.status_code == 200 assert (await post(client, nonce="same-nonce-123")).status_code == 401 body = json.dumps(payload()).encode() signed = headers(body) assert (await client.post(PATH, content=body + b" ", headers=signed)).status_code == 401 assert (await post(client, timestamp=str(int(time.time()) - 301))).status_code == 401 assert (await client.post(PATH, json=payload())).status_code == 401 async with sessions() as session: assert len(list((await session.scalars(select(IntegrationAssessment))).all())) == 1 async def test_idempotency_query_isolation_and_query_signature(integration_db): client, _, _ = integration_db original = await post(client) body = json.dumps(payload(), sort_keys=True, separators=(",", ":")).encode() repeated = await client.post(PATH, content=body, headers=headers(body)) assert repeated.status_code == 200 assert repeated.text == original.text assert repeated.headers["x-assessment-id"] == original.headers["x-assessment-id"] changed = payload() changed["assessment"]["materialType"] = "软管" assert (await post(client, changed)).status_code == 409 query = "requestId=PLM-D-FT-003-0001" retrieved = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query)) assert retrieved.status_code == 200 and retrieved.text == original.text missing = await client.get(PATH + "?" + query, headers=headers(method="GET", query=query, client="other-system")) assert missing.status_code == 404 tampered = await client.get(PATH + "?requestId=OTHER", headers=headers(method="GET", query=query)) assert tampered.status_code == 401 @pytest.mark.parametrize("change", ["missing_category", "blank_material", "bad_stage", "bad_date", "extra_score"]) async def test_invalid_fields_return_json_422(integration_db, change): client, _, _ = integration_db data = payload() if change == "missing_category": del data["assessment"]["productCategory"] elif change == "blank_material": data["assessment"]["materialType"] = " " elif change == "bad_stage": data["assessment"]["stageNew"] = "未知阶段" elif change == "bad_date": data["assessment"]["asOfDate"] = "2099-01-01" else: data["assessment"]["score"] = 99 response = await post(client, data) assert response.status_code == 422 assert response.json()["code"] == 422 and response.json()["data"] is None @pytest.mark.parametrize("change", ["missing_user", "duplicate_union", "disabled_user", "no_permission", "mismatch"]) async def test_user_mapping_and_permissions(integration_db, change): client, sessions, _ = integration_db data = payload() async with sessions() as session: if change == "missing_user": data["operator"]["dingtalkUnionId"] = "not-opened" elif change == "duplicate_union": session.add( UserAccount( emp_no="second", name="重复映射", role="admin", status="启用", dingtalk_union_id="union-1", dingtalk_id="ding-2", ) ) elif change == "disabled_user": await session.execute(update(UserAccount).values(status="停用")) elif change == "no_permission": await session.execute(update(RolePermission).values(can_operate=False)) else: data["operator"]["dingtalkUserId"] = "wrong-user" await session.commit() assert (await post(client, data)).status_code == 403 async with sessions() as session: assert await session.scalar(select(IntegrationAssessment)) is None async def test_rate_limit_has_retry_after(integration_db): client, _, settings = integration_db settings.integration_requests_per_minute = 1 assert (await post(client)).status_code == 200 response = await post(client) assert response.status_code == 429 and response.headers["retry-after"] == "60" async def test_failed_and_expired_processing_requests_can_retry(integration_db, monkeypatch): client, sessions, _ = integration_db original = integration_risk.ExternalRiskReportService.assess async def fail(self, payload, as_of): raise RuntimeError("provider secret must not be returned") monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", fail) failed = await post(client) assert failed.status_code == 503 and "provider secret" not in failed.text monkeypatch.setattr(integration_risk.ExternalRiskReportService, "assess", original) assert (await post(client)).status_code == 200 async with sessions() as session: await session.execute( update(IntegrationAssessment).values( status="processing", lease_until=datetime.now(UTC) + timedelta(seconds=100), ) ) await session.commit() pending = await post(client) assert pending.status_code == 202 and pending.headers["retry-after"] == "5" async with sessions() as session: await session.execute( update(IntegrationAssessment).values( lease_until=datetime.now(UTC) - timedelta(seconds=1), ) ) await session.commit() assert (await post(client)).status_code == 200 async def test_unrecognized_category_and_ai_failure_are_explained(integration_db, monkeypatch): client, _, _ = integration_db async def unavailable(self, context): raise RuntimeError("LLM down") monkeypatch.setattr(LLMClient, "generate_prevention_suggestions", unavailable) data = payload() data["assessment"]["productCategory"] = "防晒" data["assessment"]["productName"] = "|[链接](javascript:test)" response = await post(client, data) assert response.status_code == 200 assert "无法归类" in response.text and "固定验证建议" in response.text assert "