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